ReviewOS

stacks/bun-router

perf(request): avoid URL allocation for query-free input

#890
Closed chris opened this 9 days ago ·
9 days ago

getParsedQuery() always materializes a URL and visits its searchParams, including requests whose URL has no query delimiter. Stacks request input helpers reach this through the shared lazy query accessor.

Investigate skipping URL creation for a request with no ?, while preserving an already cached URL, explicit query accessor overrides, per-request query identity, repeated keys, decoding, and fragment semantics. Implement in bun-router itself if supported by repeatable measurements, without downstream patches or dependency aliases.

Baseline: stacksjs/bun-router@21eb756. Caller: stacksjs/stacks@96fc5a8.

Validation will compare frozen source snapshots with alternating fresh-process CPU samples on the current shared macOS/Bun host, then exercise actual routing if the helper result supports proceeding. These diagnostics are not HTTP throughput rankings or publishable benchmark results.

Related: stacksjs/stacks#2450 and #888. Any additional commit requires rebuilding and revalidating the eventual release; npm publication is still pending explicit approval.

9 days ago

Implemented and pushed to origin/main as Chris in d4073c2.

getParsedQuery() now creates its own empty query cache without a URL when the request URL contains no query delimiter. If another helper already created a URL, that object still supplies the query, including caller edits. URLs containing ? still use the native URL parser. Explicit accessor overrides and cache identity are preserved.

Validation:

  • New regression file on baseline: 9 pass, 1 fail. The failure is the allocation guard; baseline already passed the public behavior checks.
  • After change: 10/10 focused tests and full suite 557 pass, 0 fail, 1,703 assertions across 43 files.
  • Both type-check projects, pickier, production build and built-package generic/native HTTP smoke passed.
  • Independent Standards and Spec reviews passed. No dependency alias, installed-package patch or benchmark-specific production branch.

Measurements use Bun 1.3.14 on this shared macOS arm64 host. Eight alternating baseline/candidate fresh-process pairs for every shape. CPU is process user + system time. All requests are fresh and results consumed. This is helper/direct-dispatch CPU, not network throughput, not a dedicated-host benchmark, and not a ranking.

MeasurementBaseline median msCandidate median msMedian paired candidate/baselinePairs with lower CPU
Helper, no query351.163130.9060.37508/8
Helper, query760.159768.0871.00911/8
Helper, already parsed URL360.535350.6170.97306/8
Generic dispatch, no query447.061316.8560.75798/8
Generic dispatch, query627.903640.9451.02712/8
Native route handler, no query349.070256.0170.73148/8
Native route handler, query552.649553.5551.00493/8

Query-free CPU fell approximately 62.5% in the helper, 24.2% through generic dispatch and 26.9% through the built native route handler. Query-bearing samples were slightly slower: 0.9% helper, 2.7% generic and 0.5% native by paired medians, with visible host noise. These results support the query-free improvement, not an improvement to query-bearing requests. Generic no-query samples also have substantial spread, so the paired result and all eight lower samples matter more than a ratio of the two separate medians.

Helper harness: 20,000 warmup calls, 500,000 measured fresh requests per process, 128 URLs, two cached query accesses checked for identity, keys consumed. Already-parsed shape calls getParsedURL() first.

Routing harness: real upstream Router with GET /items returning Response.json(req.query), 20,000 warmup requests, 200,000 measured requests per process, status checked and response text consumed. Generic uses router.handleRequest; native directly invokes _buildNativeRoutes()['/items'].GET. A Bun loader selects a frozen parser snapshot identically for both arms; the rest of the router source is unchanged. Built-package HTTP checks separately verify actual transport behavior.

Source baseline: 21eb756. Candidate snapshots differ from committed production code only by the explanatory comments.

Related: stacksjs/stacks#2450 and #888. Stacks has not adopted this unpublished change. The eventual release needs a fresh artifact and release verification for the selected commit; npm publication remains pending approval.

Raw CPU samples:

none: baseline=351.163, candidate=130.906, paired ratio=0.3750, lower=8/8
b=[352.241, 425.861, 354.898, 350.085, 331.732, 354.195, 338.589, 339.907]
c=[131.332, 131.859, 135.817, 131.962, 128.016, 127.257, 126.29, 130.479]
query: baseline=760.159, candidate=768.087, paired ratio=1.0091, lower=1/8
b=[752.452, 759.189, 759.237, 763.112, 764.028, 761.081, 754.676, 775.785]
c=[768.625, 763.952, 794.936, 767.549, 773.078, 764.021, 764.35, 770.171]
parsed: baseline=360.535, candidate=350.617, paired ratio=0.9730, lower=6/8
b=[366.294, 345.938, 360.664, 451.645, 377.154, 360.406, 337.33, 347.393]
c=[349.9, 351.335, 382.799, 371.583, 337.843, 352.128, 336.613, 336.603]
generic/none: baseline=447.061, candidate=316.856, paired ratio=0.7579, lower=8/8
b=[395.194, 407.476, 446.337, 627.096, 445.558, 473.851, 450.196, 447.785]
c=[302.183, 306.117, 304.868, 310.007, 401.039, 401.82, 359.138, 323.706]
generic/query: baseline=627.903, candidate=640.945, paired ratio=1.0271, lower=2/8
b=[641.663, 598.135, 597.983, 705.733, 659.209, 678.279, 614.142, 609.181]
c=[617.398, 604.903, 641.381, 635.017, 664.356, 784.643, 640.509, 648.598]
native/none: baseline=349.070, candidate=256.017, paired ratio=0.7314, lower=8/8
b=[347.324, 349.87, 356.823, 348.076, 353.491, 348.27, 356.424, 343.704]
c=[256.249, 256.618, 255.545, 255.53, 257.61, 253.972, 255.784, 257.505]
native/query: baseline=552.649, candidate=553.555, paired ratio=1.0049, lower=3/8
b=[540.562, 557.413, 552.156, 535.378, 541.743, 553.142, 642.374, 559.479]
c=[544.439, 579.112, 553.568, 553.542, 582.761, 541.359, 606.462, 552.38]

Reproduction scripts (adjust the two checkout roots for another host; run comparisons from an empty output directory):

prepare.py
from pathlib import Path
import subprocess
p = Path(__file__).resolve().parent
repo = '/Users/chris/Code/Libraries/bun-router'
source = 'packages/bun-router/src/request/macros.ts'
baseline = subprocess.check_output(['git', '-C', repo, 'show', '21eb75680b32a39743171f2771ba70cd29825f65:' + source], text=True)
a = baseline.index('    for (const [key, value] of getParsedURL(req).searchParams)', baseline.index('export function getParsedQuery'))
b = baseline.index('    cache._parsedQuery = query', a)
candidate = baseline[:a] + "    if (cache._parsedURL || req.url.includes('?')) {\n" + ''.join('  ' + line + '\n' for line in baseline[a:b].splitlines()) + '    }\n' + baseline[b:]
for name, text in [('baseline', baseline), ('candidate', candidate)]:
    (p / f'{name}.ts').write_text(text)
    (p / f'{name}-source.txt').write_text(text)
worker.ts
const mode = process.argv[2]
const shape = process.argv[3]
const { getParsedQuery, getParsedURL } = await import(`./${mode}.ts`)
const urls = Array.from({ length: 128 }, (_, i) => `https://example.test/items/${i}${shape === 'query' ? '?a=1&a=2&name=hello+world' : ''}`)
let consumed = 0
function run(count: number) {
  for (let i = 0; i < count; i++) {
    const req = new Request(urls[i % urls.length])
    if (shape === 'parsed') getParsedURL(req)
    const query = getParsedQuery(req)
    consumed += Object.keys(query).length
    if (getParsedQuery(req) !== query) throw new Error('cache identity changed')
  }
}
run(20000)
const cpu = process.cpuUsage()
const start = performance.now()
run(500000)
const used = process.cpuUsage(cpu)
console.log(JSON.stringify({ mode, shape, cpuMs: (used.user + used.system) / 1000, wallMs: performance.now() - start, consumed }))
compare.ts
import { appendFile } from 'node:fs/promises'
for (const shape of ['none', 'query', 'parsed']) {
  for (let pair = 0; pair < 8; pair++) {
    for (const mode of pair % 2 ? ['candidate', 'baseline'] : ['baseline', 'candidate']) {
      const child = Bun.spawn([process.execPath, `${import.meta.dir}/worker.ts`, mode, shape], { stdout: 'pipe', stderr: 'inherit', env: { ...process.env, NODE_ENV: 'production', APP_ENV: 'production' } })
      const output = await new Response(child.stdout).text()
      if (await child.exited) throw new Error(output)
      const row = { pair, ...JSON.parse(output) }
      await appendFile(`${import.meta.dir}/samples.jsonl`, JSON.stringify(row) + '\n')
      console.log(JSON.stringify(row))
    }
  }
}
router-worker.ts
import { plugin } from 'bun'
import assert from 'node:assert/strict'
const mode = process.argv[2]
const shape = process.argv[3]
const dispatch = process.argv[4]
const source = '/Users/chris/Code/Libraries/bun-router/packages/bun-router/src/request/macros.ts'
const contents = await Bun.file(`${import.meta.dir}/${mode}.ts`).text()
plugin({ name: 'frozen-query-parser', setup(build) {
  build.onLoad({ filter: /\/request\/macros\.ts$/ }, args => {
    if (args.path !== source) throw new Error(`unexpected source: ${args.path}`)
    return { contents, loader: 'ts' }
  })
} })
const { Router } = await import('/Users/chris/Code/Libraries/bun-router/packages/bun-router/src/router/index.ts')
const router = new Router()
router.get('/items', (req) => Response.json(req.query))
const handler = dispatch === 'native' ? router._buildNativeRoutes()['/items'].GET : router.handleRequest.bind(router)
const url = `https://example.test/items${shape === 'query' ? '?a=1&a=2&name=hello+world' : ''}`
const make = () => new Request(url)
const expected = shape === 'query' ? { a: ['1', '2'], name: 'hello world' } : {}
assert.deepEqual(await (await handler(make())).json(), expected)
for (let i = 0; i < 20000; i++) await (await handler(make())).text()
Bun.gc(true)
let consumed = 0
const cpu = process.cpuUsage()
const start = performance.now()
for (let i = 0; i < 200000; i++) {
  const response = await handler(make())
  assert.equal(response.status, 200)
  consumed += (await response.text()).length
}
const used = process.cpuUsage(cpu)
console.log(JSON.stringify({ mode, shape, dispatch, cpuMs: (used.user + used.system) / 1000, wallMs: performance.now() - start, consumed }))
router-compare.ts
import { appendFile } from 'node:fs/promises'
for (const dispatch of ['generic', 'native']) {
 for (const shape of ['none', 'query']) {
  for (let pair = 0; pair < 8; pair++) {
    for (const mode of pair % 2 ? ['candidate', 'baseline'] : ['baseline', 'candidate']) {
      const child = Bun.spawn([process.execPath, '--config=/Users/chris/Code/stacks/bench/routing/bunfig.toml', `${import.meta.dir}/router-worker.ts`, mode, shape, dispatch], { stdout: 'pipe', stderr: 'inherit', env: { ...process.env, NODE_ENV: 'production', APP_ENV: 'production' } })
      const output = await new Response(child.stdout).text()
      if (await child.exited) throw new Error(output)
      const row = { pair, ...JSON.parse(output) }
      await appendFile(`${import.meta.dir}/router-samples.jsonl`, JSON.stringify(row) + '\n')
      console.log(JSON.stringify(row))
    }
  }
 }
}

Run python3 prepare.py, then the comparison scripts with NODE_ENV=production APP_ENV=production bun --config=/Users/chris/Code/stacks/bench/routing/bunfig.toml. Scripts retain raw JSONL and consume status/body data; they do not apply production patches.

Sign in to comment on this issue.