MicroArchitectures
H.Ueda
Programmer
ブログ
同じCLIを23言語で書いて「単一バイナリ=速い」を測ってみた
「コンパイル言語だから速い」「単一バイナリだから速い」——なんとなく、そう思っていないでしょうか。この記事では、その直感を実際に測ってみた結果を共有します。
やったことはシンプルです。ワークスペースを管理する小さな CLI(ws)を、同じ仕様のまま23の言語で実装し、同一のベンチマークで「JSON を読んでマッチする」処理の速さを比べました。さらに「外部ライブラリを使ってよい」条件でも測り直しています。全実装は同じ黒箱テスト(13シナリオ)を通しており、挙動はそろえてあります。
最初に結論を書くと、次の3点です。ここだけ押さえれば、あとは読み流しでも大丈夫です。
- 単一ネイティブバイナリでも、速いとは限りません(約40倍の開きが出ました)。
- 外部JSONライブラリは差を大きく吸収します。ただし「効く言語」と「効かない言語」があります。
- ついでに見えた、LLMにとっての「書きやすさ」と「パッケージの導入しやすさ」は、別の軸でした。
測定は macOS(Apple Silicon)で、数値は「1万件のJSONを読んで全走査する時間」から「空データの時間」を引いた差分(中央値)です。プロセス起動などを相殺し、純粋な JSON parse + マッチだけを取り出しています。
1. 単一バイナリ=速い、ではない
まず、各言語の標準ライブラリだけを使ったときの結果が図1です。速い順に並べています。

図1-1 同じJSON処理を各言語のstdlibで測った結果
ここで見たいのは、最速の Zig(5.7ms)と最遅の Janet(220ms)で約40倍の開きがある点です。しかも、遅い側(赤)には Haskell・Clojure・Mojo・C3 といったネイティブにコンパイルされる言語が並んでいます。「コンパイル=速い」「単一バイナリ=速い」は、少なくともこのタスクでは成り立ちませんでした。
重要なのは、遅さの原因が言語ごとに別物だった点です。ひとつの犯人ではありません。
| 遅さの原因 | 該当する言語 | ざっくり何が起きているか |
|---|---|---|
| バイトコードVM実行 | Janet 220 | 「単一バイナリ」でも中身はVM解釈 |
| 実行時リフレクション | Clojure(型ヒント無しで3350→143) | JVMが呼び出しごとにメソッドを名前解決 |
| 文字列の型 | Haskell 123 | String=[Char]=遅延リンクリストを1文字ずつ舐める |
| JSON DOMの確保戦略 | C3 92・Mojo 82・Nim 33・D 45 | オブジェクトごとにハッシュ表を作る+GC |
| JITが温まらない | Java 57 | 一撃で終わるCLIではJITのコンパイル閾値に届かず、全部インタプリタ実行 |
つまり、速度を決めているのは「言語の生CPU速度」ではなく、JSONをどんなデータ構造に落とすかとメモリ管理のモデルでした。Go がリフレクションを使いながら17msで済むのも、汎用のマップではなく型付きの構造体に直接詰めるからです。逆に、遅い言語の多くは「オブジェクトごとにハッシュ表を作る汎用DOM」を組んでいました。
ここで押さえたいのは、「単一バイナリかどうか」と「速いかどうか」は別問題、という点です。
2. 外部ライブラリを許すと、差は吸収されるのか
標準ライブラリだけだと、JSONを持っている言語と、手書きした言語で大きく差が出ます。では、各エコシステムの定番の外部JSONライブラリを使ってよいことにしたら、どうなるでしょうか。手書き・遅DOMだった12言語で測り直したのが図2です。

図2-1 標準ライブラリ(○)から外部ライブラリ(●)への変化
左に動いた(●が緑)ほど速くなった、という見方です。全体としては差が大きく吸収され、遅かった Janet(220→18ms)や D(45→5.8ms)が上位に駆け上がっています。ただ、よく見ると3つのグループに分かれます。
(1) 大きく速くなった(緑)
型付きデシリアライズ(Rust の serde、Nim の jsony)や、ネイティブ実装・コンパクトな表現のライブラリ(Janet の spork、D の asdf、Haskell の aeson)です。共通点は、汎用のハッシュ表ツリーを作らずに済むこと。Haskell は、外部の aeson が ByteString で動くため、標準の String=[Char] という遅い表現から解放されて123→35msになりました。
(2) ほとんど変わらない(オレンジ) OCaml の yojson、Clojure の cheshire、Elixir の Jason です。たとえば Clojure の cheshire は中身が高速な Jackson なのに、143→135msとほぼ動きません。理由は、律速がパース(字句解析)ではなく、1万個のClojure永続マップを作ることだったからです。そこは外部ライブラリを変えても同じです。
(3) むしろ遅くなった(赤) Mojo だけ、82→120msと悪化しました。Mojo は目玉機能のPython連携でCPythonの高速なJSONを借りられるのですが、パース後のオブジェクトをMojo側からたどるたびに言語境界を越えるコストがかかり、純Pythonより遅くなりました。
外部lib込みで最速だったのが図4です。

図4-1 外部ライブラリ込みの最速帯
首位は、なんと純Cの yyjson(1.72ms)でした。SIMDを明示的に使うC++の simdjson(1.89ms)すら、この小さめのデータでは抜いています。「速いJSON=SIMDのC++」という直感も、必ずしも正しくありません。
一行でまとめると、こうなります。外部JSONは「律速がパーサだった言語」の差を吸収しますが、「律速が自前のデータ構造を作ること」(Clojure・Elixir)や「言語境界を越えること」(Mojo)には効きません。勝者に共通するのは、「汎用のハッシュ表/DOMツリーを作らない」というただ一点でした。
3. LLMにとっての「書きやすさ」と「導入しやすさ」
最後に、少し毛色の違う話をします。この23言語の実装は、すべて大規模言語モデル(LLM)に書かせたものです。そこで、LLMが実際に実装した際の体感を、2つの軸で指標化してみました。片方は「言語そのものを書く難しさ」、もう片方は「外部パッケージを導入して動かす難しさ」です。いずれも Go を1とした主観的・序数的な目安で、厳密な測定値ではありません。

図3-1 「書く」難しさ(横)と「外部パッケージ導入」の難しさ(縦)
この2つは、相関はありますが一致はしません。たとえば、
- Rust は書くのは中程度(borrow checker)ですが、外部の導入は最易クラスです。cargo が別格に優秀だからです。
- OCaml は書くのは中程度でも、外部は難しめでした。opam環境の有効化が壊れやすかったためです。
- Mojo・Janet は両方とも難しい側にいます。前者はプレ1.0のAPI変化と実行時のライブラリ解決、後者はパッケージマネージャそのものが壊れていて、Cソースを手で組み込む羽目になりました。
何が難易度を決めていたかというと、
- 「書く」軸では、APIの安定性(プレ1.0で仕様が動く言語は、学習データに非互換な複数版が混ざり、LLMが古いAPIを書いてしまう)と、学習データの量が支配的でした。
- 「外部」軸では、パッケージマネージャの成熟度がほぼ全てでした。cargo・go modules・nimble のように「宣言してビルド」で終わるものは楽で、環境の有効化やブートストラップが要るもの、実行時のdylib解決が要るものほど大変でした。
ここで押さえたいのは、「言語の書きやすさ」と「エコシステムの導入しやすさ」は別の軸だということです。片方が良くても、もう片方が良いとは限りません。
まとめ
同じCLIを23言語で書いて測ってみて、見えたことは次の3つです。
- 単一ネイティブバイナリは、速度を保証しません。速さを決めるのはJSONの表現戦略とメモリモデルで、遅さの原因はVM・リフレクション・文字列型・DOM確保・JIT未加熱と、言語ごとに別物でした。
- 外部JSONは「パーサ律速」の言語の差を吸収しますが、「データ構造の生成が律速」や「言語境界の越境」には効かず、Mojoは逆に遅くなりました。そして最速は純Cの yyjson でした。
- LLMの実装難易度は、「コーパスの量×APIの安定性」(書く軸)と「パッケージマネージャの成熟度」(外部軸)でほぼ決まり、この2つは別の軸でした。
「なんとなく速そう」「なんとなく書きやすそう」という直感は、測ってみると案外ずれています。小さな題材でも、同じ仕様で横に並べて測ると、こうした構造がはっきり見えてきます。
本記事の測定データ・全実装・図の生成スクリプトは、同一リポジトリにまとめてあります(stdlib版のスコアカード、外部JSON版の詳細、総括ドキュメント)。数値はいずれもmacOS/Apple Siliconでの一例で、ワークロードや環境が変われば順位は変わり得ます。