Repository navigation
Make jemalloc the default Rust allocator on Linux - #66
Conversation
…ency 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
… in the x64 artifact Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
…nd --msg-size; release asserts jemalloc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
There was a problem hiding this comment.
Code Review
This pull request configures jemalloc as the default global allocator for Linux builds to optimize memory reclamation after connections close, preventing glibc's resident peak footprint issue. It updates the build configuration, documentation, and dependencies to support this change, including cross-compilation fixes for arm64 page sizes. Additionally, the out-of-process memory benchmark tool (bench-copy-memory-oop.ts) is significantly enhanced with new soak and cpu benchmarking phases, latency tracking, minor page fault monitoring, and support for testing with the system allocator. A unit test is also added to verify that Rust allocations are correctly routed to jemalloc. There are no review comments, so no further feedback is provided.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
…st via the cross GCC toolchain Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw Dispatch-Task: symphony-jemalloc-default-linux
Sync package + platform + crate versions to 1.4.0. Since 1.3.0, in the order a consumer meets them: Less memory per connection (#61). Each connection's task future shrank from 42.8 KB to 3.3 KB: the HTTP/1 header-rewriting path is boxed, the copy future is pinned once, and the accepted TLS stream is passed by reference instead of being copied into every state. Parked connections measured 66.8 KB -> 27.2 KB each (-59%). A test fails if the future grows past 4 KiB. Memory is returned after connection peaks on Linux (#66). The addon's Rust allocations now use jemalloc on Linux (glibc and musl, x64 and arm64); macOS keeps the system allocator, and Node's own allocations are unchanged. Under glibc, symphony kept ~100% of its peak memory after the connection count fell, and the peak ratcheted up across cycles. With jemalloc, troughs hold ~28% of peak and stay flat across cycles, at +1.9% memory per parked connection. Build with --no-default-features for the system allocator. arm64 builds use 64 KiB jemalloc pages so one binary runs on 4, 16 and 64 KiB-page kernels. A route that fails to rebuild no longer keeps its old client-certificate policy (#65). A hot reload now carries a failing route forward only when the new spec asks for the same client-cert policy; otherwise the SNI is tombstoned rather than admitting clients under the old verifier. TLS session resumption works for resolved suspended routes, and expired IP-affinity entries are reclaimed (#57). forwardFingerprint accepts a list, so a route can forward both JA3 and JA4 (#49). Overlapping upgrades keep status.json pointing at the new process: an outgoing process no longer repoints or unlinks it, and a process that yields to a newer peer keeps watching. Benchmarking: `npm run benchmark:memory-oop` measures symphony's own memory out of process under parked, active, stalled-reader and churn-then-drop load (#60). SYMPHONY_TLS_BUFFER_LIMIT and SYMPHONY_MALLOC_TRIM_INTERVAL_MS are benchmark hooks, off by default and not a supported configuration surface. Known open measurements on #66: CPU for 4 KiB messages is still noisy (256 B messages show no difference), and trough retention with 64 KiB pages has been measured on x86 only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
⊙ Problem
On Linux fan-out nodes, symphony keeps its peak memory after connections drop, and the peak creeps up day by day. The cause is glibc's malloc: memory freed across its per-thread arenas is not returned to the OS.
🤔 Is a default allocator change the right size for this?
Yes. Every Linux deployment has the problem, and an opt-in build would not reach them.
malloc_trimwas ruled out because each call stalls allocation for up to 689 ms.💡 Solution
On Linux, the addon's Rust allocations now go to jemalloc. Node keeps its own malloc. Measured on current main, 100k ↔ 10k connections under churn, 5 cycles, 3 repeats per allocator:
--no-default-featuresbuilds on the system allocator, for bisecting.⚖️ Alternatives
LD_PRELOADjemalloc: same memory result, but every deployment must ship the library, Node uses it too, and apps embedding the addon can't use it.MALLOC_ARENA_MAX=2: still kept 100% of peak in #60.malloc_trim: brings the trough to 15%, but each call holds arena locks 55 ms p50, 689 ms max.🤔 64 KiB jemalloc pages on arm64?
So one arm64 binary starts on 4, 16 and 64 KiB kernels; a 4 KiB build aborts on 64 KiB ones. Bigger slabs may keep more at troughs; being measured on x86 with the same geometry.
🤔 Purge threads for idle processes?
Kept jemalloc's defaults. With live traffic,
background_threadchanged nothing at the trough (742 vs 750 MiB). It only helps a process with zero connections and no traffic, where defaults keep ~25% of peak (717 → 196 MiB). If that matters, it can be switched on at runtime later.🔧 Changes
Cargo.tomlmakes the existingjemallocfeature a default whose dependency builds only on Linux;src/lib.rsinstalls it as the global allocator on Linux only, so macOS is unchanged. Symbols stay prefixed, so Node's malloc is untouched.rust_allocations_come_from_jemallocasks jemalloc whether a liveBoxis its own. That proves the allocator is selected, not just linked..cargo/config.tomlbuilds both aarch64 targets for 64 KiB, which also runs on 4 and 16 KiB kernels. Look hardest here: its memory cost was measured on x86 only (below).npm teston the arm64-gnu, x64-musl and arm64-musl artifacts throughdocker run, and fails if an artifact lacks jemalloc. Before this, only x64 glibc was ever loaded in CI. The release build checks for jemalloc too.free(), and no JS allocation by Rust. JSBuffercert arguments are borrowed: napi-rs only unrefs them on drop, andpem_bytescopies them. Values returned to JS are copied bynapi_create_*. The class wrapper and threadsafe-function payloads are freed by Rust code in napi-rs's finalizers. Recorded with the invariant inCLAUDE.md.__test__/bench-copy-memory-oop.ts):soakphase: N churned peak/drop cycles, measured against a fresh process at the trough population, then a traffic-free trough and a zero-connection hold;cpuphase: symphony CPU seconds per GB and per 1M delivered messages, with delivery latency from send timestamps the client reads back;--symphony-cpuspinning,--fixed-orderand--arm-env.--no-default-featuresbuild; the repo's own build is therust-jemallocarm.✅ Verification
Bench host: i7-12700H at 2 GHz, Linux 7.0, glibc 2.43, Node 26.2. Arms interleaved; symphony in its own cgroup, swap off. The measured tree is merged main's.
cargo test: 159 pass on the default build, including the allocator check and the 4 KiB future-size guard; 158 with--no-default-features.cargo clippy --all-targets -D warningsis clean on both.npm test: 158/158 on the default (jemalloc) build.--no-default-featuresbuild has no jemalloc in it.Parked, 100k connections (abc phase), 3 runs per arm
Soak, 5 cycles, 100k ↔ 10k under churn
Soak, 5 cycles of ramp to 100000 under churn → hold 120 s → drop to 10% → hold 390 s. Footprint = VmRSS + VmSwap, MiB:
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):
Fan-out CPU and latency, 256 B messages, symphony pinned to CPUs 0-7
Fan-out CPU, 20000 connections, 256 B at 10/s/conn, 3 × 60 s, symphony pinned to CPUs 0-7. GB = payload bytes through symphony, both directions; latency = upstream write → client read:
Fan-out CPU and latency, 4096 B messages, symphony pinned to CPUs 0-7
Fan-out CPU, 20000 connections, 4096 B at 2/s/conn, 3 × 60 s, symphony pinned to CPUs 0-7. GB = payload bytes through symphony, both directions; latency = upstream write → client read:
Signed: Claude Opus 5.5
🤖 Generated with Claude Code
https://claude.ai/code/session_016hppFxKQ8eH3zUG7dRrjyw
Related PRs: #60 overlaps
Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; blocked=claude(fallback)(out-of-budget); declined=cursor-grok,cursor-kimi,cursor-muse,muse; rounds=4; full=1 @ b0ece2f
Review-Attention: study ~15m (sensitive: CI.yml; decisions: jemalloc-default-on-linux, arm64-single-64k-page-build, idle-reclaim-off, variant-legs-run-as-root, do-less-alternative) @ b0ece2f