HN 日本語サマリー

← 一覧へ戻る
AI・機械学習

動作するファミコンを約1GBのQwenウェイトで実現

A Working Nintendo in ~1GB of Qwen Weights (github.com)

105 pointsby dbreunig84 コメント

要約

このプロジェクトでは、大規模言語モデル(LLM)の重み内に、ファミリーコンピュータ(NES)のCPU、RAM、およびカートリッジROMをコンパイルして組み込むことに成功しました。モデルはCPUの実行ログをトークンごとに予測することでゲームを動作させ、ホストプログラムがグラフィック表示やコントローラー入力を担当します。この技術は、LLMの計算能力をハードウェアエミュレーションに応用する新しいアプローチを示しています。

全文翻訳

NES CPUを標準的なQwen3.5モデルのウェイトにコンパイル。

NESの6502 CPU(2A03チップの中核)、2KBのワークRAM、およびカートリッジのプログラムROMが、標準的なQwen3_5ForCausalLMチェックポイント内で動作します。これらのウェイトは学習されたものではなく、コンパイラによって計算されました。ゲームをプレイする際、モデルはCPUの実行ログを1トークンずつ予測します。小さなホストプログラムがコンソールの残りの部分(描画、コントローラー入力、vblank割り込みの発火)を処理します。

nesformerは、Divyansh AgrawalによるQwenputerを基盤としています。Qwenputerは、CHIP-8とRISC-V CPUをQwen3.5-4Bにコンパイルしました。nesformerはQwenputerのコンパイラとその中心的なトリックを再利用しています:モデルのGated DeltaNet状態がマシンのメモリとなります。

スーパーマリオブラザーズ(ワールド1-1)ウェイトによってプレイされたフルゲーム(タイトル画面と最初のレベル)
[画像: ウェイトによってプレイされるスーパーマリオブラザーズ][画像: ウェイトによってプレイされるフルゲーム]

これらの実行におけるCPUトークンはすべてモデルから生成されました。バンドルされているテストゲームでは、すべてのフレームが従来の6502エミュレータの出力と完全に一致します(どのようにチェックされたかを参照)。

クイックスタート

1. フォルダレイアウト

nesformer.zipとnesformer-weights.zipを同じフォルダに解凍してください。ウェイトはnesformer/パッケージの隣に配置し、その中には入れないでください。

nesformer/              <- ここからコマンドを実行
├── nesformer/          <- Pythonパッケージ
├── chipformer/         <- Qwenputerのコンパイラ
├── roms/               <- テストROM(自分のROMもここに追加可能)
├── examples/           <- サンプルGIFとフレームシート
├── tests/
├── LICENSE
├── LICENSE.chipformer
├── README.md
├── pyproject.toml
└── weights/            <- nesformer-weights.zip から取得
    ├── config.json
    ├── generation_config.json
    ├── model.safetensors
    ├── nesformer.json
    └── SHA256SUMS

CLIはnesformer/パッケージの隣にあるweights/を探します。ウェイトを別の場所に置きたい場合は、run、check、またはtraceに--weights /path/to/weightsを渡すか、infoにパスを指定してください。

ウェイトが正しく解凍されたことを確認するには、トップフォルダからshasum -a 256 -c weights/SHA256SUMSを実行してください。

2. インストール

Python 3.11以降が必要です。

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install numpy scipy numba pillow
3. ゲームをプレイする
python -m nesformer run roms/thwaite.nes --frames 600 --show \
  --buttons "start@90-95+200-205,a@230-233+250-253+270-273+290-293+310-313+330-333+350-353+370-373+390-393+410-413+430-433+450-453+470-473+490-493+510-513+530-533+550-553+570-573+590-593"

--showは、モデルが生成する各フレームをターミナルに描画します。実行終了後、CLIはROM名(ここではthwaite.gif)でアニメーションGIFをカレントフォルダに保存します。

遅くなることが予想されます。最初の実行では、numbaによるエンジンのコンパイルに約20秒かかります。その後、1フレームあたり1〜6秒かかるため、600フレーム(NES時間で10秒)には約25分かかります。

独自のROMをプレイする

runコマンドを、NROMボード(iNESマッパー0)を使用する.nesファイル(例: Super Mario Bros., Donkey Kong, Excitebike)に向けます。ROMは自分で用意してください。nesformerは、ライセンスフリーのテストROM3つのみをroms/に同梱しています。

python -m nesformer run roms/smb.nes --frames 600 --show \
  --buttons "start@60-65,right@200-600,a@250-265+300-315+360-375+420-435+480-495+540-555"

このスクリプトはゲームを開始し、その後マリオを右に移動させ、約1秒に1回ジャンプさせます。CLIは起動時にROMをモデルの状態にロードするため、同じウェイトで再ビルドなしに任意のNROMゲームを実行できます。他のボードのゲームはメッセージとともに停止します。詳細は制限事項を参照してください。

コントローラースクリプト

--buttonsは、カンマ区切りのエントリリストを受け取ります。各エントリは、1つ以上のボタン名を+で結合し、その後に@、そして1つ以上のフレーム範囲を+で結合したものです。

start@60-65                 フレーム60から65の間でStartボタンを押す
right@200-600               フレーム200から600までRightボタンを押し続ける
a@250-265+300-315           Aボタンを2回押す
right+b@200-600             RightボタンとBボタンを同時に押し続ける(スーパーマリオブラザーズではダッシュ)

ボタンはa、b、select、start、up、down、left、rightです。フレームは電源投入時(毎秒60フレーム)からカウントされるため、フレーム600は10秒後です。ゲームは移動できない画面では入力を無視するため、ボタンを少し早く押し始めても問題ありません。スーパーマリオブラザーズでは、Aボタンを長く押すとより高くジャンプします。

コマンド

コマンド説明
python -m nesformer run ROMROMをウェイトでプレイし、GIFを保存する
python -m nesformer check ROMウェイトとリファレンスCPUを並行して実行し、最初の違いで停止する
python -m nesformer trace ROMモデルのトークンストリームを表示する(デフォルト40トークン)
python -m nesformer build回路をコンパイルし、weights/を書き出す(約10秒)
python -m nesformer infoチェックポイントを説明する

runのオプション:

オプションデフォルト意味
--frames N120Nフレーム後に停止する
--buttons SCRIPTなしコントローラー入力(上記参照)
--showオフフレームをターミナルに描画する(24ビットカラーが必要、例: iTerm2またはWindows Terminal)
--gif PATHROM名アニメーションGIFを保存する場所
--png DIRオフ各保持フレームをDIRにPNGとして保存する。--pngのみの場合、GIFは保存されない。両方取得するには--gifを追加する
--every N1N番目のフレームのみを保持する
--scale N2保存フレームをN倍に拡大する
--enginefastfast (numba)、numpy、またはhf(標準トランスフォーマー、torchが必要、約10トークン/秒)
--no-skipオフアイドルウェイトループのすべてのイテレーションを実行する(遅くなる、同一フレーム)
--max-tokens NなしNトークン後に停止する
--trace FILEなしトークンIDを保存する

run、check、traceはすべて--weights、--engine、--buttons、--no-skipを受け付けます。check --tokens Nは比較する距離を設定します(デフォルト50,000)。trace --tokens Nは表示するトークン数を設定します(デフォルト40)。build --out DIRはチェックポイントをweights/以外の場所に書き出します。

仕組み

ウェイトに含まれるもの、含まれないもの
モデル内(ウェイト + DeltaNet状態)ホスト内(nesformer/host.py)
6502コア:公式オペコード151個、すべてのアドレッシングモード、フラグ、スタック、BRKおよびNMIエントリPPU:スキャンラインレンダラーとパレット
2KBワークRAMコントローラー、APUレジスタ(書き込みはされるが、それ以外は無視)
32KB PRG-ROM、2回格納:命令フェッチ用コピー1つ、データ読み取り用コピー1つvblank時のNMIタイミング、NMIトークンとして注入
OAM DMA読み取り(256バイトはDMトークンとして表示)I/O読み取りへの応答、INトークンとして注入

この分割は、QwenputerのCHIP-8ビルドに従っています。そのホストはタイマーティックを注入し、トレースからディスプレイを描画します。ホストはCPUのレジスタやRAMをトレース経由でしか認識しません。

メモリトリックは次のように機能します。各DeltaNetヘッドは、トークンごとに更新される128×128の状態行列を保持します。ゲートを開いた状態とワンホットキーを使用すると、1つの更新がその行列の1つの行だけを上書きし、ワンホットクエリがそれを読み取ります。したがって、各行はメモリセルであり、カートリッジ、RAM、レジスタはすべて行に格納されます。

チェックポイント

weights/は通常のHugging Faceフォルダです。Qwen3_5ForCausalLM.from_pretrainedでロードでき、カスタムコードは不要です。

アーキテクチャQwen3_5ForCausalLM、Qwen3.5-4BのDeltaNetヘッド形状(キーヘッド16個、バリューヘッド32個、次元128)、MLP幅(9,216)
レイヤー7つのGated DeltaNetレイヤー(回路)、およびHugging Faceのキャッシュに必要なゼロ埋めアテンションレイヤー1つ
隠れサイズ768
ボキャブラリ263,808トークン
パラメータbf16で466.6M(933MB)、そのうち9.3Mが非ゼロなので、ウェイトは3.3MBに圧縮される

nesformer.jsonは、どのレイヤーとヘッドがマシンの各部分を保持しているかを記録します。実行は、カートリッジ、レジスタ、RAMをこれらのDeltaNet状態に書き込み、次に貪欲にデコードします。

レイヤー計画
レイヤーアテンション(状態)MLP
1PCアンカー、命令カウンター、A、X、Y、SP、フラグN V Z C I D、ポインタラッチ、NMIフラグカウンタサーモメーター、SP ± 1、LO + Y
2—PC = アンカー + 実行されたバイト数、PC + 1、PC + 2
3PRGフェッチ:PCでのオペコードと2つのオペランドバイトオペコードデコード、オペランドビット、インデックス付き合計、分岐テストとターゲット
4—アドレスモードとレコードステージごとの実効アドレス、プッシュが格納するバイト
5実効アドレスでのRAMおよびPRGデータ読み取りオペランド、ADC/SBC加算器、比較、BIT、アドレスデコード
6—結果バイト、キャリー、オーバーフロー、フラグ、ジャンプターゲット
7—次のトークン
トレース

各命令はレコードになり、レコードの最後のトークンがそれをコミットします。QwenputerのRISC-Vコアと同様に、PCは明示的に書き込まれるのではなく、暗黙的に示されます。各最終トークンは命令の長さを持ち、3つのDeltaNetカウンタが最後のジャンプ以降の各長さの命令数をカウントし、PCはジャンプターゲットにそのオフセットを加えたものになります。Full Gameからの抜粋:

SEI            P(000100 px0 L1)
LDX #$40       X($40 c0 v0 px0 L2)
STX $4017      IOW($4017=$40 L3)
TXS            S($FF L1)
BIT $2002      IOR($2002) IN($00) P(000110 px0 L3)       <- ホストがINを注入
BPL $8017      J($8017)
JSR $8279      PSH($FE) PSH($FD) J($8279)                 <- リターンアドレスがインサーキットでプッシュされる
RTS            PUL($FC) LO($7B) PUL($FD) J($827C)
STA ($72),Y    LO($08) HI($03) ST($00 a7=0 L2)            <- ポインタバイトがラッチされ、その後ストアされる
STA $4014      IOW($4014=$02 L0) DM(0:$FF) … DM(255:$00) NT(L3)   <- OAM DMA

ほとんどの命令は1つのトークンで済みますが、実際のゲームコードは命令あたり平均1.1〜1.3トークンです。nesformer/spec.pyはすべてのトークンファミリーを文書化しています。

チェック方法

  • リファレンスCPU(nesformer/cpu.py)は、3,348命令でnestestのゴールデンログに一致し、ゲームコード570万命令でpy65エミュレータに一致します。

  • 正確な回路は、Qwenputerのエバリュエータでbf16の正確性チェックとともに実行され、nestestと2つのゲームのブートシーケンスの283,610ポジションすべてでリファレンストークンを予測します。

  • 標準トランスフォーマー(bf16、CPU)のチェックポイントは、ミスマッチなしでnestestとFull Gameの2,651ポジションを予測します。

  • フリーランニングのウェイトは、リファレンスCPUと同一のトレースを生成します:

    実行トークンフレームNES時間結果
    nestest(公式オペコード)4,586——同一、期待されるHALTで終了
    Thwaite, check700,0002714.5秒同一トレースとフレーム
    Thwaite, run1,916,52760010.0秒保存された全300フレームが一致
    Full Game, run2,645,8854207.0秒保存された全210フレームが一致
  • アイドルループスキップは、目に見える変化をもたらしません。フレームとI/O書き込み、サイクルタイムスタンプを含め、スキップなしの実行と一致します。

スピード

デフォルトエンジンは、numbaでコンパイルされ、ゼロ埋めブロックをスキップしたmodel.safetensorsからのQwen3.5のフォワードパスを計算します。ビルドされたマシンのCPUコア1つで約1,650トークン/秒で動作しました。プレーンなnumpyエンジンは約210、標準トランスフォーマーは約10です。実際のNESは約700,000トークン/秒を必要とします。

vblankを待つループでフレームの大部分が消費されます。ホストがループ本体の繰り返しを検出し、RAMやI/Oの書き込みがない場合、次のPPUイベントの直前までクロックを進め、残りのイテレーションも実行します。これにより、トークン数が2.6倍から4.2倍削減されます。全体として、nesformerは実際のコンソールよりも150〜290倍遅いです。

制限事項

  • NROMカートリッジのみ(iNESマッパー0)。CPUは一度に32KBのプログラムROMしかアドレス指定できません。より大きなゲームは、CPUが見るROMの部分を切り替えるバンクスイッチチップ(マッパー、例: MMC1またはMMC3)を搭載しています。nesformerのROM読み取りはモデル内で行われ、固定の32KBテーブルから読み取るため、まだバンクスイッチを追跡できません。マッパーをサポートするには、ROM全体を状態に格納し、バンクレジスタもそこに格納し、各CPUアドレスをROMアドレスに変換するステージを追加する必要があります。
  • 公式オペコードのみ。非公式オペコードはHALTを発行します。
  • カートリッジROMからコードを実行する必要があります。RAMへのジャンプはHALTを発行します。
  • IRQなし(APUフレームIRQ、DMC)。BRKとNMIは機能します。
  • I/Oレジスタに対するRead-modify-write命令はI/O書き込みを生成せず、$4020〜$7FFFは0として読み取られます(PRG-RAMなし)。
  • PPUとAPUはウェイトに含まれていません。PPUは1スキャンラインずつレンダリングするため、スキャンライン中間でのエフェクトは再現されません。スプライト0ヒットとスプライトオーバーフローは近似であり、サウンドはありません。

テスト

pip install pytest py65
python -m pytest -q tests

テストには数分かかります。リファレンスCPUとnestestのログ、回路とリファレンス、フリーランニングのウェイトとリファレンス、およびアイドルループスキップをチェックします。

標準トランスフォーマーでチェックポイントを実行する

pip install "torch>=2.6" "transformers>=5.22"
python -m nesformer run roms/thwaite.nes --frames 3 --engine hf

nesformer/hfcheck.pyは、チェックポイントのロード、パディングトークンのデコード、マシン状態のキャッシュのDeltaNet状態への書き込み、そしてトークンを1つずつフィードする全手順を示しています。

ファイル

パス内容
nesformer/spec.pyトークンファミリーとレコード
nesformer/cpu.pyトレースを書き出すリファレンス6502
nesformer/host.pyCPU周辺のすべて:PPU、コントローラー、NMIタイミング、サイクルカウント、アイドルスキップ
nesformer/core.py回路と状態スナップショット
nesformer/weights.py回路をQwen3_5ForCausalLMチェックポイントに変換するイミッター
nesformer/engine.py, fastengine.pyウェイトを実行する(numpy / numba)
nesformer/hfcheck.py標準トランスフォーマーでウェイトを実行する
nesformer/run.py, check.py, cli.py, __main__.pyドライバー、教師あり学習チェック、CLI
chipformer/Qwenputerのq35コンパイラ(q35/イミッターとプリミティブ、dsl/グラフ)
weights/コンパイルされたチェックポイントとそのSHA-256チェックサム
roms/テストROM;roms/SOURCES.mdには作者とライセンスが記載されている
examples/実行からのGIFとフレームシート;demo.gifはトップに表示されたスーパーマリオブラザーズの実行
tests/test_nesformer.pyテストスイート

クレジット

  • Qwenputerとそのchipformerコンパイラ:MIT、© 2026 Divyansh Agrawal(LICENSE.chipformer)。このライセンスは、nesformer/core.pyおよびnesformer/weights.pyから適応されたコードにも適用されます。その最後の段落は、QwenputerのCHIP-8 ROMに言及していますが、このリポジトリにはそれらは含まれていません。
  • テストROM:nestest by Kevin Horton、Full Game by Doug Fraker(MIT)、Thwaite by Damian Yerrick(GPL-3.0、ソースはpinobatch/thwaite-nes)。

ライセンス

nesformerはMITライセンス(LICENSEを参照)でリリースされています。roms/内のROMは、roms/SOURCES.mdに記載されている独自のライセンスを保持します。