diff --git a/scripts/benchmarks/families/index.ts b/scripts/benchmarks/families/index.ts index 87538597c7..0404ea6792 100644 --- a/scripts/benchmarks/families/index.ts +++ b/scripts/benchmarks/families/index.ts @@ -2,6 +2,7 @@ import { controlFamily } from "./control.js"; import { dnsFamily } from "./dns.js"; import { fsFamily } from "./fs.js"; import { netFamily } from "./net.js"; +import { perfFindingsFamily } from "./perf-findings.js"; import { pipesFamily } from "./pipes.js"; import { processFamily } from "./process.js"; @@ -12,6 +13,7 @@ export const allFamilies = [ dnsFamily, pipesFamily, controlFamily, + perfFindingsFamily, ]; export const allOps = allFamilies.flat(); diff --git a/scripts/benchmarks/families/perf-findings.ts b/scripts/benchmarks/families/perf-findings.ts new file mode 100644 index 0000000000..60505e74cc --- /dev/null +++ b/scripts/benchmarks/families/perf-findings.ts @@ -0,0 +1,80 @@ +import type { BenchmarkOp } from "../lib/layers.js"; + +/** + * Perf-finding regression ops. + * + * Each op exercises one hot-path anti-pattern surfaced by the 2026-06-30 + * perf-slop scan (see ~/progress/secure-exec/2026-06-30-fuzz-perf-optimization-report/ + * perf-slop-scan.md). The `program` runs identically under host Node and inside + * the guest VM, so `tax.emulation = guest.p50 / node.p50` is the guest overhead + * the corresponding fix is expected to shrink. Capture these numbers BEFORE the + * fix lands (baseline) and re-run AFTER (regression guard). + * + * Findings already covered by existing benches (no op here): + * - host_call.rs:429/435/452/534 (per-syscall lock + String clone) -> sync-bridge-floor + * - bridge.rs:1978 (args buffer clone) -> sync-bridge-floor --payload-bytes=65536 + * - execution.rs:897 (loopback peer-pairing 50x10ms retry) -> net/tcp_connect_close + * + * Findings that could NOT be turned into a clean guest-vs-node op (documented so + * the gap is explicit, not silently dropped): + * - v8_host.rs:296 (serialize->deserialize per stream event): stdout/stderr IS + * the harness data channel, so flooding the stdio event path overruns the + * 4096 sidecar event buffer and hangs instead of timing. Needs a + * sidecar-internal per-event counter. + * - execution.rs:962 (byte-by-byte loopback-TLS read): a guest HTTPS loopback + * request currently ERRORS (`ERR_AGENTOS_NODE_SYNC_RPC: loopback TLS transport + * read`), so there is no successful transfer to time. The error on that exact + * path is itself worth a follow-up. + * - execution.rs:19315/19895 (HTTP/2 accept + backpressure polls): node:http2 + * inside the guest hangs (unsupported), so no request completes to time. + */ + +export const perfFindingsFamily: BenchmarkOp[] = [ + { + // crates/sidecar/src/filesystem.rs:1284 — fs.write stdio handler clones the + // full write buffer into the Stdout/Stderr event. fs.writeSync(2, ...) forces + // the write synchronously through the sync bridge so the per-call clone cost + // is actually on the measured path (async stream .write() buffers and reads + // as ~0). 8 x 64KiB per iter stays well under the 4096 event-buffer bound. + family: "perf-finding", + name: "stdio_writeSync_8x64k", + nativeOp: "pipe_throughput", + fileLine: "crates/sidecar/src/filesystem.rs:1284", + reproducer: "8 synchronous fs.writeSync(2, 64KiB) inside VM (stdio buffer clone)", + program: `async () => { + const fs = await import("node:fs"); + const buf = Buffer.alloc(64 * 1024, 7); + for (let k = 0; k < 8; k++) fs.writeSync(2, buf); +}`, + }, + { + // crates/sidecar/src/execution.rs:2213 — guest Unix-socket accept loop sleeps + // a fixed 10ms on WouldBlock instead of async readiness. One connect+close + // pays one accept-latency tick. This is the clearest signal in the set + // (baseline ~30x guest/node) — the 10ms poll dominates a sub-ms host accept. + family: "perf-finding", + name: "unix_accept_latency", + nativeOp: "tcp_connect", + fileLine: "crates/sidecar/src/execution.rs:2213", + reproducer: "connect+close one Unix-domain socket inside VM (10ms accept poll)", + program: `async () => { + const net = await import("node:net"); + const os = await import("node:os"); + const path = await import("node:path"); + const sock = path.join( + os.tmpdir(), + "perf-unix-" + process.pid + "-" + Math.random().toString(16).slice(2) + ".sock", + ); + await new Promise((resolve, reject) => { + const server = net.createServer((s) => s.end()); + server.on("error", reject); + server.listen(sock, () => { + const c = net.connect(sock); + c.on("error", reject); + c.on("close", () => server.close(() => resolve())); + c.end(); + }); + }); +}`, + }, +]; diff --git a/scripts/benchmarks/run-benchmarks.sh b/scripts/benchmarks/run-benchmarks.sh index c158af6aac..40d9fc434c 100755 --- a/scripts/benchmarks/run-benchmarks.sh +++ b/scripts/benchmarks/run-benchmarks.sh @@ -204,6 +204,16 @@ run "sync-bridge-floor-phases" \ --sync-rpc-latency \ --bridge-phases +# Large-args sync bridge lane. Same no-op bridge RPC, but with a 64KiB payload so +# the per-call argument-buffer clone (bridge.rs:1978) is material. This is the +# standing regression guard for the args-serialization buffer reuse. +run "sync-bridge-floor-bigargs" \ + scripts/benchmarks/sync-bridge-floor.bench.ts \ + --iterations="${BENCH_SYNC_BRIDGE_BIGARGS_ITERATIONS:-10}" \ + --warmup="${BENCH_SYNC_BRIDGE_BIGARGS_WARMUP:-2}" \ + --call-counts="${BENCH_SYNC_BRIDGE_BIGARGS_CALL_COUNTS:-1,8,32}" \ + --payload-bytes="${BENCH_SYNC_BRIDGE_BIGARGS_PAYLOAD_BYTES:-65536}" + # Focused DNS lookup floor. Splits the broad dns rows into warm, repeated, # concurrent, and fresh-process lookup shapes. run "dns-lookup-floor" \