最近の世の中では「Go から Ruby on Railsに移行してみました!」とかいうと、「わかる」「実家のような安心感」「Hotwire/Simulatus独自路線はいい線」「SolidQueueとかええんちゃう?」といった肯定的意見もあれば「わかってない」「こいつはなにもわかってない」「実装からAPIスキーマ生成できないとかダサすぎて逆にオシャレ」などといったRuby批判コメントがどこからともなく飛んでくるといいます。ところがおれはなにもわかっていないせいか、Ruby処理系をスクラッチで実装してしまった。 みなさんが来たるべき夏に向けて家庭菜園でミニトマトとかを育てている間、純粋C#製 mrubyランタイム [MRubyCS](https://github.com/hadashiA/MRubyCS) はプレビュー版を卒業し、制御フロー・例外・型システムだけでなく、同梱RubyライブラリなどのRubyレベルの互換性がほぼ100%となり、いつのまにかFiberサポートもついでのように追加され、実用的に使えるレベルに育ちました。 これで C# が動く場所ではすぐに mruby を使いはじめられるようになりました。すぐにです。時代はC# です。「協調的スケジューリングがあるからまじでなんも考えず書いてるだけでなんかしらんけどスループットでる」とか言っている場合ではありません。我々C#erはもっと先を見ています。C#の上でRuby。それが答えです。 訓練されたUnityユーザーのプロジェクトには当然、[NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity)が入っていますよね? おもむろにUnityツールバーの NuGet -> Manage Nuget Packagesメニューから、 `MRubyCS` と入力してください。そう、MRubyCS はもうプレビューではなく正式バージョンなので、一撃で Install ボタンが表示されます。押しましょう。押すしかありません。 ところで話は変わりますが、 mruby のアーキテクチャのおもしろいところが、mrubyコンパイラとmrubyランタイム(バイトコードマシン) を分離できるという点です。 例) ```mermaid graph TB subgraph host["host machine"] A[source code<br/>.rb files] C[byte-code<br/>.mrb files] A -->|compile| C end C -->|deploy/install| E subgraph application["application"] D[mruby VM] E[byte-code<br>.mrb files] E -->|exucute byte-cose| D end ``` 図のように、我々はRubyというでかいソフトウェアを組み込むとみせかけて、アプリケーションに配信するリソースにはコンパイラを含めないことが可能で、こうすると組み込みに必要なリソースをかなり削減することができます。 MRubyCS は基本的には上記の図でいう vm(バイトコードマシン) 部分の C# 実装となり、コンパイラは独立しています。 さて、Unity パッケージマネージャから 以下の git URL を入れると、Unity上のMRubyCSから簡単に利用できるコンパイラのラッパが利用可能になります。 ```cs https://github.com/hadashiA/MRubyCS.git?path=src/MRubyCS.Compiler.Unity/Assets/MRubyCS.Compiler#0.10.2 ``` これで、 Unity 上で以下のように Rubyが動作するようになりました。 ```cs var state = MRubyState.Create(); var compiler = MRubyCompiler.Create(state); // Rubyコードの実行 var result = compiler.LoadSourceCode(""" def foo(x) = x * 100 foo(2) """); result.ToString() //=> "200" ``` 「UnityでRubyスクリプトが簡単に組み込める」この、時代の先端を行きすぎて振り返っても誰もついてきていない領域に対し、Redditではこんなコメントが寄せられていました。 「UnityはアプリケーションのC# を『スクリプト』と呼称しているわけだが、 C# スクリプトの上にスクリプトって意味あるのかね?それってご飯の上に炒飯をかけて食べるようなものではないのかね? 君?」 そう、Unityエンジン初期、 誰でも超簡単にゲームをつくれるというイメージから、UnityはC++製Unityエンジン部分に対しての C# レイヤーを「スクリプト」と名付けてたんでした。でもこのことは、BumbooScriptの存在と共に一旦忘れてください。 Unity でゲームをつくる場合、そのプロジェクトに特化した絵面やゲームシステムの実装のすべては基本的にはC# で書くことになります。それなりのゲームになってくるとUnityがあらかじめ用意した部品だけで組むというわけにもいかず、C#コードベースは巨大になることは珍しくありません。そしてまた、C#は十分に低レイヤーの制御ができる仕様を持ちますし、最近のUnityはレンダーパイプラインなどのコア機能を徐々にC#化しています。つまり当然のことながら、アプリケーション開発者からみれば C# は 、設計実装すべきこと多岐にわたる立派なゲームシステムプログラミングレイヤーです。 というわけで、形はどうあれ、「ゲームシステム全体のコンパイルが不要でホットリローディングもできてシステムプログラミングに影響を与えないレイヤー」は効果的、というのはある種のセオリーであり、Unity に C# の上にスクリプティングレイヤーを載せる行為は事例も多いです。自分が関わったことのある国民的RPGの冠がついたUnity製プロジェクトでもlua を組み込んでいた思い出があります。 ## ビルトインライブラリ MRubyCS 0.10.0 では、とりあえず mruby 3.3 相当の同梱ライブラリがすべて実装されました。 `Array`、`Hash`、`String`、`Integer`、`Float`、`Range`、`Fiber` 、これらのクラスは、本家mruby と同じテストをパスしています。 標準ライブラリ実装してみての感想はこんなかんじです。 - `Hash` - 実は Ruby のHash は順序の概念を持っており、「先頭のキー」といった概念があること知らなかったので、順序を保持できるようにデータ構造を変更することになった。 - どうも、mruby実装は 要素数が少ない場合は 単に 配列を O(N) で走査する、という実装になっているようだ。 - Array - C# の `Span<T>` を駆使して Copy on Write なデータ構造にした - String - MRubyCS では ruby の文字列エンコーディングは常に UTF8 にしている - C# 文字列は内部表現がUTF16 、かつイミュータブルなため、C# 標準ライブラリをそのまま使うわけにはいかず、UTF8シーケンス操作を実装することになった - UTF8 は ASCII 互換なところは非常に使いやすいしそこは優れていると思うが、文字数のカウントなど、処理内容によってはすぐに線形な走査が必要になってしまい、そこは最適化に難がでてくるのだなあ。そういう意味では エンコードが不要な限りでは UTF16 にも利点はあるのですね - Fiber - Fiber の実装については基本的には mruby 本家の移植になっている - ユーザーから見たFiberは、単に 中断/再開できるコード片だが、内部的にはFiberはそれぞれ個別に専用の実行コンテキストとスタックを持っている。おそらくこの実装方針がFiberを「軽量スレッド」と称している所以なんだろう - javascript の generator や、C# の enumerator、あるいはasync が 状態を包んだstate-machine を生成する、そして特殊なシンタックスを導入しているのとは対称的。 - Fiberは実行コンテキストをもっているため一つ生成するコストは高いが、そのおかげでコールスタックがネストしていても yield できたりするといった機能的な柔軟性がある ## Fiber Fiber が実装されたことで、以下のように、Rubyコードを好きなところで一旦中断して C# へ処理を戻し、C# は持ち前の async/await のノリで 好き放題したあと、またRubyへ制御を戻す、この間スレッドのブロックなし、といったことが可能になりました。 ```cs // Fiberの使用例 var lib = """ def move(x, y) Fiber.yield(:move, x, y) end """; var script = """ move 11, 22 move 11, 22 """; compiler.LoadSourceCode(lib); var fiber = compiler.LoadSourceCodeAsFiber(code); while (fiber.IsAlive) { // Fiber#resume var result = fiber.Resume(); // ruby 世界は yieldでサスペンドされる // yield経由でC#側にメッセージを投げられる var args = result.As<RArray>(); if (args[0] == state.Intern("move"u8)) { var x = args[1].IntegerValue; var y = args[2].IntegerValue; await MoveAsync(x, y); } } ``` こうして見ると Fiberは、 C# - Ruby 間の Channel のようなものと考えることもできそうです。 Channel ということは…そう、C# ならではの機能として、Fiberの再開を await で待機することができます。 ```cs var resumeResul = await fiber.WaitForResumeAsync(); ``` なんと `await foreach` も可能です。 ```cs await foreach (var resumeResult in fiber.AsAsyncEnumerable) { // ... } ``` もはや 「Rubyは型がないからバツ!」 とか言っている場合ではないでしょう。システムプログラミングはC#、システムに影響を与えないDSLに特化したスクリプティングはRuby、と住み分けが可能なのです。 注意点としては、MRubyState はスレッドセーフではないため、Ruby機能にアクセスしたい場合は 元のスレッドでやるか lock が必要です。 一般的なGUIアプリケーションのように、awiat後に必ずメインスレッドへ戻る環境であればそんなに問題にならないかもしれません。使い途はそれなりにあると思います。 ## パフォーマンス 最近はもっぱら互換性向上を目指していたので、パフォーマンスはとくに良くなっていなかったんですが、と思いきや、先日、Akeit0氏からこんなPRをもらいました。 VM 全体の分岐を減らす最適化です https://github.com/hadashiA/MRubyCS/pull/52 PRからの転載ですが、ごく単純な四則演算の比較では 本家を上回る速度が出たようです。 ```ruby a=0.1 while a < 100000 a+=1 a -=0.1 a *= 1.001 a /= 1.001 end ``` ``` | Method | Mean | Error | StdDev | Allocated | |------------ |---------:|----------:|----------:|----------:| | MRubyCS(PR) | 3.084 ms | 0.0158 ms | 0.0105 ms | 176 B | | MRubyNative | 4.415 ms | 0.0419 ms | 0.0277 ms | 3 B | ``` もちろん、実際の複雑なケースになってくると MRubyCSのほうが速い、とは全然言えません。むしろ大抵のベンチマークではすごく負けてると思います。が、C# だから Cより遅い、とかそういう単純な話ではないことが結果からも見えてきてますね。 ## 今後 Fiberが入ったことによって、mruby を使用したフレームワークである [VitalRouter.MRuby](https://vitalrouter.hadashikick.jp/extensions/mruby) の心臓部を MRubyCSにすることが可能になりました。C# との親和性を活かしてより使いやすくできる余地がかなりありそうです。 まずは、MRubyValue を自動的に C# カスタム型 に相互変換する機能を MRubyCS の拡張として追加したいかな、と思っていますが未定です。 ## そのほか #ぶたのゲーム(さくっと出そうとおもっているクソゲー未満) がリリースされるよりも先に MRubyCS にFiberが入ってしまったため、先に #ぶたのゲーム にMRubyCS を組み込むかもしれません。ゲームをつくっていたはずなのにいつのまにか mruby処理系をつくってしまっていることを反省しつつ、来月こそゲーム開発とかそういうのしたいですが、どうなるかわかりません。