Files
OmniRoute/scripts/perf/agentPayloadCorpus.ts
MumuTW 4bf47c9b80 chore(perf): add deterministic request-body heap benchmark (#7847) (#8549)
#7847 reports a 3.05 MiB request (729 messages / 86 tools) reaching ~12,282 MiB of V8
heap, and asks for "a regression benchmark that records peak heap for representative
500-800-message, tool-rich requests" before any fix lands. There is currently no memory
baseline in the repo at all (bench:compression is the only benchmark), so a clone-reduction
change could neither be justified nor regression-guarded.

npm run bench:heap-body attributes retained heap to each copy the chat path makes:

  | mechanism                          | call site                          | retained | x wire |
  | cloneLogPayload (unbounded)        | chat.ts buildClientRawRequest      | 3.18 MiB |  1.04x |
  | cloneBoundedForLog (bounded)       | requestLogger.logClientRawRequest  | 0.04 MiB |  0.01x |
  | structuredClone x3 (combo targets) | combo.ts attemptBody               | 9.53 MiB |  3.12x |
  | JSON.stringify (token estimate)    | combo.ts estimateTokens            | 3.06 MiB |  1.00x |
  | per request (sum)                  |                                    |15.81 MiB |  5.17x |

It measures the real production helpers rather than reimplementations, so a change to the
log bounds or the clone strategy is reflected directly.

Design notes:
- Deterministic: fixed-seed LCG, no Math.random(). Verified byte-identical across three
  consecutive runs — without that, a before/after delta measures noise, not the change.
- Corpus lives in its own side-effect-free module so the unit test can import it without
  booting SQLite (requestLogger transitively opens the DB at import time).
- Hermetic: DATA_DIR is redirected to a temp dir before importing, so the benchmark never
  touches the operator's real ~/.omniroute store.
- Node, not bun: --expose-gc and V8 heap accounting are the measurement; another engine's
  heap number would not describe the production runtime.
- --max-retained-mib exits non-zero, so this can become a CI gate once a target is agreed.

Reports only; wires nothing into CI and changes no production code.
2026-07-26 03:52:42 -03:00

84 lines
2.4 KiB
TypeScript

/**
* Deterministic coding-agent request corpus for the #7847 heap benchmark.
*
* Kept separate from `request-body-heap.ts` so it can be imported with zero side effects:
* the benchmark redirects DATA_DIR and transitively boots SQLite at import time, which a unit
* test must not do just to assert corpus stability.
*
* Determinism is the point. A benchmark corpus built with Math.random() would make a
* before/after comparison measure noise instead of the change under test, so the generator uses
* a fixed-seed LCG and is covered by tests/unit/heap-benchmark-corpus.test.ts.
*/
/** Defaults reproduce the #7847 production incident: 3.05 MiB, 729 messages, 86 tools. */
export const INCIDENT_SHAPE = {
messages: 729,
tools: 86,
/** Calibrated so the defaults land on the incident's wire size. */
contentWords: 527,
} as const;
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x100000000);
}
const WORDS = [
"refactor",
"handler",
"provider",
"combo",
"stream",
"token",
"context",
"payload",
"upstream",
"fallback",
"quota",
"retry",
"executor",
"translate",
"compression",
"cache",
];
function sentence(rnd: () => number, words: number): string {
const out: string[] = [];
for (let i = 0; i < words; i++) out.push(WORDS[Math.floor(rnd() * WORDS.length)]);
return out.join(" ");
}
/** A coding-agent shaped request: long alternating history plus a large tool catalog. */
export function buildAgentPayload(
messages: number = INCIDENT_SHAPE.messages,
tools: number = INCIDENT_SHAPE.tools,
contentWords: number = INCIDENT_SHAPE.contentWords
): Record<string, unknown> {
const rnd = lcg(0x5eed);
return {
model: "claude-opus-5",
stream: true,
messages: Array.from({ length: messages }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: sentence(rnd, contentWords),
})),
tools: Array.from({ length: tools }, (_, i) => ({
type: "function",
function: {
name: `tool_${i}`,
description: sentence(rnd, 25),
parameters: {
type: "object",
properties: Object.fromEntries(
Array.from({ length: 8 }, (_, p) => [
`param_${p}`,
{ type: "string", description: sentence(rnd, 10) },
])
),
required: ["param_0"],
},
},
})),
};
}