【AI Shift Advent Calendar 2022】bun.jsの中身を覗いてみよう

こんにちは
青野(brn)といいます
株式会社AI Shiftで開発責任者してます

こんな人間です

株式会社AI ShiftのAdventCalender 22日目です

今更ですがBunについてです
全然追えていなかったんですが、ひとしきり騒ぎが終わったっぽいのでブログのネタとして調べてみることにしました

一応2018年くらいから V8 のContributorしたり、こんな記事
https://abcdef.gets.b6n.ch/entry/2019/12/11/121840
https://abcdef.gets.b6n.ch/entry/2019/07/22/142510

を書いているので、まあそのシリーズの一環として場所を会社のテックブログに移してやっていこうかと

Bun

2022にReleaseされたJavascript RuntimeでBundlerなヤツ
ウリとしては

  • 速い
  • All in one
    • ts/tsx/es modules/node_modules/napiやらに対応
  • javascript core(JSC)使ってますよ
    ってことらしいので念頭においてコードベースを眺めてみました

First Impression

これは..なかなか勢いで開発されているコードベースでした
zig言語を使っているようで、読みやすいんだけど読みづらい不思議なコードが多かったです
あとコメントないので何やってるか調べるのがなかなか苦痛

Zig

まずzig言語なんですが、C++、Rust大好きdeallocateとか呼びたくない系男子としてはRAIIが無い言語はもう辛いです
c大好きっ子はいいと思うんですが
それ以外の言語仕様は割と現代的で面白い言語だなと思いました

でも一切前提知識なしで読んだのに読みやすかったので、そういう意味ではC++とかよりも良い言語かもしれないですね
GCの無いgoみたいな

最近はRustだったりgoだったりJSより速い言語でBundler作るのが流行ってますが、その流れなんでしょうね
Bundlerのパフォーマンスで生産性も大きく変わるので大事なポイントではあるものの、old jserとしてはやや寂しいですね

さてBunに戻ると、Bunはこのzig言語でほぼ記述されています
で最初はJavascript Coreをただ導入しただけのなんちゃってruntimeかなとか思っていたんですが、Bundler/Transpilerも兼ねているということでちゃんとJSのParserが実装されていました
js_parser.zigがBundle/Transpileで使われるParserとなります

このParser見てもらえるとわかるんですが、javascriptとTypeScriptとjsx/tsxをParseします
これをスクラッチから書くとかなりきつそうだな..という印象です
javascriptの構文は年代を経るたびにparser unfriendlyになっていくので、このParserをスクラッチで書ききったのはようやったなと思いました
ちなみにParser自体は再帰下降構文解析でした
JSは一応LL(1)でパースはできるのでだいたいどのエンジンも同じような実装になりますね

ただ、気になるのはParserにテストが全然ないんですよね..

ちょっと意地悪なコードをいくつか送り込んでみると、成功するだけじゃなくて、落ちるケースもありました
例えばtest262からこんなコードを拝借すると

'use strict'; if (1); else function a(){}
bun will crash now 😭😭😭

となりました
ちなみにこのコードはjavascript VMがearly errorとして検知して報告しないといけないエラーです
strict mode時にはelse節にはfunction declarationを置けません

他にも

({set a([]){'use strict'}})

みたいなコードも通っちゃったりします
このコードはstrictモード外で関数本体にてstrict modeの宣言をした場合、通常の引数以外はエラーとなる仕様のテストです
本来は引数に分割代入が現れて関数本体にuse strictがある場合はエラーとなります

とまあ自前Parserに問題は結構あるのでまだブラッシュアップが必要な状態かなと思います
でも思ったよりtest262通ってたのですごい

javascript core

さて、うりのJSC組み込みですが、結構丁寧にzig<=>c++/cバインディングを生成していたり、DOMJITをしっかり活用していたりとなかなか使いこなしていて見事
Webkit自体にもPatch当てて用意しているみたいでこちらも頑張ってますね

Startup time

でパフォーマンスの話になるわけですが、V8を活用したRuntimeであるNode/denoとの比較をよく見ますが、V8とJSCの実装方針の違いが大きいのかなと思いました
とくによく言われているStartup TimeはJSCがグローバルオブジェクトやprototypeを起動時に初期化していないのが影響しているように見えます
https://github.com/WebKit/WebKit/blob/6b1eb391bf3d0bf4fa2b3834e61df0a4dfa95c11/Source/JavaScriptCore/runtime/JSGlobalObject.cpp#L824
あたりからinitLater祭りなんですが、これはアクセスされるまではPointertag bitを立てておいて、アクセスされたタイミングで値をPointerに突っ込む機能です

StartUp Timeが速いのは、それはそれで最近流行りのEdge ComputingやServerlessには非常に向いているのではないかと思います

JIT

あとよく言われるV8とJSCの違いとしてはJITのTier数でしょうか
Tierというのは要は段階です
V8は3段階の最適化パイプラインを持っていますが、JSCは4段階です

V8

  1. Bytecode Interpreter(Ignition)
  2. Baseline JIT(sparkplug)
  3. Full JIT(TurboFan)

V8は2.がない時代ですが、資料書いたので参考にしてください
https://speakerdeck.com/brn/source-to-binary-journey-of-v8-javascript-engine

処理はまず、Bytecode Interpreterから始まります、
このBytecode InterpreterはBytecodeから対応するAssemblyのスニペットを実行するような形で実行されます

その後、実行回数がある程度増えてくると、2段階目のSparkplugへ移行します
Sparkplugは1のBytecodeをlinerに巡回して、対応するMachine Codeを生成します
その際、IR(中間表現)を生成しないため、結果として非常に単純なMachine Codeが生成されます
IRがないため、最適化等はほぼ実施しませんが、1と3をつなぐための中間Tierなので問題ありません

その後さらに実行回数が増え、HotSpotが見つかった場合
3のTurboFanが実行されます
ここではIRを作成して、各種最適化を実施の上、分岐を取り除いたMachine Codeが作成されます

JSC

  1. LLInt
  2. Baseline JIT
  3. DFG JIT
  4. FTL JIT

処理はまず、1.LLInt(Low Level Interpreter)から始まります、
このLLIntもV8と同じようにBytecodeから対応するAssemblyのスニペットを実行するような形で実行されます

その後、実行回数がある程度増えてくると、2段階目の2.Baseline JITへ移行します
ここもV8と同じく最適化等はほぼ実施しませんが、中間Tierなので問題ありません

その後さらに実行回数が増え、HotSpotが見つかった場合
3.DFG JITが実行されます
DFG JITはコードをCPS(継続渡しフォーム)に修正して、Data Flow Graph(変数の生存期間解析グラフ)からJITコードを生成します

DFGではそこそこの最適化が実施されてDead Code Elimination等が実施されます、また変数の型情報をLLInt、Baseline JITから受け取ってコード生成します

4.FTL JITはSSA Form(静的単一代入)という形式に変換後、最高レベルの最適化を実施します
(著者がJSCに詳しくないので雑ですいません)

とJSCの方が数は多いです(最近までV8は2Tierでした)
が、数が多ければいいわけではなく、Tierが増えれば保持すべきcode量も増えるし、最適化のパイプラインも走るので、どちらがいいというわけではないです
また、Tier数が多いとコード自体の複雑さも上がっていきます(Tierに応じたJIT Infraが必要となります)

Tierを増やす理由はengineの起動時間とcodeの実行時間のバランスをとるためです
基本的にどのエンジンもTierの後半になるにつれてコンパイル時間・負荷が上がります
そのため、起動時間の高速化のために、最初は最適化されていないTierから開始します

またjavascriptは動的型付け言語なので、変数に束縛されている値の型が変わることがあります
その場合、最適化されているコードだと処理ができないため(基本的に統計情報を元に型を決め打ちしたコードを生成するため)、脱最適化(以前のTierに戻って、より汎用的なコードに戻す)という処理が必要なのですが(Bailoutといいます)、
Tierが少ないとTier間の性能差が顕著なので、行き来するコストが非常に高くなり性能の劣化につながります
なのでjavascriptの最適化Tipsとかで変数の型を変えるな!とか言われるわけですね


Tierが少ない場合Tier1 => Tier2を頻繁に行き来すると負荷が高く
Tier1滞在時間が伸びるため、平均速度も低下する


Tierが多い場合Tier間の性能がグラデーションのようになっているため、Tier間を移動しても負荷が低く済む
また、なんとか高Tierに滞在しようとするため、平均速度も向上する

余談ですがMicrosoftの研究だと
https://microsoftedge.github.io/edgevr/posts/Super-Duper-Secure-Mode/
みたいなものあり、要はJITはセキュリティホールを作るけど、パフォーマンスにいうほど寄与してなくない?
みたいな研究もあったりします

ということで、パフォーマンス比較は結構大きめなアプリケーションを長いスパンで走らせないといけないし、JITが効いているか、どのStageにいるか、プロパティアクセスはキャッシュされているかとか、複雑な要因が様々に絡み合っており、
アプリちょっと動かして比較してみたぜ!速いぜ!とかはならないわけですね
また、JS VMのFrontendであるRuntime側が、実行速度を強く主張する意味があるのかはよくわかりません(最終的にJS Engineの差でしかない)
ま、速いことは悪いことではないんですが..

どっちかというと、「JSCはStartup Timeが速いので、Edge Computing/Serverlessの実行エンジンとして強みを活かす」みたいな方が、わかりやすいし誇大広告みたいな言われ方をしなくて済んだのではないかなぁと思ったりしました

ffi

あとFFIがとっても速いということだったんですが、TinyCCという
軽量Cコンパイラでjust-in-timeでコンパイルしているから速いそうなので、見てみると
https://github.com/oven-sh/bun/blob/e7a14f857d74fc6d61dad4414f01d8621ecc8262/src/bun.js/api/ffi.zig#L345
ここでDynamicにdlopenに渡されたJSのオブジェクトをマッピングしているようですね

実際に使う場合こんな感じなんですが

const {
  symbols: {
    // sqlite3_libversion is the function we will call
    sqlite3_libversion,
  },
} =
  // dlopen() expects:
  // 1. a library name or file path
  // 2. a map of symbols
  dlopen(path, {
    // `sqlite3_libversion` is a function that returns a string
    sqlite3_libversion: {
      // sqlite3_libversion takes no arguments
      args: [],
      // sqlite3_libversion returns a pointer to a string
      returns: FFIType.cstring,
    },
  });

console.log(`SQLite 3 version: ${sqlite3_libversion()}`);

dlopenに渡した第2引数にCオブジェクトをマッピングするんですが、引数・戻り値型から、その場でCのコードを生成して、
もともと用意されているBunのRutimeが内包されているヘッダを付与して、tinyCCでコンパイルしているようです
で、その結果の関数ポインタをJSCのJSFunctionでwrapして返すようです
https://github.com/oven-sh/bun/blob/e7a14f857d74fc6d61dad4414f01d8621ecc8262/src/bun.js/api/ffi.zig#L383
JSC.NewFunctionのところです

というわけで、予めffiを用意するのではなく必要な呼び出しをjust-in-timeでコンパイルしているため速いということでした
速さは確認してないんですが、とりあえずドキュメントで言っている通りのことは実行されているようです
こういう強引だけど、確かに速いかもねみたいな構成は結構好きです

しかしJSC埋めて、TinyCC埋めてと、なかなか富豪的なRuntimeですな

HttpParser

あと、個人的にはあまり詳しくないのであれなんですが..
会長(@yosuke_furukawa)がスライドを作ってくれていました

https://speakerdeck.com/yosuke_furukawa/bun-first-impressions?slide=30
この辺にHttpParserの差がのっています

これがServerの速度差につながっているのかもしれないですね
(余談ですが、上記のスライド見ればBunのことがだいたいわかるのでこの記事読まなくてもいいです。最後の章なんで言いますけど)

まとめ

気づけばJITの話になってしまった..

機能が多すぎなので、興味があった場所だけ覗いてみました
npm install的なのが速いよとかは見てないです

あと、BunとNodeのパフォーマンス比較してくれていた人がいたので
https://tsh.io/blog/bun-benchmark/
見てみると、まあそうですよね的な結果でした
ただ、Startup Timeも僅差だったので、JSC入れた意味あるのか..?と思ってしまった..

ただコードベースも勢いがあって、誰しもが一度は考えるようなことをある程度ちゃんと実現できていて、JSCにもPatch適用してまで動かしていて、結構なコード量を頑張って書いているところを見ちゃうと、こういう情熱があるプロジェクトはやっぱ応援したくなりますね
数少ないJSC runtimeとして頑張って欲しいです

Winter CGみたいなのも始まって、JS Runtimeが増えて競争も激化してきたので、また面白いRuntime、Engineがでてくるといいですね

この辺で

今絶賛エンジニア募集中なので、フロント・サーバ問わず事業にご興味があるかたはご連絡ください!
まずはカジュアル面談からお話できればと思います
ご連絡はこちらのTwitterアカウントまでお願いします

明日は開発チームの水谷から「GitHub Actions で便利だった機能 3 選を紹介します」という記事が公開される予定です。こちらもご覧いただけると幸いです。

PICK UP

TAG