ホーム / ブログ / エンジン
公開日: 2026年6月12日 約 11 分 エンジン
ゲームエンジンにおけるC++メモリ管理:フレームを食うのは誰か

ゲームエンジンにおけるC++メモリ管理:フレームを食うのは誰か

フレーム内でnewを呼び出すとプロファイラに13msのスパイクが出たが、フレームアリーナとオブジェクトプーリングを導入した結果、ピークは7.9msに低減した。その原因分析と対策の手順を詳しく解説し、プロファイル結果の観察からメモリ割り当てのボトルネック特定、アリーナアロケータの実装、スレッドごとの分離までの全工程を追う。

プロファイラに現れた3ミリ秒のスパイク

昨冬の終わりに自前エンジンの戦闘シーンをプロファイリングしていた。フレーム時間は平均で約9msだったが、数秒ごとに13msのフレームが現れた。画面上のカクつきは目立たなかったものの、60FPSを目指す環境ではそのスパイクは無視できなかった。まずは誰もが最初に見る描画処理を調べたが、レンダリング時間はフレーム間でほぼ一定だった。

原因はDamageNumberSystem内部の一行、new DamageLabel()がヒットごとに実行されていたことだった。1秒間に400回のヒットがある波では、フレームあたり6〜7回のヒープ割り当てが発生した。ほとんどは60nsで終わったが、たまに1回だけ40µsを超え、フレーム全体を台無しにした。平均値のグラフには現れないが、400回中399回は安価だったためだ。

この種のスパイクはすべてが割り当てから来るわけではないので、まず原因を分離した。フレーム内のすべてのoperator new呼び出しにカウンタを入れ、カウンタがピークに達したフレームがフレーム時間のピークと完全に一致した。これで議論の余地はなくなった。

まさにこれがゲームエンジンにおけるC++メモリ管理が別題になる理由だ。平均コストは問題にならず、最悪ケースが重要になる。16.6msの予算内でたった一つのテイルレイテンシがフレームを逃すと、プレイヤーは数値を見る前に違和感を感じる。

フレーム内でnew/deleteが危険な理由

汎用アロケータは決定論的ではない。mallocはフリーロリストを走査して適切なブロックを探し、ロックを取得し、場合によってはOSに新しいページを要求する。その最後のケースではコストがナノ秒からマイクロ秒に跳ね上がり、コードを読んだだけではどのフレームで起きるか分からない。Windowsでは測定したスパイクの多くがページフォルトと一致していた。

二つ目は断片化だ。長時間のセッションで、サイズが混在する数千件の短命割り当てがアドレス空間に穴を開け、同サイズの要求が以前より高コストになる。40分間のテストで同じコードパスが最初の1分の2倍の時間を要した。コンソールでは仮想メモリの余裕が少ないため、さらに悪化する。

三つ目はマルチスレッドエンジンにおける隠れた同期点だ。4つのWorkerThreadが同時に割り当てを行うと、アロケータ内部のロックがシリアライズする。プロファイラでは一つの大きなスタルブロックとしては現れず、至る所に小さな遅延が散在するため、問題が後になってから気付くことになる。

四つ目は測定そのものだ。汎用アロケータのコストは一箇所に集まらず、数百の呼び出し箇所に分散し、どれも単独では目立たない。そのため、個別システムを見ていては問題は表面化せず、総計を測ったときに初めて顕在化する。

フレームアリーナ:フレームごとのリセット

アリーナアロケータ(リニアアロケータ)はシンプルな考え方だ。起動時に大きなブロックを確保し、割り当てごとにカーソルを前進させ、個別に解放しない。フレームの終わりにカーソルを0に戻せば完了になる。私たちのFrameArenaは8MBで開始し、割り当てはアラインステップ+加算1回で済む。測定したピーク使用量の2倍にサイズを設定し、毎フレーム終了時に使用バイト数をテレメトリに書き出す。

ルールは、フレームを超えて生存しないものだけをアリーナに入れることだ。可視リスト、テンポラリDrawCommand配列、物理ブロードフェーズのペアリスト、そのフレーム用にUIが生成するテキストジオメトリなどが該当する。フレーム境界を跨ぐポインタがアリーナ由来であってはならず、そうすると次フレームで上書きされ、バグは静かに潜む。

Reset()はデストラクタを呼び出さない。だからトリビアに破棄可能な型だけを入れる。その他は別途デストラクタリストが必要で、速度優位性が失われるため実装は避けた。割り当て箇所にstatic_assertを入れ、コンパイル時にルールを強制する。

各ワーカースレッドは自分専用のアリーナを持った。これにより割り当てパスから最後のアトミック操作さえ除去でき、スレッド間の隠れ競合は完全に消失した。共有されないアリーナはロック不要だ。4つのアリーナで合計32MBとなり、フレーム時間の改善に比べればごくわずかなコストであった。

ArenaAllocator.h
#pragma once #include <cstddef> #include <cstdint> // Bump-pointer arena: allocate cheaply, reset once per frame. class ArenaAllocator { public: ArenaAllocator(void* block, std::size_t bytes) : base_(static_cast<std::uint8_t*>(block)), size_(bytes), head_(0) {} // Returns nullptr when the arena is exhausted; the caller must check. void* Allocate(std::size_t bytes, std::size_t align = 16) { const std::uintptr_t start = reinterpret_cast<std::uintptr_t>(base_); const std::uintptr_t aligned = (start + head_ + align - 1) & ~(align - 1); const std::size_t next = (aligned - start) + bytes; if (next > size_) return nullptr; head_ = next; return reinterpret_cast<void*>(aligned); } // Destructors are NOT run here: store trivially destructible types only. void Reset() { head_ = 0; } private: std::uint8_t* base_; std::size_t size_; std::size_t head_; };
cppArenaAllocator.h

固定サイズオブジェクトのオブジェクトプーリング

フレームを超えて生存するオブジェクトにはアリーナは無意味です。弾丸、オーディオソース、パーティクルエミッタは数秒間生き、任意の順序で消滅します。これらにはプールアロケータを使用します。サイズが同じブロックの配列と、空きブロックを指すフリーレストです。ブロックサイズが固定されているため、断片化は全く問題になりません。

メモリプールの良い点は、フリーレストをブロック自体に格納できることです。未使用のブロックは使用中でないので、最初の8バイトに次の空きブロックのアドレスを書き込みます。別個のデータ構造や別の割り当ては不要です。これによりAcquire()Release()は定数時間でほぼ分岐なしになります。

プールされたオブジェクトへの生ポインタは決して渡しません。すべての弾丸は32ビットのインデックスと世代カウンタを持ち、弾丸が死ぬと世代がインクリメントされ、他所で保持されている古いハンドルは自動的に無効になります。これにより、使用後解放によるクラッシュが、ダメージを与える前にアサートできるサイレントな失敗に変わります。

ProjectilePoolは容量4096を選択しました。最も密集したウェーブで測定したピーク使用量は2,870でした。プールが満杯になると拡張せず、最も古い弾丸を再利用します。フレーム途中で拡張すると、最初にアリーナとプールを導入した目的が失われます。容量はリリースごとに一度見直し、ピーク値のテレメトリが報告されます。

キャッシュローカリティ、アラインメント、偽共有

アロケータを変更した本当の効果は割り当て時間ではなく、オブジェクトがメモリ上で隣り合うことです。プールからの2,870個の弾丸は連続して配置され、更新ループは64バイトのキャッシュラインを順に読み取り、ハードウェアプリフェッチが働きます。同じ数の弾丸をnewで散在させたとき、キャッシュミス率は3倍になりました。

ホットデータを小さく保つことも重要です。Projectile構造体を96バイトから48バイトに縮小すると、キャッシュライン1つに2個の弾丸が収まり、更新ループは0.9 msから0.6 msに短縮されました。メッシュポインタやサウンドIDといった冷たいフィールドを別の配列に移すだけで十分でした。測定で変わったのはフィールド配置だけで、計算は同じままでした。

偽共有は測定しないと見えません。4つのワーカースレッドがそれぞれ自分のカウンタをインクリメントしていましたが、カウンタが同じキャッシュライン上にあったため、書き込みごとに他コアのラインが無効化されました。alignas(64)で分離しただけで、そのシステムで1.2 msの改善が得られ、変更は1行だけでした。

SIMD側ではアラインメントは任意ではありません。アリーナのAllocate関数はアラインメントパラメータを受け取り、__m128用バッファには16、AVXパスには32を渡します。非アラインアクセスがクラッシュしないプラットフォームでも、測定すると明らかに遅くなることが分かりました。アリーナはすでにアラインされたブロックを返すので、そのチェックはちょうど1箇所に集約されています。

std::pmrで十分か、独自アロケータか?

測定サマリー:戦闘シーンの平均フレーム時間は9.2 msから7.1 msに短縮されましたが、実際の差は99パーセンタイルにあります。ピークは13.4 msから7.9 msに減少し、プロファイラの周期的スパイクは完全に消えました。同じ測定を2台の異なるマシンで繰り返し、比率は維持されました。全作業は3週間で、ヘッダーファイル2つに収まりました。

これらの多くはstd::pmrだけで実現可能です。std::pmr::monotonic_buffer_resourceはすでにアリーナであり、unsynchronized_pool_resourceはすでにプールです。標準コンテナを使用している場合、まず測定し、その後メモリリソースをコンテナの下に差し込めば、ほとんどの要件をほぼメンテナンスコストゼロで満たせます。さらに、チーム全員が既に認識している標準インターフェースである点も利点です。

独自のカスタムアロケータを書かなければならないケースは限られています。仮想呼び出しすらしたくないホットループ、ポインタの代わりに32ビットインデックスを格納するシステム、コンソールハードウェア上の特殊メモリ領域、フレーム予算を報告する独自テレメトリなどです。これら4つのうちどれも当てはまらなければ、pmrを使い続けてください。

最初にすべきことは、フレーム内の割り当て回数を数えることです。エンジンにカウンタを追加し、各フレームで何回割り当てが行われたかを出力するだけで、作業時間は約30分です。出てくる数値はチームの誰も予想しなかったものが多いです。その数がゼロに近づくほど、他のすべてが楽になります。

← すべての記事