DEKI/ENGINE ドキュメント
Deki 0.19.0
組み込みディスプレイとデスクトップのためのモジュール式 C++ 2D エンジン
読み取り中…
検索中…
一致する文字列を見つけられません
メモリ

ルール

デバイスで実行されるコードのメモリ確保は、すべてエンジンを通します。 エンジンのランタイムコードでも、パッケージのコードでも、new[]、malloc、heap_caps_* は使いません。Deki::Memory が全体を取りまとめ、ほとんどの場合に使うべきものは Deki::Buffer<T> です。

これは書き方の好みではなく、tools/ci/check_allocations.py によって強制されています。これを守らなければならない理由は 3 つあります。

  1. new[] はうまく失敗できません。 ESP-IDF は例外を無効にしてビルドされる (CONFIG_COMPILER_CXX_EXCEPTIONS が設定されていない) ので、確保に失敗すると __cxa_allocate_exception に到達して abort() が呼ばれ、ボードが再起動します。catch を追加する場所はありません。std::nothrow も役に立ち*ません*。libstdc++ は nothrow 版を、-fno-exceptions によって取り除かれた try/catch の中で例外を投げる版を呼ぶ形で実装しているので、まったく同じように abort します。これは、operator new[](unsigned int, std::nothrow_t const&) が例外を投げる版を直接呼んでいるバックトレースから判明しました。
  2. メモリをどこに置くべきかを知っているのはエンジンだけです。 そのボードに外部 RAM があるか、それがどれだけ遅いか、周辺機器がある領域にアクセスできるかは、呼び出し側ではなくプラットフォームの性質です。領域を自分で選ぶ呼び出し側は、次のボードで間違ったものになります。
  3. Deki::Memory は集計とリークの検査も担います。 統計、二重解放の防止、(デバッグビルドでは) リークの表はすべてその背後にあります。それを経由しない確保は見えなくなります。

Deki::Buffer<T> - これを使う

<deki/providers/Buffer.h>。Deki::Memory を通して確保し、自分で解放する、所有権を持つ配列です。

#include <deki/providers/Buffer.h>
Deki::Buffer<uint8_t> baked;
if (!baked.Allocate(width * height * 2, Deki::Memory::External))
{
DEKI_LOG_WARNING("no room for a %dx%d bake; not drawing it", width, height);
return false; // carry on without the thing, do not abort
}
RenderInto(baked.Data()); // Data(), Count(), Bytes(), operator[], begin/end
  • ムーブのみです。 コピーには失敗しうる 2 回目の確保が必要で、コピーコンストラクターはその失敗を報告できません。
  • T はトリビアルにコピー可能かつトリビアルに破棄可能でなければなりません。 どちらも static_assert で強制されます。ここでは要素の構築も破棄も行いません。これらは、エンジンがブリッターやドライバーに渡すピクセル、グリフ、スパン、サンプルの生のバッファです。デストラクターを持つ T は、そのデストラクターが黙って実行されないことになります。
  • 領域の指定は必須です。ほかの場所と同じです。
  • 個数が変わらない Allocate は何もしません。 そのため、描画のパスから毎フレーム呼んでも安全です。個数が異なる場合は、コピーせずに先に古いバッファを解放します。どの呼び出し側も直後にバッファを上書きしますし、2 つの確保を同時に持つことこそ、制約の厳しいチップでは許されないことだからです。
  • Allocate(0) は成功です。失敗ではありません。コードポイントのない疎なフォントも、スパンのないスプライトも、どちらも普通にあるものです。
  • 要素数はオーバーフローを検査されます。 count * sizeof(T) はデバイス上では 32 ビットの size_t で計算されるので、ファイルやシーンから読んだ個数が回り込んで小さな確保になり、後でバッファを越えて書き込む可能性があります。
  • Reset() は早めに解放します。Release() は生のポインターを渡し、呼び出し側が Deki::Memory::Free の責任を負うようになります。Release はあえて使う機会が少なくなるようにしています。

自動化されていないのは、*メモリがあったかどうか*だけです。その後のすべての分岐で後始末をするのではなく、バッファを作成した場所で 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 つです。この集合は開かれています。

// in a board-support package - no engine change, no registration call
namespace DekiEsp32
{
inline constexpr Deki::Memory::Region Rtc{"esp32.rtc"};
}
battery.Allocate(count, DekiEsp32::Rtc);

パッケージは自分の領域を 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 を返し、失敗のメッセージには領域の名前が出ます。

Failed to allocate 64 bytes in 'esp32.rtc' for 'void Battery::Save()'
at Battery.cpp:88 (0 bytes free there)

別の領域で代用されることはありません。特に Rtc ではこれが普段以上に重要です。この領域の目的はディープスリープを越えて保持することなので、黙って通常の RAM を返すと、まさに保持されるべきときにデータが失われてしまいます。

領域名はケーパビリティ名でもある

ターゲットは、持っている領域を memory: を接頭辞としたケーパビリティとして公開します。

ケーパビリティ 由来
memory:internal すべてのプラットフォーム - 自動的に導出
memory:external プラットフォームの psramSize - 自動的に導出
memory:esp32.rtc ボードの provides。それを知っているのはボードだけなので

そのため、領域を必要とするパッケージはそれを宣言し、ビルドは何かが実行される前に、それを持たないターゲットを名前を挙げて拒否します。

"platformRequires": ["memory:esp32.rtc"]

Region("esp32.rtc") と "memory:esp32.rtc" は、誰かが揃えておく 2 つのリストではなく、接頭辞を付けただけの同じトークンなので、両者がずれることはありません。ケーパビリティ名は小文字で、.、-、:、_ を使います。"WiFi" を宣言するプラットフォームは読み込み時に警告を受けます。文字列の完全一致では、黙って一度も一致しないことになるからです。

配置は 1 回で決まる。フォールバックはない

選んだ領域がいっぱいなら、確保は失敗して null を返します。もう一方の領域で再試行することはありません。これは意図的です。

  • Hot のバッファを黙って外部 RAM に移すと、配置の誤りが、誰にも原因のわからないフレーム落ちに変わります。
  • Buffer を黙って内部 RAM に移すと、次の Hot の確保に必要なヒープを使い果たし、失敗が無関係な場所で表面化します。

失敗すると、それが何で、どれだけの大きさで、どの領域で、フォールバックを試みなかったことがログに出ます。どの呼び出し側も、もともと null を処理しなければなりません。

外部 RAM のないボードでは、すべてが内部に解決されます。これはフォールバックではなく、領域がそれしかないということです。

API のその他の部分

Deki::Memory (<deki/providers/Memory.h>)。Buffer<T> でカバーされない場合に使います。

void* Allocate(size_t size, Memory::Region region, AllocContext ctx = {});
void* AllocateDma(size_t size, Memory::Region region, AllocContext ctx = {});
T* AllocateArray<T>(size_t count, Memory::Region region, AllocContext ctx = {});
void Free(void* ptr, const char* context = nullptr);
  • メモリは返される前にゼロで埋められるので、バッファがノイズで埋まった状態で始まることはなく、呼び出し側が独自のクリア処理を書く必要もありません。
  • Free は、ユーザーに渡すポインターの直前に置かれたヘッダーを読んでブロックがどの領域から来たかを判断するので、呼び出し側が選ぶことはありません。このヘッダーにはマジック値も含まれており、二重解放や外部のポインターを、ヒープの破壊ではなくログに出るエラーにします。
  • 確保に名前を付けるのは任意です。 後述の AllocContext を参照してください。
  • 失敗時には、試した領域にどれだけ空きがあったかが報告されます。これにより、「このアセットはこのボードには大きすぎる」と「何かが解放すべきメモリを持ち続けている」を区別できます。
  • Memory::Initialize は起動時に 1 回、内部 RAM と外部 RAM の空き容量をログに出します。そのため、最初のメモリ不足のログ行ではなく、実際の数値に基づいてプロジェクトの規模を決められます。
  • AllocateInternal / FreeInternal は配置の判断を迂回するもので、プラットフォームレイヤーのためにあります。コンポーネントからは使わないでください。

メモリの行き先を調べる

Deki::Memory::LogStats("after level load");
// Memory at after level load: 48216 bytes allocated (48216 internal, 0 external);
// 237096 free internal
  • LogStats(label) は「どれだけメモリを使っているか」に 1 回の呼び出しで答えます。デバッグビルドでは、現在の確保数とピーク時の確保数も報告します。呼ばない限りコストはかかりません。
  • GetStats() は同じ数値を構造体で返します。GetAvailableInternal() と GetAvailablePSRAM() は空き容量を返します。IsPSRAMAvailable() は外部の領域がそもそもあるかどうかを返します。これらはどれも、自分でメモリを配置してよいという許可ではありません。プロジェクトの規模を決めるためと、解放すべきメモリを持ち続けているものを見つけるためのものです。

AllocContext - ラベルは自動で書かれる

確保を行う呼び出しはすべてこれを受け取りますが、ほとんど誰も書きません。

posX.Allocate(capacity, Deki::Memory::Internal);

デバッグビルドではデフォルト引数が呼び出し箇所を取得するので、失敗は次のように表示されます。

Failed to allocate 153600 bytes in internal RAM for
'void ParticlePool::SetCapacity(size_t)' at ParticlePool.h:47
(126556 bytes free there)

渡す名前はなく、渡す方法もありません。手書きのタグは同じ確保に対する 2 つ目の識別子であり、誰かが 1 つ目と揃えておかなければなりません。しかも答えはコンパイラーがすでに知っています。1 つの関数内の 2 つの確保は、行番号で区別されます。

'void ParticlePool::SetCapacity(size_t)' at ParticlePool.h:47
'void ParticlePool::SetCapacity(size_t)' at ParticlePool.h:48

示されるのはエンジンではなく呼び出し側です。 Buffer<T>::Allocate と Memory::Allocate はどちらもコンテキストを受け取り、デフォルト値で上書きせずにそのまま渡すので、場所は Buffer.h ではなく*あなたが*呼び出しを書いた場所になります。テストがまさにそれを確認しています。これを間違えた版は、エンジン内のすべての確保についてもっともらしく見えるファイルを報告し、自信満々に役に立たないものになるからです。

何もコピーされません。 どのメンバーも静的記憶域期間を持つ文字列リテラルを指しているので、デバッグ用のリークの表がポインターを保持できます。

取り除く

取得はすべてのビルドで有効です。そのコストは ESP32-S3 のファームウェアで 4,272 バイト、1.5 MB のイメージの 0.28% で、呼び出し箇所あたり約 40 バイトです。気にする必要がないほど小さいコストです。

フラッシュを取り戻す必要があるビルドでは、DEKI_STRIP_ALLOC_CONTEXT を定義します。AllocContext は空の構造体になり、パスも関数名もバイナリに入らず、失敗時には発生元を示さずに何をしようとしていたかを報告します。

Failed to allocate 153600 bytes in internal RAM (126556 bytes free there)

取り除いたビルドと取り除いていないビルドではレイアウトが異なるので、エンジンとそれを呼ぶパッケージは一致している必要があります。これは MemoryStats がすでに課しているのと同じ制約です。

Deki::Vector<T> - 実行時に個数が変わる場合

<deki/providers/Vector.h>。Buffer<T> は、確保時にわかっている個数を扱います。個数が変わり、固定の上限がない場合、デバイス上では std::vector は誤った選択です。push_back は operator new を呼びますが、これは例外が無効だとメモリ不足を報告できないので、abort してボードが再起動します。

Deki::Vector はそれと同じ形で、ここで重要な 1 点だけを変えたものです。確保しうるものはすべて bool を返します。

Deki::Vector<Enemy> enemies(Deki::Memory::Internal);
if (!enemies.Reserve(32)) // out of memory, said once
return;
if (!enemies.PushBack(spawned)) // and per growth, if it has to grow
DEKI_LOG_WARNING("dropped a spawn");
  • 領域はコンストラクターの引数です。 そのため、このリストがどのメモリを占めるかは宣言を見ればわかり、拡張のたびに同じ場所に確保されます。デフォルトコンストラクターはありません。拡張するベクターは新しいブロックをどこに置くかを知っていなければならず、エンジンが代わりに決めることはないからです。
  • 最初に Reserve すると、その後の追加は失敗しなくなります。 要素ごとではなく 1 回の確認で済みます。これがデバイスで書く価値のあるパターンです。
  • 拡張は 最初のブロック 4 から倍々に増えます。16 ではありません。デバイスのプロジェクトには短いリストが多く、それぞれに大きなデフォルトを持たせると、リスト自体よりも大きくなってしまいます。
  • 拡張に失敗しても、すでに格納されているものはすべてそのまま残ります。 そのため、新しい要素を捨てた呼び出し側も、正常に動くベクターを持ち続けられます。
  • RemoveUnordered(i) は O(1) で、空いた場所を最後の要素で埋めます。ParticlePool::KillSwap と同じ手法で、生きているエンティティのリストに適しています。Clear() は次のフレームで詰め直すためにメモリを保持し、Reset() は解放します。
  • T の制約は Buffer と同じで、トリビアルにコピー可能かつトリビアルに破棄可能です。拡張時には memcpy で要素を移動します。

まずは Buffer を使ってください。ここでいう「動的な」データのほとんどは、プロジェクトがすでに知っている上限を持っています。また、固定の確保は、繰り返しの拡張のように小さなヒープを断片化させることがありません。

std::vector、std::string、通常の new

これらは operator new を呼び、Deki::Buffer<T> はそれを置き換えられません。デバイス向けのビルドでは、エンジンが演算子そのものを置き換えます (src/providers/OperatorNew.cpp)。これで 2 つのことが変わります。

  • 確保に失敗するとそう伝えます。 要求したサイズをログに出してから abort します。例外が無効だと、例外を投げる operator new にはほかに選択肢がないためです。"Out of memory asking for 40960 bytes" は診断になりますが、それに置き換わる前の再起動は診断になりませんでした。
  • std::nothrow が機能します。 new (std::nothrow) T[n] は abort せずに nullptr を返すので、メモリ不足を自分で処理したいコードはそうできます。置き換えがないと abort します。libstdc++ は nothrow 版を、取り除かれた try/catch の中で例外を投げる版を呼ぶ形で実装しているためです。

これらもエンジンを通ります。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 になりました。

プラットフォームレイヤー: IMemoryProvider

<deki/providers/IMemoryProvider.h>。プラットフォームはこれを 1 つ用意し、Memory::Initialize() の前に渡します。

Deki::Memory::SetBackend(new MyBoardMemoryProvider()); // takes ownership
Deki::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 を上げる必要があります。

lint

python tools/ci/check_allocations.py # packages beside the repo
python tools/ci/check_allocations.py --verbose # also list files and waivers

ランタイムコード内の 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 で常にわかります。

void* p = malloc(n); // deki-alloc-ok: the C API frees this itself

この lint はすでに役に立っています。手作業の確認では見逃されていた、Standard2DRenderer 内の Deki::Memory のバッファに対する delete[] を見つけました。