令和最新版、と一度言ってみたかった。
先日、手作りでWASM Componentのバイナリを作ってみたんですが、mruby/edgeは全部Rustで書いているので、では最近のRustではどうするといいかをしゅっと残しときます。
結論ファースト
ここに書いてある通りです 〜完〜
が、世の中割とcargo-componentベースの手順が多かったりするので、少しでも新しい情報を多くしようかなと思って(あと自分の理解のため)ブログに残しておきます。
続きを読むcomponentize_any
というコマンドラインツールを作りました。Rubyで書いたので以下の方法でインストールしてください。
$ gem install componentize_any ## もしくは $ git clone https://github.com/udzura/componentize_any.git && cd componentize_any $ bundle install
以下のようなスクリプトを用意します。(wittyファイルとでも名付けました)
witty do world do export "wasi:cli/run@0.2.0" end package "wasi:cli@0.2.0" do interface "run" do define "run", :func, {[] => :result}, counterpart: "component_run" end end end
以下のようなRubyとRBSのファイルを用意し、 mec
コマンドをインストールしていわゆる普通の(WASI p1依存なしの)WASMバイナリを用意。
# run.rb def component_run 0 end
# run.export.rbs def component_run: () -> Integer
$ cargo install mec --version=1.0.0-rc3 $ mec --no-wasi run.rb $ file run.wasm run.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)
mec
とはRubyのスクリプトをCore WASMのバイナリにコンパイルするコマンドです。以下の記事などで解説しています。
これらが揃ったら componentize_any
でComponentを作ることができます。
$ bundle exec componentize_any \ -witty-file witty.rb \ --input run.wasm \ --output out.wasm Writing WAT to /var/folders/sv/... Compiling WAT to WASM0: /var/folders/sv/... joining WASM0 files with run.wasm created out.wasm run to check: `wasm-tools dump out.wasm 2>&1 | less`
ちゃんとWIT表現を取り出せることを確認。
$ wasm-tools component wit out.wasm package root:component; world root { export wasi:cli/run@0.2.0; } package wasi:cli@0.2.0 { interface run { run: func() -> result; } }
このComponentは wasi:cli/run@0.2.0
を実装しているので、現在の wasmtime
ならそのままファイルを渡して実行可能です。実行が(何も警告を吐かずに)成功することを確認します。
$ wasmtime out.wasm $ echo $? 0
この時、Rubyスクリプトの Kernel#component_run
で 1
を返すようにして再度この手順を踏めば、実行は正しく失敗します。
mrubyファミリ (組み込み向け軽量Ruby) Advent Calendar 2024、23日目の記事です。メリークリスマス!
mrubyファミリAdvent Calendar、今年は1日目からyharaさんによる衝撃的な記事がありました。
なぜ衝撃的だったかというと、僕も全く同じことをしてた(mrubyをRubyで実装しようとしていた)ので...。
で、その実装の下準備のために mruby/c 1.2 のコードリーディングをしていたわけです。今日はそのメモの内容を整理して、最低限のmruby VMの実装を理解する手助けとしようと思います。
ちなみに今回、以下はしません。
あと、筆者は言語実装の素人なのですが、しかし読みやすくするためにこの実装が何者なのかについて適当に言い切っちゃってる点もあります。ツッコミは優しく、事実確認は適宜ご自身でおなしゃす。
また、mruby/cがどのバージョンでどの使用をサポートしているかについての情報もアドベントカレンダーで公開されました。ぜひ併せてお読みください。この記事で色々な背景を残していただいているおかげで、筆者もコード上の不明点がいくつか明らかになり大変助かりました。
続きを読むRuby Advent Calendar 2024の20日目の記事です。
今年後半からWarditeという名前のPure Ruby WASM Runtimeを作り始めたのですが、
その内部の話とかは今日は置いといて(東京Ruby会議12前夜祭で話しちゃおっかな)、今回、Warditeを作る上で計測しつつ高速化した話をします。と言いつつ、めちゃくちゃ基本的なことをしただけです。
以前grayscale処理を完走させた話をしました*1。
しかし、最初の実装では、YJIT有効でも、 150x150 のPNGファイルのグレースケール処理に14.43sが必要でした(コマンドを起動して、パースして...の処理全体での計測)。
bundle exec ruby --yjit tmp/grayscale-cmd.rb --wasm-file --source --dest 14.43s user 0.06s system 99% cpu 14.533 total
ひとまずボトルネックを探りたいな〜、このレベルで遅いと何か基本的なことを見逃しているのかも?と思いました。
まずは定番ではありますがruby-profを用いてみました。このgemは導入が簡単で、Gemfileに以下を記述して bundle installで導入できます。
gem "ruby-prof", require: false
その後計測スクリプトに以下のように追記するだけです。
require 'ruby-prof' profile = RubyProf::Profile.new # 関数呼び出しの前後で計測する profile.start offset = instance.runtime.grayscale($options.width, $options.height, start, data_url.size) result = profile.stop # ...処理の最後に出力 printer = RubyProf::GraphPrinter.new(result) printer.print(STDOUT, {})
ということで、これで計測した結果の抜粋を掲載します*2。
------------------------------------------------------------------------------------------------------------------------------------------------------ 54.539 9.845 0.000 44.69413069318/13069318 Kernel#loop 73.63% 13.29% 54.539 9.845 0.000 44.694 13069318 Wardite::Runtime#eval_insn /Users/udzura/ghq/github.com/udzura/wardite/lib/wardi te.rb:420 19.493 0.024 0.000 19.469 95886/95886 Wardite::Runtime#fetch_ops_while_end 15.638 6.483 0.000 9.156 5225913/5225913 <Module::Wardite::Evaluator>#i32_eval_insn 3.330 1.238 0.000 2.093 1155055/1155055 Wardite::Runtime#do_branch 0.773 0.773 0.000 0.00013069318/13069318 Wardite::Op#namespace 0.757 0.757 0.000 0.00012631671/73174992 Array#[] 0.749 0.749 0.000 0.00015275900/53340046 BasicObject#! 0.542 0.542 0.000 0.00010109073/23362733 Wardite::Runtime#stack
#fetch_ops_while_end
という関数がまあまあ支配的、という結果が出ました。
で、この #fetch_ops_while_end
が何だったかというと、もともと、WebAssemblyの :block/:loop/:if
といった命令を評価した際に、対応する :end
の位置を命令列をたどって計算するというメソッドでした。
when :if block = insn.operand[0] raise EvalError, "if op without block" if !block.is_a?(Block) cond = stack.pop raise EvalError, "cond not found" if !cond.is_a?(I32) next_pc = fetch_ops_while_end(frame.body, frame.pc)
例えばifから抜ける際にそのendまでジャンプする必要があるので、その位置を計算してそこに飛ばす必要があるわけです。
問題はこのendの位置を、毎回 :block/:loop/:if
に入るたびにいちいち計算していたということでした。この状況ではブロックやifのネストが深かったりする時、とんでもなく遅くなることが考えられます。また、何度も呼ばれる関数にこれらの命令があると無駄に毎回計算することになるはず。
この対応するendの位置は、パースして命令列が決まってしまえば変わることがないものです。そこで、一度命令列をパースしてから、もう一度命令列を検査して、 :block/:loop/:if
ごとに対応するendの位置を事前計算してキャッシュするように変更しました。
def revisit! @ops.each_with_index do |op, idx| case op.code when :block next_pc = fetch_ops_while_end(idx) op.meta[:end_pos] = next_pc when :loop next_pc = fetch_ops_while_end(idx) op.meta[:end_pos] = next_pc when :if next_pc = fetch_ops_while_end(idx) else_pc = fetch_ops_while_else_or_end(idx) op.meta[:end_pos] = next_pc op.meta[:else_pos] = else_pc end end end
詳しくはP/Rを参照してください。
これで毎回の再計算を防ぐことができました。
上記の対応を入れたところ、計測結果が8秒台前半になりました。
bundle exec ruby --yjit tmp/grayscale-cmd.rb --wasm-file --source --dest 8.17s user 0.07s system 99% cpu 8.300 total
1 - 8.17/14.43 = 0.4338
ということで素朴に解釈すれば実行時間の43%削減を実現できたということになります。
その後、もう一箇所怪しかった、 関数に入る際にローカル変数を毎回ゼロから作っていた箇所を、事前生成するように変えた ところ、どうやら安定して8秒を切るようになりました。
bundle exec ruby --yjit examples/grayscale.rb --wasm-file --source --dest 7.84s user 0.06s system 99% cpu 7.962 total
まだまだ普通に遅いと思うのでもっと頑張らねばというところですね。
ちなみに、以下は perf を用いて支配的な関数をreportした結果の抜粋です。
# Children Self Command Shared Object Symbol # ........ ........ ....... ..................... .................................................... # 100.00% 0.00% ruby ruby [.] _start | ---_start __libc_start_main 0xffffbd7173fc main ruby_run_node rb_ec_exec_node rb_vm_exec | |--98.93%--vm_exec_core | | | |--9.67%--0xffffbe152a58 | | | | | |--7.26%--rb_vm_set_ivar_id | | | | | | | |--2.86%--rb_shape_get_iv_index | | | | | | | |--1.91%--rb_shape_get_shape_by_id@plt | | | | | | | --0.94%--rb_shape_get_shape_by_id | | | | | |--0.81%--rb_shape_get_shape_by_id@plt | | | | | --0.67%--rb_shape_get_shape_by_id | | | |--7.32%--0xffffbe1525cc | | | | | --6.62%--rb_class_new_instance_pass_kw | | | | | |--2.52%--vm_call0_cc | | | | | | | |--1.51%--vm_call0_body | | | | | | | | | --0.81%--vm_call_iseq_setup | | | | | | | --0.67%--rb_vm_exec | | | | | |--2.22%--rb_class_allocate_instance | | | | | | | --2.15%--newobj_alloc | | | | | | | --1.58%--gc_continue | | | | | | | --1.41%--gc_sweep_step | | | | | | | --0.94%--obj_free | | | | | --0.50%--rb_call0 | | | |--5.78%--0xffffbe14a65c | | | | | --4.90%--rb_class_new_instance_pass_kw | | | | | |--1.95%--rb_class_allocate_instance | | | newobj_alloc | | | | | | | --1.38%--gc_continue | | | | | | | --1.24%--gc_sweep_step | | | | | | | --0.74%--obj_free | | | | | --1.81%--vm_call0_cc | | | | | |--0.94%--vm_call0_body | | | | | | | --0.57%--vm_call_iseq_setup | | | | | --0.74%--rb_vm_exec ...
正直めちゃくちゃ効果がありそうな箇所は見えてはいないのですが、まず、 rb_vm_set_ivar_id()
、おそらくインスタンス変数へのセットが上に来ています。あと目立つのは rb_class_new_instance_pass_kw()
など、オブジェクトを作っているところでしょうか。
ところでこのgrayscale処理の中身を考えると、大きくいうとbase64のencode/decodeと(dataUrlを扱うため)、PNGを処理するためのdeflateの処理の時間が支配的だと思われます*3。
つまり、数値計算が多いはずです。
実際、WASMの内部の数値型に対応するオブジェクト(I32, I64, F32, F64
)が、一回の処理でどれくらい生成されるかを計測してみました。
ちなみに計測には TracePoint を使いました。
$COUNTER = {} TracePoint.trace(:call) do |tp| # こういう名前のメソッドを作って初期化しているので、名前を追えばわかる if %i(I32 I64 F32 F64).include?(tp.method_id) $COUNTER[tp.method_id] ||= 0 $COUNTER[tp.method_id] += 1 end end END { pp $COUNTER }
結果は、150x150 のPNGの処理で以下のような感じでした。
{:I32=>18847048, :I64=>1710695, :F32=>247501, :F64=>2}
1880万回も I32
クラスのオブジェクトが生成されているということがわかります。そう考えると、 I32
クラスなどを作るときには、当然 値をインスタンス変数にセット しているので、 rb_vm_set_ivar_id()
が上がってくることにも関係していそうです。
このあたりの箇所について、オブジェクトを介さずにRubyの Integer
で直接計算処理を扱うなどでチューニングができるかもしれません。そのためには内部設計に大きく手を加えることになりそうなのですが。
ということで、Wardite高速化坂を登り始めたばかりだからな... と締めておきます。
最後に検証した環境情報を残しておきます。
*1:grayscale処理自体をちゃんと公開していなかったのですが https://gist.github.com/udzura/6409550d7200f52b32b057e02ba2f8e9 にコードだけ置いておきます
*2:Wardite v0.5.0 相当のコードで試せます
*3:処理自体のコードは https://gist.github.com/udzura/513f3f04303ca3f946abe0678c16a1f0 にアップしました