From 924bfa7ebbfd596a286e4752b857f8ba5c3d8818 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 12:17:56 -0600 Subject: [PATCH 1/9] Benchmark: multi-cycle soak, pinned-CPU fan-out cost and delivery latency The soak kind runs N churned peak/drop cycles against a fresh-process floor at the trough population, then a traffic-free trough and a zero-connection hold. The cpu kind reports symphony CPU seconds per GB and per 1M delivered messages, with upstream-stamped delivery latency. --symphony-cpus pins symphony; --fixed-order interleaves arms deterministically; allocator config env vars no longer leak in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- __test__/bench-copy-memory-oop.ts | 336 ++++++++++++++++++++++++++++-- 1 file changed, 322 insertions(+), 14 deletions(-) diff --git a/__test__/bench-copy-memory-oop.ts b/__test__/bench-copy-memory-oop.ts index 2f31385..489b8f6 100644 --- a/__test__/bench-copy-memory-oop.ts +++ b/__test__/bench-copy-memory-oop.ts @@ -46,6 +46,9 @@ const AFFINITY_RECLAIM_MS = 300_000 + 60_000; // ── Options ─────────────────────────────────────────────────────────────────── +type PhaseKind = 'abc' | 'd' | 'soak' | 'cpu'; +const PHASE_KINDS: PhaseKind[] = ['abc', 'd', 'soak', 'cpu']; + interface Options { conns: number; clients: number; @@ -77,8 +80,14 @@ interface Options { clientReadBufferSize?: number; upstreamReadBufferSize?: number; workerThreads?: number; + cycles: number; + troughHoldMs: number; + cpuWindowMs: number; + cpuWindows: number; + symphonyCpus?: string; + fixedOrder: boolean; arms: string[]; - phases: ('abc' | 'd')[]; + phases: PhaseKind[]; repeats: number; jemalloc: string; mimalloc?: string; @@ -131,6 +140,15 @@ const HELP = `Options (defaults in brackets): --arms a,b,... ${ALL_ARMS.join(', ')} [every arm whose prerequisites exist] --phases abc,d abc = parked → active → slow in one process; d = churn-then-drop [abc,d] + soak = --cycles of churned ramp to --conns, --peak-hold-ms, drop to + --drop-fraction, --trough-hold-ms; cpu = --conns parked, then --cpu-windows + windows of --cpu-window-ms at --msg-rate with no pings: symphony CPU s per GB + and per 1M messages, and delivery latency (upstream write → client read) + --cycles N soak cycles [5] + --trough-hold-ms T soak hold at each trough [390000] + --cpu-window-ms T / --cpu-windows N [60000 / 3] + --symphony-cpus LIST run symphony under taskset -c LIST (pin the orchestrator elsewhere yourself) + --fixed-order run arms in --arms order every repeat instead of shuffling --repeats N [3] --jemalloc PATH LD_PRELOAD library [/usr/lib/-linux-gnu/libjemalloc.so.2] --mimalloc PATH LD_PRELOAD library for mimalloc-preload [none] @@ -174,7 +192,8 @@ function parseOptions(argv: string[]): Options { } const phases = (argValue(argv, '--phases') ?? 'abc,d').split(','); for (const phase of phases) { - if (phase !== 'abc' && phase !== 'd') throw new Error(`--phases takes abc and/or d, got '${phase}'`); + if (!PHASE_KINDS.includes(phase as PhaseKind)) + throw new Error(`--phases takes ${PHASE_KINDS.join(', ')}, got '${phase}'`); } const opts: Options = { conns, @@ -207,8 +226,14 @@ function parseOptions(argv: string[]): Options { clientReadBufferSize: optionalNum('--client-read-buffer-size'), upstreamReadBufferSize: optionalNum('--upstream-read-buffer-size'), workerThreads: optionalNum('--worker-threads'), + cycles: Math.max(1, num('--cycles', 5)), + troughHoldMs: num('--trough-hold-ms', AFFINITY_RECLAIM_MS + 30_000), + cpuWindowMs: num('--cpu-window-ms', 60_000), + cpuWindows: Math.max(1, num('--cpu-windows', 3)), + symphonyCpus: argValue(argv, '--symphony-cpus'), + fixedOrder: argv.includes('--fixed-order'), arms: [], - phases: phases as ('abc' | 'd')[], + phases: phases as PhaseKind[], repeats: Math.max(1, num('--repeats', 3)), jemalloc: argValue(argv, '--jemalloc') ?? `/usr/lib/${libArch}-linux-gnu/libjemalloc.so.2`, mimalloc: argValue(argv, '--mimalloc'), @@ -267,7 +292,7 @@ interface Arm { addon?: string; /** A library that must show up in symphony's memory map, proving the arm is what ran. */ mustMap?: string; - phases: ('abc' | 'd')[]; + phases: PhaseKind[]; } function armUnavailable(name: string, opts: Options): string | undefined { @@ -343,6 +368,7 @@ type ClientCommand = | { type: 'pause' } | { type: 'resume' } | { type: 'ping'; intervalMs: number; size: number } + | { type: 'latency'; msgSize: number } | { type: 'stats'; seq: number } | { type: 'exit' }; @@ -358,10 +384,12 @@ interface ClientStats { bytesIn: number; bytesOut: number; rss: number; + /** Delivery latency histogram (LATENCY_BUCKETS), when stamped messages are on. */ + latency?: number[]; } type UpstreamCommand = - | { type: 'push'; rate: number; size: number } + | { type: 'push'; rate: number; size: number; stamp?: boolean } | { type: 'slow'; rate: number } | { type: 'stats'; seq: number } | { type: 'exit' }; @@ -378,6 +406,23 @@ interface UpstreamStats { rss: number; } +/** Eight buckets per power of two of microseconds, ~9% wide. */ +const LATENCY_BUCKETS = 8 * 32; +const latencyBucket = (micros: number): number => + Math.min(LATENCY_BUCKETS - 1, Math.max(0, Math.floor(Math.log2(Math.max(1, micros)) * 8))); +const bucketMicros = (bucket: number): number => 2 ** ((bucket + 1) / 8); + +function percentileMicros(histogram: number[], fraction: number): number { + const total = histogram.reduce((a, b) => a + b, 0); + if (total === 0) return NaN; + let seen = 0; + for (let i = 0; i < histogram.length; i++) { + seen += histogram[i]; + if (seen >= total * fraction) return bucketMicros(i); + } + return bucketMicros(histogram.length - 1); +} + const emptyClientStats = (): ClientStats => ({ live: 0, inflight: 0, @@ -413,6 +458,10 @@ interface ClientConn { /** Position in `live`; -1 once removed. */ index: number; closing: boolean; + /** Stamped-message framing: echoed burst bytes still to skip, offset in the current message. */ + skip: number; + pos: number; + stamp?: Buffer; } function runClient(argv: string[]): void { @@ -434,6 +483,25 @@ function runClient(argv: string[]): void { let pingCursor = 0; let ping = Buffer.alloc(0); let nextSlot = 0; + let stampedSize = 0; + let latency: number[] | undefined; + + // The upstream's first 8 bytes of each message are its CLOCK_MONOTONIC write time, shared host-wide. + const readStamps = (conn: ClientConn, chunk: Buffer): void => { + const now = process.hrtime.bigint(); + let i = Math.min(conn.skip, chunk.length); + conn.skip -= i; + while (i < chunk.length) { + const n = Math.min((conn.pos < 8 ? 8 : stampedSize) - conn.pos, chunk.length - i); + if (conn.pos < 8) { + chunk.copy(conn.stamp!, conn.pos, i, i + n); + if (conn.pos + n === 8) latency![latencyBucket(Number(now - conn.stamp!.readBigUInt64LE(0)) / 1000)]++; + } + conn.pos += n; + i += n; + if (conn.pos === stampedSize) conn.pos = 0; + } + }; const remove = (conn: ClientConn): void => { if (conn.index < 0) return; @@ -460,7 +528,14 @@ function runClient(argv: string[]): void { let established = false; const tcp = netConnect({ host: '127.0.0.1', port, localAddress }); const socket = tlsConnect({ socket: tcp, servername: SNI, rejectUnauthorized: false }); - const conn: ClientConn = { socket, index: -1, closing: false }; + const conn: ClientConn = { + socket, + index: -1, + closing: false, + skip: burst.length, + pos: 0, + stamp: stampedSize ? Buffer.alloc(8) : undefined, + }; socket.on('secureConnect', () => { established = true; stats.inflight--; @@ -471,6 +546,7 @@ function runClient(argv: string[]): void { }); socket.on('data', (chunk: Buffer) => { stats.bytesIn += chunk.length; + if (conn.stamp) readStamps(conn, chunk); }); socket.on('error', () => {}); socket.on('close', () => { @@ -546,9 +622,14 @@ function runClient(argv: string[]): void { ping = Buffer.alloc(msg.size, 0x70); pingBudget = 0; break; + case 'latency': + stampedSize = msg.msgSize; + latency = new Array(LATENCY_BUCKETS).fill(0); + break; case 'stats': stats.live = live.length; stats.rss = process.memoryUsage.rss(); + stats.latency = latency; process.send!({ type: 'stats', seq: msg.seq, stats }); break; case 'exit': @@ -577,6 +658,7 @@ function runUpstream(argv: string[]): void { const conns: UpstreamConn[] = []; const stats = emptyUpstreamStats(); let msg = Buffer.alloc(0); + let stamp = false; let rate = 0; let budget = 0; let cursor = 0; @@ -592,7 +674,11 @@ function runUpstream(argv: string[]): void { stats.dropped++; return false; } - const sent = msg; + let sent = msg; + if (stamp) { + sent = Buffer.from(msg); + sent.writeBigUInt64LE(process.hrtime.bigint(), 0); + } conn.socket.write(sent, (err) => { if (!err) stats.bytesOut += sent.length; }); @@ -676,6 +762,7 @@ function runUpstream(argv: string[]): void { switch (cmd.type) { case 'push': rate = cmd.rate; + stamp = cmd.stamp === true; msg = Buffer.alloc(cmd.size, 0x6d); budget = 0; break; @@ -1224,8 +1311,8 @@ class Run { for (const c of this.clients) c.send(cmd); } - push(rate: number): void { - for (const u of this.upstreams) u.send({ type: 'push', rate, size: this.opts.msgSize }); + push(rate: number, stamp = false): void { + for (const u of this.upstreams) u.send({ type: 'push', rate, size: this.opts.msgSize, stamp }); } slow(cmd: ClientCommand): void { @@ -1247,7 +1334,11 @@ class Run { const sum = emptyClientStats(); const all = [...this.clients, ...this.slowClients]; for (const s of await Promise.all(all.map((c) => c.stats()))) { - for (const k of Object.keys(sum) as (keyof ClientStats)[]) sum[k] += s[k]; + for (const k of Object.keys(emptyClientStats()) as (keyof ClientStats)[]) (sum[k] as number) += s[k] as number; + if (s.latency) { + sum.latency ??= new Array(LATENCY_BUCKETS).fill(0); + s.latency.forEach((n, i) => (sum.latency![i] += n)); + } } return sum; } @@ -1352,7 +1443,7 @@ class Run { interface RunResult { arm: string; - kind: 'abc' | 'd'; + kind: PhaseKind; repeat: number; error?: string; samples: Sample[]; @@ -1362,6 +1453,8 @@ interface RunResult { /** The LD_PRELOAD allocator library, for the preload arms. */ preloadSha256?: string; maps?: string[]; + /** symphony's Cpus_allowed_list, to show --symphony-cpus took effect. */ + cpusAllowed?: string; } function generateCert(dir: string): { cert: string; key: string } { @@ -1469,7 +1562,7 @@ async function stopAll(children: ChildProcess[]): Promise { async function runProcess( opts: Options, arm: Arm, - kind: 'abc' | 'd', + kind: PhaseKind, repeat: number, useScope: boolean, cert: { cert: string; key: string } @@ -1544,11 +1637,14 @@ async function runProcess( 'SYMPHONY_MALLOC_TRIM_INTERVAL_MS', 'SYMPHONY_TLS_BUFFER_LIMIT', 'RUST_LOG', + 'MALLOC_CONF', + '_RJEM_MALLOC_CONF', ]) { delete env[name]; } const unit = `symphony-memory-oop-${process.pid}-${arm.name}-${kind}-r${repeat}`; const nodeCmd = [ + ...(opts.symphonyCpus ? ['taskset', '-c', opts.symphonyCpus] : []), 'env', ...Object.entries(arm.env).map(([k, v]) => `${k}=${v}`), process.execPath, @@ -1583,7 +1679,7 @@ async function runProcess( let spawnError: Error | undefined; symphony.once('error', (err) => (spawnError = err)); const pid = symphony.pid!; - // systemd-run --scope and env both exec in place, so the spawned pid must be the server itself. + // systemd-run --scope, taskset and env all exec in place, so the spawned pid must be the server itself. await waitFor( async () => { if (spawnError) throw new RunAborted(`${command[0]}: ${spawnError.message}`); @@ -1606,6 +1702,7 @@ async function runProcess( if (arm.mustMap && !maps.includes(realpathSync(arm.mustMap))) { throw new Error(`${arm.mustMap} is not mapped into symphony`); } + result.cpusAllowed = /^Cpus_allowed_list:\s*(\S+)/m.exec(readFileSync(`/proc/${pid}/status`, 'utf8'))?.[1]; const cgroupDir = useScope ? cgroupDirOf(pid) : undefined; if (cgroupDir && !cgroupDir.endsWith(`/${unit}.scope`)) throw new Error(`symphony landed in ${cgroupDir}, not its own scope`); @@ -1720,6 +1817,69 @@ async function runProcess( const mismatch = await run.drainAndVerify(); await run.sample('drained'); if (mismatch) throw new RunAborted(mismatch); + } else if (kind === 'soak') { + startTraffic(); + run.broadcast({ type: 'churn', turnoverS: opts.churnTurnoverS }); + const low = Math.round(opts.conns * opts.dropFraction); + // What a process that never saw the peak holds at the trough population: the troughs' floor. + run.phase = 'fresh-low'; + run.setTarget(low); + await run.reach(low); + await run.hold(opts.peakHoldMs); + await run.sample('fresh-low'); + for (let cycle = 1; cycle <= opts.cycles; cycle++) { + run.phase = `ramp${cycle}`; + run.setTarget(opts.conns); + await run.reach(opts.conns); + run.phase = `peak${cycle}`; + await run.hold(opts.peakHoldMs); + await run.sample(`peak${cycle}`); + run.phase = `trough${cycle}`; + run.setTarget(low); + await run.reach(low); + await run.hold(10_000); + await run.sample(`trough${cycle}-start`); + await run.hold(Math.max(0, opts.troughHoldMs - 10_000)); + await run.sample(`trough${cycle}`); + } + // Without traffic or churn the allocator sees no activity, which is when lazy purging stalls. + run.phase = 'quiet-low'; + run.broadcast({ type: 'churn', turnoverS: 0 }); + run.push(0); + run.broadcast({ type: 'ping', intervalMs: 0, size: 0 }); + await run.hold(opts.settleMs); + await run.sample('quiet-low-start'); + await run.hold(Math.max(0, opts.troughHoldMs - opts.settleMs)); + await run.sample('quiet-low'); + run.phase = 'zero'; + run.setTarget(0); + await run.reach(0); + await run.hold(opts.settleMs); + await run.sample('zero-start'); + await run.hold(Math.max(0, opts.troughHoldMs - opts.settleMs)); + await run.sample('zero'); + const last = run.samples[run.samples.length - 1]; + if (last.clients.lost > 0) { + throw new RunAborted(`${last.clients.lost} connections closed without the client closing them`); + } + } else if (kind === 'cpu') { + run.phase = 'parked'; + run.broadcast({ type: 'latency', msgSize: opts.msgSize }); + run.setTarget(opts.conns); + await run.reach(opts.conns); + await run.hold(opts.settleMs); + run.phase = 'warmup'; + run.push(opts.msgRate, true); + await run.hold(opts.settleMs); + await run.sample('cpu-0'); + for (let w = 1; w <= opts.cpuWindows; w++) { + run.phase = `window${w}`; + await run.hold(opts.cpuWindowMs); + await run.sample(`cpu-${w}`); + } + run.phase = 'drain'; + const mismatch = await run.drainAndVerify(); + if (mismatch) throw new RunAborted(mismatch); } else { run.phase = 'ramp'; startTraffic(); @@ -1822,6 +1982,65 @@ function derive(opts: Options, r: RunResult): Record { out.slowCgroupSockPerSlow = (slow.cgroup.sock - active.cgroup.sock) / slow.clients.paused; if (slowMid) out.slowSecondHalfPerSlow = (slow.proc.vmRss - slowMid.proc.vmRss) / slow.clients.paused; } + } else if (r.kind === 'soak') { + const footprint = (s: Sample): number => s.proc.vmRss + s.proc.vmSwap; + let firstPeak: Sample | undefined; + let maxPeak = 0; + for (let c = 1; c <= opts.cycles; c++) { + const peak = at(`peak${c}`); + const troughStart = at(`trough${c}-start`); + const trough = at(`trough${c}`); + if (!peak) break; + firstPeak ??= peak; + maxPeak = Math.max(maxPeak, footprint(peak)); + out[`peak${c}MiB`] = footprint(peak) / 1048576; + if (troughStart) out[`troughStart${c}Pct`] = (footprint(troughStart) / footprint(peak)) * 100; + if (trough) { + out[`trough${c}MiB`] = footprint(trough) / 1048576; + out[`trough${c}Pct`] = (footprint(trough) / footprint(peak)) * 100; + out[`trough${c}PctOfMaxPeak`] = (footprint(trough) / maxPeak) * 100; + } + } + const lastPeak = at(`peak${opts.cycles}`); + const firstTrough = at('trough1'); + const lastTrough = at(`trough${opts.cycles}`); + if (firstPeak) out.peak1PerConn = footprint(firstPeak) / firstPeak.conns; + const freshLow = at('fresh-low'); + if (freshLow) { + out.freshLowMiB = footprint(freshLow) / 1048576; + for (let c = 1; c <= opts.cycles; c++) { + const trough = at(`trough${c}`); + if (trough) out[`trough${c}OverFreshMiB`] = (footprint(trough) - footprint(freshLow)) / 1048576; + } + } + for (const label of ['quiet-low-start', 'quiet-low', 'zero-start', 'zero']) { + const s = at(label); + if (s) out[`${label}MiB`] = footprint(s) / 1048576; + } + if (idle) out.idleMiB = footprint(idle) / 1048576; + if (firstPeak && lastPeak) out.peakGrowthMiB = (footprint(lastPeak) - footprint(firstPeak)) / 1048576; + if (firstTrough && lastTrough) out.troughGrowthMiB = (footprint(lastTrough) - footprint(firstTrough)) / 1048576; + } else if (r.kind === 'cpu') { + const first = at('cpu-0'); + const last = at(`cpu-${opts.cpuWindows}`); + if (first && last) { + const seconds = (last.t - first.t) / 1000; + const cpu = last.proc.cpu - first.proc.cpu; + const fanOut = last.clients.bytesIn - first.clients.bytesIn; + const proxied = fanOut + (last.upstreams.bytesIn - first.upstreams.bytesIn); + const messages = fanOut / opts.msgSize; + out.cpuMsgsPerS = messages / seconds; + out.cpuDeliveredPct = (messages / (opts.msgRate * last.conns * seconds)) * 100; + out.cpuCores = cpu / seconds; + out.cpuSecPerGB = cpu / (proxied / 1e9); + out.cpuSecPerMMsgs = cpu / (messages / 1e6); + if (first.clients.latency && last.clients.latency) { + const window = last.clients.latency.map((n, i) => n - first.clients.latency![i]); + out.latencyP50Ms = percentileMicros(window, 0.5) / 1000; + out.latencyP99Ms = percentileMicros(window, 0.99) / 1000; + out.latencyP999Ms = percentileMicros(window, 0.999) / 1000; + } + } } else { const peak = at('peak'); const dropStart = at('drop-start'); @@ -1973,6 +2192,94 @@ function summarize(opts: Options, results: RunResult[]): string { ] ), ]; + const cycles = Array.from({ length: opts.cycles }, (_, i) => i + 1); + if (groups.size && [...groups.keys()].some((key) => key.endsWith('|soak'))) { + lines.push( + '', + `Soak, ${opts.cycles} cycles of ramp to ${opts.conns} under churn → hold ${opts.peakHoldMs / 1000} s → ` + + `drop to ${opts.dropFraction * 100}% → hold ${opts.troughHoldMs / 1000} s. Footprint = VmRSS + VmSwap, MiB:`, + '', + ...table( + 'soak', + [ + 'peak/conn c1 (B)', + ...cycles.map((c) => `peak c${c}`), + ...cycles.map((c) => `trough c${c}`), + ...cycles.map((c) => `trough c${c} % of peak`), + 'trough c1 +10 s % of peak', + 'peak growth c1→last', + 'trough growth c1→last', + ], + [ + ['peak1PerConn'], + ...cycles.map((c): [string, number] => [`peak${c}MiB`, 0]), + ...cycles.map((c): [string, number] => [`trough${c}MiB`, 0]), + ...cycles.map((c): [string, number] => [`trough${c}Pct`, 1]), + ['troughStart1Pct', 1], + ['peakGrowthMiB', 0], + ['troughGrowthMiB', 0], + ] + ), + '', + 'Soak floors, MiB: fresh-low = a process that never saw the peak, at the trough population, before cycle 1; ' + + 'quiet = trough population with traffic and churn stopped; zero = every connection closed (start = +settle, end = +trough hold):', + '', + ...table( + 'soak', + [ + 'idle', + 'fresh-low', + ...cycles.map((c) => `trough c${c} over fresh-low`), + 'quiet start', + 'quiet end', + 'zero start', + 'zero end', + ], + [ + ['idleMiB', 0], + ['freshLowMiB', 0], + ...cycles.map((c): [string, number] => [`trough${c}OverFreshMiB`, 0]), + ['quiet-low-startMiB', 0], + ['quiet-lowMiB', 0], + ['zero-startMiB', 0], + ['zeroMiB', 0], + ] + ) + ); + } + if ([...groups.keys()].some((key) => key.endsWith('|cpu'))) { + lines.push( + '', + `Fan-out CPU, ${opts.conns} connections, ${opts.msgSize} B at ${opts.msgRate}/s/conn, ` + + `${opts.cpuWindows} × ${opts.cpuWindowMs / 1000} s` + + (opts.symphonyCpus ? `, symphony pinned to CPUs ${opts.symphonyCpus}` : '') + + '. GB = payload bytes through symphony, both directions; latency = upstream write → client read:', + '', + ...table( + 'cpu', + [ + 'msgs/s', + 'delivered %', + 'symphony cores', + 'CPU s per GB', + 'CPU s per 1M msgs', + 'p50 ms', + 'p99 ms', + 'p99.9 ms', + ], + [ + ['cpuMsgsPerS'], + ['cpuDeliveredPct', 1], + ['cpuCores', 3], + ['cpuSecPerGB', 2], + ['cpuSecPerMMsgs', 3], + ['latencyP50Ms', 2], + ['latencyP99Ms', 2], + ['latencyP999Ms', 2], + ] + ) + ); + } const failed = results.filter((r) => r.error); if (failed.length) lines.push('', 'Invalid runs (excluded):', ...failed.map((r) => `- ${r.arm} ${r.kind} r${r.repeat}: ${r.error}`)); @@ -2035,6 +2342,7 @@ function summarizeDirs(dirs: string[]): void { jemalloc: undefined, mimalloc: undefined, phases: undefined, + fixedOrder: undefined, minAvailableMb: undefined, }); for (const run of runs.slice(1)) { @@ -2100,7 +2408,7 @@ async function orchestrate(argv: string[]): Promise { const results: RunResult[] = []; for (let repeat = 1; repeat <= opts.repeats; repeat++) { // Shuffled per repeat, so drift on a shared host spreads across arms instead of biasing one. - for (const arm of shuffle(arms)) { + for (const arm of opts.fixedOrder ? arms : shuffle(arms)) { for (const kind of arm.phases) { if (stopping) return; console.log(`[${new Date().toISOString()}] ${arm.name} ${kind} repeat ${repeat}`); From 9d84f4e09e7152234d5f2fbe425c150132e6d543 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 19:07:56 -0600 Subject: [PATCH 2/9] Benchmark: glibc arms run a --no-default-features build; per-arm env With jemalloc becoming the default Rust allocator, the repo's own addon is the rust-jemalloc arm, and every other arm runs the build:bench-system-allocator output (--system-addon). --arm-env sets extra environment for one arm, e.g. _RJEM_MALLOC_CONF for a tuned or stats-printing jemalloc arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- __test__/bench-copy-memory-oop.ts | 80 ++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/__test__/bench-copy-memory-oop.ts b/__test__/bench-copy-memory-oop.ts index 489b8f6..97cf161 100644 --- a/__test__/bench-copy-memory-oop.ts +++ b/__test__/bench-copy-memory-oop.ts @@ -8,6 +8,7 @@ * memory, not the proxy's; do not fold this back into one process. * * npm run build # release build; never benchmark a debug addon + * npm run build:bench-system-allocator # the glibc-allocator build every arm but rust-jemalloc runs * npm run benchmark:memory-oop -- --conns 100000 --repeats 3 * * On a shared host, run it under whatever serialises benchmarks there. `--help` lists the options. @@ -91,9 +92,13 @@ interface Options { repeats: number; jemalloc: string; mimalloc?: string; - rustJemallocAddon?: string; + rustJemallocAddon: string; + /** A `--no-default-features` build: the allocator every arm except rust-jemalloc runs on. */ + systemAddon?: string; /** Extra arms that differ only by addon build, `--addon-arm name=path`. */ addonArms: Record; + /** Extra environment per arm, `--arm-env name:KEY=VALUE`. */ + armEnv: Record>; out: string; cgroup: boolean; minAvailableMb: number; @@ -152,11 +157,14 @@ const HELP = `Options (defaults in brackets): --repeats N [3] --jemalloc PATH LD_PRELOAD library [/usr/lib/-linux-gnu/libjemalloc.so.2] --mimalloc PATH LD_PRELOAD library for mimalloc-preload [none] - --rust-jemalloc-addon P .node built with \`--features jemalloc\` (npm run build:bench-jemalloc) - [target/bench-jemalloc/symphony.linux--gnu.node if present] - --addon-arm NAME=PATH an extra arm running another .node build on the default allocator, for - comparing a native change; only the .node is swapped, the JS is this + --rust-jemalloc-addon P .node with jemalloc as its Rust allocator [the default build in the repo root] + --system-addon P .node built without it, for every other arm (npm run build:bench-system-allocator) + [target/bench-system-allocator/symphony.linux--gnu.node if present] + --addon-arm NAME=PATH an extra arm running another .node build on whichever Rust allocator it was + built with, for comparing a native change; only the .node is swapped, the JS is this checkout's dist/; repeatable, and included in the default arm list + --arm-env NAME:KEY=VALUE extra environment for one arm, e.g. an --addon-arm with + _RJEM_MALLOC_CONF=background_thread:true; repeatable --out DIR results root; each invocation writes a timestamped directory under it [$TMPDIR/symphony-memory-oop, else ./bench-results] --no-cgroup run symphony without its own systemd scope @@ -185,7 +193,7 @@ function parseOptions(argv: string[]): Options { argValue(argv, name) === undefined ? undefined : num(name, 0); const conns = num('--conns', 30000); const libArch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; - const defaultAddon = join(ROOT, 'target', 'bench-jemalloc', `symphony.linux-${process.arch}-gnu.node`); + const defaultSystemAddon = join(ROOT, 'target', 'bench-system-allocator', `symphony.linux-${process.arch}-gnu.node`); const sourceHeader = argValue(argv, '--source-header') ?? 'proxyProtocol'; if (sourceHeader !== 'proxyProtocol' && sourceHeader !== 'none') { throw new Error(`--source-header must be proxyProtocol or none, got '${sourceHeader}'`); @@ -237,7 +245,8 @@ function parseOptions(argv: string[]): Options { repeats: Math.max(1, num('--repeats', 3)), jemalloc: argValue(argv, '--jemalloc') ?? `/usr/lib/${libArch}-linux-gnu/libjemalloc.so.2`, mimalloc: argValue(argv, '--mimalloc'), - rustJemallocAddon: argValue(argv, '--rust-jemalloc-addon') ?? (existsSync(defaultAddon) ? defaultAddon : undefined), + rustJemallocAddon: argValue(argv, '--rust-jemalloc-addon') ?? rootAddon(), + systemAddon: argValue(argv, '--system-addon') ?? (existsSync(defaultSystemAddon) ? defaultSystemAddon : undefined), addonArms: Object.fromEntries( argv .flatMap((arg, i) => (arg === '--addon-arm' ? [argv[i + 1] ?? ''] : [])) @@ -252,6 +261,14 @@ function parseOptions(argv: string[]): Options { return [name, resolve(spec.slice(eq + 1))]; }) ), + armEnv: argv + .flatMap((arg, i) => (arg === '--arm-env' ? [argv[i + 1] ?? ''] : [])) + .reduce>>((all, spec) => { + const m = /^([a-z0-9-]+):([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(spec); + if (!m) throw new Error(`--arm-env takes NAME:KEY=VALUE, got '${spec}'`); + (all[m[1]] ??= {})[m[2]] = m[3]; + return all; + }, {}), out: resolve( argValue(argv, '--out') ?? (process.env.TMPDIR ? join(process.env.TMPDIR, 'symphony-memory-oop') : join(ROOT, 'bench-results')) @@ -298,12 +315,18 @@ interface Arm { function armUnavailable(name: string, opts: Options): string | undefined { if (Object.hasOwn(opts.addonArms, name)) return existsSync(opts.addonArms[name]) ? undefined : `${opts.addonArms[name]} not found`; + if (name === 'rust-jemalloc') { + if (!existsSync(opts.rustJemallocAddon)) return `${opts.rustJemallocAddon} not found`; + return isJemallocBuild(opts.rustJemallocAddon) + ? undefined + : `${opts.rustJemallocAddon} was built without its jemalloc feature`; + } + if (!ALL_ARMS.includes(name)) return 'unknown arm'; + if (!opts.systemAddon || !existsSync(opts.systemAddon)) { + return 'no --system-addon (npm run build:bench-system-allocator)'; + } + if (isJemallocBuild(opts.systemAddon)) return `${opts.systemAddon} has jemalloc as its Rust allocator`; switch (name) { - case 'glibc': - case 'arena2': - case 'buflimit': - case 'buf-small': - return undefined; case 'trim': return process.report?.getReport && (process.report.getReport() as any).header.glibcVersionRuntime ? undefined @@ -312,21 +335,24 @@ function armUnavailable(name: string, opts: Options): string | undefined { return existsSync(opts.jemalloc) ? undefined : `${opts.jemalloc} not found (--jemalloc)`; case 'mimalloc-preload': return opts.mimalloc && existsSync(opts.mimalloc) ? undefined : 'no --mimalloc library given'; - case 'rust-jemalloc': - if (!opts.rustJemallocAddon || !existsSync(opts.rustJemallocAddon)) { - return 'no --rust-jemalloc-addon (npm run build:bench-jemalloc)'; - } - // Statically linked, so it never shows up in /proc//maps; its message prefix does in the file. - return readFileSync(opts.rustJemallocAddon).includes('') - ? undefined - : `${opts.rustJemallocAddon} was not built with --features jemalloc`; default: - return 'unknown arm'; + return undefined; } } +const rootAddon = (): string => join(ROOT, `symphony.linux-${process.arch}-gnu.node`); + +/** Statically linked, so it never shows up in /proc//maps; its message prefix does in the file. */ +const isJemallocBuild = (addon: string): boolean => readFileSync(addon).includes(''); + function resolveArm(name: string, opts: Options): Arm { - const arm: Arm = { name, env: {}, config: {}, phases: opts.phases, addon: opts.addonArms[name] }; + const arm: Arm = { + name, + env: {}, + config: {}, + phases: opts.phases, + addon: opts.addonArms[name] ?? (name === 'rust-jemalloc' ? opts.rustJemallocAddon : opts.systemAddon), + }; switch (name) { case 'arena2': arm.env.MALLOC_ARENA_MAX = '2'; @@ -343,9 +369,6 @@ function resolveArm(name: string, opts: Options): Arm { arm.env.LD_PRELOAD = opts.mimalloc!; arm.mustMap = opts.mimalloc; break; - case 'rust-jemalloc': - arm.addon = opts.rustJemallocAddon; - break; case 'buflimit': arm.env.SYMPHONY_TLS_BUFFER_LIMIT = String(opts.tlsBufferLimit); // The queue cap only matters where queues form: active fan-out and slow readers. @@ -356,6 +379,7 @@ function resolveArm(name: string, opts: Options): Arm { arm.config = { clientReadBufferSize: 1024, upstreamReadBufferSize: 4096 }; break; } + Object.assign(arm.env, opts.armEnv[name]); return arm; } @@ -1626,7 +1650,7 @@ async function runProcess( const staged = arm.addon ? stageAddon(arm.addon, join(runDir, 'stage')) : undefined; const serverJs = staged?.serverJs ?? join(ROOT, 'dist', 'server.js'); - const expectedAddon = staged?.addon ?? join(ROOT, `symphony.linux-${process.arch}-gnu.node`); + const expectedAddon = staged?.addon ?? rootAddon(); result.addonSha256 = sha256(expectedAddon); if (arm.mustMap) result.preloadSha256 = sha256(arm.mustMap); // Only this arm's allocator variables reach symphony, whatever the orchestrator was started with. @@ -2303,7 +2327,7 @@ function environment(): Record { } function checkReleaseAddon(): void { - const addon = join(ROOT, `symphony.linux-${process.arch}-gnu.node`); + const addon = rootAddon(); if (!existsSync(join(ROOT, 'dist', 'server.js')) || !existsSync(addon)) { throw new Error('dist/server.js or the native addon is missing: run `npm run build` (release) first'); } @@ -2338,7 +2362,9 @@ function summarizeDirs(dirs: string[]): void { repeats: undefined, out: undefined, addonArms: undefined, + armEnv: undefined, rustJemallocAddon: undefined, + systemAddon: undefined, jemalloc: undefined, mimalloc: undefined, phases: undefined, diff --git a/package.json b/package.json index 715a216..ac5a5de 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "benchmark:throughput": "tsc -p tsconfig.test.json && node dist-test/__test__/benchmark-throughput.js", "diagnostic:keepalive": "tsc -p tsconfig.test.json && node dist-test/__test__/diagnostic-uds-keepalive.js", "benchmark:memory-oop": "tsc -p tsconfig.test.json && node dist-test/__test__/bench-copy-memory-oop.js", - "build:bench-jemalloc": "napi build --platform --release --features jemalloc --js false --dts addon.d.ts target/bench-jemalloc" + "build:bench-system-allocator": "napi build --platform --release --cargo-flags=--no-default-features --js false --dts addon.d.ts target/bench-system-allocator" }, "devDependencies": { "@harperfast/integration-testing": "^0.2.0", From 9a5475c4837d5def7d071247352488ae464c7e88 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 19:08:42 -0600 Subject: [PATCH 3/9] Make jemalloc the default Rust allocator on Linux builds glibc keeps the addon's peak footprint resident after connections close and creeps upward across peaks; jemalloc returns freed memory. On a 5-cycle 100k <-> 10k churned soak glibc held ~100% of peak at every trough while jemalloc held ~28% and stayed flat, for +1.9% parked bytes per connection and no measurable CPU cost. The existing jemalloc feature becomes a default, Linux-only dependency (glibc and musl); --no-default-features builds on the system allocator. aarch64 builds target 64 KiB pages so the binary starts on 4/16/64 KiB kernels. CI now runs the JS suite on the arm64 and musl artifacts as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .cargo/config.toml | 5 +++++ .github/workflows/CI.yml | 43 ++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 11 +++++++++- Cargo.toml | 6 +++++- README.md | 5 +++++ src/lib.rs | 45 ++++++++++++++++++++++++++++++++++++---- 6 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ea4928d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[env] +# jemalloc fixes its page size when it is built and aborts on a kernel with larger pages. arm64 +# kernels run 4, 16 or 64 KiB pages and a cross build would detect the builder's, so build for 64 KiB. +AARCH64_UNKNOWN_LINUX_GNU_JEMALLOC_SYS_WITH_LG_PAGE = "16" +AARCH64_UNKNOWN_LINUX_MUSL_JEMALLOC_SYS_WITH_LG_PAGE = "16" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fc3f323..47743f1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -202,6 +202,49 @@ jobs: - name: Run tests run: npm test + test-linux-variants: + # Every Linux build links its own jemalloc (page size and libc differ per target), so each one is + # loaded and exercised, not just x64 glibc. + name: Test – ${{ matrix.artifact }} + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + needs: [build-linux] + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04-arm + container: node:22-bookworm + artifact: symphony.linux-arm64-gnu.node + - os: ubuntu-latest + container: node:22-alpine + artifact: symphony.linux-x64-musl.node + - os: ubuntu-24.04-arm + container: node:22-alpine + artifact: symphony.linux-arm64-musl.node + + steps: + - uses: actions/checkout@v4 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + + # The local-build loader looks for the flat `-[-gnu].node` names only. + - name: Stage addon + run: | + arch=$(node -p process.arch) + [ -f "symphony.linux-$arch-gnu.node" ] || mv "${{ matrix.artifact }}" "symphony.linux-$arch.node" + if command -v apk >/dev/null; then apk add --no-cache openssl; fi + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + integration-test: name: Integration Test – Harper UDS proxy runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 7883aa4..12f3cc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ TCP accept (SO_REUSEPORT per worker thread) | File | Responsibility | |---|---| -| `src/lib.rs` | Crate root; `mod` declarations; `#[macro_use] napi_derive` | +| `src/lib.rs` | Crate root; `mod` declarations; `#[macro_use] napi_derive`; jemalloc as the global allocator on Linux | | `src/proxy.rs` | All `#[napi]`-exposed types and methods; config parsing helpers | | `src/listener.rs` | TCP accept loop for TLS listeners; SO_REUSEPORT per worker; RLIMIT_NOFILE | | `src/liveness.rs` | Dead-peer detection: TCP keepalive on the listening socket; the half-close bound | @@ -151,6 +151,15 @@ Two further things are deliberately *not* solved: rustls' ring `Ticketer` rotate ### Send + Sync in napi classes napi `Buffer` contains raw pointers (`*mut napi_env__`, `*mut napi_ref__`) that are not `Sync`. The `SymphonyProxyWrap` struct must be `Send + Sync`. Solution: napi types (`Buffer`, `JsCertConfig`, etc.) are only used as constructor/method *parameters*, immediately converted to plain Rust (`Vec`, `ListenerTlsSpec`, etc.), and never stored in the struct. The struct only holds types that are provably `Send + Sync`. +### jemalloc as the Rust allocator on Linux (`src/lib.rs`, default `jemalloc` feature) +glibc keeps a fan-out node's peak footprint after its connections close: freed chunks are scattered across per-thread arenas, and glibc returns only the top of a heap. On a 100k ↔ 10k churned soak glibc held ~100% of peak at every trough and at zero connections, and its peaks crept up cycle over cycle; jemalloc fell to ~28% of peak at each trough and stayed flat. `--cargo-flags=--no-default-features` builds on the system allocator, for bisecting. + +- **Prefixed symbols are what make it safe in a Node process.** tikv-jemallocator exports `_rjem_*`, so only Rust's `#[global_allocator]` reaches it and Node/V8 stay on glibc. The invariant that follows: memory is freed by the allocator that allocated it. It holds because nothing crosses the boundary by ownership — JS `Buffer` arguments are borrowed (napi-rs unrefs them on drop, never frees the bytes) and `pem_bytes` copies them; values returned to JS are copied by `napi_create_*`; the class wrapper and threadsafe-function payloads are freed by Rust code in napi-rs's finalizers. Returning a Rust `Vec` to JS as an external buffer would still be fine (its finalizer is Rust), but a C library that `free()`s memory Rust allocated, or vice versa, would crash. +- **`disable_initial_exec_tls`** is required: Node `dlopen()`s the addon, and jemalloc's default TLS model cannot be dlopened. +- **arm64 page size is fixed at build time** (`.cargo/config.toml`): jemalloc refuses to start on a kernel whose pages are larger than the ones it was built for, and a cross build would detect the builder's 4 KiB. The aarch64 targets are built for 64 KiB, which also runs on 4 and 16 KiB kernels. +- **Defaults are deliberate.** jemalloc purges freed pages only when that arena sees allocation activity, so a process with zero connections and no traffic keeps them (~25% of peak in the soak); `background_thread:true` returned them (717 → 196 MiB) but made no difference at a trough with live traffic, where the residue is pages pinned by surviving connections. If idle reclamation matters, enable it with `mallctl("background_thread")` after initialization — jemalloc documents that turning it on through `malloc_conf` at startup can deadlock. +- `allocator_tests::rust_allocations_come_from_jemalloc` asks jemalloc (`arenas.lookup`) whether a live `Box` is its own, which proves selection, not just linkage. + --- ## Conventions diff --git a/Cargo.toml b/Cargo.toml index 8407e3d..b0237b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,12 +27,16 @@ ring = "0.17" md-5 = "0.10" sha2 = "0.10" zeroize = "1" + +[target.'cfg(target_os = "linux")'.dependencies] # `disable_initial_exec_tls`: jemalloc's default TLS model cannot be dlopen()ed, which is how Node # loads this cdylib. tikv-jemallocator = { version = "0.6", optional = true, features = ["disable_initial_exec_tls"] } [features] -# Memory-benchmark arm only (see src/lib.rs); never enabled in published builds. +default = ["jemalloc"] +# Rust's global allocator on Linux (see src/lib.rs). `--no-default-features` builds on the system +# allocator instead. jemalloc = ["dep:tikv-jemallocator"] [dev-dependencies] diff --git a/README.md b/README.md index 1fd2a82..930644f 100644 --- a/README.md +++ b/README.md @@ -821,6 +821,11 @@ npm run build:debug # builds a dev .node file npm run build # release build (LTO, stripped) ``` +On Linux the addon's Rust allocations go to a statically linked jemalloc, which returns memory to the +OS after connections close; glibc's malloc keeps the peak resident. Its symbols are prefixed, so Node's +own allocations stay on the system allocator. To build on the system allocator instead, for example +to bisect a memory problem: `npm run build -- --cargo-flags=--no-default-features`. + ### Cross-compilation Use the napi-rs Docker images (same ones used in CI): diff --git a/src/lib.rs b/src/lib.rs index 9e720d9..4f897e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,9 +21,46 @@ mod suspended; mod tls; mod upstream; -// Memory-benchmark arm (`npm run build:bench-jemalloc`): the addon's Rust allocations go to jemalloc -// while Node's stay on the system allocator. Its symbols stay prefixed, so it never interposes -// malloc for the rest of the process. -#[cfg(feature = "jemalloc")] +// glibc keeps the peak footprint resident after connections close: freed chunks stranded across its +// per-thread arenas are never returned to the OS. jemalloc returns them. Only the addon's Rust +// allocations move; its symbols stay prefixed, so Node's malloc is untouched, and nothing either +// side allocates is freed by the other (Rust↔JS values are copied, and napi finalizers run Rust drops). +#[cfg(all(target_os = "linux", feature = "jemalloc"))] #[global_allocator] static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +#[cfg(all(test, target_os = "linux", feature = "jemalloc"))] +mod allocator_tests { + use std::ffi::{c_char, c_int, c_void}; + + extern "C" { + #[link_name = "_rjem_mallctl"] + fn mallctl( + name: *const c_char, + oldp: *mut c_void, + oldlenp: *mut usize, + newp: *mut c_void, + newlen: usize, + ) -> c_int; + } + + /// Linking jemalloc is not selecting it: `arenas.lookup` only succeeds for memory jemalloc handed out. + #[test] + fn rust_allocations_come_from_jemalloc() { + let boxed = Box::new([7u8; 64]); + let mut ptr = boxed.as_ptr() as *const c_void; + let mut arena: u32 = u32::MAX; + let mut arena_len = std::mem::size_of::(); + let rc = unsafe { + mallctl( + c"arenas.lookup".as_ptr(), + &mut arena as *mut u32 as *mut c_void, + &mut arena_len, + &mut ptr as *mut *const c_void as *mut c_void, + std::mem::size_of::<*const c_void>(), + ) + }; + assert_eq!(rc, 0, "a Box was not allocated by jemalloc"); + assert_ne!(arena, u32::MAX); + } +} From b26e4e9e025c28cdb7ed538fbee06dc4863a550d Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 19:35:13 -0600 Subject: [PATCH 4/9] Run CI variant tests through docker; harden the benchmark's cpu phase The runner cannot run JavaScript actions in an Alpine job container on arm64, so each variant runs npm test via docker run. The upstream stamps a connection only after echoing its burst, so a late open cannot be misframed; cpu runs sample after draining, so a latched fault invalidates them; --msg-size below the 8-byte stamp is rejected; and --summarize keys an arm on its environment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .github/workflows/CI.yml | 30 +++++++++++--------------- Cargo.toml | 3 +-- __test__/bench-copy-memory-oop.ts | 36 ++++++++++++++++++++++++++----- src/lib.rs | 8 +++---- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 47743f1..599aaca 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -203,11 +203,9 @@ jobs: run: npm test test-linux-variants: - # Every Linux build links its own jemalloc (page size and libc differ per target), so each one is - # loaded and exercised, not just x64 glibc. + # Each Linux artifact links its own jemalloc (libc and page size differ per target). name: Test – ${{ matrix.artifact }} runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} needs: [build-linux] timeout-minutes: 15 strategy: @@ -215,13 +213,13 @@ jobs: matrix: include: - os: ubuntu-24.04-arm - container: node:22-bookworm + image: node:22-bookworm artifact: symphony.linux-arm64-gnu.node - os: ubuntu-latest - container: node:22-alpine + image: node:22-alpine artifact: symphony.linux-x64-musl.node - os: ubuntu-24.04-arm - container: node:22-alpine + image: node:22-alpine artifact: symphony.linux-arm64-musl.node steps: @@ -232,18 +230,16 @@ jobs: with: name: ${{ matrix.artifact }} - # The local-build loader looks for the flat `-[-gnu].node` names only. - - name: Stage addon - run: | - arch=$(node -p process.arch) - [ -f "symphony.linux-$arch-gnu.node" ] || mv "${{ matrix.artifact }}" "symphony.linux-$arch.node" - if command -v apk >/dev/null; then apk add --no-cache openssl; fi - - - name: Install dependencies - run: npm ci - + # `docker run`, not a job container: the runner cannot run JavaScript actions inside Alpine on + # arm64. The local-build loader looks only for the flat `-[-gnu].node` names. - name: Run tests - run: npm test + run: | + docker run --rm -v "$PWD:/w" -w /w ${{ matrix.image }} sh -ec ' + arch=$(node -p process.arch) + [ -f "symphony.linux-$arch-gnu.node" ] || mv ${{ matrix.artifact }} "symphony.linux-$arch.node" + if command -v apk >/dev/null; then apk add --no-cache openssl; fi + npm ci + npm test' integration-test: name: Integration Test – Harper UDS proxy diff --git a/Cargo.toml b/Cargo.toml index b0237b8..bb8ab2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,7 @@ tikv-jemallocator = { version = "0.6", optional = true, features = ["disable_ini [features] default = ["jemalloc"] -# Rust's global allocator on Linux (see src/lib.rs). `--no-default-features` builds on the system -# allocator instead. +# The Linux global allocator; build with `--no-default-features` to bisect against the system one. jemalloc = ["dep:tikv-jemallocator"] [dev-dependencies] diff --git a/__test__/bench-copy-memory-oop.ts b/__test__/bench-copy-memory-oop.ts index 97cf161..cdb5bbc 100644 --- a/__test__/bench-copy-memory-oop.ts +++ b/__test__/bench-copy-memory-oop.ts @@ -282,6 +282,9 @@ function parseOptions(argv: string[]): Options { ); } if (opts.slowFraction >= 1) throw new Error('--slow-fraction must be below 1'); + if (opts.phases.includes('cpu') && opts.msgSize < 8) { + throw new Error('--phases cpu needs --msg-size >= 8: each message carries an 8-byte send timestamp'); + } if (opts.slowFraction > 0 && spawnSync('python3', ['--version']).status !== 0) { throw new Error('the slow-reader phase needs python3 (or pass --slow-fraction 0)'); } @@ -674,10 +677,13 @@ interface UpstreamConn { slow: boolean; /** Bytes received before the PROXY v1 line ended; undefined once it has. */ header?: Buffer; + /** Burst bytes not yet echoed. A stamped message must not precede them: the client frames after them. */ + burstPending: number; } function runUpstream(argv: string[]): void { const path = argValue(argv, '--socket')!; + const burst = Number(argValue(argv, '--burst')); const expectHeader = argValue(argv, '--source-header') === 'proxyProtocol'; const conns: UpstreamConn[] = []; const stats = emptyUpstreamStats(); @@ -698,6 +704,7 @@ function runUpstream(argv: string[]): void { stats.dropped++; return false; } + if (stamp && conn.burstPending > 0) return false; let sent = msg; if (stamp) { sent = Buffer.from(msg); @@ -716,6 +723,7 @@ function runUpstream(argv: string[]): void { index: conns.length, slow: false, header: expectHeader ? Buffer.alloc(0) : undefined, + burstPending: burst, }; conns.push(conn); socket.on('data', (chunk: Buffer) => { @@ -737,6 +745,7 @@ function runUpstream(argv: string[]): void { if (chunk.length === 0) return; } stats.bytesIn += chunk.length; + conn.burstPending = Math.max(0, conn.burstPending - chunk.length); const echoed = chunk; // Echoed: the burst and pings come back like a broker's acks. socket.write(echoed, (err) => { @@ -1476,6 +1485,8 @@ interface RunResult { addonSha256?: string; /** The LD_PRELOAD allocator library, for the preload arms. */ preloadSha256?: string; + /** What this arm added to symphony's environment. */ + env?: Record; maps?: string[]; /** symphony's Cpus_allowed_list, to show --symphony-cpus took effect. */ cpusAllowed?: string; @@ -1640,9 +1651,22 @@ async function runProcess( new Child( `upstream ${i}`, track( - fork(__filename, ['--role', 'upstream', '--socket', path, '--source-header', opts.sourceHeader], { - stdio: ['ignore', 'inherit', 'inherit', 'ipc'], - }) + fork( + __filename, + [ + '--role', + 'upstream', + '--socket', + path, + '--source-header', + opts.sourceHeader, + '--burst', + String(opts.burstSize), + ], + { + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + } + ) ) ) ); @@ -1653,6 +1677,7 @@ async function runProcess( const expectedAddon = staged?.addon ?? rootAddon(); result.addonSha256 = sha256(expectedAddon); if (arm.mustMap) result.preloadSha256 = sha256(arm.mustMap); + result.env = arm.env; // Only this arm's allocator variables reach symphony, whatever the orchestrator was started with. const env: Record = { ...(process.env as Record) }; for (const name of [ @@ -1903,6 +1928,7 @@ async function runProcess( } run.phase = 'drain'; const mismatch = await run.drainAndVerify(); + await run.sample('drained'); if (mismatch) throw new RunAborted(mismatch); } else { run.phase = 'ramp'; @@ -2383,11 +2409,11 @@ function summarizeDirs(dirs: string[]): void { if (harnesses.size > 1) { console.warn(`--summarize: pooling ${harnesses.size} harness versions; check they measure the same way`); } - // An arm name pools runs only when they ran the same native build and preloaded allocator. + // An arm name pools runs only when they ran the same native build, preloaded allocator and environment. const builds = new Map(); for (const r of results) { if (r.error) continue; - const build = `${r.addonSha256}+${r.preloadSha256 ?? ''}`; + const build = `${r.addonSha256}+${r.preloadSha256 ?? ''}+${JSON.stringify(r.env ?? {})}`; if (builds.has(r.arm) && builds.get(r.arm) !== build) { throw new Error(`--summarize: arm '${r.arm}' ran different builds across the directories`); } diff --git a/src/lib.rs b/src/lib.rs index 4f897e9..16a9600 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,10 +21,8 @@ mod suspended; mod tls; mod upstream; -// glibc keeps the peak footprint resident after connections close: freed chunks stranded across its -// per-thread arenas are never returned to the OS. jemalloc returns them. Only the addon's Rust -// allocations move; its symbols stay prefixed, so Node's malloc is untouched, and nothing either -// side allocates is freed by the other (Rust↔JS values are copied, and napi finalizers run Rust drops). +// glibc keeps the peak footprint resident after connections close; jemalloc returns it. Prefixed +// symbols keep Node on its own malloc, so memory must never be freed across the boundary (CLAUDE.md). #[cfg(all(target_os = "linux", feature = "jemalloc"))] #[global_allocator] static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; @@ -44,7 +42,7 @@ mod allocator_tests { ) -> c_int; } - /// Linking jemalloc is not selecting it: `arenas.lookup` only succeeds for memory jemalloc handed out. + /// `arenas.lookup` only succeeds for memory jemalloc handed out, so this proves selection, not linkage. #[test] fn rust_allocations_come_from_jemalloc() { let boxed = Box::new([7u8; 64]); From db6cd196997d073bb11b6923db26614814119b15 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 19:35:22 -0600 Subject: [PATCH 5/9] CI: fail a Linux variant whose artifact lacks jemalloc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .github/workflows/CI.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 599aaca..19e1692 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -234,6 +234,7 @@ jobs: # arm64. The local-build loader looks only for the flat `-[-gnu].node` names. - name: Run tests run: | + grep -q '' ${{ matrix.artifact }} docker run --rm -v "$PWD:/w" -w /w ${{ matrix.image }} sh -ec ' arch=$(node -p process.arch) [ -f "symphony.linux-$arch-gnu.node" ] || mv ${{ matrix.artifact }} "symphony.linux-$arch.node" From 9773a3023fd726d705ceef0f24d1ae30003a87ba Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 20:00:52 -0600 Subject: [PATCH 6/9] Benchmark: release-check every addon an arm runs; CI asserts jemalloc in the x64 artifact Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .github/workflows/CI.yml | 4 ++++ __test__/bench-copy-memory-oop.ts | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 19e1692..8766b45 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -196,6 +196,10 @@ jobs: with: name: ${{ matrix.artifact }} + - name: Check the artifact links jemalloc + if: runner.os == 'Linux' + run: grep -q '' ${{ matrix.artifact }} + - name: Install dependencies run: npm ci diff --git a/__test__/bench-copy-memory-oop.ts b/__test__/bench-copy-memory-oop.ts index cdb5bbc..c54e3bf 100644 --- a/__test__/bench-copy-memory-oop.ts +++ b/__test__/bench-copy-memory-oop.ts @@ -2352,15 +2352,17 @@ function environment(): Record { }; } -function checkReleaseAddon(): void { - const addon = rootAddon(); - if (!existsSync(join(ROOT, 'dist', 'server.js')) || !existsSync(addon)) { - throw new Error('dist/server.js or the native addon is missing: run `npm run build` (release) first'); +function checkReleaseAddons(arms: Arm[]): void { + if (!existsSync(join(ROOT, 'dist', 'server.js'))) { + throw new Error('dist/server.js is missing: run `npm run build` (release) first'); } - // The release profile strips symbols; a debug build keeps them and would skew every number. - const res = spawnSync('file', ['-L', addon], { encoding: 'utf8' }); - if (res.status === 0 && /not stripped/.test(res.stdout)) { - throw new Error(`${addon} is a debug (unstripped) build: run \`npm run build\` before benchmarking`); + for (const addon of new Set(arms.map((arm) => arm.addon ?? rootAddon()))) { + if (!existsSync(addon)) throw new Error(`${addon} is missing: run \`npm run build\` (release) first`); + // The release profile strips symbols; a debug build keeps them and would skew every number. + const res = spawnSync('file', ['-L', addon], { encoding: 'utf8' }); + if (res.status === 0 && /not stripped/.test(res.stdout)) { + throw new Error(`${addon} is a debug (unstripped) build: benchmark release builds only`); + } } } @@ -2429,7 +2431,6 @@ function socketRoot(): string { async function orchestrate(argv: string[]): Promise { const opts = parseOptions(argv); - checkReleaseAddon(); opts.out = join(opts.out, new Date().toISOString().replace(/[:.]/g, '-')); mkdirSync(opts.out, { recursive: true, mode: 0o700 }); const cert = generateCert(opts.out); @@ -2437,6 +2438,7 @@ async function orchestrate(argv: string[]): Promise { if (opts.cgroup && !useScope) console.warn('systemd-run --user --scope unavailable: no cgroup stats, and symphony may swap'); const arms = opts.arms.map((name) => resolveArm(name, opts)); + checkReleaseAddons(arms); // sun_path holds 108 bytes, and the admin server first binds a `.` sibling (≤ 8 more). const longest = join(socketRoot(), 'smo-XXXXXX', 'admin.sock'); if (Buffer.byteLength(longest) + 8 > 107) { From b0ece2f5add83ce2b8c72a45ae14cd1c07c859c4 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 20:08:44 -0600 Subject: [PATCH 7/9] Benchmark: minor page faults in the cpu phase; tighter arm identity and --msg-size; release asserts jemalloc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .github/workflows/release.yml | 1 + Cargo.toml | 1 - __test__/bench-copy-memory-oop.ts | 13 +++++++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eba417b..61f9dbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,7 @@ jobs: fi npm run build -- --target ${{ matrix.target }} strip --strip-unneeded ${{ matrix.artifact }} 2>/dev/null || true + grep -q '' ${{ matrix.artifact }} - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/Cargo.toml b/Cargo.toml index bb8ab2e..d513195 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,6 @@ tikv-jemallocator = { version = "0.6", optional = true, features = ["disable_ini [features] default = ["jemalloc"] -# The Linux global allocator; build with `--no-default-features` to bisect against the system one. jemalloc = ["dep:tikv-jemallocator"] [dev-dependencies] diff --git a/__test__/bench-copy-memory-oop.ts b/__test__/bench-copy-memory-oop.ts index c54e3bf..06830ec 100644 --- a/__test__/bench-copy-memory-oop.ts +++ b/__test__/bench-copy-memory-oop.ts @@ -282,8 +282,8 @@ function parseOptions(argv: string[]): Options { ); } if (opts.slowFraction >= 1) throw new Error('--slow-fraction must be below 1'); - if (opts.phases.includes('cpu') && opts.msgSize < 8) { - throw new Error('--phases cpu needs --msg-size >= 8: each message carries an 8-byte send timestamp'); + if (opts.phases.includes('cpu') && !(Number.isInteger(opts.msgSize) && opts.msgSize >= 8)) { + throw new Error('--phases cpu needs an integer --msg-size >= 8: each message carries an 8-byte send timestamp'); } if (opts.slowFraction > 0 && spawnSync('python3', ['--version']).status !== 0) { throw new Error('the slow-reader phase needs python3 (or pass --slow-fraction 0)'); @@ -1081,6 +1081,8 @@ interface ProcMem { privateDirty: number; /** utime + stime, seconds. */ cpu: number; + /** Page faults served without I/O: what an allocator that purges and re-touches pages pays. */ + minorFaults: number; } function procMem(pid: number): ProcMem { @@ -1096,6 +1098,7 @@ function procMem(pid: number): ProcMem { anonymous: smaps.Anonymous, privateDirty: smaps.Private_Dirty, cpu: (Number(fields[11]) + Number(fields[12])) / 100, // USER_HZ is 100 on Linux + minorFaults: Number(fields[7]), }; } @@ -1485,7 +1488,6 @@ interface RunResult { addonSha256?: string; /** The LD_PRELOAD allocator library, for the preload arms. */ preloadSha256?: string; - /** What this arm added to symphony's environment. */ env?: Record; maps?: string[]; /** symphony's Cpus_allowed_list, to show --symphony-cpus took effect. */ @@ -2084,6 +2086,7 @@ function derive(opts: Options, r: RunResult): Record { out.cpuCores = cpu / seconds; out.cpuSecPerGB = cpu / (proxied / 1e9); out.cpuSecPerMMsgs = cpu / (messages / 1e6); + out.minorFaultsPerS = (last.proc.minorFaults - first.proc.minorFaults) / seconds; if (first.clients.latency && last.clients.latency) { const window = last.clients.latency.map((n, i) => n - first.clients.latency![i]); out.latencyP50Ms = percentileMicros(window, 0.5) / 1000; @@ -2313,6 +2316,7 @@ function summarize(opts: Options, results: RunResult[]): string { 'symphony cores', 'CPU s per GB', 'CPU s per 1M msgs', + 'minor faults/s', 'p50 ms', 'p99 ms', 'p99.9 ms', @@ -2323,6 +2327,7 @@ function summarize(opts: Options, results: RunResult[]): string { ['cpuCores', 3], ['cpuSecPerGB', 2], ['cpuSecPerMMsgs', 3], + ['minorFaultsPerS', 0], ['latencyP50Ms', 2], ['latencyP99Ms', 2], ['latencyP999Ms', 2], @@ -2415,7 +2420,7 @@ function summarizeDirs(dirs: string[]): void { const builds = new Map(); for (const r of results) { if (r.error) continue; - const build = `${r.addonSha256}+${r.preloadSha256 ?? ''}+${JSON.stringify(r.env ?? {})}`; + const build = `${r.addonSha256}+${r.preloadSha256 ?? ''}+${JSON.stringify(Object.entries(r.env ?? {}).sort())}`; if (builds.has(r.arm) && builds.get(r.arm) !== build) { throw new Error(`--summarize: arm '${r.arm}' ran different builds across the directories`); } From cd1ed3ee42f81f04460a59f694000da387a34586 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 20:23:00 -0600 Subject: [PATCH 8/9] CI/release: build jemalloc with gcc where the image's clang cannot link Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .github/workflows/CI.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8766b45..0aa9e1a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -102,6 +102,13 @@ jobs: export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc export CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc fi + # jemalloc's configure links a test program, which the image's clang cannot do for these targets. + if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then + export CC_x86_64_unknown_linux_musl=gcc + fi + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + export CC_aarch64_unknown_linux_gnu=/usr/aarch64-unknown-linux-gnu/bin/aarch64-unknown-linux-gnu-gcc + fi npm run build -- --target ${{ matrix.target }} strip --strip-unneeded ${{ matrix.artifact }} 2>/dev/null || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61f9dbd..23ffb9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,13 @@ jobs: export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc export CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc fi + # jemalloc's configure links a test program, which the image's clang cannot do for these targets. + if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then + export CC_x86_64_unknown_linux_musl=gcc + fi + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + export CC_aarch64_unknown_linux_gnu=/usr/aarch64-unknown-linux-gnu/bin/aarch64-unknown-linux-gnu-gcc + fi npm run build -- --target ${{ matrix.target }} strip --strip-unneeded ${{ matrix.artifact }} 2>/dev/null || true grep -q '' ${{ matrix.artifact }} From b5c4325f6fa78eea2c0b791c30bf1bae27426705 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 14 Oct 2026 20:27:57 -0600 Subject: [PATCH 9/9] CI/release: aarch64-gnu keeps clang and links jemalloc's configure test via the cross GCC toolchain Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux --- .github/workflows/CI.yml | 6 ++++-- .github/workflows/release.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 0aa9e1a..0eda924 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -102,12 +102,14 @@ jobs: export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc export CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc fi - # jemalloc's configure links a test program, which the image's clang cannot do for these targets. + # jemalloc's configure links a test program, which the image's clang cannot do for these + # targets on its own. aarch64 keeps clang (ring's assembly needs it) and borrows the cross + # GCC toolchain's linker and runtime. if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then export CC_x86_64_unknown_linux_musl=gcc fi if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then - export CC_aarch64_unknown_linux_gnu=/usr/aarch64-unknown-linux-gnu/bin/aarch64-unknown-linux-gnu-gcc + export CFLAGS_aarch64_unknown_linux_gnu="${CFLAGS:-} --gcc-toolchain=/usr/aarch64-unknown-linux-gnu" fi npm run build -- --target ${{ matrix.target }} strip --strip-unneeded ${{ matrix.artifact }} 2>/dev/null || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23ffb9e..8309157 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,12 +59,14 @@ jobs: export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc export CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc fi - # jemalloc's configure links a test program, which the image's clang cannot do for these targets. + # jemalloc's configure links a test program, which the image's clang cannot do for these + # targets on its own. aarch64 keeps clang (ring's assembly needs it) and borrows the cross + # GCC toolchain's linker and runtime. if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then export CC_x86_64_unknown_linux_musl=gcc fi if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then - export CC_aarch64_unknown_linux_gnu=/usr/aarch64-unknown-linux-gnu/bin/aarch64-unknown-linux-gnu-gcc + export CFLAGS_aarch64_unknown_linux_gnu="${CFLAGS:-} --gcc-toolchain=/usr/aarch64-unknown-linux-gnu" fi npm run build -- --target ${{ matrix.target }} strip --strip-unneeded ${{ matrix.artifact }} 2>/dev/null || true