ブログ

同じCLIを23言語で書いて「単一バイナリ=速い」を測ってみた

「コンパイル言語だから速い」「単一バイナリだから速い」——なんとなく、そう思っていないでしょうか。この記事では、その直感を実際に測ってみた結果を共有します。

やったことはシンプルです。ワークスペースを管理する小さな CLI(ws)を、同じ仕様のまま23の言語で実装し、同一のベンチマークで「JSON を読んでマッチする」処理の速さを比べました。さらに「外部ライブラリを使ってよい」条件でも測り直しています。全実装は同じ黒箱テスト(13シナリオ)を通しており、挙動はそろえてあります。

最初に結論を書くと、次の3点です。ここだけ押さえれば、あとは読み流しでも大丈夫です。

  • 単一ネイティブバイナリでも、速いとは限りません(約40倍の開きが出ました)。
  • 外部JSONライブラリは差を大きく吸収します。ただし「効く言語」と「効かない言語」があります
  • ついでに見えた、LLMにとっての「書きやすさ」と「パッケージの導入しやすさ」は、別の軸でした。

測定は macOS(Apple Silicon)で、数値は「1万件のJSONを読んで全走査する時間」から「空データの時間」を引いた差分(中央値)です。プロセス起動などを相殺し、純粋な JSON parse + マッチだけを取り出しています。


1. 単一バイナリ=速い、ではない

まず、各言語の標準ライブラリだけを使ったときの結果が図1です。速い順に並べています。

図1 stdlibランキング

図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 stdlib→external

図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 最速帯

図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 難易度2軸

図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つです。

  1. 単一ネイティブバイナリは、速度を保証しません。速さを決めるのはJSONの表現戦略とメモリモデルで、遅さの原因はVM・リフレクション・文字列型・DOM確保・JIT未加熱と、言語ごとに別物でした。
  2. 外部JSONは「パーサ律速」の言語の差を吸収しますが、「データ構造の生成が律速」や「言語境界の越境」には効かず、Mojoは逆に遅くなりました。そして最速は純Cの yyjson でした。
  3. LLMの実装難易度は、「コーパスの量×APIの安定性」(書く軸)と「パッケージマネージャの成熟度」(外部軸)でほぼ決まり、この2つは別の軸でした。

「なんとなく速そう」「なんとなく書きやすそう」という直感は、測ってみると案外ずれています。小さな題材でも、同じ仕様で横に並べて測ると、こうした構造がはっきり見えてきます。


本記事の測定データ・全実装・図の生成スクリプトは、同一リポジトリにまとめてあります(stdlib版のスコアカード、外部JSON版の詳細、総括ドキュメント)。数値はいずれもmacOS/Apple Siliconでの一例で、ワークロードや環境が変われば順位は変わり得ます。