774 lines · 25.2 KB · TypeScript
RawDownload
/** * Session-creation "VM tax" benchmark (JS layer, llmock, deterministic). * * Measures the cost of getting a pi agent ready across lanes and reports how much * of it is the agentOS VM vs the inherent Node.js pi-SDK work: * *   lane `vm`        — AgentOs.create() (vmCreate) + openSession({ sessionId, agent: "pi" }) *   lane `bare-node` — the SAME pi-SDK session construction on host node, no VM *                      (sessionCreate = load pi SDK + createAgentSession) * *   derived.vmTaxMs    = vm.sessionCreate.p50 − bareNode.sessionCreate.p50 *   derived.vmTaxRatio = vm.sessionCreate.p50 / bareNode.sessionCreate.p50 * * All LLM traffic goes to a local llmock, so vmCreate/sessionCreate are * deterministic and gate-able. Prompt latency is intentionally NOT measured here * (it's LLM-bound; it belongs in a separate informational real-API suite). * * Usage: *   pnpm exec tsx scripts/benchmarks/session.bench.ts [--iterations=N] [--warmup=N] *                 [--lanes=vm,bare-node] [--gate] [--update-baseline] * *   --gate             exit non-zero if a gated metric regresses vs baseline.json *   --update-baseline  overwrite baseline.json with this run (review the diff in a PR) */ import { spawn } from "node:child_process";import { createRequire } from "node:module";import { existsSync, readFileSync, writeFileSync } from "node:fs";import { homedir, tmpdir } from "node:os";import os from "node:os";import { dirname, join } from "node:path";import { performance } from "node:perf_hooks";import { AgentOs } from "@rivet-dev/agentos-core";import { LLMock } from "@copilotkit/llmock"; const BASELINE_PATH = join(import.meta.dirname, "baseline.json");const REPO_ROOT = join(import.meta.dirname, "..", ".."); interface PhaseStats {	mean: number;	p50: number;	p95: number;	p99: number;	min: number;	max: number;	stddev: number;} interface BenchMetadata {	timestamp: string;	gitSha: string;	gitDirty: boolean;	hardware: {		cpu: string;		cores: number;		ram: string;		node: string;		os: string;		arch: string;	};	deps: Record<string, string>;	llmock: boolean;	iterations: number;	warmup: number;} interface BenchResult extends BenchMetadata {	benchmark: string;	lanes: Record<string, Record<string, PhaseStats>>;	derived: Record<string, number>;} interface GateRule {	path: string;	tolerance: number;	noiseFloor?: number;	label?: string;} function round(n: number, decimals = 2): number {	const f = 10 ** decimals;	return Math.round(n * f) / f;} function percentile(sorted: number[], p: number): number {	const idx = Math.ceil((p / 100) * sorted.length) - 1;	return sorted[Math.max(0, Math.min(sorted.length - 1, idx))];} function stats(samples: number[]): PhaseStats {	const sorted = [...samples].sort((a, b) => a - b);	const mean = samples.reduce((a, b) => a + b, 0) / samples.length;	const variance =		samples.reduce((a, b) => a + (b - mean) ** 2, 0) / samples.length;	return {		mean: round(mean),		p50: round(percentile(sorted, 50)),		p95: round(percentile(sorted, 95)),		p99: round(percentile(sorted, 99)),		min: round(sorted[0]),		max: round(sorted[sorted.length - 1]),		stddev: round(Math.sqrt(variance)),	};} function safe<T>(fn: () => T, fallback: T): T {	try {		return fn();	} catch {		return fallback;	}} function pkgVersion(name: string): string {	return safe(() => {		const req = createRequire(join(REPO_ROOT, "package.json"));		let dir = dirname(req.resolve(name));		for (let i = 0; i < 8; i++) {			const candidate = join(dir, "package.json");			if (existsSync(candidate)) {				const pkg = JSON.parse(readFileSync(candidate, "utf8"));				if (pkg.name === name && pkg.version) return pkg.version;			}			const parent = dirname(dir);			if (parent === dir) break;			dir = parent;		}		return "unknown";	}, "absent");} function collectMetadata(opts: {	iterations: number;	warmup: number;	llmock: boolean;}): BenchMetadata {	const cpus = os.cpus();	return {		timestamp: new Date().toISOString(),		gitSha: process.env.GITHUB_SHA?.slice(0, 12) ?? "unknown",		gitDirty: false,		hardware: {			cpu: cpus[0]?.model ?? "unknown",			cores: os.availableParallelism(),			ram: `${round(os.totalmem() / 1024 ** 3, 1)} GB`,			node: process.version,			os: `${os.type()} ${os.release()}`,			arch: os.arch(),		},		deps: {			"@rivet-dev/agentos-core": pkgVersion("@rivet-dev/agentos-core"),			"@rivet-dev/agentos-sidecar": pkgVersion("@rivet-dev/agentos-sidecar"),			"@rivet-dev/agentos-runtime-core": pkgVersion("@rivet-dev/agentos-runtime-core"),			"@agentos-software/pi": pkgVersion("@agentos-software/pi"),			"@mariozechner/pi-coding-agent": pkgVersion(				"@mariozechner/pi-coding-agent",			),		},		iterations: opts.iterations,		warmup: opts.warmup,		llmock: opts.llmock,	};} function loadBaseline(): BenchResult | null {	if (!existsSync(BASELINE_PATH)) return null;	return JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as BenchResult;} function writeBaseline(result: BenchResult): void {	writeFileSync(BASELINE_PATH, `${JSON.stringify(result, null, 2)}\n`);} function resolvePath(result: BenchResult, path: string): number | undefined {	const parts = path.split(".");	if (parts[0] === "derived") return result.derived[parts[1]];	const [lane, metric, field] = parts;	const s = result.lanes[lane]?.[metric];	return s ? (s[field as keyof PhaseStats] as number) : undefined;} interface GateOutcome {	path: string;	label: string;	baseline: number | undefined;	current: number | undefined;	deltaPct: number | undefined;	tolerance: number;	regressed: boolean;} function evaluateGate(	current: BenchResult,	baseline: BenchResult | null,	rules: GateRule[],): GateOutcome[] {	return rules.map((rule) => {		const cur = resolvePath(current, rule.path);		const base = baseline ? resolvePath(baseline, rule.path) : undefined;		const deltaPct =			base !== undefined && base !== 0 && cur !== undefined				? round(((cur - base) / base) * 100)				: undefined;		const absDelta =			base !== undefined && cur !== undefined ? cur - base : undefined;		const regressed =			deltaPct !== undefined &&			absDelta !== undefined &&			deltaPct > rule.tolerance * 100 &&			absDelta > (rule.noiseFloor ?? 0);		return {			path: rule.path,			label: rule.label ?? rule.path,			baseline: base,			current: cur,			deltaPct,			tolerance: rule.tolerance,			regressed,		};	});} function printDeltaTable(outcomes: GateOutcome[]): void {	const headers = ["metric", "baseline", "current", "delta%", "budget%", ""];	const rows = outcomes.map((o) => [		o.label,		o.baseline ?? "-",		o.current ?? "-",		o.deltaPct === undefined ? "-" : `${o.deltaPct > 0 ? "+" : ""}${o.deltaPct}`,		`+${round(o.tolerance * 100)}`,		o.baseline === undefined ? "NEW" : o.regressed ? "REGRESSED" : "ok",	]);	const widths = headers.map((h, i) =>		Math.max(h.length, ...rows.map((r) => String(r[i]).length)),	);	const fmt = (row: (string | number)[]) =>		row.map((c, i) => String(c).padStart(widths[i])).join(" | ");	console.error("");	console.error(fmt(headers));	console.error(widths.map((w) => "-".repeat(w)).join("-+-"));	for (const row of rows) console.error(fmt(row));	console.error("");} // ── args ─────────────────────────────────────────────────────────────const argv = process.argv.slice(2);const arg = (k: string, d: string) =>	argv.find((a) => a.startsWith(`--${k}=`))?.split("=")[1] ?? d;const flag = (k: string) => argv.includes(`--${k}`); const ITERATIONS = Number.parseInt(arg("iterations", "5"), 10);const WARMUP = Number.parseInt(arg("warmup", "1"), 10);const LANES = arg("lanes", "vm,bare-node").split(",");const GATE = flag("gate");const UPDATE_BASELINE = flag("update-baseline");const TRACE = flag("trace");// By default the vm lane reuses one sidecar across sessions (production pattern:// build the snapshot once, reuse it). Pass --fresh-vm-per-session for the cold// path (a fresh sidecar per session, so the snapshot cache is never reused).const FRESH_VM_PER_SESSION = flag("fresh-vm-per-session"); if (flag("help")) {	console.log(		"Usage: pnpm exec tsx scripts/benchmarks/session.bench.ts [--iterations=N] [--warmup=N] [--lanes=vm,bare-node] [--gate] [--update-baseline]",	);	process.exit(0);} // ── gate rules: p50 of deterministic metrics + the hardware-independent ratio ──const GATE_RULES: GateRule[] = [	// noiseFloor keeps tiny/fast metrics from flaking on sub-ms jitter.	{ path: "vm.vmCreate.p50", tolerance: 0.15, noiseFloor: 10, label: "vm.vmCreate p50" },	{ path: "vm.sessionCreate.p50", tolerance: 0.12, noiseFloor: 40, label: "vm.sessionCreate p50" },	{ path: "derived.vmTaxRatio", tolerance: 0.15, noiseFloor: 0.1, label: "VM-tax ratio (vm/bare)" },]; // ── shared llmock ────────────────────────────────────────────────────let llmock: LLMock | undefined;let llmockUrl = "";let llmockPort = 0;async function ensureLlmock() {	if (llmock) return { url: llmockUrl, port: llmockPort };	llmock = new LLMock({ port: 0, logLevel: "silent" });	llmock.addFixtures([		{ match: { predicate: () => true }, response: { content: "Hello from llmock" } },	]);	llmockUrl = await llmock.start();	llmockPort = Number(new URL(llmockUrl).port);	return { url: llmockUrl, port: llmockPort };} // ── workload resolution (robust to repo layout) ────────────────────── async function loadPiSoftware(): Promise<unknown> {	// Preferred: the in-repo registry build — that is the code under test, and the	// only build that carries the agent-SDK snapshot (agent.snapshot + the	// dist/sdk-snapshot.js bundle). The published @agentos-software/pi may predate	// the snapshot work, so benchmarking against it silently measures the	// non-snapshot fallback path and hides the optimization entirely.		const local = join(			import.meta.dirname,			"../../../secure-exec/software/pi/dist/index.js",		);	if (existsSync(local)) return (await import(local)).default;	// Fallback: the published/installed software package. Variable specifier so	// this typechecks even when the package isn't installed in the dev workspace.	const piPkg = "@agentos-software/pi";	try {		return (await import(piPkg)).default;	} catch {		throw new Error(				"Could not resolve the pi software package (../secure-exec/software/pi/dist or @agentos-software/pi). Build it first.",		);	}} const PI_SDK_PKG = "@mariozechner/pi-coding-agent"; /** Locate the installed pi-coding-agent package root on the host, or null. */function findPiSdkRoot(): string | null {	const reqs = [		createRequire(join(import.meta.dirname, "../../package.json")),			createRequire(				join(					import.meta.dirname,					"../../../secure-exec/software/pi/package.json",				),			),	];	for (const req of reqs) {		for (const base of req.resolve.paths(PI_SDK_PKG) ?? []) {			const root = join(base, PI_SDK_PKG);			if (existsSync(join(root, "package.json"))) return root;		}		try {			// pnpm symlink layout: resolve the entry, then walk up to the pkg root.			let dir = dirname(req.resolve(PI_SDK_PKG));			for (let i = 0; i < 10; i++) {				const pj = join(dir, "package.json");				if (					existsSync(pj) &&					JSON.parse(readFileSync(pj, "utf8")).name === PI_SDK_PKG				) {					return dir;				}				const parent = dirname(dir);				if (parent === dir) break;				dir = parent;			}		} catch {			/* not resolvable via this require context */		}	}	return null;} /** * Resolve the pi SDK package root, or throw (hard error) if it isn't installed — * the Node.js-equivalent baseline must actually run, not silently vanish. */function resolvePiSdkRootOrThrow(): string {	const root = findPiSdkRoot();	if (!root) {		throw new Error(			`bare-node lane: ${PI_SDK_PKG} is not installed/resolvable on the host. ` +				"Run `pnpm install` (it's a devDependency), or pass --lanes=vm to skip this lane.",		);	}	return root;} /** * The bare-node session-creation script, run in a FRESH node process per sample * so each pays the full cold SDK load (the VM lane reloads the SDK in a fresh V8 * isolate every session, so this is the apples-to-apples "Node.js equivalent").	 * Mirrors secure-exec software/pi/src/adapter.ts `newSession`, with no VM. It times the * SDK load + session construction internally and prints `__MS__=<ms>`. */function bareNodeScript(root: string): string {	return `import { performance } from "node:perf_hooks";import { homedir, tmpdir } from "node:os";import { join } from "node:path";const root = ${JSON.stringify(root)};const phases = [];const t0 = performance.now();async function span(name, fn) {  const s = performance.now();  try { return await fn(); }  finally { phases.push({ name, cat: "pi", ph: "X", pid: 1, tid: 1, ts: (s - t0) * 1000, dur: (performance.now() - s) * 1000 }); }}const [sdk, settings, resource, session] = await span("loadPiSdkRuntime", () => Promise.all([  import(join(root, "dist/core/sdk.js")),  import(join(root, "dist/core/settings-manager.js")),  import(join(root, "dist/core/resource-loader.js")),  import(join(root, "dist/core/session-manager.js")),]));if (typeof sdk.createAgentSession !== "function") {  console.error("pi SDK API changed: createAgentSession missing");  process.exit(2);}const cwd = tmpdir();const agentDir = join(homedir(), ".pi", "agent");const settingsManager = settings.SettingsManager.create(cwd, agentDir);const resourceLoader = new resource.DefaultResourceLoader({ cwd, agentDir });await span("resourceLoader.reload", () => resourceLoader.reload());await span("createAgentSession", () => sdk.createAgentSession({  cwd, agentDir,  sessionManager: session.SessionManager.inMemory(cwd),  resourceLoader, settingsManager,  tools: sdk.createCodingTools(cwd, {}),  customTools: sdk.createCodingTools(cwd, {}),}));const total = performance.now() - t0;console.log("__MS__=" + total);console.log("__TRACE__=" + JSON.stringify([{ name: "newSession", cat: "pi", ph: "X", pid: 1, tid: 1, ts: 0, dur: total * 1000 }, ...phases]));process.exit(0);`;} type TraceEvent = {	name: string;	ph: string;	ts: number;	dur: number;	pid: number;	tid: number;	cat?: string;}; function runBareNode(	root: string,	llmockUrlArg: string,): Promise<{ ms: number; trace: TraceEvent[] }> {	return new Promise((res, rej) => {		const child = spawn(			process.execPath,			["--input-type=module", "-e", bareNodeScript(root)],			{				env: {					...process.env,					ANTHROPIC_BASE_URL: llmockUrlArg,					ANTHROPIC_API_KEY: "bench-key",				},				stdio: ["ignore", "pipe", "pipe"],			},		);		let out = "";		let err = "";		child.stdout.on("data", (d) => {			out += d;		});		child.stderr.on("data", (d) => {			err += d;		});		child.on("error", rej);		child.on("exit", (code) => {			if (code !== 0) {				rej(					new Error(						`bare-node subprocess exited ${code}: ${(err || out).slice(-400)}`,					),				);				return;			}			const m = out.match(/__MS__=([\d.]+)/);			if (!m) {				rej(new Error(`bare-node subprocess produced no timing: ${out.slice(-200)}`));				return;			}			const tm = out.match(/__TRACE__=(.+)/);			res({ ms: Number(m[1]), trace: tm ? JSON.parse(tm[1]) : [] });		});	});} async function bareNodeLaneSample(	root: string,	llmockUrlArg: string,): Promise<Sample> {	const { ms } = await runBareNode(root, llmockUrlArg);	return { sessionCreate: ms };} // ── lanes ────────────────────────────────────────────────────────────type Sample = Record<string, number>; // VM lane: create ONE VM/sidecar and loop open/delete on it, so the// process-wide agent-SDK snapshot cache is exercised the way it is in production// (build-once-per-sidecar, then reuse). A fresh VM per session — the old// behavior — gives every session a cold cache, which hides the snapshot benefit// entirely. The WARMUP sessions build/warm the snapshot; the measured sessions// restore from the cached blob. vmCreate is a single create, reported as one// sample. To measure the cold per-VM path instead, pass --fresh-vm-per-session.async function runVmLane(	software: unknown,): Promise<Record<string, PhaseStats>> {	const { port, url } = await ensureLlmock();	const sessionEnv = {		ANTHROPIC_API_KEY: "bench-key",

Lines 1–500 of 774.