[TE] perf(efa): allow bounding the batch MR registration fan-out - #3210
Open
whn09 wants to merge 4 commits into
Open
[TE] perf(efa): allow bounding the batch MR registration fan-out#3210whn09 wants to merge 4 commits into
whn09 wants to merge 4 commits into
Conversation
registerLocalMemoryBatch() and unregisterLocalMemoryBatch() spawn one std::async(std::launch::async) per buffer with no cap, which libstdc++ takes literally -- one fresh thread each. Kimi-K3 registers ~180 KV buffers per TP rank in a single batch, and with one TransferEngine per rank an 8-rank node peaks at ~1100 threads all inside fi_mr_reg / fi_mr_regattr. Replace both fan-outs with a fixed pool pulling from a shared index, sized by MC_MAX_CONCURRENT_REG_MR. The default of 0 means unbounded, so behavior is unchanged unless an operator sets the knob: with no cap the pool spawns count-1 threads and runs the caller as a worker, matching what std::async did. Error semantics are also unchanged -- every item is still attempted and the first non-zero return is propagated. The knob is opt-in with no built-in default because its effect depends on the order the caller passes buffers in, which this layer cannot see. Measured on p5.48xlarge (32 NICs) replaying K3's registration the way SGLang issues it -- 8 processes, one engine and one GPU each, 182 buffers of 2.5 KB to 391 MB per process, barrier-synchronized. Slowest rank, since nothing serves until all 8 finish: per-proc cap descending pool order (SGLang's) ascending unset 108/99/128 s 112 s 120 s 64 184/208 s 101 s 60 s 16 95/98/97 s 43 s 36/37 s 8 107 s -- 44 s 4 157 s -- -- A cap of 16 is worth 2.6x on the order SGLang actually uses, but only 1.1x on descending order, and the order alone swings the capped result by 2.7x. Unbounded is order-insensitive (99-128 s) because nothing queues. Largest-first being the worst order contradicts longest-processing-time scheduling and is still unexplained, so no sort is applied here yet. Two other platforms put the optimum elsewhere, which is the other reason not to compile in a default: p5 host memory (fi_mr_reg), 128 x 2 GiB of 4 KB pages, prefers cap 16 (59.1 s vs 274 s unbounded); a 2x p6-b300 K3 server run at 16 NICs/rank prefers cap 8 (20.7 s vs 138.7 s unbounded). A core-scaled default would be worse still on a large node -- where capping helps, the bottleneck is the provider lock, not CPU. So the code keeps its historical behavior and only exposes the pool size.
The bullet had grown into a multi-paragraph section with measurement tables, out of proportion to every other entry in this list. Keep the operationally relevant facts -- the cap is per process, the default is unbounded, a good value is platform- and order-dependent, do not core-scale it -- and leave the supporting measurements to the PR.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The comment and docs claimed the cap should not be core-scaled, on the grounds that the bottleneck was a shared provider lock rather than CPU. Both halves were wrong, and both came from a single-process benchmark where 48 registration threads on a 192-core node never contended. Measuring at the real topology instead -- one TransferEngine per TP rank, and varying both the rank count and the core budget -- shows the optimum is set by the CPU budget: 8 ranks/192 cores prefers cap 16, 4 ranks/192 cores prefers 32, and 8 ranks/64 cores prefers 8. All three are the same ~cores global thread count, so a good value is roughly cores/processes-per-node. The lock hypothesis is refuted separately by a 23x speedup from capping a serial baseline, which a size-proportional global lock cannot produce. Also drops the retracted single-process figures and shortens the comment. No functional change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
whn09
marked this pull request as ready for review
July 31, 2026 06:26
whn09
requested review from
ShangmingCai,
UNIDY2002,
alogfans,
chestnut-Q,
doujiang24,
staryxchen,
stmatengss and
ykwd
as code owners
July 31, 2026 06:26
30 tasks
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.
Description
registerLocalMemoryBatch()andunregisterLocalMemoryBatch()in the EFA transport spawn onestd::async(std::launch::async)per buffer with no cap, which libstdc++ takes literally — one fresh thread each. Kimi-K3 registers ~180 KV buffers per TP rank in a single batch, and since SGLang creates oneTransferEngineper TP rank, an 8-rank node peaks at ~1100 threads all sitting insidefi_mr_reg/fi_mr_regattr.This replaces both fan-outs with a fixed pool pulling from a shared atomic index, sized by a new
MC_MAX_CONCURRENT_REG_MRoption.Default behavior is unchanged. The default of 0 means unbounded: the pool spawns
count-1threads and runs the caller as a worker, matching whatstd::asyncdid. Error semantics are also unchanged — every item is still attempted and the first non-zero return is propagated.What the knob does, measured
Measured on p5.48xlarge (32 EFA NICs), replaying K3's KV registration the way SGLang actually issues it: 8 processes, one engine and one GPU each, 182 GPU buffers of 2.5 KB–391 MB per process, barrier-synchronized so all 8 libfabric domains hit the NICs at once. Times are the slowest rank, since nothing serves until every rank has registered.
Repeated entries are separate sweeps (~3% run-to-run; unbounded's own spread is ~30%).
The optimum also tracks the platform:
fi_mr_reg, 128 × 2 GiB of 4 KB pages): 274 s unbounded, 120 s @64, 89.6 s @32, 59.1 s @16, 70.9 s @8, 257 s @4. (Caps were swept out of order — 8, 128, 32, 4, 64, 16 — so the curve is not a warming/fragmentation artifact.)The optimum is
cap x nranks ~= coresThe value is not a platform mystery to be swept for: registration threads do CPU-bound page-pinning, so what matters is the global thread count against the core count. Since the cap is per process, that is
cap x nranks.Tested by moving both sides of that relation on the same 192-core p5 node (
tasksetputs all ranks in one shared cpuset, so they still contend with each other, just with a smaller budget). Optimum in bold:The optimal cap differs in all three rows — 16, 32, 8 — so it is not a per-process constant. Converted to global threads the three optima collapse:
Oversubscription is punished superlinearly: 2.7× oversubscribed (row 1, cap=64) is 1.8× slower than the optimum, and 4× oversubscribed (row 3, cap=32) is 2.2× slower. So a practical rule is
cap ~= cores / nranks, giving roughly 0.7–1.0 × cores in flight globally. On a tp8 192-core node that is 16–24, which is what the sweep above found.This is consistent with the p5 host-memory optimum (cap=16 → also 128 threads). The B300 optimum is lower in global terms (cap=8 → 64 threads); that run was a full K3 server, which leaves far less idle CPU than a standalone replay, but this has not been verified and is only a plausible reading.
Why the knob is still opt-in rather than a compiled-in default
cores / nranksis only computable by something that knows how many peer processes share the node, and a singleTransferEnginedoes not — SGLang creates one per TP rank with no cross-rank coordination. A constant would be wrong by the rank count, and acores-derived value would oversubscribe by exactly that factor (picking 192 on a tp8 node yields 1536 threads, i.e. the 8× oversubscription this data shows is the worst case). Combined with the ordering penalty below — a cap makes a largest-first batch only 1.1× faster, so an operator whose caller passes buffers in a bad order gets little for it until the companion sort (#3216) lands — the historical unbounded behavior stays the default, and an operator who knows their rank count opts in. What has changed is that they no longer need to sweep: the value is derivable.The ordering effect: mechanism found
The earlier revision of this PR listed "largest-first is the worst order" as an open mystery. It is now explained, and it is not a scheduling effect at all — which is why longest-processing-time intuition mispredicted it.
Registering device memory on EFA costs roughly k x (bytes of device memory already registered on that domain), k ~= 260 ms/GiB on p5.48xlarge, flat across 0–19 GiB, and nearly independent of the buffer's own size. Measured single-threaded and strictly serial (one buffer per
batch_register_memory()call), so no lock and no queue is involved:Control: identical 48 buffers, identical 9.23 GiB total, only the order differs — ascending 35.5 s vs descending 93.3 s, both ending with the same 2421/2422 ms final registration. So cost is a function of what is already in, not of the buffer's own size, and ascending order is a well-defined optimum rather than a heuristic.
This also explains the cap=24 (23.0 s) vs cap=47 (5.5 s) gap noted in the earlier revision, which queue depth alone could not (4.2x where only 2x was available): a worker's second registration happens when the domain is much fuller.
Host memory does not accumulate. The same sweep on
mmap'd host buffers is flat (a 391 MB buffer costs ~600 ms whether it is 1st or 48th) and order makes no difference (a control varying only registration order gives 15.3/14.2 s ascending vs 13.2/15.7 s descending — the direction flips between reps). So this is a device-memory property, most likely the CUDA/HMEM path rebuilding per-domain state, and worth a libfabricefaissue separately.The follow-up this enables — sorting ascending inside
registerLocalMemoryBatch()— is a separate PR, #3216, because it changes behavior for every caller rather than only for those who opt in, and because whether the same sort should apply to the other 15 transports is worth a maintainer discussion. Note the two changes are coupled in one direction: the sort only has an effect when a cap is set. Unbounded, every buffer gets its own thread, nothing queues, and the dispatch order never reaches the provider (112 s vs 120 s, i.e. noise).Known gaps
nic_assignments[0]gets every context), while NIXL's libfabric plugin registers aVRAM_SEGbuffer only on that GPU's topology-local rails (selectRailsForMemory()→getEfaDevicesForPci()). On p5.48xlarge that is 32 NICs vs 4. Restricting Mooncake's device list to emulate it, on 48 x 391 MB GPU buffers serially: 123.7 s at 32 NICs vs 17.6 s at 4 vs 4.4 s at 1 — 7.0x, larger than either the cap or the sort, and it multiplies the accumulation above (the per-domain cost is paid once per domain). That is a throughput trade-off rather than a free win, since it changes which NICs can serve a transfer, so it needs its own PR with bandwidth numbers and not just registration times. Filed as [TE][EFA] GPU buffers are registered on all NICs, costing 7x vs per-GPU rail selection #3217; flagging it here so reviewers know this PR is not claiming to close the gap to NIXL.Module
mooncake-transfer-engine)mooncake-store)mooncake-ep)mooncake-pg)mooncake-integration)mooncake-p2p-store)mooncake-wheel)mooncake-common)mooncake-rl)Type of Change
How Has This Been Tested?
Test commands:
Test results:
config_testcovers unset → unbounded, explicit0→ unbounded, valid values, and rejection of malformed input (non-numeric, negative, trailing garbage, empty) with the default retained.Manual testing is the measurement matrix above: 8-process GPU registration on p5.48xlarge (32 NICs) across 5 cap values × 3 input orders, plus host-memory and B300 sweeps, plus the 12-cell core-budget experiment (3 rank/core configurations × 4 caps), plus the serial single-buffer sweeps that establish the accumulation mechanism (4 size sequences × GPU and host, 2 order-control reps, and a 32/4/1-NIC fan-out comparison). Unbounded runs reproduce the ~127 s registration wall time seen in a real K3 prefill log, confirming the harness matches production. Verified the default path (
MC_MAX_CONCURRENT_REG_MRunset) is unchanged versus the previousstd::asyncimplementation.One caveat on methodology, since it produced a wrong claim in an earlier revision of this PR: this knob cannot be measured single-process. 48 registration threads on a 192-core node never contend, so more threads is unconditionally faster there and the optimum vanishes. An earlier single-process sweep on that basis concluded the bottleneck was a provider lock and that the value should not be core-scaled; both were wrong, and both have been corrected above. Single-process runs are used here only for the per-registration cost curve, which is what they are valid for.
Checklist
./scripts/code_format.shpre-commit run --all-filesand all hooks passpre-commitis not installed in my environment; I ranclang-format(via the repo's 80-column config) on every touched C++ file instead. The change is ~190 LOC, so no RFC.Checked for overlap: #3174 (idempotent duplicate registration) and #3207 (EFA auto-discovery) touch neither
efa_transport.cppnor the config plumbing, so there is no conflict.AI Assistance Disclosure
Claude Code was used to write the benchmark harness, run the measurement sweeps, and draft the code and documentation. Notably, it also caught that an earlier iteration of this work had the wrong process topology — a single-process replay that produced the opposite conclusion (239 s unbounded vs 1051 s capped) and would have justified the wrong default. Every line has been reviewed by me and I can defend the change end-to-end.