CLI 5 実装のベンチマーク

待受け中のメモリ・起動の速さ・大きさ・ビルドの時間を、C# ・ node ・ bun ・ Rust ・ Go で並べて測った

📅 作成: 2026-09-12 / 更新: 2026-09-13

待受け (aichat wait)は 1 プロジェクトにつき 1 本、12 時間張りっぱなしになる。常駐するものなので、待受け中のメモリがいちばん効く。そこを軸に、起動の速さ・ソースと実行ファイルの大きさ・ビルドにかかる時間まで並べて測った。

測った時点で持っていたのは C# 版(aichat-cs.exe)と node 版(src/client/chat.mjs)の 2 本で、node 版は bun でも動く。ここに比較の下限を知るためだけの最小実装として Rust 版・Go 版を足した。後の 2 本は待受けしか持たず、検証も表示もしない。

測ったあとで前提が変わった。Mac ・ Linux にも展開すると決めたため、C# は土俵を降りる。PowerShell で書いた道具も同じである。
CLI は TypeScript (node ・ bun)と Rust の 2 系統で進める。この資料の数字はそのまま使えるが、読む順番が変わる。C# が最小だった項目は、比べる相手がいなくなったという意味になる。
その後 Rust 版を製品として作り、14 コマンドすべてが揃った(i260913-01)。測り直した数字は「分かったこと」の後半にある。

サーバー側は別に測った。サーバーのベンチマークにある。

目次

  1. 分かったこと
  2. 待受け中のメモリ
  3. 起動の速さ
  4. ソースと実行ファイルの大きさ
  5. ビルドと文法チェックの時間
  6. 測り方

分かったこと

待受け中の実メモリは Rust 版が 4.89 MB で、node 版の 1/12 だった。C# 24.90 MB ・ bun 25.59 MB ・ node 58.24 MB とは桁が違う。
ただし Rust 版・Go 版は待受けしか持たない 100 行あまりの最小実装で、製品と同じ仕事はしていない。そのまま置き換えられる数字ではない。

Mac ・ Linux へ展開すると決めたので、残る選択肢は TypeScript (node ・ bun)と Rust になる。その 2 系統で読むと、待受け中の実メモリは bun 25.59 MB 対 Rust 4.89 MB。差は 20.7 MB/本である。
ただし確保量では bun 108.43 MB 対 node 62.44 MB で、bun のほうが大きい。同じ傾向はサーバーでも出た (サーバーのベンチマーク)。処理系の癖であり、どちらのメモリを見るかで結論が変わる。

その後、Rust 版を製品として作った

全 14 コマンドを入れても 6.41 MB だった。上の 4.89 MB は待受けしか持たない 111 行の最小実装で、そこに 13 コマンドと検証を足して増えたのは 1.5 MBである。
「機能を足せば増える」と書いたが、ほとんど増えなかった。

実装待受け中の実メモリ確保量waiters持っている機能
Rust 版 (製品)6.41 MB1.00 MB25 ms14 コマンド
C# 版24.89 MB18.53 MB229 ms14 コマンド
bun 版38.31 MB162.45 MB64 ms14 コマンド
node 版54.20 MB60.50 MB112 ms14 コマンド
Rust 版 (最小)4.89 MB0.84 MB—待受けだけ (111 行)

4 実装とも同じ 14 コマンドを持ったうえでの比較になった。実メモリで node 版の 1/8、確保量では bun 版の 162 分の 1 である。

ここは Windows の API を直に呼ぶことで逆転した。もともと 3 本とも PowerShell を起こしており、その起動だけで 213 ms かかっていた(Rust 523 ・ node 668 ・ bun 673 ms)。CreateToolhelp32Snapshot で一覧を取り、PEB を辿ってコマンドラインを読む形にしたところ、C# 版 (229 ms)を下回った。

実装PowerShellC# へ委譲API 直呼び使った仕組み
Rust 版523 ms274 ms25 msstd の extern(外部クレート不要)
bun 版673 ms310 ms64 msbun:ffi(標準)
node 版668 ms350 ms112 mskoffi(実行時の依存が増えた)
C# 版——229 ms.NET から WMI

PEB とは

Process Environment Block。プロセスごとに 1 つ用意される構造体で、そのプロセスの素性を持っている。コマンドライン・環境変数・カレントディレクトリ・読み込んだ DLL の一覧などが入っている。

ここを読むのは、他プロセスのコマンドラインを取る公式の口が無いため。GetCommandLine() という API はあるが、自分自身のものしか返さない。一覧を取る CreateToolhelp32Snapshot も、返すのは pid ・ 親 pid ・ 実行ファイル名までで、引数は持っていない。そこで相手のメモリを直に読みに行くことになる。

段呼ぶもの取れるもの
1NtQueryInformationProcessPEB の番地 (ntdll の非公開 API)
2ReadProcessMemory PEB +0x20ProcessParameters の番地
3ReadProcessMemory その +0x70コマンドラインの長さ (2 バイト)と、本体の番地 (+0x78 の 8 バイト)
4ReadProcessMemory その番地コマンドライン本体 (UTF-16)

3 段目の「長さ + 番地」の並びは UNICODE_STRING と呼ばれる形で、Windows の内部で文字列を渡すときに使われる。終端の記号を持たず、長さで区切る。

waiters が待受けを見分ける手がかりは wait :ID: というコマンドラインそのものなので、4 段目まで辿れないと道具として成り立たない。

PEB の並びは公開されていない。上の +0x20 ・ +0x70 ・ +0x78 はマイクロソフトが仕様として示している値ではなく、Windows の版が変わると位置がずれうる。
ずれたら黙って PowerShell へ落ちる。waiters は「止めてよい待受けを名指しする」道具なので、誤って数えるより遅いほうがよい。

これで C# 版への依存が切れた。一時は委譲によって「Windows では C# 版が waiters の本体」という向きになっていたが、API を直に呼べるようになって解消した。

測り方を分けてあるのは、上の 5 実装の表が「起動」を見ているのに対し、こちらは張りっぱなしの状態を見ているため。道具も tools/40_test/benchmark-waiters.mjs に分けた (PowerShell ではなく node で書いてある。Mac ・ Linux でも測れるようにするため)。

5 実装を 1 枚に

実装 実メモリ
(MB)
確保量
(MB)
起動 中央値
(ms)
ソース
(行)
実行ファイル ビルド
(ms)
持っている機能
C# 版 24.90 18.54 44.0 2,985 68.5 KB 63 全部
node 版 58.24 62.44 43.6 2,061 — — 全部
bun 版 25.59 108.43 16.9 2,061 — — 全部 (node 版と同じソース)
Rust 版 4.89 0.84 21.1 111 4.63 MB 457 待受けだけ
Go 版 8.08 45.15 24.8 124 8.13 MB 518 待受けだけ

読み取れること

常駐のコストは Rust が圧倒的に低い

実メモリ (Working Set)で Rust 4.89 MB ・ Go 8.08 MB ・ C# 24.90 MB ・ bun 25.59 MB ・ node 58.24 MB。確保量 (Private Bytes)ではさらに差が開き、Rust は 0.84 MB しか確保していない。bun の 108.43 MB とは 129 倍になる。

bun は「軽い」とは言い切れない

実メモリと確保量は別物である。bun は実メモリ 25.59 MB で C# 版と並ぶが、確保量は 108.43 MB で 5 実装のうち最大だった。
実メモリだけを見て「bun は node の半分」と判断すると、空きメモリが減ったときの挙動を読み違える。Claude Code が背面のコマンドを刈るのは、この空きが減ったときである。

起動は bun が最速で、Rust より速い

中央値で bun 16.9 ms < Rust 21.1 ms < Go 24.8 ms < node 43.6 ms ≒ C# 44.0 ms。ネイティブの実行ファイルが bun に負けるのは意外だが、この領域ではプロセスを 1 つ作る手間そのものが大きい。詳しくは 起動の速さ に書いた。

機能の量が違うことを忘れない

Rust 版・Go 版は待受けの 1 コマンドしか持たない。製品の 2 本は say ・ who ・ waiters ・ recent ・ restart などを備え、引数の検証・JST の整形・カーソルの保存まで面倒を見ている。行数で 2,985 行 ・ 2,061 行 に対して 111 行 ・ 124 行 である。

置き換えを考えるなら、常駐する wait だけを別の実行ファイルに切り出す形になる。この資料はその判断材料までで、実装の計画はまだ無い。

node で動かすと、往復ごとに 15 ms 乗る

この資料は起動だけを測っていて、API を叩く時間は測っていない。サーバーを測ったときに分かったことを 1 つ書いておく。

node の fetch は Windows で 1 往復ごとに 15 ms ほど待たされる。同じ node の http.request は 0.18 ms で、85 倍の開きがある。
src/client/chat.mjs は fetch を使っているので、say ・ who ・ recent はこれを踏む。bun で動かすと乗らない。
待受け (wait)には効かない。1 往復が 240 秒なので埋もれる。課題 i260912-02 に残した。

つまり node 版の say は、起動 43.6 ms に 15 ms を足した値になる。bun 版なら 16.9 ms のままである。起動の表だけを見て node と bun の差を 27 ms と読むと、実際より小さく見積もることになる。

待受け中のメモリ

いちばん知りたかったところ。待受けを立ち上げて 10 秒待ち、2 秒おきに 5 回測る。それを 3 回繰り返した平均である。子プロセスが居れば合算する (bun は子を作らないため、どの実装も 1 プロセスだった)。

実メモリ (Working Set)

待受け中の実メモリ (Working Set / MB・小さいほど良い) 015304560 C# 版 node 版 bun 版 Rust 版 Go 版 24.90 58.24 25.59 4.89 8.08
図 1: 待受け中の実メモリ。3 回 × 5 サンプルの平均 (MB)

確保量 (Private Bytes)

待受け中の確保量 (Private Bytes / MB・小さいほど良い) 0255075100 C# 版 node 版 bun 版 Rust 版 Go 版 18.54 62.44 108.43 0.84 45.15
図 2: 待受け中の確保量。bun がいちばん大きい (MB)

数値

実装 実メモリ 平均 実メモリ 幅 確保量 平均 確保量 幅 プロセス数
C# 版24.9024.88 〜 24.9218.5418.50 〜 18.581
node 版58.2458.21 〜 58.3162.4461.74 〜 62.861
bun 版25.5925.56 〜 25.63108.43100.78 〜 112.291
Rust 版4.894.88 〜 4.900.840.82 〜 0.861
Go 版8.087.95 〜 8.2745.1544.98 〜 45.421

単位は MB。幅は 3 回 × 5 サンプル、計 15 点の最小と最大。どの実装もぶれが小さく、待受けはずっと同じ量を掴んだまま動く。

実メモリと確保量の違い

指標何を表すか足りなくなると
実メモリ
(Working Set)
いま物理メモリの上に載っている量 ページアウトされて減る。数字は小さくなるが動きは遅くなる
確保量
(Private Bytes)
そのプロセスが OS に確保させた量 (載っていない分も含む) 減らない。ページングファイルを食い続ける

ランタイムを持つ 3 実装 (node ・ bun ・ Go)は、使う予定の領域をまとめて確保してから、必要になった分だけ物理メモリに載せる。だから確保量が実メモリを上回る。Rust 版は逆で、確保量 0.84 MB に対して実メモリ 4.89 MB — この差は実行ファイルそのものと、OS が貸している共有ライブラリの分である。

何本張れるか

待受けはプロジェクトにつき 1 本。同時に 5 本張ったときの実メモリを単純に足すと、こうなる。

実装1 本3 本5 本10 本
node 版58 MB175 MB291 MB582 MB
bun 版26 MB77 MB128 MB256 MB
C# 版25 MB75 MB125 MB249 MB
Go 版8 MB24 MB40 MB81 MB
Rust 版5 MB15 MB24 MB49 MB

共有ライブラリの分は本数だけ増えるわけではない。同じ実行ファイルを 5 本走らせても、コードの領域は OS が 1 つを貸し回す。上の表は上限の見積もりであり、実際にはこれより少なく済む。

起動の速さ

--help を渡して、起動して 1 行出して終わるまでを測った。ウォームアップ 3 回のあと 20 回。実装の並び順による差を見るため、順番を変えて 2 巡している。

起動して 1 行出して終わるまで (中央値 ms・小さいほど良い) 0153045 C# 版 node 版 bun 版 Rust 版 Go 版 node -e 0 (素) bun -e 0 (素) 44.0 43.6 16.9 21.1 24.8 42.3 18.1
図 3: 起動にかかる時間の中央値。下 2 本 (淡い色)はランタイムを素で起動しただけの参考値
実装最短中央値平均最長1 巡目 中央値2 巡目 中央値
bun 版12.116.917.421.816.917.9
Rust 版19.821.121.828.121.121.9
Go 版23.024.825.129.924.825.0
node 版38.443.644.249.443.644.5
C# 版38.744.044.249.844.044.2
node -e 0 (素)38.942.343.249.742.342.7
bun -e 0 (素)15.418.119.624.420.618.1

単位は ms。1 巡目と 2 巡目の差はどれも 1 ms 前後で、順番による偏りは無かった。

読み取れること

アプリのコードはほとんど乗っていない

node 版 43.6 ms に対して、素の node -e 0 が 42.3 ms。差は 1.3 ms しかない。chat.mjs と 3 つのモジュール、計 2,061 行を読んで構文解析する分がこれである。起動時間の 97% は Node そのものの立ち上がりで、アプリを削っても速くならない。

bun も同じで、bun 版 16.9 ms に対して素の bun が 18.1 ms。誤差の中に埋もれている。

C# 版が node と並ぶのは意外だった

ネイティブの実行ファイルなら速いと思っていたが、44.0 ms で node 版と変わらない。.NET は実行ファイル自体が 68.5 KB でも、走らせるには共有のランタイムを読み込んで JIT を立ち上げる必要がある。そこが node の立ち上がりと同じくらいかかっている。

Rust・Go が bun に負ける

Rust 21.1 ms ・ Go 24.8 ms は、どちらも「何もしないに等しい 100 行あまり」の起動である。それでも bun の 16.9 ms に届かない。
この領域ではプロセスを 1 つ作って、実行ファイルを読み込んで、終了コードを返すまでの OS の手間が支配的になる。実行ファイルが 4.63 MB ・ 8.13 MB と大きいことも効いている。

ただし待受けでは効かない

待受けは 12 時間に 1 回しか起動しない。起動が 20 ms 速いことには、ほとんど意味が無い。効くのは say ・ who のように何度も叩くコマンドのほうで、そこでは bun が有利になる。

ソースと実行ファイルの大きさ

Rust 版・Go 版の行数は参考値である。製品の 2 本と同じ仕事をしていない。待受けの 1 コマンドだけを、検証も表示も省いて書いたものである。

ソースの行数

実装行数内訳
C# 版 2,985 Commands.cs 798 / Waiters.cs 509 / Program.cs 368 / Args.cs 303 / Json.cs 256 / Client.cs 227 / Definition.cs 216 / SinceParse.cs 196 / WaitLog.cs 66 / JstTime.cs 46
node 版
bun 版
2,061 chat.mjs 1,530 / options.mjs 230 / since-parse.mjs 186 / waiters-pick.mjs 115
Rust 版111tools/40_test/wait-rs/main.rs(参考)
Go 版124tools/40_test/wait-go/main.go(参考)

C# 版が node 版より 45% 多い。JSON の読み書き (Json.cs 256 行)が標準で用意されておらず、自前で持っているのが効いている。node 側はこれが JSON.parse の 1 行で済む。

実行ファイルの大きさ

走らせるのに要るファイルの大きさ (MB) 0255075100 aichat-cs.exe aichat-rs.exe aichat-go.exe node.exe bun.exe 0.067 (68.5 KB) 4.63 8.13 98.94 82.11
図 4: 走らせるのに要るファイル。C# 版は棒が見えないほど小さいが、別に .NET ランタイムが要る
ファイルバイト読みやすくこれだけで動くか
aichat-cs.exe70,14468.5 KBいいえ。.NET ランタイムが要る
aichat-rs.exe4,851,6144.63 MBはい
aichat-go.exe8,526,3368.13 MBはい
node.exe103,747,40098.94 MBスクリプトと合わせて動く
bun.exe86,096,98482.11 MBスクリプトと合わせて動く

「小さい」の意味が 3 通りある。
配る量なら C# 版の 68.5 KB が最小。ただし受け取る側に .NET が要る。
1 ファイルで完結する量なら Rust 版の 4.63 MB が最小。何も前提を置かずに渡せる。
すでに入っているものを使うなら node ・ bun は 0 バイト。このマシンにはどちらも入っている。

Rust 版が 4.63 MB もあるのは、外部クレートを 1 つも使っていないのに標準ライブラリを丸ごと抱き込むためである。Go 版の 8.13 MB も同じで、net/http を入れた分だけ大きい。

ビルドと文法チェックの時間

コードを直してから確かめられるまでの待ち時間である。node 版・bun 版はビルドが無いので 0 msだが、代わりに TypeScript の検査を掛けるとどうなるかを測った。

直してから確かめられるまで (中央値 ms・小さいほど良い) 0150300450600 C# 版 csc node/bun tsc Rust 版 rustc Go 版 go build 63 320 457 518
図 5: ビルド (C# ・ Rust ・ Go)と文法チェック (node/bun)にかかる時間の中央値
実装使ったもの1 回目2 回目3 回目中央値対象
C# 版csc856358632,985 行
node 版
bun 版
tsc --noEmit3193203313202,061 行
Rust 版rustc -O457463456457111 行
Go 版go build1,362518435518124 行

単位は ms。Go の 1 回目 1,362 ms はビルドキャッシュが空だったため。2 回目以降は標準ライブラリの再コンパイルが要らなくなり、435 〜 518 ms に落ち着く。

Rust は 111 行に対して 457 ms かかっている。キャッシュの効き方は Go と違い、1 回目から 3 回目までほぼ変わらない。単一ファイルを rustc に直接渡しているので、毎回まるごとやり直している。

TypeScript の文法チェック

まだ TypeScript 化していないので、allowJs + checkJs で .mjs のまま型検査に掛けた。設定は tmp/tsc-check/tsconfig.json に置いた一時のもの。

{
	"compilerOptions": {
		"target": "ES2022",
		"module": "ESNext",
		"moduleResolution": "bundler",
		"allowJs": true,
		"checkJs": true,
		"noEmit": true,
		"strict": false,
		"skipLibCheck": true,
		"types": ["node"]
	},
	"include": ["../../src/client/*.mjs"]
}

320 ms で 5 件のエラーが出た。strict を切った緩い設定でこれである。文法の誤りではなく、型の食い違いを 5 件見つけた。

場所件数何を言っているか
chat.mjs:1047 2 算術演算の左右が数値だと保証できない。文字列が入りうる値をそのまま引き算している
chat.mjs:1147
chat.mjs:1148
chat.mjs:1149
3 Property 'rooms' / 'pid' does not exist on type 'object'。JSON.parse の戻りを object のまま辿っている

どちらも実際に落ちる場所ではない(サーバーが返す形は決まっている)。それでも、320 ms で 5 件の「保証できていない箇所」が機械的に出てくるのは、2,061 行を目で読むより確実である。

検査には @types/node が要る。入れずに走らせると process ・ fs など Node の API がすべてエラーになり、57 件の無意味なエラーに本物の 5 件が埋もれる。
今回は tmp/tsc-check/ に一時で入れて測った。常用するなら、プロジェクト側に開発用の依存として置く判断が要る。この資料はその判断材料までで、まだ入れていない。

測り方

環境

項目版
OSMicrosoft Windows NT 10.0.26200.0
Node.jsv26.8.1
bun1.4.2
Rust1.96.1
Go1.26.4
TypeScript7.0.2
測った日時2026-09-12 21:56

使ったもの

置き場中身
tools/40_test/benchmark-cli.ps1本体。メモリと起動を測って tmp/benchmark-cli.json に書き出す
tools/40_test/benchmark-cli.cmdダブルクリック用のランチャー
tools/40_test/wait-rs/main.rsRust 版の最小の待受け (111 行)
tools/40_test/wait-go/main.goGo 版の最小の待受け (124 行)

測る手順

何をどう測ったか
メモリ --wait-sec 600 で待受けを立て、10 秒待ってから 2 秒おきに 5 回。これを 3 回繰り返して平均を取る。子プロセスも合算する
起動 --help を渡し、ウォームアップ 3 回のあと 20 回。並び順を変えて 2 巡
ソース行数 wc -l。空行とコメントを含む
実行ファイル バイト数をそのまま。MB は 10242 で割った値
ビルド 出力を消してから 3 回。Go だけキャッシュが効くので 1 回目が突出する
文法チェック tmp/tsc-check/ に typescript と @types/node を入れ、tsc --noEmit を 3 回

踏んだ落とし穴

待受けが起動直後に落ちる

初めて繋ぐ相手には案内だけ出して終わる仕様になっている (課題の i260909-01)。そのままだとメモリを測る前に消える。

先に --wait-sec 1 で 1 回走らせてカーソルを立ててから、本番の --wait-sec 600 を立てる。2 回目以降は普通にぶら下がる。

CLI の案内が例外になる

繋ぎ先の案内を標準エラーに出すため、$ErrorActionPreference = 'Stop' のもとでは NativeCommandError になって止まる。

function Invoke-Quiet {
	param([string] $File, [string[]] $Arguments)
	$prev = $ErrorActionPreference
	$ErrorActionPreference = 'Continue'
	try { & $File @Arguments *> $sink } finally { $ErrorActionPreference = $prev }
}

局所的に Continue へ落として、抜けるときに戻す。スクリプト全体を Continue にすると、本物のエラーまで見逃す。

リダイレクト先のファイルが競合する

待受けと起動測定で同じ捨て場を共有していたため、別のプロセスで使用されているで落ちた。毎回 [guid]::NewGuid() で別名にし、プロセスを止めてから 500 ms 待って消す。

Bash から呼ぶと出力が化ける

PowerShell 5.1 の標準出力は CP932 として扱われる。Bash 経由で呼ぶと日本語が崩れるので、先頭で [Console]::OutputEncoding = [Text.Encoding]::UTF8 を立てる。
aichat-cs.exe だけは化けない。Program.cs が自分で Console.OutputEncoding = Encoding.UTF8 を立てているためである。

この測り方の限界