コンテンツにスキップ

placement new・allocator・memory pool・arena allocator・fragmentation

なぜ自前のメモリ管理が要るのか

汎用の new(malloc)は「任意サイズ・任意タイミング・任意スレッド」に応える万能選手で、その分遅く・不定時間で・断片化します。ゲームは「フレーム末尾で全部捨てる」「同サイズを大量に」などパターンが分かっているので、特化アロケータで桁違いに速くできます。

fragmentation(断片化)

ヒープ: [A使用][ 空き8KB ][B使用][ 空き4KB ][C使用][ 空き6KB ]
要求: 12KBの確保 → 空き合計18KBあるのに、連続12KBがなく失敗(外部断片化)
  • 長時間プレイ(ライブゲーム・常駐サーバー)でじわじわ効く。「合計は足りてるのに確保失敗」「アロケータ内の管理コスト増で徐々に遅くなる」
  • 対策の方向: 同サイズはプールに(穴が同サイズなので断片化しない)、同寿命はアリーナに(まとめて捨てるので穴が空かない)

placement new(場所を指定して構築)

// C++20 — 「メモリ確保」と「オブジェクト構築」を分離する低レベル道具
alignas(Enemy) std::byte buffer[sizeof(Enemy)];   // 確保は自分で済ませた
Enemy* e = new (buffer) Enemy();    // placement new: このbufferの上にコンストラクタだけ実行
e->~Enemy();                        // 破棄も分離: デストラクタだけ明示的に呼ぶ(deleteしない!)

すべての自前アロケータの基礎部品です(vectorの内部も「確保→placement new構築」の分離でできている)。C++17以降は std::construct_at / std::destroy_at という整理された道具もあります。

memory pool(同サイズ特化)

Object Pool(第4部)の下位層版: 同サイズのブロックをフリーリストで貸し借りします。確保・解放はポインタ操作数回=O(1)、断片化ゼロ。弾・ノード・メッセージなど「同じ型を大量に出し入れ」する対象に。

プール: [■][□][■][□][□][■]...(全ブロック同サイズ)
        フリーリスト: 空きブロックを鎖で繋ぐ → Acquire=先頭を外す、Release=先頭に繋ぐ

arena allocator(同寿命特化。линейный/bump allocator とも)

// C++20 — ポインタを進めるだけの最速アロケータ(検証済み: samples/arena_allocator.cpp)
class Arena {
public:
    explicit Arena(std::size_t bytes) : buffer_(bytes), offset_(0) {}

    void* Allocate(std::size_t size, std::size_t align) {
        std::size_t p = (offset_ + align - 1) & ~(align - 1);   // アラインメント調整
        if (p + size > buffer_.size()) return nullptr;          // 満杯ポリシーは設計判断
        offset_ = p + size;
        return buffer_.data() + p;
    }
    void Reset() { offset_ = 0; }    // 全部まとめて「解放」(個別解放はできない/しない)
private:
    std::vector<std::byte> buffer_;
    std::size_t offset_;
};
  • 確保=加算1回(mallocの数百倍速い)。解放は個別にはしない——Reset() で一括破棄
  • 用途: フレームアロケータ(毎フレームの一時データ→フレーム末尾でReset)、ロード時アロケータ(レベルの全データ→アンロードでReset)
  • 制約: デストラクタが必要な型は置けない(Resetはデストラクタを呼ばない)or 別途登録して呼ぶ仕組みが要る。「同寿命でPOD的なデータ」が最適な獲物

allocator(STLへの注入口)

STLコンテナは確保戦略を差し替えられます: std::vector<T, MyAllocator<T>>。C++17の polymorphic memory resource (pmr) が現代の形:

#include <memory_resource>
std::byte buf[4096];
std::pmr::monotonic_buffer_resource arena(buf, sizeof(buf));   // 標準のアリーナ実装
std::pmr::vector<int> v(&arena);    // このvectorの確保はすべてarenaから(型は同じpmr::vectorのまま)

(検証環境GCC 9.2はpmr対応。ただし本Wikiのサンプルは自作Arenaで概念を示しています)

全体地図(どの層の話か)

[Object Pool(第4部)]  ゲームオブジェクトの再利用(リセット・ハンドル管理込み)
[memory pool / arena]   バイト供給の戦略(このページ)
[malloc / new]          汎用ヒープ(OSから貰った領域を管理)
[OS(VirtualAlloc/mmap)] ページ単位の供給

C#/Unityとの対応

  • C#では確保戦略を差し替えられない(GCヒープ一択)。対抗手段は「確保しない」(構造体、プール、stackallocSpan<T>、ArrayPool)——C#のゼロアロケーション技法は、C++のアロケータ選択の代替と整理できる
  • Unityの NativeArray+Allocator指定(Allocator.Temp/TempJob/Persistent)は、まさにフレームアロケータ/アリーナの製品化。Tempが高速なのはこのページの理屈
  • UEには FMemStack(フレームアロケータ)、各種プールが内蔵(→ 第10部)

Unity開発者が誤解しやすい点

  1. 「メモリ確保は一瞬」— 汎用mallocはロック・探索を含む不定時間の処理。フレーム中の大量確保はスパイク源(C#のGC Allocと同型の問題がC++にもある)
  2. 「アロケータ自作は変態技」— フレームアロケータは50行で書けて効果が大きい、ゲーム業界の標準装備
  3. Allocator.Temp の「1フレーム制限」を不便と思っていた人は、このページで理由(Resetモデル)が腑に落ちるはず

使う場面 / 使わない場面

  • 使う: 計測で確保がボトルネック/スパイク源と判明。同サイズ大量(プール)・同寿命大量(アリーナ)のパターンがある。断片化が問題になる長時間運用
  • 使わない: 起動時に確保して以後触らないデータ(汎用newで十分)。パターンのない雑多な確保。計測前

理解度チェック

  1. 断片化への2大対策(同サイズ→プール/同寿命→アリーナ)の理屈を説明できますか。
  2. placement newが分離する2つのステップは?
  3. アリーナに「デストラクタが必要な型」を置くと何が問題ですか。

演習

samples/arena_allocator.cpp を拡張し、「フレームの一時文字列組み立て」をアリーナ上で行い、Reset で使い回すデモを書いてください。汎用newとの速度比較(100万回確保)も計測してみてください。


前: キャッシュ局所性とAoS/SoA | カテゴリ目次 | 次: GCとの違い(C#管理ヒープ)