Skip to content

[Bug]: MultiConnector: finished_recving lacks per-connector dedup — late/stale load reports crash scheduler assert in P/D + dual-connector setups #53049

Description

@me040526

Your current environment

The output of python collect_env.py
Collecting environment information...
==============================
        System Info
==============================
OS                           : Ubuntu 24.04.4 LTS (x86_64)
GCC version                  : (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
CMake version                : version 3.31.6
Libc version                 : glibc-2.39

==============================
       PyTorch Info
==============================
PyTorch version              : 2.13.0+cu130
Is debug build               : False
CUDA used to build PyTorch   : 13.0

==============================
      Python Environment
==============================
Python version               : 3.12.3 (main, Mar 23 2026, 19:04:32) [GCC 13.3.0] (64-bit runtime)
Python platform              : Linux-6.17.0-29-generic-x86_64-with-glibc2.39

==============================
       CUDA / GPU Info
==============================
Is CUDA available            : True
GPU 0-7                      : NVIDIA GeForce RTX 4090 (x8)
Nvidia driver version        : 595.71.05
cuDNN version                : Could not collect

==============================
       CPU / NUMA
==============================
CPU                          : Intel(R) Xeon(R) Platinum 8368Q CPU @ 2.60GHz x2 (152 threads)
NUMA 节点                    : 2 (node0: 0-37,76-113 / node1: 38-75,114-151)

==============================
Versions of relevant libraries
==============================
[pip3] flashinfer-python==0.6.15.post1
[pip3] nccl4py==0.4.1
[pip3] numpy==2.2.6
[pip3] nvidia-cudnn-cu13==9.20.0.48
[pip3] nvidia-nccl-cu13==2.29.7
[pip3] tokenspeed-triton==3.8.10.post20260721
[pip3] torch==2.13.0+cu130
[pip3] torchaudio==2.11.0+cu130 / torchvision==0.28.0+cu130 / torchcodec==0.15.0
[pip3] transformers==5.15.0
[pip3] triton==3.7.1

==============================
         vLLM Info
==============================
vLLM Version                 : 0.27.1+backport052.sm89.cu130
vLLM Build Flags:
  CUDA Archs: Not Set; ROCm: Disabled; XPU: Disabled
GPU Topology: GPU0-3 ↔ NUMA0 (NODE), GPU4-7 ↔ NUMA1 (NODE), cross-socket SYS
  (P/D split aligned to sockets: prefill on node0 GPUs, decode on node1 GPUs)

==============================
      Environment Variables
==============================
PYTORCH_NVML_BASED_CUDA_CHECK=1
TORCHINDUCTOR_COMPILE_THREADS=1
TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_root

Note: the vLLM build is a custom sm89 backport of v0.27.1 (wtdcode/vllm-backport v0.5.2); the three files central to this report — scheduler.py, multi_connector.py, nixl/pull_scheduler.py — were byte-compared against current main (d626108b18) and are identical in the relevant sections.

🐛 Describe the bug

MultiConnector deduplicates async saves across sub-connectors (_extra_async_saves) but performs a bare union for async loads (finished_recving). In a P/D disaggregation setup where the decode engine also runs a second connector doing async loads (LMCache MP), a request can complete via the faster load path while the slower NIXL pull is still in flight; the late NIXL completion is then reported for a request the scheduler already removed, tripping:

File ".../vllm/v1/core/sched/scheduler.py", line 2741, in _update_from_kv_xfer_finished
    assert req_id in self.requests
AssertionError

EngineDeadError, decode engine dies, service outage until manual restart.

Minimal reproduction

Exact decode-engine connector config (relevant flag only; rest of vllm serve is stock — TP4, fp8_ds_mla, 262K context):

vllm serve <model-path> \
  --tensor-parallel-size 4 \
  --kv-cache-dtype fp8_ds_mla \
  --max-model-len 262144 \
  --port 8200 \
  --kv-transfer-config '{"kv_connector":"MultiConnector","kv_role":"kv_consumer","kv_connector_extra_config":{"connectors":[{"kv_connector":"NixlConnector","kv_role":"kv_consumer","kv_load_failure_policy":"fail"},{"kv_connector":"LMCacheMPConnector","kv_role":"kv_both","kv_connector_extra_config":{"lmcache.mp.host":"tcp://localhost","lmcache.mp.port":6556}}]}}'

Prefill engine is symmetric (kv_producer roles). LMCache server is stock upstream: lmcache server --port 6556 --l1-size-gb 48 --eviction-policy LRU --chunk-size 256.

Traffic needed to trigger: multi-turn agent-style requests with long contexts (~100K tokens) where the decode-side LMCache has a prefix hit from an earlier turn while the router-initiated NIXL push of the same prompt KV is still in flight. With 262K contexts over TCP (~400 MB/s), GB-scale transfers take seconds — the race window is wide. Crashed ~20 minutes after deployment under real traffic.

An end-to-end runnable snippet is not practical here (needs the P/D router, two engines, and the LMCache server process); the config above plus the traffic pattern is the minimal reproduction.

The asymmetry in current main (d626108)

vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py, get_finished():

# Aggregate finished recving request ids.
finished_recving.update(recving or ())          # <-- bare union, no dedup

# Aggregate finished sending request ids - only include
# once we've drained the "extra" count (for cases where
# more than one connector is async-saving the same request).
for req_id in sending or ():
    extra_pending = self._extra_async_saves.get(req_id)   # <-- dedup for saves

The sending-path comment shows the multi-connector-same-request case was considered for saves; the symmetric load case was not.

Failure chain:

  1. Request R arrives at decode. The router two-phase flow means prefill unconditionally pushes R's prompt KV via NIXL (producer-initiated). If R's prefix also hits in decode-side LMCache (earlier turn), start_load_kv — broadcast by MultiConnector to all sub-connectors — starts a second parallel async load.
  2. LMCache (CPU memory) load completes first → R leaves WAITING_FOR_REMOTE_KVS → generates → finishes → removed from self.requests.
  3. NIXL pull of a 100K+ token context completes seconds later → NixlConnector reports R in finished_recving.
  4. assert req_id in self.requests → engine dies.

Aggravating factor: NixlConnector d-node returns False from request_finished() for in-flight pulls (pull_scheduler.py ~L223, if is_d_node and not self.is_bidirectional_kv_xfer_enabled: return False, None), so R's blocks are already freed when the orphaned pull completes — the UCX write can land in freed/reallocated memory. The assert crash is arguably the lucky outcome; silent KV corruption is the unlucky one.

Observed result (full traceback)

Expected behavior: a late load-completion report for an already-finished request is ignored (or the two loads are coordinated so completion is reported exactly once).

Observed (decode engine log):

(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351] EngineCore encountered a fatal error.
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351] Traceback (most recent call last):
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]   File ".../vllm/v1/engine/core.py", line 1342, in run_engine_core
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]     engine_core.run_busy_loop()
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]   File ".../vllm/v1/engine/core.py", line 1386, in run_busy_loop
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]     self._process_engine_step()
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]   File ".../vllm/v1/engine/core.py", line 1439, in _process_engine_step
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]     outputs, model_executed = self.step_fn()
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]   File ".../vllm/v1/core/sched/scheduler.py", line 1986, in update_from_output
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]     self._update_from_kv_xfer_finished(kv_connector_output)
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]   File ".../vllm/v1/core/sched/scheduler.py", line 2741, in _update_from_kv_xfer_finished
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351]     assert req_id in self.requests
(EngineCore pid=2047449) ERROR 08-19 16:30:41 [core.py:1351] AssertionError

Engine-death log tail additionally shows in-flight UCX get(multi) transfers not flushed at shutdown, consistent with the late-pull explanation.

Why existing tests likely don't catch it

The documented LMCache P/D example topology (docs.lmcache.ai, "disaggregated prefill") is TP=1 / 16K context on one host: NIXL transfers complete in milliseconds, so the window between "LMCache load done" and "NIXL pull done" is effectively zero. At 100K-262K contexts with GB-scale KV over TCP, the window is seconds wide and triggers within ~20 minutes of real agent traffic.

Suggested fix directions

  1. MultiConnector: mirror _extra_async_saves for loads — track which sub-connectors have an in-flight load per request and emit finished_recving only when the last one completes (scheduler-side _requests_to_connector already holds the assignment; worker side needs it, e.g. via metadata as done for saves).
  2. NixlConnector: when a d-node request with an in-flight pull finishes, pin blocks (return True) and report the late completion as finished_sending — the _reqs_need_send TTL machinery already exists for the bidirectional path.
  3. Scheduler (defense in depth): warn-and-skip instead of assert for req_ids no longer in self.requests (state-wise a late report is harmless; memory safety must be fixed at the connector level per 1/2).

Current production workaround: LMCache on the prefill side only (the double-path there is async-save, already deduped) — stable 18+ hours under real traffic with the same model and load.

Happy to turn 1/3 into a PR if the direction sounds right.

Before submitting a new issue...

  • Make sure you already searched for relevant issues, and asked the chatbot living at the bottom right corner of the documentation page, which can answer lots of frequently asked questions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions