A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 4, 13:45 UTC
[Bugfix] release KV blocks for skipped P-ranks to prevent invalid KV errors and timeouts when P_tp > D_tp and MLA (#40449)
Signed-off-by: yangruize <yangruize7@163.com> Co-authored-by: Roger Wang <hey@rogerw.io>
yangrz committed
Apr 29, 2026 at 02:38 UTC
de3fe8dc62f3d77eb8dab8125ca90436f606bccb
2 files changed
+120
-1
tests/v1/kv_connector/unit/test_nixl_connector.py
+119
@@ -2479,3 +2479,122 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
2479
remote_tp_size=1,
2480
expected_engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
2481
)
2482
+
2483
+ @patch(
2484
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker.NixlWrapper",
2485
+ FakeNixlWrapper,
2486
+ )
2487
+ def test_mla_broadcast_notif_uses_remote_request_id(
2488
+ self, default_vllm_config, dist_init
2489
+ ):
2490
+ """MLA + remote TP > local TP: the broadcast notification sent to
2491
+ non-read prefill ranks must be keyed by the prefill-side request
2492
+ id (``meta.remote.request_id``), not the local decode request id.
2493
+
2494
+ Prefill ranks key ``_reqs_to_send`` by their own request id, so a
2495
+ broadcast keyed by the decode id is rejected in
2496
+ ``_get_new_notifs`` with "Potentially invalid KV blocks for
2497
+ unrecognized request" and the blocks only release via the abort
2498
+ timeout. See ``_read_blocks_for_req`` in
2499
+ ``vllm/distributed/kv_transfer/kv_connector/v1/nixl/worker.py``.
2500
+ """
2501
+ decode_tp_size = 1
2502
+ prefill_tp_size = 4
2503
+
2504
+ vllm_config = create_vllm_config()
2505
+ vllm_config.parallel_config.tensor_parallel_size = decode_tp_size
2506
+
2507
+ connector = NixlConnector(
2508
+ vllm_config, KVConnectorRole.WORKER, make_kv_cache_config(block_size=16)
2509
+ )
2510
+ connector.connector_worker = FakeNixlConnectorWorker(
2511
+ vllm_config, connector.engine_id, hand_shake_latency=0
2512
+ )
2513
+ worker = connector.connector_worker
2514
+
2515
+ # Force the MLA path; only `self.use_mla` gates the branches we
2516
+ # exercise inside `_read_blocks_for_req`.
2517
+ worker.use_mla = True
2518
+
2519
+ # Manually register the remote (P) engine and pre-populate the
2520
+ # per-rank state the handshake would normally fill in. The real
2521
+ # `_nixl_handshake` is unnecessary here — we only need
2522
+ # `transfer_topo` to know `remote_tp_size`, and `_remote_agents`
2523
+ # / `dst_xfer_side_handles` to be keyed by remote rank.
2524
+ remote_engine_id = "remote_engine"
2525
+ worker.transfer_topo.register_remote_engine(
2526
+ remote_engine_id=remote_engine_id,
2527
+ remote_tp_size=prefill_tp_size,
2528
+ remote_block_size=worker.block_size,
2529
+ remote_block_len=worker.block_size * 4096,
2530
+ remote_physical_blocks_per_logical=1,
2531
+ local_block_len=worker.block_size * 4096,
2532
+ )
2533
+ worker._remote_agents[remote_engine_id] = {
2534
+ rank: f"agent_p{rank}" for rank in range(prefill_tp_size)
2535
+ }
2536
+ worker.dst_xfer_side_handles = {
2537
+ remote_engine_id: {rank: 100 + rank for rank in range(prefill_tp_size)}
2538
+ }
2539
+ # Sanity: D TP=1, P TP=4 => tp_ratio = -4 (P > D).
2540
+ assert worker.transfer_topo.tp_ratio(prefill_tp_size) == -prefill_tp_size
2541
+
2542
+ # Distinct ids on each side — that's the whole point of the bug.
2543
+ decode_req_id = "decode-req-AAAA"
2544
+ prefill_req_id = "prefill-req-BBBB"
2545
+ assert decode_req_id != prefill_req_id
2546
+
2547
+ metadata = NixlConnectorMetadata()
2548
+ metadata.add_new_req_to_recv(
2549
+ request_id=decode_req_id,
2550
+ local_block_ids=([0, 1, 2],),
2551
+ kv_transfer_params={
2552
+ "remote_block_ids": ([10, 11, 12],),
2553
+ "remote_engine_id": remote_engine_id,
2554
+ "remote_request_id": prefill_req_id,
2555
+ "remote_host": "localhost",
2556
+ "remote_port": 1234,
2557
+ "remote_tp_size": prefill_tp_size,
2558
+ },
2559
+ )
2560
+ meta = metadata.reqs_to_recv[decode_req_id]
2561
+
2562
+ # Capture broadcast send_notif calls; stub `_read_blocks` so we
2563
+ # don't need a working xfer path. Real `_read_blocks` emits its
2564
+ # auto-notif via `make_prepped_xfer`, not via `send_notif`, so
2565
+ # any captured `send_notif` here is a broadcast.
2566
+ send_notif_calls: list[tuple[str, bytes]] = []
2567
+ worker.nixl_wrapper.send_notif = ( # type: ignore[method-assign]
2568
+ lambda agent_name, notif_msg: send_notif_calls.append(
2569
+ (agent_name, notif_msg)
2570
+ )
2571
+ )
2572
+ worker._read_blocks = MagicMock() # type: ignore[method-assign]
2573
+
2574
+ worker._read_blocks_for_req(decode_req_id, meta)
2575
+
2576
+ # MLA: read once from rank 0 and broadcast to the other ranks.
2577
+ worker._read_blocks.assert_called_once()
2578
+ assert worker._read_blocks.call_args.kwargs["remote_rank"] == 0
2579
+ assert (
2580
+ worker._read_blocks.call_args.kwargs["remote_request_id"] == prefill_req_id
2581
+ )
2582
+
2583
+ # Broadcast goes to ranks {1, 2, 3} only, never to the read target.
2584
+ expected_recipients = {
2585
+ worker._remote_agents[remote_engine_id][r]
2586
+ for r in range(1, prefill_tp_size)
2587
+ }
2588
+ assert {agent for agent, _ in send_notif_calls} == expected_recipients
2589
+
2590
+ # Every broadcast notif must be keyed by the prefill request id.
2591
+ # Pre-fix this used the *decode* request id, which prefill ranks
2592
+ # didn't recognize.
2593
+ expected_notif = f"{prefill_req_id}:{decode_tp_size}".encode()
2594
+ bad_notif = f"{decode_req_id}:{decode_tp_size}".encode()
2595
+ for agent, notif in send_notif_calls:
2596
+ assert notif == expected_notif, (
2597
+ f"Broadcast notif to {agent!r} must use prefill_req_id; "
2598
+ f"got {notif!r} (expected {expected_notif!r}, "
2599
+ f"buggy form would be {bad_notif!r})"
2600
+ )
vllm/distributed/kv_transfer/kv_connector/v1/nixl/worker.py
+1
-1
@@ -1971,7 +1971,7 @@ class NixlConnectorWorker:
1971
if self.use_mla and tp_ratio < 0:
1972
# ..but we still need to notify the other remote ranks that we
1973
# have the blocks we need so they can update the request state.
1974
- notif_id = f"{req_id}:{self.world_size}".encode()
1974
+ notif_id = f"{meta.remote.request_id}:{self.world_size}".encode()
1975
remote_agents = self._remote_agents[meta.remote.engine_id]
1976
for rank_to_notify, agent in remote_agents.items():
1977
if rank_to_notify != remote_rank: