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 11, 06:15 UTC
[NIXL] Support P tensor-parallel-size > D tensor-parallel-size (#27274)
Signed-off-by: NickLucche <nlucches@redhat.com>
Nicolò Lucchesi committed
Dec 18, 2025 at 04:53 UTC
bc3700e0cd8875951b87b17accd7aa4d80ddca50
4 files changed
+556
-212
tests/v1/kv_connector/nixl_integration/tp_config_sweep_accuracy_test.sh
+3
@@ -8,9 +8,12 @@ SCRIPT="v1/kv_connector/nixl_integration/run_accuracy_test.sh"
8
configs=(
9
"GPU_MEMORY_UTILIZATION=0.6 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2"
10
"GPU_MEMORY_UTILIZATION=0.6 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2"
11
+ "GPU_MEMORY_UTILIZATION=0.6 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=1"
12
"GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA case
13
"GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny"
14
+ "GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=1 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny"
15
"DP_EP=1 GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA+P-TP1, D-DPEP=2 (TP=1)
16
+ "DP_EP=1 GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA+P-TP2, D-DPEP=2 (TP=1)
17
)
18
19
run_tests() {
tests/v1/kv_connector/unit/test_nixl_connector.py
+223
-22
@@ -391,6 +391,8 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
391
super().__init__(*args, **kwargs)
392
self._hand_shake_latency = hand_shake_latency
393
self.kv_cache_layout = kv_cache_layout
394
+ # Mock register_kv_caches attribute needed for tests that do not call it.
395
+ self.src_xfer_handles_by_block_size = {self.block_size: 1}
396
397
def _nixl_handshake(
398
self, host: str, port: int, remote_tp_size: int, expected_engine_id: str
@@ -407,22 +409,43 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
409
410
assert expected_engine_id == self.REMOTE_ENGINE_ID
411
410
- remote_agent_name = self.add_remote_agent(
411
- NixlAgentMetadata(
412
- engine_id=self.REMOTE_ENGINE_ID,
413
- agent_metadata=FakeNixlWrapper.AGENT_METADATA,
414
- kv_caches_base_addr=[0],
415
- device_id=0,
416
- num_blocks=1,
417
- block_lens=self.block_len_per_layer,
418
- # `self.kv_cache_layout` is only forced to HND when vllm engine
419
- # is started. We mock HND here.
420
- kv_cache_layout="HND",
421
- block_size=self.block_size,
422
- ),
423
- remote_tp_size=remote_tp_size,
424
- )
425
- return {0: remote_agent_name}
412
+ # Adjust remote block length metadata to satisfy heterogeneous TP
413
+ # invariants enforced during handshake validation.
414
+ remote_block_lens = list(self.block_len_per_layer)
415
+ tp_ratio = self.kv_topo.tp_ratio(remote_tp_size)
416
+ if remote_tp_size > self.world_size:
417
+ # P TP > D TP case, block_len of remote is smaller
418
+ remote_block_lens = [
419
+ block_len // (-tp_ratio) for block_len in remote_block_lens
420
+ ]
421
+ elif remote_tp_size < self.world_size:
422
+ remote_block_lens = [
423
+ block_len * tp_ratio for block_len in remote_block_lens
424
+ ]
425
+
426
+ # When remote tp_size > local tp_size, handshake with multiple
427
+ # remote ranks.
428
+ num_hanshakes = 1 if tp_ratio > 0 else -tp_ratio
429
+ remote_agents: dict[int, str] = {}
430
+ for remote_tp_rank in range(num_hanshakes):
431
+ remote_agent_name = self.add_remote_agent(
432
+ NixlAgentMetadata(
433
+ engine_id=self.REMOTE_ENGINE_ID,
434
+ agent_metadata=FakeNixlWrapper.AGENT_METADATA,
435
+ kv_caches_base_addr=[0],
436
+ device_id=remote_tp_rank,
437
+ num_blocks=1,
438
+ block_lens=remote_block_lens,
439
+ # `self.kv_cache_layout` is only forced to HND when vllm engine
440
+ # is started. We mock HND here.
441
+ kv_cache_layout="HND",
442
+ block_size=self.block_size,
443
+ ),
444
+ remote_tp_rank=remote_tp_rank,
445
+ remote_tp_size=remote_tp_size,
446
+ )
447
+ remote_agents[remote_tp_rank] = remote_agent_name
448
+ return remote_agents
449
450
451
class TestNixlHandshake:
@@ -453,7 +476,13 @@ class TestNixlHandshake:
476
vllm_config, connector.engine_id, hand_shake_latency=0
477
)
478
assert isinstance(connector.connector_worker.nixl_wrapper, FakeNixlWrapper)
456
- connector.connector_worker.nixl_wrapper.set_cycles_before_xfer_done(3)
479
+ worker = connector.connector_worker
480
+ worker.nixl_wrapper.set_cycles_before_xfer_done(3)
481
+ # simulate handshake
482
+ worker.dst_xfer_side_handles = {
483
+ FakeNixlConnectorWorker.REMOTE_ENGINE_ID: {0: 1}
484
+ }
485
+ worker.kv_cache_layout = "HND"
486
num_xfers = 4
487
while True:
488
# For the same request_id, initiate multiple xfers across different
@@ -567,6 +596,171 @@ class TestNixlHandshake:
596
return
597
raise TimeoutError("Took too long to complete async handshake.")
598
599
+ @patch(
600
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.NixlWrapper",
601
+ FakeNixlWrapper,
602
+ )
603
+ @pytest.mark.parametrize("local_tp_size", [1, 2])
604
+ def test_prefill_tp_size_greater_than_decode_tp_size(
605
+ self, local_tp_size: int, dist_init
606
+ ):
607
+ """
608
+ Verify remote TP > local TP handshake succeeds with different
609
+ remote configurations.
610
+ """
611
+
612
+ vllm_config = create_vllm_config()
613
+ local_tp_size = 1
614
+ vllm_config.parallel_config.tensor_parallel_size = local_tp_size
615
+
616
+ connector = NixlConnector(vllm_config, KVConnectorRole.WORKER)
617
+ connector.connector_worker = FakeNixlConnectorWorker(
618
+ vllm_config, connector.engine_id, hand_shake_latency=0
619
+ )
620
+ worker = connector.connector_worker
621
+
622
+ # Minimal local registration params used by add_remote_agent
623
+ worker.slot_size_per_layer = [4096]
624
+ worker.block_len_per_layer = [4096 * worker.block_size]
625
+ worker.num_blocks = 1
626
+ worker.dst_num_blocks[worker.engine_id] = worker.num_blocks
627
+ worker.src_blocks_data = [(0, worker.block_len_per_layer[0], worker.tp_rank)]
628
+
629
+ def check_handshake(remote_tp_size: int):
630
+ tp_ratio = remote_tp_size // local_tp_size
631
+ assert set(remote_agents.keys()) == set(range(tp_ratio))
632
+
633
+ remote_engine_id = worker.REMOTE_ENGINE_ID
634
+ assert worker._tp_size[remote_engine_id] == remote_tp_size
635
+ assert -tp_ratio == worker.kv_topo.tp_ratio_from_engine_id(remote_engine_id)
636
+ # ensure src_xfer_handles_by_tp_ratio is populated with tpratio chunks
637
+ assert -tp_ratio in worker.src_xfer_handles_by_tp_ratio
638
+ assert len(worker.src_xfer_handles_by_tp_ratio[-tp_ratio]) == tp_ratio
639
+ assert remote_engine_id in worker.dst_xfer_side_handles
640
+ assert set(worker.dst_xfer_side_handles[remote_engine_id].keys()) == set(
641
+ range(tp_ratio)
642
+ )
643
+
644
+ remote_agents = worker._nixl_handshake(
645
+ host="localhost",
646
+ port=1234,
647
+ remote_tp_size=2,
648
+ expected_engine_id=worker.REMOTE_ENGINE_ID,
649
+ )
650
+ check_handshake(2)
651
+
652
+ # NOTE flexiblity: a second remote with higher number of ranks is
653
+ # discovered. This is not a scenario we actively support right now, but
654
+ # the connector allows it.
655
+ worker.REMOTE_ENGINE_ID = "remote_engine_2"
656
+ remote_agents = worker._nixl_handshake(
657
+ host="localhost",
658
+ port=1234,
659
+ remote_tp_size=6,
660
+ expected_engine_id=worker.REMOTE_ENGINE_ID,
661
+ )
662
+ check_handshake(6)
663
+
664
+ @patch(
665
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.NixlWrapper",
666
+ FakeNixlWrapper,
667
+ )
668
+ @pytest.mark.parametrize("local_tp_size", [1, 2])
669
+ def test_prefill_tp_size_greater_than_decode_tp_size_mla(
670
+ self, local_tp_size: int, dist_init
671
+ ):
672
+ """
673
+ Verify remote TP > local TP handshake succeeds with different
674
+ remote configurations for an MLA model.
675
+ """
676
+ vllm_config = create_vllm_config()
677
+ d_tp_size = 1
678
+ p_tp_size = 2
679
+
680
+ # Build two separate connectors/workers to emulate P TP=2 ranks.
681
+ conn_p0 = NixlConnector(vllm_config, KVConnectorRole.WORKER)
682
+ conn_p1 = NixlConnector(vllm_config, KVConnectorRole.WORKER)
683
+ conn_p0.connector_worker = FakeNixlConnectorWorker(
684
+ vllm_config, conn_p0.engine_id, hand_shake_latency=0
685
+ )
686
+ conn_p1.connector_worker = FakeNixlConnectorWorker(
687
+ vllm_config, conn_p1.engine_id, hand_shake_latency=0
688
+ )
689
+
690
+ # Force P world size to 2 for both workers and emulate distinct tp_ranks.
691
+ # Also enable MLA path so that expected_finished_count is updated.
692
+ for rank, worker in enumerate(
693
+ (conn_p0.connector_worker, conn_p1.connector_worker)
694
+ ):
695
+ worker.world_size = p_tp_size
696
+ worker.kv_topo.remote_tp_size = {worker.engine_id: p_tp_size}
697
+ worker.tp_rank = rank
698
+ worker.use_mla = True
699
+
700
+ req_id = "req-ep-dp2-p0"
701
+ now = time.perf_counter()
702
+ # Register a request on P that is waiting for consumers to read
703
+ # (both workers track it).
704
+ conn_p0.connector_worker._reqs_to_send[req_id] = now + 10.0
705
+ conn_p0.connector_worker._reqs_to_process.add(req_id)
706
+ conn_p1.connector_worker._reqs_to_send[req_id] = now + 10.0
707
+ conn_p1.connector_worker._reqs_to_process.add(req_id)
708
+
709
+ # Simulate a read notification coming from D with (tp=1, dp=2).
710
+ notif = f"{req_id}:{d_tp_size}".encode()
711
+ # D0-0->P0 notif
712
+ conn_p0.connector_worker.nixl_wrapper.get_new_notifs = lambda: {
713
+ "agent": [notif]
714
+ } # type: ignore[method-assign]
715
+ conn_p1.connector_worker.nixl_wrapper.get_new_notifs = lambda: {
716
+ "agent": [notif]
717
+ } # type: ignore[method-assign]
718
+
719
+ # Trigger notification processing via get_finished().
720
+ done_sending0, _ = conn_p0.get_finished(finished_req_ids=set())
721
+ done_sending1, _ = conn_p1.get_finished(finished_req_ids=set())
722
+ assert req_id in done_sending0 and req_id in done_sending1
723
+
724
+ # E2E aggregation: ensure the aggregated output marks the request
725
+ # as finished using the connector's expected_finished_count.
726
+ from vllm.v1.outputs import KVConnectorOutput, ModelRunnerOutput
727
+
728
+ aggregator = KVOutputAggregator.from_connector(conn_p0, world_size=2)
729
+
730
+ out0 = ModelRunnerOutput(
731
+ req_ids=[req_id],
732
+ req_id_to_index={req_id: 0},
733
+ sampled_token_ids=[[0]],
734
+ logprobs=None,
735
+ prompt_logprobs_dict={},
736
+ pooler_output=[None],
737
+ kv_connector_output=KVConnectorOutput(
738
+ finished_sending=done_sending0,
739
+ finished_recving=None,
740
+ ),
741
+ )
742
+ out1 = ModelRunnerOutput(
743
+ req_ids=[req_id],
744
+ req_id_to_index={req_id: 0},
745
+ sampled_token_ids=[[0]],
746
+ logprobs=None,
747
+ prompt_logprobs_dict={},
748
+ pooler_output=[None],
749
+ kv_connector_output=KVConnectorOutput(
750
+ finished_sending=done_sending1,
751
+ finished_recving=None,
752
+ ),
753
+ )
754
+ aggregated = aggregator.aggregate([out0, out1], output_rank=0)
755
+ assert aggregated.kv_connector_output is not None
756
+ assert aggregated.kv_connector_output.finished_sending == {req_id}
757
+
758
+ # Producers cleaned up state for the finished request.
759
+ assert req_id not in conn_p0.connector_worker._reqs_to_send
760
+ assert req_id not in conn_p0.connector_worker._reqs_to_process
761
+ assert req_id not in conn_p1.connector_worker._reqs_to_send
762
+ assert req_id not in conn_p1.connector_worker._reqs_to_process
763
+
764
@patch(
765
"vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.NixlWrapper",
766
FakeNixlWrapper,
@@ -585,6 +779,9 @@ class TestNixlHandshake:
779
connector.connector_worker = FakeNixlConnectorWorker(
780
vllm_config, connector.engine_id
781
)
782
+ # Register (mocked) local xfer handler
783
+ # worker = connector.connector_worker
784
+ # worker.src_xfer_handles_by_block_size = {worker.block_size: 1}
785
metadata = NixlConnectorMetadata()
786
total_reqs = 5
787
for i in range(total_reqs):
@@ -672,7 +869,6 @@ class TestNixlHandshake:
869
with pytest.raises(RuntimeError):
870
# mismatched layout is expected to fail
871
worker.add_remote_agent(meta, remote_tp_size=2)
675
- with pytest.raises(AssertionError):
872
worker.add_remote_agent(meta, remote_tp_size=1)
873
874
@patch(
@@ -1357,8 +1553,11 @@ def test_shutdown_cleans_up_resources(dist_init):
1553
patch.object(nixl_wrapper, "deregister_memory") as mock_dereg,
1554
):
1555
worker._recving_transfers = {"req1": [123]}
1360
- worker.src_xfer_side_handle = 456
1361
- worker.dst_xfer_side_handles = {"engine1": 789}
1556
+ # Mock register_kv_cache which registers local handle
1557
+ worker.src_xfer_handles_by_block_size = {worker.block_size: 455}
1558
+ # P TP = 2 * D TP case, we should register 2 local handles
1559
+ worker.src_xfer_handles_by_tp_ratio = {-2: [456, 457]}
1560
+ worker.dst_xfer_side_handles = {"engine1": {0: 789}}
1561
worker._remote_agents = {"engine1": {0: "agent1"}}
1562
worker._registered_descs = ["desc1", "desc2"]
1563
@@ -1379,8 +1578,10 @@ def test_shutdown_cleans_up_resources(dist_init):
1578
mock_listener.join.assert_called_once()
1579
1580
mock_rel_xfer.assert_called_once_with(123)
1382
- assert mock_rel_dlist.call_count == 2
1383
- mock_rel_dlist.assert_any_call(456) # src handle
1581
+ assert mock_rel_dlist.call_count == 4
1582
+ mock_rel_dlist.assert_any_call(455) # src handle (whole region)
1583
+ mock_rel_dlist.assert_any_call(456) # src handle (1st chunk)
1584
+ mock_rel_dlist.assert_any_call(457) # src handle (2nd chunk)
1585
mock_rel_dlist.assert_any_call(789) # dst handle
1586
mock_rem_agent.assert_called_once_with("agent1")
1587
assert mock_dereg.call_count == 2
vllm/distributed/kv_transfer/kv_connector/utils.py
+40
-22
@@ -21,6 +21,8 @@ if TYPE_CHECKING:
21
22
logger = init_logger(__name__)
23
24
+EngineId = str
25
+
26
27
def get_kv_connector_cache_layout():
28
# NOTE (NickLucche) When running disaggregated PD with NIXL, HND layout is
@@ -209,12 +211,12 @@ class TpKVTopology:
211
"""
212
213
tp_rank: int
212
- remote_tp_size: dict[str, int]
214
+ remote_tp_size: dict[EngineId, int]
215
is_mla: bool
216
total_num_kv_heads: int
217
attn_backend: type[AttentionBackend]
216
- engine_id: str
217
- remote_block_size: dict[str, int]
218
+ engine_id: EngineId
219
+ remote_block_size: dict[EngineId, int]
220
221
def __post_init__(self):
222
# Figure out whether the first dimension of the cache is K/V
@@ -256,18 +258,28 @@ class TpKVTopology:
258
Calculate the tensor parallel ratio between local and remote TP.
259
We can think of it as the number of local TP workers-per-remote TP
260
workers. Local workers will read from the same remote TP worker in
259
- groups of size `tp_ratio`.
261
+ groups of size `tp_ratio`.If remote tp_size > local tp_size, the
262
+ ratio is flipped (remote_size/local_size) and the returned value is
263
+ negative.
264
"""
261
- assert self.tp_size % remote_tp_size == 0, (
262
- f"Local tensor parallel size {self.tp_size} is not divisible "
263
- f"by remote tensor parallel size {remote_tp_size}."
265
+ if self.tp_size >= remote_tp_size:
266
+ assert self.tp_size % remote_tp_size == 0, (
267
+ f"Local tensor parallel size {self.tp_size} is not divisible "
268
+ f"by remote tensor parallel size {remote_tp_size}."
269
+ )
270
+ return self.tp_size // remote_tp_size
271
+
272
+ assert remote_tp_size % self.tp_size == 0, (
273
+ f"Remote tensor parallel size {remote_tp_size} is not divisible "
274
+ f"by local tensor parallel size {self.tp_size}."
275
)
265
- return self.tp_size // remote_tp_size
276
+ # P TP > D TP case, return the ratio as negative
277
+ return -remote_tp_size // self.tp_size
278
279
def block_size_ratio(
280
self,
281
remote_block_size: int,
270
- ) -> float:
282
+ ) -> int:
283
"""
284
Calculate the block size ratio between local and remote TP.
285
"""
@@ -279,19 +291,19 @@ class TpKVTopology:
291
292
def tp_ratio_from_engine_id(
293
self,
282
- remote_engine_id: str,
294
+ remote_engine_id: EngineId,
295
) -> int:
296
remote_tp_size = self.remote_tp_size[remote_engine_id]
297
return self.tp_ratio(remote_tp_size)
298
299
def block_size_ratio_from_engine_id(
300
self,
289
- remote_engine_id: str,
290
- ) -> float:
301
+ remote_engine_id: EngineId,
302
+ ) -> int:
303
remote_block_size = self.remote_block_size[remote_engine_id]
304
return self.block_size_ratio(remote_block_size)
305
294
- def is_kv_replicated(self, engine_id: str) -> bool:
306
+ def is_kv_replicated(self, engine_id: EngineId) -> bool:
307
"""
308
Whether the KV cache is replicated across TP workers due to the
309
number of TP workers being greater than the number of KV heads.
@@ -299,24 +311,30 @@ class TpKVTopology:
311
tp_size = self.remote_tp_size[engine_id]
312
return tp_size // self.total_num_kv_heads >= 1
313
302
- def replicates_kv_cache(self, remote_engine_id: str) -> bool:
314
+ def replicates_kv_cache(self, remote_engine_id: EngineId) -> bool:
315
# MLA is always replicated as the hidden dim can't be split.
316
return self.is_mla or self.is_kv_replicated(remote_engine_id)
317
306
- def get_target_remote_rank(
318
+ def get_target_remote_ranks(
319
self,
320
remote_tp_size: int,
309
- ) -> int:
321
+ ) -> list[int]:
322
"""
323
Get the remote TP rank (on P) that the current local TP rank
312
- (on D) will read from.
324
+ (on D) will read from. When remote tp_size > local tp_size, we
325
+ read from multiple remote ranks.
326
"""
327
tp_ratio = self.tp_ratio(remote_tp_size)
315
- return self.tp_rank // tp_ratio
328
+ if tp_ratio > 0:
329
+ return [self.tp_rank // tp_ratio]
330
317
- def get_target_remote_rank_from_engine_id(
331
+ # P TP > D TP case, D reads from |tp_ratio| remote workers.
332
+ tp_ratio = -tp_ratio
333
+ return [self.tp_rank * tp_ratio + i for i in range(tp_ratio)]
334
+
335
+ def get_target_remote_ranks_from_engine_id(
336
self,
319
- remote_engine_id: str,
320
- ) -> int:
337
+ remote_engine_id: EngineId,
338
+ ) -> list[int]:
339
remote_tp_size = self.remote_tp_size[remote_engine_id]
322
- return self.get_target_remote_rank(remote_tp_size)
340
+ return self.get_target_remote_ranks(remote_tp_size)
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+290
-168
@@ -23,7 +23,7 @@ from vllm import envs
23
from vllm.attention.backends.abstract import AttentionMetadata
24
from vllm.attention.selector import get_attn_backend
25
from vllm.config import VllmConfig
26
-from vllm.distributed.kv_transfer.kv_connector.utils import TpKVTopology
26
+from vllm.distributed.kv_transfer.kv_connector.utils import EngineId, TpKVTopology
27
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
28
CopyBlocksOp,
29
KVConnectorBase_V1,
@@ -56,7 +56,6 @@ if TYPE_CHECKING:
56
from vllm.v1.request import Request
57
58
TransferHandle = int
59
-EngineId = str
59
ReqId = str
60
61
#
@@ -873,9 +872,10 @@ class NixlConnectorWorker:
872
self.copy_blocks: CopyBlocksOp | None = None
873
874
# Map of engine_id -> kv_caches_base_addr. For TP case, each local
876
- # rank will still only pull from a single remote TP worker.
877
- self.kv_caches_base_addr: dict[EngineId, list[int]] = {}
875
self.device_id: int = 0
876
+ # Current rank may pull from multiple remote TP workers.
877
+ # EngineId, dict[int, list[int]] -> engine_id, tp_rank, base_addr_for_layer
878
+ self.kv_caches_base_addr = defaultdict[EngineId, dict[int, list[int]]](dict)
879
880
# Number of NIXL regions. Currently one region per cache
881
# (so 1 per layer for MLA, otherwise 2 per layer)
@@ -883,10 +883,12 @@ class NixlConnectorWorker:
883
self.num_layers = 0
884
885
# nixl_prepped_dlist_handle.
886
- self.src_xfer_side_handle: int = 0
887
- self.src_xfer_side_handles: dict[int, int] = {}
888
- # Map of engine_id -> nixl_prepped_dlist_handle (int)].
889
- self.dst_xfer_side_handles: dict[EngineId, int] = {}
886
+ self.src_xfer_handles_by_block_size: dict[int, int] = {}
887
+ # Populated dynamically during handshake based on remote configuration.
888
+ # Keep track of regions at different tp_ratio values. tp_ratio->handles
889
+ self.src_xfer_handles_by_tp_ratio: dict[int, list[int]] = {}
890
+ # Map of engine_id -> {tp_rank: nixl_prepped_dlist_handle (int)}.
891
+ self.dst_xfer_side_handles = defaultdict[EngineId, dict[int, int]](dict)
892
893
# Map of engine_id -> num_blocks. All ranks in the same deployment will
894
# have the same number of blocks.
@@ -977,103 +979,108 @@ class NixlConnectorWorker:
979
expected_engine_id: str,
980
) -> dict[int, str]:
981
"""Do a NIXL handshake with a remote instance."""
980
-
981
- start_time = time.perf_counter()
982
-
983
- # NOTE(rob): we need each rank to have a unique port. This is
984
- # a hack to keep us moving. We will switch when moving to etcd
985
- # or where we have a single ZMQ socket in the scheduler.
986
-
987
- # Handshake only with the remote TP rank that current local rank will
988
- # pull from. With homogeneous TP it happens to be the same rank_i.
989
- p_remote_rank = self.kv_topo.get_target_remote_rank(remote_tp_size)
982
+ # When target instance TP > local TP, we need to perform multiple
983
+ # handshakes. Do it in a single background job for simplicity.
984
+ # Regardless, only handshake with the remote TP rank(s) that current
985
+ # local rank will read from. Note that With homogeneous TP,
986
+ # this happens to be the same single rank_i.
987
+ p_remote_ranks = self.kv_topo.get_target_remote_ranks(remote_tp_size)
988
+ remote_rank_to_agent_name = {}
989
path = make_zmq_path("tcp", host, port)
991
- logger.debug(
992
- "Querying metadata on path: %s at remote tp rank %s", path, p_remote_rank
993
- )
990
995
- # Send query for the request.
991
with zmq_ctx(zmq.REQ, path) as sock:
997
- msg = msgspec.msgpack.encode((GET_META_MSG, p_remote_rank))
998
- # Set receive timeout to 5 seconds to avoid hanging on dead server
999
- sock.setsockopt(zmq.RCVTIMEO, 5000) # milliseconds
1000
- sock.send(msg)
1001
- handshake_bytes = sock.recv()
1002
-
1003
- # Decode handshake payload to get compatibility hash
1004
- handshake_decoder = msgspec.msgpack.Decoder(NixlHandshakePayload)
1005
- try:
1006
- handshake_payload = handshake_decoder.decode(handshake_bytes)
1007
- except (msgspec.DecodeError, msgspec.ValidationError) as e:
1008
- raise RuntimeError(
1009
- f"Failed to decode NixlHandshakePayload. This likely indicates "
1010
- f"an incompatibility between connector version. Error: {e}"
1011
- ) from e
1012
-
1013
- got_metadata_time = time.perf_counter()
1014
- logger.debug(
1015
- "NIXL handshake: get metadata took: %s", got_metadata_time - start_time
1016
- )
1017
-
1018
- # Check compatibility hash BEFORE decoding agent metadata
1019
- if (
1020
- self.enforce_compat_hash
1021
- and handshake_payload.compatibility_hash != self.compat_hash
1022
- ):
1023
- raise RuntimeError(
1024
- f"NIXL compatibility hash mismatch. "
1025
- f"Local: {self.compat_hash}, "
1026
- f"Remote: {handshake_payload.compatibility_hash}. "
1027
- f"Prefill and decode instances have incompatible configurations. "
1028
- f"This may be due to: different vLLM versions, models, dtypes, "
1029
- f"KV cache layouts, attention backends, etc. "
1030
- f"Both instances must use identical configurations."
1031
- f"Disable this check using "
1032
- f'--kv-transfer-config \'{{"kv_connector_extra_config": '
1033
- f'{{"enforce_handshake_compat": false}}}}\''
992
+ for remote_rank in p_remote_ranks:
993
+ logger.debug(
994
+ "Querying metadata on path: %s at remote tp rank %s",
995
+ path,
996
+ remote_rank,
997
)
998
1036
- logger.info(
1037
- "NIXL compatibility check passed (hash: %s)",
1038
- handshake_payload.compatibility_hash,
1039
- )
999
+ start_time = time.perf_counter()
1000
+ # Send query for the request.
1001
+ msg = msgspec.msgpack.encode((GET_META_MSG, remote_rank))
1002
+ # Set receive timeout to 5 seconds to avoid hanging on dead server
1003
+ sock.setsockopt(zmq.RCVTIMEO, 5000) # milliseconds
1004
+ sock.send(msg)
1005
+ handshake_bytes = sock.recv()
1006
1041
- # Decode agent metadata
1042
- metadata_decoder = msgspec.msgpack.Decoder(NixlAgentMetadata)
1043
- try:
1044
- metadata = metadata_decoder.decode(
1045
- handshake_payload.agent_metadata_bytes
1007
+ # Decode handshake payload to get compatibility hash
1008
+ handshake_decoder = msgspec.msgpack.Decoder(NixlHandshakePayload)
1009
+ try:
1010
+ handshake_payload = handshake_decoder.decode(handshake_bytes)
1011
+ except (msgspec.DecodeError, msgspec.ValidationError) as e:
1012
+ raise RuntimeError(
1013
+ f"Failed to decode NixlHandshakePayload. This likely indicates "
1014
+ f"an incompatibility between connector version. Error: {e}"
1015
+ ) from e
1016
+
1017
+ got_metadata_time = time.perf_counter()
1018
+ logger.debug(
1019
+ "NIXL handshake: get metadata took: %s",
1020
+ got_metadata_time - start_time,
1021
)
1047
- except (msgspec.DecodeError, msgspec.ValidationError) as e:
1048
- # This should not happen if hash matched
1049
- raise RuntimeError(
1050
- f"Failed to decode NixlAgentMetadata. Error: {e}"
1051
- ) from e
1022
1053
- # Ensure engine id matches.
1054
- if metadata.engine_id != expected_engine_id:
1055
- raise RuntimeError(
1056
- f"Remote NIXL agent engine ID mismatch. "
1057
- f"Expected {expected_engine_id},"
1058
- f"received {metadata.engine_id}."
1059
- )
1023
+ # Check compatibility hash BEFORE decoding agent metadata
1024
+ if (
1025
+ self.enforce_compat_hash
1026
+ and handshake_payload.compatibility_hash != self.compat_hash
1027
+ ):
1028
+ raise RuntimeError(
1029
+ f"NIXL compatibility hash mismatch. "
1030
+ f"Local: {self.compat_hash}, "
1031
+ f"Remote: {handshake_payload.compatibility_hash}. "
1032
+ f"Prefill and decode instances have incompatible "
1033
+ f"configurations. This may be due to: different vLLM versions,"
1034
+ f" models, dtypes, KV cache layouts, attention backends, etc. "
1035
+ f"Both instances must use identical configurations."
1036
+ f"Disable this check using "
1037
+ f'--kv-transfer-config \'{{"kv_connector_extra_config": '
1038
+ f'{{"enforce_handshake_compat": false}}}}\''
1039
+ )
1040
1061
- # Register Remote agent.
1062
- assert metadata.block_size <= self.block_size, (
1063
- "nP > nD is not supported yet."
1064
- )
1065
- remote_agent_name = self.add_remote_agent(
1066
- metadata, p_remote_rank, remote_tp_size
1067
- )
1041
+ logger.info(
1042
+ "NIXL compatibility check passed (hash: %s)",
1043
+ handshake_payload.compatibility_hash,
1044
+ )
1045
1069
- setup_agent_time = time.perf_counter()
1070
- logger.debug(
1071
- "NIXL handshake: add agent took: %s",
1072
- setup_agent_time - got_metadata_time,
1073
- )
1046
+ # Decode agent metadata
1047
+ metadata_decoder = msgspec.msgpack.Decoder(NixlAgentMetadata)
1048
+ try:
1049
+ metadata = metadata_decoder.decode(
1050
+ handshake_payload.agent_metadata_bytes
1051
+ )
1052
+ except (msgspec.DecodeError, msgspec.ValidationError) as e:
1053
+ # This should not happen if hash matched
1054
+ raise RuntimeError(
1055
+ f"Failed to decode NixlAgentMetadata. Error: {e}"
1056
+ ) from e
1057
+
1058
+ # Ensure engine id matches.
1059
+ if metadata.engine_id != expected_engine_id:
1060
+ raise RuntimeError(
1061
+ f"Remote NIXL agent engine ID mismatch. "
1062
+ f"Expected {expected_engine_id},"
1063
+ f"received {metadata.engine_id}."
1064
+ )
1065
+ # Ensure engine id matches.
1066
+ if metadata.engine_id != expected_engine_id:
1067
+ raise RuntimeError(
1068
+ f"Remote NIXL agent engine ID mismatch. "
1069
+ f"Expected {expected_engine_id},"
1070
+ f"received {metadata.engine_id}."
1071
+ )
1072
+ setup_agent_time = time.perf_counter()
1073
1075
- # Remote rank -> agent name.
1076
- return {p_remote_rank: remote_agent_name}
1074
+ # Register Remote agent.
1075
+ remote_agent_name = self.add_remote_agent(
1076
+ metadata, remote_rank, remote_tp_size
1077
+ )
1078
+ logger.debug(
1079
+ "NIXL handshake: add agent took: %s",
1080
+ setup_agent_time - got_metadata_time,
1081
+ )
1082
+ remote_rank_to_agent_name[remote_rank] = remote_agent_name
1083
+ return remote_rank_to_agent_name
1084
1085
def initialize_host_xfer_buffer(self, kv_caches: dict[str, torch.Tensor]) -> None:
1086
"""
@@ -1283,7 +1290,7 @@ class NixlConnectorWorker:
1290
assert len(self.block_len_per_layer) == len(seen_base_addresses)
1291
assert self.num_blocks != 0
1292
1286
- self.kv_caches_base_addr[self.engine_id] = seen_base_addresses
1293
+ self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
1294
self.num_regions = len(caches_data)
1295
self.num_layers = len(xfer_buffers.keys())
1296
@@ -1310,9 +1317,9 @@ class NixlConnectorWorker:
1317
1318
# Register local/src descr for NIXL xfer.
1319
self.seen_base_addresses = seen_base_addresses
1313
- self.src_xfer_side_handle = self.register_local_xfer_handler(self.block_size)
1314
-
1315
- self.src_xfer_side_handles[self.block_size] = self.src_xfer_side_handle
1320
+ self.src_xfer_handles_by_block_size[self.block_size], self.src_blocks_data = (
1321
+ self.register_local_xfer_handler(self.block_size)
1322
+ )
1323
1324
# TODO(mgoin): Hybrid memory allocator is currently disabled for
1325
# models with local attention (Llama 4). Can remove this once enabled.
@@ -1340,8 +1347,8 @@ class NixlConnectorWorker:
1347
agent_metadata = NixlAgentMetadata(
1348
engine_id=self.engine_id,
1349
agent_metadata=self.nixl_wrapper.get_agent_metadata(),
1343
- kv_caches_base_addr=self.kv_caches_base_addr[self.engine_id],
1350
device_id=self.device_id,
1351
+ kv_caches_base_addr=self.kv_caches_base_addr[self.engine_id][self.tp_rank],
1352
num_blocks=self.num_blocks,
1353
block_lens=self.block_len_per_layer,
1354
kv_cache_layout=self.kv_cache_layout
@@ -1359,7 +1366,7 @@ class NixlConnectorWorker:
1366
def register_local_xfer_handler(
1367
self,
1368
block_size: int,
1362
- ) -> int:
1369
+ ) -> tuple[int, list[tuple[int, int, int]]]:
1370
"""
1371
Function used for register local xfer handler with local block_size or
1372
Remote block_size.
@@ -1407,7 +1414,7 @@ class NixlConnectorWorker:
1414
1415
descs = self.nixl_wrapper.get_xfer_descs(blocks_data, self.nixl_memory_type)
1416
# NIXL_INIT_AGENT to be used for preparations of local descs.
1410
- return self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs)
1417
+ return self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs), blocks_data
1418
1419
def add_remote_agent(
1420
self,
@@ -1421,10 +1428,12 @@ class NixlConnectorWorker:
1428
1429
In particular, handle both homogeneous and heterogeneous TP. The former
1430
requires local rank_i to read from remote rank_i.
1424
- The latter, assuming D.world_size > P.world_size, requires that two or
1425
- more local TP worker share the xfer from a single TP worker.
1431
+ The latter, in the case of D.world_size < P.world_size, requires that a
1432
+ local (D) TP worker reads from multiple remote (P) TP workers.
1433
+ Conversely, assuming D.world_size > P.world_size, two or more local TP
1434
+ workers will read from a single remote TP worker.
1435
1427
- Here's an example (non-MLA case):
1436
+ Here's an example for the last case described above (non-MLA):
1437
1438
rank_offset p_remote_tp_rank
1439
(kv split no)
@@ -1474,9 +1483,6 @@ class NixlConnectorWorker:
1483
nixl_agent_meta.agent_metadata
1484
)
1485
1477
- # Handle tp_size>num_kv_heads: replicate KV cache.
1478
- replicates_kv_cache = self.kv_topo.replicates_kv_cache(engine_id)
1479
-
1486
# Create dst descs and xfer side handles. TP workers have same #blocks
1487
# so we only register once per engine_id.
1488
# Example:
@@ -1490,14 +1496,52 @@ class NixlConnectorWorker:
1496
self.dst_num_blocks[engine_id] = nixl_agent_meta.num_blocks
1497
1498
# Keep track of remote agent kv caches base addresses.
1493
- self.kv_caches_base_addr[engine_id] = nixl_agent_meta.kv_caches_base_addr
1494
-
1499
+ self.kv_caches_base_addr[engine_id][remote_tp_rank] = (
1500
+ nixl_agent_meta.kv_caches_base_addr
1501
+ )
1502
self._validate_remote_agent_handshake(nixl_agent_meta, remote_tp_size)
1503
1497
- # Number of D TP workers reading from a single P TP worker. This is
1498
- # 1 when P and D `--tensor-parallel-size` match.
1504
+ # This is 1 when P and D `--tensor-parallel-size` match. Otherwise,
1505
+ # this is the ratio between the two sizes.
1506
tp_ratio = self.kv_topo.tp_ratio_from_engine_id(engine_id)
1507
1508
+ # Handle tp_size>num_kv_heads: replicate KV cache.
1509
+ indexes_into_remote = (
1510
+ not self.kv_topo.replicates_kv_cache(engine_id) and tp_ratio > 0
1511
+ )
1512
+
1513
+ logger.debug(
1514
+ "Registering remote agent (%s, rank %s) memory regions with tp_ratio %s",
1515
+ engine_id,
1516
+ remote_tp_rank,
1517
+ tp_ratio,
1518
+ )
1519
+
1520
+ ### (Optional) Register local agent memory regions. MLA is not split.
1521
+ if (
1522
+ tp_ratio < 0
1523
+ and not self.use_mla
1524
+ and tp_ratio not in self.src_xfer_handles_by_tp_ratio
1525
+ ):
1526
+ # Remote tp_size > local tp_size: read from multiple remote ranks.
1527
+ # Logically "split" own regions into |tp_ratio| chunks. Mind that
1528
+ # we only do this once per remote tp_size (replica-friendly).
1529
+ self.src_xfer_handles_by_tp_ratio[tp_ratio] = []
1530
+ for i in range(-tp_ratio):
1531
+ blocks_data = []
1532
+ for memory_region in self.src_blocks_data:
1533
+ addr, local_block_len, own_tp_rank = memory_region
1534
+ # Computing block len layer by layer allows for different
1535
+ # block sizes to be used.
1536
+ remote_block_len = local_block_len // (-tp_ratio)
1537
+ addr = addr + i * remote_block_len
1538
+ blocks_data.append((addr, remote_block_len, own_tp_rank))
1539
+ descs = self.nixl_wrapper.get_xfer_descs(
1540
+ blocks_data, self.nixl_memory_type
1541
+ )
1542
+ handle = self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs)
1543
+ self.src_xfer_handles_by_tp_ratio[tp_ratio].append(handle)
1544
+
1545
### Register remote agent memory regions
1546
blocks_data = []
1547
# With homogeneous TP, D pulls the whole kv cache from corresponding
@@ -1507,14 +1551,19 @@ class NixlConnectorWorker:
1551
1552
# Register all remote blocks, but only the corresponding kv heads.
1553
for i, base_addr in enumerate(nixl_agent_meta.kv_caches_base_addr):
1510
- kv_block_len = self.get_backend_aware_kv_block_len(layer_idx=i)
1511
- remote_kv_block_len = kv_block_len // block_size_ratio
1554
+ # Read our whole local region size from remote.
1555
+ local_block_len = self.get_backend_aware_kv_block_len(layer_idx=i)
1556
+ remote_kv_block_len = local_block_len // block_size_ratio
1557
if block_size_ratio > 1:
1558
# using remote kv_block_len as transfer unit
1514
- kv_block_len = remote_kv_block_len
1559
+ local_block_len = remote_kv_block_len
1560
+
1561
+ if tp_ratio < 0 and not self.use_mla:
1562
+ # Remote tp is bigger: read a chunk of local region from remote
1563
+ local_block_len = local_block_len // (-tp_ratio)
1564
rank_offset = (
1565
self.tp_rank % tp_ratio * remote_kv_block_len
1517
- if not replicates_kv_cache
1566
+ if indexes_into_remote
1567
else 0
1568
)
1569
for block_id in range(nixl_agent_meta.num_blocks):
@@ -1524,7 +1573,7 @@ class NixlConnectorWorker:
1573
# self.block_len == remote_block_len//tp_ratio bytes.
1574
addr = base_addr + block_offset + rank_offset
1575
# (addr, len, device id)
1527
- blocks_data.append((addr, kv_block_len, nixl_agent_meta.device_id))
1576
+ blocks_data.append((addr, local_block_len, nixl_agent_meta.device_id))
1577
1578
if self.kv_topo.is_kv_layout_blocks_first:
1579
# With FlashInfer index V separately to allow head splitting.
@@ -1533,7 +1582,7 @@ class NixlConnectorWorker:
1582
addr = base_addr + block_offset + rank_offset
1583
v_addr = addr + nixl_agent_meta.block_lens[i] // 2
1584
blocks_data.append(
1536
- (v_addr, kv_block_len, nixl_agent_meta.device_id)
1585
+ (v_addr, local_block_len, nixl_agent_meta.device_id)
1586
)
1587
1588
logger.debug(
@@ -1546,15 +1595,15 @@ class NixlConnectorWorker:
1595
1596
# Register with NIXL.
1597
descs = self.nixl_wrapper.get_xfer_descs(blocks_data, self.nixl_memory_type)
1549
- self.dst_xfer_side_handles[engine_id] = self.nixl_wrapper.prep_xfer_dlist(
1550
- remote_agent_name, descs
1598
+ self.dst_xfer_side_handles[engine_id][remote_tp_rank] = (
1599
+ self.nixl_wrapper.prep_xfer_dlist(remote_agent_name, descs)
1600
)
1601
1602
if block_size_ratio > 1:
1603
# when prefill with smaller block_size, we need to init a
1604
# new handler with same block_len to match
1556
- self.src_xfer_side_handles[nixl_agent_meta.block_size] = (
1557
- self.register_local_xfer_handler(nixl_agent_meta.block_size)
1605
+ self.src_xfer_handles_by_block_size[nixl_agent_meta.block_size] = (
1606
+ self.register_local_xfer_handler(nixl_agent_meta.block_size)[0]
1607
)
1608
1609
return remote_agent_name
@@ -1574,7 +1623,9 @@ class NixlConnectorWorker:
1623
block_size_ratio = self.kv_topo.block_size_ratio_from_engine_id(
1624
remote_engine_id
1625
)
1577
- assert tp_ratio > 0, "Decode TP cannot be smaller than prefill TP"
1626
+ # Num kv_heads > tp_size and P TP > D TP case, not supported
1627
+ assert not (tp_ratio < 0 and self.kv_topo.is_kv_replicated(remote_engine_id))
1628
+
1629
assert not self._use_pallas or tp_ratio == 1, (
1630
"TPU (pallas_v1) DOES NOT support heterogeneous TP yet."
1631
)
@@ -1616,17 +1667,29 @@ class NixlConnectorWorker:
1667
"All remote layers must have the same block size"
1668
)
1669
1619
- assert (
1620
- remote_block_len
1621
- == (self.block_len_per_layer[0] * tp_ratio) // block_size_ratio
1622
- ), (
1623
- "Remote P worker KV layer cache must be of shape [2, N, "
1624
- "local_kv_heads*tp_ratio, block_size, head_dim] and same dtype."
1625
- )
1670
+ if tp_ratio > 0:
1671
+ # Remote tp is smaller: remote block_len size is bigger
1672
+ assert (
1673
+ remote_block_len
1674
+ == (self.block_len_per_layer[0] * tp_ratio) // block_size_ratio
1675
+ ), (
1676
+ "Remote P worker KV layer cache must be of shape [2, N, "
1677
+ "local_kv_heads*tp_ratio, page_size, head_dim] and same dtype."
1678
+ ) # noqa: E501
1679
+ else:
1680
+ assert block_size_ratio == 1, (
1681
+ "Different local/remote block sizes are not supported when"
1682
+ " P TP > D TP."
1683
+ )
1684
+ # Remote tp is bigger: remote block_len size is smaller
1685
+ assert remote_block_len == self.block_len_per_layer[0] // (-tp_ratio), (
1686
+ "Remote P worker KV layer cache must be of shape [2, N, "
1687
+ "local_kv_heads/tp_ratio, page_size, head_dim] and same dtype."
1688
+ ) # noqa: E501
1689
1627
- # TP workers have same #blocks.
1690
+ # TP workers that handhshake with same remote have same #blocks.
1691
assert self.dst_num_blocks[remote_engine_id] == nixl_agent_meta.num_blocks
1629
-
1692
+ # Same number of regions/~layers.
1693
assert len(nixl_agent_meta.kv_caches_base_addr) == len(self.block_len_per_layer)
1694
1695
def sync_recved_kv_to_device(self, req_id: str, meta: ReqMeta):
@@ -1710,7 +1773,7 @@ class NixlConnectorWorker:
1773
)
1774
cache.index_copy_(0, indices, permuted_blocks)
1775
1713
- def blocksize_post_process(self, block_ids_per_ratio: dict[float, list[list[int]]]):
1776
+ def blocksize_post_process(self, block_ids_per_ratio: dict[int, list[list[int]]]):
1777
def _process_local_gt_remote(blocks_to_update, block_size_ratio):
1778
n_kv_heads, block_size, head_size = blocks_to_update.shape[1:]
1779
remote_block_size = block_size // block_size_ratio
@@ -1840,7 +1903,7 @@ class NixlConnectorWorker:
1903
notified_req_ids: set[str] = set()
1904
for notifs in self.nixl_wrapper.get_new_notifs().values():
1905
for notif in notifs:
1843
- req_id, tp_ratio = notif.decode("utf-8").rsplit(":", 1)
1906
+ req_id, tp_size = notif.decode("utf-8").rsplit(":", 1)
1907
if (
1908
req_id not in self._reqs_to_send
1909
and req_id not in self._reqs_to_process
@@ -1853,9 +1916,22 @@ class NixlConnectorWorker:
1916
)
1917
continue
1918
1919
+ # NOTE: `tp_ratio` is the opposite when swapping local<>remote
1920
+ n_consumers = int(tp_size)
1921
+ tp_ratio = self.kv_topo.tp_ratio(n_consumers)
1922
+
1923
+ # Number of reads *per producer* to wait for.
1924
+ # When remote D TP > local P TP we expect `tp_ratio` reads.
1925
+ consumers_per_producer = (
1926
+ -tp_ratio if n_consumers > self.world_size else 1
1927
+ )
1928
+
1929
self.consumer_notification_counts_by_req[req_id] += 1
1930
# Wait all consumers (D) to be done reading before freeing.
1858
- if self.consumer_notification_counts_by_req[req_id] == int(tp_ratio):
1931
+ if (
1932
+ self.consumer_notification_counts_by_req[req_id]
1933
+ == consumers_per_producer
1934
+ ):
1935
notified_req_ids.add(req_id)
1936
del self.consumer_notification_counts_by_req[req_id]
1937
self._reqs_to_process.remove(req_id)
@@ -1872,7 +1948,7 @@ class NixlConnectorWorker:
1948
"""
1949
done_req_ids: set[str] = set()
1950
for req_id, handles in list(transfers.items()):
1875
- in_progress = False
1951
+ in_progress = []
1952
for handle in handles:
1953
try:
1954
xfer_state = self.nixl_wrapper.check_xfer_state(handle)
@@ -1882,7 +1958,7 @@ class NixlConnectorWorker:
1958
self.xfer_stats.record_transfer(res)
1959
self.nixl_wrapper.release_xfer_handle(handle)
1960
elif xfer_state == "PROC":
1885
- in_progress = True
1961
+ in_progress.append(handle)
1962
continue
1963
else:
1964
logger.error(
@@ -1892,7 +1968,6 @@ class NixlConnectorWorker:
1968
xfer_state,
1969
)
1970
self._handle_failed_transfer(req_id, handle)
1895
- in_progress = False
1971
except Exception:
1972
logger.exception(
1973
"NIXL transfer exception for request %s. "
@@ -1900,11 +1975,13 @@ class NixlConnectorWorker:
1975
req_id,
1976
)
1977
self._handle_failed_transfer(req_id, handle)
1903
- in_progress = False
1978
1979
if not in_progress:
1980
+ # Only report request as completed when all transfers are done.
1981
done_req_ids.add(req_id)
1982
del transfers[req_id]
1983
+ else:
1984
+ transfers[req_id] = in_progress
1985
return done_req_ids
1986
1987
def _handle_failed_transfer(self, req_id: str, handle: int):
@@ -1982,18 +2059,62 @@ class NixlConnectorWorker:
2059
2060
def _read_blocks_for_req(self, req_id: str, meta: ReqMeta):
2061
assert meta.remote is not None
1985
- logger.debug(
1986
- "Remote agent %s available, calling _read_blocks for req %s",
1987
- meta.remote.engine_id,
1988
- req_id,
1989
- )
1990
- self._read_blocks(
1991
- request_id=req_id,
1992
- dst_engine_id=meta.remote.engine_id,
1993
- remote_request_id=meta.remote.request_id,
1994
- local_block_ids=meta.local_physical_block_ids,
1995
- remote_block_ids=meta.remote.block_ids,
2062
+ remote_ranks = self.kv_topo.get_target_remote_ranks_from_engine_id(
2063
+ meta.remote.engine_id
2064
)
2065
+ tp_ratio = self.kv_topo.tp_ratio_from_engine_id(meta.remote.engine_id)
2066
+ # D may have to perform multiple reads from different remote ranks.
2067
+ for i, remote_rank in enumerate(remote_ranks):
2068
+ if self.use_mla and tp_ratio < 0 and i > 0:
2069
+ # MLA opt: when P TP > D TP, only a single read is executed for
2070
+ # the first remote rank (cache is duplicated)..
2071
+ break
2072
+
2073
+ remote_block_size = self.kv_topo.remote_block_size[meta.remote.engine_id]
2074
+ logger.debug(
2075
+ "Remote agent %s available, calling _read_blocks"
2076
+ " on remote rank %s with remote block size %s for req %s",
2077
+ meta.remote.engine_id,
2078
+ remote_rank,
2079
+ remote_block_size,
2080
+ req_id,
2081
+ )
2082
+ # Get side handles.
2083
+ if tp_ratio < 0 and not self.use_mla:
2084
+ assert remote_block_size == self.block_size
2085
+ # Remote tp_size > local tp_size: we must perform multiple
2086
+ # reads. Get the memory chunk onto which we will write to.
2087
+ local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[tp_ratio][i]
2088
+ else:
2089
+ # Single read from remote, we write to the whole memory region.
2090
+ # Also handle remote block size different from local block size.
2091
+ local_xfer_side_handle = self.src_xfer_handles_by_block_size[
2092
+ remote_block_size
2093
+ ]
2094
+
2095
+ # Destination handle: remote_engine_id -> remote_rank -> handle.
2096
+ remote_xfer_side_handle = self.dst_xfer_side_handles[meta.remote.engine_id][
2097
+ remote_rank
2098
+ ]
2099
+ self._read_blocks(
2100
+ request_id=req_id,
2101
+ dst_engine_id=meta.remote.engine_id,
2102
+ remote_request_id=meta.remote.request_id,
2103
+ local_block_ids=meta.local_physical_block_ids,
2104
+ remote_block_ids=meta.remote.block_ids,
2105
+ remote_rank=remote_rank,
2106
+ local_xfer_side_handle=local_xfer_side_handle,
2107
+ remote_xfer_side_handle=remote_xfer_side_handle,
2108
+ )
2109
+
2110
+ if self.use_mla and tp_ratio < 0:
2111
+ # ..but we still need to notify the other remote ranks that we
2112
+ # have the blocks we need so they can update the request state.
2113
+ notif_id = f"{req_id}:{self.world_size}".encode()
2114
+ remote_agents = self._remote_agents[meta.remote.engine_id]
2115
+ for rank_to_notify, agent in remote_agents.items():
2116
+ if rank_to_notify != remote_rank:
2117
+ self.nixl_wrapper.send_notif(agent, notif_msg=notif_id)
2118
2119
def _read_blocks(
2120
self,
@@ -2002,7 +2123,14 @@ class NixlConnectorWorker:
2123
dst_engine_id: str,
2124
request_id: str,
2125
remote_request_id: str,
2126
+ remote_rank: int,
2127
+ local_xfer_side_handle: int,
2128
+ remote_xfer_side_handle: int,
2129
):
2130
+ """
2131
+ Post a READ point-to-point xfer request from a single local worker to
2132
+ a single remote worker.
2133
+ """
2134
block_size_ratio = self.kv_topo.block_size_ratio_from_engine_id(dst_engine_id)
2135
if block_size_ratio > 1:
2136
local_block_ids = self.get_mapped_blocks(
@@ -2031,18 +2159,14 @@ class NixlConnectorWorker:
2159
# saturate IB with heterogeneous TP sizes. We should remove the staging
2160
# blocks until we are ready.
2161
2034
- # Number of D TP workers that will read from dst P. Propagate tp_ratio
2162
+ # Number of D TP workers that will read from dst P. Propagate info
2163
# on notification so that dst worker can wait before freeing blocks.
2036
- tp_ratio = self.kv_topo.tp_ratio_from_engine_id(dst_engine_id)
2037
- notif_id = f"{remote_request_id}:{tp_ratio}".encode()
2164
+ notif_id = f"{remote_request_id}:{self.world_size}".encode()
2165
2166
# Full prefix cache hit: do not need to read remote blocks,
2167
# just notify P worker that we have the blocks we need.
2168
num_local_blocks = len(local_block_ids)
2169
if num_local_blocks == 0:
2043
- remote_rank = self.kv_topo.get_target_remote_rank_from_engine_id(
2044
- dst_engine_id
2045
- )
2170
agent_name = self._remote_agents[dst_engine_id][remote_rank]
2171
try:
2172
self.nixl_wrapper.send_notif(agent_name, notif_msg=notif_id)
@@ -2062,13 +2186,6 @@ class NixlConnectorWorker:
2186
if num_local_blocks < num_remote_blocks:
2187
remote_block_ids = remote_block_ids[-num_local_blocks:]
2188
2065
- # Get side handles.
2066
- remote_block_size = self.kv_topo.remote_block_size[dst_engine_id]
2067
- local_xfer_side_handle = self.src_xfer_side_handles.get(
2068
- remote_block_size, self.src_xfer_side_handle
2069
- )
2070
- remote_xfer_side_handle = self.dst_xfer_side_handles[dst_engine_id]
2071
-
2189
# NOTE (nicolo) With homogeneous TP, each TP worker loads KV from
2190
# corresponding rank. With heterogeneous TP, fixing D>P, the D tp
2191
# workers will issue xfers to parts of the P worker remote kv caches.
@@ -2230,7 +2347,7 @@ class NixlConnectorWorker:
2347
block_ids_np, self._physical_blocks_per_logical_kv_block, block_arange
2348
).tolist()
2349
2233
- def get_backend_aware_kv_block_len(self, layer_idx: int):
2350
+ def get_backend_aware_kv_block_len(self, layer_idx: int) -> int:
2351
"""
2352
Get the block length for one K/V element (K and V have the same size).
2353
@@ -2276,11 +2393,16 @@ class NixlConnectorWorker:
2393
for handle in handles:
2394
self.nixl_wrapper.release_xfer_handle(handle)
2395
self._recving_transfers.clear()
2279
- if self.src_xfer_side_handle:
2280
- self.nixl_wrapper.release_dlist_handle(self.src_xfer_side_handle)
2281
- self.src_xfer_side_handle = 0
2282
- for dst_xfer_side_handle in self.dst_xfer_side_handles.values():
2283
- self.nixl_wrapper.release_dlist_handle(dst_xfer_side_handle)
2396
+ for handle in self.src_xfer_handles_by_block_size.values():
2397
+ self.nixl_wrapper.release_dlist_handle(handle)
2398
+ self.src_xfer_handles_by_block_size.clear()
2399
+ for handles in self.src_xfer_handles_by_tp_ratio.values():
2400
+ for handle in handles:
2401
+ self.nixl_wrapper.release_dlist_handle(handle)
2402
+ self.src_xfer_handles_by_tp_ratio.clear()
2403
+ for dst_xfer_side_handles in self.dst_xfer_side_handles.values():
2404
+ for dst_xfer_side_handle in dst_xfer_side_handles.values():
2405
+ self.nixl_wrapper.release_dlist_handle(dst_xfer_side_handle)
2406
self.dst_xfer_side_handles.clear()
2407
for remote_agents in self._remote_agents.values():
2408
for agent_name in remote_agents.values():