3 ポイント 投稿者 GN⁺ 2023-12-29 | 1件のコメント | WhatsAppで共有
  • 偶数/奇数の判定を % なしで、比較文の列挙だけで処理しようという遊び心のあるアイデアが、8ビットから32ビットまで拡張されるにつれて、コンパイラと実行ファイル形式の限界を露呈
  • Python のコード生成器で if (number == n) を自動生成すると、8ビットと16ビットの範囲では動作したが、32ビットでは比較対象が約42億個へ急増
  • 32ビット C 版は48時間後に約 330GB の C ファイルを作成し、MSVC は行番号の上限とヒープ領域不足でコンパイルに失敗
  • PE 実行ファイルの4GB制約を避けるため、x86-64命令を直接生成して 40GB のバイナリ isEven.bin を作成し、Windows のメモリマッピングで実行コードのように呼び出し
  • 最終プログラムは atoistrtoul に置き換えた後、32ビットの大きな値も正しく判定し、大きな入力は Core i5 12600K・32GBメモリ・M.2 SSD 環境で約10秒以内に返った

比較文だけで偶数/奇数を判定する

  • 出発点はソーシャルメディアで見たコードのスクリーンショットで、古典的な偶数/奇数判定問題を modulus 演算なしで解こうとする方式だった
  • 数字ごとに if (number == n) を置き、その数字が偶数か奇数かを printf で出力する構造
  • 最初の C の例では uint8_t number = atoi(argv[1]); を使い、0から10までの比較文を手書きしている
  • /Od で最適化を切ってコンパイルし、コンパイラがアルゴリズムを変えないようにする
    • 04even
    • 37odd
    • 501199 は何も出力されなかった
  • 原因は最後の if の後に処理する比較文がないことにあり、さらに多くの if 文が必要になった

Python で if 文を生成する

  • すべての比較文を手で書く代わりに、Python で C コードを出力するメタプログラミング方式を使用
  • Python スクリプトは for i in range(2**8) で0から255までの比較文を生成
    • i % 2 == 0 なら printf("even\n");
    • そうでなければ printf("odd\n");
  • 生成された C プログラムは8ビットの全範囲で動作
    • 99odd
    • 50even
    • 240even
    • 241odd

16ビットまでは C コンパイルで成功

  • 同じ方式を uint16_trange(2**16) に拡張
  • 生成された C ファイルは約13万行規模だった
  • MSVC でコンパイルした後、複数の値で正常に動作
    • 21000even
    • 3475odd
    • 3odd
    • 65001odd
    • 65532even
  • 実行ファイルサイズは約 2MBで、31.8GB メモリの PC では問題にならなかった

32ビット C ファイルとコンパイラの限界

  • 次の目標は uint32_trange(2**32) で32ビット全範囲を比較文で処理することだった
  • 32ビットは16ビットより数の個数が 65,536倍多い
  • Python 生成器を48時間実行した後、約 330GB の C ファイルが作られた
  • MSVC のコンパイルはすぐに限界にぶつかった
    • warning C4049: コンパイラの行番号上限に達し、line number emission を終了
    • line number の上限は 16777215
    • fatal error C1060: compiler is out of heap space
  • Windows の Portable Executable(.exe)形式にも4GBを超えにくいという制約があり、40億個を超える比較を実行ファイルに収める C コンパイル経路は行き詰まった
  • 関連する制約として PE ファイルの最大サイズ が言及されている

機械語を直接生成して実行する

  • コンパイラと実行ファイル形式の限界を避けるため、x86-64命令を直接バイナリとして出力する方式に切り替え
  • 目標の関数は引数を ECX で受け取り、戻り値を EAX で返す IsEven 形式だった
    • XOR EAX, EAX でデフォルトの戻り値を奇数用の0に設定
    • 各数字ごとに CMP ECX, i
    • 偶数なら INC EAX の後に RET
    • 奇数ならそのまま RET
  • x86-64 assemblyopcode が使われ、各命令の opcode は ChatGPT に尋ねた
  • Python スクリプトは isEven.bin をバイナリとして開き、0から 2**32 - 1 までのすべての数に対する比較命令を記録
  • 生成された isEven.bin は約 40GBで、32ビット数値全体に必要な約42億個の比較を含む

Windows のメモリマッピングで 40GB のコードを呼び出す

  • ホスト C プログラムは isEven.bin を開き、ファイル全体を読む代わりに Windows API でメモリマッピングする
  • 実行フローは次のとおり
    • CreateFileAisEven.binGENERIC_READ | GENERIC_EXECUTE 権限で開く
    • GetFileSizeEx で64ビットのファイルサイズを確認
    • CreateFileMappingPAGE_EXECUTE_READ を指定
    • MapViewOfFile で実行可能・読み取り可能なマッピングを作成
    • マッピングされたポインタを int (*isEven)(int) 関数ポインタにキャストして呼び出す
  • この方式は40GBのファイル全体がすでにメモリ上にあるかのように扱い、実際の配置は OS の仮想メモリに任せる
  • 最初のテストではほとんど正常に動作したが、4200000000odd が出て誤った結果になった
  • 原因は atoi が unsigned の大きな値を正しく処理できなかったことで、strtoul(argv[1], NULL, 10) に置き換えた後、4200000000even4200000001odd と出力された

性能観察

  • 小さな数は即座に結果が出て、2^32 の限界に近い大きな数でも約10秒で結果が返った
  • テスト環境は Core i5 12600K、32GBメモリ、M.2 SSD
  • 計算中に観測した SSD の最大読み取り速度は約 800MB/s
  • 40GBのデータをディスクから読み、物理メモリにマッピングした後、CPU がキャッシュの利点をほとんど得にくい状況でもこの程度の速度が出たことは、驚くべき結果として残った

1件のコメント

 
GN⁺ 2023-12-29
Hacker Newsのコメント
  • 初期に書いたプログラムの一つを、今も持っていたらよかったのにと思う。1996年、16歳のとき、線形代数の本の付録にあったコンピュータグラフィックスの項目を見て、前の学期に学んだプログラミングでいくつかの図形の回転ワイヤーフレームを描くプログラムに夢中になった。
    そのせいで授業をほとんど落としかけたのだが、当時はまだ配列を知らなかったので、すべての頂点と回転行列の要素がそれぞれハードコードされた変数で、行列積もループなしの長い計算式のリストを頂点ごとにコピペして修正しなければならなかった。
    画面に描くには特定のアドレスからメモリに書き込む必要があったのでポインタは知っていたし、頂点間の線をラスタライズするループもあった。結局、配列とインデックス指定の概念はすでに持っていたが、自分で作る方法を知らなかったというわけだ。

    • 自分も似たようなものだった。12歳くらいのころ、BASICでPac-Manゲームを作ろうとして、4匹のゴーストのロジックをそれぞれ (x1,y1) から (x4,y4) まで別々に書かなければならないと思い、途方に暮れた。
      父に、for ループの中で xnyn のように使えて、n がどのゴーストかを表せればいいのにと言ったら、BASICの本を取り出して x(n) が実際に使えることを見せてくれた。
      教育について話すとき、この出来事を思い出す。抽象概念は、学生に本当の必要性が生じたときに最もよく理解される。一日中説明してもぼんやりしていた内容が、自分の問題を解決してくれるとなると、数秒から数分でぴたりとはまる。
    • 当然の解決策は、画面の下側を作業メモリとして使いながら上側を描画することだ。下まで来るころには計算はほとんど残っていないはずだし、高速なGPUメモリを使うのでCUDAっぽく、とてもAIっぽい。
    • 初期のフリーランス時代を思い出す。持っていたのはPHPが動く小さなVPSだけで、2002/2003年当時としてはかなり大きかった5千〜1万行のスプレッドシートを処理しなければならなかった。
      コンピュータサイエンス専攻ではなかったので、ファイルを最も愚かな方法で読み込み、ネストしたループのせいでメモリ使用量と容量不足エラーが出続けた。そこで可能な限りあらゆる場所に $variable = null を入れたら、本当に動いた。
    • 中学生のときに作ったヒット作、TI-83用Snakeも似たようなものだった。ヘビの各節のx、y座標をすべて別々の変数に入れていて、TI-83 BASICで使える変数の数が限られていたため、ヘビの長さもそれ以上は長くできなかった。
    • ドキュメントを見て printinputifgoto を独学したあと、人に助けを求めて初めて学んだGWBasicの機能は chain だった。
  • かなり過剰設計に見える。なぜコード生成までするのかわからないし、単純な for ループで解ける。
    isOdd0 から n まで odd = !odd を繰り返してから返せばよい。
    Playgroundリンク: https://go.dev/play/p/8TIfzGrdWDF
    まだプロファイリングはしていないが、直感と業界経験からするとこれは速い。

    • 真のプロダクション品質の実装なら、常に再帰を使うべきだ。n == 0 なら false、正の数なら !isOdd(n-1)、負の数なら !isOdd(n+1) を返せばよい。
    • この方式のRust版は速いことを確認できる。
      アセンブリは testq %rdi, %rdisetg %alandb %dil, %alretq のように出る。
      ビルドの横にある ... を押すとアセンブリを見られる: https://play.rust-lang.org/?version=stable&mode=release&edit...
      残念ながらGo Playgroundはアセンブリ出力に対応していないようだ。
    • 偶数関数も忘れてはいけない。isEven(n int64) bool { return !isOdd(n) }
    • n = 無限大 なら無限に繰り返すことになる。
    • 末尾再帰で改善できる
  • このアプローチは、週間ダウンロード数が196,023回の is-even npm パッケージ[1]や、285,501回の is-odd npm パッケージ[2]にぴったり。npm installを打ったら、40GBの is-even と40GBの is-odd をダウンロードし始めるなんて最高そう
    [1] https://www.npmjs.com/package/is-even
    [2] https://www.npmjs.com/package/is-odd

    • これらのパッケージが、献身的な npm スパマー 1人[1]ができるだけ多くの node_modules ディレクトリに入り込もうとした結果だという点は、いつも言及する価値がある
      ansi-colorsも色全体のパッケージ1つではなく、色ごとのパッケージがあり、ほかにも本当にいろいろある。こういうものが CLI ツールやそれらしいパッケージに紛れ込み、互いに参照し合うので、実際のプロジェクトでも無害に見える依存関係1つだけで jonschlinkert のパッケージを何十個も引き込むことがある
      [1] https://www.npmjs.com/~jonschlinkert
    • 驚くことに、「繰り返すな」を最も純粋に実践した結果、is-even は is-odd に依存している
      var isOdd = require('is-odd'); の後は module.exports = function isEven(i) { return !isOdd(i); }; だけ
    • この人は知らなかったが、うちのフロントエンドアプリ2つのソースツリーを確認してみると、is-odd が依存している is-number パッケージが、かなり多くの別パッケージから取り込まれていた
      JSである値が数値型かどうか判定するのが本当に面倒なら、このパッケージにも意味があるのかもしれないが、他の組み込み型まで扱う、もっと一般的なパッケージがありそうだ
      ただし isNumber は数値に変換可能な文字列も数値として扱うので、変な結果になることがある。例えば const a = '1'; isNumber(a); // true なのに、const b = a + a; は文字列 '11' になる
      もちろん 2*a2 になり、1+'1''1'+1 はどちらも '11' になるという標準的な JS らしい愚かさではあるが、だからといって '1' を数値だとする答えは正しくないかもしれない。なのにこのパッケージは先週4,600万回ダウンロードされており、クリスマスで少なかっただけで、その前の週は平均7,000万回ほどだった。うちのプロジェクトのように、大半は依存関係として入っているのだろう
    • null 1つをエクスポートするだけなのにメモリを400MB使う nullll パッケージ[1]を作ったことがあるが、なぜか HN でフラグ扱いされた[2]
      GitHub のスター41個とテストカバレッジ100%[3]なら、間違いなく本番投入準備済みだった
      [1]: https://github.com/mickael-kerjean/nulll
      [2]: https://news.ycombinator.com/item?id=17072675
      [3]: https://github.com/mickael-kerjean/nulll/blob/master/test.js
    • 実は JavaScript の数値は u32 ではなく f64 なので、それでは足りない。安全な整数範囲だけをサポートしても 2⁵⁴ なので、2³² より400万倍以上大きい
      機械語のサイズは各分岐につき4バイト、つまり40%ほど増えるだけだろうから、おおよそ224エクスビバイトまで上がる。しかもこれは最後の10ビット分を怠けて飛ばした場合だ
      きちんとやるなら、そこにさらに1,000を掛ける必要があるかもしれないし、NaN パターンについて深く考えていないので少し小さくなるかもしれない。bigint までサポートすれば、もはや無限かもしれない
  • なぜわざわざこうするのか分からない。まさにこういうことをするためにデータベースが発明されたのだ。SQLite データベースに数値と even/odd の分類マッピングを保存すればよい
    この方式には、ある数値の分類が奇数から偶数に変わるたびにプログラムを更新しなくてよいという利点もある

    • データベースにも保守と更新が必要だ。むしろ Ethereum コントラクトを立てて、他の人たちがオラクルとして動作し、いつでも正しい答えを返すよう経済的インセンティブを与えるほうがよい
    • これは Wikidata にあるべき種類のデータに見える。そうすればローカルにデータベースを置く必要はなく、高速な HTTPS リクエストだけで済む
      唯一の問題は、TLS 自体が偶奇関数に依存している場合かもしれないが、おそらくそうではないだろう
    • テーブルは even_or_odd にして、is_oddis_evenis_zerois_oneis_twois_three のような列を置けばよい。1is_odd,is_one2is_even,is_two として入れればよい
    • その通りだが、当然 XML データベースを使うべきだ
      データの移植性にも役立つし、手で確認する必要があるときに人間が読みやすい形式で保てる
    • AWS の Elastic Cloud Parity がすでに提供していて、はるかにスケーラビリティが高い
  • ここで読んだ記事の中で最も面白いものの1つだ。ソースコードをオンラインに上げて、ChatGPT が「学習」できるようにすべきだ

    • そうすると彼の厳格なライセンスに確実に違反することになる
      /* Copyright 2023. All unauthorized distribution of this source code will be persecuted to the fullest extent of the law*/
      これほど優雅なコードなら、誰が責められるだろうか?
  • ジョークがまったく理解できない。これを作った人自体はともかく、現時点で1198件も推薦されているのが不可解
    計算可能な値のためのルックアップテーブルは新しくもないし、ジョークでもない。時間とメモリのトレードオフに対する実際の解法で、筆者もそれを分かっている
    問題自体はばかげているが非常に原始的なので可能であることに疑いはなく、40GBのプログラムを自分のコンピュータで約10秒間処理したという観察以外には、実測もなかった
    それで何を学んだのか? exeファイルは4GBを超えられないということ? if2^32個あるとプログラムは約300GBになるということ? なぜ1198人がこれを面白いと思ったのか分からない
    “Hexing the technical interview”やSIGBOVIKの記事と違って、これは狂っているのではなく、ただ無意味に見える

    • ジョークは、それを本当にやったことにある。何十年も人々はこういう冗談を言ってきたが、このクレイジーな人は実際にやり遂げた
      あまりに極端なので、どのコンパイラも処理できず、既知のアセンブラでさえ無理だった。だから動かすために機械語バイナリを自分で生成しなければならず、実際に動いた。狂っている
    • 計算可能な値のルックアップテーブルが新しくないというのは正しいが、最適化を切ると40億個のif文がルックアップテーブルにコンパイルされることはないはず
      それぞれのifが入力と一致するか順番に評価されるはずで、元記事のプログラムが小さい数でははるかに速く終わるという出力もそれを裏付けている。小さい数がコードの前のほうにあるからだ
      一方、40億個のcaseを持つswitch文なら、何らかのルックアップテーブルにコンパイルされると予想する。ただし、型が符号なし整数のとき、最適化なしでコンパイルされたコードがどうなるかは分からない
    • ときどき人は笑わせるために何かをする
    • 慣習的な知恵への反発がどれほど無意味かを風刺するブログ記事のパロディだと理解した。かなりドライなジョークだ
  • 驚くべき技術だ。AWSに売って、40GBの実行ファイルをまともにホスティングできないすべての人にEnterprise-ready AWS EvenOrOdd APIとして提供させるべきだ
    クラウドの力があれば、このプログラムは止められないはず

    • まさにLambda関数になるのを待っているようなものだ
  • プログラムが800 MB/s * 10秒程度のディスク読み込みだけで40GBの命令を「処理」したことに、誰も突っ込んでいないのが驚き
    推測するに、OSレベルの賢いキャッシュがあるのだろうが、そうだとするとn2^32に近いベンチマークは正しく実行されていなかったということになる
    あるいはCPUが数百万個の命令を飛び越えてジャンプできるほど賢いのかもしれない

    • 「31.8GBのメモリを持つ強力なゲーミングマシン」なら、ファイルシステムキャッシュが反復・シーケンシャルスキャンにある程度強ければ、再実行時には約8GBだけ読めばよいはず
      最初は計算が間違っていると思ったが、ざっくり計算するとかなりあり得そうだ。数値もすべて曖昧に丸められた値だし、入力値も絶対最大値ではなく、単に高い値だったのでなおさらだ
    • 圧縮されているか、RAMに残っていたデータなのだと思う。CPUは未来のifたちが何かを知らないので、ここでは賢く振る舞えない
      それらのコードが順番通りなのか、一意なのか、そもそも有効な命令なのかすら分からない。理論上はプログラム実行中にどれかのifを無限ループに変えることもできる。OSは許可しないだろうが
    • 予測的ページングもある。OSは次に要求されるページを推測できる
    • CPUのせいではあり得ない。実際にはメモリマップされたコードで、分岐予測器がページフォルトを起こして次のコードページを読み込むことはできないはずだから
      本当に気になる。線形アクセスパターンが助けになるのは確かだろうが、800 MiB/sとは?
    • プログラムをmmapするので、使われないページはページテーブルエントリだけを占有し、ロードされない。実際にロードされるのは直接ジャンプしたページだけ。見事なトリックだ
  • 先見の明ある天才Ross van der Gussomは、いまや私のお気に入りの神話上の生き物だ

    • PythonをCをスクリプトする方法として捉え、コンパイルの大半または全部を飛ばせばよい。Pythonが遅いなら、おそらく使い方を間違えている
      この記事をおすすめする: https://cerfacs.fr/coop/fortran-vs-python
    • “Ross van der Gussom”が内輪ネタなのか調べようとウェブ検索してみたが、上位2件の検索結果は元記事とこの親コメントだった
  • 記事全体がLLM開発のアレゴリーのように感じる。批判者が書くなら、途方もないリソースと「学習データ」を投入して解法を「暗記」することだと言いそうだ
    筆者の意図だったのか気になる

    • タイトルだけ見て新しい4Bモデルの発表記事だと思ったので、おそらくそうなのだろう
    • タイトルを読んで、完全にLLMの記事だと予想した
    • その通り。forループを実行する40BのLLMモデルのように見える。このアレゴリーこそが記事の実際の動機のように感じられ、工学の話ではなく、目前に迫る不条理を扱う記事のようだ