プログラミング
Playdate向けC言語のダーティな最適化テクニック
Dirty Optimization Secrets (C for Playdate) (devforum.play.date)
要約
この記事は、Playdate向けのゲームエミュレータ開発中に発見された、高度なC言語最適化テクニックを紹介しています。CPUは高速だがキャッシュアクセスが遅いというPlaydateの特性を踏まえ、命令キャッシュ(ICache)に収まるようにコードを圧縮すること、カスタムリンカースクリプトでコード配置を制御すること、シンボル情報を検査してコードサイズを確認すること、そしてTCM(Tightly-Coupled Memory)領域をデータやコードの高速化に活用する方法が解説されています。これらのテクニックは、エミュレータやシミュレーションなど、高いパフォーマンスが求められるアプリケーションに特に有効です。
全文翻訳
Playdate向けC言語のダーティな最適化テクニック
SDK開発
ディスカッション
ハウツー
NaOH
2025年6月14日 午前1時31分
1
これらは、@stonerlと私がフルスピードのゲームボーイエミュレータを開発中に発見した、高度な最適化トリックのいくつかです。これらは、他の場所で見つけられる一般的なゲーム最適化のヒントとは異なり、既存のテクニックに慣れていない場合はあまり役に立たないかもしれません。このような最適化テクニックは、エミュレータ、大規模シミュレーション(Factorioのようなもの)、3Dレンダラー、コーデックなど、非常にパフォーマンスの高いコードを目指している場合に最も役立つ可能性が高いです。
1. 高速CPU、低速キャッシュ
これは@StiNKzが行った研究によるもので、彼らの結果はDiscordで確認できます。PlaydateのCPUのモデルは、非常に高速なCPUだが、特にキャッシュにないメモリへのアクセスが遅いというものです。これは、コードが主にレジスタで操作され、大量のデータをロードしない場合、たとえ多くのループや分岐予測ミスがあっても、驚くほど高速になる可能性があることを意味します。
2. 命令キャッシュ
-O3が必ずしも最速とは限りません。-Osの方がはるかに良いかもしれません。Playdateには小さな命令キャッシュ(Rev Aではわずか4キロバイト)があるため、コアコード(例えば、エミュレータのインタープリターループ、フラグメントレンダラー、物理エンジン)が4キロバイト(Rev Bでは16キロバイトらしい?)に収まらない場合、より圧縮されたコード(CPU操作が多くても)よりもパフォーマンスが低下する可能性が高いです。
ゲームボーイエミュレーションのパフォーマンスを向上させるために最初に行ったことは、20キロバイトのエミュレータコアを、巨大なスイッチテーブルを削除し、わずか数回の分岐でコード量を大幅に削減することで、わずか2キロバイトに収めることでした。(そして-Osを使用しました。)振る舞いは同じでしたが、結果として大幅に高速になりました。
さて、この4キロバイトのキャッシュに収まる必要があるのは、コアコードだけです。まれな操作(例えば、ほとんど発生しないオペコードや、シミュレーションで非常に可能性の低いエッジケース)は、別の場所に配置できます。理想的には、4キロバイトのコアコードは、リンカによって連続したアドレスに配置されるべきです。これを達成するにはどうすればよいでしょうか?各コア関数に__attribute__((section(".text.<your-section-name-here>")))を使用すると、リンカはそれらを1つの連続したセクションに配置します。リンカマップ(次のセクションを参照)で、さらに高度なトリックを使用できます。
3. カスタムリンカマップ
C_API/buildsupportフォルダからlink_map.ldをプロジェクトにコピーし、Makefileにcommon.mkを含める前に次の行を追加します:
override LDSCRIPT=./link_map.ld
これにより、カスタムリンカスクリプトをポータブルに使用できます。リンカスクリプトを編集したことがない場合(ほとんどの人がそうであるように)、心配しないでください。それほど複雑なものではありません。特定のファイルからのすべてのコードを特定の場所に配置するように指定できます(.oファイルがbuild/に配置されていると仮定します):
/* カスタムコードセクション(つまり、__attribute__((section(".text.blah"))) を使用) */
*(.text.blah)
/* foo.c からのすべて */
build/foo.o(.text)
build/foo.o(.text.*)
/* 32バイトにアライン */
. = ALIGN(32);
/* 上記で明示的に言及されていないコード */
*(.text)
*(.text.*)
例:リンカスクリプト。
4. シンボルの検査
このコマンドをすぐに使えるようにしておきましょう:
nm Source/pdex.elf | sort > syms.txt
これにより、すべてのシンボル(関数、変数など)とその関連アドレスのリストが生成されます。コアコードのサイズを簡単に確認し、ICacheに収まるかどうかを確認できます。
例:出力(一部):
00019361 t write_cart_ram_file /* <-- これは関数です */
00019420 t $d
00019440 t $t
00019441 t gb_error
000194dc t $d
00019500 t $t
00019501 t PGB_GameScene_menu
00019638 t $d
0001965c t $t
00019a04 t $d
00019a30 t $t
00019a31 T PGB_LibraryConfirmModal
00019a44 t $d
00019a50 t $t
00019a51 t gb_save_to_disk.isra.0
00019aa8 t $d
00019ac0 t $t
00019ac1 T __gb_write_vram
00019b10 t $t
00019b11 t PGB_GameScene_free
00019b9c t $d
00019bc0 t $t
00019bc1 T PGB_GameScene_new
5. DTCMアクセラレーション
TCM(Tightly-Coupled Memory)と呼ばれるメモリの小さな領域があり、通常のメモリよりも高速です。スタックはこのTCM領域に配置されるため、スタックに割り当てられたオブジェクトは、ヒープ上のオブジェクトや静的変数よりも一般的に高速になります。実際、ある構造体をしばらく操作する場合、それをスタックにmemcpyし、そこで操作してから、元に戻すmemcpyを行う価値があります。
@RPDevはこれをPlayGBに実装し、大幅なパフォーマンス向上を得ました。
// 遅い
long_operation(&global_var);
// より速い
some_type copy;
memcpy(©, &global_var, sizeof(global_var));
long_operation(©);
memcpy(&global_var, ©, sizeof(global_var));
しかし、さらに改善できます!なぜ変数をヒープ上ではなく、永続的にスタック上に置いておかないのでしょうか?そうすれば、2回のmemcpyをスキップできます。さて、これを行えない理由があります — メイン関数を制御できないため、スタック上に何かを永続的に保持できません。しかし、回避策があります — スタックの反対側(つまり、低アドレス、スタックオーバーフローのリスクを冒して決して到達すべきではない部分)に格納しましょう。イベントハンドラでkEventInitの__builtin_frame_address(0)を使用してスタックの高アドレス端を特定し、それより10キロバイト未満(0x2180が安全だとわかりました)を引くことで、スタックの低端に到達できます。この機能が最終的に追加された場合、スタックの低アドレス領域を見つけるより良い方法となります。この低領域(dtcm_mempoolと呼びます)に何かを格納すると、アップデートごとに持続するはずです。dtcm_mempoolの開始と終了にカナリアを配置し、アップデートごとに少なくとも一度チェックして、スタックオーバーフローがdtcm_mempoolを破損していないことを確認してください。ちなみに、Playdateのフレームバッファなど、TCM領域にある他のメモリ部分もあります。スタック領域が不足している場合、効率的なアクセス用にデータをそこに配置できます(ただし、明らかにレンダリングすると画面に表示されるという事実に対処する必要があります!)。
6. ITCMアクセラレーション
スタック(またはdtcm_mempool)に配置できるのはデータだけではありません。コードも配置できます。これがRev Bでパフォーマンス向上につながるかどうかは不明ですが、さらなる調査が必要です。しかし、Rev Aでは、効率的なゲームボーイエミュレーションの秘訣です。
さて、PlaydateはコードをTCM領域に自動的に配置しません。そのため、.textから手動でコピーする必要があります。Cでこのマクロを定義して、関数がitcm領域に属することを示します:
#define _itcm __attribute__((section(".itcm"))) __attribute__((short_call))
_itcm を関数定義の前に置くと、itcmに属することを示せます。次に、リンカマップで次のように指定します:
__itcm_start = .;
*(.itcm)
__itcm_end = .;
これにより、Cに2つのシンボルが公開されます。1つは.itcmコードの開始、もう1つは終了です。これを使用して、.itcmコード全体を好きな場所に(TCMにコピーすることを含めて)memcpyできます。重要:コピー先の地址は、元の地址とmod 2で合同である必要があります。これは、Cortex M7が関数ポインタの最下位ビットを使用してコードがThumbかARMかを示すためです。また重要:同じ理由で、関数アドレスから直接memcpyすることはできません。関数アドレスは実際には関数の開始から1バイト後になるためです。ARMは奇妙です。
extern char __itcm_start, __itcm_end;
char dst_itcm_region[(uintptr_t)__itcm_end - (uintptr_t)__itcm_start];
memcpy(&dst_itcm_region, &__itcm_start, &__itcm_end - &__itcm_start);
// ITCMコピーの関数を呼び出す
((fn_ptr_t)((void*)some_itcm_fn - __itcm_start + dst_itcm_region))(args...);
考慮事項:-fPIC を有効にしないでください。これは、直感に反して、コードがこのようにリロケートされるとリロケーションテーブルの作業が壊れるためです。itcmコードにはshortcall属性を使用してください。上記のマクロのように、itcm関数が別のitcm関数を呼び出す場合、相対ジャンプを行う必要があるためです(そうでなければ、元の非リロケートされた場所にジャンプする可能性があります)。.itcm領域外の任意の関数