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 likeautocannonare 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