-
Notifications
You must be signed in to change notification settings - Fork 111
perf(api): benchmark service-bound route workers #595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Makisuo
wants to merge
4
commits into
main
Choose a base branch
from
perf/api-route-workers
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
738570f
perf(api): benchmark service-bound route workers
Makisuo 4ec17ec
docs(api): plan Cloudflare-native API topology
Makisuo 2a353bf
docs(api): record standalone binding benchmark
Makisuo fc89d9d
fix(api): stop service-binding benchmark workers
Makisuo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
|
|
||
| # cold-path measurement bundle output | ||
| .coldpath-out | ||
| # Cold-path measurement outputs | ||
| .coldpath-* | ||
| *.cpuprofile |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { spawn } from "node:child_process" | ||
| import { fileURLToPath } from "node:url" | ||
|
|
||
| const samples = Number(process.argv.find((arg) => arg.startsWith("--samples="))?.split("=")[1] ?? 250) | ||
| const port = Number(process.argv.find((arg) => arg.startsWith("--port="))?.split("=")[1] ?? 9897) | ||
| const benchDir = fileURLToPath(new URL("./service-binding-bench/", import.meta.url)) | ||
| const wranglerCli = fileURLToPath(new URL("../node_modules/wrangler/wrangler-dist/cli.js", import.meta.url)) | ||
| const baseUrl = `http://127.0.0.1:${port}` | ||
|
|
||
| const configs = ["router", "echo", "monolith", "telemetry"].flatMap((name) => [ | ||
| "-c", | ||
| `${benchDir}${name}.wrangler.jsonc`, | ||
| ]) | ||
|
|
||
| // Run Wrangler's real CLI instead of its thin bin wrapper. The wrapper does | ||
| // not forward SIGTERM to the CLI process it spawns, which used to orphan both | ||
| // Wrangler and workerd after every benchmark run. A detached POSIX process | ||
| // group lets cleanup address the whole tree, including workerd descendants. | ||
| const wrangler = spawn("node", ["--no-warnings", wranglerCli, "dev", ...configs, "--port", String(port)], { | ||
| cwd: fileURLToPath(new URL("../", import.meta.url)), | ||
| detached: process.platform !== "win32", | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }) | ||
|
|
||
| if (wrangler.pid === undefined) throw new Error("wrangler did not start") | ||
| const wranglerPid = wrangler.pid | ||
|
|
||
| let wranglerLog = "" | ||
| for (const stream of [wrangler.stdout, wrangler.stderr]) { | ||
| stream.setEncoding("utf8") | ||
| stream.on("data", (chunk: string) => { | ||
| wranglerLog = `${wranglerLog}${chunk}`.slice(-16_000) | ||
| }) | ||
| } | ||
|
|
||
| const isMissingProcess = (error: unknown): boolean => | ||
| typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH" | ||
|
|
||
| const signalWranglerTree = (signal: NodeJS.Signals | 0): boolean => { | ||
| try { | ||
| if (process.platform === "win32") { | ||
| return signal === 0 ? wrangler.kill(0) : wrangler.kill(signal) | ||
| } | ||
| process.kill(-wranglerPid, signal) | ||
| return true | ||
| } catch (error) { | ||
| if (isMissingProcess(error)) return false | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| const waitForWranglerTreeExit = async (timeoutMs: number): Promise<boolean> => { | ||
| const deadline = Date.now() + timeoutMs | ||
| while (signalWranglerTree(0) && Date.now() < deadline) { | ||
| await new Promise((resolve) => setTimeout(resolve, 25)) | ||
| } | ||
| return !signalWranglerTree(0) | ||
| } | ||
|
|
||
| let stopPromise: Promise<void> | undefined | ||
| const stop = (): Promise<void> => | ||
| (stopPromise ??= (async () => { | ||
| if (!signalWranglerTree(0)) return | ||
| signalWranglerTree("SIGTERM") | ||
| if (await waitForWranglerTreeExit(5_000)) return | ||
| signalWranglerTree("SIGKILL") | ||
| if (!(await waitForWranglerTreeExit(1_000))) { | ||
| throw new Error(`wrangler process tree ${wranglerPid} did not stop`) | ||
| } | ||
| })()) | ||
|
|
||
| const stopForSignal = (exitCode: number) => { | ||
| void stop().finally(() => process.exit(exitCode)) | ||
| } | ||
| const onSigint = () => stopForSignal(130) | ||
| const onSigterm = () => stopForSignal(143) | ||
| process.once("SIGINT", onSigint) | ||
| process.once("SIGTERM", onSigterm) | ||
|
|
||
| const waitUntilReady = async () => { | ||
| const deadline = Date.now() + 45_000 | ||
| while (Date.now() < deadline) { | ||
| if (wrangler.exitCode !== null) throw new Error(`wrangler exited early\n${wranglerLog}`) | ||
| try { | ||
| const response = await fetch(`${baseUrl}/ready`) | ||
| if (response.ok) return | ||
| } catch { | ||
| // The listener is not ready yet. | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 100)) | ||
| } | ||
| throw new Error(`wrangler did not become ready\n${wranglerLog}`) | ||
| } | ||
|
|
||
| const percentile = (values: ReadonlyArray<number>, fraction: number): number => { | ||
| const sorted = [...values].sort((a, b) => a - b) | ||
| return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? 0 | ||
| } | ||
|
|
||
| const summarize = (values: ReadonlyArray<number>) => ({ | ||
| medianMs: percentile(values, 0.5), | ||
| p95Ms: percentile(values, 0.95), | ||
| minMs: Math.min(...values), | ||
| }) | ||
|
|
||
| const samplePath = async (pathname: string): Promise<ReadonlyArray<number>> => { | ||
| const timings: Array<number> = [] | ||
| for (let index = 0; index < samples; index++) { | ||
| const response = await fetch(`${baseUrl}${pathname}`) | ||
| if (!response.ok) throw new Error(`${pathname} returned ${response.status}`) | ||
| await response.arrayBuffer() | ||
| const timing = Number(response.headers.get("x-service-binding-ms")) | ||
| if (!Number.isFinite(timing)) throw new Error(`${pathname} omitted x-service-binding-ms`) | ||
| timings.push(timing) | ||
| } | ||
| return timings | ||
| } | ||
|
|
||
| try { | ||
| await waitUntilReady() | ||
| // Warm both isolates and the local HTTP connection before collecting samples. | ||
| await fetch(`${baseUrl}/direct`) | ||
| await fetch(`${baseUrl}/bound`) | ||
|
|
||
| const [direct, bound] = await Promise.all([samplePath("/direct"), samplePath("/bound")]) | ||
| const monolith = await (await fetch(`${baseUrl}/probe/monolith`)).json() | ||
| const telemetry = await (await fetch(`${baseUrl}/probe/telemetry`)).json() | ||
| const monolithWarm = await (await fetch(`${baseUrl}/probe/monolith`)).json() | ||
| const telemetryWarm = await (await fetch(`${baseUrl}/probe/telemetry`)).json() | ||
|
|
||
| console.log( | ||
| JSON.stringify( | ||
| { | ||
| samples, | ||
| warmRouter: { | ||
| direct: summarize(direct), | ||
| serviceBinding: summarize(bound), | ||
| }, | ||
| coldModuleGraph: { monolith, telemetryIsland: telemetry }, | ||
| warmModuleGraph: { monolith: monolithWarm, telemetryIsland: telemetryWarm }, | ||
| notes: [ | ||
| "Service-binding timings are measured inside the router around binding.fetch().", | ||
| "Module timings measure dynamic import/evaluation, not database or warehouse I/O.", | ||
| "Local workerd numbers are relative evidence; production placement still needs a canary.", | ||
| ], | ||
| }, | ||
| null, | ||
| 2, | ||
| ), | ||
| ) | ||
| } finally { | ||
| process.off("SIGINT", onSigint) | ||
| process.off("SIGTERM", onSigterm) | ||
| await stop() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # Service-bound route worker benchmark | ||
|
|
||
| Measured on 2026-08-23 with Bun 1.4.0, Wrangler 4.118.0, and local workerd on an Apple Silicon | ||
| development machine. These are relative local measurements, not production latency promises. | ||
|
|
||
| ## What implementation actually exists | ||
|
|
||
| Hono does **not** create sub-workers for mounted routes. Its | ||
| [`HonoBase.route()` implementation](https://github.com/honojs/hono/blob/main/src/hono-base.ts) | ||
| copies a sub-application's handlers into the same in-process router. | ||
|
|
||
| The architecture that matches the remembered behavior is Cloudflare's | ||
| [HTTP service-binding API gateway pattern](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/http/): | ||
| a small public Worker selects a coarse route island and forwards the original `Request` to a private | ||
| Worker. Cloudflare documents service bindings as running on the same thread and server by default, | ||
| with no additional request charge, while each hop still counts toward the 32-Worker invocation | ||
| limit. | ||
|
|
||
| ## Existing stack | ||
|
|
||
| Every request sample starts a fresh `wrangler dev` process, waits for the listener, and then sends | ||
| the first request. The workerd startup profile is measured separately by `wrangler check startup`. | ||
| Medians use five request runs (three for `/v2/services`). | ||
|
|
||
| | Metric | `main` | Lazy HTTP routes | Cold bootstrap | | ||
| | ----------------------------------------------- | -----------------------: | -----------------------: | -----------------------: | | ||
| | workerd active startup CPU | 114.2 ms | 117.8 ms | 30.7 ms | | ||
| | bundle (raw / gzip) | 12,895.31 / 2,544.05 KiB | 12,895.60 / 2,544.19 KiB | 12,832.11 / 2,534.29 KiB | | ||
| | desktop startup graph eval | 98 ms | 100 ms | 34 ms | | ||
| | desktop startup heap delta | 30.85 MB | 30.85 MB | 5.03 MB | | ||
| | cold `GET /health` request | 202.3 ms | 125.6 ms | 7.4 ms | | ||
| | cold unauthenticated `GET /v2/services` request | 207.3 ms | 129.0 ms | 217.5 ms | | ||
| | startup CPU + `/v2/services` local proxy | 321.5 ms | 246.8 ms | 248.2 ms | | ||
|
|
||
| Interpretation: | ||
|
|
||
| - Lazy endpoint compilation cuts the first real-route proxy by about 23%, while leaving upload-time | ||
| startup unchanged as designed. | ||
| - The cold-bootstrap layer removes 73% of active startup CPU, 84% of startup heap, and 96% of the | ||
| cold health-request time. | ||
| - The generated anticipated-error list moves domain-schema evaluation from startup to the first | ||
| real route. Consequently, request-only `/v2/services` time rises, but the combined local cold | ||
| proxy stays flat versus the lazy-route layer. It solves startup-budget and liveness risk; it does | ||
| not claim a further real-route cold win. | ||
|
|
||
| ## Route-island experiment | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| bun run --cwd apps/api bench:service-bindings --samples=250 | ||
| ``` | ||
|
|
||
| The router measures `binding.fetch()` inside workerd. The two probe Workers compare today's complete | ||
| HTTP/service module graph with the smallest useful telemetry-route module graph using current module | ||
| boundaries. Seven fresh-process runs produced: | ||
|
|
||
| | Graph | Cold module-evaluation samples | Median | | ||
| | ---------------- | ------------------------------------ | -----: | | ||
| | monolith | 153, 154, 150, 151, 157, 152, 152 ms | 152 ms | | ||
| | telemetry island | 107, 101, 101, 101, 103, 102, 102 ms | 102 ms | | ||
|
|
||
| The telemetry island reduces module evaluation by **32.9%**. A representative 250-request warm run | ||
| measured the service-binding call at **0 ms median / 1 ms p95** at workerd's timer resolution. | ||
|
|
||
| This is enough evidence to build a functional canary, not enough evidence to route production | ||
| traffic immediately. The probe evaluates real Maple modules, but intentionally excludes Effect | ||
| Layer acquisition, authentication results, database dialing, and warehouse I/O. | ||
|
|
||
| ## Recommended rollout and acceptance gate | ||
|
|
||
| This benchmark is intentionally separate from the two runtime optimizations it measured. The | ||
| implementation sequence, topology, developer experience, production canary, and rollback design | ||
| live in [`docs/cloudflare-native-api.md`](../../../../docs/cloudflare-native-api.md). | ||
|
|
||
| Use coarse islands, not one Worker per endpoint. Start with the read-heavy v2 telemetry surface; it | ||
| has a coherent dependency graph and the benchmarked 34% module-evaluation reduction. Keep health and | ||
| preflight handling in the tiny public router. Let the target Worker own auth, request scope, | ||
| telemetry, and its per-invocation Hyperdrive connection. A shared "database Worker" would violate | ||
| Maple's request-bound connection lifecycle and add a hop without preserving a socket between | ||
| invocations. | ||
|
|
||
| Deploy the private target before the router, then canary a small share of telemetry requests and | ||
| compare against the monolith for at least one full traffic cycle. Ship the split only if all of these | ||
| hold: | ||
|
|
||
| 1. Cold telemetry-route p50 and p95 improve by at least 15%. | ||
| 2. Warm route p95 regresses by less than 2 ms. | ||
| 3. Error rate, response bytes, CORS headers, auth envelopes, and tracing are equivalent. | ||
| 4. Router plus target CPU is lower at cold p95 and no higher at warm p95. | ||
| 5. Target startup, heap, compressed size, subrequests, and Worker-invocation depth remain inside | ||
| Cloudflare limits with at least 25% headroom. | ||
|
|
||
| If the functional canary misses either latency gate, keep the first two stack layers and drop the | ||
| route-worker layer; the benchmark harness remains useful for future module-boundary changes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export default { | ||
| fetch(): Response { | ||
| const startedAt = performance.now() | ||
| const response = new Response("ok") | ||
| response.headers.set("x-target-handler-ms", (performance.now() - startedAt).toFixed(4)) | ||
| return response | ||
| }, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "$schema": "../../node_modules/wrangler/config-schema.json", | ||
| "name": "maple-api-route-echo-bench", | ||
| "main": "./echo.worker.ts", | ||
| "compatibility_date": "2026-04-08", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| } |
28 changes: 28 additions & 0 deletions
28
apps/api/scripts/service-binding-bench/monolith-probe.worker.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| let initialized = false | ||
| let initialization: Promise<void> | undefined | ||
|
|
||
| const initialize = (): Promise<void> => { | ||
| if (initialization !== undefined) return initialization | ||
| initialization = Promise.all([ | ||
| import("../../src/runtime/service-graph"), | ||
| import("../../src/runtime/http-graph"), | ||
| import("../../src/platform/DatabasePgLive"), | ||
| import("../../src/platform/pg-connection-scope"), | ||
| ]).then(() => undefined) | ||
| return initialization | ||
| } | ||
|
|
||
| export default { | ||
| async fetch(): Promise<Response> { | ||
| const wasInitialized = initialized | ||
| const startedAt = performance.now() | ||
| await initialize() | ||
| const moduleEvaluationMs = performance.now() - startedAt | ||
| initialized = true | ||
| return Response.json({ | ||
| graph: "monolith", | ||
| cached: wasInitialized, | ||
| moduleEvaluationMs, | ||
| }) | ||
| }, | ||
| } |
7 changes: 7 additions & 0 deletions
7
apps/api/scripts/service-binding-bench/monolith.wrangler.jsonc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "$schema": "../../node_modules/wrangler/config-schema.json", | ||
| "name": "maple-api-route-monolith-bench", | ||
| "main": "./monolith-probe.worker.ts", | ||
| "compatibility_date": "2026-04-08", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| interface ServiceBinding { | ||
| fetch(request: Request): Promise<Response> | ||
| } | ||
|
|
||
| interface Env { | ||
| ECHO: ServiceBinding | ||
| MONOLITH: ServiceBinding | ||
| TELEMETRY: ServiceBinding | ||
| } | ||
|
|
||
| const withBindingTiming = async (service: ServiceBinding, request: Request): Promise<Response> => { | ||
| const startedAt = performance.now() | ||
| const response = await service.fetch(request) | ||
| const bindingMs = performance.now() - startedAt | ||
| const headers = new Headers(response.headers) | ||
| headers.set("x-service-binding-ms", bindingMs.toFixed(4)) | ||
| return new Response(response.body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers, | ||
| }) | ||
| } | ||
|
|
||
| export default { | ||
| async fetch(request: Request, env: Env): Promise<Response> { | ||
| const pathname = new URL(request.url).pathname | ||
| switch (pathname) { | ||
| case "/ready": | ||
| case "/direct": | ||
| return new Response("ok", { headers: { "x-service-binding-ms": "0" } }) | ||
| case "/bound": | ||
| return withBindingTiming(env.ECHO, request) | ||
| case "/probe/monolith": | ||
| return withBindingTiming(env.MONOLITH, request) | ||
| case "/probe/telemetry": | ||
| return withBindingTiming(env.TELEMETRY, request) | ||
| default: | ||
| return new Response("Not found", { status: 404 }) | ||
| } | ||
| }, | ||
| } |
12 changes: 12 additions & 0 deletions
12
apps/api/scripts/service-binding-bench/router.wrangler.jsonc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "$schema": "../../node_modules/wrangler/config-schema.json", | ||
| "name": "maple-api-route-router-bench", | ||
| "main": "./router.worker.ts", | ||
| "compatibility_date": "2026-04-08", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| "services": [ | ||
| { "binding": "ECHO", "service": "maple-api-route-echo-bench" }, | ||
| { "binding": "MONOLITH", "service": "maple-api-route-monolith-bench" }, | ||
| { "binding": "TELEMETRY", "service": "maple-api-route-telemetry-bench" }, | ||
| ], | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Concurrent sampling contaminates warm latency numbers
Promise.all([samplePath("/direct"), samplePath("/bound")])runs both 250-request loops at once, so each stream measures latency while the other loads the same local server. Both the direct baseline and the service-binding number are inflated under doubled load, defeating the comparison the benchmark exists to make.Was this helpful? React with 👍 or 👎 to provide feedback.