Webアプリのベンチマークについて
Webアプリをベンチマークする方法について書きます。ここでいうWebアプリはCloudflare Workers、Deno、Bun、Node.jsで動くJavaScriptのバックエンドのアプリです。また、測るものは速度です。JavaScriptですが、他の言語のWebアプリにも応用できるかもしれません。僕がHonoを開発する上でパフォーマンス計測、ベンチマークをしているのでそこで得た知識です。
どうやってベンチマークするか?
どうやってベンチマークするかについて考えます。今回紹介するのは以下の3つです。
- マイクロベンチマーク
- サーバーを立てるベンチマーク
- サーバーを立てないベンチマーク
「1. マイクロベンチマーク」ではある単一の機能、小さな機能同士の速度を比較します。例えば、ボトルネックになっている関数をピックアップして、新旧の実装を比べたりします。
Webアプリのインとアウトをロードテストする方法には2つの方法があります。
分かりやすいのは「2. サーバーを立てるベンチマーク」です。まずコマンドでアプリケーションを起動し、特定のポートをサーバーにします。例えば、bun run app.ts
とやれば、デフォルトでlocalhost
の3000
ポートにサーバーが立ちます。そこにHTTPリクエストを飛ばして、レスポンスが返ってくるまでの速度を計測します。Webのパフォーマンスを測る一般的な方法でいくつか指標がありますが、「Requests per Second」は分かりやすいでしょう。
次に「3. サーバーを立てないベンチマーク」というのがあります。JavaScriptのWebアプリの場合はこれができます。例えば、以下のようなアプリがあるとします。
const app = {
fetch: async (req: Request) => {
// Heavy tasks
return new Response(`You access ${req.url}`)
},
}
これは、app
にfetch
が生えているので、export default
すればサーバーで立ち上げることができます。
export default app
Bunの場合、このコマンドです。
bun run app.ts
これは実際のサーバーを立てるわけですが、立てないとはどういうことでしょうか。
それはプログラム内で、app.fetch
を実行する時間を測るのです。例えば、/hello
というパスへアクセスした場合の速度を測るにはRequest
オブジェクトを以下のように作ります。
const req = new Request('http://localhost/hello')
そしてこれをapp.fetch()
に渡して、Response
が返ってくる時間を計測すればいいのです。
const res = await app.fetch(req) // <=== この時間を計測する
サーバーを立てる方法に比べて、気軽であり、外部の要因に左右されにくいという利点があります。一方で、「Requests per Second」など一般的な指標を取ることができません。また差が数値として出にくいです。同じアプリケーションのチューニング前とその後をベンチマークしたい時の参考に使うといいでしょう。
ランタイム
JavaScriptのアプリといえど、実行するランタイムで結果が変わります。得意、不得意があります。例えば「AランタイムとBランタイムではAランタイムの方が正規表現が速い」という場合です。すると正規表現を使ったX関数と使わないY関数を比べた時に、Aランタイムの方が差が顕著にでるし、Bランタイムではさほど変わらないという結果がでます。
僕はよくBunとNode.jsでベンチマークを取ります。すると、実装によって差が出るのはもちろんですが、BunはJavaScriptCore、Node.jsはV8といったJavaScriptエンジンによる差もでます。どちらもとって参考にするのもいいですし、BunならBunで実行した結果をパフォーマンスの正とすると定めてもいいでしょう。
Bunに見習う
Bunのドキュメントでは、Bunが推奨するベンチマークツールを紹介しています。
それによるとマイクロベンチマークではmitata
というソフトを使うのがよいです。
ですので、上記の「1. マイクロベンチマーク」で使えるのと「3. サーバーを立てないベンチマーク」でも使えます。
また、HTTPのロードテストについては以下のように書いてます。
For load testing, you must use an HTTP benchmarking tool that is at least as fast as
Bun.serve()
, or your results will be skewed. Some popular Node.js-based benchmarking tools likeautocannon
are not fast enough.
つまり、Bun.serve()
同等以上の速度を持つベンチマークツールを使わないと結果が歪みます。Node.jsでポピュラーなautocannon
はそれに満たないため、推奨していません。その代わり以下を進めています。
僕はbombardierを使っています。
やり方
上記3つについて具体的なやり方を見ていきます。
1. マイクロベンチマーク
Bunのやり方に見習いmitata
を使います。
例を出します。URLのクエリストリングのパースを考えてください。つまりhttp://example.com/hello?name=yusuke
からname
に当たるyusuke
を取得する方法です。一般的にはURLSearchParams
というAPIがあるのでそれを使いますが、Honoではそれにオリジナルのロジックを使っています。ではこの2つのパフォーマンスを比べてみましょう。
ベンチマーク用のコードは以下です。
import { getQueryParam } from 'hono/utils/url'
import { run, bench, group, summary } from 'mitata'
group('Parse search params', () => {
summary(() => {
bench('Original', () => {
getQueryParam('http://example.com/hello?name=yusuke')['name']
})
bench('Web Standards', () => {
new URLSearchParams('name=yusuke').get('name')
})
})
})
await run()
これを比較したいランタイムのコマンドで実行します。つまりBun上でのパフォーマンスを比較したければ以下のコマンドを打ちます。
bun run src/bench-query.js
結果が以下のように出力されます。
clk: ~3.13 GHz
cpu: Apple M1 Pro
runtime: bun 1.2.20 (arm64-darwin)
benchmark avg (min … max) p75 / p99 (min … top 1%)
------------------------------------------- -------------------------------
• Parse search params
------------------------------------------- -------------------------------
Original 86.28 ns/iter 84.92 ns █
(81.45 ns … 194.60 ns) 155.04 ns █
( 0.00 b … 764.00 b) 1.91 b █▇▂▁▃▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁
Web Standards 364.53 ns/iter 333.00 ns █
(125.00 ns … 716.17 µs) 1.83 µs █▂
( 0.00 b … 48.00 kb) 364.37 b ▂██▆▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
summary
Original
4.23x faster than Web Standards
これで、Honoのパースロジックの方がURLSearchParams
を使った場合より4.23倍速いことがわかります。次にNode.js上でのパフォーマンスを測ってみます。
node bench-query.js
結果は以下の通りです。
clk: ~3.15 GHz
cpu: Apple M1 Pro
runtime: node 24.5.0 (arm64-darwin)
benchmark avg (min … max) p75 / p99 (min … top 1%)
------------------------------------------- -------------------------------
• Parse search params
------------------------------------------- -------------------------------
Original 83.74 ns/iter 83.15 ns █
(81.50 ns … 122.10 ns) 99.19 ns █
( 33.30 b … 283.43 b) 160.20 b ▆██▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁
Web Standards 121.21 ns/iter 90.65 ns █
(85.56 ns … 1.74 µs) 1.32 µs █
(121.34 b … 451.40 b) 312.25 b █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
summary
Original
1.45x faster than Web Standards
Honoのロジックの方が1.45倍速いとでました。勝ち負けは同じですが、差が違うのが興味深いです。
このようにマイクロベンチマークを取ります。
2. サーバーを立てるベンチマーク
bombardierを使ってサーバーを立てるベンチマークをします。
例えば、ローカルの3000
ポートで立ち上げているサーバー上のアプリの/hello
の速度を測定したい場合は以下のコマンドを打ちます。
bombardier -c 125 --fasthttp 'http://localhost:3000/hello'
結果が以下のように出力されます。
Bombarding http://localhost:3000/hello for 10s using 125 connection(s)
[=======================================================================================================================================================] 10s
Done!
Statistics Avg Stdev Max
Reqs/sec 79067.62 5703.71 95323.52
Latency 1.58ms 211.29us 17.51ms
HTTP codes:
1xx - 0, 2xx - 790402, 3xx - 0, 4xx - 0, 5xx - 0
others - 0
Throughput: 16.66MB/s
基本的にはReqs/sec
の平均値である79067.62
を使って比べることが多いです。
Bun HTTP Framework Benchmark
ちなみに、JavaScriptのWebフレームワーク同士のベンチマークを取るためのGitHubプロジェクトがあってそこではbombardier
を使っています。Elysiaの作者がつくってます。
様々なJavaScriptフレームワークが登録されていて、結果をMarkdownにしたりできたりして、参考になります。
3. サーバーを立てないベンチマーク
サーバーを立てないベンチマークは「1. マイクロベンチマーク」と同じくmitata
をつかってやります。例えば、こんな感じのスクリプトを書くことがあります。
import { Hono } from 'hono'
import { run, bench, group, summary } from 'mitata'
import { Hono as NewHono } from '../../dist/index.js'
const app = new Hono()
const appNew = new NewHono()
group('Parse search params', () => {
summary(() => {
bench('Current', async () => {
await app.fetch(new Request('http://localhost'))
})
bench('new', async () => {
await appNew.fetch(new Request('http://localhost'))
})
})
})
await run()
他のやり方は「1. マイクロベンチマーク」と同じです。
まとめ
以上、僕が普段行っている以下の3つのベンチマークについて紹介しました。
- マイクロベンチマーク
- サーバーを立てるベンチマーク
- サーバーを立てないベンチマーク
今回は速度でしたが、メモリやファイルサイズなど測る場所は他にもあります。うまいことベンチマークして、よいアプリを作りましょう。
Discussion