C# Kaigi 2026

C#でブラウザを自作して学んだ

高速化と、効率的なメモリの使い方

JavaScriptエンジン Okojo
ブラウザ / レンダリングエンジン Falconet

akeit02026年9月19日(土)docomo R&D OPEN LAB ODAIBA

自己紹介

akeit0のプロフィール画像

  • 名前:akeit0
  • 読み方:あけいと
  • 所属:株式会社バーチャルキャスト(2025年新卒入社)
  • 趣味:C#
  • GitHubgithub.com/akeit0
  • X@Akeit0_

発表では、この資料全体を自作ブラウザで表示した

いま動いている構成
Marp HTMLこの発表のスライド
OkojoJavaScriptを実行
FalconetHTML / CSSを処理
Rust + wgpu画面へ描画
JS
HTML
/ CSS
DOM / styleの更新
描画データ
ページ送りの操作も、OkojoでJavaScriptとして実行する。

JavaScriptの実行から画面の更新まで、実際に動く実装の話

Part 1 / Okojo

JavaScriptの実行

実行中の仕事を、どう減らしたか

値を置く → 関数を呼ぶ → 検索を再利用する

なぜJavaScriptエンジンまで自作したのか

  1. 開発に関わった言語処理系を高速化した
  2. 設計でcostが変わると分かった
    • value representation・VM・JITを一緒に見る
  3. 最初から設計して試したくなった
    • JavaScriptエンジン Okojo

「使えるエンジンがない」などの需要よりも、自分で作りたかった

JintとOkojoは、実行までの作りが違う

実行前の準備 準備後の実行
Jint parse → ASTに対応するinterpreterを準備 ASTをtree-walk評価
Okojo parse → bytecodeへcompile VMでbytecodeを実行
  • Okojoは先に命令列を作り、繰り返す処理を単純にする
  • JintのJavaScript値は、抽象class JsValueの派生型で表す
  • Okojoは16-byteのJsValue structで表す
  • object中心では、Jintの参照型がallocationで有利なcaseもある

Jint由来の12 workloadsを準備と実行に分けて比較

比率は Okojo / Jint。1未満ならOkojoの時間・allocationが小さい。

工程 平均時間が短いcase time比 allocation比
準備(parse + compile) Jint:12 / 12 1.0–2.4×¹ 0.4–1.1×
準備後の実行 Okojo:9 / 12 0.5–1.6× 0.4–1.1ײ
  • array-stress:Array操作、linq-js:LINQ風libraryの定義
  • 3d-cube:matrix・vector計算、ほかにJSON parse、eval、RegExpなど

¹ minimalは3.5× ² evalは9.3×

2026-09-12 / BenchmarkDotNet ShortRun / Jint 4.16.1

Okojo benchmark (5ee59d1) / Jint.Benchmark (cd4037d)

実行性能の代表例:関数呼び出し

let identity = function (x) { return x; };

let s = 0;
for (let i = 0; i < 5000; i = i + 1) {
    s = identity(i) + 1;
}
2026-09-11 / executionのみ 時間
Jint 903.46 µs
Okojo 282.79 µs
Okojo / Jint 0.31

ShortRun / TieredPGO=0。測定対象は、5000回のcallを含むloop全体。

実行性能のもう一例:Math builtin

for (let i = 1; i <= 2000; i++) {
    s += Math.sin(i) + Math.cos(i) + Math.sqrt(i);
    s += Math.log(i) + Math.pow(i, 0.5) + Math.imul(i, 3);
}
2026-09-11 / executionのみ 時間 allocation
Jint 1,321.75 µs 1,063,536 B
Okojo 702.95 µs 0 B

Math.trunc / log2 / log10と定数参照も含む。Okojo / Jint = 0.53

設計の出発点は、V8 Ignition

参考にした設計 Okojoでの実装環境
opcode set・compile結果 C#のvalue type
accumulator + register VM CLRのmanaged reference / GC
stack / call frame JIT / Span<T>
property accessのIC / feedback slot OkojoのShape / inline cache

参考にしたarchitectureを、C# / CLR GC / JITの上でどう実装したか

この関数呼び出しで、何が起きるか

function add(a, b) {
    return a + b;
}

add(20, 22);
  • 引数:20 / 22
  • 計算:add
  • 戻り値:42

値の置き場所と、call / returnの実装を見ていく。

registerとaccumulatorで、値を受け渡す

function add(a, b) { return a + b; }
bytecode上の操作
Ldar r1 r1 = 22accumulator = 22
Add r0 r0 = 20accumulator = 42
Return 42を戻す
  • register:引数・local variable・一時値
  • accumulator:命令間の計算結果

ここでのregisterはVM上のslot。CPUの物理registerとは別。

関数ごとのframeを、1本のstackに積む

CallFrame header + registersを、同じJsValue[]の後ろへ積む。

JavaScriptの関数呼び出しに、C#の再帰を使わない

C#の再帰で表す場合 OkojoのVM loop
Run(script)Run(add)Run(foo) CALL:frameを積み、fp / pcを切り替える
C#のcall stackも深くなる RUN:calleeのbytecodeを実行する
returnのたびにC#へ戻る RETURNCallerFp / CallerPcを復元する

通常の同一Realm内bytecode callを対象とする。

JavaScriptのcall stackは、自前のframe stackで管理する。

C#へ渡すためだけの、引数配列を作らない

foo(10, 20, 30);
public ReadOnlySpan<JsValue> Arguments =>
    Realm.Stack.AsSpan(ArgumentOffset, ArgumentCount);
  • 引数はすでにVM stackの連続領域にある
  • CallInfoはoffsetとcountを持ち、元の領域を参照する

callback用の追加配列と、そこへ詰め直すcopyをなくす。

JavaScriptの値を、16バイトのJsValueにそろえる

NaN boxingで、同じ8 bytesをdoubleのbit列またはtag付きの値として使う。

structの代入にも、GCの仕事が入る

target[i] = source[i];
  • JsValueは16 bytesのvalue type
  • numberを運ぶときも、型にはobject? Obj fieldがある
  • heap memoryへの代入で、JIT出力にCORINFO_HELP_ASSIGN_BYREFを確認

runtime / tier / 代入先によって生成codeは変わる。

16 bytesのstructでも、単純なbit列のcopyとは限らない。

値のbit列と参照を、別々に書き込む

Unsafe.AsRef(in dst.U) = src.U;
if (src.Obj is null)
{
    Unsafe.AsRef(in dst.Obj) = null;
}
else
{
    Unsafe.AsRef(in dst.Obj) = src.Obj;
}
  • Uは共通で先に書く
  • Obj is nullでも、古い参照をnullで消す
  • 参照があればwrite barrierを残す

copyの時間は、値の種類で変わった

for (var i = 0; i < source.Length; i++)
    Copy(ref target[i], in source[i]);
input assignment candidate
numeric 0.920 0.554
reference 1.107 1.200
mixed 1.489 1.138

1,024要素 / 2026-09-17。reduced probeの結果であり、engine全体の改善率ではない。

item.scoreを、毎回名前から探したくない

let total = 0;

for (const item of items) {
    total += item.score;
}
  • 同じaccess siteでscoreを繰り返し読む
  • Dictionary<string, …>ではhash計算と候補keyの文字列照合が残る
  • bucket / entry / valueが分かれ、連続配置しにくい
  • 以降はown data propertyに限定

まず、初回に名前から値を取り出す経路を見る。

プロパティ名を、Atomの整数IDにする

文字列 intern後のAtom ID(説明用)
"id" 17
"name" 31
"score" 42
  • 同じ名前なら同じIDが返る
  • property layoutでは文字列ではなく整数で照合する
  • Atom IDとslot番号は別の値

文字列の比較を、整数IDの比較へ変える。

初回:8バイトのEntryから、保存先を探す

const item = { id: 7, name: "apple", score: 80 };

同じShapeなら、同じプロパティが同じslotにある

const a = { id: 1, name: "A", score: 80 };
const b = { id: 2, name: "B", score: 95 };

同じShapeならproperty配置も同じで、scoreは同じslotにある。

2回目:Shapeが一致すれば、検索を省く

初回は Atomから検索。cache hitでは Shapeを確認してslotへ直行

Part 2 / Falconet

HTML Rendering

DOM / CSSを変えたあと、どこまでやり直すか

差分を調べる → 影響を伝える → 必要な範囲を再計算

HTML rendererを作った動機

作りたかったから

色を変える。幅を変える。必要な処理は同じ?

box.style.color = "red";
box.style.width = "240px";
変更 geometry 周囲への影響
color 通常は不変 子孫のstyle更新はあり得る
width 変更 wrap / sibling / parent

どちらもCSSの変更。でも、やり直す仕事は同じではない

Styleの差分で、Layoutをやり直すか決める

  1. DOM / CSSの変更からcomputed styleを更新する
  2. style差分を調べ、後段へのimpactを分類する
  3. 必要ならsize・positionをLayoutで再計算する
  4. Paintが描画dataを更新する
差分 Layout Paint
colorのみ 前回の結果を再利用 更新
width 再計算 更新

毎回すべての工程をやり直すのではなく、前回の結果を使う。

同じstyleをIDで共有し、差分を比べる

group 変更前 StyleId #120 変更後 StyleId #145 判定
Box #24 #24 同じIDを共有
FlexGrid #09 #09 同じIDを共有
Paint #57 #91 変更あり
Transform #03 #03 同じIDを共有
Text #11 #11 同じIDを共有
  • IDは説明用。変わったgroupからproperty単位の差分・impactを調べる
  • Boxだけでなく、TextやFlexGridなどを含めてLayoutへの影響を判断する

同値は共有する。変わった部分から、後段への影響を分類する。

colorはPaintへ、widthはLayoutへ

box.style.color = "red";
box.style.width = "240px";
styleの差分 必要な処理
Paint group:color Paint(Layout結果は再利用)
Box group:width Layout + Paint
  • colorはinheritするため、子孫のstyle更新が必要になる場合もある
  • Layoutを再利用できることと、style更新が一要素で済むことは別

「CSSが変わった」だけでなく、何が変わったかを渡す。

layoutの状態は、NodeIdから配列を参照する

layout tree(対応を単純化)
NodeData[] — 主なfield
parent #3
box #4
sibling #5
NodeId = index + generation
親子関係もIDと範囲で持つ
index
Style
Layout
Cache
Dirty
3
handles
x, y, w, h
前回の結果
4
handles
x, y, w, h
前回の結果
5
handles
x, y, w, h
前回の結果
Children[]
#4
#5
parent #3の子の範囲

nodeごとにclassやListを増やさず、状態と子IDをarenaへまとめる

dirtyをboolで終わらせず、変更の種類を残す

var box = _styleStore.GetBox(
    _nodes[index].Style.Box);
box.Size = new SizeDimensions(
    width, box.Size.Height);
UpdateBoxStyle(
    index,
    LayoutDependency.Width,
    ref box);
node dirty dependency
対象のbox SelfWidth
parent Descendant
sibling 直接はNone

Width / Height / Intrinsic … 変更内容を残すことで、消すcacheを絞れる

変更に依存していたcacheだけを無効にする

今回の変更:Dirty = Width

cache entryのDependencies 判定
Width | Intrinsic 交差あり → 無効化
Height 交差なし → 保持
(dirtyDependencies & entry.Dependencies) != 0

cacheを保持できても、再利用にはinput constraintなどの確認が必要。

入力が変わらないsubtreeは、前回の結果を使う

変更があった枝を辿る
rootDescendant
containerDescendant
sidebarclean
boxWidth → 再計算
sidebarでcache hitを確認
dependency今回の変更と交差しない
known dimensions既知の寸法が一致
available space使える領域が一致
generation計測情報などが有効
条件一致 → 前回の結果を再利用
cleanでも入力制約が変われば再計算する。子のサイズ変化が親へ波及する場合もある。

変更がないだけでなく、再利用の条件が成立するかを確認する。

描画は、Rust + wgpuの自作rendererへ

C# boundary Rust
DOM / Style / Layout / Paint FrameData Falconet用2D renderer → wgpu → GPU
  • この描画経路はSkia / Velloを挟まない
  • font / GPU周辺のlibraryを扱いやすいことを重視した
  • browser側とrenderer側でcompile / testを分けられる

実行速度の比較ではなく、使いたいlibraryと開発単位でboundaryを選んだ

小さく持つ / 動かさない / やり直さない

小さく持つ

  • JsValue / Atom / SlotInfo / StyleId

動かさない

  • VM stack / CallInfo / Span<T>

やり直さない

  • Shape / inline cache / style差分 / layout cache

値の表現と再利用の単位を変え、実行時の仕事そのものを減らす。

C# Kaigi 2026 / akeit0

ありがとうございました

OkojoとFalconetの実装は、上のrepositoryで公開しています。