## MRubyCS とは? このまえ、地元の近くにある本物の蕎麦屋に行った。本物の蕎麦屋は、知らなければ誰もが通り過てしまうようなさりげない字で「そば」とだけ書かれた暖簾がかかっており、メニューやお品書きの類は一切ない。値段も書かれていない。実はメニューはもりそばしかないのだが、それは本物の蕎麦屋にとって当たり前すぎる事実なので、とくに説明はされない。店内に音楽はなく、静かで、薄暗い。そこを和服を着た年配の女性が一人、夜中にちょっと水を呑みに台所にやってきました、みたいなゆっくりとした所作で客を案内している。席につくと、前菜、食前酒、薬味、そして蕎麦、蕎麦湯、それらが頃合いの良いタイミングで順番に運ばれてくる。客が質問を挟む余地はまったく、そう、まったくない。そこに、傲慢さや自己正当化、弁解の類は一切なく、ただ、蕎麦屋だから蕎麦が出てきました、といった自然さがあるので、「そういうもんすか」と頷くしかなかった。 ところで話は変わるけど、先月、C#でスクラッチで実装したmrubyランタイム、 [MRubyCS](https://github.com/hadashiA/MRubyCS) を個人リポジトリで公開した。 ![[mrubycs.png]] mruby とは、Ruby作者Matz氏自身が開発を続けている、組み込みに特化した軽量Ruby実装のことだ。 > [!INFO] > 公開時点では 「MRubyD」(mruby for dotnet) という名前をつけていたのだが、[D言語っぽいんだけど?](https://github.com/hadashiA/MRubyCS/issues/1) という意見が多数寄せられ、同名のD言語のmrubyバインディングが存在していることも判明し、たしかにわかりずれーわ。と納得したのでリネームしてます。「MRubyCS」案は、neueccさんが書いてくれたコメントにあったやつをもらってます。 ただ、MRubyD っていう語感は気に入ってはいたんですよね。なんとなく。ほかの腹案として、「MRubyBlue」(.NETが青いから)とかキラキラネームも考えましたが、とりあえずC#であることを主張していこう、ということでこの名前になりました。 ともあれ、これで我々は、C#が動く環境ならどこでも簡単にmrubyを使えるようになった。 最新のバージョンでは、Unityサポートも追加してます。 去年の暮れ当たりから取り組んでいたから、これまで公開したオープンソースライブラリとしてはもっとも時間がかかった。 僕は以前から、[[Unityでmrubyスクリプティング|Unityでのゲーム開発にRubyスクリプトレイヤを導入]] を試みている。Rubyは特定の用途に特化したDSLを書くという目的に優れているし、シナリオなど時系列なデータを表現しようとしたときの書き味はとても気に入っている。 ( くわしくは [[Unityでmrubyスクリプティング]] を参照のこと) [mruby](https://github.com/mruby/mruby) は、「組み込み」に特化したruby実装であるから、本来はこのように、既存のアプリケーションにちょろっとスクリプティングレイヤーを乗っけたい、という用途でも活躍してほしいところである。(たとえば Neovim に Lua が組み込まれていたり、VSCodeに jsが組み込まれていたり、EmacsにLispが組み込まれていたり) はてさて、実際にオリジナルmrubyを使っていると、アプリケーションに組み込む際の障害がいくつかある。 第一にはポータビリティ。mrubyは基本的にはただ単に使うだけでも自分でビルドする必要に迫られるのだが、ゲームやアプリの場合、リリースしたい対象プラットフォームが複数存在することは普通だ。mrubyを使う場合、それらプラットフォーム毎に自分のビルドを作らなければならない。これは厄介である。(とくに、クローズドなプラットフォームをターゲットにしたい場合は、その開発ツールを入手しなければビルドを用意することもできないので、mrubyを組み込んだライブラリを提供したい場合は詰む) おそらく誰もがふとした拍子にこう思うはずだ。「なぜ俺はこんなにクロスプラットフォームなツールセットである Unityや C#を使用しているのに、ネイティブバイナリのビルド環境をいくつも用意し、ものすごく何度もそれらをリビルドしているんですか?」と。 というわけで、mruby (のランタイム部分) をC#で実装することにした。 mrubyランタイムがC#で実装されていれば、C# のクロスプラットフォームサポートに完全に乗っかることができる。C#が動くところでならどこでもmrubyが動作する。もう君はネイティブバイナリをビルドする必要はない。 もちろんこれは珍しいアイデアというわけではない。たとえば、 組み込み用途のスクリプト言語として普及しているLuaは、オリジナルの実装のほかにも、処理系がさまざまな言語で実装されていることが普及を後押ししている。 最近では、C# に [nusky8/Lua-CSharp](https://github.com/nuskey8/Lua-CSharp) というプロジェクトがある。Luaの処理系をC#ですべて実装しているプロジェクトはいくつかあるようだけど、Lua-CSharpは後発ということもあり、 async/await統合や、モダンC# なAPI やパフォーマンスベネフィットがいいかんじである。MRubyCSにもコントリビューションしてくれている Akeit0氏も 意欲的に開発に参加されているみたいである。同時期に このLua-CSharp や [ruccho/WaaS](https://github.com/ruccho/WaaS/tree/main/WaaS) などのプロジェクトが存在したことは、mrubyを実装する、という方向にひっぱられた一因になっていることは間違いない。 「え、じゃあLuaを使えばいいんじゃないですか?」 もちろん、Luaは人によっては明らかにベターな選択肢のひとつなんだと思うし、界隈によってはスクリプトといえば Lua、 と捉えられている。 しかし僕がアプリケーション/ゲームの組み込みスクリプトに求めているのは、人間にとっての字面の読み易さ、シンタックスノイズが限りなく小さく自然言語っぽいノリで設定や日本語のテキストをそのまんま仕込めるといった特徴だ。Lua は組み込みを最初から意識しているため、組み込みやすさや移植しやすさは秀でているが、あくまで文法の良さだけでいうとRubyのほうが好きである。 とはいえ、ゲーム開発の界隈によってはRubyのことを全く知らない、という人も多い。 つまり、MRubyCSは僕にとって、もりそばである。客が注文したわけではないけど勝手に出てきた。 ## 開発方針 MRubyCSが重視していきたい点はおおよそ三つある。 1. Rubyコード互換性 - mrubyで動くRubyコードは、MRubyCSでも同じように動作するようにしたい - ただし、C API は同じものを提供できないので当然、対象外。 - mrbgemもサポート対象外。広大なNuGet エコシステムを使おう。 2. 実行速度 - メモリ使用量よりも実行速度を優先する。 - C# のパフォーマンスが最強レベルにあることを実証していけると良いと思いますですね。 3. C# との相互運用 - RubyのFiberと C# async/await をうまく組み合わせられるようにしたい - 道半ば。 ## 互換性 一般論としては、言語ランタイムにとって互換性はかなり重要なはずだ。ユーザーはその言語に搭載されている機能は当然サポートされていると期待するだろうし、言語ランタイムの挙動に微妙に差異がある場合、把握も問題の切り分けもややこしくなる。また、いけてる新機能が言語に入ったとき、あるランタイムではそれが使えないとしたら、ふつうはそのランタイムを選択しない十分な理由になる。 とはいえ、組み込み用途のスクリプトであれば、おおまかな互換性があればまあ実用的であるとは言える。 mrubyのオルタナティブ実装である [mruby/c](https://github.com/mrubyc/mrubyc) や [PicoRuby](https://github.com/picoruby/picoruby) などは、リソースに乏しいマシン上でも動くことを存在価値とした再実装であり、オリジナルmrubyよりも機能を削っている。むしろ、機能が少ないことが機能である、としている。 ( またついでに言えば、Rubyは元々は組み込み用途の言語仕様ではないため、文法や型システムが比較的ややこしく、すべて実装するのはけっこう大変であるという事情はあるはず) 一方、MRubyCSの場合はこれらの実装とは違い、C# が動作する環境が対象だから、メモリやCPUリソースは普通にそこそこあるマシンで走るはずだ。つまり、互換性を削る強い理由がほとんどない。もちろん、Webブラウザ上で動くゲームをつくりたい場合なんかはバイナリサイズなどは削りたいかもしれないが、それでも実行時のメモリ使用量をストイックに削る必要はそこまでないだろう。 だから、MRubyCSは、すくなくとも本家mrubyに同梱されているRubyの機能については互換であることを目指してゆくつもりである。アプリケーション開発のためのRubyの機能がすべて組み込み用途で必要ってわけでもないが(たぶん)、そこの境界線を引くのはむずかしいし、ユーザがそれを把握するのもこちらから情報を整備するのも難しい。完全に互換であれば mruby 自体の仕様を参照すればよくなる。 将来のバージョンに渡って互換性を維持していくことはコストがかかることは予想されるけど、MRubyCSはあくまでVM部分の実装なので、Rubyのパーサーよって実装される機能には自動で追従していけるはずである。 というわけで、現在のプレビューリリース時点で、MRubyCSはとても高い互換性を持っている。 ```ruby a = [] limit = 3 e = RuntimeError.new("!") for i in 0...3 a.push i * 4 + 1 begin limit -= 1 break unless limit > 0 a.push i * 4 + 2 raise e rescue a.push i * 4 + 3 redo ensure a.push i * 4 + 4 end end assert_equal [1, 2, 3, 4, 1, 2, 3, 4, 1, 4], a ``` ```ruby module M0 def m1; [:M0] end end module M1 def m1; [:M1, super, :M1] end end module M2 def m1; [:M2, super, :M2] end end M3 = Module.new do def m1; [:M3, super, :M3] end end module M4 def m1; [:M4, super, :M4] end end class P0 include M0 prepend M1 def m1; [:C0, super, :C0] end end class P1 < P0 prepend M2, M3 include M4 def m1; [:C1, super, :C1] end end obj = P1.new expected = [:M2,[:M3,[:C1,[:M4,[:M1,[:C0,[:M0],:C0],:M1],:M4],:C1],:M3],:M2] assert_equal(expected, obj.m1) ``` ていうコードの結果は本家mrubyと等しくなる。偉い。 mrubyバイトコードのオペコードは現状で(ほぼ)全て実装済みで、例外や制御フローのための機能や型システムなども実装されている。 実をいうと、上記のコード例は、本家mrubyのリポジトリに入っているテストコードである。そう、mrubyのテストはRubyで書かれているのだ。これは野良VM実装者にとって非常にありがたい構成である。 Rubyのテストコードを使えるおかげで、MRubyCSはC#プロジェクトであるが、本家mrubyとまったくおなじテストをされていることが保証できる。品質の高いテストをつくることは Rubyの仕様をかなりちゃんと把握する必要があり、かなり大変かつ重要な部分であるので、圧倒的に手間が省けたと思う。 mruby本家のテストコードを走らせる、というのは、開発の割と早い段階でのマイルストーンとしていた。 https://x.com/hadashiA/status/1898291863872008399 現在、本家mrubyから拝借させてもらった、[syntax.rb](https://github.com/hadashiA/MRubyCS/blob/main/tests/MRubyCS.Tests/ruby/test/syntax.rb)、[methods.rb](https://github.com/hadashiA/MRubyCS/blob/main/tests/MRubyCS.Tests/ruby/test/methods.rb)、[class.rb](https://github.com/hadashiA/MRubyCS/blob/main/tests/MRubyCS.Tests/ruby/test/class.rb) [module.rb](https://github.com/hadashiA/MRubyCS/blob/main/tests/MRubyCS.Tests/ruby/test/module.rb) あたりの主要な言語機能のテストをパスしている。偉い。 標準ライブラリはまだ揃っていなくて、とくにString 、Hash あたりが抜けてるのでちょっと実用レベルがアレなのだが、この辺もテストは(本家のほうに)あるので、もはや時間と労力の問題となっている。一月以内くらいには揃えられそうな見込みである。(願望) ## で、パフォーマンスは? 実行速度を重視していくとは言ったものの、 残念ながら現状はこれみよがしに公開できるほどの結果を出せていない。 まず、前提として、C# は速い。「速い」の意味はいくつかあるけど、C#はすべての意味の「速い」を戦っている言語のひとつである。 単純なCPUバウンドな処理のシングルスレッド実行速度については、JITコンパイラのネイティブ[[はじめての Performance Improvemnets in .NET|機械語の最適化は進化し続けている]]し、汎用言語のなかではユーザーが明示的にヒープアロケーションを消す機能、コピーも消す機能に優れているのがC#である。 そんなわけで、当初、mruby実装についても、モダンC#で書けばふつうにそこそこ速いんやろ? どうせ? 知ってる知ってる。と考えていた。 結果、どうなったか。現在のバージョン (0.6.0-preview) を自分のマシン (macOS + arm64)で試してみたところ、 単純な数値演算ではオリジナルと比較して 40% 差くらいで負けている、 ```ruby a = 0 while a < 100000 do a = a + 1 end ``` ``` | Method | Mean | Error | StdDev | Allocated | |------------ |-----------:|---------:|---------:|----------:| | MRubyCS | 1,263.5 us | 45.14 us | 29.86 us | 177 B | | MRubyNative | 864.1 us | 9.80 us | 6.48 us | 1 B | ``` 40%負けという結果はまあまあだが、これはほぼオペコード処理のループの性能の比較で、実はフィボナッチ数列とか再帰呼び出しのベンチマークになると、さらに結果が悪くなっていき、4-5倍の差がついて負けてしまっている、というのが現状である。 この結果から、現状は VMのメインループ開始のオーバヘッドが大きく、メソッド呼び出しでメインループがネストするとさらに差が開いてしまうという状況にあることがわかる。 ふーむ ### オペコード分岐 最初にぱっと見で気づく点として、オリジナルのmruby実装(のデフォルト)は、オペコード毎の処理に分岐を使うのではなく、コード上の指定アドレスへ直接goto でジャンプする、というC 言語くらいじゃないとできなそうなアンセーフな方法がとられている。 C#はこのアンセーフな手法をそのまま真似することができないため、オペコードの分岐には巨大なswitch文を使っている。これはネイティブアセンブリでは複数回の分岐命令にされているっぽいので不利な点のひとつ。 ### ポインタ C# は `ref` 参照や `Span<T>` など、GCマネージドなオブジェクトの参照を操作することができるので、ある領域では Cと同等にメモリ操作を最適化するコードを書くことができる。 のだが、もちろんC# はマネージドなランタイムであることを忘れてないので、参照に対してコンパイラが厳しめに安全性をチェックするし、仕様にいろいろと制約がある。 たとえば、連続したメモリ領域への参照を表現できる `Span<T>` は、それ自体をヒープに置けない。 mruby本家実装では、スタックなどの参照にCのポインタが使われていて、このポインタを複数の構造体がシェアしたりしているが、C#では、連続したメモリ領域の途中を示すポインタをオーバヘッドなしでヒープに置いたりとかできないので、仕方なくオフセットを持ったりしている。 ### NaN Boxing / Word Boxing Matz氏本人によるmruby本に載っているので有名な話かもしれないが、mrubyのRuby世界の値 、`mrb_value` 構造体は、NaN Boxingないし Word Boxingという テクニックによって、わずか1ワード(ポインタ1つ分)のサイズに圧縮されている。 mrb_value は 整数、浮動少数、シンボル、オブジェクトの参照 いずれかを表現することができる値なのであるので、普通に実装するともうちょっとサイズが大きくなるわけだが、アンセーフな手法によって1ワードに収めている。 mrubyのVM内では、スタック領域やレジスタ領域などが基本的には mrb_value で埋まっているため、ここのサイズの違いはパフォーマンスに割と影響しそうな部分である。 この手のスクリプト言語の実装ではこの種のテクニックはよく用いられているようで、調べてみるとv8 などのjsエンジンとかでも値の圧縮に苦心しているようである。 - 参考: - https://github.com/mruby/mruby/blob/master/doc/internal/boxing.md - https://v8.dev/blog/pointer-compression 当初、C# でも mrubyの Word Boxingを真似て、`Unsafe.As` などを駆使してマネージドポインタ1つぶんのサイズに収めようとしてみた。 ```cs struct MRubyValue { public static readonly MRubyValue Nil = new(0); public static readonly MRubyValue False = new(0b0100); public static readonly MRubyValue True = new(0b1100); public static readonly MRubyValue Undef = new(0b0001_0100); public static MRubyValue From(bool value) => value ? True : False; public static MRubyValue From(RObject obj) => new(obj); public static MRubyValue From(int value) => new((value << 1) | 1); public static MRubyValue From(long value) => new((value << 1) | 1); public static MRubyValue From(Symbol symbol) => new(((long)symbol.Value << 32) | 0b1_0100); RBasic x; // 中身がオブジェクトの場合はふつうにフィールドに入れる public static MRubyValue From(RBasic obj) => new MRubyValue(x); // それ以外の値型 の場合は 64bit 未満の 数値表現にパックして無理矢理つめる public static MRubyValue From(nint bits) => Unsafe.As<nint, MRubyValue>(ref bits); } ``` 一見、このめちゃくちゃアンセーフな手法は動作する、ように見えるのだが、残念ながら、.NETランタイムのGC管理下にある参照の値は、 実際にはアドレスではない数値をぶち込むと実行時エラーになってしまうことが判明し、あきらめることに。 しかたないので、 参照と、整数/浮動少数などの即値は分けて保持するようにした、というのが次の実装。 ```cs struct MRubyValue { // ... RBasic object; readonly long bits; // ... } ``` 一応、即値はNaN Boxingによって1ワードに収めているが、構造体全体では計2ワードだ。 この実装をしばらく使っていたところ、Akeit0氏が改善案をくれた。 [Change: implement enum reference union by Akeit0 - Pull Request #9](https://github.com/hadashiA/MRubyCS/pull/9) この提案は、GCマネージドポインタにごく小さい数値を入れると別にエラーにならない、という発見を利用している。.NET ランタイムのソースコードとかを調べて裏をとってくれたようである。(へーへー) 新しい実装では、マネージドポインタと即値の計2フィールドを持つ、という点はそのままだが、即値の場合に無駄になってしまう `object` フィールドを有効活用し、ここに小さな数値を入れておくことで型判別用のタグにする、というアイデア。 (くわしくは上記リクなど参照のこと) この改善によって、サイズは16バイトながら、値の型判定のためのビット演算が削減された。 これは自分ではおもいつかなかった おもしろいハックだ。 ### VMメインループ起動のオーバヘッド 現状は、 バイトコードの実行を開始する度に大きめのオーバーヘッドがあるようである。 現在の実装だと、バイトコードを処理するメインループ部分は、ベタ書きのでかい while 文になっている。 https://github.com/hadashiA/MRubyCS/blob/main/src/MRubyCS/MRubyState.Vm.cs#L296 つくっている段階では、「C#のメソッド呼び出しのコストも消えるし、goto文とかによるダイレクトなジャンプも使い放題になるから、ベタ書きでいいんじゃね」程度に考えていたのだが、どうも、巨大なメソッドというのはパフォーマンスのペナルティがある、ようだ。 JIT後のネイティブアセンブリとかを調べてくれていた Akeit0氏によると、でかすぎるメソッドはJITが最適化をがんばらない、らしい。 メソッド内のローカル変数が多過ぎることも遅くなる一因で、以下ではローカル変数を減らすことで実行速度が改善していることが数値で示されている。 [VM Loop optimization by Akeit0 · Pull Request #8 · hadashiA/MRubyCS](https://github.com/hadashiA/MRubyCS/pull/8) うーん、ローカル変数が多すぎなことによるパフォーマンス低下、というのは今まで考えてなかった。C# ランタイムが初期化する処理を挟むためのオーバヘッドがあったり、メモリの配置の局在性が下がることが要因として考えられるようだ。 VM開始のオーバヘッドはできればどうにかしたいところだ。アプローチは大きく2つくらいの方向が考えられる。 1. 巨大メソッド自体の最適化 2. C# 世界と Ruby世界の行き来を減らす Rubyでは、 `do ... end` で表現されるスコープは内部的には `Proc` オブジェクトとなっており、ブロックが実行される度に `Proc#call` が実行されている。つまりブロックがあるたびに理論上は、バイトコード実行が開始される。 バイトコード処理メインループの最中に、中身がバイトコードな `Proc` を実行する といったケースでは、新規に巨大メソッドを起動せずにインラインで処理される実装になっているので問題ない。 が、 C# 世界で実装されたメソッドからバイトコードで実装された Proc を実行する、といったケースは、バイトコード起動のオーバヘッドがProc#call のたびに発生することになる。 単純な実行速度ではバイトコードのほうが圧倒的に遅いはずなので、C#世界との行き来を減らしつつ、できるだけC#で処理させる、といった構成を模索していくことになりそうである。 ### C#で実装してよかったところ C#で実装したことによって、GCとかはまったく実装する必要がなかったし、例外時の long jump もC#の例外を使えば済むのでひじょーに楽だった。 とくに、GC は C#が動く環境ではマルチスレッドな性能の高いものが動いてくれている期待ができるため、こういうところは本家mrubyより有利であるし、また当然ながらmrubyを組み込むにあたっての実装全体のコード量も減る要因になっている。 それから実は、C# 実装のほうが速そうな点もないわけではない。 https://github.com/hadashiA/MRubyCS/blob/main/src/MRubyCS/Internals/Operands.cs ## 今後 とりあえず互換性を意識していたこともあって、現状の実装はおおまかには 本家mrubyの移植をしているだけ、ではある。 今後は さらにC# との親和性を高めたり、JIT 後のネイティブコード量の削減にまで注目した最適化もやっていければいいなと思っている。本家実装にはない工夫をいろいろとかちこめるのは楽しみである。 ネイティブアセンブリを確認する環境であるとか、パフォーマンス面については akeit0氏からとても大きく知見をいただいており、大感謝です。彼はいったい何者なんでしょうか。 現在、プレビュー版としている理由は主に2つの理由から。 - 標準ライブラリというかビルトインライブラリがすべて揃ってないから - Fiberを実装した場合に、APIのシグネチャが変わる可能性がけっこうあるから ていうあたりです。 言い忘れてましたが Fiberがまだ未実装なんです。 とりあえずこのプロジェクトのゴールは [[Unityでmrubyスクリプティング|VitalRouter.MRuby]] をめちゃめちゃ良くすることにあるので、そこまでははりきりたいものです。 ## そのほか 最近は仕事でもUnityをぽつぽついじったりしとります。自作のライブラリが採用されてたりするとなにかと嬉しいですね。 「ぶたのゲーム」、来月中にunityroomとかで公開したいなあと思ってます。絵を描く作業と文を書く作業の終わりが見えない。