|
Deki 0.19.0
組み込みディスプレイとデスクトップのためのモジュール式 C++ 2D エンジン
|
デバイスで実行されるコードのメモリ確保は、すべてエンジンを通します。 エンジンのランタイムコードでも、パッケージのコードでも、new[]、malloc、heap_caps_* は使いません。Deki::Memory が全体を取りまとめ、ほとんどの場合に使うべきものは Deki::Buffer<T> です。
これは書き方の好みではなく、tools/ci/check_allocations.py によって強制されています。これを守らなければならない理由は 3 つあります。
<deki/providers/Buffer.h>。Deki::Memory を通して確保し、自分で解放する、所有権を持つ配列です。
自動化されていないのは、*メモリがあったかどうか*だけです。その後のすべての分岐で後始末をするのではなく、バッファを作成した場所で if (!buffer) を 1 回確認してください。
フレームアリーナでも失敗の処理はなくせますが、事前にサイズを決めておく必要があります。全画面のグラデーションのベイク 1 回分に足りる大きさのアリーナは、プロジェクトにグラデーションがあるかどうかにかかわらず 150 KB を確保します。ヒープが数百 KB しかないチップでは、存在しないかもしれない機能のためにメモリの半分を押さえることになります。アリーナは余裕がある環境で効果を発揮します。ここでのほとんどの確保は寿命が長く、いずれにしても何かが所有しています。グリフの表、ピクセルデータ、パーティクルの列、フレームバッファなどです。これは RAII が対応するもので、アリーナは対応しません。
<deki/providers/Memory.h>。デフォルトはなく、何も推測されません。 確保を行う呼び出しはすべて領域を指定し、省略するとコンパイルできません。
| 領域 | 確保先 |
|---|---|
| Deki::Memory::Internal | 内部 RAM。高速で、乏しい方です |
| Deki::Memory::External | ボードにあれば PSRAM。メガバイト単位ですが、SPI 経由でアクセスするので明らかに遅くなります |
Deki::Memory::AllocateDma(bytes, region) は、DMA エンジンがそのブロックを読めなければならないという制約を加えます。これは配置とは独立しています。フレームバッファは、ボードがそれを内部に置くか PSRAM に置くかにかかわらずアクセス可能でなければならないので、3 つ目の領域ではなく別のエントリーポイントになっています。
エンジンが提供するのは 2 つです。この集合は開かれています。
パッケージは自分の領域を Deki::Memory の中ではなく、自分の名前空間に置きます。クラスは閉じているのでどのみちそうするしかありませんが、それこそが狙いです。どちらも Rtc という定数を使いたい 2 つのボードパッケージは、言語が両者を分けてくれるので衝突しません。定数がどこにあるかは決して重要ではありません。領域の識別子はハッシュ化した名前なので、名前空間の間で移動しても何も変わりません。
Region() は、エンジンのほかの部分と同じ FNV-1a で名前をコンパイル時にハッシュ化するので、領域はプロバイダーが switch できる定数式になります。
2 つのパッケージが同じ領域名を使えば、それは同じ領域を意味します。 2 つのボードパッケージが調整なしで "rtc" について合意できるのはこのためで、2 つのパッケージがどちらも spi ケーパビリティを要求するのとまったく同じです。これが通用しないのは、2 つのパッケージが 1 つの単語で*異なる*ものを意味している場合です。そのため、自分で考えたものでない名前には修飾を付けてください: particles.scratch、esp32.rtc。確保のヘッダーには id しか保存されないので、デバッグビルドは同じハッシュになる 2 つの異なる名前を報告します。
パッケージは領域に*名前を付ける*ことはできますが、それを提供できるのはボードの IMemoryProvider だけです。esp32.rtc を知らないプロバイダーは nullptr を返し、失敗のメッセージには領域の名前が出ます。
別の領域で代用されることはありません。特に Rtc ではこれが普段以上に重要です。この領域の目的はディープスリープを越えて保持することなので、黙って通常の RAM を返すと、まさに保持されるべきときにデータが失われてしまいます。
ターゲットは、持っている領域を memory: を接頭辞としたケーパビリティとして公開します。
| ケーパビリティ | 由来 |
|---|---|
| memory:internal | すべてのプラットフォーム - 自動的に導出 |
| memory:external | プラットフォームの psramSize - 自動的に導出 |
| memory:esp32.rtc | ボードの provides。それを知っているのはボードだけなので |
そのため、領域を必要とするパッケージはそれを宣言し、ビルドは何かが実行される前に、それを持たないターゲットを名前を挙げて拒否します。
Region("esp32.rtc") と "memory:esp32.rtc" は、誰かが揃えておく 2 つのリストではなく、接頭辞を付けただけの同じトークンなので、両者がずれることはありません。ケーパビリティ名は小文字で、.、-、:、_ を使います。"WiFi" を宣言するプラットフォームは読み込み時に警告を受けます。文字列の完全一致では、黙って一度も一致しないことになるからです。
選んだ領域がいっぱいなら、確保は失敗して null を返します。もう一方の領域で再試行することはありません。これは意図的です。
失敗すると、それが何で、どれだけの大きさで、どの領域で、フォールバックを試みなかったことがログに出ます。どの呼び出し側も、もともと null を処理しなければなりません。
外部 RAM のないボードでは、すべてが内部に解決されます。これはフォールバックではなく、領域がそれしかないということです。
Deki::Memory (<deki/providers/Memory.h>)。Buffer<T> でカバーされない場合に使います。
確保を行う呼び出しはすべてこれを受け取りますが、ほとんど誰も書きません。
デバッグビルドではデフォルト引数が呼び出し箇所を取得するので、失敗は次のように表示されます。
渡す名前はなく、渡す方法もありません。手書きのタグは同じ確保に対する 2 つ目の識別子であり、誰かが 1 つ目と揃えておかなければなりません。しかも答えはコンパイラーがすでに知っています。1 つの関数内の 2 つの確保は、行番号で区別されます。
示されるのはエンジンではなく呼び出し側です。 Buffer<T>::Allocate と Memory::Allocate はどちらもコンテキストを受け取り、デフォルト値で上書きせずにそのまま渡すので、場所は Buffer.h ではなく*あなたが*呼び出しを書いた場所になります。テストがまさにそれを確認しています。これを間違えた版は、エンジン内のすべての確保についてもっともらしく見えるファイルを報告し、自信満々に役に立たないものになるからです。
何もコピーされません。 どのメンバーも静的記憶域期間を持つ文字列リテラルを指しているので、デバッグ用のリークの表がポインターを保持できます。
取得はすべてのビルドで有効です。そのコストは ESP32-S3 のファームウェアで 4,272 バイト、1.5 MB のイメージの 0.28% で、呼び出し箇所あたり約 40 バイトです。気にする必要がないほど小さいコストです。
フラッシュを取り戻す必要があるビルドでは、DEKI_STRIP_ALLOC_CONTEXT を定義します。AllocContext は空の構造体になり、パスも関数名もバイナリに入らず、失敗時には発生元を示さずに何をしようとしていたかを報告します。
取り除いたビルドと取り除いていないビルドではレイアウトが異なるので、エンジンとそれを呼ぶパッケージは一致している必要があります。これは MemoryStats がすでに課しているのと同じ制約です。
<deki/providers/Vector.h>。Buffer<T> は、確保時にわかっている個数を扱います。個数が変わり、固定の上限がない場合、デバイス上では std::vector は誤った選択です。push_back は operator new を呼びますが、これは例外が無効だとメモリ不足を報告できないので、abort してボードが再起動します。
Deki::Vector はそれと同じ形で、ここで重要な 1 点だけを変えたものです。確保しうるものはすべて bool を返します。
まずは Buffer を使ってください。ここでいう「動的な」データのほとんどは、プロジェクトがすでに知っている上限を持っています。また、固定の確保は、繰り返しの拡張のように小さなヒープを断片化させることがありません。
これらは operator new を呼び、Deki::Buffer<T> はそれを置き換えられません。デバイス向けのビルドでは、エンジンが演算子そのものを置き換えます (src/providers/OperatorNew.cpp)。これで 2 つのことが変わります。
これらもエンジンを通ります。operator new は IMemoryProvider::AllocateRaw を呼ぶので、ボードはデバイス上のすべての確保を把握でき、そのすべてに自分のヒープを使えます。
ただし、Memory::Allocate を通るわけでは*ありません*。そうすると、小文字列バッファを超えたすべての std::string の前に 16 バイトのヘッダーが付き、ベクターがこれから上書きするメモリを memset し、小さなブロックに ESP32 プロバイダーの 16 バイトの SIMD アラインメントのコストを払うことになります。しかも ESP-IDF はアラインメント付きの確保を余分に確保することで実装しているので、その無駄はヘッダーよりも大きくなります。AllocateRaw にはそのどれもありません。自然なアラインメントで、ヘッダーはなく、ヒープが返したポインターをそのまま返します。
計測結果。48 バイトの確保と解放の組を 1.6M 回、エンジンは -O2:
| ns/op | |
|---|---|
| malloc + free | 47-52 |
| プロバイダー経由 | 46-51 - 区別できない |
| プロバイダー経由 + DEKI_MEMORY_TRACK_RAW | 63-71 (+35%) |
つまり、経由させるコストはゼロなので常に有効です。バイト数の集計はオプトインです。ヘッダーがないとブロックのサイズはヒープから得る必要があり、Windows ではそれがロックの下で HeapSize を呼ぶ _msize になるためです。メモリの行き先を探しているときは DEKI_MEMORY_TRACK_RAW を付けてビルドしてください。Memory::LogStats がコンテナの保持量を報告します。付けない場合、その行は誤解を招くゼロを出すのではなく、集計していないことを示します。
ヘッダーとともに失われるのはそのマジック値です。そのため、コンテナのメモリの二重解放は、この経路が単なる malloc だったときと同じく診断できません。エンジンが記述できるものはすべて Allocate を通り、防止機能を保ちます。
配置はここではあえて決めません。削除された Auto ルールのようにサイズで振り分けると、大きな std::vector が黙って PSRAM に移り、コードからは見えない理由でホットループが遅くなってしまいます。外部メモリが欲しい呼び出し側は Deki::Memory::External を指定して Deki::Buffer を使います。operator new にはそれがわからないので、推測はしません。
これは __cpp_exceptions が未定義の場合、つまり abort の問題が存在するビルドでだけコンパイルされます。ホストやエディターのビルドは標準の演算子を使い続けます。過剰アラインメント版 (align_val_t) は、対になったまま保たれるように libstdc++ に任せています。
まとめると、実行時に変わる個数には Deki::Vector<T> を使い、false を処理してください。サイズがファイル、シーン、画面から来るものには Deki::Buffer<T> を使います。std::vector はエディターやツールのコードでは引き続き適切で、デバイス上でのメモリ不足は、黙った abort ではなくログに出る abort になりました。
<deki/providers/IMemoryProvider.h>。プラットフォームはこれを 1 つ用意し、Memory::Initialize() の前に渡します。
このインターフェースは外部 RAM について報告し (HasExternalRAM、合計、空き)、各領域から確保します: AllocateExternal / FreeExternal、AllocateInternal / FreeInternal、AllocateDMA。
配置に関する仮想関数はすべて純粋仮想です。すべて実装してください。 プロバイダーは、たとえ答えが「ここではほかと同じ」であっても、各領域がそのプラットフォームで何を意味するかを明示します。AllocateDMA に return malloc(size); と書いたデスクトップのプロバイダーは、読んで確認できる主張をしたことになります。
重要なのは AllocateDMA です。これを AllocateInternal から継承できるとしたら、両者が異なるチップでは、ディスプレイの周辺機器がアクセスできないメモリを渡すことになります。黙ってデータが壊れ、ビルドには何もそれを示すものがありません。
GetAvailableInternalRAM が必須なのも同じ理由です。本気でないのに 0 と答えるプロバイダーがあると、起動時のログ行がボードのメモリが尽きていると報告してしまいます。
純粋仮想関数は実装済みの関数と同じ vtable のスロットを占めるので、ABI のバージョンを上げる必要はありません。プロバイダーは、決めるまでコンパイルできなくなるだけです。
AllocateRaw / FreeRaw / GetRawBlockSize は 4 組目の組と問い合わせです。グローバルな operator new が経由する、自然なアラインメントの単純なアロケーターです。これにより、ボードのヒープが、エンジンが配置するすべてのものに加えて std::vector やスカラーの new にも使われます。デフォルトは malloc/free で、プラットフォームが答えられない場合 GetRawBlockSize は 0 を返します。その影響は LogStats の精度だけです。組の両方をオーバーライドするか、どちらもしないかにしてください。
AllocateDMA が AllocateInternal と別になっているのは、ESP32-S3 では内部 RAM の大部分は DMA でアクセスできるもののすべてではなく、アクセスできない領域のフレームバッファを読むディスプレイの周辺機器では、エラーではなく黙ってデータが壊れるためです。ESP32 のプロバイダーは明示的に MALLOC_CAP_DMA を要求します。AllocateInternal/FreeInternal のどちらかをオーバーライドする場合は両方をオーバーライドしてください。組は一致していなければなりません (ESP32 のプロバイダーは 16 バイトのアラインメントで heap_caps_aligned_alloc を使い (これによりブリットのディスパッチャーが S3 の SIMD カーネルを使えるようにもなります)、heap_caps_free を使います)。
IMemoryProvider はパッケージの ABI の一部です。 そのため、その仮想関数は tools/ci/check_abi.py によって固定されています。追加、削除、並べ替えをすると vtable のスロットが動くので、同じコミットで kDekiReflectionAbiVersion を上げる必要があります。
ランタイムコード内の new[]、delete[]、malloc/calloc/realloc、heap_caps_* で失敗します。すべてのパッケージだけでなく、エンジン自身も対象です。エンジン内の確保はすべてのボードのすべてのプロジェクトに届きますが、パッケージの確保はそれをインストールしたプロジェクトにしか届きません。むしろエンジンの方が重要な半分です。この lint の最初の版でエンジンを対象外にしたところ、すぐにそれが証明されました。ProjectSettings がデバイス上の設定のチャンクを素の realloc で保持していたのです。
対象外: editor/、tests/、generated/、third_party/、エンジンの platforms/editor と platforms/desktop のレイヤー。これらはすべて、例外が有効でヒープがデスクトップのものであるホスト上で動きます。パスによる除外もあります。これらはルールを破ってよいコードではなく、スタックの最下層だからです: providers/Memory.cpp、providers/IMemoryProvider.h、すべての *MemoryProvider.*、そして providers/OperatorNew.cpp。最後のものは、ほかが定義しなくて済むように operator new[] を定義しなければなりません。
照合の前にコメントと文字列リテラルは空白に置き換えられるので、このルールを説明するコメントで引っかかることはありません。
どうしても避けられない場合は、理由を付けて例外を認めます。理由のないものは数えられないので、どの例外が根拠を示したものかが diff で常にわかります。
この lint はすでに役に立っています。手作業の確認では見逃されていた、Standard2DRenderer 内の Deki::Memory のバッファに対する delete[] を見つけました。