サーバーは Windows サービスとして常駐し、待受けの相手をしている。常駐するものなのでメモリがいちばん効く。そこに say ・ recent の往復と、発言を待受けへ配る遅れを足して測った。
測ったのは node と bun の 2 つだけである。サーバーは TypeScript (node ・ bun)で進めると決めたため、他の処理系は土俵に載せていない。
メモリは、どちらを見るかで勝者が入れ替わる。待受け 10 本で、実メモリは node 56.9 MB ・ bun 37.8 MB (bun が 19 MB 軽い)。ところが確保量は node 31.1 MB ・ bun 135.1 MB で、bun が 4.3 倍を抱える。
say ・ recent ・ 配信の遅れには差が無い。どれも SQLite と配信の仕組みが決めていて、処理系を替えても動かない。
| 項目 | node | bun | 読み方 |
|---|---|---|---|
| 起動 (待ち受け開始まで) | 152.4 ms | 151.0 ms | 測るたびに動く。常駐するので効かない |
| 実メモリ 待受け 0 本 | 54.5 MB | 32.0 MB | bun が 22.5 MB 軽い |
| 実メモリ 待受け 10 本 | 56.9 MB | 37.8 MB | bun が 19.1 MB 軽い |
| 確保量 待受け 0 本 | 29.0 MB | 129.5 MB | node が 100.5 MB 軽い |
| 確保量 待受け 10 本 | 31.1 MB | 135.1 MB | node が 104.0 MB 軽い |
| say | 3.18 ms | 3.13 ms | 差なし |
| recent 50 件 | 0.33 ms | 0.34 ms | 差なし |
| recent 500 件 | 1.31 ms | 0.98 ms | わずかに bun |
| 配信 10 本へ | 27.95 ms | 26.35 ms | 差なし |
測る前は「待受けを何本も抱えると重くなる」と思っていたが、10 本ぶら下げても node で 2.08 MB、bun で 5.93 MB しか増えなかった。1 本あたり 0.21 MB ・ 0.59 MB である。
常駐コストを決めているのは、ぶら下がっている本数ではなく処理系そのものだった。そこで 22.9 MB の差が付いている。
1 本あたりの伸びは bun のほうが 2.8 倍きつい。それでも出発点が 22.9 MB 低いので、追いつかれるのは 59 本ぶら下がったときである。
待受けは 1 プロジェクトにつき 1 本。現実に並ぶのは数本なので、この交差点には届かない。
| 操作 | かかる時間 | 何が決めているか |
|---|---|---|
say | 3.1 ms | SQLite への書き込み。処理系を替えても動かない |
recent 50 件 | 0.33 ms | 読むだけ。十分速い |
recent 500 件 | 1.1 ms | 件数に比例。10 倍にして 3 倍 |
| 配信 | 2.2 ms/本 | 待受けを 1 本ずつ起こす手間 |
say の 3.1 ms は読み取りの 10 倍かかっている。WAL に同期で書いているためで、ここを速くしたいなら処理系ではなく書き方を変えることになる。ただし人が待つ時間としては十分短い。
実メモリだけを見て「bun のほうが軽い」と決めると読み違える。確保量では逆に 4.3 倍を抱えている。
| 指標 | node | bun | 勝つのは |
|---|---|---|---|
| 実メモリ (待受け 10 本) | 56.9 MB | 37.8 MB | bun (19.1 MB 差) |
| 確保量 (待受け 10 本) | 31.1 MB | 135.1 MB | node (104.0 MB 差) |
空きメモリが減ったときに効くのは確保量のほうである。実メモリはページアウトされて減るが、確保量は減らない。
Claude Code が背面のコマンドを刈るのは空きが減ったときで、待受けが [killed] で落ちる原因になっている(i260901-07)。この観点では node のほうが安全である。
CLI を測ったときも同じ傾向だった。bun は実メモリ 25.59 MB で C# と並びながら、確保量は 108.43 MB で 5 実装のうち最大だった。処理系の癖であり、サーバーでも CLI でも変わらない。
確保量は Windows でしか測っていない。聞き方が OS ごとに違う (Windows は Get-Process、Linux は /proc/<pid>/status、Mac は ps や vmmap)。実メモリのほうは process.memoryUsage() で揃えているので、どの OS でも同じ形で測れる。
待受けを 0 ・ 1 ・ 5 ・ 10 本ぶら下げ、それぞれで落ち着くのを待ってから 3 秒ぶんを平均した。
| 待受け | node 実 | bun 実 | node 確保 | bun 確保 |
|---|---|---|---|---|
| 0 本 | 54.52 MB | 31.99 MB | 29.00 MB | 129.49 MB |
| 1 本 | 54.89 MB | 35.11 MB | 29.27 MB | 131.77 MB |
| 5 本 | 55.50 MB | 36.43 MB | 29.79 MB | 133.21 MB |
| 10 本 | 56.91 MB | 37.79 MB | 31.13 MB | 135.12 MB |
| 1 本あたりの伸び | 0.24 MB | 0.58 MB | 0.21 MB | 0.56 MB |
待受けは /api/poll にぶら下がったまま何もしない。サーバー側が抱えるのは TCP の接続 1 本と、いつ起こすかを覚えておく分だけである。発言が来るまで処理は動かない。
1 本あたり node 0.24 MB ・ bun 0.58 MB。bun のほうが 1 本を重く持つが、実メモリでは出発点の差が大きいので順位は変わらない。
実メモリの伸び方がそのまま続くとして解くと、66 本で交差する。
node 54.52 + 0.24 × n
bun 31.99 + 0.58 × n
→ n ≒ 66
待受けは 1 プロジェクトにつき 1 本と決めている。66 本が並ぶことは無い。
確保量のほうには交差点が無い。出発点で node が 100.5 MB 少なく、伸び方もほぼ同じ (node 0.21 MB ・ bun 0.56 MB)。何本ぶら下げても node のほうが少ないままである。
人が待つのはここである。say は 200 回、recent(/api/history)は件数を変えて 200 回ずつ測った。
| 操作 | 処理系 | 中央値 | p95 | 最小 | 最大 |
|---|---|---|---|---|---|
say | node | 3.18 | 4.57 | 2.26 | 9.46 |
| bun | 3.13 | 4.55 | 2.29 | 6.08 | |
recent 50 件 | node | 0.33 | 0.54 | 0.29 | 1.05 |
| bun | 0.34 | 0.46 | 0.29 | 1.88 | |
recent 500 件 | node | 1.31 | 2.51 | 1.18 | 3.53 |
| bun | 0.98 | 1.95 | 0.82 | 3.93 |
単位は ms。
say 3.1 ms に対して recent 50 件が 0.33 ms。差は SQLite に同期で書いているぶんである。読むほうはメモリの上で済むことが多い。
件数を 50 から 500 へ 10 倍にしても、かかる時間は 3 倍にしかならない。問い合わせ自体の手間が大きく、件数はその上に乗るだけだからである。
| 状況 | node | bun |
|---|---|---|
| 誰もぶら下がっていない | 3.18 ms | 3.13 ms |
| 待受けが 10 本ぶら下がっている | 3.02 ms | 3.02 ms |
別のルームへ打って、起こす処理が混ざらないようにして測った。抱えているだけでは何も起きない。
say を打ってから、ぶら下がっている待受けが全員返るまでを測った。人から見ると「送った」から「相手に出た」までである。
| 待受け | node | bun | 1 本あたり |
|---|---|---|---|
| 1 本 | 5.55 ms | 5.83 ms | — |
| 5 本 | 16.12 ms | 16.27 ms | 2.6 ms |
| 10 本 | 27.95 ms | 26.35 ms | 2.2 ms |
本数に比例する。1 本増えるごとに 2.2 〜 2.6 ms。待受けを 1 本ずつ起こし、その相手ごとに返すものを作っているためである。
最初の 1 本に 5.6 ms かかるのは、say 自体の 3.1 ms が含まれているから。書き終えてから起こしにいく。
10 本で 28 ms なら、人には分からない。並ぶのはせいぜい数本なので、いまの作りで困る場面は無い。
比例するということは、本数が 2 桁になれば効いてくるという意味でもある。100 本なら 220 ms。そこまで増やすなら配り方を変える話になる。
| 項目 | 版 |
|---|---|
| OS | Microsoft Windows NT 10.0.26200.0 |
| Node.js | v26.8.1 |
| bun | 1.4.2 |
| 測った日 | 2026-09-12 |
| 置き場 | 中身 |
|---|---|
tools/40_test/benchmark-server.mjs | 本体。結果を tmp/benchmark-server.json に書く |
tools/40_test/server-probe.mjs | サーバーを同じプロセスで立て、メモリを報告する |
別のプロセスのメモリを測る手立ては OS ごとに違う。Windows は tasklist ・ WMI、Mac ・ Linux は ps になる。Mac ・ Linux にも展開するので、測り方が OS で分かれる形は持ちたくない。
そこで サーバーを子プロセスの中で import し、そのプロセス自身に process.memoryUsage() を呼ばせた。node でも bun でも、どの OS でも同じ 1 本のコードで済む。
// server-probe.mjs
await import(pathToFileURL(join(root, 'src', 'server', 'main.mjs')).href);
process.stdout.write('__READY__\n');
setInterval(() => {
const m = process.memoryUsage();
process.stdout.write(`__MEM__ ${m.rss} ${m.heapUsed} ${m.heapTotal} ${m.external}\n`);
}, 250);
親は標準出力の印つきの行だけを拾う。サーバー自身のログも同じ出力に混ざるので、印で選り分けている。
| 守り | やり方 |
|---|---|
| 置き場 | tmp/bench-server/<処理系>/_data を渡す。既定でなければテスト用として立つ |
| ポート | OS に空きを割り当てさせる。番号をソースに書かないので、本番へ飛ぶ打ち間違いが起きない |
| 立った後 | probe が本番の置き場だったら、測る前に exit 3 で止める |
| 終わったら | 置き場ごと捨てる |
ベンチマークは say を 800 回以上打つ。置き場を渡し損ねて本番の DB を掴むと、そのまま残る。
だから probe 側にも確かめを置いた。渡す側の注意に頼らず、立った先で止める。
| 何を | どう測ったか |
|---|---|
| 起動 | プロセスを作ってから __READY__ が出るまで |
| 実メモリ | 本数ごとに 2 秒落ち着かせ、3 秒ぶん (250 ms 間隔)を平均 |
| 確保量 | そのあと Get-Process に 5 回聞いて平均。PowerShell は 1 回だけ起こす(250 ms ごとに起こすと、起動 185 ms のほうが間隔より長くなる) |
say ・ recent | 捨てる 20 回のあと 200 回。recent は先に 600 件積んでから |
| 配信 | 張り直しては 1 回打つ、を 10 回 |
process.memoryUsage() でどの OS でも測れるが、確保量は Get-Process に聞いている。Mac ・ Linux では別の聞き方が要るrecent の数字が変わる最初の測定は捨てた。サーバーではなく、測っている側を測っていた。
say も recent も、件数を 10 倍にしても一律 15 msになった。そこで気づいた。
| 測ったもの | node の値 | bun の値 |
|---|---|---|
say | 15.46 ms | 15.37 ms |
recent 50 件 | 13.64 ms | 15.53 ms |
recent 500 件 | 15.66 ms | 15.10 ms |
書くのも読むのも、50 件も 500 件も同じ値になることはない。どれかが全部を律速している。
何もしない HTTP サーバーを立てて、同じやり方で測った。
node の fetch だけが遅かった。最小は 0.36 ms まで下がるので、返せないのではなく待たされている。Windows の既定のタイマー解像度 15.6 ms と一致する。
ベンチマークの側を node:http の http.request(keepAlive つき)に替えた。node で走らせても bun で走らせても同じ数字が出るようになり、そこで初めて処理系の差が見えた。
| 測り方 | node サーバーの say | bun サーバーの say | 差が見えるか |
|---|---|---|---|
| node の fetch で測る | 15.46 ms | 15.37 ms | 見えない |
| http.request で測る | 3.18 ms | 3.13 ms | 見える (そして差は無かった) |
結論は変わらなかったが、「差が無い」と「差が見えない」は別のことである。前者は測ってから言える。
ローカルの往復を node の fetch で測ってはいけない。15 ms 未満のものはすべて 15 ms に見える。
CLI も src/client/chat.mjs で fetch を使っているので、node で動かすと 1 往復ごとにこれが乗る。課題 i260912-02 に残した。
再現の手順は tests/http-client-latency.test.mjs に置いた。速さの下限は決めていない。手段によって結果が変わったときだけ落ち、往復の時間は毎回数字として出る。課題が直ったかはその数字で見る。