简洁版本: 在许多编程语言中,返回大对象(例如向量/数组)是常见的。如果类具有移动构造函数,或者C ++程序员认为这很奇怪/丑陋/可憎之处,现在在C ++ 0x中可以接受这种样式?

长版: 在C ++ 0x中,这仍然被视为不良形式吗?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

传统版本看起来像这样:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

在较新版本中,值从 BuildLargeVector 是一个rvalue,因此V将使用Move构造函数构造 std::vector, ,假设没有发生(n)RVO。

即使在C ++ 0x之前,由于(n)RVO,第一个形式通常也会“有效”。但是,(n)RVO由编译器自行决定。现在我们有了RVALUE参考 保证 不会发生深层副本。

编辑: :问题实际上不是关于优化。两种表格显示的现实计划中都具有几乎相同的性能。鉴于过去,第一种形式的表现可能会更差。结果,第一种形式是长期以来C ++编程中的主要代码气味。我希望不再了吗?

有帮助吗?

解决方案

戴夫·亚伯拉罕(Dave Abrahams)对 通过/返回值的速度.

简短答案,如果您需要返回值,则返回值。不要使用输出引用,因为编译器无论如何都可以使用。当然有警告,因此您应该阅读该文章。

其他提示

至少IMO,这通常是一个糟糕的主意,但是 不是 出于效率原因。这是一个糟糕的想法,因为所讨论的功能通常应写入通过迭代器产生其输出的通用算法。几乎所有接受或返回容器而不是在迭代器上操作的代码都应被视为可疑。

不要误会我的意思:有时会有时间传递类似收藏的对象(例如,字符串),但是对于引用的例子,我会考虑将矢量传递或返回矢量是一个糟糕的想法。

要点是:

复制Elision和RVO 能够 避免“可怕的副本”(不需要编译器实施这些优化,在某些情况下,它不能应用)

C ++ 0x RVALUE参考 允许 字符串/向量实现 保证 那。

如果您可以放弃旧的编译器 / STL实现,请自由返回向量(并确保自己的对象也支持它)。如果您的代码库需要支持“较小”编译器,请坚持旧样式。

不幸的是,这对您的界面产生了重大影响。如果不是C ++ 0x,并且需要保证,则可以在某些情况下使用引用计数或复制对象。但是,他们有多线程的缺点。

(我希望C ++中的一个答案简单明了,没有条件)。

确实,自C ++ 11以来,成本 复制std::vector 在大多数情况下都消失了。

但是,应该记住 构造 新向量(然后 破坏性 它仍然存在,当您希望重复使用向量的容量时,使用输出参数而不是按值返回仍然很有用。这被记录为一个例外 F.20 C ++核心准则。

让我们比较:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

和:

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

现在,假设我们需要调用这些方法 numIter 时间紧密,并执行一些动作。例如,让我们计算所有元素的总和。

使用 BuildLargeVector1, ,您会这样做:

size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
    std::vector<int> v = BuildLargeVector1(vecSize);
    sum1 = std::accumulate(v.begin(), v.end(), sum1);
}

使用 BuildLargeVector2, ,您会这样做:

size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
    BuildLargeVector2(/*out*/ v, vecSize);
    sum2 = std::accumulate(v.begin(), v.end(), sum2);
}

在第一个示例中,发生了许多不必要的动态分配/交易,在第二个示例中通过使用输出参数以旧方式来阻止,重复使用已分配的内存。与计算/突变值的成本相比,该优化是否值得进行取决于分配/交易的相对成本。

基准

让我们玩耍 vecSizenumIter. 。我们将保持vecsize*numiter常数,以便“理论上”,它应该需要相同的时间(=具有相同数量的作业和添加数量,具有完全相同的值),并且时间差只能来自分配,交易和更好地使用缓存。

更具体地说,让我们使用vecsize*numiter = 2^31 = 2147483648,因为我有16GB的RAM,并且此数字可确保分配不超过8GB(sizeof(int)= 4),以确保我不交换到磁盘(所有其他程序均已关闭,运行测试时我有〜15GB)。

这是代码:

#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>

class Timer {
    using clock = std::chrono::steady_clock;
    using seconds = std::chrono::duration<double>;
    clock::time_point t_;

public:
    void tic() { t_ = clock::now(); }
    double toc() const { return seconds(clock::now() - t_).count(); }
};

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

int main() {
    Timer t;

    size_t vecSize = size_t(1) << 31;
    size_t numIter = 1;

    std::cout << std::setw(10) << "vecSize" << ", "
              << std::setw(10) << "numIter" << ", "
              << std::setw(10) << "time1" << ", "
              << std::setw(10) << "time2" << ", "
              << std::setw(10) << "sum1" << ", "
              << std::setw(10) << "sum2" << "\n";

    while (vecSize > 0) {

        t.tic();
        size_t sum1 = 0;
        {
            for (int i = 0; i < numIter; ++i) {
                std::vector<int> v = BuildLargeVector1(vecSize);
                sum1 = std::accumulate(v.begin(), v.end(), sum1);
            }
        }
        double time1 = t.toc();

        t.tic();
        size_t sum2 = 0;
        {
            std::vector<int> v;
            for (int i = 0; i < numIter; ++i) {
                BuildLargeVector2(/*out*/ v, vecSize);
                sum2 = std::accumulate(v.begin(), v.end(), sum2);
            }
        } // deallocate v
        double time2 = t.toc();

        std::cout << std::setw(10) << vecSize << ", "
                  << std::setw(10) << numIter << ", "
                  << std::setw(10) << std::fixed << time1 << ", "
                  << std::setw(10) << std::fixed << time2 << ", "
                  << std::setw(10) << sum1 << ", "
                  << std::setw(10) << sum2 << "\n";

        vecSize /= 2;
        numIter *= 2;
    }

    return 0;
}

这就是结果:

$ g++ -std=c++11 -O3 main.cpp && ./a.out
   vecSize,    numIter,      time1,      time2,       sum1,       sum2
2147483648,          1,   2.360384,   2.356355, 2147483648, 2147483648
1073741824,          2,   2.365807,   1.732609, 2147483648, 2147483648
 536870912,          4,   2.373231,   1.420104, 2147483648, 2147483648
 268435456,          8,   2.383480,   1.261789, 2147483648, 2147483648
 134217728,         16,   2.395904,   1.179340, 2147483648, 2147483648
  67108864,         32,   2.408513,   1.131662, 2147483648, 2147483648
  33554432,         64,   2.416114,   1.097719, 2147483648, 2147483648
  16777216,        128,   2.431061,   1.060238, 2147483648, 2147483648
   8388608,        256,   2.448200,   0.998743, 2147483648, 2147483648
   4194304,        512,   0.884540,   0.875196, 2147483648, 2147483648
   2097152,       1024,   0.712911,   0.716124, 2147483648, 2147483648
   1048576,       2048,   0.552157,   0.603028, 2147483648, 2147483648
    524288,       4096,   0.549749,   0.602881, 2147483648, 2147483648
    262144,       8192,   0.547767,   0.604248, 2147483648, 2147483648
    131072,      16384,   0.537548,   0.603802, 2147483648, 2147483648
     65536,      32768,   0.524037,   0.600768, 2147483648, 2147483648
     32768,      65536,   0.526727,   0.598521, 2147483648, 2147483648
     16384,     131072,   0.515227,   0.599254, 2147483648, 2147483648
      8192,     262144,   0.540541,   0.600642, 2147483648, 2147483648
      4096,     524288,   0.495638,   0.603396, 2147483648, 2147483648
      2048,    1048576,   0.512905,   0.609594, 2147483648, 2147483648
      1024,    2097152,   0.548257,   0.622393, 2147483648, 2147483648
       512,    4194304,   0.616906,   0.647442, 2147483648, 2147483648
       256,    8388608,   0.571628,   0.629563, 2147483648, 2147483648
       128,   16777216,   0.846666,   0.657051, 2147483648, 2147483648
        64,   33554432,   0.853286,   0.724897, 2147483648, 2147483648
        32,   67108864,   1.232520,   0.851337, 2147483648, 2147483648
        16,  134217728,   1.982755,   1.079628, 2147483648, 2147483648
         8,  268435456,   3.483588,   1.673199, 2147483648, 2147483648
         4,  536870912,   5.724022,   2.150334, 2147483648, 2147483648
         2, 1073741824,  10.285453,   3.583777, 2147483648, 2147483648
         1, 2147483648,  20.552860,   6.214054, 2147483648, 2147483648

Benchmark results

(英特尔i7-7700k @ 4.20GHz; 16GB DDR4 2400MHz; kubuntu 18.04)

符号:mem(v)= v.size() * sizeof(int)= v.size() * 4在我的平台上。

毫不奇怪,当 numIter = 1 (即mem(v)= 8GB),时间完全相同。实际上,在这两种情况下,我们只在记忆中只有一个庞大的8GB向量分配。这也证明了使用buildlargevector1()时不会发生任何副本:我没有足够的RAM进行副本!

什么时候 numIter = 2, ,重用矢量容量而不是重新分配第二个矢量的速度更快1.37倍。

什么时候 numIter = 256, ,重复使用矢量容量(而不是一次又一次地分配/处理矢量256次...)更快2.45倍:)

我们可以注意到Time1从中几乎是恒定的 numIter = 1numIter = 256, ,这意味着分配一个8GB的大量向量的成本与分配256个32MB的矢量一样昂贵。但是,分配一个8GB的一个巨大矢量比分配32MB的一个向量更昂贵,因此重复使用矢量的能力可提供性能提高。

numIter = 512 (mem(v)= 16mb)至 numIter = 8M (mem(v)= 1kb)是最佳点:这两种方法的速度均快,并且比Numiter和Vecsize的所有其他组合都更快。这可能与以下事实有关:我的处理器的L3高速缓存大小为8MB,因此矢量几乎完全适合缓存。我真的没有解释为什么突然跳跃 time1 是针对MEM(V)= 16MB,在MEM(V)= 8MB之后发生似乎更合乎逻辑的是。请注意,令人惊讶的是,在这个最佳位置,不重复使用的能力实际上稍快地!我真的没有解释这一点。

什么时候 numIter > 8M 事情开始变得丑陋。两种方法都变慢,但按值返回向量的情况甚至更慢。在最坏的情况下,只有一个矢量只有一个 int, ,重复使用能力而不是按值返回的速度更快3.3倍。据推测,这是由于Malloc()开始统治的固定成本。

请注意,时间2的曲线比Time1的曲线更光滑1:不仅重复使用向量的矢量容量通常更快,而且更重要的是,它更多的是 可预见的.

另请注意,在最佳位置,我们能够在〜0.5s中执行20亿次添加64位整数,这在4.2GHz 64位处理器上非常最佳。我们可以通过平行计算以使用所有8个内核来做得更好(上面的测试一次一次使用一个核心,我在监视CPU使用时通过重新运行测试进行了验证)。当MEM(V)= 16KB(即L1缓存的数量级)(i7-7700K的L1数据缓存为4x32kb)时,可以实现最佳性能。

当然,差异变得越来越不相关,您实际上必须对数据进行计算的越多。以下是我们替换的结果 sum = std::accumulate(v.begin(), v.end(), sum); 经过 for (int k : v) sum += std::sqrt(2.0*k);:

Benchmark 2

结论

  1. 使用输出参数,而不是按值返回 可能 通过重新利用能力提供绩效提高。
  2. 在现代台式计算机上,这似乎仅适用于大型矢量(> 16MB)和小型矢量(<1KB)。
  3. 避免分配数百万/数十亿个小型矢量(<1KB)。如果可能的话,重复使用能力,或者更好的是,以不同的方式设计您的体系结构。

结果可能在其他平台上有所不同。像往常一样,如果性能很重要,请为您的特定用例编写基准测试。

我仍然认为这是一种不好的做法,但值得注意的是,我的团队使用MSVC 2008和GCC 4.1,因此我们不使用最新的编译器。

以前,MSVC 2008中VTUNE中显示的许多热点都归结为字符串复制。我们有这样的代码:

String Something::id() const
{
    return valid() ? m_id: "";
}

...请注意,我们使用了自己的字符串类型(这是必需的(因为我们提供了一个软件开发套件,插件作者可以使用不同的编译器,因此STD :: string/std :: Wstring的不同编译器,因此不同的,不兼容的实现)。

我对呼叫图采样分析会话进行了简单的更改,显示字符串:: string(const string&)需要花费大量时间。在上面的示例中,方法是最大的贡献者(实际上,分析会话显示内存分配和Deallocation是最大的热点之一,字符串复制构造函数是分配的主要贡献者)。

我所做的更改很简单:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

然而,这使世界有所不同!热点在随后的Profiler会议上消失了,除此之外,我们进行了许多彻底的单元测试以跟踪我们的应用程序性能。这些简单的更改后,各种性能测试时间都大大减少。

结论:我们没有使用绝对最新的编译器,但是我们似乎仍然不取决于编译器优化复制的编译器,以可靠地返回值(至少在所有情况下都不是)。对于使用MSVC 2010等新型编译器的人来说,情况可能并非如此。我期待我们何时可以使用C ++ 0x并简单地使用RVALUE参考,而不必担心我们通过返回复杂复杂的复杂而对我们的代码进行悲伤按价值划分。

编辑]正如Nate指出的那样,RVO适用于函数内部创建的返回临时性。就我而言,没有这样的临时工(除了我们构造一个空字符串的无效分支外),因此RVO不会适用。

只是有点nitpick:在许多编程语言中,从函数返回数组并不常见。在其中大多数中,返回对数组的引用。在C ++中,最接近的类比将返回 boost::shared_array

如果性能是一个真正的问题,您应该意识到动作语义不是 总是 比复制快。例如,如果您的字符串使用 小字符串优化 然后,对于小字符串,移动构造函数必须与常规复制构造函数进行完全相同的工作。

许可以下: CC-BY-SA归因
不隶属于 StackOverflow
scroll top