20.9K Views
November 27, 23
スライド概要
■概要
RE ENGINEではメモリアロケーターを内製しています。
それぞれの時代に合わせて進化を遂げてきたメモリアロケーターの歴史と、最新タイトルで採用されている仮想メモリアロケーターについてご説明します。
※CAPCOM Open Conference Professional RE:2023 で公開された動画を一部改変してスライド化しております。
■想定スキル
malloc, freeといった基本的なメモリ確保・解放の知識
詳細は下記公式サイトをご確認ください。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CAPCOM Open Conference Professional RE:2023
https://www.capcom-games.com/coc/2023/
カプコンR&Dの最新情報は公式Twitterをチェック!
https://twitter.com/capcom_randd
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Copyright (c) 2018-2021 Microsoft Corporation, Daan Leijen
Released under the MIT License: https://opensource.org/license/mit/
Microsoft, Windows, and Microsoft Teams are trademarks or registered trademarks of Microsoft Corporation in the United States and other countries.
Screenshots of Microsoft products are used with permission from Microsoft.
株式会社カプコンが誇るゲームエンジン「RE ENGINE」を開発している技術研究統括によるカプコン公式アカウントです。 これまでの技術カンファレンスなどで行った講演資料を公開しています。 【CAPCOM オープンカンファレンス プロフェッショナル RE:2023】 https://www.capcom-games.com/coc/2023/ 【CAPCOM オープンカンファレンス RE:2022】 https://www.capcom.co.jp/RE2022/ 【CAPCOM オープンカンファレンス RE:2019】 http://www.capcom.co.jp/RE2019/
仮想メモリアロケータ導入への 道のり 仮想メモリアロケータ導入への道のりと題して、 RE ENGINEのゲームラインタイムのメモリアロケータを仮想メモリ管理にした点をお話します。 -------- mimalloc Copyright (c) 2018-2021 Microsoft Corporation, Daan Leijen Released under the MIT License: https://opensource.org/license/mit/ Microsoft, Windows, and Microsoft Teams are trademarks or registered trademarks of Microsoft Corporation in the United States and other countries. Screenshots of Microsoft products are used with permission from Microsoft. ©CAPCOM 1
はじめに Street Fighter 6やEXOPRIMALでは仮想メモリアロケータを採用 他のタイトルでは別のメモリアロケータを利用(後述) RE ENGINEにおけるメモリ管理方法をご紹介しつつ、 多様なゲームジャンルを支えるメモリアロケータについて解説 このセッションではメモリアロケータの話題を取り扱います。 2023年に入ってから発売されたStreet Fighter 6やEXOPRIMALでは、仮想メモリアロケータが採用されています。 2 これら以外の既発売タイトルでは、別のメモリアロケータが使われています。 こんにちのRE ENGINEには複数のメモリアロケータが存在し、最も若い実装が仮想メモリアロケータです。 多様なゲームジャンルを支えるメモリアロケータの戦略についてお話します。 ©CAPCOM 2
アジェンダ • メモリアロケータ概要 • メモリ管理の方式 • ヒープアロケータ概要 • 仮想メモリアロケータ • まとめ・将来への展望 アジェンダです。 メモリアロケータの概要やメモリ管理方式などをお話したうえで、 カプコンで運用されているアロケータ本体について話を移していきます。 ©CAPCOM 3 3
メモリアロケータ概要 まずは、メモリアロケータ概要です。 4 ©CAPCOM 4
メモリアロケータとは 確保、解放されるメモリ領域を切り出すプログラム C++でnew, deleteすると呼び出される ゲーム内では様々な要素がメモリを要求する mMesh = new Mesh(); mDrawPoints = new vec3[1024]; mBufferPtr = std::make_unique<u8[]>(1024); メモリ領域 メモリアロケータとは、確保・解放されるメモリ領域を切り出すプログラムのことです。 C++で言うと、たとえばnew, deleteをしたときに呼び出されます。 5 ゲーム内では、画面上に映っている見えるオブジェクトに限らず、ゲームの状態管理をしているプログラムやエンジン内の処理など、 様々な要素が常時メモリを要求してきます。 ©CAPCOM 5
メモリアロケータに求められる水準 ゲーム実行中は多数のメモリ確保・解放が走る 実行速度へのシビアな要求 限りあるメモリ空間を有効に使うことも求められる 使わない 使わない メモリ領域 使わない メモリ領域 ゲーム中は、多数のメモリ確保・解放が常に行われています。 つまり、メモリアロケータが遅いとゲーム全体の実行速度が低下することを意味し、軽量であることが求められます。 6 かといって、雑な処理でよいわけではありません。 このように固定サイズで割り当てればカウンタを進めればよいので高速ですが必要なサイズが割り当てサイズに満たない場合、 領域を無駄にします。 現代のゲーム専用機はページスワッピングがサポートされていないことが多く、 PCと比較すると扱うことのできるメモリ領域に厳しい制約があります。 無駄な領域をゼロにすることは難しいですが、軽量であるだけでなく、 限りあるメモリ空間を余すところなく使うことも求められます。 ©CAPCOM 6
RE ENGINEでの取り組み RE ENGINEではメモリアロケータを内製 パフォーマンスが予見できるうえ、使い方に合わせた最適化が可能 リーク検出やプロファイリングの処理を導入しやすい このような厳しい要求を満たすため、RE ENGINEではメモリアロケータを内製しています。 内製することでパフォーマンスが予見でき、最適化の余地が生まれます。 7 また、リーク検出やパフォーマンスプロファイリングなどの開発効率化に繋がる処理も導入しやすいといったメリットもあります。 ©CAPCOM 7
断片化とゲームによるメモリ確保の傾向 さて、ここからの話題で触れる用語の認識を揃えるために、断片化そしてゲームによるメモリ確保の傾向をみていきます。 8 ©CAPCOM 8
メモリの断片化とは メモリ全体に空き領域と使用中の領域が点在している状態 見かけ上の空き容量すべてを確保できない 断片化が進行するとプログラムは続行不能に陥る 本講演では、メモリの断片化、あるいは単純に断片化という語句は、 メモリ領域の中に空き領域と使用中の領域がバラバラに存在している状態を指します。 このようにメモリ領域があり、青色のメモリ領域が使われているとします。 9 この場合、空き領域がとびとびになっており、見かけ上の容量よりも少ないメモリしか確保できません。 たとえば、メモリブロック3つぶんの領域を確保したくても、連続している空き領域が足りません。 断片化が徐々に進行すると、メモリ確保に失敗してプログラムは続行不能に陥ります。 ゲームごとの特性についても見ていきましょう。 ©CAPCOM 9
断片化が起こりづらいゲーム 対戦格闘・ステージ攻略型のゲーム 読み込むものと流れが決まっていることが多い ※同じゲームでもステージやキャラクターによってメモリ確保の傾向にばらつきがある メモリはきれいな状態が維持されやすい 対戦格闘やステージ攻略型のゲームでは、読み込むものやステージ内の導線がある程度決まっていることが多く、 メモリはステージの出入りに合わせてきれいな状態が維持されやすいです。 10 ©CAPCOM 10
断片化が誘発されやすいゲーム ユーザーの動きをコントロールできないオープンフィールド型のゲーム ランダム性に富んだオンライン要素を持つゲーム メモリは事前に決定できない確保・解放が頻発し、寿命の制御も難しい 他方、オープンフィールド型のゲームであったり、オンライン要素のあるゲームではユーザーの動きをあまり縛ることができず、 メモリブロックの寿命を事前に決められる範囲が限られており、ある場所で確保したメモリがずっと保持され続けるなど、 ややランダム性の強いものとなります。 11 ©CAPCOM 11
RE ENGINEでのメモリ管理方式 認識が揃ったところで、RE ENGINEの内部の話に入っていきます。 メモリをどのようにプログラム中で管理しているかという点からお話します。 12 ©CAPCOM 12
メモリ管理方式 メモリ予算分の領域を”セグメント”に分割 メモリアロケータはセグメントごとに存在 メモリの全体的な使用状況を俯瞰できる 断片化はある程度コントロールされる Default Permanent メモリ領域 Resource Develop Temp ScopedMemorySegment segment(Temp); // このメモリはTempセグメントから取得 auto scratchPtr = new u8[1024]; RE ENGINEではメモリ予算分全体の領域を、セグメントと呼ばれる単位に分割します。 それぞれのセグメントのサイズはタイトル制作者が決定します。 セグメントの例としては、 一般的なメモリを格納するDefault、 シングルトンやマネージャーといった永続的なメモリブロックが入るPermanent、 一時的に使うメモリのためのTemp、 シーンデータやモーションなどのゲーム内リソースを配置するResource、 といった形です。セグメントごとにメモリアロケータのインスタンスが存在します。 コード上では、右下のように動的にセグメントを切り替えて確保します。 13 この割り当て方式には、どういった用途のメモリが多く使われているか一目でわかりやすい利点があります。 また、用途ごとにメモリ領域が分離しているため、断片化はある程度自然にコントロールされます。 ©CAPCOM 13
ヒープアロケータ 続いて、メモリを実際に切り出すアロケータの一種であるヒープアロケータの説明に入ります。 このヒープアロケータは、仮想メモリアロケータが登場する前のすべてのRE ENGINEタイトルで採用されていたものです。 14 ©CAPCOM 14
ヒープアロケータの初期化から終了まで 起動時に予算分のメモリをすべてOSから確保 プロセス終了まで返却しない OSから全容量の領域を確保 プロセス起動 ゲーム実行中 プロセス終了 Default Permanent Resource Develop Temp OSに返却 RE ENGINEのヒープアロケータは、プロセス起動時に予算分のメモリをすべてOSから確保します。 セグメントごとのアロケータが連続したメモリアドレスを予算ぶんだけOSから取得しますので、 この図で言うとOSへのメモリ確保要求は5回のみ走ります。 15 そして、ゲーム実行中はプログラム内のメモリ割り当て要求に応じてメモリブロックを切り出していきます。 プロセスが終了すると、各セグメントのアロケータはOSへメモリを返却します。 ©CAPCOM 15
ヒープアロケータの利点 速度面で有利 システムコールが発生しない 最大限のページアライメントに配置 デバッグのヒントを得られる クラッシュ時のレジスタから関連領域の推測ができる 隣接するメモリからヘッダ情報を探しやすい Permanent Header Header Overrun! Linear address 実行時間の予測が難しいシステムコールをゲーム実行中に一切行わない点は、速度上のアドバンテージです。 また、最大限の適切なページアライメントが適用できることでTLBミスが最小化される点も実行速度に寄与します。16 さらに、ヒープアロケータが管理するメモリ領域は起動時に確定し、 ゲーム実行中には変動しないという特徴はデバッグにも役立ちます。 クラッシュした際のレジスタ情報から、関連するセグメントを割り出すことができます。 ©CAPCOM 16
ヒープアロケータの利点 速度面で有利 システムコールが発生しない 最大限のページアライメントに配置 デバッグのヒントを得られる クラッシュ時のレジスタから関連領域の推測ができる 隣接するメモリからヘッダ情報を探しやすい Permanent Header Header Overrun! Linear address レジスタに入っているアドレスがこの赤いメモリブロックを指していれば、 アドレス範囲からPermanentセグメントを参照していることがわかります。 セグメントから割り当てられたすべてのメモリブロックにはヘッダ情報が付いており、 確保元を辿るための情報も埋め込まれています。 17 そのため、オーバーランで破壊された領域に隣接するメモリブロックからヘッダ情報を探すことで、 こういった厄介なバグも早期に原因特定を行うことが可能です。 ©CAPCOM 17
ヒープアロケータの課題 用途ごとに容量が固定されている オープンワールドのようなゲーム作りでは不利に働きやすい ゲーム全編を通して範囲内に収まるように調整 このように利点が多くあるヒープアロケータにも、開発規模の拡大に伴って課題が出てきました。 大きく分けて2つあります。 ひとつは、用途ごとに容量が固定されていることです。 特にオープンワールドであったり、一つのゲーム内に複数のゲームモードがある場合には 18 このように、全く異なるメモリ消費状況となるケースも珍しくありません。 しかし、容量は用途ごとに固定されているため、ゲーム全編を通して範囲内に収まるように調整する必要があります。 ©CAPCOM 18
ヒープアロケータの課題 断片化に弱い Used : 24MB 60MB Free : 40MB 4MB Max.Avail: 16MB 4MB もう一つの課題は、断片化に弱いことです。 セグメントという概念によって用途ごとに分離されていることである程度の断片化は自然と抑えられていますが、 19 メモリブロックが残置されてしまう状況になると、 アロケータ内の数値としては空き領域が40MBあるのに連続領域としてはもっと少ないメモリしか確保できなくなります。 こうならないようにゲーム制作のメモリ確保と解放は徹底する必要があるものの、オープンワールドの要素があるゲームなど、 状況によっては避けられないケースもあります。 ©CAPCOM 19
仮想メモリアロケータ そこに、仮想メモリアロケータがやってきます 20 ©CAPCOM 20
仮想アドレスと物理アドレス プログラム上で普段触れているメモリアドレス=仮想アドレス 仮想アドレスに物理アドレスがマップされている 仮想アドレス 物理アドレス memset(bufferPtr, 0xff, size); 0x1fbcc000 ~ 0x1fbcd000 0x1000 ~ 0x2000 position->x += 1.0f; 0x20001000 ~ 0x20002000 0x2000 ~ 0x3000 0x70004000 – 0x70005000 0x3000 ~ 0x4000 int v = *srcPtr; 仮想メモリアロケータの話題に入る前に、仮想アドレスと物理アドレスについて触れておきます。 普段、プログラムから直接扱っているのは仮想アドレスです。 この仮想アドレスに物理アドレスがマップされることで、実際のメインメモリにアクセスできます。 21 たとえば、このようにバッファに0xffを書き込むプログラムが走るとき、bufferPtr変数に入っているアドレスは仮想アドレスです。 この仮想アドレスは、メインメモリ上の物理アドレスに紐づけられています。 実際の0xffの値は、仮想アドレスを経由して物理アドレスに書き込まれます。 他にも、構造体やクラスのメンバに値を加算するときの読み書きをしたり、ポインタ変数経由でデータを読み取るときも同様です。 ©CAPCOM 21
断片化との関係 ヒープアロケータのマッピング振り返り 仮想アドレスと物理アドレスのマッピングを起動時に行う 仮想アドレス空間内の断片化がメモリ領域の断片化に直結 Linear address Used : 24MB Free : 40MB Max.Avail: 20MB ヒープアロケータは仮想アドレスと物理アドレスのマッピングを起動時に行います。 そのため、仮想アドレス空間内の断片化が利用可能なメモリ領域の断片化に直結していました。 22 仮想アドレスと物理アドレスのマップ操作を細かく行うことで、この断片化はある程度解消することが可能になります。 ©CAPCOM 22
断片化の解消方法 仮想アドレスと物理アドレスのマップ操作はページ単位で行うことが可能 物理アドレスは離れた場所からかき集めることができる 物理アドレス 仮想アドレス u8 delete[] p = new p; u8[8192]; 0x10000000 ~ 0x10001000 0x10001000 ~ 0x10002000 0x01000 ~ 0x02000 0x02000 ~ 0x03000 0x03000 ~ 0x04000 仮想アドレス空間は広大なため断片化は無視できる 物理メモリ領域とは比較にならない広大な仮想アドレス空間が利用可能 仮想アドレスと物理アドレスのマップ操作はページと呼ばれるサイズの単位で行うことができます。 ページの大きさは4KiB,16KiB,64KiBなどで、プラットフォームによって異なります。 ページが4KiBの環境で8KiBのメモリを確保する場面を考えます。 仮想アドレスは連続しています。 23 一方の物理アドレスは他の場所から参照されている領域があり、断片化しています。 それでも、ページ単位でマップすることができるため、問題ありません。 ©CAPCOM 23
断片化の解消方法 仮想アドレスと物理アドレスのマップ操作はページ単位で行うことが可能 物理アドレスは離れた場所からかき集めることができる 物理アドレス 仮想アドレス u8 delete[] p = new p; u8[8192]; 0x10000000 ~ 0x10001000 0x10001000 ~ 0x10002000 0x01000 ~ 0x02000 0x02000 ~ 0x03000 0x03000 ~ 0x04000 仮想アドレス空間は広大なため断片化は無視できる 物理メモリ領域とは比較にならない広大な仮想アドレス空間が利用可能 このように、物理アドレスはメモリじゅうから空いているページをかき集めて使うことができます。 メモリ解放時に物理アドレスとのマップを解除しておけば、別の用途で再利用されます。 24 なお、プロセスが扱う物理アドレス空間がGBのオーダーに対して、 64bitプロセスであれば仮想アドレス空間はTBオーダーで存在します。 そのため、仮想アドレス空間の断片化はあまり深く考える必要はありません。 ©CAPCOM 24
セグメントは管理上の概念へ サイズ割り当てはメモリ領域の分離を意味しない 実際には空いている物理メモリを有効活用 メモリ領域 割当サイズ (標準予算) あるシーン 別のシーン Default Default Permanent Permanent Default Resource Resource Perma nent Resource Develop Develop Develop Temp Temp Temp また、これによって解決されるのは断片化だけではありません。 仮想アドレスと物理アドレスが分離したことで、セグメントごとのサイズ割り当てはメモリ領域の分離を意味しなくなります。 25 数値の上では区分けされていても、空いている物理メモリを拾ってくればメモリ割り当てには成功しますので、 実質的にセグメントのサイズは可変になります。 セグメントは、何がどれだけメモリを使えるのかという予算を示す管理上の目安となります。 ©CAPCOM 25
仮想メモリアロケータの需要の高まり 開発が大規模化・多様化していく中で、先に説明した仮想メモリアロケータを求める声は多くみられるようになってきました。 26 ©CAPCOM 26
仮想メモリアロケータ ヒープアロケータに仮想アドレスと物理アドレスのマッピングを追加 不要になった領域はOSへ返す 物理アドレスが断片化していても、合計領域が足りれば確保に成功 懸念 TLBキャッシュミスのパフォーマンスへの影響 システムコールのオーバーヘッド どの程度の粒度でOSに返すべきか 仮想メモリアロケータに関するノウハウが皆無 VRAMアロケータの知見はそのまま転用できない では、仮想メモリアロケータの導入について考えていきます。 原理を考えれば、ヒープアロケータに仮想アドレスと物理アドレスの動的なマッピング機能を追加すれば良さそうです。 27 不要になったメモリをOSに返しておけば、物理アドレスが断片化していても合計領域が足りれば確保に成功します。 しかし、懸念も存在します。 たとえば、TLBキャッシュミスのパフォーマンスへの影響や仮想アドレスと物理アドレスのマッピング状態を操作するシステムコー ルのオーバーヘッドに関する知見がほとんどない状態からのスタートでした。 VRAMは早々に仮想アドレスと物理アドレスを分離した操作に移行していましたが、CPUよりも扱う粒度が大きいため、 そのまま素直に参考にすることはできませんでした ©CAPCOM 27
仮想メモリアロケータ導入のネック 知見が乏しい中でヒープアロケータを拡張するのはリスクを伴う 既発売タイトルを用いた検証だけでは不安は払拭できない 後年に発売されるタイトルほどハードウェアを使い切る物量 既存のライブラリを組み込む方針 ライセンスが明確でありクローズドソースの開発に適していること パフォーマンスがヒープアロケータから乖離しないこと 移植性に優れていること 実績があること このような状況下で、ヒープアロケータを拡張して仮想メモリアロケータを実装することには大きなリスクが伴います。 RE ENGINEを採用したタイトル数は10を超え、タイトル開発チームはハードウェアの性能を引き出すつくりをするようになってき 28 ました。 そのため、既発売タイトルを用いた検証だけでは不安を払しょくできません。 かといって、悠長に構えていると時間だけが過ぎ去っていきます。 そこで、既存のライブラリを組み込む方針を選択しました。 ライブラリに求めた要件は次のとおりです。 ライセンスが明確であり、クローズドソースのRE ENGINEに組み込みやすいこと パフォーマンスがヒープアロケータから乖離しないこと 移植性に優れていること すでに何らかの実績を持っていること ©CAPCOM 28
仮想メモリアロケータの実装選定 いくつかの候補から、最も有力そうなものを組み込んで検証 mimalloc 2.0.3を採用 1.x系列よりも少ないメモリオーバーヘッド MITライセンス マルチコアにスケールするロックレスなメモリ管理戦略 移植性を考慮した設計 検証の結果、条件に合うメモリアロケータとしてmimallocの採用に至りました。 正確なバージョンとしては 2.0.3 としています。 安定版とされているバージョン1系列よりもメモリ空間のオーバーヘッドが小さいことが理由です。 29 ライセンスはMITライセンスです。 RE ENGINE採用タイトルではWebマニュアルまたはゲーム内で権利表記されています。 mimallocはマルチコアにスケールするロックレスな戦略をとる設計です。 マイクロベンチマークを用いたパフォーマンス調査においてヒープアロケータに肉薄したことも重要なポイントでした。 具体的なパフォーマンスは最後にご紹介します。 ©CAPCOM 29
ゲームプラットフォームへの移植性 Windows, macOS, POSIX, wasmの実装は標準で存在 移植性が考慮されている証 VirtualAlloc /VirtualFreeに相当する関数を実装すれば動作 仮想アドレスの確保・解放 物理アドレスのマップ・アンマップ 現代のOSではほとんどの場合、相当するAPIが提供されている 移植性については、Windows, macOS, POSIX, wasmの実装が存在しており、十分に移植性が高いコードベースです。 細かいことを除けば、Windows APIであるVirtualAlloc /VirtualFreeに相当する関数を実装すれば動作します。 30 これらのAPIは、仮想アドレスの確保・解放、物理アドレスのマップ・アンマップを司るものです。 現代のOSではほとんどの場合、相当するAPIが提供されています。 ©CAPCOM 30
遭遇した課題と解決方法 ここからは、mimallocを導入するにあたって遭遇した課題と解決方法をご紹介します。 あくまでRE ENGINEにmimallocを導入したときの話であり、一般的なWindowsアプリケーションに導入するものとは事情が異なり 31 ます。 メモリ制約の厳しいプラットフォームで、そこそこアロケーションヘビーなゲームに適用して動かす場合の情報としてお聞きくださ い。 ©CAPCOM 31
実装上の課題 物理メモリ容量にゆとりのある環境 またはメモリスワッピングを前提としたメモリ管理 一度確保したメモリはほとんどOSに返さない スレッドごとにメモリをOSから確保 終了済みスレッドのメモリ解放が大きく遅延する 実質的に利用できないメモリ空間が大きい(~30%) 最初に遭遇したのは、物理メモリ容量がかなり広大であるか、メモリスワッピングを前提としたかのようなメモリ管理が行われてい る点です。 32 一度確保したメモリはほとんどOSに返されず、スレッドごとにメモリをOSから確保するため他のスレッドとのメモリ共有も効かず、 また、実行が終了したスレッドのメモリ解放も楽観的に扱われています。 結果として、無駄になるメモリ空間の領域は全体の3割に届こうとしているレベルでした。 それぞれの課題についてみていきます。 ©CAPCOM 32
課題:メモリをほとんどOSに返さない パフォーマンス上の最適化戦略に依るもの 都度、確保・返却しているとOSに負荷がかかる ゲーム機では物理メモリが小さく、メモリスワッピングも利用できない できるだけ返すようにオプションを設定 早期返却するようソースコードを書き換え // mimallocに用意されているオプション mi_option_disable(eager_commit); mi_option_set(segment_commit_delay, 0); mi_option_set(reset_delay, 25); // 次の機能を拡張 segment_cacheに上限を設定できるように page_free, page_retireを即時実行 page_free_collectの範囲拡張 メモリをほとんどOSに返却しないのは、mimallocの最適化戦略によるものです。 パフォーマンスを稼ぐため、一度OSから取得したメモリは返却されません。 物理メモリ容量が小さく、メモリスワッピングが利用できるとは限らない環境には適さないため、 できるだけ返すようにオプションを設定するほか、早期返却を促すようにソースコードを書き換えています。 ©CAPCOM 33 33
課題:スレッドごとにメモリをOSから確保 全てのメモリ確保はmi_heapのインスタンスを経由 mi_heapのインスタンスがスレッドごとに作成される mi_heap内のメモリは別スレッドからアクセス不可 OSへ返却されるまでは確保したスレッド(mi_heap)が独占 空き領域となったメモリも他スレッドからは利用不可 mimallocでは、全てのメモリ確保はmi_heapのインスタンスを経由します。 mi_heapはOSから確保したメモリを切り盛りします。 mi_heapのインスタンスはスレッドごとに作成されます。 スレッドごとにメモリを保持する戦略が、高い並列性を実現しています。 34 さて、ここからが問題です。 一度mi_heapに入ったメモリはOSへ返却されるまでは、確保したスレッドが独占します。 仮に使われていないメモリ領域があっても、別スレッドのmi_heapからはアクセスできないため、 見かけ上の空き領域と実際に使うことのできる空き領域に差が生まれます。 ゲーム内には数十を超える本数のスレッドが動作するため、このオーバーヘッド領域を削減しないとメモリが容易に枯渇します。 この問題は次のように解決しました。 ©CAPCOM 34
解決策:低優先スレッドを論理的に統合 ゲームループに紐づいたパフォーマンス重視のスレッド プロセス内の本数は少ない スレッドごとにヒープを持ちmimallocの持ち味を活かす 低優先ワーカースレッド ミドルウェアの外部スレッド 論理的な1本のスレッドとして管理 ロックフリーの個所に適宜、ロックを挿入 実行パフォーマンスよりもメモリ空間効率を優先 RE ENGINEにはゲームループに紐づいた、パフォーマンス上重要なスレッドと多少、実行が遅延しても影響のないスレッドがあり、 パフォーマンス重視のスレッドは本数が少なく、その数のコントロールも容易であることからmi_heapをスレッドごとに持たせます。 35 他方の低優先のワーカースレッドやミドルウェアのスレッドはひとつにまとめて、 論理的な1本のスレッドと見立てて同期をとりながらメモリ確保するようにしました。 これによって、メモリ空間の無駄な領域は大幅に抑えられることとなりました。 ©CAPCOM 35
課題:終了済みスレッドのメモリ解放 終了済みスレッドのヒープ情報をリンクリストで管理 ゲームループとは独立した区間で解放 // mi_heapfree()に増設したコールバック void re_mi_callback_heapfree(mi_heap_t* heap) { // 解放できるメモリをすべて手放す re_mi_heap_disable_delayed_free(heap); re_mi_heap_release_unused(heap); // 空でなければ管理リストに接続してバックグランドでGC if (!re_mi_heap_is_empty(heap)) { re_mi_heap_enqueue_abandoned(heap); } } 通常、終了済みスレッドに紐づいていたmi_heapのメモリは遅延解放されますが、 RE ENGINE内で破棄済みmi_heapを双方向リンクリストで管理し、 ゲームループとは独立したタイミングで早期解放を行うようにしています。 36 なお、先のスライドで説明した論理スレッドの概念が入ってからはこの処理を使うスレッドはほとんどありません。 ©CAPCOM 36
課題:大きなメモリ空間のオーバーヘッド 対策は入れたものの、もともとmi_heapは広いメモリを確保する パフォーマンス上のメリットよりもメモリ空間効率が気になる メモリ確保粒度をパフォーマンスとのバランスが取れるレベルに調整 #define MI_SEGMENT_SLICE_SHIFT (11 + MI_INTPTR_SHIFT) // Segment Size: 8MiB #define MI_SEGMENT_SHIFT (8 + MI_SEGMENT_SLICE_SHIFT) // Medium Page: 128KiB #define MI_MEDIUM_PAGE_SHIFT (3+MI_SEGMENT_PAGE_SHIFT) // Small Page: 16KiB #define MI_SEGMENT_PAGE_SHIFT (MI_SEGMENT_SLICE_SHIFT) これまで挙げてきた対策を入れても、物理メモリが小さいプラットフォームでは、 mi_heapが広いメモリを扱うことになっている点が問題となりました。 OSからのメモリ確保粒度をパフォーマンスとのバランスが取れるレベルに調整しています。 これらの数値に設定することでプログラム的に破綻する個所があれば、そこにも手を入れています。 37 この数値にすると、結果としてSmall Object扱いされる最大サイズが4KiBに制限されますが、 RE ENGINEの特性としてゲームループで集中的にメモリが確保される局面では4KiB以内のサイズが支配的であることを意識しての 設定になっています。 参考にされる際は、ここの数値設定は鵜呑みにせずお手元の環境のメモリ確保傾向を収集したうえで具体的な数値をご検討ください。 ©CAPCOM 37
新課題:システムコールの頻発 システムコールの実行コストは予測が難しい スパイクはできるだけ避けたい 60fps固定の格闘ゲームでは品質に直結 LRU方式でマップ済みメモリをキャッシュ LRU=Least Recently Used 70%程度のヒット率 struct mapped_t { u64 packed_virtual_addr : 44; u64 page_count : 16; u64 sparse : 1; u64 misc : 3; }; constexpr size_t LRU_Entries = 16; mapped_t mRecentPages[LRU_Entries]; これまでの取り組みを行うと、メモリをマップするシステムコールの発行数が増えてきました。 システムコールの実行コストは予測が難しく、 ある程度コントロールしないとゲームループ内でスパイクを誘発する恐れがありました。 38 ゲームループ内のスパイクは、フレームレートが固定の格闘ゲームでは品質に直結する問題です。 そこで、LRU(Least Recently Used)方式でマップ済みメモリをキャッシュするようにしました。 結果としてキャッシュヒット率は70%程度に上り、システムコール数の削減に繋がりました。 ©CAPCOM 38
実装上の課題 WindowsのVirtualAlloc /VirtualFreeを前提とした呼び出し マップ済み領域を跨いだマップ要求 マップ済み領域の一部のマップ解除 マップしたメモリはゼロクリア保証 他にも課題はあります。 WindowsのVirtualAlloc /VirtualFree APIはリッチな仕様を持っており、 マップ済み領域を跨いだマップ要求やマップ済み領域の一部のマップ解除が可能です。 39 さらに、マップしたメモリはゼロクリアが保証されています。 mimallocはゼロクリア保証を前提とした作りになっています。 ©CAPCOM 39
課題:リッチなVirtualAlloc/VirtualFree仕様 仮想メモリ・物理メモリの管理を自力で行うことで解決 仮想アドレス・物理アドレスともに64KiBごとに切り出すシンプルな実装 マッピング状態を簡易ページテーブルで管理 mimallocからの複雑な要求を再解釈 システムコールが直接サポートしないメモリマッピング要求に対応 struct page_t { u64 continuous_page_count : 12; u64 misc : 4; u64 packed_virtual_addr : 48; }; page_t mMappedPages[MaxPhysicalMem >> 16]; VirtualAlloc /VirtualFreeと同じ仕様を移植対象プラットフォームのAPIが持っていることが期待できないため、 仮想メモリと物理メモリの管理を自力で行うことで解決しました。 物理アドレスのアライメント保証や連続性の担保は別途行っていますが、 基本的にはリニアに64KiBごとに切り出すシンプルな実装としています。 マッピング状態の管理は簡易的なページテーブルを保持して管理しました。 40 これにより、mimallocから発行される要求を簡易ページテーブルと照らし合わせて再解釈し、 システムコールを分割発行やキャンセルすることができるようになりました。 ©CAPCOM 40
課題:マップしたメモリのゼロクリア保証
プラットフォームごとの高速メモリクリアを実装
_mm256_stream_si256() + ループアンロール
ページ単位の割当はIntrinsicのアライメント要求を常に満たす
mimallocのメモリアクセスパターンではNon-Temporalな書き込みが有効
一般的なmemset()よりも高速
for (size_t i = 0; i < sz >> 5; i += 8) {
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
}
+ 0], kZero256);
+ 1], kZero256);
+ 2], kZero256);
+ 3], kZero256);
+ 4], kZero256);
+ 5], kZero256);
+ 6], kZero256);
+ 7], kZero256);
// 32
// 64
// 96
// 128
// 160
// 192
// 224
// 256
マップしたメモリのゼロクリア保証については、プラットフォームごとに高速なメモリクリアを実装することで十分に間に合いまし
た。
41
Windowsではバックグラウンドスレッドが空いているメモリページをゼロクリアしてプールすることで高速なゼロクリア済みメモリ
の提供を実現していますが、そこまで頑張る必要はありませんでした。
例示しているコードはAVX命令を使ってメモリクリアするルーチンです。
実際には、移植先プラットフォームのCPUのバリエーションぶん実装しています。
このコードがlibcのmemset()と異なる点は大きく2点あります。
ひとつは要求アドレスとサイズがページ単位に整列している保証があるため、
メモリクリアルーチンによく見られる、先頭と終端のアライメント調整が不要なことです。
©CAPCOM
41
課題:マップしたメモリのゼロクリア保証
プラットフォームごとの高速メモリクリアを実装
_mm256_stream_si256() + ループアンロール
ページ単位の割当はIntrinsicのアライメント要求を常に満たす
mimallocのメモリアクセスパターンではNon-Temporalな書き込みが有効
一般的なmemset()よりも高速
for (size_t i = 0; i < sz >> 5; i += 8) {
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
_mm256_stream_si256(&addr256[i
}
+ 0], kZero256);
+ 1], kZero256);
+ 2], kZero256);
+ 3], kZero256);
+ 4], kZero256);
+ 5], kZero256);
+ 6], kZero256);
+ 7], kZero256);
// 32
// 64
// 96
// 128
// 160
// 192
// 224
// 256
もうひとつは、CPUキャッシュを汚さないNon-Temporalな書き込みをしている点です。
mimallocはゼロクリアされることを期待してマッピング要求を行いますが、
実際にマップされたメモリ領域のうち即座に参照されるのは先頭ページくらいであり、
残りはCPUのサイクルからすると相当未来の話になります。
©CAPCOM
42
42
実装上の課題 確保スレッドと解放スレッドが異なるのはレアケース扱い 解放キューに積まれて実際の解放は遅延される GC処理の呼び出しが必須 RE ENGINEでは多くのケースで該当 バックグラウンドスレッドで初期化したものをゲームループで解放 ゲームループ内で確保したものを別のワーカースレッドで解放 mimallocでは、あるスレッドのmi_heapに別スレッドからメモリが返却されることが特殊ケースとして処理されます。 返されたメモリは解放キューに積まれます。 解放キューに積まれたものはmi_heapに対するGC処理が必要となります。 43 RE ENGINEではバックグラウンドスレッドで初期化したものをゲームループで解放したり、 ゲームループ内で確保したものを別のワーカースレッドで解放することが多くあります。 GC処理をいつ、どの粒度で実行するかはパフォーマンスの観点からとても重要です。 ©CAPCOM 43
課題:別スレッドでのメモリ解放 解放キューに積まれる挙動に統一 命令キャッシュヒット率の改善 GC処理は所有スレッドのメモリ確保時にインクリメンタルに実施 メモリ解放処理の速度に重きを置いた最適化 メモリ確保は徐々に走る一方で、 解放は一気に行われることが多い void* allocate(size_t sz, size_t align) { mi_heap_t* currentHeap = re_mi_get_default_heap(); s32 collectionSteps = 100; re_mi_heap_collect(currentHeap, collectionSteps); mi_heap_malloc(currentHeap, sz); // … この課題の解決方法としては、まず解放スレッドがいずれであっても解放キューに積む実装に統一しました。 これは条件分岐を消去し、メモリ解放ロジックのコードサイズを小さくし、結果的に命令キャッシュヒット率を向上させました。 44 そのうえで、GC処理はmi_heapの所有権を持つスレッドで、メモリ確保時にインクリメンタルに実行します。 GCで処理されるオブジェクトのステップ数を数え、一定の水準に達するとGCを打ち切るものです。 メモリ確保のタイミングで負荷はかかりますが、RE ENGINEの典型的なメモリ確保パターンとしては、メモリ確保は徐々に走る反面、 解放は一気に、なおかつゲームループ内で行われることが多いことに着目した最適化です。 ©CAPCOM 44
パフォーマンス allocate > 1MiB allocate > 4KiB allocate <= 4KiB free > 1MiB free > 4KiB free <= 4KiB 1 4 16 64 256 1024 4096 16384 65536 Nano seconds (smaller is better) VirtualAllocator Average N=10,000,000 PlayStation 5 HeapAllocator 最後に、パフォーマンスです。 これは、既発売タイトルのゲーム内シーンで計測したものです。 ゲーム実行中に頻出する4KiB未満の確保・解放と、低頻度の1MiB以上の確保・解放それぞれ分けています。 45 低頻度でサイズの大きいメモリ確保では圧倒的な速度差が生まれています。 これはシステムコールのオーバーヘッドが影響しています。 一方で、頻出かつゲームループに影響する4KiB未満で見ると、 ヒープアロケータと比較してもゲーム実行に差し支えのないパフォーマンスが実現できています。 断片化への対策とセグメントを跨いだメモリ管理ができるメリットを加味すれば、悪くない選択と言えるでしょう。 ©CAPCOM 45
まとめと展望 まとめに入ります 46 ©CAPCOM 46
まとめ RE ENGINEは多様なジャンルのゲーム制作に応えるため 仮想メモリアロケータに対応し、柔軟なメモリ管理の時代へ mimallocを組み込むことで知見を蓄えつつタイトルリリースを実現 基本的な部分は恩恵をうけつつ、自社に合わせた拡張・最適化を行った 高品質なタイトル制作を支えるための 徹底した実行パフォーマンスと動作安定性の姿勢は受け継がれている RE ENGINEでは多用なジャンルのゲーム制作に応えるため、 仮想メモリアロケータに対応し、柔軟なメモリ管理の時代が到来しました。 47 つい最近までは内製のヒープアロケータのみでタイトル制作を支えてきました。 仮想メモリアロケータの知見がない状態からmimallocを組み込むことで知見を蓄えつつタイトルリリースを実現しました。 組み込むだけで得られる恩恵は大事にしつつ、自社の運用に合わせた拡張や最適化を行いました。 高品質なタイトル制作を支えるため、 実行パフォーマンスと動作安定性へのこだわりはヒープアロケータの時代から変わらず受け継がれています。 ©CAPCOM 47
今後の展望 一定の成果は見られたが、長い歴史で培われたノウハウの適用は部分的 パフォーマンスやメモリ空間効率にはまだできることがあるはず 完全内製の仮想メモリアロケータ RE ENGINEに最大限マッチするメモリ確保戦略 より少ないメモリ空間オーバーヘッド 培われてきたノウハウを最大限活用した最適化 よりよいゲーム制作のための惜しみない技術研究開発 この一連の取り組みでタイトルリリースという一定の成果は得られましたが、 ヒープアロケータの長い歴史で培われたノウハウの投入は限定的でした。 パフォーマンスやメモリ空間効率にはまだもっとできることがあるはずだと考えています。 48 そこで、完全内製の仮想メモリアロケータの実装に取り組んでいます。 RE ENGINEに最大限マッチするメモリ確保戦略を投じることで、 より少ないメモリ空間オーバーヘッドと高い水準のパフォーマンスを両立します。 カプコンでは、よりよいゲーム制作のための惜しみない技術研究開発が可能です。 ©CAPCOM 48
ご清聴ありがとうございました 49 ©CAPCOM 49