Repository navigation
Add an out-of-process memory benchmark for active, stalled and churning connections - #60
Merged
Merged
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces an out-of-process memory benchmark (bench-copy-memory-oop.ts) and associated environment hooks in Rust to measure and control memory usage under various load conditions, including TLS buffer limits and periodic malloc_trim intervals. The review feedback highlights several robustness improvements in the benchmark script: explicitly checking if child.send is a function before calling it, handling cases where the spawned process PID is undefined, and safely checking for the existence of the memory.swap.max cgroup file to prevent unhandled ENOENT exceptions.
kriszyp
added a commit
that referenced
this pull request
Oct 13, 2026
gemini-code-assist on #60: without swap accounting the scope has no memory.swap.max, and every scoped run failed with ENOENT. VmSwap is still sampled and reported, so the run proceeds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
kriszyp
marked this pull request as ready for review
October 14, 2026 17:07
…connections `npm run benchmark:memory-oop` runs symphony-server in its own process (its own systemd scope with swap disabled, when available), drives it from separate Node TLS clients, python3 slow readers and UDS upstreams, and samples only the server: /proc status + smaps_rollup, its cgroup's memory.stat, and ss skmem of its sockets. Phases: parked, active fan-out, stalled readers, and churn → peak → drop → second peak. Arms: glibc, MALLOC_ARENA_MAX=2, periodic malloc_trim, jemalloc via LD_PRELOAD, jemalloc as the addon's Rust allocator, a rustls buffer cap, and the README's small copy buffers. Two env hooks and a cargo feature make those arms possible; all are inert unless set: - SYMPHONY_TLS_BUFFER_LIMIT caps rustls' outgoing queues (set_buffer_limit). - SYMPHONY_MALLOC_TRIM_INTERVAL_MS runs malloc_trim(0) on a blocking thread while the proxy is started. - `--features jemalloc` (npm run build:bench-jemalloc) installs tikv-jemallocator as the global allocator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
…rors Review round 1 (gemini, cursor-composer): a non-UTF-8 hook value was read as unset, and an active hook now says so on stderr; an unhandled spawn or log stream 'error' could crash the orchestrator before cleanup, and a closed IPC channel made stopAll throw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
… of git Review round 1 (adjudicated): compare /proc maps against real paths, keep an 'error' listener on every child, fail a run whose last sample raised a fault, name lost connections in a payload mismatch, ignore bench-results/, and drop machine-specific wording from comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
…vocations `--addon-arm NAME=PATH` runs another .node build as its own arm, so a code change can be priced against the same workload. `--summarize DIR...` pools separate invocations, so repeats can each take a shared benchmark lock for two hours instead of one run holding it for six. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
… shutdown Review round 2 (codex, gemini, cursor-muse): - payload accounting counts a write when its callback succeeds; - holds, deadlines and sample times use performance.now(); - a signal during start-up kills anything spawned after the stop snapshot; - the validity barrier checks child exits directly, not just the 2 s poll; - the python reader rechecks pause per selected event; - kernel page size is read, not assumed 4 KiB; - over-long Unix socket paths fail fast; - --clients cannot overlap the slow readers' source nets, and the rust-jemalloc arm must actually contain jemalloc; - the TLS buffer limit rejects values that do not fit a usize, and the hook spec restores pre-existing environment values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
…per tick Review round 2 (adjudicated): Node documents its IPC channel as unsupported with a non-Node child, so the slow reader now speaks the same JSON-lines protocol on its stdin/stdout. The churn budget is capped at the live population, and a timeline sample answered after the run ended is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
Review round 3 (gemini, domain; codex's partial output): - the Unix socket path check runs once before the sweep, with the real `.<pid>` margin, instead of aborting it part-way; - --summarize refuses to pool one arm name across different addon builds, and needs at least one directory; - the sweep stops at the next run after a signal; - a failed page-size query is not cached; - --addon-arm names cannot shadow built-in arms or break paths; - a lost connection invalidates a parked/active/slow run; - a truncated JSON line from the python reader fails that child instead of throwing out of the reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
…se losses Review round 4 (codex, gemini, domain): - Unix sockets go in a private directory under $XDG_RUNTIME_DIR (else TMPDIR), so a deep checkout's default results path no longer overflows the 108-byte socket address; - meta.json records the harness file's hash, and --summarize warns when it pools runs from different harness versions; - a connection closed without the client closing it invalidates a churn-then-drop run too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
Repeats run from different frozen copies of the same build carry different addon and preload paths; the per-arm addon hash already identifies the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
Review round 5 (nits): Child and Run declare their fields explicitly, so the file stays type-strippable; a child that fails rejects its pending stats requests at once instead of after the 15 s IPC timeout; socket directories are removed on a signal and when setup fails; --summarize ignores --phases, --no-cgroup and --min-available-mb when comparing workloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
Review round 6: --summarize no longer compares preload library paths, so each run now records the preloaded library's hash and pooling requires it to match. A run without its own cgroup no longer pools with isolated runs, and --upstreams is bounded to 1–64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
gemini-code-assist on #60: without swap accounting the scope has no memory.swap.max, and every scoped run failed with ENOENT. VmSwap is still sampled and reported, so the run proceeds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5 Dispatch-Task: symphony-active-conn-memory-bench
kriszyp
force-pushed
the
bench/active-connection-memory
branch
from
October 14, 2026 17:47
2bc999b to
77005d8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
On fan-out nodes, symphony 1.3.x holds 3–4× the ~19–25 KB per connection that an earlier parked-connection test reported, and its RSS stays at the peak after the connection count falls. That test's harness was never committed, so nothing could say which cause dominates.
🤔 Is a committed benchmark with default-off hooks the right size for this?
As specified, yes. A production node cannot try an allocator swap or a rustls queue cap, and the in-process benchmarks measure their own client. The hooks do nothing unless an env var or cargo feature is set; removing them later is one small revert.
💡 Solution
npm run benchmark:memory-oopsamples only symphony-server, in its own cgroup, while separate processes drive parked, active, stalled-reader and churn-then-drop load. At 100k connections (repeats within 0.1%), before #61:malloc_trimor jemalloc bring it to 15–16% of peak after a 90% drop.#61's body has post-#61 numbers: parked 66.8 → 27.2 KB per connection at 30k.
🤔 Which follow-up first?
With the future-size fix (−34.6 KB per connection, merged as #61 Cut parked per-connection memory 59% by shrinking each connection's task) in, 1024/4096 copy buffers on the MQTT listener bring a connection to 21.5 KB (−68%). Then jemalloc as the Rust allocator: it frees memory after drops, for 6–7% more RSS.
⚖️ Alternatives
benchmark.ts— it hosts the proxy in its load generator; its RSS is mostly the client's.SO_RCVBUF, so the client's own kernel absorbs the backlog; a python3 client sets it and a production-like MSS.LD_PRELOAD,MALLOC_ARENA_MAX, glibc tunables) — none returns free pages from the middle of an arena, and none caps rustls' queue.LD_PRELOADonly — it cannot show whether the addon alone gets the benefit;rust-jemallocshows it does.🤔 Should the two hooks ship in the published addon?
SYMPHONY_TLS_BUFFER_LIMITandSYMPHONY_MALLOC_TRIM_INTERVAL_MSare read in every build, so a field node can try a lever without a rebuild. A malformed value fails proxy construction. The alternative is a cargo feature likejemalloc: no unsupported surface, but experiments need a special build.🔧 Changes
__test__/bench-copy-memory-oop.ts(new, run withnpm run benchmark:memory-oop): an orchestrator plus three child roles, all in one file.systemd-run --userworks (else a plain spawn, with a warning), symphony-server runs fromdist/server.jsin its ownsystemd-run --user --scopewithMemorySwapMax=0. Before loading anything, the harness checks the pid against/health, the addon and preload library against/proc/<pid>/maps, and the cgroup andmemory.swap.max.SO_RCVBUF/TCP_MAXSEG./proc/<pid>/statusandsmaps_rollup, the scope'smemory.stat,ssskmem on both socket ends, and/proc/net/sockstat. It also reports delivered bytes and symphony's CPU time per arm, so a reader can check the arms did equal work.tcp_mempressure.glibc,arena2,trim,jemalloc-preload,rust-jemalloc,buflimit,buf-small, and any--addon-arm NAME=PATHbuild.--summarizepools separate invocations and refuses to mix builds under one arm name.src/bench_hooks.rs(new): readsSYMPHONY_TLS_BUFFER_LIMIT(≥ 1024; 0 would spin tokio-rustls) andSYMPHONY_MALLOC_TRIM_INTERVAL_MS(≥ 100, glibc only). A malformed value is a construction error, and an active hook says so on stderr.trim_periodicallyrunsmalloc_trim(0)on a blocking thread and logs each call's duration.src/proxy_conn.rs:tls_acceptappliesset_buffer_limitthroughaccept_withonly when the limit is set. Unset, it isacceptor.accept, the code this replaced. That is one branch per handshake and nothing per byte.src/proxy.rs: reads both hooks at construction. The trim loop starts instart()and ends on the shutdown broadcast, like the eviction tasks.src/lib.rs,Cargo.toml: an optionaljemallocfeature installstikv-jemallocator(prefixed symbols,disable_initial_exec_tlsfordlopen) as the global allocator. It is off by default, CI never enables it, andnpm run build:bench-jemallocbuilds it.package.json: adds the two scripts and putsbench-hooks.spec.jsinnpm test.README.md: points the sizing caveat at the new benchmark..gitignore: ignoresbench-results/.✅ Verification
cargo test: 157 pass.the_tls_buffer_limit_bounds_what_a_stalled_reader_can_queuefails iftls_acceptdrops the limit: with a 4 KiB cap a stalled client must pin under 16 KB, and over 48 KB without one. Clippy is clean on the default build and with--features jemalloc.npm test: 156 pass.bench-hooks.spec.tscovers malformed values rejected, a 512 KiB patterned echo intact under a 1 KiB rustls cap, and the trim loop on two proxies in turn.bench-run(CPU capped at 2 GHz); no lost or failed connections. Also v1.2.0 vs current at 30k (2 repeats), and the combined levers at 100k (2 repeats).Which hypothesis accounts for the gap
Per connection, 1.3.x with host-manager's config (8 KiB copy buffers, IP affinity, PROXY v1), 100k connections, glibc:
-Zprint-type-sizes. Shrinking it by 34,616 B (futfix) lowered parked RSS by 34,586 B. v1.2.0's 17,936 B smaller future lowered it by 17,990 Bbuf-small(1024/4096) lowered parked RSS by 11,253 B; the buffers shrank by 11,264 Bbuflimit16 KiB → +18.9 KBtrim→ 15% of peak, but eachmalloc_trim(0)holds arena locks for 55 ms (p50) to 689 ms (max); jemalloc → 16% with no pausesssskmem and cgroupsock≈ 1 B/conn at peakThe other ~8.6 KB is rustls session state, its 4 KiB deframer buffer, tokio I/O registrations and IP-affinity entries; it is not broken down further.
The 18.7 KB marginal of the earlier test does not reproduce. v1.2.0 under that test's config measures 37.5 KB marginal at 30k. 1.3.x adds the future's 18 KB (55.5 KB), and the default 8 KiB buffers add 11 KB (66.7 KB). That is inside the 56–75 KB/conn seen in production, with no traffic effect needed. Confidence that this transfers to production is medium: loopback, a glibc build, no RTT.
Results, 100k connections, 3 repeats
N=100000, 8 client procs, 4 UDS upstreams (ipAffinity true), msg 256 B at 1/s/conn, ping 8 B every 30 s, slow fraction 0.1, churn turnover 300 s, drop to 10% held 390 s. Cells: mean (min–max) over runs; bytes per connection unless noted. RSS = VmRSS; kernel = symphony-side TCP skmem r+w+f from ss.
Parked → active → slow:
Churn → peak → drop → second peak → quiesced:
Repeat 1's
jemalloc-preloadandrust-jemallocruns first failed to start, because their admin socket path passed the 108-byte limit; the harness now puts sockets under$XDG_RUNTIME_DIR. Those two arms were re-run for repeat 1 with identical binaries. A stalled reader here hasSO_RCVBUF4096 and MSS 1448; symphony's send buffer for it autotunes to 85 KB. Symphony's CPU varies 280–480% across arms with no per-arm pattern, so it is host load, not an arm effect.v1.2.0 vs current, the earlier test's config, 30k connections
1024/4096 copy buffers, no IP affinity, no PROXY header.
v120is v1.2.0's native addon run with--addon-arm.N=30000, 3 client procs, 4 UDS upstreams (ipAffinity false), msg 256 B at 1/s/conn, ping 8 B every 30 s, slow fraction 0, churn turnover 300 s, drop to 10% held 390 s. Cells: mean (min–max) over runs; bytes per connection unless noted. RSS = VmRSS; kernel = symphony-side TCP skmem r+w+f from ss.
Parked → active → slow:
Combined: the future-size patch plus 1024/4096 buffers, 100k connections
futfixis the patched build andglibctoday's build, both with 1024/4096 copy buffers.N=100000, 8 client procs, 4 UDS upstreams (ipAffinity true), msg 256 B at 1/s/conn, ping 8 B every 30 s, slow fraction 0.1, churn turnover 300 s, drop to 10% held 390 s. Cells: mean (min–max) over runs; bytes per connection unless noted. RSS = VmRSS; kernel = symphony-side TCP skmem r+w+f from ss.
Parked → active → slow:
The future-size patch (the
futfixarm, measured before #61 merged it)handle's future goes from 42,808 to 8,192 bytes. Boxing costs one allocation, only on routes that rewrite HTTP headers.Method limits
.nodevia--addon-armon a musl host.~/dev/tmp/bench-runs/symphony-memory-{r1,r1b,r2,r3,v120,combo}-*/meta.txton the bench host (CPU capped at 2 GHz).🤖 Generated by Claude (Opus 5.5); posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CpGsSRp9pXECi7rMgHedg5
Related PRs: #61 overlaps (same TLS-accept block in proxy_conn.rs, merged first; resolved in the rebase), #57 independent
Complexity: medium
Dispatch: task
symphony-active-conn-memory-bench· queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse,muse; rounds=9; full=2 @ 77005d8
Review-Attention: study ~14m (decisions: do-less-alternative, failed-connection-policy, swap-tolerance, bench-hooks-in-npm-test) @ 77005d8