リトル C++ 標準ライブラリ ユーティリティ:std::align

02 について最近知りました は、使用例が限られているため、C++ 標準ライブラリのあまり知られていない関数の 1 つです。特定の使用例がないと説明が難しいため、アリーナ アロケータ の単純な実装を使用します。 やる気を起こさせる例として。

アリーナ アロケータ

アリーナ、バンプ アロケータとも呼ばれます または地域ベースのアロケーター 、おそらく最も簡単な割り当て戦略です。これは非常に広く使用されているため、C++ 標準ライブラリでさえ std::pmr::monotonic_buffer_resource と呼ばれるアリーナ実装を持っています。

アリーナでは、スタックまたは 10 などの別のアロケーターから事前に割り当てられた大量のメモリから開始します。 .その後、ポインター オフセットをバンプすることによって、そのチャンクからメモリを割り当てます。

Arena アロケーターは、特に 27 のような複雑な獣と比較した場合、優れたパフォーマンス特性を持っています .各割り当てにはポインタ バンプのみが必要であり、割り当てられたオブジェクトが簡単に破壊可能である限り、解放はほとんど自由です。 1 .デストラクタを呼び出す必要がある場合は、破棄するオブジェクトのリストを維持する必要があります.デストラクタをサポートすると、アリーナの実装がかなり複雑になり、この投稿の範囲を超えています.

アリーナの欠点は、アリーナが個々の割り当てを追跡しないため、一度にすべての割り当てられたメモリしか解放できないことです.それでも、一緒に解放する必要がある異種の割り当てがたくさんある場合に役立ちます. 、コンパイラからビデオ ゲームまで、アプリケーション ドメインで広く使用されています。

アリーナ アロケータとスタック アロケータの間にはいくつかの混乱があります .スタック アロケータはアリーナ アロケータの自然な進化であり、スタック アロケータの割り当ては LIFO (後入れ先出し) の順序で解放できます。

アリーナの最小実装

アリーナの簡単な実装は次のようになります:

struct Arena {
  std::byte* ptr = 0;
  std::size_t size_remain = 0;

  [[nodiscard]] auto alloc(std::size_t size) noexcept -> void*
  {
    if (size_remain < size) return nullptr;
    
    auto* alloc_ptr = ptr;
    ptr += size;
    size_remain -= size;
    return alloc_ptr;
  }
};

32 の代わりにエンド ポインターを格納することもできます。 46 を比較します ただし、それによって全体像が大きく変わることはありません。

アリーナを使用するには、まず事前に割り当てられたバッファーからアリーナを構築します。次に、アリーナから生のメモリを割り当て、割り当てられたメモリの上にオブジェクトを作成できます。

std::byte buffer[1000];
Arena arena {
  .ptr = buffer, 
  .size_remain = std::size(buffer)
};

auto* ptr = static_cast<std::uint8_t*>(arena.alloc(sizeof(std::uint8_t)));
ptr = new(ptr) std::uint8_t{42};
  
auto* ptr2 = static_cast<std::uint32_t*>(arena.alloc(sizeof(std::uint32_t)));
ptr2 = new(ptr2) std::uint32_t{1729};

私たちの型は整数であるため、ここでの配置ニュースは何もしませんが、オブジェクトの有効期間を開始するために必要です。 直接は、技術的には C++ では未定義の動作です。

配置

配置を忘れていなければ、上記の単純な解決策は完璧です。 .ただし、現実の世界では、62 によって返されるポインタ そのメモリ位置に作成したいオブジェクトに対して適切に配置されていない可能性があります。

C++ では、すべての型とオブジェクトに、78 によって手動で制御されるアラインメントがあります。 81 によって照会されます .

位置合わせされていない場所でオブジェクトの有効期間を開始することは、未定義の動作です。異なるアーキテクチャによっては、位置合わせされていないオブジェクトにアクセスしようとすると、メモリ アクセスが遅くなったり、謎のクラッシュが発生することさえあります。

C++ プログラマーの間で最も恐ろしいことの 1 つである、未定義の動作がいかに簡単に発生するかをご覧ください。未加工のメモリを操作すると、メモリ割り当てをカプセル化したい理由があります。

コンパイラーがアラインメントを見つけ出し、99 などの標準ライブラリ関数を使用できるため、通常はアラインメントについてあまり気にしません。 自動的に十分なアライメントを提供します (109 ただし、カスタムのメモリ割り当て戦略を試し始めると、アライメントを理解することが突然不可欠になります。

アリーナの以前の使用法が何をするかを考えてみましょう。最初は、アリーナは空です。次に、1 バイトのメモリを割り当て、114 を構築します。 しかし、ここで 4 バイトを割り当てると、122 で必要とされる 4 バイトのアラインメント境界から 1 バイトずれた場所に割り当てられます。 :

上記の例は、私たちが冒険を始めて独自のメモリ割り当て戦略を考え出すときに、アライメントの重要性を納得させるはずです.

アリーナ、固定

アライメントを考慮したアリーナを実装するには、まずヘルパー関数 132 が必要です 特定のアラインメントを指定して、アラインメントされたアドレスに特定のポインタを前方にバンプします:

[[nodiscard]] inline auto align_forward(std::byte* ptr, std::size_t alignment) noexcept
  -> std::byte*
{
  const auto addr = std::bit_cast<uintptr_t>(ptr);
  const auto aligned_addr = (addr + (alignment - 1)) & -alignment;
  return ptr + (aligned_addr - addr);
}
140 は C++20 の機能です。 C++20 より前では、154 が必要です .

最初にポインターを整数にキャストし、(整数) アドレスを式 163 でアラインメント境界に切り上げます。 .

この式が正確に何をしているのかを理解するには、178 の意味を考える必要があります。 ビット単位の設定の整数:すべてのビットを反転し、結果に 1 を追加します。たとえば、182 としましょう 197 です 、それは

として表されます

208

否定を適用すると、217 が得られます 、2 の補数で

として表されます

229 .

先頭のバイトをすべて省略しましたが、パターンを確認できるはずです:アラインメントの否定は、まさに下位ビットをマスクしたいビットマスクです.

最後に、 230 をキャストする必要があります ポインタに戻ります。別のビット キャスト (243) を行う代わりに、ポインター演算を行うことにしました。 ) そのため、clang-tidy からポインタの来歴に関する警告を受けません。

ヘルパー関数が整ったので、 257 を実装できるようになりました :

struct Arena {
  std::byte* ptr = 0;
  std::size_t size_remain = 0;

  [[nodiscard]]
  auto aligned_alloc(std::size_t alignment, std::size_t size) noexcept -> void*
  {
    std::byte* aligned_ptr = align_forward(ptr, alignment);
    const size_t size_for_alignment = aligned_ptr - ptr;
    const size_t bump_size = size_for_alignment + size;
    if (size_remain < bump_size) return nullptr;

    ptr = aligned_ptr + size;
    size_remain -= bump_size;
    return aligned_ptr;
  }
};

関数名を 267 から変更したことに注意してください 279 まで 281 を明示的に渡す必要があるため この関数への引数。まず、290 を呼び出します 関数内のアラインメント境界へのポインターを調整します。次に、割り当てに必要なバイト数を計算します (これは、配置に使用されるバイト数に、割り当てる必要がある実際のサイズを加えたものです)。最後に、割り当てるのに十分なサイズがある場合は、ポインターをバンプし、残りのサイズを減らし、調整されたポインターを返す必要があります。

この実装を使用するには、アリーナにアライメントを明示的に渡す必要があります:

auto* ptr = static_cast<std::uint8_t*>(
  arena.aligned_alloc(alignof(std::uint8_t), sizeof(std::uint8_t)));
ptr = new(ptr) std::uint8_t{42};
  
auto* ptr2 = static_cast<std::uint32_t*>(
  arena.aligned_alloc(alignof(std::uint32_t), sizeof(std::uint32_t)));
ptr2 = new(ptr2) std::uint32_t{1729};

クライアント側のコードを書くのが少し面倒になっていることがわかります。ただし、実際には、306 の呼び出しを非表示にすることができます。 テンプレート化された関数の背後にあります。重要なことは、割り当てが適切に調整されることです:

それでも古い 319 が必要な場合 アラインメントを考慮しないメンバー関数、322 のラッパーとして記述できます 336 のアラインメントを取ります :

[[nodiscard]]
auto alloc(std::size_t size) noexcept -> void*
{
  return aligned_alloc(alignof(std::max_align_t), size);
}

このバージョンの 340 厳密に 359 にアラインされたポインタを常に返します 、367 と同様 .この方法では、各割り当ての正しい配置も保証されますが、小さなオブジェクトに多くの割り当てがある場合、スペースが浪費される可能性があります.

373 を入力してください

上記のアリーナの実装は信頼できます。私は一連の C プロジェクトで本質的に同一のバージョンのアリーナを使用しています。しかし、標準ライブラリの助けを借りて、C++ でより良い結果を得ることができます。

384 395 で定義されている標準関数です .次のインターフェースがあります:

namespace std {
  auto align(std::size_t alignment,
           std::size_t size,
           void*& ptr,
           std::size_t& space)
  -> void*;
}

次のことを行います:

404 のインターフェース 主に参照によって渡される 2 つの in-out パラメータがあるため、間違いなく把握するのは容易ではありません。しかし、415 と同様の目的を果たします。 function.最初の 2 つのパラメーター、422431442 に渡したパラメータと同じです .そして 458461 これが私たちの舞台の状態です。

471 484 が十分にあるかどうかを確認することから始めます 494 を割り当てる アライメント調整後のバイト。そうであれば、ポインター 505 を調整します。 、減少 513 位置合わせに使用されるバイト数によって、位置合わせされたポインターを返します。

528 で 、私たちのコードは大幅に簡素化できます:

struct Arena {
  void* ptr = 0;
  std::size_t size_remain = 0;
  
  [[nodiscard]]
  auto aligned_alloc(std::size_t alignment, std::size_t size) noexcept -> void*
  {
    void* res = std::align(alignment, size, ptr, size_remain);
    if (res) {
        ptr = static_cast<std::byte*>(res) + size;
        size_remain -= size;
        return res;
    }
    return nullptr;
  }
};

ヘルパー関数 531 はもう必要ありません 、 540 以降 同様の目的を果たします.ポインタから整数へのキャストとビット操作を自分で書く必要がないのは素晴らしいことです.そして私たちの 557 関数も最初の 563 と同じくらいシンプルに見えます 配置を考慮しない関数。

574 以降に注意してください 587 だけ増加します アライメント境界まで減少し、593 を減らします アラインメントに使用されるバイト数によって、これら 2 つの変数を実際の 604 で変更する必要があります。

もう 1 つの小さな変更点は、617 です。 628 を使用する必要があります 以前の実装では 633 を使用していましたが、 .ポインタ演算を自分で行う必要がなくなったので、 645 を使用しても問題ありません 、これは 653 の型でもあります とにかく戻る必要があります。

結論

660 ユースケースの数がわかりません カスタム アロケータの外側にあります。柔軟な配列メンバーのような構造を実装することも役立つかもしれません。それでも、C++ 標準ライブラリにこの小さなユーティリティがあり、手動のアライメント計算で頭を悩ませる必要がなくなりました。

<オール>
  • C++ では、型は自明に破壊可能です アクションを実行するデストラクタがない場合。例:675 そして 680 デストラクタがメモリを解放するため、自明に破壊することはできません。自明に破壊できない型を含むものはすべて、自明に破壊することもできません.↩