Nintendo SwitchへのSBCL移植
(reader.tymoon.eu)- TrialゲームエンジンをNintendo Switchで動かすため、Common Lispランタイム SBCL を2年間かけて移植し、現在はSwitch上でLispコードのコンパイル・実行と共有ライブラリ連携まで到達している
- Switchは ARM64 Cortex-A57 4コア、4GB RAM 環境だが、シェル・コマンドライン・コンパイラがなく、実行可能ページの生成も禁止されているため、一般的なSBCLのブートストラップ方式と合わない
- ビルドはLinux ARM64ホストとNintendo SDKを組み合わせ、
fasteval、immobile-code、elfination でcoreをコードとデータに分離したうえで最終パッケージにまとめる構成になっている - Trial REPLの例はSwitch devkitでOpenGLコンテキスト、入力処理、シェーダー割り当てまで実行できるが、ガベージコレクタ 実行時にクラッシュし、Cコールバックの問題でオーディオはまだ利用できない
- 残る主要課題は、マルチスレッドGC向け safepoint の安定化、Cコールバックの復旧、CLOSのランタイムコンパイル回避、性能最適化であり、NintendoのNDAのため一部コードは公開できない
SwitchでSBCLはどこまで動くのか
- TrialゲームエンジンをNintendo Switchで動かすため、Charles Zhangとともにこの2年間SBCLの移植を進めてきた
- 中核の難題は、Trialの下で動作する Common Lispランタイム をSwitchプラットフォームに適合させることだった
- 現在できること:
- ランタイムとコンパイラを移植し、Switch上で任意のLispコードを直接コンパイル・実行できる
- 共有ライブラリとインターフェースできる
- TrialがSwitchで必要とする複数のOS移植性ライブラリも移植した
- TrialのREPLサンプルはSwitch devkit上で動作する
- Switch自体にはターミナルがないため、TrialがOpenGLコンテキストを作成し、入力を管理し、シェーダーを割り当てて画面にテキストを表示する
- まだ詰まっている点:
- SBCLが ガベージコレクタ を動かそうとするとすぐにクラッシュする
- Switch固有の制約のため、まだ回避できていない部分が残っている
- Cコールバック機構が壊れており、オーディオ出力が不可能
- 性能面でも、まだ表面化していない問題がさらにあるかもしれない
コストと持続可能性
- 移植作業にはこれまで約 17,000ドル かかっており、Charles Zhangに月単位で費用を支払ってきた
- 収入源は Kandria の販売と Patreon、GitHub Sponsors、Ko-Fi の支援である
- 良い月は約1,200ドル
- 悪い月は約600ドル
- SwitzerlandのZürichではこの収入だけで生活するのは難しく、現在は両親と同居し、個人的な支出を抑えて持ちこたえている状況だ
- Switch版Kandriaの販売で移植費用を回収できるかは疑わしく、限られた資源をどこに使うかの判断が難しくなっている
- 支援収入は当面、SBCLのSwitch移植と現在進行中の未公開ゲームプロジェクトに使われる予定だ
Switch環境と移植条件
- 公開情報として知られているSwitch環境:
- ユーザーコードは ARM64 Cortex-A57 チップ上で実行される
- 4コアと4GB RAMを使用する
- Nintendo 3DS向けに最初に開発された独自マイクロカーネルOSの上で動作する
- SBCLにはすでにARM64 Linuxポートがあるため、コード生成の面は解決済みだった
- Kandriaは4GB RAMに容易に収まるので、メモリ容量そのものは問題ではない
- 実際の難所は、Switchの 独自OS とインターフェースすることにある
- 一般的なPC OSにはない制約がある
- Lispのようなシステムには特に厳しい
- Switchは、Trialが前提とする OpenGL レンダリングをサポートする唯一のコンソールである
- XboxはDirectXのみ対応だが、Microsoftが開発したOpenGL → DirectXレイヤーがあるとされており、可能性はあるかもしれない
- Playstationは完全に独自のグラフィックスAPIを持つとされ、このプラットフォームへの移植は考えたくない対象のままである
- 開発のためNintendo of Europeからアクセス権を得て、約400ドルのdevkitを購入した
- devkitとSDKはWindowsでしか動作せず、その後のビルド工程の負担につながっている
一般的なSBCLビルドとSwitchでの衝突
- SBCLは主にLisp自身で書かれており、小さなCランタイムも含んでいる
- Cランタイムは通常のCコンパイラでビルドするが、対象OS環境に関する情報を知っている必要がある
- ランタイム自体にはLispコンパイラがないため、SBCLをブートストラップするには別のLisp実装が必要で、理想的には別バージョンのSBCLを使う
-
PCでの5段階ビルド
build-config- 対象向けビルド設定オプションを集め、後続のビルド段階が読み取れる形式で出力する
make-host-1- ホストLispコンパイラでクロスコンパイラをビルドする
- Lispオブジェクトのメモリ配置をC構造体で記述するヘッダーファイルも生成する
make-target-1- 対象CコンパイラでCランタイムを生成する
- CランタイムにはガベージコレクタとOS環境との接着コードが含まれる
- OSヘッダーから、対象Lispコンパイラとランタイムが知るべき定数も生成する
make-host-2make-host-1で作ったLispクロスコンパイラで対象Lispシステム、つまりコンパイラと標準ライブラリをビルドする- ランタイムが突入できる cold core を生成する
make-target-2- cold coreを対象ランタイムにロードしてブートストラップを完了する
- Lispシステムがメモリにロードされた後、warm core としてダンプされる
- 以後は新しいコードをロードし、新しいイメージを自由にダンプできる
-
Switchで問題になる点
- SBCLビルドには対象マシン上でLispコードを実行する段階が必要である
- ユーザーLispコードはCのような単純なバッチコンパイルだけでは扱えず、実行時には対象環境にいることを前提としている
- アプリケーション配布も
make-target-2に似ており、Lispコードを段階的にコンパイルした後、ランタイム付きのcoreをダンプする - SBCLランタイムは起動時にcore blobをメモリへマッピングし、コードページを実行可能としてマークしたうえで、カスタムエントリ関数にジャンプする
- Switchではこのやり方全体が問題になる
Switch向けビルド戦略
- SwitchはPC環境ではなく、シェル、コマンドライン、コンパイラスイートが存在しない
- OSが 実行可能ページの生成 を許可しないため、仮にビルド段階をSwitch上で動かせたとしても、通常のLispの逐次コンパイルは不可能である
- コードの大半はプラットフォーム非依存なので、ARM64向けにコンパイルできる
- 周辺OS環境に触る部分だけにSwitch向けであることを認識させ、Linuxのような別のARM64環境で実装を作れる
-
Switch向けビルド手順
build-config- ホストシステム上で実行し、Switchビルドを表す特別なフラグを使う
fastevalcontribを有効化する- Switchではランタイムコンパイルができないため、
fastevalがコンパイラ呼び出しが必要な箇所を代替する
make-host-1- 大きな変更はなく、Switchプラットフォーム準備用ヘッダーを生成する
make-target-1- Nintendo SDKが提供するCコンパイラでSwitch向けCランタイムをクロスコンパイルする
- Switch OSはPOSIX互換ではないため、SBCLにカスタムのランタイム対象を作り、動的リンクやページマッピングのようなOS差異をスタブ化またはラップする
- 2回目の
build-config、make-host-1、make-target-1- Switchと同等の機能セットを持つ一般的なARM64 Linuxシステムを作る
- 一部のLispプロセスには最終ターゲットがSwitchであることを伝える特別なフラグを使う
make-host-2、make-target-2- 少し特殊なLinux ARM64向けSBCLビルドを得て、その後にユーザーコードをコンパイルする
- ユーザーコードのコンパイル
- LinuxではなくSwitch上で実行中だと信じ込ませるため
*features*を変更する :nxを含め、:linux、:unix、:posixは除外する- ASDFを無効化したうえで、Trialのようなプログラムを通常に近い形でコンパイルし、新しいcoreをダンプする
- LinuxではなくSwitch上で実行中だと信じ込ませるため
immobile-code と elfinationでcoreをパッケージ化
- Switchでは通常のcoreマッピング戦略が使えないため、Switch向けランタイムに新しいcoreを添付する方法は機能しない
- 解決策として、SBCLの比較的知られていない機能である
immobile-codeと elfination を使う - 通常のSBCLはランタイムでコンパイルしたコードをどこかのページに置き、そのページを実行可能としてマークする
- 後でそのコードが不要になればガベージコレクション対象にできる
- 領域を回収し、残りのコードを圧縮できる
immobile-codeは別の戦略を採る- コードを特別に予約されたコードページに置き、その場に固定する
- コードはガベージコレクションできない
- その代わり、従来のOS実行ファイル支援を利用できる
- 実行ファイルにはOSがコードとして認識する事前マーク済みセクションがあり、プログラム起動時にOSがマッピングを処理する
- elfination段階では、coreを通常の実行ファイルに必要な独立したコードセクションとデータセクションに変換する
- elfinatorがcoreを解析する
- アドレス空間配置のランダム化に必要な 位置独立コード になるようアセンブリを書き換える
- 純粋なコードアセンブリファイルと純粋なデータペイロードファイルに分離する
- 最終段階:
- elfinatorでアセンブリファイルを生成
- 最終バイナリをリンク
- Nintendo SDKのauthoringツールでメタデータ、共有ライブラリ、アセット、アプリケーションバイナリを1つの最終パッケージにまとめる
ビルドインフラと公開可能な範囲
- ビルドには、少なくともビルドの大半を実行する ARM64 Linuxマシン が必要である
- Nintendo SDKコンパイラとauthoringツールを実行するには、AMD64 Windowsマシン、またはWineを使うAMD64 Linuxマシンが必要になる
- 実際の構成はほぼ3台体制である
- AMD64の「driver」
- ARM64ビルドホスト
- devkitと通信するWindows VM
- キャッシュとマシン間同期ロジックを備えた専用ビルドシステムを書いて自動化した
- MSYS2/Windows環境でも動く必要があり、パス変換の問題があった
- ARM64でelfinatorと
immobile-codeを動作させ、pathname-utils、libmixed、cl-gamepadのような支援ライブラリも移植した - NintendoのNDAのため、多くの詳細は公開できない
- 公開可能な作業はupstreamに取り込み、Lispライブラリにはprivate forkを持たない
- Nintendo SDKへ直接つながないために別のCライブラリを作り、Lispライブラリはこのカスタムインターフェース経由でOS機能へアクセスする
- Lisp側は公開し、小さなCライブラリだけを非公開に保つための構成である
絶対ポインタとロード時再配置
- elfinationは、最初から位置独立実行可能なLispコードを作るよう設計された機能ではなかった
- Lispコードには通常 絶対ポインタ が多く含まれる
- SBCLコンパイラとランタイムで、ロード時の絶対ポインタ再配置を支えるための作業が必要だった
- コードオブジェクトには通常コード定数が入っており、もはや絶対ポインタを持たないようにしなければならなかった
- GCは実行セクションを変更できない
- OSローダーも、絶対ポインタ再配置のために実行セクションを変更できない
- 解決方法:
- コード定数のような絶対ポインタをtext領域外の読み書き可能な空間へ移す
- コード中の定数参照を、この読み書き可能空間からロードするよう書き換える
- ローダーと移動GCはその空間内のポインタを修正できる
ガベージコレクタとsafepointの問題
- SBCLの標準GCは gencgc、つまり世代別ガベージコレクタである
- gencgcはオブジェクト世代を分離して異なる頻度でスキャンし、オブジェクトを別世代の位置へコピーして領域を圧縮する
- この構造自体はSwitchで本質的な問題ではないが、マルチスレッド化が問題を生む
- 複数スレッドがあると、あるスレッドがオブジェクトにアクセス中かもしれず、オブジェクトを勝手に移動できない
- 最も簡単な解決策は、GC開始前にすべてのスレッドを停止させることだ
-
UnixとSwitchの違い
- Unix系ではsignal機構で他スレッドに信号を送り、parkさせられる
- Switchにはsignal機構がなく、スレッドをinterruptすることもできない
- 各スレッドが自分でparkすべきだと気づくようにする必要があり、一般的な戦略が safepoint である
- safepointはコンパイラが追加コードを挿入し、スレッドがparkすべきか確認させる
- チェック追加にはコストがあるので、できるだけ少なくしたい
- チェック頻度が低すぎると、全スレッドがparkするまでGCを開始できず、他スレッドが止まる
- チェックに多くの命令が必要だと、CPUキャッシュラインやパイプライン最適化を妨げる
-
既存SBCL safepointの限界
- SBCLの現在のsafepointシステムはWindows向けに書かれている
- Windowsもプロセス間signal handlerはないが、Switchと違って現在のスレッド向けsignal handlingはある
- 既存方式:
- 各スレッドは、safepointが1ワードを書き込むページを保持する
- GCが始まると、そのページを読み取り専用としてマークする
- 他スレッドがsafepointに到達してページへ書こうとするとsegmentation faultが発生し、そのスレッドがparkする
- 単一の書き込み命令だけで済むため効率的である
- Switchではこの手法も使えず、より複雑なチェックを挿入しなければならない
- safepointはWindows以外のプラットフォームでは必要なかったため他環境で検証されておらず、Switch向け修正とは別に不安定である
- コードベース内のこの部分は大きく整理が必要かもしれないが、完全な書き直しにまでは至らないことを願っている
CLOSのランタイムコンパイル回避
- CLOSはメソッドディスパッチに必要な discriminating function のコンパイルを、通常はジェネリック関数の初回呼び出しまで遅延させる
- CLOSは非常に動的で、メソッドをほぼいつでも追加・削除できるため、システムが完成した時点を把握しにくいからである
- Switchではコンパイラを呼び出せないため、この遅延コンパイル方式をそのまま使うことはできない
- 現在の戦略はfast evaluatorに依存することだ
compile関数をスタブ化する- コンパイルの代わりに、evaluatorでコードを実行するlambdaを生成する
compileに依存するユーザーコードでも動作する- 実際のコンパイルより実行速度は大幅に遅い
残る作業と性能リスク
fasteval方式は主にフォールバックである- 最終イメージをダンプする直前に、可能な限り多くのCLOS状態を固定し、可能な限り多くのコードを事前コンパイルする方法を探りたい
- Charlesが数年前に復元したblock compilation modeもさらに調査する予定だ
- Switchの比較的非力なプロセッサのため、追加最適化が必要になる可能性がある
- Trialエンジン側
- Kandriaコード側
- これまでは10年前のPCでもゲーム要件を十分満たせていたため、最適化は比較的少なくて済んでいた
- 優先度の高い作業:
- ガベージコレクタを完全動作させること
- 現在は起動後にTrialのメインループまでは入れる
- multi-generation compactionに到達すると失敗する
- Cから来るコールバックを再び動作させること
- SBCLコードベースの該当部分には手書きアセンブリルーチンが多い
immobile-codeとelfinationに合わせた調整が必要かもしれない- Trialでは
libmixedを通じたサウンド再生にのみコールバックが必要である
- Trialのselftest suiteがSwitch上で完全通過するまでに必要な追加移植性機能の作業
- ガベージコレクタを完全動作させること
NDAで制限される公開範囲
- 移植作業全体を公開したいが、NintendoのNDAのため公開できない
- 公開可能な部分はupstreamするか公開している
- Nintendo SDKへ直接つながる一部コードは、NDAに署名していない人には共有できない
- Nintendo SwitchでCommon Lispゲームをリリースしたい人がいれば、NDA署名後に移植作業へのアクセス権を得られる
- Patron向けの月次アップデートでは、進捗をさらに詳しく共有する予定だ
1件のコメント
Hacker News のコメント
ここ数週間、Common Lisp でゲーム開発を試してみようと思って Trial(https://github.com/Shirakumo/trial)を使ってみたが、とても楽しかった。
ゲームが実行中の状態でほぼあらゆる要素を変更できる点が本当に大きな利点なので、この移植が成功してほしい。
コード変更の間もアプリケーションの状態を維持できるのも非常に有用そうだし、Common Lisp も漠然と思っていたよりずっと速い言語に見える。
個人的には、ゲーム開発に限らず全般的にデータ構造の使い方、特にマップを扱う方法が不格好なのが最大の欠点だが、そのトレードオフは受け入れられそうだ。
これは本当に素晴らしい。SBCL は優れた言語実装で、「本物の」ゲームコンソール上で Common Lisp 開発をしてみたかった。
Shinmera がこの作業をしているというのも、良い意味で驚きだ。以前 #lispgames や Lisp Discord で何度か見かけたが、こういう低レベル開発にも関心があるとは知らなかった。
SBCL の内部を少し覗いて怖気づいて引き下がったことがあるので、なおさらすごく見える。SBCL にスレッド機能と SDL2 を組み合わせたものが、今は Raspberry Pi で動くのかも気になる。
それから前述のとおり、*her :)
興味深く詳細な記事を書いてくれた著者に感謝する。このレベルの公式コンソール移植の詳細は、たいていコンソールの寿命が終わって何年も経ってからようやく明らかになる印象がある。
こういう深い作業を読むと、一日中反復的なソフトウェアを使っている自分の仕事を思い出して、うらやましくもなる。
開発キットには通常もっと良いフックがあったが、同時に GDB 中心の流れを避けられる利点もあった。両方を経験してみると、そこまでうらやむような世界かはよく分からない。
もしかすると、自分の関心を必要としているプロジェクトではなく、自分の関心が後で必要とするプロジェクトなのかもしれない :D
「全部公開したいが NDA のせいでそうできない」という部分を見て、制約のないホームブリュー SDK(https://switchbrew.org/wiki/Setting_up_Development_Environment)ではなく公式 SDK を使った理由が気になった。
完全な推測だが、Nintendo がサードパーティ SDK でビルドしたゲームの公式リリースを認めていないからかもしれない。
関連して https://opengoal.dev がある。
背景として、Naughty Dog は PS2 の Jak & Daxter シリーズを作る際、Lisp に似た独自言語 GOAL を使っていた。デバッグ情報を十分に残していたおかげでリバースエンジニアリングが可能になり、OpenGOAL プロジェクトがそれを成し遂げた。
今では GOAL コンパイラが移植されたあらゆるプラットフォームでこれらのゲームを実行できる。私の知る限りでは現在は x86 程度で、Switch に移植されたら素晴らしそうだ。
Kandria をたった今買った。ゲームをたくさんするほうではないのであまりプレイしないかもしれないが、Shinmera は Lisp の世界の境界を明らかに広げているので、支援する価値があると思う。
彼女の仕事は本当にすごい。時々 Common Lisp を使う立場として、こういう取り組みはとてもうれしい。
Nintendo や Sony のような会社がこうした努力を直接支援してくれたらいいのにと思う。結局のところコンソール向けゲーム、つまり IP を作る別の方法なのだから、プラットフォーム側で Github Accelerator のようなものを始めたときに、どんな欠点があるのか分からない。
Nintendo は Switch へのリリースを促す新しいインセンティブを作る必要がない。すでに1億4000万台以上の販売と高いゲーム購入率という最大のインセンティブがあるからだ。
それでも昔ほど手続きが多いわけではない。最近のシステムはほとんどが汎用 CPU と GPU アーキテクチャに収束しており、違いがあっても小さな装飾程度であることが多い。
だから良いものを享受しにくくなるのだ。
こういう記事を見るために HN に来ている。原著者と同僚に拍手を送りたい。不可能だとは分かっているが、Nintendo が自社システムをもう少しだけオープンに扱ってくれたら、本当に大きな恵みになると思う。
どこにも説明がなかったので付け加えると、SBCL は “Steel Bank Common Lisp” のことだ。
“Steel Bank Common Lisp(SBCL) は高性能な Common Lisp コンパイラである。寛容なライセンスのオープンソース/自由ソフトウェアであり、ANSI Common Lisp 向けのコンパイラとランタイムシステムに加えて、デバッガ、統計プロファイラ、コードカバレッジツール、複数の拡張を含む対話環境を提供する”
https://www.sbcl.org/
https://benchmarksgame-team.pages.debian.net/benchmarksgame/box-plot-summary-charts.html