Skip to content

Commit c285f9a

Browse files
chore(benchmark): add app.fetch() overhead benchmark (#5117)
* chore(benchmark): add app.fetch() overhead benchmark * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent fb9d7bf commit c285f9a

8 files changed

Lines changed: 246 additions & 0 deletions

File tree

‎benchmarks/fetch/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
bun.lock
2+
.claude

‎benchmarks/fetch/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# fetch benchmark
2+
3+
Measure `app.fetch()` overhead in-process.
4+
5+
```sh
6+
bun bench.mts
7+
node bench.mts
8+
```
9+
10+
Compare working tree vs a git ref (default: `main`):
11+
12+
```sh
13+
./compare.sh
14+
./compare.sh v4.12.0
15+
RUNTIME=node ./compare.sh
16+
ROUNDS=5 ./compare.sh
17+
```
18+
19+
`compare.sh` runs each variant in a fresh process and reverses the variant order on every round, so that same-process JIT/GC warm-up and execution order do not bias the results. The summary reports the median of the per-round averages along with the raw values of every round.

‎benchmarks/fetch/bench.mts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Measure app.fetch() overhead in-process.
2+
//
3+
// bun bench.mts / node bench.mts ... benchmark the working tree
4+
// ./compare.sh [ref] ... also benchmark <ref> and compare
5+
//
6+
// Each invocation benchmarks a single variant so that results are not
7+
// affected by same-process JIT/GC order effects. compare.sh spawns one
8+
// process per variant per round and aggregates the results.
9+
//
10+
// Env:
11+
// HONO_SRC ... path to the hono `src` directory to benchmark (default: ../../src)
12+
// HONO_LABEL ... label used in the output (default: hono)
13+
// HONO_JSON=1 ... suppress mitata output and print a single JSON line instead
14+
//
15+
// Runs on both Bun and Node.
16+
import './ts-resolve.mjs'
17+
import { run, bench } from 'mitata'
18+
import { pathToFileURL } from 'node:url'
19+
20+
const src = process.env.HONO_SRC ?? new URL('../../src', import.meta.url).pathname
21+
const label = process.env.HONO_LABEL ?? 'hono'
22+
const asJson = process.env.HONO_JSON === '1'
23+
24+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25+
const makeApp = async (src: string): Promise<any> => {
26+
const { Hono } = await import(pathToFileURL(`${src}/index.ts`).href)
27+
const { RegExpRouter } = await import(pathToFileURL(`${src}/router/reg-exp-router/index.ts`).href)
28+
29+
const app = new Hono({ router: new RegExpRouter() })
30+
/* eslint-disable @typescript-eslint/no-explicit-any */
31+
app
32+
.get('/', (c: any) => c.text('Hi'))
33+
.post('/json', (c: any) => c.req.json().then(c.json))
34+
.get('/id/:id', (c: any) => {
35+
const id = c.req.param('id')
36+
const name = c.req.query('name')
37+
c.header('x-powered-by', 'benchmark')
38+
return c.text(`${id} ${name}`)
39+
})
40+
return app
41+
}
42+
43+
const app = await makeApp(src)
44+
45+
const ping = new Request('http://localhost/')
46+
const query = new Request('http://localhost/id/1?name=bun')
47+
48+
let sink: unknown
49+
50+
51+
const cases: [string, (app: any) => Promise<unknown>][] = [
52+
['ping GET /', (app) => app.fetch(ping)],
53+
['query GET /id/1?name=bun', (app) => app.fetch(query)],
54+
[
55+
'body POST /json',
56+
(app) =>
57+
app.fetch(
58+
new Request('http://localhost/json', {
59+
method: 'POST',
60+
headers: { 'content-type': 'application/json' },
61+
body: '{"hello":"world"}',
62+
})
63+
),
64+
],
65+
]
66+
67+
for (const [name, fn] of cases) {
68+
bench(name, async () => {
69+
sink = await fn(app)
70+
})
71+
}
72+
73+
if (asJson) {
74+
const { benchmarks } = await run({ format: 'quiet' })
75+
const results: Record<string, { avg: number; min: number; p75: number }> = {}
76+
for (const b of benchmarks) {
77+
const { stats, error } = b.runs[0]
78+
if (error || !stats) {
79+
throw error ?? new Error(`no stats for ${b.alias}`)
80+
}
81+
results[b.alias] = { avg: stats.avg, min: stats.min, p75: stats.p75 }
82+
}
83+
console.log(JSON.stringify({ label, cases: results }))
84+
console.error(typeof sink)
85+
} else {
86+
console.log(`benchmarking: ${label} (${src})`)
87+
await run()
88+
console.log(typeof sink)
89+
}

‎benchmarks/fetch/compare.sh‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/bash
2+
# Compare app.fetch() between the working tree and a git ref (default: main).
3+
#
4+
# Each measurement runs in a fresh process, and the variant order is
5+
# reversed on every round, so that same-process JIT/GC warm-up and
6+
# execution order do not bias the results.
7+
#
8+
# Usage: ./compare.sh [ref]
9+
# RUNTIME=node ROUNDS=5 ./compare.sh [ref]
10+
set -e
11+
cd "$(dirname "$0")"
12+
13+
REF=${1:-main}
14+
RUNTIME=${RUNTIME:-bun}
15+
ROUNDS=${ROUNDS:-3}
16+
ROOT=$(git rev-parse --show-toplevel)
17+
WT=$(mktemp -d)/hono-base
18+
RESULTS=$(mktemp)
19+
20+
cleanup() {
21+
git -C "$ROOT" worktree remove --force "$WT" 2>/dev/null || true
22+
rm -f "$RESULTS"
23+
}
24+
trap cleanup EXIT
25+
26+
git -C "$ROOT" worktree add --detach -q "$WT" "$REF"
27+
28+
run_variant() { # <label> <src>
29+
HONO_JSON=1 HONO_LABEL="$1" HONO_SRC="$2" $RUNTIME ./bench.mts >>"$RESULTS" 2>/dev/null
30+
}
31+
32+
for ((i = 1; i <= ROUNDS; i++)); do
33+
if ((i % 2)); then
34+
echo "round $i/$ROUNDS: $REF -> dev" >&2
35+
run_variant "$REF" "$WT/src"
36+
run_variant "dev" "$ROOT/src"
37+
else
38+
echo "round $i/$ROUNDS: dev -> $REF" >&2
39+
run_variant "dev" "$ROOT/src"
40+
run_variant "$REF" "$WT/src"
41+
fi
42+
done
43+
44+
$RUNTIME ./summarize.mts "$RESULTS" "$REF"

‎benchmarks/fetch/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"devDependencies": {
3+
"@types/node": "^26.1.1",
4+
"mitata": "^1.0.34"
5+
}
6+
}

‎benchmarks/fetch/summarize.mts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Summarize NDJSON results produced by `HONO_JSON=1 bench.mts`.
2+
// Usage: bun summarize.mts <results.ndjson> <base-label>
3+
import { readFileSync } from 'node:fs'
4+
5+
const [file, baseLabel = 'base'] = process.argv.slice(2)
6+
7+
type Stats = { avg: number; min: number; p75: number }
8+
type Line = { label: string; cases: Record<string, Stats> }
9+
10+
const lines: Line[] = readFileSync(file, 'utf8')
11+
.trim()
12+
.split('\n')
13+
.map((l) => JSON.parse(l))
14+
15+
// case name -> label -> avg per round
16+
const byCase = new Map<string, Map<string, number[]>>()
17+
for (const line of lines) {
18+
for (const [name, stats] of Object.entries(line.cases)) {
19+
const byLabel = byCase.get(name) ?? new Map<string, number[]>()
20+
byCase.set(name, byLabel)
21+
const avgs = byLabel.get(line.label) ?? []
22+
byLabel.set(line.label, avgs)
23+
avgs.push(stats.avg)
24+
}
25+
}
26+
27+
const median = (xs: number[]): number => {
28+
const s = [...xs].sort((a, b) => a - b)
29+
const mid = Math.floor(s.length / 2)
30+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2
31+
}
32+
33+
const fmt = (ns: number): string =>
34+
ns >= 1000 ? `${(ns / 1000).toFixed(2)} µs` : `${ns.toFixed(2)} ns`
35+
36+
for (const [name, byLabel] of byCase) {
37+
console.log(`• ${name}`)
38+
for (const [label, avgs] of byLabel) {
39+
const rounds = avgs.map(fmt).join(', ')
40+
console.log(` ${label.padEnd(16)} median ${fmt(median(avgs)).padStart(10)} rounds: [${rounds}]`)
41+
}
42+
const dev = byLabel.get('dev')
43+
const base = byLabel.get(baseLabel)
44+
if (dev && base) {
45+
const ratio = median(base) / median(dev)
46+
const [verb, x] = ratio >= 1 ? ['faster', ratio] : ['slower', 1 / ratio]
47+
console.log(` summary: dev is ${x.toFixed(2)}x ${verb} than ${baseLabel} (median of round avgs)`)
48+
}
49+
console.log('')
50+
}

‎benchmarks/fetch/ts-resolve.mjs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Resolve extensionless TypeScript imports (e.g. `./request` -> `./request.ts`)
2+
// so that Node's built-in type stripping can run hono's src directly.
3+
// Import this once at the top of a bench file, then load hono via dynamic import().
4+
// No-op on Bun, which resolves extensionless imports natively.
5+
import * as mod from 'node:module'
6+
7+
if (typeof mod.registerHooks === 'function') {
8+
mod.registerHooks({
9+
resolve(specifier, context, nextResolve) {
10+
try {
11+
return nextResolve(specifier, context)
12+
} catch (err) {
13+
if (specifier.startsWith('.') || specifier.startsWith('file:')) {
14+
for (const suffix of ['.ts', '/index.ts']) {
15+
try {
16+
return nextResolve(specifier + suffix, context)
17+
} catch {
18+
// try the next suffix
19+
}
20+
}
21+
}
22+
throw err
23+
}
24+
},
25+
})
26+
}

‎benchmarks/fetch/tsconfig.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"allowImportingTsExtensions": true,
4+
"esModuleInterop": true,
5+
"module": "NodeNext",
6+
"noEmit": true,
7+
"types": ["node"]
8+
},
9+
"include": ["./*.mts", "./*.mjs"]
10+
}

0 commit comments

Comments
 (0)