Files
openclaw/scripts/bench-agent-concurrency.ts
Peter Steinberger 448eb41653 refactor(tooling): unify benchmark and process helpers (#125491)
* refactor(tooling): unify benchmark and process helpers

* test(tooling): harden process fixture readiness

* fix(tooling): keep benchmark harness types private
2026-08-17 19:08:22 -07:00

395 lines
12 KiB
TypeScript

import { pathToFileURL } from "node:url";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
emitBenchmarkReport,
parseBenchmarkInteger,
parseBenchmarkIntegerList,
parseBenchmarkOptions,
parseBenchmarkWorkerResult,
runBenchmarkEntrypoint,
runBenchmarkJobs,
runBenchmarkWorker,
summarizeBenchmarkTimings,
type BenchmarkTimingSummary,
type BenchmarkWorkerProcessResult,
} from "./lib/benchmark-harness.mts";
const DEFAULT_FANOUT = [1, 8, 32, 64];
const DEFAULT_SWEEP_ROWS = [32, 128, 512];
const WORKER_TIMEOUT_MS = 300_000;
export const WORKER_RESULT_SENTINEL = "[bench-agent-concurrency-result] ";
export type WorkerScenario =
| "spawnPipelineInMemory"
| "spawnPipelineDurable"
| "admission"
| "recoverySweep"
| "duplicateSuppression";
export type WorkerResult = {
scenario: WorkerScenario;
size: number;
timingsMs: number[];
memory: {
rssStartBytes: number;
rssEndBytes: number;
processMaxRssBytes: number;
};
invariant: Record<string, number | boolean>;
};
type Options = {
runs: number;
warmup: number;
fanout: number[];
sweepRows: number[];
output?: string;
json: boolean;
help: boolean;
};
const SCENARIO_SPECS: ReadonlyArray<{
scenario: WorkerScenario;
sizes: "fanout" | "sweepRows";
}> = [
{ scenario: "spawnPipelineInMemory", sizes: "fanout" },
{ scenario: "spawnPipelineDurable", sizes: "fanout" },
{ scenario: "admission", sizes: "fanout" },
{ scenario: "recoverySweep", sizes: "sweepRows" },
{ scenario: "duplicateSuppression", sizes: "sweepRows" },
];
const REQUIRED_INVARIANT_FIELDS: Record<WorkerScenario, readonly string[]> = {
spawnPipelineInMemory: [
"ok",
"registeredRuns",
"reservationsReleased",
"blockedWaits",
"settledRuns",
"settledTasks",
"outstandingWaits",
"durableSubagentRows",
"durableTaskRows",
"durableStateFile",
"postTeardownRegistryRows",
"postTeardownTaskRows",
"postTeardownDurableSubagentRows",
"postTeardownDurableTaskRows",
"postTeardownActiveRootWork",
],
spawnPipelineDurable: [
"ok",
"registeredRuns",
"reservationsReleased",
"blockedWaits",
"settledRuns",
"settledTasks",
"outstandingWaits",
"durableSubagentRows",
"durableTaskRows",
"durableStateFile",
"postTeardownRegistryRows",
"postTeardownTaskRows",
"postTeardownDurableSubagentRows",
"postTeardownDurableTaskRows",
"postTeardownActiveRootWork",
],
admission: ["ok", "admissionCap", "overflowRejected", "released"],
recoverySweep: [
"ok",
"seededRows",
"removedRows",
"retainedCurrent",
"sessionEffects",
"recoveryProjections",
"lostContextCompletions",
],
duplicateSuppression: [
"ok",
"inputRowsPerOrdering",
"newestFirstSelectedRows",
"oldestFirstSelectedRows",
"newestFirstSelectedNewest",
"oldestFirstSelectedNewest",
],
};
type BenchmarkRuntime = {
runWorker?: typeof runWorker;
writeProgress?: (line: string) => void;
now?: () => number;
};
function usage(): string {
return `OpenClaw agent concurrency benchmark
Usage:
node --import tsx scripts/bench-agent-concurrency.ts [options]
Options:
--runs <n> Measured samples per scenario (default: 5)
--warmup <n> Warmup samples per scenario (default: 1)
--fanout <list> Comma-separated spawn/admission sizes (default: 1,8,32,64)
--sweep-rows <list> Comma-separated child counts, with 3 generations each (default: 32,128,512)
--output <path> Write the JSON report to a file
--json Print only the JSON report
--help Show this text
`;
}
function parseOptions(argv: string[]): Options {
return parseBenchmarkOptions<Options>(
argv,
{
runs: 5,
warmup: 1,
fanout: DEFAULT_FANOUT,
sweepRows: DEFAULT_SWEEP_ROWS,
json: false,
help: false,
},
{
"--runs": (options, value) => {
options.runs = parseBenchmarkInteger(value, "--runs", 1, 100);
},
"--warmup": (options, value) => {
options.warmup = parseBenchmarkInteger(value, "--warmup", 0, 20);
},
"--fanout": (options, value) => {
options.fanout = parseBenchmarkIntegerList(value, "--fanout", 256);
},
"--sweep-rows": (options, value) => {
options.sweepRows = parseBenchmarkIntegerList(value, "--sweep-rows", 4096);
},
"--output": (options, value) => {
options.output = value;
},
},
);
}
function expectedWorkerKeys(options: Options): string[] {
return SCENARIO_SPECS.flatMap(({ scenario, sizes }) =>
options[sizes].map((size) => `${scenario}:${size}`),
);
}
function aggregateWorkerResults(
options: Options,
workers: WorkerResult[],
parentMemory = {
rssStartBytes: process.memoryUsage().rss,
rssEndBytes: process.memoryUsage().rss,
},
) {
const expected = expectedWorkerKeys(options);
const byKey = new Map(workers.map((worker) => [`${worker.scenario}:${worker.size}`, worker]));
if (byKey.size !== workers.length) {
throw new Error("worker results contain duplicate scenario/size pairs");
}
const missing = expected.filter((key) => !byKey.has(key));
const unexpected = [...byKey.keys()].filter((key) => !expected.includes(key));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`worker result mismatch: missing=${missing.join(",") || "none"} unexpected=${unexpected.join(",") || "none"}`,
);
}
const scenarios = Object.fromEntries(
SCENARIO_SPECS.map(({ scenario, sizes }) => [
scenario,
options[sizes].map((size) => {
const worker = byKey.get(`${scenario}:${size}`);
if (!worker) {
throw new Error(`missing worker result for ${scenario}:${size}`);
}
return {
size,
timingsMs: summarizeBenchmarkTimings(worker.timingsMs),
memory: worker.memory,
invariant: worker.invariant,
};
}),
]),
) as Record<
WorkerScenario,
Array<{
size: number;
timingsMs: BenchmarkTimingSummary;
memory: WorkerResult["memory"];
invariant: WorkerResult["invariant"];
}>
>;
const checks = {
spawnPipelineInMemory: scenarios.spawnPipelineInMemory.every(
(entry) => entry.invariant.ok === true,
),
spawnPipelineDurable: scenarios.spawnPipelineDurable.every(
(entry) => entry.invariant.ok === true,
),
admissionCapOverflowRelease: scenarios.admission.every((entry) => entry.invariant.ok === true),
sweepRecoveryRowsWithoutSessionEffects: scenarios.recoverySweep.every(
(entry) => entry.invariant.ok === true,
),
dedupeNewestPerChild: scenarios.duplicateSuppression.every(
(entry) => entry.invariant.ok === true,
),
};
const failures = Object.entries(checks)
.filter(([, ok]) => !ok)
.map(([name]) => name);
return {
schemaVersion: 2,
generatedAt: new Date().toISOString(),
runtime: { node: process.version, platform: process.platform, arch: process.arch },
options: {
runs: options.runs,
warmup: options.warmup,
fanout: options.fanout,
sweepRows: options.sweepRows,
},
memory: {
...parentMemory,
workerProcessMaxRssBytes: Math.max(
...workers.map((worker) => worker.memory.processMaxRssBytes),
),
},
scenarios,
invariants: { ok: failures.length === 0, failures, ...checks },
};
}
function assertFiniteNonNegative(value: unknown, field: string): asserts value is number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`worker result field ${field} must be a finite nonnegative number`);
}
}
function validateWorkerResult(
value: unknown,
expected: { scenario: WorkerScenario; size: number; runs: number },
): WorkerResult {
if (!isRecord(value)) {
throw new Error("worker result must be an object");
}
if (value.scenario !== expected.scenario || value.size !== expected.size) {
throw new Error(`worker ${expected.scenario}:${expected.size} returned mismatched identity`);
}
if (!Array.isArray(value.timingsMs) || value.timingsMs.length !== expected.runs) {
throw new Error(
`worker ${expected.scenario}:${expected.size} returned ${Array.isArray(value.timingsMs) ? value.timingsMs.length : "invalid"} samples; expected ${expected.runs}`,
);
}
value.timingsMs.forEach((timing, index) =>
assertFiniteNonNegative(timing, `timingsMs[${index}]`),
);
if (!isRecord(value.memory)) {
throw new Error("worker result memory must be an object");
}
assertFiniteNonNegative(value.memory.rssStartBytes, "memory.rssStartBytes");
assertFiniteNonNegative(value.memory.rssEndBytes, "memory.rssEndBytes");
assertFiniteNonNegative(value.memory.processMaxRssBytes, "memory.processMaxRssBytes");
if (!isRecord(value.invariant)) {
throw new Error("worker result invariant must be an object");
}
for (const field of REQUIRED_INVARIANT_FIELDS[expected.scenario]) {
const invariantValue = value.invariant[field];
if (typeof invariantValue !== "number" && typeof invariantValue !== "boolean") {
throw new Error(`worker result invariant.${field} is missing or invalid`);
}
}
if (value.invariant.ok !== true) {
throw new Error(`worker ${expected.scenario}:${expected.size} reported a failed invariant`);
}
return value as WorkerResult;
}
function parseWorkerProcessResult(
result: BenchmarkWorkerProcessResult,
expected: { scenario: WorkerScenario; size: number; runs: number },
): WorkerResult {
return parseBenchmarkWorkerResult({
result,
label: `${expected.scenario}:${expected.size}`,
sentinel: WORKER_RESULT_SENTINEL,
timeoutMs: WORKER_TIMEOUT_MS,
validate: (value) => validateWorkerResult(value, expected),
});
}
function runWorker(options: Options, scenario: WorkerScenario, size: number): WorkerResult {
return runBenchmarkWorker({
args: [
"--import",
"tsx",
"scripts/bench-agent-concurrency-worker.ts",
"--scenario",
scenario,
"--size",
String(size),
"--runs",
String(options.runs),
"--warmup",
String(options.warmup),
],
label: `${scenario}:${size}`,
sentinel: WORKER_RESULT_SENTINEL,
timeoutMs: WORKER_TIMEOUT_MS,
validate: (value) => validateWorkerResult(value, { scenario, size, runs: options.runs }),
});
}
function benchmark(options: Options, runtime: BenchmarkRuntime = {}) {
const rssStartBytes = process.memoryUsage().rss;
const jobs = SCENARIO_SPECS.flatMap(({ scenario, sizes }) =>
options[sizes].map((size) => ({ scenario, size })),
);
const run = runtime.runWorker ?? runWorker;
const workers = runBenchmarkJobs(jobs, {
prefix: "bench-agent-concurrency",
describe: ({ scenario, size }) => `scenario=${scenario} size=${size}`,
run: ({ scenario, size }) => run(options, scenario, size),
now: runtime.now,
writeProgress: runtime.writeProgress,
});
return aggregateWorkerResults(options, workers, {
rssStartBytes,
rssEndBytes: process.memoryUsage().rss,
});
}
async function main(argv = process.argv.slice(2)): Promise<void> {
const options = parseOptions(argv);
if (options.help) {
process.stdout.write(usage());
return;
}
const report = benchmark(options);
emitBenchmarkReport(report, options, (result) => [
...Object.entries(result.scenarios).flatMap(([name, scenarios]) =>
scenarios.map((scenario) => {
const tail =
scenario.timingsMs.p95 === undefined
? ""
: ` p95=${scenario.timingsMs.p95.toFixed(3)}ms p99=${scenario.timingsMs.p99?.toFixed(3)}ms`;
return `${name} size=${scenario.size} p50=${scenario.timingsMs.p50.toFixed(3)}ms max=${scenario.timingsMs.max.toFixed(3)}ms${tail}`;
}),
),
`max worker RSS ${(result.memory.workerProcessMaxRssBytes / 1024 / 1024).toFixed(1)} MiB`,
]);
}
export const testing = {
aggregateWorkerResults,
benchmark,
parseOptions,
parseWorkerProcessResult,
summarizeTimings: summarizeBenchmarkTimings,
};
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await runBenchmarkEntrypoint("bench-agent-concurrency", main);
}