Skip to content

Commit 46441d7

Browse files
committed
bench(vhost): add benchmark harness, stored results, and BENCHMARKS.md
Pin the original v3 implementation at bench/v3-baseline.cjs so old-vs-new stays reproducible. Add: - bench/index.mjs quick single-process snapshot (tinybench) - bench/session.mjs one fresh-process session, fixed-iteration hrtime - bench/run-matrix.mjs N sessions × several lengths, mean/min/max/sd - bench/collect.mjs persists raw + summary per run to bench/results/ - bench/{,run-}*experiments.mjs variant studies, each fuzz-checked for identical captures against the regex (incl. the rejected wildcard string matcher and the object-allocation lesson) Store a 25-session × {10k,100k,1M} run and document method + results in BENCHMARKS.md (medians reported alongside means since OS-scheduler spikes inflate the mean at 1M).
1 parent 53f41c7 commit 46441d7

17 files changed

Lines changed: 5131 additions & 0 deletions

BENCHMARKS.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# vhost benchmarks
2+
3+
Measures the per-request cost of the middleware returned by `vhost(...)`: hostname
4+
matching plus `req.vhost` population. Handlers are no-ops so only the library's work
5+
is timed.
6+
7+
```sh
8+
# quick single-process snapshot (tinybench)
9+
node bench/index.mjs
10+
11+
# rigorous comparison: N independent sessions × several iteration lengths
12+
node bench/run-matrix.mjs 8 10000 100000 1000000
13+
```
14+
15+
Lower ns/op is better. Numbers are machine- and load-dependent — what matters is the
16+
relative change between versions measured on the same machine. Per-op figures are
17+
comparable across the CJS baseline and the ESM build because module-load cost is paid
18+
once per process, not per operation.
19+
20+
The v3 implementation is pinned at `bench/v3-baseline.cjs` (a copy of the original
21+
`index.js`) so the old-vs-new comparison stays reproducible after the rewrite.
22+
23+
Environment: Node.js v24.15.0, darwin, arm64 (Apple Silicon).
24+
25+
## Baseline — v3.0.2 (`index.js`, CommonJS)
26+
27+
Every request runs `RegExp.exec` and, on a match, allocates a capture array and loops
28+
to copy groups onto `req.vhost` — even for an exact static hostname with no captures.
29+
30+
| scenario | ops/sec | ns/op |
31+
| --------------- | -----------: | ----: |
32+
| static match | 11,666,664 | 88.5 |
33+
| static no-match | 23,946,474 | 40.9 |
34+
| wildcard match | 6,779,127 | 160.7 |
35+
| regexp match | 5,896,864 | 195.2 |
36+
| no Host header | 35,641,036 | 21.2 |
37+
38+
`static match` is the hot path optimized in v4: an exact hostname like
39+
`mail.example.com`, the overwhelmingly common real-world usage.
40+
41+
## v4.0.0 (`dist/index.js`, TypeScript, ESM)
42+
43+
For a static (no-`*`) hostname the middleware writes `req.vhost` directly with
44+
`length: 0`, skipping `exec`'s capture array, the group-copy loop, and the result
45+
allocation on a miss. Matching uses an **ASCII-gated decision** that is provably
46+
identical to `regexp.test()`:
47+
48+
- For an ASCII literal pattern, the precompiled lowercase form `lowered` and its
49+
length are cached. At request time:
50+
- `name.length !== lowered.length`**definite non-match**. RegExp `i` uses Unicode
51+
*simple* case folding, which is 1:1 (length-preserving) and never folds a multi-char
52+
or astral sequence into an ASCII pattern character, so a length mismatch can never be
53+
a match. This rejects misses without touching the regex.
54+
- `name === lowered`**definite match** (ASCII, 1:1 fold).
55+
- otherwise (mixed case) → defer to the **identical** `regexp.test(name)`.
56+
- For a non-ASCII literal pattern, matching defers wholly to `regexp.test`, because
57+
non-ASCII folds can change length (e.g. `'İ'.toLowerCase()` is two code units) and
58+
code points such as U+212A (Kelvin) fold to ASCII under the regex but not under
59+
`toLowerCase()`. A naive lowercase compare would change matching — so it is never used.
60+
61+
Wildcard string hostnames keep the `exec` + capture path but gain a cheap, always-safe
62+
minimum-length reject: a match can never be shorter than the pattern's literal characters
63+
plus one character per `*`, so a too-short hostname is rejected before the regex runs (it
64+
only ever rejects; long-enough hosts fall through to the identical regex). RegExp hostnames
65+
are unchanged. The contract is byte-for-byte preserved: the full 56-case suite passes
66+
unchanged against the build, including regression tests for the Kelvin sign, the `İ`
67+
length-changing fold, and wildcard prefix/suffix capture + case-insensitivity.
68+
69+
### Multi-session matrix: v3 baseline vs v4
70+
71+
`node bench/collect.mjs 25 10000 100000 1000000` — 25 independent sessions (one fresh
72+
`node` process each, so independent JIT/GC state) per length. The table below is the
73+
**100,000-iteration** length, which had the lowest variance in this run; the full sweep
74+
(all three lengths, raw per-session data) is stored under `bench/results/`. `old`/`new`
75+
are mean ns/op; the **median** is shown too because a single OS-scheduler spike can inflate
76+
the mean/max (prefer the median when `±sd%` is high). `speedup` is `old mean ÷ new mean`.
77+
The `static`/`wildcard` scenarios use a lowercase Host — the realistic common case.
78+
79+
| scenario | old mean | new mean | new median | speedup | ±sd% |
80+
| -------------------------- | -------: | -------: | ---------: | ------: | ---: |
81+
| static match | 84.5 | 61.8 | 61.4 | 1.37× | 3.4% |
82+
| static no-match | 27.5 | 14.3 | 14.2 | 1.92× | 5.0% |
83+
| wildcard match | 149.7 | 149.6 | 149.5 | 1.00× | 2.0% |
84+
| wildcard match (prefix) | 151.0 | 151.7 | 150.8 | 1.00× | 3.3% |
85+
| wildcard no-match (short) | 29.7 | 13.5 | 13.4 | 2.20× | 2.7% |
86+
| wildcard no-match (suffix) | 33.6 | 33.3 | 33.1 | 1.01× | 2.0% |
87+
| multi-star match | 163.2 | 163.2 | 163.0 | 1.00× | 1.7% |
88+
| regexp match | 166.6 | 166.1 | 165.3 | 1.00× | 1.8% |
89+
| no Host header | 7.3 | 5.9 | 5.9 | 1.24× | 1.8% |
90+
91+
**Reading the results.**
92+
93+
- **Static** match **~1.37×**, no-match **~1.9×**, no-Host **~1.24×** — the ASCII-gated
94+
decision (above) deciding most requests without the regex.
95+
- **Wildcard short no-match ~2.2×** — the minimum-length reject fires before the regex.
96+
- **Wildcard match / prefix / suffix / multi-star, and RegExp: ~1.0×** — neutral, no
97+
regression. The regex still does the matching and capture here; only too-short hosts are
98+
short-circuited. (At 1,000,000 iterations a couple of these means dip to ~0.93–0.94× from
99+
single OS-scheduler spikes — their medians stay 1.00–1.01×, which is why medians are
100+
reported alongside means.)
101+
102+
### What did *not* work on the wildcard path
103+
104+
A hand-rolled single-`*` string matcher (decompose `PREFIX*SUFFIX`, compare prefix/suffix
105+
with an ASCII case-fold loop, slice out the capture, fall back to the regex for
106+
multi-`*`/non-ASCII) *looked* ~1.5× faster on matches in isolation — but that was a
107+
**measurement artifact**: the prototype built `req.vhost` as a fast object literal, whereas
108+
the contract requires `Object.create(null)` populated incrementally. Once the prototype
109+
used the real allocation, the matcher came out **0.62–0.71× (30–38% slower)** on matches,
110+
because the null-prototype result allocation dominates the per-request cost and dwarfs any
111+
saving from skipping `exec`. So the wildcard change is limited to the always-safe
112+
minimum-length reject. The experiment lives in `bench/wildcard-experiments.mjs` (variants
113+
WV1–WV4, each fuzz-checked for identical captures against the regex) with the lesson
114+
documented at the top of the file.
115+

bench/collect.mjs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Collect a full benchmark run and persist it for later review.
2+
//
3+
// node bench/collect.mjs [sessions] [lengths...]
4+
// node bench/collect.mjs 25 10000 100000 1000000 # defaults
5+
//
6+
// Spawns `sessions` independent `node bench/session.mjs <length>` processes per
7+
// length (one process = one independent JIT/GC session) comparing the pinned v3
8+
// baseline against the current build. Writes, into bench/results/<run-id>/:
9+
//
10+
// meta.json - node version, platform, sessions, lengths, timestamp
11+
// raw-<length>.json - every session's ns/op for every scenario (old + new)
12+
// summary.json - aggregated mean/min/max/sd/median/speedup per scenario
13+
// summary.md - the same as human-readable tables
14+
//
15+
// The run-id is a UTC timestamp so repeated runs accumulate instead of clobber.
16+
17+
import { execFileSync } from 'node:child_process'
18+
import { mkdirSync, writeFileSync } from 'node:fs'
19+
import { fileURLToPath } from 'node:url'
20+
import { dirname, join } from 'node:path'
21+
22+
const __dirname = dirname(fileURLToPath(import.meta.url))
23+
const sessionScript = join(__dirname, 'session.mjs')
24+
25+
const argv = process.argv.slice(2)
26+
const sessions = Number(argv[0] || 25)
27+
const lengths = (argv.length > 1 ? argv.slice(1) : ['10000', '100000', '1000000']).map(Number)
28+
29+
const runId = new Date().toISOString().replace(/[:.]/g, '-')
30+
const outDir = join(__dirname, 'results', runId)
31+
mkdirSync(outDir, { recursive: true })
32+
33+
function runSession (length) {
34+
const stdout = execFileSync(process.execPath, [sessionScript, String(length)], { encoding: 'utf8' })
35+
return JSON.parse(stdout.trim().split('\n').pop())
36+
}
37+
38+
// Discover the scenario list from the session itself so this stays in sync with
39+
// bench/session.mjs without a duplicated hardcoded list.
40+
const SCENARIOS = Object.keys(runSession(lengths[0]).scenarios)
41+
42+
function aggregate (values) {
43+
const sorted = [...values].sort((a, b) => a - b)
44+
const n = sorted.length
45+
const mean = sorted.reduce((a, b) => a + b, 0) / n
46+
const variance = sorted.reduce((a, b) => a + (b - mean) ** 2, 0) / n
47+
const median = n % 2
48+
? sorted[(n - 1) / 2]
49+
: (sorted[n / 2 - 1] + sorted[n / 2]) / 2
50+
return {
51+
mean,
52+
median,
53+
min: sorted[0],
54+
max: sorted[n - 1],
55+
sd: Math.sqrt(variance),
56+
sdPct: (Math.sqrt(variance) / mean) * 100
57+
}
58+
}
59+
60+
const meta = {
61+
runId,
62+
timestamp: new Date().toISOString(),
63+
node: process.version,
64+
platform: process.platform,
65+
arch: process.arch,
66+
sessions,
67+
lengths,
68+
scenarios: SCENARIOS,
69+
baseline: 'bench/v3-baseline.cjs (v3.0.2)',
70+
candidate: 'package "vhost" entry (current build)'
71+
}
72+
writeFileSync(join(outDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
73+
74+
const summary = { meta, results: {} }
75+
const mdLines = [
76+
`# Benchmark run ${runId}`,
77+
'',
78+
`- Node: ${process.version} · ${process.platform}/${process.arch}`,
79+
`- Sessions per length: **${sessions}** (independent processes)`,
80+
`- Baseline: \`bench/v3-baseline.cjs\` (v3.0.2) · Candidate: current build`,
81+
'',
82+
'ns/op, lower is better. `speedup` = old mean ÷ new mean.',
83+
''
84+
]
85+
86+
for (const length of lengths) {
87+
const runs = []
88+
for (let i = 0; i < sessions; i++) {
89+
runs.push(runSession(length))
90+
process.stderr.write(`\r${length} iters: session ${i + 1}/${sessions} `)
91+
}
92+
process.stderr.write('\n')
93+
94+
// Persist every raw session for this length.
95+
writeFileSync(
96+
join(outDir, `raw-${length}.json`),
97+
JSON.stringify({ length, sessions, runs }, null, 2) + '\n'
98+
)
99+
100+
summary.results[length] = {}
101+
mdLines.push(`## ${length.toLocaleString()} iterations / scenario`, '')
102+
mdLines.push('| scenario | old mean | new mean | new median | new min | new max | speedup | new ±sd% |')
103+
mdLines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
104+
105+
for (const s of SCENARIOS) {
106+
const oldAgg = aggregate(runs.map((r) => r.scenarios[s].old))
107+
const newAgg = aggregate(runs.map((r) => r.scenarios[s].new))
108+
const speedup = oldAgg.mean / newAgg.mean
109+
summary.results[length][s] = { old: oldAgg, new: newAgg, speedup }
110+
mdLines.push(
111+
`| ${s} | ${oldAgg.mean.toFixed(1)} | ${newAgg.mean.toFixed(1)} | ${newAgg.median.toFixed(1)} | ` +
112+
`${newAgg.min.toFixed(1)} | ${newAgg.max.toFixed(1)} | ${speedup.toFixed(2)}× | ${newAgg.sdPct.toFixed(1)}% |`
113+
)
114+
}
115+
mdLines.push('')
116+
}
117+
118+
writeFileSync(join(outDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n')
119+
writeFileSync(join(outDir, 'summary.md'), mdLines.join('\n') + '\n')
120+
121+
// Console echo of the summary tables.
122+
console.log(mdLines.join('\n'))
123+
console.log(`\nSaved to bench/results/${runId}/`)

0 commit comments

Comments
 (0)