## C#で書く高性能 mruby vm - モダンJITとアンセーフ最適化 perf.tokyo #1 ハダシA --- ## 自己紹介 ![[heipu-0-1.png|263x263]] [@hadashiA](https://x.com/hadashiA) https://hadashikick.land/tech/history --- 最適化とわてくし - 2016 Webサービス 鯖・蔵 設計 - 2017 オンラインゲー 鯖・蔵 設計 - 2019 Rustで低レイテンシサーバづくり - 2021 Niantic社さんで位置情報ゲークライアント最適化少々 - 2023 [Cysharp](https://github.com/cysharp) さんの OSS メンテナンスお手伝い少々 - 現在〜 さる企業の組織横断部署のUnityゲー最適化お手伝い少々 --- ## 好きな言語 C# - [hadashiA/VContainer](https://github.com/hadashiA/VContainer) ★2.4k - [hadashiA/VYaml](https://github.com/hadashiA/VYaml) ★400 - [hadashiA/VitalRouter](https://github.com/hadashiA/VitalRouter) ★300 - [hadashiA/MRubyCS](https://github.com/hadashiA/MRubyCS) - etc --- ## C# の現在 - モダンサーバランタイム - Linux ファースト - 直でOCIコンテナイメージ焼けるぞみんな - 最高レベルの低レイテンシ/ 高スループット - いつかのgrpcベンチではRustに勝利 - きいてるかみんな - http/1,2,3 装備 --- ## C# の現在 - Linux/macOS/Windows クロスプラットフォームSDK - ゲーム/GUI - 高速なコンパイル - 最高確度の静的解析/code fix/デコンパイラ --- ## 本日の題材 C# による mruby バイトコードインタプリタ実装 --- [hadashiA/MRubyCS](https://github.com/hadashiA/MRubyCS) - ゲームなどアプリケーションへのRuby組み込みを想定した mruby 実装 - mruby 全オペコードサポート - Ruby型システムと全シンタックス/制御フローのサポート - 全組み込みクラス/メソッドをサポート --- ![[screenshot_matz_oh.webp|400]] --- C#で書かれているということは…? - windows - macOS - Linux - iOS - Android - Unity - Nintendo Switch / Xbox / PlayStation …でRubyが動く --- MRubyCS自体はバイトコードインタプリタ。 - シングルスレッド。CPUバウンド。 --- 「速い」にもいろいろありますが、 - シングルスレッドCPUバウンド - →もっとも純粋な殴り合い..? --- ## AOTだから速いと思ってはいませんか? - Cだから速い? - Rust だから速い? - Zigだから速い? - Goだから速い? --- MRubyCS現在のベンチマーク (2025/8/15) --- ```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 | 2.661 ms | 0.0035 ms | 0.0018 ms | 176 B | | mruby (original) | 4.393 ms | 0.0186 ms | 0.0123 ms | 1B | --- ※四則演算が連続するのは意図的にMRubyCSに有利なケースが選ばれています --- ```ruby def fib n return n if n < 2 fib(n-2) + fib(n-1) end fib(10) ``` --- ### メソッド定義+再帰 | Method | Mean | Error | StdDev | Gen0 | Allocated | | ---------------- | --------: | --------: | --------: | -----: | --------: | | MRubyCS | 25.037 us | 0.3129 us | 0.2070 us | 0.0305 | 352 B | | mruby (original) | 8.510 us | 0.0265 us | 0.0158 us | - | - | 敗北 --- - 教訓1: メモリ上のデータの配置だいじ - 教訓2: C#のJITの進化すごい。どれだけJITに乗れるかの勝負 - 教訓3: コンパイラが賢くなっても言語のオーバヘッドを消すとまだまだその先がある --- ## 1. メモリ上のデータ ![[screenshot_not_all_cpu_operations_are_created_equal.webp]] > これ全部、メインメモリから読むより桁違いに安いんだよね!! [Andrew Kelley - Practical DOD](https://vimeo.com/649009599) ---- ちょっとだけ基礎編です --- ある日、Aさんは C言語 で型を定義したという ```c typedef struct { int32_t x; int32_t y; } P; ``` Aさん「square magnitude計算しちゃうぞー」 ```c P p = { .x = 111, .y = 222 }; ``` ```c p.x * p.x + p.y * p.y; ``` --- Bさん「おれはRustで行く」 ```rust struct P { x: i32, y: i32, } ``` ```rust let p = P { x: 111, y: 222 }; ``` ```rust p.x * p.x + p.y * p.y ``` --- ![[スクリーンショット 2025-08-09 13.42.52.png|400]] --- ![[screenshot_x64_2.png|400]] --- スタックポインタの先に 変数pのフィールドがそのまま連続 | x | | | | y | | | | | --- | --- | --- | --- | --- | --- | --- | --- | | 111 | 000 | 000 | 000 | 222 | 000 | 000 | 000 | - spの先を見たとしても アクセスは高々1回。 - スタックにあると参照局所性/空間局所性 が有利 - CPUキャッシュ乗りやすい --- ![[ss 2025-08-11 22.44.15.webp|286x196]] --- 配列/参照を扱いたいとき ```c P *p = malloc(sizeof(P) * 2); p[0] = (P){ .x = 111, .y = 222 }; p[1] = (P){ .x = 333, .y = 444 }; ``` --- ![[スクリーンショット 2025-08-10 23.09.42.png]] - `rax` の指すアドレスに 111, 222 --- ![[ss 2025-08-11 22.43.10.webp|375x257]] - ヒープアロケーションが発生すると メモリアクセスが余分に必要になる - 参照局所性/空間局所性的にCPU にやさしくなくなる --- Cさん 「C#は速いとhadashiA氏が言っていた。おれはC#で行く」 ```cs class P { public int X; public int Y; } ``` ```cs var p = new P { X = 111, Y = 222 }; ``` ```cs p.x * p.x + p.y * p.y ``` --- スタック上の変数 `p` はポインタ。 ![[スクリーンショット 2025-08-10 23.16.14.png|300]] ポインタの指す先 ![[sample_csharp_stack2.png]] --- - 言語によってインスタンスが単純な値ではない - C#の 「`class`」は ヒープアロケーションを伴う「参照型」 - 汎用的な言語ではかなりありがち --- Cさん「C# って遅いんですね…… 用事があるから帰るわ……」 --- - Pは等値性やセマンティクスからみて value object - サイズも小さいので値として扱うべき ```diff - class P + sturct P { public int X; public int Y; } ``` --- ![[スクリーンショット 2025-08-10 23.17.16.png|300]] - `struct` キーワードは C/Rustと同じように インスタンスが「値」になる - コンパイル時に型が確定しているのでメソッドテーブル不要 - 言語仕様/処理系によって「値」をどう扱うかは大きく異なる --- ### メモリへのやさしさTier A: ユーザ定義型は値。アロケーションは明示的に行う - C言語 (malloc等)/ C++(new,smart ptr) / Zig - Rust (ただし型にたくさんの情報がある) --- A: 値型か参照型か、型レベルで区別 - Swift - C# (.NET) - Rust(アロケータを呼ぶというよりは型で判別) --- A-: エスケープ解析によって暗黙のうちにアロケーションが発生 - Go --- B: ユーザ定義型は原則アロケーション (値型がない) - JVM (Java, Kotlin, Scala, etc) - エスケープ解析によって暗黙のうちに値になる(かもしれない) - ※Project Valhala/inlineなどの試みが存在 --- C: プリミティブ値にも型判別タグが必要 - Ruby/Python/Javascript など --- Rubyなどのスクリプト言語は、変数になんでもかんでも入る ```ruby # mrubyの場合! i = 1 # これは mrb_vlaue f = 1.234 # これも mrb_value o = Hoge.new # これも mrb_value o = 1 # サイズ全部一緒。なんでも入る。 ``` 宣言型はぜんぶ同じ。実行時に型が決まる --- mruby 本家の デフォルト仕様 ![[スクリーンショット 2025-08-10 18.00.48.png|400]] [mruby/doc/internal/boxing.md at master · mruby/mruby](https://github.com/mruby/mruby/blob/master/doc/internal/boxing.md) --- - Word Boxing - → ポインタの値は実際には下位3bitが0 になることを利用 - 下位3bit が0 のアドレスは、ポインタではないただの値であると解釈 - 浮動小数点は表現できるの? - NaN Boxing - → 浮動小数点の NaN は 2^51 種類あることを利用する - NaN として有効である値を別の型と見做す --- ちなみに v8 (javascript) [Pointer Compression in V8 · V8](https://v8.dev/blog/pointer-compression) --- - メリット: メモリ効率がよくなる - デメリット: 整数/浮動小数点などのプリミティブ値を取り出すにはビット演算が必要 --- ![[ss 2025-08-11 22.45.50.webp|500x264]] - mruby vm 内ではおよそすべてのものが `mrb_value` - `mrb_value` のサイズで効率が変わってくる --- ### `MRubyValue` take 1 ```cs struct MRubyValue { RBasic x; // 参照をひとつだけ持つ構造体 // 中身がオブジェクトの場合はふつうにフィールドに入れる public static MRubyValue0 From(RBasic obj) => new MRubyValue0(x); // それ以外の値型 の場合は 64bit 未満の 数値表現にパックして無理矢理つめる public static MRubyValue0 From(nint bits) => Unsafe.As<nint, MRubyValue0>(ref bits); 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); } ``` 計: 8バイト --- ……というのは残念ながら動かなかった - .NET の Precise GC - GC 管理下のマネージドポインタに対し、「アドレス埴として有効な整数だが実はなにも参照していない値」を入れてしまうとクラッシュする模様 --- ### `MRubyValue` take 2 ```cs struct MRubyValue { readonly RObject object; // 参照 readonly long bits; // それ以外はNaN Boxingをキメる } ``` 計: 16バイトで妥協 --- ### `MRubyValue` take 3 ```cs struct MRubyValue { readonly TypeObjectUnion union; readonly long bits; } ``` [Change : change boxing to union by Akeit0 · Pull Request #12 · hadashiA/MRubyCS](https://github.com/hadashiA/MRubyCS/pull/12) せっかく 2フィールドあるので、使ってないフィールドをタグとして使い、プリミティブ値を取り出す際のビット演算を省略! --- ## 2. JIT 値のレイアウトが C/Rust と同等の表現ができると仮定すると、どこで差がでるか? --- C# が マシン語になる迄の軌跡 ![[ss 2025-08-15 12.05.45.png]] ※ dotnet runtime + JIT の場合 --- C#のJIT - DynamicPGO - On-Stack Replacement (OSR) - Tiered Compilation - Object Stack Allocation --- 簡単なデモ --- ### inlining ```cs // 1-12月 が何日あるかテーブル static ReadOnlySpan<int> Days => [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; // テーブルを引いて日数を調べるメソッド public static int GetDays(int month) => Days[month - 1]; ``` --- ```cs [MethodImpl(MethodImplOptions.NoInlining)] public static int GetNovemberDays() => GetDays(11); //=> 30 ``` --- ```cs // いっぱい実行する for (var i = 0; i < 1000; i++) { var result = GetNovemberDays(); // 適当にSleepを入れて Tier 1 emitterが発動するように仕向ける if (i % 100 == 0) { Console.WriteLine(result); Thread.Sleep(100); } } ``` --- ```bash $ DOTNET_JitDisasm="GetNovemberDays" dotnet run -c Release ``` --- #### Tier 0 ![[ss 2025-08-11 15.16.15.png|400]] - `wo` に引数 `11` が積まれ - x1 のアドレスの関数呼び出し --- #### Tier 1 ![[ss 2025-08-11 15.09.02_e.png|400]] - 定数30をただ返すだけ! - Cで書こうがRustで書こうが、これ以上速くなることはありえない --- Tiered-compilation > 1. The method needs to be called at least 30 times, as measured by the call counter, and this gives us a rough notion that the method is 'hot'. > 2. At startup a timer is initiated with a 100ms timeout. If any Tier0 jitting occurs while the timer is running then it is reset. If the timer completes without any Tier0 jitting then, and only then, is call counting allowed to commence. [tiered-compilation.md · dotnet/runtime](https://github.com/dotnet/runtime/blob/main/docs/design/features/tiered-compilation.md) --- ### devirtualization + inlining ```cs interface IValueProducer { int GetValue(); } ``` ```cs class Producer42 : IValueProducer { public int GetValue() => 42; } ``` --- ```cs static readonly IValueProducer producer = new Producer42(); [MethodImpl(MethodImplOptions.NoInlining)] public static int GetValue() => producer.GetValue(); ``` --- #### Tier 0 ![[ss 2025-08-11 15.40.29.webp|500x515]] --- #### Tier 1 ![[ss 2025-08-11 15.38.09.webp|500x291]] - 定数42を返すだけ! - DynamicPGO によって、ホットスポットはアグレッシブに最適化しにいく - 実行時に判明する具象型も ファストパスをつくる --- その他 - inlining - devirtualization - ループ最適化 - 境界チェックはずし - およそC以外の普通の言語全般がする境界チェック - C と戦うための兵器 - Hot/Cold Code splitting --- ### MRubyCSの場合 ```cs for (var i = 0; i < 10000; i++) { mruby.Execute(irep); } ``` --- ```bash $ DOTNET_JitDisasm="MRubyCS.MRubyState:Execute" dotnet run -c Release ``` --- #### Tier 0 ``` ; Assembly listing for method MRubyCS.MRubyState:Execute(MRubyCS.Irep,int,int):MRubyCS.MRubyValue:this (Instrumented Tier0) ; Emitting BLENDED_CODE for generic ARM64 - Apple ; Instrumented Tier0 code ; fp based frame ; fully interruptible (中略...) ; Total bytes of code 56168 ``` --- #### Tier 1 ``` ; Assembly listing for method MRubyCS.MRubyState:Execute(MRubyCS.Irep,int,int):MRubyCS.MRubyValue:this (Tier1-OSR) ; Emitting BLENDED_CODE for generic ARM64 - Apple ; Tier1-OSR code ; OSR variant for entry point 0x74 ; optimized code ; optimized using Dynamic PGO ; fp based frame ; fully interruptible ; with Dynamic PGO: fgCalledCount is 8.997083 (中略) ; Total bytes of code 39096 ``` - OSR (On Stack Replacement)が発動 - 行け行け - やれやれ --- ```cs L2e42: call qword ptr [0x7ffdacbb7af8]; MRubyCS.MRubyValue.From(Int64) L2e48: jmp L30a0 L2e4d: mov rdx, rax L2e50: add rdx, r8 L2e53: jo short L2e57 L2e55: jmp short L2e3f L2e57: call 0x00007ffdac9e1710; CORINFO_HELP_OVERFLOW L2e5c: int3 L2e5d: movsxd rdx, r14d L2e60: add rdx, [rdi] L2e63: jo short L2e73 L2e65: mov rcx, rdi L2e68: call qword ptr [0x7ffdacbb7af8]; MRubyCS.MRubyValue.From(Int64) L2e6e: jmp L30a0 L2e73: call 0x00007ffdac9e1710; CORINFO_HELP_OVERFLOW L2e78: int3 L2e79: lea rcx, [rdi+0x10] L2e7d: movsxd rdx, r14d L2e80: call qword ptr [0x7ffdacbb7af8]; MRubyCS.MRubyValue.From(Int64) ``` インライン化されてもよさそうなのがたくさん残ってる...? --- ![[ss 2025-08-11 16.33.39.webp|500x248]] - 巨大メソッドサイズを小さくすると速くなる!? - [VM optimization by Akeit0 · Pull Request #56 · hadashiA/MRubyCS](https://github.com/hadashiA/MRubyCS/pull/56) --- ヒューリスティックいろいろ [runtime/docs/design/coreclr/jit/inline-size-estimates.md at main · dotnet/runtime](https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/jit/inline-size-estimates.md) - つねに無限に最適化のための探索ができるわけではない - インライン化にもいろいろ戦略がある模様… --- ## 3. mruby 本家のアンセーフ手法 --- ### メインループ https://github.com/mruby/mruby/blob/master/src/vm.c ```cs INIT_DISPATCH { CASE(OP_NOP, Z) { /* do nothing */ NEXT; } CASE(OP_MOVE, BB) { regs[a] = regs[b]; NEXT; } // ... ``` - opcode 100〜 分の分岐 - このマクロは一体…? --- ``` #define CASE(insn,ops) L_ ## insn: { const mrb_code *pc = ci->pc+1; FETCH_ ## ops (); ci->pc = pc; } L_ ## insn ## _BODY: ``` - CASE を書いた位置に `L_{opcode}` ラベルが展開される --- ``` #define NEXT insn=BYTECODE_DECODER(*ci->pc); CODE_FETCH_HOOK(mrb, irep, ci->pc, regs); goto *optable[insn] ``` - goto で直接ジャンプ --- ```c #ifndef MRB_USE_VM_SWITCH_DISPATCH static const void * const optable[] = { #define OPCODE(x,_) &&L_OP_ ## x, #include <mruby/ops.h> #undef OPCODE }; #endif ``` - ソースコード内の `L_{opcode}` のアドレスが optableになっている --- ここまでのアンセーフな業は真似できない --- ### ポインタ ```c typedef struct { // ... mrb_value *stack; const mrb_code *pc; /* current address on iseq of this proc */ // ... } mrb_callinfo; ``` --- ![[ss 2025-08-11 23.24.14-1.webp|213x123]] ![[ss 2025-08-11 23.24.20.webp|198x164]] - Cでは連続したメモリ領域確保後、途中への参照も保持できる - ポインタを複数箇所からグローバルに参照することにも制限がない - 現代の普通の言語では安全のためのオーバヘッドがある - C# の `Span<T>` - Go の スライス - Rust のスライス --- mruby (本家) のオペランド読み込み ```c #define PEEK_B(pc) (*(pc)) #define READ_B() PEEK_B(pc++) ``` ```c #define FETCH_BB() do {a=READ_B(); b=READ_B();} while (0) ``` ```c // マクロを展開するとつまりこう a = *pc++; b = *pc++; ``` --- ふつうの C# ```cs [MethodImpl(MethodImplOptions.AggressiveInlining)] public void ReadOperand( ReadOnlySpan<byte> sequence, out byte operand1, out byte operand2) { operand1 = sequence[pc + 1]; operand2 = sequence[pc + 2]; pc += 4; } ``` - 1. 途中を直接挿せないのでオフセットを足す - 2. 配列の境界チェックが挿しこまれるかもしれない - 3. `[]` がメソッドなのでインライン化されない場合はオーバヘッドに... --- C# のマネージドポインタ ```cs ref var sequence = ref Unsafe.Add(ref sequence, 1); ``` - 実は `ref` で挿した先に対してポインタ演算ができる - 非マネージドポインタとは違い、GCコンパクションに対して安全 - 上のような奇っ怪なコードを書いていくことで、C# 第二形態 --- MRubyCS最適化後 ```cs [StructLayout(LayoutKind.Explicit)] struct OperandBB { [FieldOffset(0)] public byte A; [FieldOffset(1)] public byte B; public static OperandBB Read(ref byte sequence, ref int pc) { pc += 3; var result = Unsafe.ReadUnaligned<OperandBB>(ref Unsafe.Add(ref sequence, (pc - 2))); return result; } } ``` [VM Loop optimization by Akeit0 · Pull Request #8 · hadashiA/MRubyCS](https://github.com/hadashiA/MRubyCS/pull/8) Thanks Akeit0 ! --- ## まとめ - C# 速ー - JIT 速ー