web-dev-qa-db-ja.com

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から返される値は右辺値であるため、(N)RVOが実行されないと仮定して、vはstd::vectorの移動コンストラクターを使用して構築されます。

C++ 0xより前でも、(N)RVOのために最初の形式はしばしば「効率的」でした。ただし、(N)RVOはコンパイラーの裁量です。右辺値参照ができたので、ディープコピーが行われないことはguaranteedです。

編集:質問は最適化に関するものではありません。示されている両方の形式は、実世界のプログラムでほぼ同じパフォーマンスを持っています。一方、以前は、最初のフォームのパフォーマンスが桁違いに劣っていた可能性があります。その結果、最初の形式は、長い間C++プログラミングの主要なコード臭でした。もうありません、私は願っていますか?

102
Nate

Dave Abrahamsには、 値を渡す/返す速度 のかなり包括的な分析があります。

簡単な答え、値を返す必要がある場合は値を返します。とにかくコンパイラがそれを行うので、出力参照を使用しないでください。もちろん警告がありますので、その記事を読んでください。

73
Peter Alexander

少なくともIMOは、通常は貧弱なアイデアですが、効率上の理由からnotです。問題の関数は通常、イテレータを介して出力を生成する汎用アルゴリズムとして記述される必要があるため、これは不適切なアイデアです。反復子を操作する代わりにコンテナを受け入れるまたは返すコードは、ほとんど疑わしいと見なされます。

誤解しないでください:コレクションのようなオブジェクト(文字列など)を渡すのが理にかなっている場合がありますが、引用した例では、ベクトルを渡すか返すことは悪い考えだと思います。

37
Jerry Coffin

要点は次のとおりです。

エリシオンとRVOのコピーcan「怖いコピー」を避けます(コンパイラーはこれらの最適化を実装する必要はなく、状況によっては適用できません)

C++ 0x RValue参照allow文字列/ベクターの実装guarantees that。

古いコンパイラ/ STL実装を放棄できる場合は、ベクトルを自由に返します(そして、独自のオブジェクトもそれをサポートしていることを確認してください)。コードベースが「より少ない」コンパイラをサポートする必要がある場合は、古いスタイルに固執します。

残念ながら、それはインターフェイスに大きな影響を及ぼします。 C++ 0xがオプションではなく、保証が必要な場合は、いくつかのシナリオで代わりに参照カウントオブジェクトまたはコピーオンライトオブジェクトを使用することがあります。ただし、マルチスレッドには欠点があります。

(C++での1つの答えが単純で簡単で、条件がなければいいのですが)。

18
peterchen

実際、C++ 11以降、copying the _std::vector_のコストはほとんどの場合なくなっています。

ただし、constructing new vector(then destructing it)のコストは依然として存在し、値で返す代わりに出力パラメーターを使用することは依然として有用であることに留意してくださいベクターの容量を再利用したい場合。これは、C++コアガイドラインの F.2 の例外として文書化されています。

比較してみましょう:

_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);
}
_

最初の例では、多くの不必要な動的割り当て/割り当て解除が発生しますが、2番目の例では、出力パラメーターを古い方法で使用して、既に割り当てられたメモリを再利用することで防止します。この最適化を行う価値があるかどうかは、値の計算/変更のコストと比較した割り当て/割り当て解除の相対的なコストに依存します。

基準

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

(Intel 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_の場合、2番目のベクトルを再割り当てする代わりにベクトル容量を再利用すると、1.37倍高速になります。

_numIter = 256_の場合、ベクトル容量を再利用する(256回繰り返してベクトルを割り当て/割り当て解除する代わりに...)は、2.45倍高速です:)

Time1は_numIter = 1_から_numIter = 256_までほぼ一定であることがわかります。つまり、8GBの1つの巨大なベクトルを割り当てると、32MBの256個のベクトルを割り当てるのと同じくらいのコストがかかります。ただし、8GBの1つの巨大なベクトルを割り当てることは、32MBの1つのベクトルを割り当てるよりも明らかに高価です。そのため、ベクトルの容量を再利用するとパフォーマンスが向上します。

_numIter = 512_(mem(v)= 16MB)から_numIter = 8M_(mem(v)= 1kB)までがスイートスポットです。両方のメソッドは、numIterとvecSizeの他のすべての組み合わせとまったく同じくらい高速で高速です。 。これはおそらく、プロセッサのL3キャッシュサイズが8MBであるため、ベクターがキャッシュにほぼ完全に収まるようになっているためと思われます。 _time1_の突然のジャンプがmem(v)= 16MBである理由については本当に説明していませんが、mem(v)= 8MBの直後に発生するのはより論理的に思えます。驚くことに、このスイートスポットでは、容量の再利用は実際にはわずかに速いことに注意してください。私はこれを本当に説明しません。

_numIter > 8M_がthingsいものになり始めると。どちらの方法も遅くなりますが、値によるベクトルの返送はさらに遅くなります。最悪の場合、単一のintのみを含むベクターでは、値で返す代わりに容量を再利用するのが3.3倍速くなります。おそらく、これは支配的になり始めるmalloc()の固定費によるものです。

Time2の曲線がtime1の曲線よりも滑らかであることに注意してください。ベクトル容量の再利用が一般に高速であるだけでなく、おそらくもっと重要なことは、より多くの予測可能です。

また、スイートスポットでは、約0.5秒で64ビット整数の20億の加算を実行できたことに注意してください。これは、4.2Ghz 64ビットプロセッサで非常に最適です。 8つのコアすべてを使用するために計算を並列化することで、より良い結果を得ることができます(上記のテストでは、一度に1つのコアのみを使用します。 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. 値で返す代わりに出力パラメーターを使用するmay容量を再利用することでパフォーマンスが向上します。
  2. 現代のデスクトップコンピューターでは、これは大きなベクター(> 16MB)と小さなベクター(<1kB)にのみ適用されるようです。
  3. 数百万/十億の小さなベクトル(<1kB)の割り当てを避けます。可能であれば、容量を再利用するか、さらに良い場合は、アーキテクチャを異なる方法で設計します。

他のプラットフォームでは結果が異なる場合があります。通常、パフォーマンスが重要な場合は、特定のユースケースのベンチマークを作成します。

8
Boris Dalstein

私はまだ悪い習慣だと思いますが、私のチームがMSVC 2008とGCC 4.1を使用しているので、最新のコンパイラを使用していません。

以前、MSVC 2008でvtuneに表示されたホットスポットの多くは、文字列のコピーに起因していました。次のようなコードがありました。

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

...独自のString型を使用していることに注意してください(プラグイン作成者が異なるコンパイラーを使用し、std :: string/std :: wstringの異なる非互換の実装を使用できるソフトウェア開発キットを提供しているため、これが必要でした)。

String :: String(const String&)がかなりの時間を要することを示すコールグラフサンプリングプロファイリングセッションに応じて、簡単な変更を加えました。上記の例のようなメソッドが最大の貢献者でした(実際、プロファイリングセッションでは、メモリ割り当てと割り当て解除が最大のホットスポットの1つであり、文字列コピーコンストラクターが割り当ての主要な貢献者でした)。

私が行った変更は簡単でした:

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

しかし、これは違いの世界を作りました!ホットスポットはその後のプロファイラーセッションで消滅し、これに加えて、アプリケーションのパフォーマンスを追跡するために多くの徹底的なユニットテストを行います。これらの簡単な変更の後、あらゆる種類のパフォーマンステスト時間が大幅に短縮されました。

結論:絶対的な最新のコンパイラを使用していませんが、(少なくともすべての場合において)確実に値を返すためにコピーを最適化するコンパイラに依存しているようには見えません。 MSVC 2010のような新しいコンパイラを使用している人には当てはまらないかもしれません。C++ 0xを使用して単純に右辺値参照を使用できるようになるのを楽しみにしています。値によるクラス。

[編集]ネイトが指摘したように、RVOは関数内で作成された一時的なものを返すことに適用されます。私の場合、そのような一時的なものはなく(空の文字列を作成する無効なブランチを除く)、RVOは適用されませんでした。

5
stinky472

ちょっとちょっと:配列を関数から返すことは多くのプログラミング言語では一般的ではありません。それらのほとんどでは、配列への参照が返されます。 C++では、最も近いアナロジーはboost::shared_arrayを返すことです

3

パフォーマンスが実際の問題である場合、移動セマンティクスはコピーよりも常に速くないことを認識してください。たとえば、小さな文字列の最適化を使用する文字列がある場合、小さな文字列の場合、移動コンストラクタは通常のコピーとまったく同じ量の作業を行う必要がありますコンストラクタ。

2
Motti