Skip to content

Commit 492d298

Browse files
committed
test: add CPU benchmarks for the engine-facing request and run-engine paths
Two on-demand benchmarks, neither in the default suite: - apps/webapp: spawns a real webapp with --inspect against throwaway containers, seeds a production environment with a promoted managed deployment, and drives a closed-loop supervisor pool through the worker-action lifecycle. Profiles over CDP so the profile covers only the measured window, and samples event-loop utilization inside the webapp. - internal-packages/run-engine: drives RunEngine directly, profiling the enqueue and lifecycle phases separately so engine cost is not mixed with request-stack overhead. Plus a dependency-free .cpuprofile analyzer that symbolicates through the build's source maps and ranks CPU by package, by self time and by total time. startWebapp gains overrideEnv, applied after the worker-disable defaults, so the HTTP bench can re-enable the run engine worker that drains the master queue into the worker queues a supervisor dequeues from.
1 parent d044670 commit 492d298

19 files changed

Lines changed: 2000 additions & 3 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ ailogger-output.log
8787
observability-map.json
8888

8989
.claude/worktrees/
90+
91+
# CPU benchmark artifacts (profiles + summaries)
92+
.bench/

apps/webapp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
2626
"test": "vitest --no-file-parallelism",
2727
"test:perf": "vitest --config ./vitest.perf.config.ts --run",
28-
"eval:dev": "evalite watch"
28+
"eval:dev": "evalite watch",
29+
"test:bench": "vitest --config ./vitest.bench.config.ts --run"
2930
},
3031
"dependencies": {
3132
"@ai-sdk/openai": "^3.0.0",

apps/webapp/test/bench/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Engine CPU benchmarks
2+
3+
Two benchmarks for the paths the production engine service spends its CPU in, plus a
4+
`.cpuprofile` analyzer. Neither runs in CI: they take minutes, attach the V8 profiler, and
5+
report numbers rather than assert on them.
6+
7+
| bench | what it covers | where |
8+
| --- | --- | --- |
9+
| `engineHttp.bench.test.ts` | the full request stack for `engine/v1/worker-actions/*` | `apps/webapp` |
10+
| `runEngineLifecycle.bench.test.ts` | run-engine and run-queue with no HTTP in the way | `internal-packages/run-engine` |
11+
12+
Artifacts (profiles + JSON summaries) land in `.bench/` at the repo root, which is gitignored.
13+
14+
## HTTP bench
15+
16+
Measures what a managed supervisor actually does: dequeue, start attempt, heartbeat,
17+
read latest snapshot, complete attempt. Needs a built webapp.
18+
19+
```bash
20+
pnpm run build --filter webapp
21+
cd apps/webapp
22+
pnpm run test:bench
23+
```
24+
25+
It spawns a real webapp against throwaway Postgres and Redis containers, seeds a production
26+
environment with a promoted managed deployment, fills the worker queue over the public
27+
trigger API, then drives a closed-loop supervisor pool for the measured window.
28+
29+
The webapp is spawned with `--inspect` and profiled over CDP, so the profile covers only the
30+
measured window rather than boot. Event-loop utilization is sampled **inside** the webapp
31+
process over the same connection.
32+
33+
Knobs:
34+
35+
| var | default | meaning |
36+
| --- | --- | --- |
37+
| `BENCH_RUNS` | 1200 | runs queued before the window opens |
38+
| `BENCH_SUPERVISORS` | 16 | concurrent virtual supervisors |
39+
| `BENCH_HEARTBEATS` | 2 | heartbeats per run |
40+
| `BENCH_DURATION_MS` | 60000 | measured window |
41+
| `BENCH_SAMPLING_INTERVAL_US` | 200 | V8 sampling interval |
42+
| `BENCH_PROFILE_NAME` | `engine-http` | artifact basename |
43+
| `BENCH_EXTRA_ENV` || JSON merged into the webapp's env |
44+
| `BENCH_OUT_DIR` | `<repo>/.bench` | artifact directory |
45+
46+
`BENCH_EXTRA_ENV` plus `BENCH_PROFILE_NAME` is how you A/B a single flag:
47+
48+
```bash
49+
BENCH_RUNS=5000 BENCH_SUPERVISORS=24 BENCH_DURATION_MS=90000 \
50+
BENCH_PROFILE_NAME=engine-http-no-elm \
51+
BENCH_EXTRA_ENV='{"EVENT_LOOP_MONITOR_ENABLED":"0"}' \
52+
pnpm run test:bench
53+
```
54+
55+
Run the same size for both arms and compare `on-cpu ms per completed run` rather than
56+
throughput: throughput on a laptop moves ~5% run to run, on-CPU per unit of work is far
57+
steadier.
58+
59+
## Run-engine bench
60+
61+
No HTTP, no webapp: drives `RunEngine` directly so engine and queue costs are not mixed with
62+
request-stack overhead. Profiles two phases separately, because blending them hides which one
63+
owns a hot frame.
64+
65+
```bash
66+
cd internal-packages/run-engine
67+
pnpm run test:bench
68+
```
69+
70+
Knobs: `BENCH_RUNS`, `BENCH_CONSUMERS`, `BENCH_HEARTBEATS`, `BENCH_CONCURRENCY_LIMIT`,
71+
`BENCH_SAMPLING_INTERVAL_US`, `BENCH_OUT_DIR`.
72+
73+
The driver shares a process with the code under measurement, so its own cost is in the
74+
profile. It is a thin await loop and appears under its own frames rather than smeared across
75+
engine frames.
76+
77+
## Analyzing a profile
78+
79+
```bash
80+
pnpm --filter webapp exec tsx test/bench/analyzeProfile.ts .bench/engine-http.cpuprofile --top 30
81+
```
82+
83+
Three views: CPU by bucket (which package owns the cycles), hottest frames by self time (what
84+
to go fix), and hottest frames by total time (entry points, and a check that the load
85+
exercised the route mix you intended). Frames are symbolicated through the build's source
86+
maps, so bundled chunks report as the source files they came from.
87+
88+
Percentages are shares of **on-CPU** time, with V8's `(idle)` and `(program)` excluded. A
89+
share of wall clock would make everything look cheap whenever the bench was IO-bound.
90+
91+
`--json <path>` writes the full analysis for diffing two runs.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Ranks where a `.cpuprofile` spent its cycles.
4+
*
5+
* pnpm --filter webapp exec tsx test/bench/analyzeProfile.ts <profile> [--top 40] [--json out.json]
6+
*
7+
* `--root` overrides the repo root used to make source paths relative and to
8+
* find the build's source maps; it defaults to the repo containing this file.
9+
*/
10+
import { readFileSync, writeFileSync } from "node:fs";
11+
import { resolve } from "node:path";
12+
import { analyzeProfile, formatAnalysis, type CpuProfile } from "./lib/profileAnalysis";
13+
14+
function parseArgs(argv: string[]): {
15+
profilePath?: string;
16+
top: number;
17+
json?: string;
18+
root: string;
19+
} {
20+
const here = typeof __dirname === "string" ? __dirname : import.meta.dirname;
21+
22+
const defaults = {
23+
top: 30,
24+
root: resolve(here, "..", "..", "..", ".."),
25+
};
26+
27+
let profilePath: string | undefined;
28+
let top = defaults.top;
29+
let json: string | undefined;
30+
let root = defaults.root;
31+
32+
for (let i = 0; i < argv.length; i++) {
33+
const arg = argv[i]!;
34+
if (arg === "--top") top = Number(argv[++i]);
35+
else if (arg === "--json") json = argv[++i];
36+
else if (arg === "--root") root = resolve(argv[++i]!);
37+
else if (!arg.startsWith("--")) profilePath = arg;
38+
}
39+
40+
return { profilePath, top, json, root };
41+
}
42+
43+
const { profilePath, top, json, root } = parseArgs(process.argv.slice(2));
44+
45+
if (!profilePath) {
46+
console.error("usage: analyzeProfile.ts <path-to-.cpuprofile> [--top N] [--json out.json]");
47+
process.exit(1);
48+
}
49+
50+
const profile = JSON.parse(readFileSync(profilePath, "utf8")) as CpuProfile;
51+
const analysis = analyzeProfile(profile, root);
52+
53+
console.log(`\n=== ${profilePath} ===`);
54+
console.log(formatAnalysis(analysis, top));
55+
56+
if (json) {
57+
writeFileSync(json, JSON.stringify(analysis, null, 2));
58+
console.log(`\nwrote ${json}`);
59+
}

0 commit comments

Comments
 (0)