📏

Webアプリのベンチマークについて

に公開

Webアプリをベンチマークする方法について書きます。ここでいうWebアプリはCloudflare Workers、Deno、Bun、Node.jsで動くJavaScriptのバックエンドのアプリです。また、測るものは速度です。JavaScriptですが、他の言語のWebアプリにも応用できるかもしれません。僕がHonoを開発する上でパフォーマンス計測、ベンチマークをしているのでそこで得た知識です。

どうやってベンチマークするか?

どうやってベンチマークするかについて考えます。今回紹介するのは以下の3つです。

  1. マイクロベンチマーク
  2. サーバーを立てるベンチマーク
  3. サーバーを立てないベンチマーク

「1. マイクロベンチマーク」ではある単一の機能、小さな機能同士の速度を比較します。例えば、ボトルネックになっている関数をピックアップして、新旧の実装を比べたりします。

Webアプリのインとアウトをロードテストする方法には2つの方法があります。

分かりやすいのは「2. サーバーを立てるベンチマーク」です。まずコマンドでアプリケーションを起動し、特定のポートをサーバーにします。例えば、bun run app.tsとやれば、デフォルトでlocalhost3000ポートにサーバーが立ちます。そこにHTTPリクエストを飛ばして、レスポンスが返ってくるまでの速度を計測します。Webのパフォーマンスを測る一般的な方法でいくつか指標がありますが、「Requests per Second」は分かりやすいでしょう。

次に「3. サーバーを立てないベンチマーク」というのがあります。JavaScriptのWebアプリの場合はこれができます。例えば、以下のようなアプリがあるとします。

app.ts
const app = {
  fetch: async (req: Request) => {
    // Heavy tasks
    return new Response(`You access ${req.url}`)
  },
}

これは、appfetchが生えているので、export defaultすればサーバーで立ち上げることができます。

app.ts
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が推奨するベンチマークツールを紹介しています。

https://bun.com/docs/project/benchmarking

それによるとマイクロベンチマークではmitataというソフトを使うのがよいです。

https://github.com/evanwashere/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 like autocannon are not fast enough.

つまり、Bun.serve()同等以上の速度を持つベンチマークツールを使わないと結果が歪みます。Node.jsでポピュラーなautocannonはそれに満たないため、推奨していません。その代わり以下を進めています。

僕はbombardierを使っています。

https://github.com/codesenberg/bombardier

やり方

上記3つについて具体的なやり方を見ていきます。

1. マイクロベンチマーク

Bunのやり方に見習いmitataを使います。

例を出します。URLのクエリストリングのパースを考えてください。つまりhttp://example.com/hello?name=yusukeからnameに当たるyusukeを取得する方法です。一般的にはURLSearchParamsというAPIがあるのでそれを使いますが、Honoではそれにオリジナルのロジックを使っています。ではこの2つのパフォーマンスを比べてみましょう。

ベンチマーク用のコードは以下です。

bench-query.js
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の作者がつくってます。

https://github.com/SaltyAom/bun-http-framework-benchmark

様々な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つのベンチマークについて紹介しました。

  1. マイクロベンチマーク
  2. サーバーを立てるベンチマーク
  3. サーバーを立てないベンチマーク

今回は速度でしたが、メモリやファイルサイズなど測る場所は他にもあります。うまいことベンチマークして、よいアプリを作りましょう。

Discussion