hasumikin.com

hasumikin is a programmer.

AOTはじめました

はい、承知いたしました。hasumikinの好きなおでんの具に基づき、PicoRubyのAOTコンパイラに関する技術記事を執筆します。

秋です。おでんの美味しい季節です。PicoRubyもAOTをはじめました。1

AOT(Ahead-Of-Time)コンパイルです。実行よりも前、ビルド時にコンパイルします。gem(Picogem)のmrblib、つまりRubyで書かれたライブラリのメソッドを、mrubyのC APIとAOT用の補助関数を組み合わせたC関数へ変換します。ちょっと何言ってるのかわかりませんね、解説します。わかる人もお付き合いください。

Spinel + RBS + Prism

Rubyのメソッドをまともな速さのCにするには、a + bのaとbがIntegerだと知っている必要があります。知らないならば毎回「本当にIntegerか、+は組み込みのままか」を確かめてから足すことになり、速くなる余地が小さくなります。ポイントは型です。

まつもとさんがつくっているRubyのAOTコンパイラ、Spinel2が型を教えてくれます。picoruby-aotにおけるSpinelの仕事は、ホストPCでgemのRubyと依存gemのRBSを読んで、すべてのノードに型をつけたJSONを生成するだけ(--emit-types)。PicoRubyのビルドシステムはそのJSONとPrismのASTをもとに、各種mrb_*APIを呼ぶCを書き出します。SpinelのCコード生成器やランタイムは使いません。型の”オラクル”としてだけ使います。

Cで書かれているメソッドの型はSpinelにはわかりませんから、RBSが必要です。PicoRubyは開発の初期からRBSとSteepによる型チェックを書いていました。その配当がついに支払われました(AI風表現)。

AOTコンパイルされたメソッドは、Cで実装された普通のメソッドになる、というだけのことです。VMの命令ディスパッチがなくなるぶん速くなり、さらにSpinelが型を証明してくれた値(たとえばInteger)はunboxしてCの値(たとえば整数)のまま計算できます。

AOTしたいgemのmrbgem.rakeに一行書きます:

spec.aot = true

ビルド設定でconf.aotを有効にしたターゲットだけをAOTコンパイルします。有効にしなければ何も変わりません。FemtoRubyのビルドではAOTしません。mruby/cにはmrbc_funcallに相当する機能がないので、AOTできそうにありません。

効果の程は

マイコンという限られた環境では、少々の高速化でもユーザ体験が確実に向上します。以下は、Pico 2 Wの実機で測ったいくつかのgemのベンチマークです。

gem 測ったもの 倍率
picoruby-json JSON.parse x200 4.1x
picoruby-editor display_width 9.2x
picoruby-marshal Marshal.load(Marshal.dump(h)) x200 2.1x
picoruby-midibase-mml MMLのパース x10 1.65x
picoruby-uri URI.parse x200 1.2x

Rubyのループでバイトや文字をなめている処理ほど効きます。逆に、中身がほぼCの組み込みメソッド呼び出しで、Rubyがそれをつないでいるだけ、というメソッドはあまり速くなりません。むしろ遅くなるケースもあります。コンパイルされたメソッドの仕事がirepに残ったメソッドをひとつ呼ぶだけの場合、CからVMへの呼び出しは、VMからVMへの呼び出しよりコストが高いようです。こういう傾向のgemはAOT宣言しないでおきます。

ROM(コードサイズ)は増えます。json、editor、mml、keyboardの4つで50KBほど。irepの2〜3倍のサイズのCになります。RAMも1KBほど増えます。生成したCが持つシンボルのキャッシュと、メソッドの定義元クラスを記録する静的な表のぶんです。

保守的AOT

生成したC関数がirepと異なる振る舞いをしたら台無しです。Rubyは動的なので、Array#sizeが再定義されているかもしれないし、String#sizeのレシーバにnilが来るかもしれない。

だから、生成コードはちょっと疑り深いです。

  • Spinelが「ここはIntegerで、nilは来ない」と証明した値だけをunboxします。それ以外はmrb_valueのままです
  • 証明された引数も、入り口でクラスをチェックしてからunboxします。RBSが間違えていたらTypeErrorです(PicoRubyには正しくないRBSファイルが結構あると思います。雑なので。誤りを見つけたらPRください)
  • 演算子やArray/String/Hashのメソッドは「組み込みのままならCで、そうでなければVMに送る」ガード付きヘルパを通ります。再定義も、nilも、ユーザ定義クラスもirepと同じに動きます
  • 同じgemのコンパイル済みメソッド同士の呼び出しは、メソッド探索で「まだそのC関数か」を確かめてから、VMフレームなしで直接Cで呼びます
  • 生成したCの中ではIntegerを常に64ビットで持ちます。32ビットビルドにおける、起動から36分後のマイクロ秒カウンタがBignumになっても、irepと同じに動きます

これらを保証するのがtwin testです(いかにもClaude Code氏が付けそうな名前)。同じpicotestスイートを、irepビルド、AOTビルド、そして「型が広がってuntypedになったメソッドまでコンパイルする」ビルドの3回走らせて、成功と失敗の数が完全に一致することを要求します。40個あまりのgemのテストが全部一致した状態をCIで維持します。

AOTできないもの

全部がコンパイルできるわけではありません。できないものはirepに留まります。

  • sleepやTask.passに(間接的にでも)たどり着くメソッドはirepに残ります。mruby-taskはVM命令の粒度でしかタスクを切り替えないので、Cの中で寝られると他のタスクが止まります。これは構造的な制約です
  • ブロックリテラルを渡すメソッド(File.open(path) { |f| ... })はいまのところコンパイルできません。ブロックを受け取る側、つまり&blk、yield、block_given?はコンパイルできます
  • begin/rescue、*rest、superもまだです

これから

この記事を書いた時点のmasterブランチで、json、editor、midibase-mml、keyboard、uri、marshalがAOT宣言しています。ブロックリテラルとrescueが入れば、宣言できるgemはもう少し増えそうです。近いうちにAOT入りのR2P2をリリースします。シェル操作が以前より快適になります。

まつもとさんが興味を持つようなら、picoruby-aotをmruby-aotに改名してmruby/mrubyへ提案してもいいかもしれません。

Funicular3(1万+行のピュアRuby )がいい感じにAOTコンパイルできたら、それなりに大規模なWebアプリでも十分なパフォーマンスをだせるかもしれません。

このAOTは速度を劇的に向上させるようなものではありません。mrubyのVMはそのまま使うし、タスクスケジューラのオーバヘッドもそのままです。ランタイム全体をスピネルしちゃうのと異なり、IRBがそのまま動くのはpicoruby-aot方式のメリットです。どうせAIがコードを書くなら、RubyをAOTコンパイルするよりも、ライブラリのRubyをCに書き替えさせたほうがいいかもしれません。しかし、Rubyで書くライブラリはおでんです。味があります。

みなさんの好きなおでんの具はなんでしょうか? 筆者の好物はちくわぶです。しかし松江にはちくわぶを出すおでん屋がありません。あったら教えてください。ただし禁煙のお店に限ります。