web-dev-qa-db-ja.com

std :: vector <std :: unique_ptr <T >>のサイズ変更のパフォーマンス

一般的な概念としては、_std::unique_ptr_には 時間オーバーヘッドなし があり、適切に使用されている生の生のポインタ 十分な最適化が与えられた場合 と比較されます。

しかし、複合データ構造で_std::unique_ptr_、特に_std::vector<std::unique_ptr<T>>_を使用することについてはどうでしょうか。たとえば、_Push_back_の最中に発生する可能性のあるベクトルの基になるデータのサイズ変更。パフォーマンスを分離するために、_pop_back_、_shrink_to_fit_、_emplace_back_をループします。

_#include <chrono>
#include <vector>
#include <memory>
#include <iostream>

constexpr size_t size = 1000000;
constexpr size_t repeat = 1000;
using my_clock = std::chrono::high_resolution_clock;

template<class T>
auto test(std::vector<T>& v) {
    v.reserve(size);
    for (size_t i = 0; i < size; i++) {
        v.emplace_back(new int());
    }
    auto t0 = my_clock::now();
    for (int i = 0; i < repeat; i++) {
        auto back = std::move(v.back());
        v.pop_back();
        v.shrink_to_fit();
        if (back == nullptr) throw "don't optimize me away";
        v.emplace_back(std::move(back));
    }
    return my_clock::now() - t0;
}

int main() {
    std::vector<std::unique_ptr<int>> v_u;
    std::vector<int*> v_p;

    auto millis_p = std::chrono::duration_cast<std::chrono::milliseconds>(test(v_p));
    auto millis_u = std::chrono::duration_cast<std::chrono::milliseconds>(test(v_u));
    std::cout << "raw pointer: " << millis_p.count() << " ms, unique_ptr: " << millis_u.count() << " ms\n";
    for (auto p : v_p) delete p; // I don't like memory leaks ;-)
}
_

Linux上のIntel Xeon E5-2690 v3 @ 2.6 GHz(ターボなし)で、gcc 7.1.0、clang 3.8.0、および17.0.4を使用して_-O3 -o -march=native -std=c++14 -g_でコードをコンパイルします。

_raw pointer: 2746 ms, unique_ptr: 5140 ms  (gcc)
raw pointer: 2667 ms, unique_ptr: 5529 ms  (clang)
raw pointer: 1448 ms, unique_ptr: 5374 ms  (intel)
_

生のポインタバージョンは、すべての時間を最適化されたmemmoveに費やしています(intelはclangやgccよりもはるかに優れているようです)。 _unique_ptr_コードは、最初に1つのメモリブロックから別のメモリブロックにベクターデータをコピーし、元のブロックにゼロを割り当てます-すべてが恐ろしく最適化されていないループにあります。次に、元のデータブロックを再びループして、ゼロにされたもののいずれかがゼロ以外であり、削除する必要があるかどうかを確認します。詳細は godbolt で確認できます。 問題は、コンパイルされたコードがどのように異なるかではありません、それはかなり明確です。問題はwhyコンパイラーが、通常、余分なオーバーヘッドのない抽象化と見なされるものを最適化できないことです。

コンパイラが_std::unique_ptr_の処理をどのように推論するかを理解するために、分離コードをもう少し調べていました。例えば:

_void foo(std::unique_ptr<int>& a, std::unique_ptr<int>& b) {
  a.release();
  a = std::move(b);
}
_

または同様の

_a.release();
a.reset(b.release());
_

x86コンパイラはどれも 最適化できるように見える 意味のないif (ptr) delete ptr;。インテルのコンパイラーは削除に28%の確率を与えます。驚くべきことに、削除チェックは常に次の場合に省略されます。

_auto tmp = b.release();
a.release();
a.reset(tmp);
_

これらの問題はこの質問の主要な側面ではありませんが、これらのすべてが私に何かが足りないことを感じさせます。

さまざまなコンパイラが_std::vector<std::unique_ptr<int>>_内の再割り当てを最適化できないのはなぜですか?生のポインタと同じくらい効率的なコードの生成を妨げる何かが標準にありますか?これは標準ライブラリ実装の問題ですか?それとも、コンパイラーは(まだ)十分に賢くないだけですか?

生のポインタを使用する場合と比較して、パフォーマンスへの影響を回避するために何ができますか?

注:Tは多態性であり、移動にコストがかかるため、_std::vector<T>_はオプションではありません。

32
Zulan

unique_ptrが生のポインタと同様に機能するという主張最適化後は、ほとんどの場合、作成、逆参照、単一のポインタの割り当て、削除など、単一のポインタに対する基本的な操作にのみ適用されます。これらの操作は、最適化コンパイラが通常必要な変換を実行して、結果のコードのパフォーマンスがrawバージョンと同等(またはほぼ同等)になるように、単純に定義されています。

テストで指摘したように、これが崩れる1つの場所は、特にstd::vectorなどの配列ベースのコンテナーでの高レベルの言語ベースの最適化です。これらのコンテナは通常、型の特性に依存するソースレベル最適化を使用して、コンパイル時にmemcpyなどのバイト単位のコピーを使用して型を安全にコピーできるかどうかを判断し、可能であればそのようなメソッドに委任します。または、要素単位のコピーループにフォールバックします。

memcpyで安全にコピーできるようにするには、オブジェクトが trivially copyable でなければなりません。さて、std::unique_ptrは簡単にはコピーできません。それは、実際には requirements のいくつかに失敗するためです。正確なメカニズムは、関連する標準ライブラリに依存しますが、一般に、高品質のstd::vector実装は、memmoveに委譲する単純なコピー可能な型に対して std::uninitialized_copy のような特殊な形式を呼び出すことになります。

典型的な実装の詳細はかなり拷問されていますが、libstc++gccで使用)の場合、高レベルの相違が std::uninitialized_copy で確認できます。

 template<typename _InputIterator, typename _ForwardIterator>
 inline _ForwardIterator
 uninitialized_copy(_InputIterator __first, _InputIterator __last,
                    _ForwardIterator __result)
 {
        ...
   return std::__uninitialized_copy<__is_trivial(_ValueType1)
                                    && __is_trivial(_ValueType2)
                                    && __assignable>::
     __uninit_copy(__first, __last, __result);
 }

そこから、いくつかのstd::vector "movement"メソッドがここで終了し、__uninitialized_copy<true>::__uinit_copy(...)が最終的にmemmoveを呼び出すのに対し、<false>バージョンは呼び出さない、またはコードを自分でトレースすることができます(ただし、ベンチマークですでに結果を確認しました)。

最終的に、目的のオブジェクトの移動コンストラクターを呼び出してから、すべてのソースオブジェクトのデストラクタを呼び出すなど、重要なオブジェクトに必要なコピー手順を実行するいくつかのループが発生します。これらは別個のループであり、最近のコンパイラーでも「OK、最初のループですべての宛先オブジェクトを移動したので、ptrメンバーがnullになるため、2番目のループは何も行われません」などの理由はほとんどありません。 。最後に、生のポインタの速度を等しくするには、コンパイラがこれら2つのループ全体で最適化する必要があるだけでなく、全体をmemcpyまたはmemmoveで置き換えることができることを認識する変換が必要です。2

したがって、あなたの質問に対する答えの1つは、コンパイラーはこの最適化を行うのに十分スマートではないということですが、それは主に、「未加工」バージョンには、この最適化の必要性を完全にスキップするためのコンパイル時のヘルプがたくさんあるためです。

ループフュージョン

前述のように、既存のvector実装は、(新しいストレージの割り当てや古いストレージの解放などの非ループ作業に加えて)2つの個別のループでサイズ変更タイプの操作を実装します。

  • ソースオブジェクトを新しく割り当てられた宛先配列にコピーします(概念的には、配置コンストラクターのようなものを使用して、移動コンストラクターを呼び出します)。
  • 古い領域のソースオブジェクトを破棄します。

概念的には、別の方法を想像することができます。これをすべて1つのループで実行し、各要素をコピーすると、すぐに破棄されます。コンパイラーは、2つのループが同じ値のセットを反復処理し、Fuse 2つのループが1つになることに気付くことさえあり得る。 [どうやら]、しかし、( https://gcc.gnu.org/ml/gcc/2015-04/msg00291.htmlgccは何もしないループフュージョン =今日、そして、clangiccを信じないなら このテスト もしない。

したがって、ループをソースレベルで明示的にまとめようとしています。

2ループの実装は、コピーの構築部分が完了するまでソースオブジェクトを破棄しないことで、操作の例外安全契約を維持するのに役立ちますが、自明にコピー可能で、それぞれ自明に破壊可能なオブジェクト。特に、単純な特性に基づく選択では、コピーをmemmoveに置き換えることができ、破棄ループを完全に省略できます

したがって、2ループのアプローチは、これらの最適化が適用される場合に役立ちますが、実際には、自明なコピーも破壊もできないオブジェクトの一般的なケースでは害を及ぼします。つまり、オブジェクトを2回パスする必要があり、オブジェクトのコピーとその後の破棄の間でコードを最適化して排除する機会が失われます。 unique_ptrの場合、ソースunique_ptrNULL内部ptrメンバーが含まれるという知識をコンパイラーが伝搬できなくなるため、if (ptr) delete ptrチェックを完全にスキップします4

ささいな可動

ここで、同じ型特性のコンパイル時最適化をunique_ptrのケースに適用できるかどうかを尋ねるかもしれません。たとえば、trivially copyable要件を見て、std::vectorの一般的なmove操作に対して厳格すぎる可能性があることがわかります。確かに、unique_ptrは明らかにコピー可能ではありません。ビット単位のコピーでは、ソースと宛先の両方のオブジェクトに同じポインターが残るためです(結果として二重削除になる)が、ビット単位である必要があるようです移動可能unique_ptrをメモリのある領域から別の領域に移動し、ソースをライブオブジェクトと見なさなくなった場合(したがって、そのデストラクタを呼び出さない場合)は、「機能する」- typicalunique_ptrの実装。

残念ながら、そのような「ささいな動き」の概念は存在しませんが、自分で自分で動かそうとすることはできます。これは、バイト単位でコピーでき、移動シナリオでのコンストラクターまたはデストラクタの動作に依存しないオブジェクトのUBかどうかについて オープンディベート のようです。

(a)オブジェクトには些細な移動コンストラクターがあり、(b)移動コンストラクターのソース引数として使用すると、オブジェクトは状態のままです。デストラクタの効果がない場合。 「些細な移動コンストラクタ」(基本的に要素ごとのコピーのみ)はソースオブジェクトの変更と整合しないため、このような定義は現在ほとんど役に立たないことに注意してください。したがって、たとえば、単純な移動コンストラクタは、ソースunique_ptrptrメンバーをゼロに設定できません。したがって、ソースオブジェクトを有効な、ただし指定されていない状態にするのではなく、破壊するdestructive move演算の概念を導入するなど、さらに多くのフープを通過する必要があります。

ISO C++ usenetディスカッショングループの this thread で、この「些細なこと」の詳細な説明を見つけることができます。特に、リンクされた応答では、unique_ptrのベクトルの正確な問題が対処されています。

多くのスマートポインター(unique_ptrおよびshared_ptrを含む)はこれらの3つのカテゴリすべてに該当し、それらを適用することで、最適化されていないデバッグビルドでも、生のポインターに対してオーバーヘッドが本質的にゼロのスマートポインターのベクトルを作成できます。

relocator の提案も参照してください。


質問の最後にあるベクター以外の例は、これが常に当てはまるわけではないことを示しています。これは、zneakが his answer で説明しているように、エイリアスの可能性があるためです。生のポインタは、unique_ptrが持つ間接性を欠いているため(たとえば、参照によるポインタを持つ構造ではなく、値によって生のポインタを渡すなど)、if (ptr) delete ptrチェックを省略できるため、これらのエイリアスの問題の多くを回避します。完全に。

2 たとえば、memmoveは、コピー元とコピー先がオーバーラップしている場合、オブジェクトのコピーループとは意味が微妙に異なるため、これは実際には想像以上に困難です。もちろん、生のポイントで機能する高レベルの型特性コードは、オーバーラップがないことを(コントラクトによって)知っているか、オーバーラップがあってもmemmoveの動作は一貫していますが、後の任意の最適化パスで同じことを証明する可能性がありますずっと難しい。

これらの最適化は多かれ少なかれ独立していることに注意することが重要です。たとえば、多くのオブジェクトは簡単に破壊可能であり、atは簡単にはコピーできません。

4my test では、gccclangもチェックを抑制することができませんでしたが、__restrict__が適用されていても、エイリアス分析が不十分であるか、std::moveが「制限」修飾子を何らかの方法で取り除いているためと思われます。

39
BeeOnRope

ベクトルであなたを後ろから噛んでいるものについて正確な答えはありません。 BeeOnRopeがすでに持っているようです。

幸いなことに、ポインターをリセットするさまざまな方法(エイリアス分析)を含むあなたのマイクロの例の裏で何があなたを苦しめているのかを教えてくれます。具体的には、コンパイラーは2つのunique_ptr参照が重複しないことを証明できません(または推測したくない)。最初の値への書き込みによって2番目の値が変更された場合、unique_ptr値を強制的に再ロードします。 bazは影響を受けません。なぜなら、コンパイラは、整形式プログラムのどちらのパラメーターも、関数ローカルの自動ストレージを持つtmpとエイリアスできないことを証明できるためです。

これを確認するには、いずれかの__restrict__参照パラメーターに unique_ptrキーワードを追加 (これは、二重下線が多少意味するように、標準のC++ではありません)です。そのキーワードは、参照がそのメモリにアクセスできる唯一の参照であることをコンパイラーに通知します。したがって、他の参照がエイリアスになる可能性はありません。これを行うと、関数の3つのバージョンすべてが同じマシンコードにコンパイルされ、unique_ptrを削除する必要があるかどうかを確認する必要がなくなります。

8
zneak