Repository navigation
Conversation
…n extension glibc's per-thread arenas serialize the KV router's event workers on arena locks: cross-thread frees and realloc growth in CRTC stores wait on __lll_lock_wait_private, and write capacity stops rising after about 16 event workers. mimalloc's thread-local heaps remove that ceiling. - Make mimalloc the global allocator of the `_core` extension behind a default `mimalloc` feature, built with local-dynamic TLS so a `dlopen`ed extension does not take the static TLS reserve. It covers Rust allocations made by the extension only; Python and C/C++ libraries keep their allocators. - Model teardown trims glibc arenas with `malloc_trim`, which no longer holds the extension's Rust heap. Add `register_allocator_trim_hook` so the extension also runs `mi_collect(true)` there. - Link the router benches against mimalloc so they measure the shipped allocator, and collect mimalloc's heap in their pre-run quiescence. Signed-off-by: PeaBrane <yanrpei@gmail.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
⛔ Files ignored due to path filters (3)
📒 Files selected for processing (30)
💤 Files with no reviewable changes (2)
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review. WalkthroughThe pull request adds mimalloc configuration and trim hooks for Python bindings and router benchmarks. It also changes the compressed radix tree to track worker coverage by reusable slots, protect child access with epochs, and update store, lookup, removal, repair, and dump operations to use those mechanisms. ChangesMimalloc setup and benchmark alignment
Compressed radix-tree coverage and reclamation
Priority: ➖ Normal Estimated code review effort: 5 (Critical) | ~150 minutes Merge Risk: ⚪ Minimal · up to This change makes mimalloc the allocator and reworks CRTC coverage and reclamation for write scaling. The review found no concrete defects. The PR does not show benchmark results for the preferred 8-event-worker setup or a keep-up read-path replay; maintainers should confirm those results before relying on the change at default settings. 🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 52.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 305 functions across 22 files. (6 skipped: 6 unsupported.) Full details: Title checkExplanation The title says the extension uses jemalloc, but the changes and objectives describe mimalloc integration and major CRTC updates. It therefore misidentifies the allocator and does not summarize the main changes. Full details: Description checkExplanation The description is detailed, but it conflicts with the changes: it says the PR uses jemalloc and changes no CRTC code, while the summary shows mimalloc integration and extensive CRTC changes. It also omits the required Related Issues reference and the template headings. Resolution Revise the description to match this pull request’s actual mimalloc and CRTC changes, update any benchmarks and validation claims that do not apply, use the required template sections, and add a Related Issues reference such as Closes
Comment |
❌ Dynamo PR CI failed — run 37599990823 (attempt 1) on
|
| Framework | Build |
|---|---|
| vLLM | ⏹️ 1 |
| SGLang | ⏹️ 1 |
| TRT-LLM | ⏹️ 1 |
| Other | Jobs |
|---|---|
| dynamo-runtime | ⏹️ 1 |
| dynamo-sidecar | ⏹️ 1 |
| frontend | ❌ 1 |
| planner | ⏹️ 1 |
| triton-runtime | ⏹️ 1 |
Failure details
1 job failed: the frontend EPP image step hit its 60-minute timeout with no compile or build error. The 7 cancelled build jobs are not fail-fast cancels; each hit its own job time limit (1h0m or 1h30m, per its annotation).
❌ frontend / Build multi-arch cpu: `Build EPP Image (For Frontend Only)` timed out after 60 minutes
Job: frontend / Build multi-arch cpu · Failed step: Build EPP Image (For Frontend Only) · Logs: gh run view --job 112721894591 -R ai-dynamo/dynamo --log-failed
09:22:01 + make -C deploy/inference-gateway/ext-proc image-multiarch-push IMAGE_REPO=***/dynamo-epp ...
...
#58 [linux/arm64 builder 11/13] COPY --from=dynamo deploy/inference-gateway/sidecar/ ...
#58 DONE 0.1s <- ~23 min with no output before the next step
...
#60 [linux/arm64 builder 13/13] RUN ... cargo build ...
#60 DONE 352.1s
#67 exporting to image
#67 exporting manifest list sha256:7be33e00a5ae... done
10:21:46 #67 DONE 1.6s
10:22:13 ##[error]The action 'Build EPP Image (For Frontend Only)' has timed out after 60 minutes.
The EPP image built for both amd64 and arm64 (Rust compile done, sccache hit rate 100%, image exported) and was killed 27 s after the export, right at the 60-minute step limit. There is no error in the build itself. Most of the time went to long silent gaps in the BuildKit builder (about 23 min after #58 on arm64, plus two gaps of about 6 min on trivial COPY steps). This looks like builder or infrastructure slowness, and since the other build jobs hit their own time limits too, a rerun of failed jobs is likely enough.
For agents
{"pr": 15606, "run_id": 37599990823, "run_attempt": 1, "head_sha": "ca51e42427a88bc9a6ca1d61bed491a3a9ba836e", "failures": [{"job": "frontend / Build multi-arch cpu", "job_id": 112721894591, "failed_step": "Build EPP Image (For Frontend Only)", "signature": "step timed out after 60 minutes (EPP multi-arch buildx build/push)", "tests": [], "log_cmd": "gh run view --job 112721894591 -R ai-dynamo/dynamo --log-failed"}], "cancelled_by_timeout": ["vllm-runtime / Build multi-arch cuda13.0", "dynamo-sidecar / Build multi-arch cpu", "triton-runtime / Build multi-arch cpu", "trtllm-runtime / Build multi-arch cuda13.1", "sglang-runtime / Build multi-arch cuda13.0", "planner / Build multi-arch cpu", "dynamo-runtime / image / Build multi-arch cuda13.0"]}Posted automatically by Devin for run 37599990823. Updated on every full-CI run of this PR.
ae71f77 to
ee55ac5
Compare
|
@coderabbitai review |
|
…uge pages mimalloc advises every arena it reserves for transparent huge pages, while glibc, which held the extension's Rust allocations before, never asked for them. In a process with over a hundred threads, each thread's sparse pages fault in whole 2 MiB pages and khugepaged refills partly freed ones while idle. In the frontend benchmark topology at concurrency 128, the mock-worker process (134 threads) held 486 MiB after load and five idle minutes, against 179 MiB on glibc, and started at 169 MiB against 98 MiB. - Default mimalloc's allow_thp option off at module init, before the first Rust allocation reserves an arena. MIMALLOC_ALLOW_THP still overrides it. Because the option changes after mimalloc's own startup, mimalloc skips its process-wide PR_SET_THP_DISABLE, so Python and C/C++ memory keep the host's THP policy. - Opt dynamo.frontend back in. Its dense heap serves 3-4% more requests at saturation on huge pages. - Test that importing the extension advises no arena for huge pages and leaves process-wide THP alone, and that MIMALLOC_ALLOW_THP=1 restores the advice. - Note in INDEXER_BENCH.md that the benches keep the huge-page advice, as the frontend does. Measured on a 96-core node (frontend pinned to 4 cores, KV router mode, 4 mock workers, 60k-token multi-turn workload), against mimalloc's defaults: - Huge pages off in the mock worker: its RSS after five idle minutes drops from 486 to 226 MiB and at startup from 169 to 103 MiB (5 trials), and stays flat while idle (measured with mimalloc's no_thp build, which leaves mimalloc in the same state as this change). - Frontend on huge pages, worker off: throughput 1.006x, latency within about 2% (1 trial; the frontend runs the default configuration). - Huge pages off in both: throughput 0.96x at concurrency 128 (5 trials), unchanged at concurrency 16 (2 trials). - dynamo.router and other processes that load the extension now run without huge pages. In the KV indexer benchmark, write throughput is unchanged without them and query p50 latency rises 3-6%; MIMALLOC_ALLOW_THP=1 opts a process back in. Signed-off-by: PeaBrane <yanrpei@gmail.com>
dmitry-tokarev-nv
left a comment
There was a problem hiding this comment.
Approve at 4c3e7e280f6a8ccc334ccd930bc50cc753961ade. The bar holds with one P2 (inline) and one P3, and the open items below do not block.
- [P2] Inline at
lib/bindings/python/rust/lib.rs:4-6: with jemalloc preloaded, the frontend's Rust heap goes to mimalloc, so the documented jemalloc fix for frontend memory no longer works. - [P3]
lib/bindings/python/rust/lib.rs:1621:to_capsulegiveskvbm._corea RustWeak<DistributedRuntime>from this extension. The two extensions now use different Rust allocators. Ifupgrade()ever succeeds and KVBM drops the last reference, glibc frees memory that mimalloc allocated. This cannot happen today only becauseupgrade()always fails, onmaintoo. Please note the constraint atto_capsule. - Open, not graded: the new
test_allocator_thp.pyruns only indynamo-runtime / test / sequential(amd64 and arm64). That job did not run on this head, because thedynamo-runtimeimage build stopped at the 1-hour job limit. The test passed in the head'svllm-runtime-testimage. - Open, not graded:
xpu-ci-vllmwas skipped, so this PR's CI built no XPU wheel with the defaultmimallocfeature.
What I read, and what I measured on the CI-built images of the head and the merge base.
I read all lines of both commits: the 340 diff lines of ee55ac52 and the 181 of 4c3e7e28. I also read all of allocator.rs, the libmimalloc-sys 0.1.49 build script, and the TLS, THP and mi_collect code of the bundled mimalloc. Of the bench sources I read only the changed lines. I did not judge the bench tables. I leave them to the performance team.
New crates
All three checksums match a download from crates.io.
| crate | version | license | checksum |
|---|---|---|---|
cty |
0.2.2 | MIT OR Apache-2.0 | b365fabc… |
libmimalloc-sys |
0.1.49 | MIT | 6a45a52f… |
mimalloc |
0.1.52 | MIT | 2d4139bb… |
libmimalloc-sys bundles mimalloc C 3.3.2 (MI_MALLOC_VERSION 30302) and builds v3 unless the v2 feature is set. Its build.rs passes -ftls-model=local-dynamic with local_dynamic_tls, and -ftls-model=initial-exec without it. cargo-deny printed bans ok, licenses ok in rust-tests for . and lib/bindings/python. The CI-built image of ee55ac52 lists all three crates in /legal/NOTICES-Rust.txt and osrb-deps.csv, so no checked-in list needs a change. The second commit changes no Cargo file.
Built _core
From the CI images. Both head commits give the same numbers:
| build | STATIC_TLS |
TLS relocations | allocator exports (nm -D) |
|---|---|---|---|
| head x86_64 | no | 38 R_X86_64_DTPMOD64 |
none |
| merge base x86_64 | no | 38 R_X86_64_DTPMOD64 |
none |
| head aarch64 | no | 68 R_AARCH64_TLSDESC |
none |
| merge base aarch64 | no | 63 R_AARCH64_TLSDESC |
none |
The head TLS segment grows by 0x28 bytes, the size of mimalloc's block, and mi_collect calls __tls_get_addr. Control: the same mimalloc C built with -ftls-model=initial-exec has STATIC_TLS and 5 R_X86_64_TPOFF64 on x86_64. On aarch64 it has 3 R_AARCH64_TLS_TPREL64 and no STATIC_TLS flag. On aarch64, use the relocation type, not the flag.
Static TLS in a real process
I loaded torch, CUDA and vllm first. Then the head _core imports on x86_64, in the head vllm-runtime image. It also imports on aarch64, in an arm64 vllm-runtime-test image on CPU. On x86_64 this holds also with GLIBC_TUNABLES=glibc.rtld.nns=1:glibc.rtld.optional_static_tls=0. On aarch64 that setting stops torch itself (libc10.so: cannot allocate memory in static TLS block). Failing control: an initial-exec library of 2048 bytes (x86_64) or 1024 bytes (aarch64) fails with cannot allocate memory in static TLS block.
Transparent huge pages
In the runs below, mimalloc advises 1024 MiB for huge pages in the frontend and none in the dynamo.vllm worker. The new test passes with the head _core. It fails with the _core of ee55ac52 (advised_mib: 1024) and with the merge-base _core (advised_mib: 0), so both of its asserts can fail.
Memory
These numbers are for 4c3e7e28, in its CI-built vllm-runtime image, with the same setup as the inline comment. RSS in MiB, glibc, one value per run:
| process | _core |
start | after load | after 60 s idle | after worker teardown |
|---|---|---|---|---|---|
| frontend | merge base | 302 | 469 | 458 | 244 |
| frontend | head | 195, 189 | 703, 723 | 682, 414 | 370, 388 |
dynamo.vllm worker |
merge base | 1500 | 1517 | 1517 | - |
dynamo.vllm worker |
head | 1497, 1484 | 1525, 1511 | 1525, 1511 | - |
The vLLM engine-core process does not load _core. It is the control, at 2262 to 2276 MiB after load in all 8 runs. At ee55ac52, before the huge-page commit, the worker held 1617 to 1628 MiB after load. The merge-base glibc frontend varies between images: 469 MiB after load here, 545 and 552 MiB in the ee55ac52 image. Without huge pages (MIMALLOC_ALLOW_THP=0), the head frontend holds 395 MiB after load. So huge pages cost it about 310 to 330 MiB here. The PR body estimates about 120 MiB for its own workload.
No limit in this repository is near these numbers. The operator sets no default memory for frontend or worker pods. The Helm charts run no process that loads _core. Of the 78 frontend services in the DGD manifests of this repository, 70 set no memory and 8 set limits of 32Gi to 100Gi. Not measured: SGLang and TensorRT-LLM workers, and kv_state_agent in examples/backends/vllm/deploy/kvcr/agg-memory-service.yaml (4Gi limit with a 2 GB shared-memory pool).
Allocator boundary
I searched the first-party crates and the 734 third-party crates of the production feature set. C buffers are copied and then freed with C free (nixl-sys, video-rs, ffmpeg-next). Rust boxes that C holds are freed by Rust callbacks (zmq, ffmpeg-next). _core has no DLPack or numpy hand-off. The only Rust object that crosses into another extension is the capsule in the P3.
DYN_FRONTEND_JEMALLOC=1
The head frontend starts with jemalloc mapped and serves 1200 of 1200 requests. The trim runs on a dedicated thread in allocator.rs. It starts 100 ms after the last model teardown guard drops. It runs malloc_trim(0) and then the new hook, mi_collect(true). Each run logged one trim after the worker exited. jemalloc defines no malloc_trim, so the call trims glibc's arenas. A Rust probe with the same trim wiring and 8 idle worker threads: the hook releases 366 MiB of freed mimalloc memory down to 31 to 35 MiB (glibc with malloc_trim: 320 to 0.7 MiB).
CI
On 4c3e7e28, Pre Merge is green (run 37392668489). In run 37392671301, the vLLM, SGLang and TensorRT-LLM image builds, compliance audits and runtime tests are green on amd64 and arm64. The frontend, planner and dynamo-runtime image builds stopped at the 1-hour job limit with no build error, so dynamo-status-check and backend-status-check are red. The same timeouts stopped attempts 1 to 3 of run 37237854280 on ee55ac52, and its attempt 4 was green. The deploy tests were skipped on both heads.
Merge with main
main at afd07588a4 changed Cargo.lock and lib/bench/Cargo.toml since the merge base. A local merge with the head is clean, and both lock files pass cargo tree --locked. Control: the same command fails after I delete one line from a merged lock.
The frontend opted dynamo._core's mimalloc heap back into transparent huge pages. In a frontend deployment that serves real traffic, the opt-in held about 300 MiB more resident memory, after load and while idle, than the same frontend run with huge pages disabled (MIMALLOC_ALLOW_THP=0), which outweighs its throughput gain at saturation. - Stop setting MIMALLOC_ALLOW_THP in dynamo.frontend, so the frontend keeps the extension's default: mimalloc does not advise its arenas for huge pages. MIMALLOC_ALLOW_THP=1 still opts any process in. - Drop the comments and the INDEXER_BENCH.md note that described the frontend opting in. Signed-off-by: PeaBrane <yanrpei@gmail.com>
…alloc DYN_FRONTEND_JEMALLOC=1 makes dynamo.frontend re-exec itself with jemalloc in LD_PRELOAD, and the docs also describe setting LD_PRELOAD directly. Since dynamo._core made mimalloc its global allocator, either preload reaches only Python and C/C++ allocations, so it stopped bounding most of the frontend's memory. - Replace the plain mimalloc global allocator with one that picks mimalloc or std's System allocator at its first call and keeps that choice. It picks System when an LD_PRELOAD entry's file name starts with libjemalloc, the test the frontend applies before re-executing, read through getenv because std::env allocates. The preload is the only signal: a process that inherits DYN_FRONTEND_JEMALLOC without the preload, or whose frontend could not find libjemalloc, keeps mimalloc. Every call, including dealloc and realloc, dispatches on the stored choice, so no block reaches an allocator that did not make it. The hot path adds one relaxed atomic load and a predictable branch. - Under System, skip mimalloc's THP configuration at module init and do not register mi_collect as a teardown trim hook. mimalloc then reserves no arena, though its load-time constructor still reads MIMALLOC_*. - Document DYN_FRONTEND_JEMALLOC and MIMALLOC_ALLOW_THP in the frontend configuration reference, including that MIMALLOC_ALLOW_THP=0 disables huge pages for the whole process, and say in both allocator docs that a jemalloc preload now serves the Rust heap as well. - Test in fresh interpreters that a RadixTree's Rust heap stays out of glibc by default, under a non-jemalloc preload, and under DYN_FRONTEND_JEMALLOC alone; moves to the system allocator and leaves no mimalloc arena when LD_PRELOAD names libjemalloc; and, where libjemalloc is installed, grows in the preloaded jemalloc. Keep LD_PRELOAD out of the THP probes. Signed-off-by: PeaBrane <yanrpei@gmail.com>
…alloc Replace mimalloc with jemalloc (tikv-jemallocator) as the Python extension's global allocator, behind the default `jemalloc` feature. - Prefixed symbols that stay local to the extension: Python and C/C++ libraries keep their allocator, glibc or a preloaded jemalloc, which then runs as a second, independent instance. This drops the LD_PRELOAD dispatch. - On Linux with glibc, `background_threads` builds in `background_thread:true`; `_RJEM_MALLOC_CONF` overrides it. Gated because jemalloc fails to initialize with that option where it has no background threads (macOS), and tikv-jemalloc-sys does not support them on musl. - `disable_initial_exec_tls` keeps the dlopen'ed extension off the static TLS reserve; the built library carries no STATIC_TLS flag. - Pin a 64 KiB jemalloc page size for aarch64 Linux in `.cargo/config.toml`. jemalloc 5.3.1's configure already defaults to it there; the pin keeps arm64 wheels running on both 4 KiB and 64 KiB-page kernels if a later jemalloc drops that default. - The model-teardown trim hook purges all jemalloc arenas instead of mi_collect. - Note at DistributedRuntime.to_capsule that the runtime lives on this extension's allocator, which another extension must not free into. - Drop the mimalloc huge-page configuration and its tests. New tests check, in fresh interpreters, that Rust allocations bypass glibc and a preloaded jemalloc, that background threads start and `_RJEM_MALLOC_CONF` turns them off, and that the extension has no STATIC_TLS flag and exports no allocator symbols. Signed-off-by: PeaBrane <yanrpei@gmail.com>
Switch the router benches' global allocator from mimalloc to jemalloc, built like the Python extension's (prefixed, `background_thread:true` on Linux with glibc, `disable_initial_exec_tls`), so the benches measure the shipped allocator. Pre-run quiescence purges all jemalloc arenas instead of calling mi_collect. The linker still relaxes jemalloc's TLS accesses in the bench executables to local-exec, which the extension gives up to stay dlopen-safe; INDEXER_BENCH.md notes the difference. Signed-off-by: PeaBrane <yanrpei@gmail.com>
The Dynamo Python extension now serves its Rust allocations from a linked-in jemalloc. Update the host memory allocator reference and the frontend memory tuning guide: - DYN_FRONTEND_JEMALLOC, LD_PRELOAD, and MALLOC_CONF affect only Python and C/C++ allocations; a preload runs alongside the extension's jemalloc. - Document _RJEM_MALLOC_CONF for the extension's jemalloc: its background_thread:true default on Linux with glibc, that jemalloc reads it when the extension loads, the background thread that makes an importing process multi-threaded, the missing profiling and statistics support, and the higher memory that can linger after a burst. Drop MIMALLOC_ALLOW_THP. - Suggest MALLOC_CONF=background_thread:true as an unmeasured starting point for Python and C/C++ allocations, and explain why the previous narenas/tcache/decay string is no longer recommended, scoping that evidence to the KV router indexer. - Replace the decay-only advice in the tuning guide with background threads, and ask for the peak as well as the idle footprint. Signed-off-by: PeaBrane <yanrpei@gmail.com>
The Python extension now compiles the jemalloc that tikv-jemalloc-sys vendors. Its BSD-2-Clause license lives in jemalloc/COPYING, below the crate root, so the registry harvest missed it and the wheel's Rust NOTICES carried only the crate's MIT and Apache-2.0 texts. Harvest that file for tikv-jemalloc-sys as well. Signed-off-by: PeaBrane <yanrpei@gmail.com>
Without background threads, freed memory is purged only when a thread allocates again, so an idle process keeps more of it. Describe the burst case relative to the idle level instead of claiming that turning the threads off lowers memory after a burst. Signed-off-by: PeaBrane <yanrpei@gmail.com>
dmitry-tokarev-nv
left a comment
There was a problem hiding this comment.
Approve at ca51e42427a88bc9a6ca1d61bed491a3a9ba836e. Our P2 and P3 from the last round are fixed, and the bar holds with one new P3.
- [P2, fixed] r1001 thread at
lib/bindings/python/rust/lib.rs:4-6: the frontend's Rust heap now returns memory with or withoutDYN_FRONTEND_JEMALLOC=1. The numbers are in that thread. - [P3, fixed]
lib/bindings/python/rust/lib.rs:1589-1592: the new comment atto_capsulenames the allocator boundary. kvbm'supgrade()still fails on this head, so no memory crosses allocators. - [P3]
lib/bindings/python/tests/test_allocator.py:220: the arm64 linker sets noSTATIC_TLSflag, so this test passes for an initial-exec build that fails to load. Please also fail onR_X86_64_TPOFF64andR_AARCH64_TLS_TPREL64relocations fromreadelf -r. - Not run on this head: CI built the wheels for both architectures, but every image job stopped at its time limit. So
dynamo-runtime / test / sequential, which collectstest_allocator.py, and the engine runtime tests did not run.
What I read, built and ran.
- I read all 846 lines of the three-dot diff: 18 files, +513/-25, as the files API reports. I also read the
tikv-jemalloc-sysbuild script and the NOTICES code incontainer/compliance. - The 7 new commits sit on
4c3e7e28without a force push, and the merge base is stille61319d8. - No CI image of this head exists. So I built
_corefor this head, the merge base and the r1001 head on the GPU box. I used maturin 1.15.0 and CI's feature list withoutmedia-ffmpeg. I ran them in the CI-builtvllm-runtimeimage of3cff862bc8, whose_coresources and lock file equal the merge base. "CI-built merge-base_core" below means that image's own_core. - I did not judge the bench and frontend performance tables in the description.
New crates, build flags and the wheel NOTICES.
| crate | version | checksum | declared license |
|---|---|---|---|
tikv-jemalloc-sys |
0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8 |
1a2825c7… |
MIT/Apache-2.0 |
tikv-jemallocator |
0.7.0 |
249f09e4… |
MIT/Apache-2.0 |
- Both lock files add these two packages and remove none. Against the r1001 head, they also drop
cty0.2.2,libmimalloc-sys0.1.49 andmimalloc0.1.52. - Both checksums match the crate files from crates.io.
tikv-jemalloc-sysvendors jemalloc 5.3.1 (ChangeLog entry of Apr 13, 2026), with its BSD-2-Clause license injemalloc/COPYING. - The build script ran configure with
--with-malloc-conf=background_thread:true(featurebackground_threads),--disable-initial-exec-tls(featuredisable_initial_exec_tls),--with-jemalloc-prefix=_rjem_,--with-private-namespace=_rjem_and--disable-stats. - On aarch64, the
.cargo/config.tomlentry adds--with-lg-page=16, and the library reports a 65536-byte page. With the value changed to 14, it reports 16384. Without the entry, configure's own aarch64 default still gives 65536. On x86_64, the entry has no effect, and the probe library is byte-identical with the mutant. - The
wheel_builder.Dockerfilechange copiesjemalloc/COPYINGinto the license folder oftikv-jemalloc-sysasjemalloc-COPYING. I ran the harvest andbundle_wheel_noticesas the Dockerfile does, on a wheel of this head that maturin 1.15.0 built. - The wheel's SBOM names the crate
0.7.1+5.3.1-0-g81034ce…, which matches the folder name.THIRD-PARTY-RUST-LICENSES.txtthen carries jemalloc's BSD-2-Clause text once. With the harvest of the base Dockerfile, it does not. - In CI, the image jobs of this head logged the harvest and
wheel NOTICES bundled into 2 wheel(s)for both architectures before they stopped. - In Pre Merge,
cargo-deny0.19.0 printedbans ok, licenses okfor.andlib/bindings/python.
Built artifact: ELF checks, and loading after torch and vLLM.
x86_64, this head's _core:
readelf -dshows noSTATIC_TLSflag. The dynamic TLS relocations are 39R_X86_64_DTPMOD64, against 38 in the merge base.- The jemalloc objects use only
R_X86_64_TLSGD,R_X86_64_TLSLDandR_X86_64_DTPOFF32, which are the global-dynamic and local-dynamic models. nm -Dlists 29 defined symbols: threePyInit_*and 26crc_fast_*. It lists nomalloc,free,mallctl,je_*or_rjem_*. All 584_rjem_symbols are local.- Positive control: the same search on the image's
libjemalloc.so.2listsmalloc,free,mallctland more.
aarch64: no CI artifact of this head exists. So I built a probe library with the same two crates, features and .cargo/config.toml (Debian 12, GNU ld 2.40). It has no STATIC_TLS flag, 6 R_AARCH64_TLSDESC, only TLSDESC relocations in the jemalloc objects, and no exports.
Loading after torch, CUDA and vllm:
- In the x86_64
vllm-runtimeimage with a GPU, this head's_coreloads and starts onejemalloc_bg_thdthread. The probe loads too. Both also load withGLIBC_TUNABLES=glibc.rtld.nns=1:glibc.rtld.optional_static_tls=0. - In an arm64
vllm-runtime-testimage on CPU, the probe loads. It reports a 65536-byte page andopt.background_threadtrue. - Failing control on both architectures: the probe built without
disable_initial_exec_tlsfails withcannot allocate memory in static TLS block.
The new P3: the static TLS test cannot fail on arm64.
The initial-exec probe on aarch64 has 1 R_AARCH64_TLS_TPREL64 and no STATIC_TLS flag, but it fails to load after torch and vLLM. None of the 307 libraries that load in the arm64 engine image carries the flag. On x86_64, the same probe carries the flag and 1 R_X86_64_TPOFF64. Three x86_64 _core builds have neither relocation: this head, the merge base, and the CI-built merge-base _core. So the extra assert passes on them.
Tests, fork and the kvbm capsule.
test_allocator.pyran under the repository's pytest settings in thevllm-runtimeimage. With this head's_core, 6 of 6 pass. With the CI-built merge-base_core, 3 fail: glibc growth 23 MiB, 0 background threads, and preloaded-jemalloc growth 27 MiB. So the tests catch a lost allocator.approximate_lru_bench, which Pre Merge does not build, passescargo check --lockedwith theapproximate-lrufeature.- After
import dynamo._core, the process has one more thread,jemalloc_bg_thd.os.fork()then warnsThis process (pid=7) is multi-threaded, use of fork() may lead to deadlocks in the child., as the docs say. A test that forks after the import still passes under the repository'sfilterwarnings = error. - For the CI-built merge-base
_coreand this head, kvbm'sextract_distributed_runtime_from_objfails withruntime is no longer alive (weak ref upgrade failed).
Merge with main, and CI on this head.
git merge-tree --write-treewithmainatf787ba7568is clean. Since the merge base,mainchanged both lock files and three other files of this PR.- On that merge,
cargo tree --lockedpasses for the root and the bindings workspaces. Control: it fails after I remove one dependency line from the merged bindings lock. - Pre Merge passed on the merge with
548a010548, withcargo test --lockedand clippy with--locked. - PR run 37599990823 stopped. The frontend job failed in
Build EPP Image (For Frontend Only)at its 60-minute step limit, 27 s after the image export. The other image jobs were cancelled at their time limits after their wheel stages passed. No test job ran, sobackend-status-checkanddynamo-status-checkare red. - The PR runs of the stacked PRs #15609, #15610 and #15611 started in the same minute and were cancelled too. Nothing in the logs points at this diff.
- Passed on this head: Pre Merge, DCO, copyright, codeowners, Lint PR, Docs link check, Fern Docs and CodeQL. PR-XPU ran only its guard job.
|
I'm closing this PR to separate the allocator from the CRTC stack.
The measurements in this description stay here for reference: jemalloc against glibc on |
Summary
Part 1 of a 4-part stack that scales the CRTC KV indexer's write capacity to 64 event workers. This part changes no CRTC code.
glibc malloc capped CRTC writes. Event workers serialized on arena locks (
__lll_lock_wait_private), caused by cross-thread frees of producer-allocated events and byreallocgrowth while extending leaves. On a 96-core host, write capacity stayed flat at 58–67M block ops/s from 8 to 80 event workers. On the host used below,mainreaches 45.7M block ops/s at 8 event workers and 50.6M at 64.This PR serves the Python extension's Rust allocations from a jemalloc linked into the extension:
_corecdylib's#[global_allocator]istikv-jemallocator(jemalloc 5.3.1), behind a defaultjemallocfeature. Its symbols carry the_rjem_prefix and stay local to the extension, so Python and C/C++ libraries keep their own allocator.DYN_FRONTEND_JEMALLOC=1, or a jemallocLD_PRELOAD, still moves Python and C/C++ onto a preloaded jemalloc. The Rust heap stays on the extension's jemalloc, so the process then runs two independent instances, each with its own arenas and options.background_thread:trueis built in. Up to fourjemalloc_bg_thdthreads return unused pages to the OS, so the allocating threads don't have to._RJEM_MALLOC_CONFoverrides the built-in options when the extension loads, and_RJEM_MALLOC_CONF=background_thread:falseturns the threads off.disable_initial_exec_tls(global-dynamic TLS), so the extension, which is loaded withdlopen, does not use up the static TLS reserve..cargo/config.tomlpins jemalloc's page size to 64 KiB on aarch64 Linux, so arm64 wheels start on both 4 KiB and 64 KiB kernels. jemalloc 5.3.1 already defaults to that page size there; the pin protects against the default changing.malloc_trim, which does not reach the extension's heap. A newregister_allocator_trim_hooklets the extension also purge every jemalloc arena.mooncake_bench,active_sequences_bench,approximate_lru_bench) link the same jemalloc build with the same options, so they measure the shipped allocator.mooncake_benchandapproximate_lru_benchalso purge jemalloc's arenas when they quiesce the heap.INDEXER_BENCH.mddocuments the allocator and the purge.DYN_FRONTEND_JEMALLOCand add_RJEM_MALLOC_CONF.DYN_FRONTEND_JEMALLOC,LD_PRELOAD, andMALLOC_CONFto Python and C/C++.MALLOC_CONFtobackground_thread:true, and advise against thenarenas:32,...string.Trade-off this part leaves for parts 2–3. At 64 event workers writes are 7.8× faster, so under overload, lookups queue behind coverage writers that take each node's state write lock. Overload lookup p99 rises from 7.6 µs to 636 µs (table below). Part 2 stops lookups from freeing detached subtrees, and part 3 takes coverage writes off the write lock.
At equal load, compared with
main:main.Measured costs. Against mimalloc, the earlier revision's allocator, in the indexer bench (comparator and details under Allocator choice):
Against glibc on
main, in the frontend benchmark at saturated concurrency 128, TTFT p50 rises 39% while ITL p50 falls 54% (TTFT p99 is unchanged; at concurrency 16, TTFT p50 falls 11%, indicative).Memory trade-off. Every process that imports the extension carries its jemalloc. In the frontend benchmark below, against glibc on
main:mainonly after the background threads purge (0.91× 15 s after load).Background threads return freed memory on their own schedule (jemalloc's decay time is 10 s by default), so after a burst RSS can stay elevated for several seconds: at c128 the mock worker holds 195 MiB 1 s after the last request and 170 MiB 15 s after it. With
_RJEM_MALLOC_CONF=background_thread:false, freed memory is not returned while the process is idle (frontend section below).Validation
Method for the indexer A/Bs:
lib/bench/kv_router/INDEXER_BENCH.md(128 inference workers, duplication factor 20, length factor 4, 128 lookup lanes).INDEXER_BENCH.md, so compare ratios, not absolute rates.This PR vs
main, ata9ba39f538, 10 runs per arm under overload and 6 at keep-up:mainlags offered by 0.8%)Indexer CPU at 26.7M block ops/s offered (4 event workers, both arms keeping up, 5 runs per arm) drops from 89.0 to 79.2 µs per 1k block ops, 0.89× (CI 0.88–0.90).
¹ This PR's load generator overran the 750 ms window in all 10 trials (784–832 ms), so the cell fails the bench's acceptance rule. The indexer stayed far behind (update p99 about 1.4 s), so the rate is most likely still set by drain, but treat it as indicative. At 64 event workers 1 trial overran; dropping it leaves 7.83×.
Allocator choice. Earlier revisions of this PR used mimalloc. The switch to jemalloc follows review:
DYN_FRONTEND_JEMALLOC) no longer reached the Rust heap. With this PR the Rust heap runs jemalloc by default, and with the preload on, the Python and C/C++ allocations do too, configured through_RJEM_MALLOC_CONFandMALLOC_CONFrespectively.main, the frontend's retained memory falls (frontend section below). The frontend A/B has no mimalloc arm, so this PR makes no memory claim against mimalloc.The mimalloc figures below come from mimalloc arms in the same indexer runs. Those arms keep mimalloc's default huge-page advice, which the earlier revision turned off in the extension.
What the switch keeps:
main. At 8 event workers it is 0.98, in a harness-limited cell (footnote ³).In the earlier run, the full stack at 26.7M block ops/s with 4 event workers had these update p99 values:
In this run, on the full stack at the same load, jemalloc's update p99 is 0.94× mimalloc-with-huge-pages (0.528 vs 0.563 ms, 5/5).
The advantage does not hold near capacity (table below).
What it costs, against mimalloc:
Indexer CPU:
Lookups at keep-up: p50 is 3–9% higher and p99 is 7–12% higher, in every keep-up cell (0/6 or 0/5 rounds better). mimalloc stayed within about 4% of
main.Update p99 near capacity: 1.53× mimalloc on the full stack at 64 event workers (375 ms, both arms keeping up).
Event producers. Under overload, the bench's event-issuing threads fall behind with jemalloc:
This was not profiled; extra allocator work and lock blocking in the issue path are both possible. The slower generator is what fails the 8-event-worker cell's generator check above.
² Both stack arms kept up at 375 ms, so this ratio compares drain time. At 187 ms the ratio is 0.93, but the jemalloc stack's load generator overran its window in every trial (and the mimalloc stack's in 5 of 10), so that cell is harness-limited. The 300 ms window was chosen after that run so that the jemalloc generator stays on schedule. It sits below saturation: the mimalloc stack achieved 896M block ops/s at 187 ms and 830M at 300 ms.
³ Harness-limited; see footnote ¹. Two smaller replicates, in which this PR's generator also overran, gave 0.99 (n.s.) and 0.96, 3 runs each.
Frontend end to end. Setup:
dynamo-frontend-benchmarktopology, with the frontend pinned to 4 cores in KV-router mode and one mocker with 4 workers.malloc_trim. Absolute frontend RSS depends on the tokenizer cache size (1 GiB here).main, c128main, c16 (indicative)DYN_FRONTEND_JEMALLOC=1vs this PR, c128main, c128Correctness: 0 request errors, cancellations, crashes, panics, or OOM kills across 33 runs. Every frontend and mocker exited cleanly on SIGTERM.
Frontend CPU per request: at c128 the frontend is saturated (3.93–3.96 of 4 cores), so CPU per request tracks throughput: 0.99× (n.s.). Differences under about 3% are not detectable. At c16, 0.94× (indicative).
Mock worker, which also loads the extension, at c128:
mainAt c16 (indicative) its peak is 1.14× and its RSS 1 s after load is 1.11×. Mock worker CPU per request at c128 is 0.98× (n.s.).
Background threads.
_RJEM_MALLOC_CONF=background_thread:falsein the frontend and mocker, against the default, at c128 with 2 runs each (indicative):Two jemalloc instances (
DYN_FRONTEND_JEMALLOC=1, third row). Ubuntu's jemalloc 5.3.0 serves Python and C/C++, and the extension's jemalloc keeps the Rust heap.MALLOC_CONF=background_thread:true, it adds up to four more.Inference engines: vLLM, SGLang, and TensorRT-LLM processes that load the extension are not covered here; they are left to the container e2e CI.
⁴ At c128 the jemalloc arms have higher TTFT and lower ITL in every round: TTFT p50 rises 39% (mean 23%) while ITL p50 falls 54%. TTFT p99 is unchanged, end-to-end latency p50 and mean are n.s., and latency p99 is 7% lower. Why time shifts toward the first token at saturation is not established. At c16, TTFT p50 falls 11% (indicative). Concurrencies between 16 and 128 were not measured.
Extension:
Binary checks:
readelf -dshows noSTATIC_TLSflag. The dynamic symbol table exports no allocator entry points and no_rjem_symbols; every jemalloc (_rjem_) symbol is local.test_allocator.py. In fresh interpreters it checks that:_RJEM_MALLOC_CONFoverrides the built-in options;6 of 6 pass with Ubuntu's libjemalloc 5.3.0 available; without it the preload test skips (5 passed, 1 skipped).
KV binding tests (
test_kv_bindings,test_kv_event_publisher,test_kv_router_config,test_kv_router_stream_errors,test_bindings_install): 41 of 41 pass in a release build with theselect-servicefeature, which the SelectionService tests need. A build without that feature skips those 16.Rust tests:
cargo test --features ais-forward-passinlib/bindings/python: 61 passed.manylinux_2_28: a minimal cdylib with the same jemalloc features, built in the
manylinux_2_28image, needs no glibc symbol newer than 2.28, has noSTATIC_TLS, and starts its background thread.arm64: the arm64 build was not exercised. The page-size pin was checked to reach jemalloc's configure for
aarch64-unknown-linux-gnu.Re-check against
main's tests. Part 4 (#15611,41803f6f2d, which contains this PR; release build) andmain(e61319d830), built with the same features:mainin this CPU-only environment fail the same way on the stack. Two need torch and pass in 3 of 3 runs once it is installed; five disaggregated setups time out, and one metadata check returns 404.Notes
dynamostarts a jemalloc background thread when the extension loads, so the process is multi-threaded from the start. Python 3.12 and later warn onos.fork(), and children forked withoutexecrun without background threads. 200 forks while another thread kept a RadixTree busy completed with no hang or failure.__tls_get_addrcalls), so allocator costs in the extension can be somewhat higher than the benches show.DistributedRuntime.to_capsulehands kvbm, which keeps glibc, aWeakpointing at memory from_core's allocator. Today the upgrade never succeeds (a separate, older bug), so no memory crosses allocators. A comment now marks the boundary.tikv-jemalloc-sysas MIT OR Apache-2.0, from its Cargo metadata, while the NOTICES carry jemalloc's BSD-2-Clause text.