39

私は最近、同時実行同期メカニズムとして使用されるトリプル バッファーの std::atomic を使用して、C++11 へのポートを作成しました。このスレッド同期アプローチの背後にある考え方は、プロデューサーとコンシューマーの状況で、より高速に実行されているプロデューサーを使用することです。コンシューマを待たなければならないことでプロデューサ スレッドが「遅くなる」ことがないため、トリプル バッファリングにはいくつかの利点があります。私の場合、~120fps で更新される物理スレッドと、~60fps で実行されるレンダリング スレッドがあります。明らかに、レンダー スレッドが常に可能な限り最新の状態を取得することを望んでいますが、レートの違いにより、物理スレッドから多くのフレームをスキップすることもわかっています。一方で、物理スレッドが一定の更新レートを維持し、データをロックする遅いレンダリング スレッドによって制限されないようにしたいと考えています。

元の C コードは remis-thinking によって作成され、完全な説明は彼のブログにあります。元の実装をさらに理解するために、この文書を読むことに興味のある人をお勧めします。

私の実装はここにあります。

基本的な考え方は、3 つの位置 (バッファ) を持つ配列と、いつでもどの配列要素がどの状​​態に対応するかを定義するために比較および交換されるアトミック フラグを持つことです。このように、1 つのアトミック変数のみを使用して、配列の 3 つのインデックスすべてとトリプル バッファリングの背後にあるロジックをモデル化します。バッファの 3 つの位置は、Dirty、Clean、および Snap と名付けられています。プロデューサは常に Dirty インデックスに書き込み、ライターを反転して Dirty を現在の Clean インデックスと交換できます。コンシューマは、最新のバッファを取得するために現在のスナップ インデックスをクリーン インデックスと交換する新しいスナップを要求できます。コンシューマーは、常にスナップ位置でバッファーを読み取ります。

フラグは 8 ビットの unsigned int で構成され、ビットは以下に対応します。

(未使用) (新規書き込み) (2x ダーティ) (2x クリーン) (2x スナップ)

newWrite エクストラ ビット フラグは、ライターによって設定され、リーダーによってクリアされます。リーダーはこれを使用して、最後のスナップ以降に書き込みがあったかどうかを確認できます。書き込みがない場合は、別のスナップは実行されません。フラグとインデックスは、単純なビット演算を使用して取得できます。

コードは次のとおりです。

template <typename T>
class TripleBuffer
{

public:

  TripleBuffer<T>();
  TripleBuffer<T>(const T& init);

  // non-copyable behavior
  TripleBuffer<T>(const TripleBuffer<T>&) = delete;
  TripleBuffer<T>& operator=(const TripleBuffer<T>&) = delete;

  T snap() const; // get the current snap to read
  void write(const T newT); // write a new value
  bool newSnap(); // swap to the latest value, if any
  void flipWriter(); // flip writer positions dirty / clean

  T readLast(); // wrapper to read the last available element (newSnap + snap)
  void update(T newT); // wrapper to update with a new element (write + flipWriter)

private:

  bool isNewWrite(uint_fast8_t flags); // check if the newWrite bit is 1
  uint_fast8_t swapSnapWithClean(uint_fast8_t flags); // swap Snap and Clean indexes
  uint_fast8_t newWriteSwapCleanWithDirty(uint_fast8_t flags); // set newWrite to 1 and swap Clean and Dirty indexes

  // 8 bit flags are (unused) (new write) (2x dirty) (2x clean) (2x snap)
  // newWrite   = (flags & 0x40)
  // dirtyIndex = (flags & 0x30) >> 4
  // cleanIndex = (flags & 0xC) >> 2
  // snapIndex  = (flags & 0x3)
  mutable atomic_uint_fast8_t flags;

  T buffer[3];
};

実装:

template <typename T>
TripleBuffer<T>::TripleBuffer(){

  T dummy = T();

  buffer[0] = dummy;
  buffer[1] = dummy;
  buffer[2] = dummy;

  flags.store(0x6, std::memory_order_relaxed); // initially dirty = 0, clean = 1 and snap = 2
}

template <typename T>
TripleBuffer<T>::TripleBuffer(const T& init){

  buffer[0] = init;
  buffer[1] = init;
  buffer[2] = init;

  flags.store(0x6, std::memory_order_relaxed); // initially dirty = 0, clean = 1 and snap = 2
}

template <typename T>
T TripleBuffer<T>::snap() const{

  return buffer[flags.load(std::memory_order_consume) & 0x3]; // read snap index
}

template <typename T>
void TripleBuffer<T>::write(const T newT){

  buffer[(flags.load(std::memory_order_consume) & 0x30) >> 4] = newT; // write into dirty index
}

template <typename T>
bool TripleBuffer<T>::newSnap(){

  uint_fast8_t flagsNow(flags.load(std::memory_order_consume));
  do {
    if( !isNewWrite(flagsNow) ) // nothing new, no need to swap
      return false;
  } while(!flags.compare_exchange_weak(flagsNow,
                                       swapSnapWithClean(flagsNow),
                                       memory_order_release,
                                       memory_order_consume));
  return true;
}

template <typename T>
void TripleBuffer<T>::flipWriter(){

  uint_fast8_t flagsNow(flags.load(std::memory_order_consume));
  while(!flags.compare_exchange_weak(flagsNow,
                                     newWriteSwapCleanWithDirty(flagsNow),
                                     memory_order_release,
                                     memory_order_consume));
}

template <typename T>
T TripleBuffer<T>::readLast(){
    newSnap(); // get most recent value
    return snap(); // return it
}

template <typename T>
void TripleBuffer<T>::update(T newT){
    write(newT); // write new value
    flipWriter(); // change dirty/clean buffer positions for the next update
}

template <typename T>
bool TripleBuffer<T>::isNewWrite(uint_fast8_t flags){
    // check if the newWrite bit is 1
    return ((flags & 0x40) != 0);
}

template <typename T>
uint_fast8_t TripleBuffer<T>::swapSnapWithClean(uint_fast8_t flags){
    // swap snap with clean
    return (flags & 0x30) | ((flags & 0x3) << 2) | ((flags & 0xC) >> 2);
}

template <typename T>
uint_fast8_t TripleBuffer<T>::newWriteSwapCleanWithDirty(uint_fast8_t flags){
    // set newWrite bit to 1 and swap clean with dirty 
    return 0x40 | ((flags & 0xC) << 2) | ((flags & 0x30) >> 2) | (flags & 0x3);
}

ご覧のとおり、メモリの順序付けにRelease-Consumeパターンを使用することにしました。ストアのリリース(memory_order_release) は、現在のスレッドの書き込みがストアに並べ替えられないことを保証します。一方、消費は、現在ロードされている値に依存する現在のスレッドの読み取りが、このロードのに並べ替えられないことを保証します。これにより、同じアトミック変数を解放する他のスレッドの従属変数への書き込みが、現在のスレッドで確実に表示されます。

私の理解が正しければ、フラグをアトミックに設定するだけでよいため、フラグに直接影響を与えない他の変数に対する操作は、コンパイラーによって自由に並べ替えられ、より多くの最適化が可能になります。新しいメモリ モデルに関するいくつかのドキュメントを読んで、これらの緩和されたアトミックが ARM や POWER などのプラットフォームにのみ顕著な影響を与えることも認識しています (それらは主にそれらのために導入されました)。私は ARM をターゲットにしているので、これらの操作の恩恵を受けて、パフォーマンスをもう少し引き出すことができると信じています。

質問は次のとおりです。

この特定の問題に対して Release-Consume の緩和された順序付けを正しく使用していますか?

ありがとう、

アンドレ

PS: 長い投稿で申し訳ありませんが、問題をよりよく理解するには、適切なコンテキストが必要であると考えていました。

編集: @Yakk の提案を実装:

  • flagsread onnewSnap()flipWriter()直接代入を使用していたため、 default を使用していたのを修正しましたload(std::memory_order_seq_cst)
  • わかりやすくするために、ビット操作を専用関数に移動しました。
  • boolに戻り値の型が追加されましnewSnap()た。新しいものがない場合は false を返し、それ以外の場合は true を返します。
  • = deleteが使用されている場合、コピー コンストラクターと代入コンストラクターの両方が安全ではないため、イディオムを使用してクラスをコピー不可として定義しTripleBufferました。

EDIT 2: 間違っていた説明を修正しました(@Uselessに感謝します)。新しいスナップを要求し、スナップ インデックスから読み取るのはコンシューマーです (「ライター」ではありません)。気を散らして申し訳ありません。それを指摘してくれた Useless に感謝します。

EDIT 3: @Display Name の提案に従ってnewSnap()and関数を 最適化し、ループ サイクルごとに 2 つの冗長な を効果的に削除しました。flipriter()load()

4

2 に答える 2

3

CAS ループで古いフラグの値を 2 回読み込んでいるのはなぜですか? 1 回目は でflags.load()、2 回目は で、compare_exchange_weak()CAS の失敗時に標準で指定されているものは、前の値を最初の引数 (この場合は flagsNow) にロードします。

http://en.cppreference.com/w/cpp/atomic/atomic/compare_exchangeによると、「それ以外の場合は、 *this に格納されている実際の値を期待値にロードします (ロード操作を実行します)。」したがって、ループが行っていることは失敗すると、compare_exchange_weak()が再flagsNowロードされ、ループが繰り返され、最初のステートメントが によるロードの直後にもう一度ロードされcompare_exchange_weak()ます。あなたのループは、代わりに負荷をループの外に引っ張るべきだと私には思えます。たとえば、次のようにnewSnap()なります。

uint_fast8_t flagsNow(flags.load(std::memory_order_consume));
do
{
    if( !isNewWrite(flagsNow)) return false; // nothing new, no need to swap
} while(!flags.compare_exchange_weak(flagsNow, swapSnapWithClean(flagsNow), memory_order_release, memory_order_consume));

flipWriter():

uint_fast8_t flagsNow(flags.load(std::memory_order_consume));
while(!flags.compare_exchange_weak(flagsNow, newWriteSwapCleanWithDirty(flagsNow), memory_order_release, memory_order_consume));
于 2014-04-23T00:10:04.583 に答える