mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #4209] [Bug][TE] TCP transport: first transfer after an idle gap fails with queue-full rejections; PD decode hangs until timeout #1057
Labels
No labels
ascend
auto-closed
bug
bug
CI/Build
Common
documentation
enhancement
feature request
good first issue
help wanted
high priority
MetaX
nightly-failure
pull-request
PyTorch Backend
question
RFC
Roadmap
stale
Store
Tests
Transfer Engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/Mooncake#1057
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @CAICAIIs on GitHub (Sep 18, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4209
environment
MooncakeConnector), 1 node, disaggregated prefill/decode (1P1D) on one hostmooncake_protocol: "tcp",MC_FORCE_TCP=1; RDMA path not usable on this host)🐛 Describe the bug
With the TCP transport, the first KV transfer between two engine instances succeeds, but a transfer submitted after a short idle gap (~5 s) fails: on the producer, every slice of the batch is rejected by the TCP lane queue (
TCP lane queue-full rejection count: 1 → 2 → 4 → ... → 512+within milliseconds),batch_transfer_sync_writereturns -1, and the error is relayed to the consumer (pulling kv_caches for [...] failed: Mooncake transfer engine returned -1).The consumer request then stays in
WAITING_FOR_REMOTE_KVS(Deferred: 1) and the client hangs until its own timeout. A request sent ~90 s later succeeds, so deployments observe either 60-90 s TTFT spikes after every idle period or outright request failures, depending on client timeout.How to reproduce
MooncakeConnectorover TCP (single host; any router that fires the prefill and decode requests concurrently works, e.g.examples/disaggregated/mooncake_connector/mooncake_connector_proxy.py):Sending to ... done, took 0.18 s).Consistently reproduced on two machines (H20 and A100), multiple runs. Also reproduced with
MC_TCP_ADMISSION_TIMEOUT_MS=60000(failure shape changes: the batch is not hard-rejected but still never transfers).Analysis
After a transfer completes and the peer connection group goes idle, the group is retired — all lanes are closed and
group->stateis set toCLOSED(tcp_transport_lane_impl.h, retirement block). A submission that arrives shortly after finds the group viastate->groups.find(key), seesgroup->state != GroupState::OPEN, and is hard-rejected withSHUTDOWN(enqueuePooledTransfer) instead of re-creating the group and its connections. The retired group is not removed fromstate->groupsin the retirement path, so submissions keep failing until something re-creates the group — which matches the observed ~90 s self-heal.Evidence summary:
ESTABLISHEDwith empty kernel queues → the stall is in TE user space (lane queue / admission), not the kernelCLOSEDgroup rather than a transient queue backlogSuggested fix: in
enqueuePooledTransfer, drop a non-OPENgroup and re-create it (or remove retired groups fromstate->groupsatomically when retiring) instead of rejecting the work.Workaround
Retry the request after ~90 s (self-healed), or keep the peer continuously busy (impractical). No configuration knob we found avoids it.
@github-actions[bot] commented on GitHub (Sep 18, 2026):
Thanks for opening this issue, @CAICAIIs!
39020005A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@KaroUniform commented on GitHub (Oct 7, 2026):
We hit what looks like the same failure in production (SGLang PD disaggregation, DeepSeek-V4.1-Flash,
mooncake-transfer-engine-cuda130.3.13,MC_FORCE_TCP=1). After 1–2.5 h under load every KV transfer to the decode instance failed withTCP lane queue-full rejection count: …and never recovered until the pods were restarted. Below is the root cause we found. It is already fixed onmain, but not in any release.Root cause (v0.3.13, v0.3.13.post1, v0.3.14rc1). In
TcpTransport::enqueuePooledTransfer(tcp_transport_lane_impl.h),requestGroupPumpLocked()runs for every submission, including one that is hard-rejected. It setspump_scheduled = truewhenever the queue is non-empty and no pump is scheduled. But the pump is only posted on the non-rejected path:When a rejection happens right after a pump run has cleared
pump_scheduled(queue still non-empty), the flag staystruewith no pump posted. Every laterrequestGroupPumpLocked()returns 0, so the lanes go idle and the queue (1024 entries) never drains. From then on, every submission to that peer is either hard-rejected (QUEUE_FULL) or times out in pending admission afterMC_TCP_ADMISSION_TIMEOUT_MS(QUEUE_TIMEOUT). A larger admission timeout only changes the failure shape: the work waits for queue room that never frees, which fits your 60 s observation. The group staysOPENthe whole time, so re-creating non-OPENgroups would not help this path. A singlebatch_transfer_sync_writethat exceeds the 1024 + 1024 caps by a few hundred entries triggers it reliably. In our tests one thread is enough, and a batch of 2300 or more entries wedged the peer every time.Evidence. On a wedged sender, gdb shows
requestGroupPumpLockedwithstate=OPEN,pump_scheduled=1, apump_epochthat stays frozen across probes, exactly 1024 queued items and all four lanesIDLE, and nopostGroupPump/runGroupPump. A fresh engine in another process transfers to the same receiver fine.Minimal repro (two processes, host memory, no GPU, default caps):
0.3.13 (3/3 runs):
0.3.13 with the one-line fix below (3/3 runs):
Fix. Post the pump regardless of the rejection (
if (pump_epoch != 0) postGroupPump(...)). This is exactly commit74d26b56"[TE] Preserve queued TCP pump after rejection", which landed onmainas part of #4063 together with a regression test. We run 0.3.13 rebuilt with that single line in production, plusMC_TCP_MAX_QUEUED_TRANSFERS_PER_PEER/MC_TCP_MAX_PENDING_ADMISSIONS_PER_PEER= 65535 (the defaults since #3710) to avoid the rejections themselves.Request. Could this be shipped in a patch release (0.3.13.post2 or 0.3.14)? Every released wheel can permanently wedge a PD pair on TCP after a single oversized batch. cc @adenzhou1350 (author of
74d26b56) @alogfansCaveat: this explains the
queue-fullstorm and the "never transfers" part of the report. We did not reproduce your ~90 s self-recovery. In our case the wedge lasted until restart, and a peer-key change does not retire the stuck group. So something on the vLLM side, for example re-opening the segment, may be involved there.@soovv commented on GitHub (Oct 28, 2026):
Independent SGLang PD reproduction: released wheels are affected by the dropped-pump bug; the stale-CLOSED-group path does not apply
We hit this in a single-node prefill/decode deployment and independently reproduced the released-wheel failure. Posting the data because it changes the conclusion in the original report: the root cause in
0.3.13.post1is the dropped pump epoch on rejection, not a retired group left instate->groups.Environment
lmsysorg/sglang:latest), Qwen3.8-27B-NVFP4 + DFlash draft modelmooncake_tcp,MC_TCP_ENABLE_CONNECTION_POOL=true,MC_TCP_MAX_QUEUED_TRANSFERS_PER_PEER=262144,MC_TCP_MAX_PENDING_ADMISSIONS_PER_PEER=262144custom_multi_turn: 4 turns, 2,048 output tokens per turn;32K / 64K / 128K rendered context; 2 repetitions; no PD restart between points
Results
Success rate (
mooncake-transfer-engine-cuda13 0.3.13.post1, two reps):Failures show the same shape as the report:
The prefill session is blacklisted; the SGLang failed-session probe can clear the
blacklist (
recovered via probe; un-blacklisted) but the next transfer fails again.A build of
main179ffb2(CUDA 13 wheel, same SGLang setup, default connectionpool and queue 262144, no restarts) completed all four points with zero failures:
128/128, 64/64, 32/32, 64/64.
Root cause: pump dropped on the rejection path
In
0.3.13.post1,enqueuePooledTransfer()requests a pump for every submission,including one that is hard-rejected, but posts it only on the non-rejected path:
requestGroupPumpLocked()has already setpump_scheduled = truewhen the queue isnon-empty. If the pump is not posted, the flag stays
true, every laterrequestGroupPumpLocked()returns 0, and the queue never drains. This matches thequeue-full storm and the permanent blacklist. The fix is
74d26b56("[TE] Preserve queued TCP pump after rejection"), merged in #4063, and it is
present in
v0.3.14-pre:The stale-CLOSED-group explanation does not apply
The retired group is already removed from
state->groupsbefore it is markedretiring, in both versions:
v0.3.13.post1:state->groups.erase(old_group_it);is immediately followed byretiring_group->retiring = true;in the key-change block ofenqueuePooledTransfer().main179ffb2: same block,state->groups.erase(old_group_it);attcp_transport_lane_impl.h:600,retiring_group->retiring = true;at line 603.retiring = truehas no other assignment, andGroupState::CLOSEDis only set inrunGroupRetirement()(and shutdown, which clears the maps). ThereforerunGroupRetirement()cannot observe a group that is still instate->groups; anextra erase there is unreachable. We initially carried a 6-line patch that did this;
after the source audit we dropped it. It cannot be the reason the
mainbuildpasses —
mainitself contains74d26b56.Request: cut a patch release
PyPI still serves
0.3.13.post1formooncake-transfer-engine-cuda13(checked2026-10-28);
v0.3.14-preis GitHub-only. Every released TCP wheel can wedge aPD pair after a single oversized batch. Could you cut a patch release
(
0.3.13.post2or0.3.14) containing74d26b56and #4234?The
MC_TCP_ENABLE_CONNECTION_POOL=false+ larger per-peer queues + periodicrestart combination is only a workaround; the pump fix removes the wedge.
We can provide the EvalScope JSONL/repro scripts and the full prefill/decode logs.