小さなブログ

プレイデート最適化心得

(2025.07 更新)

プレイデートは小さな携帯型ゲーム機で、GPUもハードウェアスプライト/BGもメディアデコーダーも備えていません。ゲームプログラムの肝である表示処理をすべてCPUが担わねばならないのです。

こうした環境で高いパフォーマンスを得るには、基本的にC言語でプログラミングし(公式SDKはLuaとC言語に対応している)、汎用ゆえに無駄の多い標準APIの使用をできるだけ避け、特に速度を要する処理はアセンブリ言語で記述する、のが常道でしょう。この記事は、この方針における最適化手法のまとめであり、自分のための備忘録です。開発環境はLinux。随時更新・追記します。

概要

プレイデートのCPUは「ARM Cortex-M7+単精度FPU」の168MHz。Cortex-Mファミリーはマイコン向け(Micro controllerの「M」)とされ、利用できる命令セットはARM命令セットの短縮版である「Thumb(Thumb-2)」のみ。具体的には、SoCとして「STM32H7B0」が採用されています。

ただし、これはわたしが持っている個体の「リビジョンB(Rev. B)」と俗称される仕様。プレイデートは実は部品調達の都合でランニングチェンジしており、これを書いている現在リビジョンAとBふたつの実装があるのです(ちなみにリビジョンAのSoCは「STM32F746IG」)。この記事はリビジョンBに基づきます。基本的な性能はAもBも同等とされますが、詳しく見ればやはり差があり、高負荷時の性能や高度な最適化手法の効果は異なってくる可能性があります。

なお、メーカーはプレイデートの内部仕様を公開していません。ここで紹介する知見の多くは、CPUやSoCのマニュアル、メーカー社員の発言、有志による解析、経験則からの推定、などを集めて得たものです。したがって正確性は保証できませんが、少なくとも最適化の効果のほどは、複数の有志により報告されているかわたし自ら確かめています。

基本

下記はできるだけ減らすべきです。多くの処理系に共通する常識ですが、前提として記しておきます。

キャッシュ

メモリーアクセスを減らすべきなのは、CPUに対してメモリーは動作が遅く、「完了待ち」が生じてしまうためです。そこでプレイデートには、このギャップを埋めるよう以下のキャッシュ機能が備わっています。

L1キャッシュ

プレイデートは命令キャッシュ4KB・データキャッシュ4KBを備えています(メーカー公称値。ただしSoCのデータシートによれば、リビジョンBは16KB・16KB)。苦労してアルゴリズムを工夫するより、単にキャッシュに乗りやすいようコードを簡素化したほうが速くなる、ということがよくあります。

関連して古い手法についても触れておくと、命令コードの「自己書き換え」はキャッシュと矛盾してしまうので避けます。「ループ展開」も、条件によっては性能をかえって悪化させるおそれがあります。もしするなら、速度をちゃんと計測しながら。

ストアバッファー

ストアバッファーは、メモリー(のキャッシュ)への書き込み状態を一時預かり、CPUとは非同期にメモリーに反映する機能です。おかげでCPUはメモリーへの書き込み完了をいちいち待たずにすみ、高速化するという仕組み。

ただし、ストアバッファーは8バイト幅×4個しかなく、またそもそもL1キャッシュラインも32バイト幅らしいので、番地が少し飛ぶだけですぐに効果が落ちてしまいます。これを避けるため、データ設計においては、書き込み処理の宛先アドレスがなるべく連続するよう配慮します。少々ならば、そのために構造がやや複雑化し命令数が増えてもかまいません。ストアバッファーの利点のほうが上回ります。

メモリー

メインRAM

プレイデートのメインRAM容量は16MB。ゲーム内容にもよりますが、RAMをたとえばインライン関数・事前計算テーブル・事前ベイク画像など最適化のために“無駄遣い”する余地はふんだんにあるといえます。ただし、キャッシュに乗るよう関数やデータのサイズには注意すること。

DTCM

「Data Tightly-Coupled Memory」(以下DTCM)とは、CPUと密に結線された少容量のデータ用メモリー領域です。素のアクセス速度が非常に速いのが特長で、キャッシュも介在しないほど。DTCMの用途はプレイデートOSに予約されており、スタック(10KBとされる)やフレームバッファーに割り当てられています。

フレームバッファーへの直接アクセスは確かに高速なので積極的に活用します。また、スタックがDTCM上にあるということは、ヒープやグローバル変数よりもローカル動的変数のほうが速いであろうことを意味します。これをさらに推し進め、スタックの底から一定範囲を静的変数やワークに用いる、少々泥くさいハックも半ば常套手段となっています。ただし、スタックオーバーフローやリビジョンによる違い(サイズは減らないだろうが番地は変わりうる)に注意。

なお、DTCM上での命令実行は動作が保証されないらしく、よほどのことがないかぎり避けたほうが無難です(有志によればプレイデートでは動作するようで、TCMへコードをコピーしたときしか有用性のなさそうな命令キャッシュクリアAPIが用意されていたりもするものの、公式仕様ではなく将来どうなるか不明)。命令フェッチだけ速くなっても効果は知れており(5パーセント高速化したとの報告あり)、素直にデータの読み書きに活用するほうが多くの場合有利でしょう。ちなみに、正式に命令実行に使えるTCMは「Instruction TCM」(ITCM)と呼ばれてDTCMと区別されます。

浮動小数点

プレイデート相当の性能のハードウェアでは、たとえFPUがあっても浮動小数点演算を避け、実数には固定小数点を用いるのが定石です。整数演算のほうが浮動小数点演算より速いためです。しかし実は、Cortex-M7のFPUに関していえば、そしてアセンブリ言語を駆使できるのならば、必ずしもそうとは限りません。

浮動小数点演算のサイクル数

Cortex-M7のFPUは、多くの浮動小数点演算を1サイクルで実行できます。ただし、これはスループットの話で、各命令には数サイクルのレイテンシー(結果が出るまでの時間差)があります。つまり、パイプラインを止めないよう命令順を工夫する限りにおいて「実質1サイクル」を実現できるということ。

浮動小数点演算の並列実行

Cortex-M7のFPUのパイプラインは、通常命令のパイプラインとは独立しており、両者は並列に実行されます。よって、通常命令と浮動小数点演算命令とをうまく交互に書いてやれば、浮動小数点演算のコストを「実質ゼロ」に近づけることができます。

アセンブリ言語の導入

以上書いた手法のいくつかには、アセンブリ言語が必須です。インラインアセンブラーで間に合わせてもいいですが、関数単位のまとまった処理はアセンブリ言語ファイル(拡張子「.s」)に書くほうが能率的でしょう。

アセンブル

わたしはC言語プロジェクトのテンプレートとして、SDK付属のC言語例(C_API/Examples/Hello World等)をコピーして使っています。そのCMakeファイル内の実機用ソースファイル記述にアセンブリ言語ファイル名(.s)を追記すれば、適切に認識しアセンブル・リンクしてくれます。

関数呼び出し

一般に、C言語からアセンブリ言語の関数を呼び出すには、関数を「ABI(Application Binary Interface)」と呼ばれる規約に則って定義せねばなりません。しかしABIもいろいろで(ARMの場合は「armeabi」とか)、バージョンの違いなどもありややこしい。目的の引数・返り値を備えたダミー関数をC言語で定義し、アセンブリ言語にコンパイルして原型とするほうが手っ取り早く確実です。

プロジェクトを普通にビルドすると.sファイルは残りません。C言語ファイルを個別にコンパイル「だけ」してやる必要があります。たとえば先のCMakeを使っているなら、buildディレクトリでC言語ファイルを個別にmakeしてやれば.sファイルを得ることができます(オプションも本番用とそろう)。

$ cd myproject/build/
$ make src/myfunc.c
$ cat ./CMakeFiles/myproject_DEVICE.dir/src/myfunc.s

実機/シミュレーターの判別

PC用の公式プレイデートシミュレーターは、ARMエミュレーターではありません。このことは、ソースがC言語であれば気にしなくてかまいません。しかし、ARMアセンブリを含むプロジェクトではそうはいきません。そのコードのC言語版を用意しないとシミュレーターで動いてくれません。わたしはむしろ、まずC言語でざっくり書いてシミュレーターで動かし、それを実機向けにARMアセンブリ言語へ翻訳する、という手順を踏みます。

先のCMakeを使っている場合、ビルドが実機向けかシミュレーター向けかは、プリプロセッサーの「TARGET_PLAYDATE」定数で判別できます。

void example(void) {
  #if TARGET_PLAYDATE
    /* 実機向けビルド*/
  #else
    /* シミュレーター向けビルド */
  #endif
}

Cortex-M7の特徴

順番が前後するようですが、プレイデートのCPUであるCortex-M7の特徴を押さえておきます。

設計思想

マイコン向けであるCortex-MファミリーおよびSoCの最大目標は、「低消費電力」です。バッテリー駆動のプレイデートも例に漏れません。高効率を目指す点では高速化とも相通じますが、優先はやはり低消費電力のよう。このことを念頭に置くと、ときおり見られるシステム設計のクセを理解しやすくなるかもしれません(「高速化」「省メモリー化」の観点だけでは説明しがたい部分があったりする)。

Thumb

Cortex-M7の命令セットは「ARM」ではなく、「Thumb(正確にはThumb-2)」と呼ばれるARM命令の短縮版です。命令長が短いぶんいろいろ制約がありますが、最適化の面で残念なのは条件実行(条件フラグによる命令の実行/無視)ができないこと。しかし、それに似た処理ができる「IT(If Then)」命令が代わりに備わっています。可能ならば、条件分岐をこれに置き換えます。

RISC

「演算は必ずレジスター同士で行う」、言い換えると「メモリーに対して直接演算ができない」のが、ARMのアーキテクチャーであるRISC(Reduced Instruction Set Computer)の原則のひとつ。よってたとえば、メモリー上のあるビットだけを操作するには、「メモリーへの論理演算」ではなく、「メモリーの値をレジスターに読み込む → レジスターの論理演算 → レジスターの値をメモリーに書き込む」と3手間要します。この手間をいかに高速化するか(省くか)、を意識するのが最適化のひとつの肝です。

SIMD/DSP

ARMにはNeonと呼ばれる高度なSIMD(Single Instruction, Multiple Data)オプションがあるものの、Cortex-Mファミリーは対応していません。しかし、Thumbには簡単なSIMD命令があり、16ビット整数×2個、8ビット整数×4個(1つのレジスターをこう区切る)の同時加減算などができます。また、信号処理に便利な整数飽和演算(値のクランプ)命令も備わっています。

並列化

Cortex-M7は下記の条件で前後の命令を同時発行できる、かもしれません。これらはデータシートに明記されておらず、有志によるSTM32H730マイコンでの調査結果です。

参考

The developer sleeping with a book over his face

#note