HN 日本語サマリー

← 一覧へ戻る
プログラミング

スーパーマリオのデリベーション

Super Mario Derivations (fzakaria.com)

105 pointsby domenkozar14 コメント

要約

Nix言語の遅延評価(Laziness)の特性を、スーパーマリオブラザーズ3のゲームプレイと結びつけて解説した記事です。Nixの属性パスをボタン入力シーケンスと見なし、各ゲームフレームをNixのストアにおける個別のデリベーション(派生物)として生成する仕組みを紹介しています。これにより、ゲームの進行状態をNixのキャッシュとして効率的に管理・再利用することが可能になります。

全文翻訳

Nix言語で最も驚くべき側面の一つは、特に遅延評価言語を初めて使う場合、その遅延性です。この遅延性こそが、Nixpkgsの多くの可能性と複雑さを生み出しています。遅延性を理解する最も簡単な方法の一つは、アクセスされた属性のみが評価されるということです。 ``` $ nix eval --expr 'let pkgs = { hello = "hi"; broken = throw "never forced"; }; in pkgs.hello' "hi" ``` これのより奇妙なバージョンとして、属性セット内で無限の再帰を持つことができます。Nixpkgsはこのような底なしの属性セットで満たされています。 ``` $ nix eval -f '<nixpkgs>' 'pkgs.hello' --raw /nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2 $ nix eval -f '<nixpkgs>' 'pkgs.pkgs.pkgs.hello' --raw /nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2 $ nix eval -f '<nixpkgs>' 'pkgs.python3Packages.pkgs.hello' --raw /nix/store/18bbdvag5v2f3d4y37pdbkzvh7s71cw4-hello-2.12.2 ``` 毎回同じストアパスです。pkgsはそれ自体を含み、その中のすべてのパッケージセットも同様です。 🤯 遅延性が再帰的な属性セットを終了させることを可能にするなら、再帰はまったく底に達する必要はありません。 ``` $ nix eval --expr \ 'let countdown = n: { value = n; next = countdown (n + 1); }; in (countdown 0).next.next.next.value' 3 ``` その属性セットは無限に深いです。3レベル深くインデックスを付けると、ちょうど3レベルの評価が必要になるだけで、無限の木の残りの部分は誰も要求しないため、決して構築されません。したがって、属性パスは遅延生成されたツリーを歩くことになります。これは、属性パスが何かの入力になったらどうなるだろうか、と考えさせられました。 🤔 そのアイデアを取り入れて、属性パスをスーパーマリオブラザーズ3のボタン入力シーケンスにすることにしました。ツリーの各ノードはゲームのフレームであり、各子ノードは新しいフレームを生成するボタン入力です。ゲームの状態は本質的に再帰的です。 ``` $ nix build '.#level1.rightb.rightb.rightab.rightb' $ file -L result result: PNG image data, 256 x 240, 8-bit/color RGB, non-interlaced ``` .rightb は右+Bで、スーパーマリオブラザーズ3では「右に走る」という意味です。.rightab は走ってジャンプです。出力は、実際のハードウェアでそのゲームでその順序でボタンを押した場合に表示されるフレームです。 11スクリーンショットも生成され、ビデオシーケンスをまとめる際に使用されます。プレフィックス .#level1 は、レベル1-1の開始地点に到達するための、あらかじめ用意されたボタン入力シーケンスです。パスのどこかに .play を追加すると、実行全体が録画にまとめられます。 しかし、最もクールなのは、それらのフレームのすべてが私のストアにある個別のデリベーションであるということです。コードは fzakaria/nes-nix にあります。これは汎用化されており、ROMは任意のゲームに合わせて好きな場所にポイントできるflake入力です。flakeは属性パスに基づいたデリベーションを計算し、各プレスはその独自のデリベーションとなり、前のプレスのセーブステートを入力として取ります。各デリベーションは、祖先のフレームを再エミュレートすることはありません。 22スクリーンショットはフレームごとに生成され、ビデオシーケンスをまとめる際に使用されます。 ``` trunk1 level1 2y1qjbk7…-nes-wait16 trunk2 .rightb gdbgfpdk…-nes-rightb trunk1->trunk2 run .rightb kpjlw529…-nes-rightb trunk2->run jump .rightab iv6asl0i…-nes-rightab trunk2->jump a .a q02kp71k…-nes-a run->a righta .righta nb87m9ss…-nes-righta run->righta ``` 実際的な結果として、ストアはエミュレータのセーブステート履歴になります。 ``` # 3つのデリベーション、コールドスタート $ nix build '.#game.start4.wait2.right' # 1つのデリベーション、プレフィックス再利用 $ nix build '.#game.start4.wait2.left' # 1つのデリベーション、すべて再利用 $ nix build '.#game.start4.wait2.right.right' ``` 100回のプレスの途中で分岐することは、末尾に追加することと同じように、1回のプレスで済みます。逆に考えることもできます。依存関係グラフは入力シーケンスなので、Nixにどのボタンがフレームを生成したかを尋ねることができます。 ``` $ nix-store --query --tree $(nix eval --raw '.#game.start.wait4.start.drvPath') /nix/store/32n4ni0zg01b9c9v64x67am37rdmmr9y-nes-start.drv └───/nix/store/j5vy3385pgs9dzw0y7sdrdmn7xnrxgji-nes-wait4.drv └───/nix/store/w4zz5aqj5zxqhnialabdc7p3sy80v6dc-nes-start.drv └───/nix/store/k9wfz8w5157d0xdwaw1vvhf019dvw5s0-nes-boot.drv ``` .play は実際にはほとんど何もしていません。パス上のすべてのフレームは、それ自体のプレスの出力としてすでにストアに存在するため、録画は何もエミュレートしません。ffmpegで処理するためのフレームへのシンボリックリンクのディレクトリです。 ``` $ nix build '.#level1.rightb.rightb.rightab.play' $ ls -l result/frames | head -4 0000.png -> /nix/store/3p2fxwngh…-nes-boot 0001.png -> /nix/store/4ha88l0dk…-nes-start 0002.png -> /nix/store/nh4zfsq6x…-nes-wait4 0003.png -> /nix/store/ghbgn28f1…-nes-start ``` ``` cluster_play result/frames : playデリベーション cluster_store /nix/store : プレスごとの1つのデリベーション f0 0000.png p0 3p2fxwngh…-nes-boot f0->p0 シンボリックリンク f1 0001.png p1 4ha88l0dk…-nes-start f1->p1 f2 0002.png p2 nh4zfsq6x…-nes-wait4 f2->p2 f3 0003.png p3 ghbgn28f1…-nes-start f3->p3 ``` この入力シーケンスゲーム入力のアイデアをどこまで推し進めることができるでしょうか?Nixはデフォルトで約2,400回のプレスで限界に達します。 ``` $ nix eval --raw ".#game.right.right.right…drvPath" error: stack overflow; max-call-depth exceeded ``` max-call-depthはデフォルトで10,000であり、各プレスを評価するには約4つのネストされた呼び出しが必要です。これは暴走する再帰に対するガードであり、構造的な制限ではありません。これを1000万に引き上げると、20,000回のプレスが可能になります。 ``` $ ulimit -s unlimited $ nix eval --raw --option max-call-depth 10000000 \ ".#game.$( python3 -c 'print(".".join(["right"]*20000))') .drvPath" /nix/store/p4nm0a4p4k9bdjqsag1jj0baah9mj6hb-nes-right.drv ``` 20,000回のプレスは、私のラップトップで評価するのに約14秒かかります。コストはプレスの数に線形であり、プレスあたり約0.7ミリ秒です。 1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/ しかし、次のボトルネックは、カーネルが私のマシンで21,845回のプレスで限界に達することです。属性パスは単一のargv要素であり、Linuxは引数リストの合計サイズと個々の引数のサイズを制限します。引数あたりの制限は131,072バイト(MAX_ARG_STRLEN)であり、各プレスは6バイト(right.)なので、21,845回のプレスがnix evalに単一の引数として渡せる最大数です。回避策は、実行を引数として渡すのをやめることです。 そして、入力シーケンスをファイルから読み込むことができます。 ``` $ nix build --impure --expr \ "(builtins.getFlake (toString ./.)) .packages.x86_64-linux.game.sequenceFile ./runs/world1-1.txt" ``` これは同等の属性パスとバイト単位で同一のデリベーションを生成するため、ファイルに保存された実行も同じストアパスを共有します。これらすべては、単にNix式を評価するためでした。次に、それをビルドする必要があります。Nixはデリベーションを並列でビルドするのは得意ですが、ここでの再帰はテイル再帰であるため、逐次的です。ボタンプレスのリストを増やしながらビルド時間をベンチマークしたところ、コストも線形であり、予想通り、プレスの数に比例します。プレスあたりのコストは、サブリューザーが有効な場合は約1.27秒、無効な場合は0.28秒です。キャッシュにデリベーションが存在するかどうかを確認するための往復コストは、フレームのエミュレーションコストよりも顕著に高くなります。 33ローカルビルドを優先するか、サブリューザーを許可することで、このコストを回避できます。1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/ 属性パスが単なる名前、つまり存在するカタログへの座標であることに慣れています。遅延評価は、それが実際にプログラムであることを意味します。評価者が歩む一連のステップであり、必要に応じて生成されます。Nixpkgsはその仕組みをソフトウェアを記述するために使用しますが、ツリーがカタログである必要はまったくありません。ストアが再現可能なステートマシンのためのまともな永続レイヤーであることが判明したことと相まって、私たちの「パッケージマネージャー」はマリオをプレイするために合理的に使用できます。 🍄