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, 22:30 UTC
nixl refactor [2/N]: unify TpKVTopology + HeteroTPTransferConfig into TransferTopology (#39529)
Signed-off-by: Zhanqiu Hu <zhu@redhat.com>
zhanqiuhu committed
Apr 20, 2026 at 06:39 UTC
cc3993b05d008be370778466865cce1959ac85a3
7 files changed
+653
-620
tests/v1/kv_connector/unit/test_mooncake_connector.py
+4
-4
@@ -631,7 +631,7 @@ def test_register_kv_caches_supports_mixed_mla_and_eagle_shapes():
631
mock_thread.return_value.is_alive.return_value = False
632
633
worker.use_mla = True
634
- worker.kv_topo.is_mla = True
634
+ worker.transfer_topo.is_mla = True
635
636
# MLA cache tensor: shape[-2] is the block size.
637
mla_cache = torch.zeros((2, 16, 96), dtype=torch.float16)
@@ -692,9 +692,9 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
692
# Override TP rank/size to simulate P TP=2
693
prefill_worker.tp_rank = P_TP_RANK
694
prefill_worker.tp_size = P_TP_SIZE
695
- # Update shared dict so kv_topo sees correct TP size
695
prefill_worker._tp_size[prefill_worker.engine_id] = P_TP_SIZE
697
- prefill_worker.kv_topo.tp_rank = P_TP_RANK
696
+ prefill_worker.transfer_topo.tp_rank = P_TP_RANK
697
+ prefill_worker.transfer_topo.tp_size = P_TP_SIZE
698
699
prefill_worker.kv_caches_base_addr = [0x1000]
700
prefill_worker.block_len_per_layer = [local_block_len]
@@ -714,7 +714,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
714
send_meta.ready.set()
715
716
# Compute target D ranks using the production code path
717
- target_d_ranks = prefill_worker.kv_topo.get_target_remote_ranks(d_tp_size)
717
+ target_d_ranks = prefill_worker.transfer_topo.handshake_target_ranks(d_tp_size)
718
719
mock_socket = AsyncMock(spec=zmq.asyncio.Socket)
720
mock_socket.send_multipart = AsyncMock()
tests/v1/kv_connector/unit/test_nixl_connector.py
+17
-14
@@ -21,7 +21,7 @@ from vllm import LLM
21
from vllm.config import KVTransferConfig, set_current_vllm_config
22
from vllm.distributed.kv_transfer.kv_connector.utils import (
23
KVOutputAggregator,
24
- TpKVTopology,
24
+ TransferTopology,
25
get_current_attn_backend,
26
)
27
from vllm.distributed.kv_transfer.kv_connector.v1 import nixl
@@ -463,19 +463,20 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
463
test_shape = self.attn_backends[0].get_kv_cache_shape(
464
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
465
)
466
- self.kv_topo = TpKVTopology(
466
+ self.transfer_topo = TransferTopology(
467
tp_rank=self.tp_rank,
468
+ tp_size=self.world_size,
469
+ block_size=self.block_size,
470
engine_id=self.engine_id,
469
- remote_tp_size=self._tp_size, # shared state
470
- remote_block_size=self._block_size, # shared state
471
is_mla=self.use_mla,
472
+ is_mamba=False,
473
total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
474
attn_backends=self.attn_backends,
475
tensor_shape=test_shape,
476
)
477
478
self.compat_hash = compute_nixl_compatibility_hash(
478
- self.vllm_config, self.backend_name, self.kv_topo.cross_layers_blocks
479
+ self.vllm_config, self.backend_name, self.transfer_topo.cross_layers_blocks
480
)
481
482
def _nixl_handshake(
@@ -496,7 +497,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
497
# Adjust remote block length metadata to satisfy heterogeneous TP
498
# invariants enforced during handshake validation.
499
remote_block_lens = list(self.block_len_per_layer)
499
- tp_ratio = self.kv_topo.tp_ratio(remote_tp_size)
500
+ tp_ratio = self.transfer_topo.tp_ratio(remote_tp_size)
501
if remote_tp_size > self.world_size:
502
# P TP > D TP case, block_len of remote is smaller
503
remote_block_lens = [
@@ -731,8 +732,9 @@ class TestNixlHandshake:
732
assert set(remote_agents.keys()) == set(range(tp_ratio))
733
734
remote_engine_id = worker.REMOTE_ENGINE_ID
734
- assert worker._tp_size[remote_engine_id] == remote_tp_size
735
- assert -tp_ratio == worker.kv_topo.tp_ratio_from_engine_id(remote_engine_id)
735
+ remote_info = worker.transfer_topo.get_engine_info(remote_engine_id)
736
+ assert remote_info.remote_tp_size == remote_tp_size
737
+ assert -tp_ratio == worker.transfer_topo.tp_ratio(remote_tp_size)
738
# ensure src_xfer_handles_by_tp_ratio is populated with tpratio chunks
739
assert -tp_ratio in worker.src_xfer_handles_by_tp_ratio
740
assert len(worker.src_xfer_handles_by_tp_ratio[-tp_ratio]) == tp_ratio
@@ -796,7 +798,7 @@ class TestNixlHandshake:
798
(conn_p0.connector_worker, conn_p1.connector_worker)
799
):
800
worker.world_size = p_tp_size
799
- worker.kv_topo.remote_tp_size = {worker.engine_id: p_tp_size}
801
+ worker.transfer_topo.tp_size = p_tp_size
802
worker.tp_rank = rank
803
worker.use_mla = True
804
@@ -2337,7 +2339,7 @@ def test_compatibility_hash_validation(
2339
remote_hash = compute_nixl_compatibility_hash(
2340
remote_vllm_config,
2341
decode_worker.backend_name,
2340
- decode_worker.kv_topo.cross_layers_blocks,
2342
+ decode_worker.transfer_topo.cross_layers_blocks,
2343
)
2344
2345
prefill_block_size = config_overrides.get("block_size", 16)
@@ -2424,12 +2426,13 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
2426
test_shape = backend.get_kv_cache_shape(
2427
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
2428
)
2427
- decode_worker.kv_topo = TpKVTopology(
2429
+ decode_worker.transfer_topo = TransferTopology(
2430
tp_rank=decode_worker.tp_rank,
2431
+ tp_size=decode_worker.world_size,
2432
+ block_size=decode_worker.block_size,
2433
engine_id=decode_worker.engine_id,
2430
- remote_tp_size=decode_worker._tp_size, # shared state
2431
- remote_block_size=decode_worker._block_size, # shared state
2434
is_mla=decode_worker.use_mla,
2435
+ is_mamba=False,
2436
total_num_kv_heads=decode_worker.model_config.get_total_num_kv_heads(),
2437
attn_backends=[backend],
2438
tensor_shape=test_shape,
@@ -2438,7 +2441,7 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
2441
decode_worker.compat_hash = compute_nixl_compatibility_hash(
2442
decode_worker.vllm_config,
2443
decode_worker.backend_name,
2441
- decode_worker.kv_topo.cross_layers_blocks,
2444
+ decode_worker.transfer_topo.cross_layers_blocks,
2445
)
2446
2447
if error_scenario == "handshake_decode_error":
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+14
-13
@@ -152,13 +152,14 @@ def test_read_blocks_for_req_expands_remote_ids(
152
153
remote_engine_id = "remote-engine"
154
if has_mamba:
155
- worker._mamba_phys_ratio = {remote_engine_id: remote_ratio}
155
+ worker._physical_blocks_per_logical = {remote_engine_id: remote_ratio}
156
157
- # Mock kv_topo: empty remote ranks skips the transfer machinery entirely,
158
- # isolating the block-ID expansion logic.
159
- worker.kv_topo = MagicMock()
160
- worker.kv_topo.get_target_remote_ranks_from_engine_id.return_value = []
161
- worker.kv_topo.tp_ratio_from_engine_id.return_value = 1
157
+ # Mock transfer_topo: empty remote ranks skips the transfer machinery
158
+ # entirely, isolating the block-ID expansion logic.
159
+ worker.transfer_topo = MagicMock()
160
+ worker.transfer_topo.target_remote_ranks.return_value = []
161
+ worker.transfer_topo.get_engine_info.return_value = MagicMock(remote_tp_size=1)
162
+ worker.transfer_topo.tp_ratio.return_value = 1
163
164
metadata = NixlConnectorMetadata()
165
metadata.add_new_req_to_recv(
@@ -317,7 +318,7 @@ def test_get_block_descs_ids_hybrid_ssm():
318
worker._has_mamba = True
319
worker._is_mamba_group = [False, True]
320
worker._physical_blocks_per_logical_kv_block = 1
320
- worker._mamba_phys_ratio = {engine_id: 1}
321
+ worker._physical_blocks_per_logical = {engine_id: 1}
322
worker.block_len_per_layer = [100]
323
# num_descs = num_regions * num_blocks (no blocks_first doubling)
324
worker.num_descs = 2 * num_blocks
@@ -355,7 +356,7 @@ def test_get_block_descs_ids_kernel_block_mismatch():
356
worker._has_mamba = True
357
worker._is_mamba_group = [False, True]
358
worker._physical_blocks_per_logical_kv_block = ratio
358
- worker._mamba_phys_ratio = {engine_id: ratio}
359
+ worker._physical_blocks_per_logical = {engine_id: ratio}
360
worker.block_len_per_layer = [100]
361
worker.num_descs = 2 * num_blocks # 800
362
@@ -532,15 +533,15 @@ def test_has_mamba_init(
533
((9216, 524288), 4096, 131),
534
],
535
)
535
-def test_compute_mamba_phys_ratio(ssm_sizes, block_len, expected_ratio):
536
- """Verify that compute_mamba_phys_ratio is TP-dependent.
536
+def test_compute_physical_blocks_per_logical(ssm_sizes, block_len, expected_ratio):
537
+ """Verify that compute_physical_blocks_per_logical is TP-dependent.
538
539
With dimension-sharded Mamba state, the ratio differs across TP sizes
540
(e.g. TP=1 → 261, TP=4 → 131 for Nemotron 30B). This is why
540
- _mamba_phys_ratio must be stored per-engine.
541
+ _physical_blocks_per_logical must be stored per-engine.
542
"""
543
from vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils import (
543
- compute_mamba_phys_ratio,
544
+ compute_physical_blocks_per_logical,
545
)
546
546
- assert compute_mamba_phys_ratio(ssm_sizes, block_len) == expected_ratio
547
+ assert compute_physical_blocks_per_logical(ssm_sizes, block_len) == expected_ratio
vllm/distributed/kv_transfer/kv_connector/utils.py
+486
-456
@@ -5,7 +5,7 @@ KV cache helper for store.
5
"""
6
7
from collections.abc import Iterator
8
-from dataclasses import dataclass, field
8
+from dataclasses import dataclass
9
from typing import TYPE_CHECKING, Any, Literal, cast
10
11
import torch
@@ -319,31 +319,139 @@ def yield_req_data(
319
)
320
321
322
-@dataclass
323
-class TpKVTopology:
322
+def get_current_attn_backends(
323
+ vllm_config: VllmConfig, layer_names: list[str] | None = None
324
+) -> list[type[AttentionBackend]]:
325
+ """Get all distinct attention backends for the given layers.
326
+
327
+ Args:
328
+ vllm_config: The current vLLM configuration.
329
+ layer_names: Optional list of layer names to scope the lookup.
330
+ When None, all attention layers are considered.
331
+
332
+ Returns:
333
+ Deduplicated list of attention backend classes.
334
+ """
335
+ layer_type = cast(type[Any], AttentionLayerBase)
336
+ layers = get_layers_from_vllm_config(vllm_config, layer_type, layer_names)
337
+ if layers:
338
+ seen: dict[str, type[AttentionBackend]] = {}
339
+ for layer in layers.values():
340
+ backend = layer.get_attn_backend()
341
+ seen[backend.full_cls_name()] = backend
342
+ return list(seen.values())
343
+
344
+ # Fallback for tests, when static_forward_context is empty.
345
+ logger.debug(
346
+ "No layers found in the vLLM config. Falling back to default attention backend."
347
+ )
348
+ from vllm.v1.attention.selector import get_attn_backend
349
+
350
+ return [
351
+ get_attn_backend(
352
+ head_size=vllm_config.model_config.get_head_size(),
353
+ dtype=vllm_config.model_config.dtype,
354
+ kv_cache_dtype=vllm_config.cache_config.cache_dtype,
355
+ use_mla=vllm_config.model_config.use_mla,
356
+ )
357
+ ]
358
+
359
+
360
+def get_current_attn_backend(
361
+ vllm_config: VllmConfig, layer_names: list[str] | None = None
362
+) -> type[AttentionBackend]:
363
+ """Get the first attention backend for the given layers."""
364
+ return get_current_attn_backends(vllm_config, layer_names)[0]
365
+
366
+
367
+# ---- Per-engine transfer info ----
368
+
369
+
370
+@dataclass(frozen=True)
371
+class EngineTransferInfo:
372
+ """Common per-remote-engine transfer state, computed at handshake.
373
+
374
+ Stored per ``engine_id`` inside ``TransferTopology._engines``.
375
"""
325
- Helper class for tensor parallel and KV topology information for
326
- mapping between local and remote TP workers.
376
+
377
+ remote_tp_size: int
378
+
379
+ remote_block_len: int
380
+ """Block length (bytes)"""
381
+
382
+ remote_block_size: int
383
+ """Tokens per block."""
384
+
385
+ remote_physical_blocks_per_logical: int
386
+ """Physical blocks per logical block."""
387
+
388
+
389
+@dataclass(frozen=True)
390
+class MambaEngineTransferInfo(EngineTransferInfo):
391
+ """Extends ``EngineTransferInfo`` with Mamba-hybrid transfer geometry.
392
+
393
+ For hybrid SSM+Attention models, FA and Mamba layers may require
394
+ different numbers of reads from different remote ranks. This
395
+ dataclass captures that per-engine transfer plan.
396
"""
397
398
+ remote_fa_source_ranks: tuple[int, ...]
399
+ """Remote ranks carrying unique FA heads for this local rank."""
400
+
401
+ remote_all_source_ranks: tuple[int, ...]
402
+ """All remote ranks this local rank reads from (FA + Mamba)."""
403
+
404
+ remote_num_fa_reads: int
405
+ """Number of distinct remote ranks needed for FA data."""
406
+
407
+ remote_num_mamba_reads: int
408
+ """Number of distinct remote ranks needed for Mamba data."""
409
+
410
+ remote_fa_descriptor_bytes: int
411
+ """Byte size of one FA K (or V) descriptor entry."""
412
+
413
+ is_remote_replicated: bool
414
+ """Whether the remote engine has replicated KV heads
415
+ (remote_tp_size > total_num_kv_heads)."""
416
+
417
+ remote_physical_heads: int
418
+ """Physical KV heads stored per remote rank."""
419
+
420
+
421
+# ---- Transfer topology ----
422
+
423
+
424
+@dataclass
425
+class TransferTopology:
426
+ """Single source of truth for local TP identity and per-engine remote info."""
427
+
428
tp_rank: int
330
- remote_tp_size: dict[EngineId, int]
429
+ tp_size: int
430
+ block_size: int
431
+ engine_id: EngineId
432
is_mla: bool
433
+ is_mamba: bool
434
total_num_kv_heads: int
435
attn_backends: list[type[AttentionBackend]]
334
- engine_id: EngineId
335
- remote_block_size: dict[EngineId, int]
436
tensor_shape: torch.Size | None = None
337
- is_mamba: bool = False
437
438
def __post_init__(self):
439
+ self.local_physical_heads = max(1, self.total_num_kv_heads // self.tp_size)
440
+
441
+ self._engines: dict[EngineId, EngineTransferInfo] = {}
442
+ self._fa_source_sets: dict[EngineId, frozenset[int]] = {}
443
+ self._fa_source_indices: dict[EngineId, dict[int, int]] = {}
444
+
445
# Figure out whether the first dimension of the cache is K/V
341
- # or num_blocks. This is used to register the memory regions correctly.
446
+ # or num_blocks.
447
attn_backend = self.attn_backends[0]
448
if not self.is_mamba:
449
_MOCK_BLOCK_SIZE = 16
450
kv_cache_shape: tuple[int, ...] = attn_backend.get_kv_cache_shape(
346
- num_blocks=1, block_size=_MOCK_BLOCK_SIZE, num_kv_heads=1, head_size=1
451
+ num_blocks=1,
452
+ block_size=_MOCK_BLOCK_SIZE,
453
+ num_kv_heads=1,
454
+ head_size=1,
455
)
456
logger.debug("Test kv_cache_shape: %s", kv_cache_shape)
457
# Non-MLA backends caches have 5 dims [2, num_blocks, H,N,D],
@@ -358,11 +466,9 @@ class TpKVTopology:
466
self._cross_layers_blocks = (
467
len(self.tensor_shape) == len(kv_cache_shape) + 1
468
)
361
- self.tensor_shape: torch.Size
469
470
if self._cross_layers_blocks:
471
logger.debug("Using cross-layer KV cache")
365
- # prepend layers dimension
472
_MOCK_NUM_LAYERS = 80
473
kv_cache_shape = (_MOCK_NUM_LAYERS,) + kv_cache_shape
474
try:
@@ -372,15 +478,81 @@ class TpKVTopology:
478
except (AttributeError, NotImplementedError):
479
assert self.tensor_shape is not None
480
kv_cache_stride_order = tuple(range(len(self.tensor_shape)))
375
-
376
- # In case of cross layers permute kv_cache_shape according to
377
- # stride_order to retrieve physical position of block_size
481
kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
482
483
+ # ============================================================
484
+ # Engine registration
485
+ # ============================================================
486
+
487
+ def register_remote_engine(
488
+ self,
489
+ remote_engine_id: EngineId,
490
+ remote_tp_size: int,
491
+ remote_block_size: int,
492
+ remote_block_len: int,
493
+ remote_physical_blocks_per_logical: int,
494
+ *,
495
+ local_block_len: int = 0,
496
+ ) -> EngineTransferInfo:
497
+ """Register a remote engine, unifying worker dicts state.
498
+
499
+ Only remote engines should be registered here — the local engine's
500
+ identity (tp_size, block_size, etc.) is set via ``__init__`` params.
501
+
502
+ For Mamba models, also computes the Mamba transfer plan and
503
+ builds the FA source lookup caches.
504
+
505
+ Args:
506
+ local_block_len: Local representative block_len (bytes).
507
+ Required for Mamba models to compute ``fa_descriptor_bytes``.
508
+ """
509
+ assert remote_engine_id != self.engine_id, (
510
+ f"Cannot register local engine {self.engine_id} as remote. "
511
+ f"Local identity is set via __init__ params."
512
+ )
513
+ if remote_engine_id in self._engines:
514
+ return self._engines[remote_engine_id]
515
+ info: EngineTransferInfo
516
+ if self.is_mamba:
517
+ info = self._build_mamba_info(
518
+ remote_tp_size=remote_tp_size,
519
+ remote_block_size=remote_block_size,
520
+ remote_block_len=remote_block_len,
521
+ remote_physical_blocks_per_logical=(remote_physical_blocks_per_logical),
522
+ local_block_len=local_block_len,
523
+ )
524
+ assert isinstance(info, MambaEngineTransferInfo)
525
+ self._fa_source_sets[remote_engine_id] = frozenset(
526
+ info.remote_fa_source_ranks
527
+ )
528
+ self._fa_source_indices[remote_engine_id] = {
529
+ r: i for i, r in enumerate(info.remote_fa_source_ranks)
530
+ }
531
+ else:
532
+ info = EngineTransferInfo(
533
+ remote_tp_size=remote_tp_size,
534
+ remote_block_len=remote_block_len,
535
+ remote_block_size=remote_block_size,
536
+ remote_physical_blocks_per_logical=(remote_physical_blocks_per_logical),
537
+ )
538
+ self._engines[remote_engine_id] = info
539
+ return info
540
+
541
+ def get_engine_info(self, remote_engine_id: EngineId) -> EngineTransferInfo:
542
+ return self._engines[remote_engine_id]
543
+
544
+ # ============================================================
545
+ # Layout properties
546
+ # ============================================================
547
+
548
@property
549
def is_kv_layout_blocks_first(self) -> bool:
550
return self._is_kv_layout_blocks_first
551
552
+ @property
553
+ def cross_layers_blocks(self) -> bool:
554
+ return self._cross_layers_blocks
555
+
556
@property
557
def split_k_and_v(self) -> bool:
558
# Whether to register regions for K and V separately (when present).
@@ -388,29 +560,16 @@ class TpKVTopology:
560
self._cross_layers_blocks or self.is_mla or self.is_kv_layout_blocks_first
561
)
562
391
- @property
392
- def tp_size(self) -> int:
393
- return self.remote_tp_size[self.engine_id]
563
+ # ============================================================
564
+ # Common methods
565
+ # ============================================================
566
395
- @property
396
- def block_size(self) -> int:
397
- return self.remote_block_size[self.engine_id]
567
+ def tp_ratio(self, remote_tp_size: int) -> int:
568
+ """Calculate the tensor parallel ratio between local and remote TP.
569
399
- @property
400
- def cross_layers_blocks(self) -> bool:
401
- return self._cross_layers_blocks
402
-
403
- def tp_ratio(
404
- self,
405
- remote_tp_size: int,
406
- ) -> int:
407
- """
408
- Calculate the tensor parallel ratio between local and remote TP.
409
- We can think of it as the number of local TP workers-per-remote TP
410
- workers. Local workers will read from the same remote TP worker in
411
- groups of size `tp_ratio`.If remote tp_size > local tp_size, the
412
- ratio is flipped (remote_size/local_size) and the returned value is
413
- negative.
570
+ Positive when local_tp >= remote_tp (local workers read from the
571
+ same remote worker in groups of size ``tp_ratio``). Negative when
572
+ remote_tp > local_tp (ratio is flipped).
573
"""
574
if self.tp_size >= remote_tp_size:
575
assert self.tp_size % remote_tp_size == 0, (
@@ -418,78 +577,65 @@ class TpKVTopology:
577
f"by remote tensor parallel size {remote_tp_size}."
578
)
579
return self.tp_size // remote_tp_size
421
-
580
assert remote_tp_size % self.tp_size == 0, (
581
f"Remote tensor parallel size {remote_tp_size} is not divisible "
582
f"by local tensor parallel size {self.tp_size}."
583
)
426
- # P TP > D TP case, return the ratio as negative
427
- return -remote_tp_size // self.tp_size
584
+ return -(remote_tp_size // self.tp_size)
585
429
- def block_size_ratio(
430
- self,
431
- remote_block_size: int,
432
- ) -> int:
433
- """
434
- Calculate the block size ratio between local and remote TP.
435
- """
586
+ def block_size_ratio(self, remote_block_size: int) -> int:
587
+ """Calculate the block size ratio between local and remote."""
588
assert self.block_size % remote_block_size == 0, (
589
f"Local block size {self.block_size} is not divisible "
590
f"by remote block size {remote_block_size} or vice versa."
591
)
592
return self.block_size // remote_block_size
593
442
- def tp_ratio_from_engine_id(
443
- self,
444
- remote_engine_id: EngineId,
445
- ) -> int:
446
- remote_tp_size = self.remote_tp_size[remote_engine_id]
447
- return self.tp_ratio(remote_tp_size)
448
-
449
- def block_size_ratio_from_engine_id(
450
- self,
451
- remote_engine_id: EngineId,
452
- ) -> int:
453
- remote_block_size = self.remote_block_size[remote_engine_id]
454
- return self.block_size_ratio(remote_block_size)
455
-
456
- def is_kv_replicated(self, engine_id: EngineId) -> bool:
457
- """
458
- Whether the KV cache is replicated across TP workers due to the
594
+ def is_kv_replicated(self, remote_engine_id: EngineId) -> bool:
595
+ """Whether the KV cache is replicated across TP workers due to the
596
number of TP workers being greater than the number of KV heads.
460
- When they are equal, each TP rank still owns one distinct KV head,
461
- so this is not considered replication.
597
"""
463
- tp_size = self.remote_tp_size[engine_id]
464
- return tp_size > self.total_num_kv_heads
598
+ return self._engines[remote_engine_id].remote_tp_size > self.total_num_kv_heads
599
600
def replicates_kv_cache(self, remote_engine_id: EngineId) -> bool:
601
# MLA is always replicated as the hidden dim can't be split.
602
return self.is_mla or self.is_kv_replicated(remote_engine_id)
603
470
- def get_target_remote_ranks(
471
- self,
472
- remote_tp_size: int,
473
- ) -> list[int]:
474
- """
475
- Get the remote TP rank (on P) that the current local TP rank
476
- (on D) will read from. When remote tp_size > local tp_size, we
477
- read from multiple remote ranks.
604
+ @property
605
+ def local_replicates_kv_cache(self) -> bool:
606
+ """Whether the local engine's KV cache is replicated."""
607
+ return self.is_mla or self.tp_size > self.total_num_kv_heads
608
+
609
+ def handshake_target_ranks(self, remote_tp_size: int) -> list[int]:
610
+ """Pre-registration: compute which remote TP ranks to handshake with.
611
+
612
+ Pure math based on local/remote TP sizes — does not require
613
+ the remote engine to be registered yet.
614
"""
615
tp_ratio = self.tp_ratio(remote_tp_size)
616
if tp_ratio > 0:
617
return [self.tp_rank // tp_ratio]
618
+ abs_ratio = -tp_ratio
619
+ return [self.tp_rank * abs_ratio + i for i in range(abs_ratio)]
620
483
- # P TP > D TP case, D reads from |tp_ratio| remote workers.
484
- tp_ratio = -tp_ratio
485
- return [self.tp_rank * tp_ratio + i for i in range(tp_ratio)]
621
+ def target_remote_ranks(self, remote_engine_id: EngineId) -> list[int]:
622
+ """Get the remote TP rank(s) that the current local TP rank will
623
+ read from. When remote tp_size > local tp_size, reads from
624
+ multiple remote ranks.
625
487
- def get_target_remote_ranks_from_engine_id(
488
- self,
489
- remote_engine_id: EngineId,
490
- ) -> list[int]:
491
- remote_tp_size = self.remote_tp_size[remote_engine_id]
492
- return self.get_target_remote_ranks(remote_tp_size)
626
+ For Mamba models, returns the precomputed ``all_source_ranks``
627
+ (FA + Mamba union).
628
+ """
629
+ info = self._engines[remote_engine_id]
630
+ if isinstance(info, MambaEngineTransferInfo):
631
+ return list(info.remote_all_source_ranks)
632
+
633
+ tp_ratio = self.tp_ratio(info.remote_tp_size)
634
+ if tp_ratio > 0:
635
+ return [self.tp_rank // tp_ratio]
636
+ # remote TP > local TP: read from |tp_ratio| remote workers
637
+ abs_ratio = -tp_ratio
638
+ return [self.tp_rank * abs_ratio + i for i in range(abs_ratio)]
639
640
def get_transfer_cache_regions(
641
self, cache: torch.Tensor, layer_spec: "KVCacheSpec"
@@ -498,331 +644,139 @@ class TpKVTopology:
644
also accounting for hybrid SSM models specificities.
645
"""
646
if isinstance(layer_spec, MambaSpec):
501
- # Register the whole kv cache shared tensor, including SSM/Conv. This is
502
- # similar to FI with the difference that SSM/Conv have different sizes
647
+ # Register the whole kv cache shared tensor, including
648
+ # SSM/Conv.
649
conv, ssm = cache
650
return [conv]
651
506
- # Check may be hacky but it's matching `_update_hybrid_attention_mamba_layout`.
652
+ # Check may be hacky but it's matching
653
+ # `_update_hybrid_attention_mamba_layout`.
654
if self.is_mamba and cache.shape[0] == 2:
508
- # When MAMBA is present, all backends are blocks first, so that blocks
509
- # can be shared between attention layers and mamba layers. Runner
510
- # `_update_hybrid_attention_mamba_layout` already adjusted strides
511
- # for FlashAttn-like backends so its num_blocks first.
512
- # Swap [2<>num_blocks] dims to get required layout for hybrid SSM.
655
+ # When MAMBA is present, all backends are blocks first, so
656
+ # that blocks can be shared between attention layers and mamba
657
+ # layers. Runner already adjusted strides for FlashAttn-like
658
+ # backends so its num_blocks first.
659
+ # Swap [2<>num_blocks] dims for hybrid SSM layout.
660
cache = cache.transpose(0, 1)
661
662
# Regular case: backends like FA register K/V in separate regions
663
return cache if self.split_k_and_v else [cache]
664
665
+ # ============================================================
666
+ # Mamba-specific methods
667
+ # ============================================================
668
519
-# ---- Mamba-HMA hetero-TP transfer config ----
520
-#
521
-# Key insight: with hetero-TP (P_TP > D_TP), FA KV cache may be
522
-# replicated across P ranks (when P_TP > num_kv_heads), but Mamba
523
-# conv/SSM state is almost always uniquely sharded per P rank. So the
524
-# number of P ranks D must read from can differ between FA and Mamba,
525
-# and they must be handled separately.
526
-
527
-
528
-def _physical_head_range(tp_size: int, num_heads: int, rank: int) -> range:
529
- """Physical KV head range stored in a rank's KV cache tensor.
530
-
531
- When ``tp_size <= num_heads``: sharded, K/TP contiguous heads per rank.
532
- When ``tp_size > num_heads``: 1 physical head per rank. Heads are
533
- distributed **contiguously** (matching vLLM's GQA weight partitioning):
534
- consecutive ranks share a head before moving to the next one.
535
- """
536
- if tp_size <= num_heads:
537
- assert num_heads % tp_size == 0
538
- per_rank = num_heads // tp_size
539
- return range(rank * per_rank, (rank + 1) * per_rank)
540
- else:
541
- h = rank * num_heads // tp_size
542
- return range(h, h + 1)
543
-
544
-
545
-def _range_overlap(a: range, b: range) -> range:
546
- start = max(a.start, b.start)
547
- stop = min(a.stop, b.stop)
548
- return range(start, max(start, stop))
549
-
669
+ def should_skip_fa(self, remote_engine_id: EngineId, remote_rank: int) -> bool:
670
+ """Whether to skip FA groups for this remote rank (mamba-only)."""
671
+ return remote_rank not in self._fa_source_sets[remote_engine_id]
672
551
-@dataclass
552
-class HeteroTPTransferConfig:
553
- """Precomputed transfer plan for one (D rank, P engine) pair.
554
-
555
- Currently only instantiated for Mamba-HMA (hybrid SSM+Attention) models
556
- where FA and mamba require different splitting factors. Could be extended
557
- to other model types that need non-uniform hetero-TP transfer sizing.
558
-
559
- All descriptor sizes are computed here. The guarantee is:
560
- local_entry_size == remote_entry_size (for NIXL)
561
-
562
- Attributes that start with ``fa_`` concern FlashAttention KV cache.
563
- Attributes that start with ``mamba_`` concern Mamba conv/SSM state.
564
- """
565
-
566
- # ---- Input parameters (from handshake) ----
567
- tp_ratio: int
568
- K: int # total_num_kv_heads (before TP sharding)
569
- d_tp: int # D engine's tensor_parallel_size
570
- p_tp: int # P engine's tensor_parallel_size
571
- d_rank: int # this D worker's TP rank
572
- use_mla: bool
573
-
574
- # Per-layer block lengths (bytes, K+V combined for blocks_first).
575
- # Uniform across layers for current models.
576
- d_block_len: int # D's block_len_per_layer (representative)
577
- p_block_len: int # P's block_len_per_layer (from handshake)
578
- is_blocks_first: bool # kv_topo.is_kv_layout_blocks_first
579
-
580
- # ---- Derived: computed in __post_init__ ----
581
- #
582
- # Physical heads per rank (what the KV tensor actually stores)
583
- d_physical_heads: int = field(init=False)
584
- p_physical_heads: int = field(init=False)
585
-
586
- # How many distinct P ranks D needs for FA data
587
- physical_fa_num_reads: int = field(init=False)
588
-
589
- # Which P ranks contribute unique FA heads (ordered by head index)
590
- fa_read_targets: list[int] = field(init=False)
591
-
592
- # All P ranks needed for mamba (always abs_tp for tp_ratio < 0)
593
- mamba_num_reads: int = field(init=False)
594
-
595
- # All P ranks this D rank communicates with (FA ∪ mamba)
596
- transfer_targets: list[int] = field(init=False)
597
-
598
- # FA descriptor entry size (K or V side, for blocks_first layout)
599
- # Guaranteed: fa_entry_size is the SAME for local handle AND remote desc.
600
- fa_entry_size: int = field(init=False)
601
-
602
- # Replication flags
603
- is_d_replicated: bool = field(init=False)
604
- is_p_replicated: bool = field(init=False)
605
-
606
- # Pre-built set for fast lookup
607
- _fa_target_set: frozenset[int] = field(init=False, repr=False)
608
- # Map: P rank → index in fa_read_targets (for head slot offset)
609
- _fa_target_index: dict[int, int] = field(init=False, repr=False)
610
-
611
- def __post_init__(self) -> None:
612
- K = self.K
613
- self.is_d_replicated = self.d_tp > K
614
- self.is_p_replicated = self.p_tp > K
615
-
616
- self.d_physical_heads = max(1, K // self.d_tp)
617
- self.p_physical_heads = max(1, K // self.p_tp)
618
-
619
- abs_tp = -self.tp_ratio if self.tp_ratio < 0 else 1
620
-
621
- # ---- Mamba range (computed first so FA can prefer ranks in it) ----
622
- mamba_range: range | None = None
623
- if self.tp_ratio < 0:
624
- mamba_range = range(self.d_rank * abs_tp, (self.d_rank + 1) * abs_tp)
625
-
626
- # ---- FA read targets ----
627
- if self.use_mla or self.tp_ratio >= 0:
628
- self.physical_fa_num_reads = 1
629
- self.fa_read_targets = (
630
- [0]
631
- if self.use_mla
632
- # Must match kv_topo.get_target_remote_ranks (d_rank // tp_ratio).
633
- else [
634
- self.d_rank // self.tp_ratio if self.tp_ratio > 0 else self.d_rank
635
- ]
636
- )
637
- else:
638
- d_needs = _physical_head_range(self.d_tp, K, self.d_rank)
639
- # When mamba range exists, prefer P ranks within it so that
640
- # FA targets are a subset of mamba transfer_targets (avoids
641
- # orphaned FA targets outside the transfer loop).
642
- search_range = mamba_range if mamba_range is not None else range(self.p_tp)
643
- seen: set[tuple[int, int]] = set()
644
- targets: list[int] = []
645
- for p in search_range:
646
- p_has = _physical_head_range(self.p_tp, K, p)
647
- ov = _range_overlap(d_needs, p_has)
648
- if len(ov) > 0:
649
- key = (ov.start, ov.stop)
650
- if key not in seen:
651
- seen.add(key)
652
- targets.append(p)
653
- if not targets:
654
- # Fallback: search globally (should not happen in practice)
655
- for p in range(self.p_tp):
656
- p_has = _physical_head_range(self.p_tp, K, p)
657
- ov = _range_overlap(d_needs, p_has)
658
- if len(ov) > 0:
659
- key = (ov.start, ov.stop)
660
- if key not in seen:
661
- seen.add(key)
662
- targets.append(p)
663
- self.fa_read_targets = targets
664
- self.physical_fa_num_reads = len(targets)
665
-
666
- self._fa_target_set = frozenset(self.fa_read_targets)
667
- self._fa_target_index = {r: i for i, r in enumerate(self.fa_read_targets)}
668
-
669
- # ---- Mamba targets ----
670
- if mamba_range is not None and abs_tp > self.physical_fa_num_reads:
671
- self.mamba_num_reads = abs_tp
672
- self.transfer_targets = list(mamba_range)
673
- else:
674
- self.mamba_num_reads = self.physical_fa_num_reads
675
- self.transfer_targets = list(self.fa_read_targets)
676
-
677
- # ---- FA entry size ----
678
- # For blocks_first: block_len_per_layer includes K+V; // 2 gives K (or V).
679
- # Use min(D, P) because D indexes into P when tp_ratio > 0,
680
- # and P is the natural unit when tp_ratio < 0.
681
- effective_block_len = min(self.d_block_len, self.p_block_len)
682
- if self.is_blocks_first:
683
- self.fa_entry_size = effective_block_len // 2
684
- else:
685
- self.fa_entry_size = effective_block_len
686
-
687
- self._validate()
688
-
689
- def _validate(self) -> None:
690
- """Cross-check internal consistency."""
691
- if self.is_d_replicated and self.is_p_replicated and self.tp_ratio > 0:
692
- logger.info(
693
- "Both-replicated hetero-TP: D_TP=%d > P_TP=%d > K=%d. "
694
- "Using d_rank // tp_ratio routing with relative head offset.",
695
- self.d_tp,
696
- self.p_tp,
697
- self.K,
698
- )
699
-
700
- # FA targets must be a subset of transfer_targets
701
- tt_set = set(self.transfer_targets)
702
- for t in self.fa_read_targets:
703
- if t not in tt_set:
704
- logger.error(
705
- "FA target P rank %d is NOT in transfer_targets %s. "
706
- "This will cause missed FA reads!",
707
- t,
708
- self.transfer_targets,
709
- )
710
-
711
- # For tp_ratio < 0 with blocks_first: D_K_half / reads should == P_K_half
712
- if (
713
- self.is_blocks_first
714
- and self.tp_ratio < 0
715
- and self.physical_fa_num_reads > 0
716
- ):
717
- d_k_half = self.d_block_len // 2
718
- p_k_half = self.p_block_len // 2
719
- expected_local = d_k_half // self.physical_fa_num_reads
720
- if expected_local != p_k_half:
721
- logger.warning(
722
- "FA size mismatch: D_K_half=%d / reads=%d = %d, "
723
- "but P_K_half=%d. This may indicate a head count or "
724
- "Mamba-HMA inflation inconsistency.",
725
- d_k_half,
726
- self.physical_fa_num_reads,
727
- expected_local,
728
- p_k_half,
729
- )
673
+ def fa_head_slot(self, remote_engine_id: EngineId, remote_rank: int) -> int:
674
+ """Index into local FA block for this remote rank's head data.
675
731
- # ---- Query methods ----
732
-
733
- def should_skip_fa(self, p_rank: int) -> bool:
734
- """Whether to skip FA groups for this P rank (mamba-only transfer)."""
735
- return p_rank not in self._fa_target_set
736
-
737
- def fa_head_slot(self, p_rank: int) -> int:
738
- """Index into D's FA block for this P rank's head data.
739
-
740
- For P ranks in fa_read_targets, returns 0, 1, ..., reads-1.
741
- For P ranks NOT in fa_read_targets (replicated duplicates),
742
- returns the slot of the matching FA target with the same head.
676
+ For remote ranks in ``fa_source_ranks``, returns 0, 1, …, reads-1.
677
+ For ranks NOT in ``fa_source_ranks`` (replicated duplicates),
678
+ returns the slot of the matching source rank with the same head.
679
"""
744
- if p_rank in self._fa_target_index:
745
- return self._fa_target_index[p_rank]
746
- # Duplicate head: find which fa_target has the same physical head
747
- p_head = _physical_head_range(self.p_tp, self.K, p_rank)
748
- for target in self.fa_read_targets:
749
- t_head = _physical_head_range(self.p_tp, self.K, target)
750
- if _range_overlap(p_head, t_head):
751
- return self._fa_target_index[target]
752
- return 0 # fallback
753
-
754
- def fa_rank_offset(self, remote_kv_block_len: int) -> int:
755
- """Byte offset into P's FA block for this D rank.
756
-
757
- When D is replicated (D_TP > K), multiple D ranks share a head.
758
- Computes offset *relative to the target P rank's first head*
759
- so it works regardless of how many heads P has.
760
- When neither side replicates, falls back to tp_rank % tp_ratio.
761
- Returns 0 when D does not index into P's block.
680
+ fa_index = self._fa_source_indices[remote_engine_id]
681
+ if remote_rank in fa_index:
682
+ return fa_index[remote_rank]
683
+ mamba_info = self._engines[remote_engine_id]
684
+ assert isinstance(mamba_info, MambaEngineTransferInfo)
685
+ K = self.total_num_kv_heads
686
+ remote_tp = mamba_info.remote_tp_size
687
+ r_head = self._physical_head_range(remote_tp, K, remote_rank)
688
+ for target in mamba_info.remote_fa_source_ranks:
689
+ t_head = self._physical_head_range(remote_tp, K, target)
690
+ if self._range_overlap(r_head, t_head):
691
+ return fa_index[target]
692
+ return 0
693
+
694
+ def fa_rank_offset(
695
+ self, remote_engine_id: EngineId, remote_kv_block_len: int
696
+ ) -> int:
697
+ """Byte offset into remote FA block for this local rank.
698
+
699
+ When local TP is replicated (local_tp > K), multiple local ranks
700
+ share a head. Computes offset *relative to the target remote
701
+ rank's first head* so it works regardless of how many heads the
702
+ remote has. Returns 0 when local does not index into remote.
703
"""
763
- if self.use_mla or self.tp_ratio <= 0:
704
+ mamba_info = self._engines[remote_engine_id]
705
+ assert isinstance(mamba_info, MambaEngineTransferInfo)
706
+ tp_ratio = self.tp_ratio(mamba_info.remote_tp_size)
707
+ if self.is_mla or tp_ratio <= 0:
708
return 0
765
- if self.is_d_replicated:
766
- d_head = self.d_rank * self.K // self.d_tp
767
- p_rank = self.fa_read_targets[0]
768
- p_start = p_rank * self.K // self.p_tp
769
- return (d_head - p_start) * remote_kv_block_len
770
- return self.d_rank % self.tp_ratio * remote_kv_block_len
771
-
772
- @property
773
- def needs_split_handles(self) -> bool:
774
- """Whether per-P-rank split handles are needed.
709
+ K = self.total_num_kv_heads
710
+ is_local_replicated = self.tp_size > K
711
+ if is_local_replicated:
712
+ local_head = self.tp_rank * K // self.tp_size
713
+ p_rank = mamba_info.remote_fa_source_ranks[0]
714
+ p_start = p_rank * K // mamba_info.remote_tp_size
715
+ return (local_head - p_start) * remote_kv_block_len
716
+ return self.tp_rank % tp_ratio * remote_kv_block_len
717
+
718
+ def needs_split_handles(self, remote_engine_id: EngineId) -> bool:
719
+ """Whether per-remote-rank split handles are needed.
720
721
True when FA and mamba have different read counts, requiring
722
different splitting factors in the local handle.
723
"""
779
- return self.tp_ratio < 0 and not self.use_mla and len(self.transfer_targets) > 1
724
+ mamba_info = self._engines[remote_engine_id]
725
+ assert isinstance(mamba_info, MambaEngineTransferInfo)
726
+ tp_ratio = self.tp_ratio(mamba_info.remote_tp_size)
727
+ return (
728
+ tp_ratio < 0
729
+ and not self.is_mla
730
+ and len(mamba_info.remote_all_source_ranks) > 1
731
+ )
732
733
def compute_split_handle_data(
734
self,
735
+ remote_engine_id: EngineId,
736
src_blocks_data: list[tuple[int, int, int]],
737
num_fa_descs: int,
738
abs_tp: int,
739
) -> list[list[tuple[int, int, int]]]:
787
- """Compute per-P-rank (addr, len, tp) triples for Mamba-HMA split handles.
740
+ """Per-remote-rank (addr, len, dev) triples for Mamba-HMA split
741
+ handles.
742
743
FA descriptors (indices < num_fa_descs) are sliced by
790
- ``physical_fa_num_reads``; mamba descriptors are sliced uniformly
744
+ ``remote_num_fa_reads``; mamba descriptors are sliced uniformly
745
by ``abs_tp``.
792
-
793
- Returns one list of triples per transfer target.
746
"""
747
+ mamba_info = self._engines[remote_engine_id]
748
+ assert isinstance(mamba_info, MambaEngineTransferInfo)
749
all_handle_data: list[list[tuple[int, int, int]]] = []
796
- for p_idx, p_rank in enumerate(self.transfer_targets):
750
+ for p_idx, p_rank in enumerate(mamba_info.remote_all_source_ranks):
751
handle_data: list[tuple[int, int, int]] = []
798
- skip_fa = self.should_skip_fa(p_rank)
799
- fa_slot = self.fa_head_slot(p_rank) if not skip_fa else 0
800
-
801
- for j, (addr, local_len, tp) in enumerate(src_blocks_data):
752
+ skip_fa = self.should_skip_fa(remote_engine_id, p_rank)
753
+ fa_slot = self.fa_head_slot(remote_engine_id, p_rank) if not skip_fa else 0
754
+ for j, (addr, local_len, dev) in enumerate(src_blocks_data):
755
if j < num_fa_descs:
803
- assert self.physical_fa_num_reads >= 1
804
- fa_chunk = local_len // self.physical_fa_num_reads
805
- handle_data.append((addr + fa_slot * fa_chunk, fa_chunk, tp))
756
+ assert mamba_info.remote_num_fa_reads >= 1
757
+ fa_chunk = local_len // mamba_info.remote_num_fa_reads
758
+ handle_data.append((addr + fa_slot * fa_chunk, fa_chunk, dev))
759
else:
760
mamba_chunk = local_len // abs_tp
808
- handle_data.append((addr + p_idx * mamba_chunk, mamba_chunk, tp))
761
+ handle_data.append((addr + p_idx * mamba_chunk, mamba_chunk, dev))
762
all_handle_data.append(handle_data)
763
return all_handle_data
764
765
def filter_block_ids_for_rank(
766
self,
767
+ remote_engine_id: EngineId,
768
remote_rank: int,
769
local_ids: BlockIds,
770
remote_ids: BlockIds,
771
is_mamba_group: list[bool],
772
) -> tuple[BlockIds, BlockIds]:
819
- """Zero out FA groups for P ranks outside fa_read_targets.
773
+ """Zero out FA groups for remote ranks outside ``fa_source_ranks``.
774
775
Returns (filtered_local_ids, filtered_remote_ids). When the
822
- remote rank carries FA data for this D rank, returns the inputs
823
- unchanged.
776
+ remote rank carries FA data for this local rank, returns the
777
+ inputs unchanged.
778
"""
825
- if not self.should_skip_fa(remote_rank):
779
+ if not self.should_skip_fa(remote_engine_id, remote_rank):
780
return local_ids, remote_ids
781
num_groups = len(local_ids)
782
filtered_local: list[list[int]] = [
@@ -833,108 +787,184 @@ class HeteroTPTransferConfig:
787
]
788
return filtered_local, filtered_remote
789
836
- def describe(self) -> str:
837
- """One-line summary for logging."""
838
- return (
839
- f"HeteroTPTransferConfig("
840
- f"tp_ratio={self.tp_ratio}, K={self.K}, "
841
- f"d_tp={self.d_tp}, p_tp={self.p_tp}, d_rank={self.d_rank}, "
842
- f"physical_fa_reads={self.physical_fa_num_reads}, "
843
- f"mamba_reads={self.mamba_num_reads}, "
844
- f"fa_targets={self.fa_read_targets}, "
845
- f"transfer_targets={self.transfer_targets}, "
846
- f"fa_entry_size={self.fa_entry_size}, "
847
- f"d_block_len={self.d_block_len}, p_block_len={self.p_block_len})"
790
+ def describe(self, remote_engine_id: EngineId) -> str:
791
+ """One-line summary of transfer config for logging."""
792
+ info = self._engines[remote_engine_id]
793
+ base = (
794
+ f"tp_ratio={self.tp_ratio(info.remote_tp_size)}, "
795
+ f"K={self.total_num_kv_heads}, "
796
+ f"local_tp={self.tp_size}, "
797
+ f"remote_tp={info.remote_tp_size}, "
798
+ f"local_rank={self.tp_rank}, "
799
+ f"remote_block_len={info.remote_block_len}"
800
)
801
+ if isinstance(info, MambaEngineTransferInfo):
802
+ return (
803
+ f"TransferTopology.mamba({base}, "
804
+ f"fa_reads={info.remote_num_fa_reads}, "
805
+ f"mamba_reads={info.remote_num_mamba_reads}, "
806
+ f"fa_sources={list(info.remote_fa_source_ranks)}, "
807
+ f"all_sources={list(info.remote_all_source_ranks)}, "
808
+ f"fa_desc_bytes={info.remote_fa_descriptor_bytes})"
809
+ )
810
+ return f"TransferTopology({base})"
811
+
812
+ # ============================================================
813
+ # Private helpers
814
+ # ============================================================
815
+ # Mamba-HMA hetero-TP transfer config:
816
+ # With hetero-TP (P_TP > D_TP), FA KV cache may be replicated across
817
+ # P ranks (when P_TP > num_kv_heads), but Mamba conv/SSM state is
818
+ # almost always uniquely sharded per P rank. So the number of P
819
+ # ranks D must read from can differ between FA and Mamba, and they
820
+ # must be handled separately.
821
+
822
+ @staticmethod
823
+ def _physical_head_range(tp_size: int, num_heads: int, rank: int) -> range:
824
+ """Physical KV head range stored in a rank's KV cache tensor.
825
+
826
+ When ``tp_size <= num_heads``: sharded, K/TP contiguous heads per rank.
827
+ When ``tp_size > num_heads``: 1 physical head per rank. Heads are
828
+ distributed **contiguously** (matching vLLM's GQA weight partitioning):
829
+ consecutive ranks share a head before moving to the next one.
830
+ """
831
+ if tp_size <= num_heads:
832
+ assert num_heads % tp_size == 0
833
+ per_rank = num_heads // tp_size
834
+ return range(rank * per_rank, (rank + 1) * per_rank)
835
+ else:
836
+ h = rank * num_heads // tp_size
837
+ return range(h, h + 1)
838
839
+ @staticmethod
840
+ def _range_overlap(a: range, b: range) -> range:
841
+ start = max(a.start, b.start)
842
+ stop = min(a.stop, b.stop)
843
+ return range(start, max(start, stop))
844
851
-def get_current_attn_backends(
852
- vllm_config: VllmConfig, layer_names: list[str] | None = None
853
-) -> list[type[AttentionBackend]]:
854
- """Get all distinct attention backends for the given layers.
845
+ # ============================================================
846
+ # Private: build Mamba transfer info
847
+ # ============================================================
848
856
- Args:
857
- vllm_config: The current vLLM configuration.
858
- layer_names: Optional list of layer names to scope the lookup.
859
- When None, all attention layers are considered.
849
+ def _build_mamba_info(
850
+ self,
851
+ remote_tp_size: int,
852
+ remote_block_size: int,
853
+ remote_block_len: int,
854
+ remote_physical_blocks_per_logical: int,
855
+ local_block_len: int,
856
+ ) -> MambaEngineTransferInfo:
857
+ """Compute Mamba transfer plan."""
858
+ K = self.total_num_kv_heads
859
+ local_tp = self.tp_size
860
+ local_rank = self.tp_rank
861
+
862
+ is_remote_replicated = remote_tp_size > K
863
+ remote_physical_heads = max(1, K // remote_tp_size)
864
+
865
+ if local_tp >= remote_tp_size:
866
+ assert local_tp % remote_tp_size == 0
867
+ tp_ratio = local_tp // remote_tp_size
868
+ else:
869
+ assert remote_tp_size % local_tp == 0
870
+ tp_ratio = -(remote_tp_size // local_tp)
871
861
- Returns:
862
- Deduplicated list of attention backend classes.
863
- """
864
- layer_type = cast(type[Any], AttentionLayerBase)
865
- layers = get_layers_from_vllm_config(vllm_config, layer_type, layer_names)
866
- if layers:
867
- seen: dict[str, type[AttentionBackend]] = {}
868
- for layer in layers.values():
869
- backend = layer.get_attn_backend()
870
- seen[backend.full_cls_name()] = backend
871
- return list(seen.values())
872
+ abs_tp = -tp_ratio if tp_ratio < 0 else 1
873
873
- # Fallback for tests, when static_forward_context is empty.
874
- logger.debug(
875
- "No layers found in the vLLM config. Falling back to default attention backend."
876
- )
877
- from vllm.v1.attention.selector import get_attn_backend
874
+ mamba_range: range | None = None
875
+ if tp_ratio < 0:
876
+ mamba_range = range(local_rank * abs_tp, (local_rank + 1) * abs_tp)
877
879
- return [
880
- get_attn_backend(
881
- head_size=vllm_config.model_config.get_head_size(),
882
- dtype=vllm_config.model_config.dtype,
883
- kv_cache_dtype=vllm_config.cache_config.cache_dtype,
884
- use_mla=vllm_config.model_config.use_mla,
885
- )
886
- ]
878
+ # ---- FA read targets ----
879
+ if self.is_mla or tp_ratio >= 0:
880
+ num_fa_reads = 1
881
+ fa_source_ranks: list[int] = (
882
+ [0]
883
+ if self.is_mla
884
+ else [local_rank // tp_ratio if tp_ratio > 0 else local_rank]
885
+ )
886
+ else:
887
+ local_needs = self._physical_head_range(local_tp, K, local_rank)
888
+ search_range = (
889
+ mamba_range if mamba_range is not None else range(remote_tp_size)
890
+ )
891
+ seen: set[tuple[int, int]] = set()
892
+ fa_source_ranks = []
893
+ for p in search_range:
894
+ p_has = self._physical_head_range(remote_tp_size, K, p)
895
+ ov = self._range_overlap(local_needs, p_has)
896
+ if len(ov) > 0:
897
+ key = (ov.start, ov.stop)
898
+ if key not in seen:
899
+ seen.add(key)
900
+ fa_source_ranks.append(p)
901
+ if not fa_source_ranks:
902
+ for p in range(remote_tp_size):
903
+ p_has = self._physical_head_range(remote_tp_size, K, p)
904
+ ov = self._range_overlap(local_needs, p_has)
905
+ if len(ov) > 0:
906
+ key = (ov.start, ov.stop)
907
+ if key not in seen:
908
+ seen.add(key)
909
+ fa_source_ranks.append(p)
910
+ num_fa_reads = len(fa_source_ranks)
911
912
+ # ---- All source ranks (mamba + FA) ----
913
+ if mamba_range is not None and abs_tp > num_fa_reads:
914
+ num_mamba_reads = abs_tp
915
+ all_source_ranks = list(mamba_range)
916
+ else:
917
+ num_mamba_reads = num_fa_reads
918
+ all_source_ranks = list(fa_source_ranks)
919
889
-def get_current_attn_backend(
890
- vllm_config: VllmConfig, layer_names: list[str] | None = None
891
-) -> type[AttentionBackend]:
892
- """Get the first attention backend for the given layers."""
893
- return get_current_attn_backends(vllm_config, layer_names)[0]
920
+ # ---- FA descriptor bytes ----
921
+ effective_block_len = min(local_block_len, remote_block_len)
922
+ if self.is_kv_layout_blocks_first:
923
+ fa_descriptor_bytes = effective_block_len // 2
924
+ else:
925
+ fa_descriptor_bytes = effective_block_len
926
927
+ # ---- Validation ----
928
+ is_local_replicated = local_tp > K
929
+ if is_local_replicated and is_remote_replicated and tp_ratio > 0:
930
+ logger.info(
931
+ "Both-replicated hetero-TP: local_tp=%d > remote_tp=%d > K=%d.",
932
+ local_tp,
933
+ remote_tp_size,
934
+ K,
935
+ )
936
+ tt_set = set(all_source_ranks)
937
+ for t in fa_source_ranks:
938
+ if t not in tt_set:
939
+ logger.error(
940
+ "FA source rank %d NOT in all_source_ranks %s.",
941
+ t,
942
+ all_source_ranks,
943
+ )
944
+ if self.is_kv_layout_blocks_first and tp_ratio < 0 and num_fa_reads > 0:
945
+ local_k_half = local_block_len // 2
946
+ remote_k_half = remote_block_len // 2
947
+ expected = local_k_half // num_fa_reads
948
+ if expected != remote_k_half:
949
+ logger.warning(
950
+ "FA size mismatch: local_k_half=%d / reads=%d = %d, "
951
+ "but remote_k_half=%d.",
952
+ local_k_half,
953
+ num_fa_reads,
954
+ expected,
955
+ remote_k_half,
956
+ )
957
896
-# TODO (ZhanqiuHu): Consolidate TpKVTopology and HeteroTPTransferConfig
897
-# into a single engine-agnostic TransferTopology class.
898
-# 6 of 9 HeteroTPTransferConfig init fields duplicate TpKVTopology data.
899
-#
900
-# @dataclass
901
-# class EngineTransferInfo:
902
-# """Per-remote-engine transfer state, computed at handshake."""
903
-# p_tp: int
904
-# tp_ratio: int
905
-# p_block_len: int
906
-# block_size: int
907
-# # Mamba-specific (None for non-mamba models)
908
-# fa_read_targets: list[int] | None = None
909
-# transfer_targets: list[int] | None = None
910
-# physical_fa_num_reads: int | None = None
911
-# mamba_num_reads: int | None = None
912
-# fa_entry_size: int | None = None
913
-#
914
-# class TransferTopology:
915
-# """Single source of truth for TP topology + transfer sizing."""
916
-# # Shared (set once at init, replaces duplicate fields)
917
-# tp_rank: int # == TpKVTopology.tp_rank == HeteroTP.d_rank
918
-# tp_size: int # == TpKVTopology.tp_size == HeteroTP.d_tp
919
-# total_num_kv_heads: int # == HeteroTP.K
920
-# is_mla: bool # == HeteroTP.use_mla
921
-# is_mamba: bool
922
-# is_blocks_first: bool # == HeteroTP.is_blocks_first
923
-# d_block_len: int
924
-#
925
-# # Per-engine (populated via register_engine() at handshake)
926
-# _engines: dict[EngineId, EngineTransferInfo]
927
-#
928
-# def register_engine(self, engine_id, p_tp, p_block_len, ...): ...
929
-#
930
-# # General (from TpKVTopology)
931
-# def tp_ratio(self, engine_id) -> int: ...
932
-# def target_remote_ranks(self, engine_id) -> list[int]: ...
933
-# def is_kv_replicated(self, engine_id) -> bool: ...
934
-#
935
-# # Mamba-specific (from HeteroTPTransferConfig, gated by is_mamba)
936
-# def fa_rank_offset(self, engine_id, block_len) -> int: ...
937
-# def physical_fa_num_reads(self, engine_id) -> int: ...
938
-# def transfer_targets(self, engine_id) -> list[int]: ...
939
-# def should_skip_fa(self, engine_id, p_rank) -> bool: ...
940
-# def filter_block_ids_for_rank(self, engine_id, ...) -> ...: ...
958
+ return MambaEngineTransferInfo(
959
+ remote_tp_size=remote_tp_size,
960
+ remote_block_len=remote_block_len,
961
+ remote_block_size=remote_block_size,
962
+ remote_physical_blocks_per_logical=(remote_physical_blocks_per_logical),
963
+ remote_fa_source_ranks=tuple(fa_source_ranks),
964
+ remote_all_source_ranks=tuple(all_source_ranks),
965
+ remote_num_fa_reads=num_fa_reads,
966
+ remote_num_mamba_reads=num_mamba_reads,
967
+ remote_fa_descriptor_bytes=fa_descriptor_bytes,
968
+ is_remote_replicated=is_remote_replicated,
969
+ remote_physical_heads=remote_physical_heads,
970
+ )
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py
+11
-11
@@ -21,7 +21,7 @@ from vllm import envs
21
from vllm.config import VllmConfig
22
from vllm.distributed.kv_transfer.kv_connector.utils import (
23
EngineId,
24
- TpKVTopology,
24
+ TransferTopology,
25
get_current_attn_backend,
26
get_current_attn_backends,
27
)
@@ -764,13 +764,13 @@ class MooncakeConnectorWorker:
764
logger.debug("Detected kv cache layout %s", self.kv_cache_layout)
765
766
self._tp_size: dict[EngineId, int] = {self.engine_id: self.tp_size}
767
- self._block_size: dict[EngineId, int] = {self.engine_id: self.block_size}
768
- self.kv_topo = TpKVTopology(
767
+ self.transfer_topo = TransferTopology(
768
tp_rank=self.tp_rank,
769
+ tp_size=self.tp_size,
770
+ block_size=self.block_size,
771
engine_id=self.engine_id,
771
- remote_tp_size=self._tp_size, # shared state
772
- remote_block_size=self._block_size, # shared state
772
is_mla=self.use_mla,
773
+ is_mamba=False,
774
total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
775
attn_backends=[backend],
776
)
@@ -911,7 +911,7 @@ class MooncakeConnectorWorker:
911
self, identity: bytes, sock: zmq.asyncio.Socket, meta: MooncakeXferMetadata
912
):
913
pending_reqs: dict[ReqId, SendBlockMeta] = {}
914
- remote_tp_ranks = self.kv_topo.get_target_remote_ranks(meta.remote_tp_size)
914
+ remote_tp_ranks = self.transfer_topo.handshake_target_ranks(meta.remote_tp_size)
915
if meta.remote_tp_rank not in remote_tp_ranks:
916
# This D worker does not pair with the P worker.
917
msg = (
@@ -1256,7 +1256,7 @@ class MooncakeConnectorWorker:
1256
seen_base_addresses = []
1257
self.block_len_per_layer = []
1258
1259
- split_k_and_v = self.kv_topo.split_k_and_v
1259
+ split_k_and_v = self.transfer_topo.split_k_and_v
1260
tensor_size_bytes = None
1261
for layer_name, cache_or_caches in kv_caches.items():
1262
cache_list = cache_or_caches if split_k_and_v else [cache_or_caches]
@@ -1495,8 +1495,8 @@ class MooncakeConnectorWorker:
1495
remote_engine_id: EngineId,
1496
pull_metas: dict[ReqId, PullReqMeta],
1497
):
1498
- remote_tp_ranks = self.kv_topo.get_target_remote_ranks_from_engine_id(
1499
- remote_engine_id
1498
+ remote_tp_ranks = self.transfer_topo.handshake_target_ranks(
1499
+ self._tp_size[remote_engine_id]
1500
)
1501
count = len(remote_tp_ranks)
1502
logger.debug(
@@ -1587,7 +1587,7 @@ class MooncakeConnectorWorker:
1587
)
1588
1589
def _producer_cache_is_replicated(self) -> bool:
1590
- return self.kv_topo.replicates_kv_cache(self.engine_id)
1590
+ return self.transfer_topo.local_replicates_kv_cache
1591
1592
def _get_transfer_regions(
1593
self, base_addrs: list[int], block_lens: list[int]
@@ -1595,7 +1595,7 @@ class MooncakeConnectorWorker:
1595
return _expand_transfer_regions(
1596
base_addrs=base_addrs,
1597
block_lens=block_lens,
1598
- is_kv_layout_blocks_first=self.kv_topo.is_kv_layout_blocks_first,
1598
+ is_kv_layout_blocks_first=self.transfer_topo.is_kv_layout_blocks_first,
1599
)
1600
1601
def _get_sender_transfer_plan(
vllm/distributed/kv_transfer/kv_connector/v1/nixl/worker.py
+118
-121
@@ -21,8 +21,8 @@ from vllm import envs
21
from vllm.distributed.kv_transfer.kv_connector.utils import (
22
BlockIds,
23
EngineId,
24
- HeteroTPTransferConfig,
25
- TpKVTopology,
24
+ MambaEngineTransferInfo,
25
+ TransferTopology,
26
get_current_attn_backends,
27
kv_postprocess_blksize_and_layout_on_receive,
28
kv_postprocess_blksize_on_receive,
@@ -49,7 +49,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.utils import (
49
)
50
from vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils import (
51
MambaConvSplitInfo,
52
- compute_mamba_phys_ratio,
52
+ compute_physical_blocks_per_logical,
53
derive_mamba_conv_split,
54
)
55
from vllm.distributed.nixl_utils import NixlWrapper, nixl_agent_config
@@ -269,14 +269,12 @@ class NixlConnectorWorker:
269
self._registered_descs: list[Any] = []
270
271
# ---- Mamba-HMA per-engine state (only used when self._has_mamba) ----
272
- # Per-engine transfer config (source of truth for FA/mamba sizing).
273
- self._transfer_configs: dict[str, HeteroTPTransferConfig] = {}
274
- # NOTE (ZhanqiuHu): _mamba_phys_ratio MUST be per-engine.
275
- # compute_mamba_phys_ratio = ceil((conv_bytes + ssm_bytes) / block_len)
272
+ # NOTE (ZhanqiuHu): _physical_blocks_per_logical MUST be per-engine.
273
+ # physical_blocks_per_logical = ceil((conv_bytes + ssm_bytes) / block_len)
274
# where conv/ssm bytes are per-TP-rank (dimension-sharded). With
275
# heterogeneous TP the per-rank sizes differ, so the ratio differs:
276
# e.g. Nemotron 30B: P(TP=4) → 131, D(TP=1) → 261.
279
- self._mamba_phys_ratio: dict[EngineId, int] = {}
277
+ self._physical_blocks_per_logical: dict[EngineId, int] = {}
278
279
# In progress transfers.
280
# [req_id -> list[handle]]
@@ -322,10 +320,8 @@ class NixlConnectorWorker:
320
321
# lazy initialized in register_kv_caches
322
self.compat_hash: str | None = None
325
- self.kv_topo: TpKVTopology | None = None
323
+ self.transfer_topo: TransferTopology | None = None
324
327
- self._tp_size: dict[EngineId, int] = {self.engine_id: self.world_size}
328
- self._block_size: dict[EngineId, int] = {self.engine_id: self.block_size}
325
# With heterogeneous TP, P must wait for all assigned D TP workers to
326
# finish reading before safely freeing the blocks.
327
self.consumer_notification_counts_by_req = defaultdict[ReqId, int](int)
@@ -355,7 +351,6 @@ class NixlConnectorWorker:
351
self.block_size // kernel_block_size
352
)
353
self.block_size = kernel_block_size
358
- self._block_size[self.engine_id] = kernel_block_size
354
self.num_blocks *= self._physical_blocks_per_logical_kv_block
355
356
def _nixl_handshake(
@@ -384,8 +379,8 @@ class NixlConnectorWorker:
379
# Regardless, only handshake with the remote TP rank(s) that current
380
# local rank will read from. Note that With homogeneous TP,
381
# this happens to be the same single rank_i.
387
- assert self.kv_topo is not None
388
- p_remote_ranks = self.kv_topo.get_target_remote_ranks(remote_tp_size)
382
+ assert self.transfer_topo is not None
383
+ p_remote_ranks = self.transfer_topo.handshake_target_ranks(remote_tp_size)
384
remote_rank_to_agent_name = {}
385
path = make_zmq_path("tcp", host, port)
386
@@ -649,11 +644,11 @@ class NixlConnectorWorker:
644
645
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
646
"""Register the KV Cache data in nixl."""
652
- self.kv_topo = TpKVTopology(
647
+ self.transfer_topo = TransferTopology(
648
tp_rank=self.tp_rank,
649
+ tp_size=self.world_size,
650
+ block_size=self.block_size,
651
engine_id=self.engine_id,
655
- remote_tp_size=self._tp_size, # shared state
656
- remote_block_size=self._block_size, # shared state
652
is_mla=self.use_mla,
653
total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
654
attn_backends=self.attn_backends,
@@ -664,7 +659,7 @@ class NixlConnectorWorker:
659
is_mamba=self._has_mamba,
660
)
661
self.compat_hash = compute_nixl_compatibility_hash(
667
- self.vllm_config, self.backend_name, self.kv_topo.cross_layers_blocks
662
+ self.vllm_config, self.backend_name, self.transfer_topo.cross_layers_blocks
663
)
664
665
if self.use_host_buffer:
@@ -716,7 +711,7 @@ class NixlConnectorWorker:
711
if isinstance(layer_spec, UniformTypeKVCacheSpecs):
712
# MLA DSv32 Indexer case: UniformTypeKVCacheSpecs merges kv_cache_specs
713
layer_spec = layer_spec.kv_cache_specs[layer_name]
719
- cache_list = self.kv_topo.get_transfer_cache_regions(
714
+ cache_list = self.transfer_topo.get_transfer_cache_regions(
715
cache_or_caches, layer_spec
716
)
717
# `layer_spec.page_size_bytes` only accounts for logical page_size, that is
@@ -729,7 +724,7 @@ class NixlConnectorWorker:
724
)
725
# For when registering multiple tensors eg K/V in separate regions.
726
physical_page_size = physical_page_size // len(cache_list)
732
- if self.kv_topo._cross_layers_blocks:
727
+ if self.transfer_topo._cross_layers_blocks:
728
# When cross-layers blocks are used, multiply by number of layers
729
physical_page_size = physical_page_size * len(
730
self.kv_cache_config.kv_cache_tensors
@@ -793,7 +788,7 @@ class NixlConnectorWorker:
788
self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
789
self.num_regions = len(caches_data)
790
796
- if self.kv_topo.is_kv_layout_blocks_first:
791
+ if self.transfer_topo.is_kv_layout_blocks_first:
792
# NOTE (NickLucche) When FlashInfer is used, memory is registered
793
# with joint KV for each block. This minimizes the overhead in
794
# registerMem allowing faster descs queries. In order to be able to
@@ -817,7 +812,7 @@ class NixlConnectorWorker:
812
self.dst_num_blocks[self.engine_id] = self.num_blocks
813
814
if self._has_mamba:
820
- self._mamba_phys_ratio[self.engine_id] = (
815
+ self._physical_blocks_per_logical[self.engine_id] = (
816
self._physical_blocks_per_logical_kv_block
817
)
818
logger.info(
@@ -876,11 +871,13 @@ class NixlConnectorWorker:
871
conv_offsets = self._conv_decomp.local_conv_offsets
872
conv_size, ssm_size = self._mamba_ssm_size
873
num_blocks = self._logical_num_blocks * block_size_ratio
879
- phys_ratio = self._physical_blocks_per_logical_kv_block
874
+ physical_per_logical = self._physical_blocks_per_logical_kv_block
875
876
result: list[tuple[int, int, int]] = []
877
for i, base_addr in enumerate(base_addresses):
883
- page_stride = self.block_len_per_layer[i] // block_size_ratio * phys_ratio
878
+ page_stride = (
879
+ self.block_len_per_layer[i] // block_size_ratio * physical_per_logical
880
+ )
881
for off, sz in conv_offsets:
882
for blk in range(num_blocks):
883
result.append(
@@ -900,14 +897,14 @@ class NixlConnectorWorker:
897
def _build_fa_remote_for_mamba(
898
self,
899
nixl_agent_meta: NixlAgentMetadata,
903
- transfer_cfg: HeteroTPTransferConfig,
900
block_size_ratio: int,
905
- kv_topo: TpKVTopology,
901
+ transfer_topo: TransferTopology,
902
+ remote_engine_id: EngineId,
903
) -> list[tuple[int, int, int]]:
904
"""Build remote FA descriptors for mamba models.
905
909
- Uses transfer_cfg for GQA-aware FA divisor and head-based rank offset
910
- instead of the standard uniform tp_ratio split.
906
+ Uses TransferTopology for GQA-aware FA divisor and head-based rank
907
+ offset instead of the standard uniform tp_ratio split.
908
"""
909
assert block_size_ratio == 1, (
910
"Mamba 3-read transfer with block_size_ratio != 1 is not tested. "
@@ -915,7 +912,9 @@ class NixlConnectorWorker:
912
)
913
# TODO (ZhanqiuHu): unify with register_remote_blocks when Mamba-HMA
914
# hetero-TP logic stabilizes.
918
- tp_ratio = transfer_cfg.tp_ratio
915
+ mamba_info = transfer_topo.get_engine_info(remote_engine_id)
916
+ assert isinstance(mamba_info, MambaEngineTransferInfo)
917
+ tp_ratio = transfer_topo.tp_ratio(mamba_info.remote_tp_size)
918
result: list[tuple[int, int, int]] = []
919
for i, base_addr in enumerate(nixl_agent_meta.kv_caches_base_addr):
920
local_block_len = self.get_backend_aware_kv_block_len(
@@ -926,9 +925,11 @@ class NixlConnectorWorker:
925
local_block_len = remote_kv_block_len
926
927
if tp_ratio < 0 and not self.use_mla:
929
- local_block_len = local_block_len // transfer_cfg.physical_fa_num_reads
928
+ local_block_len = local_block_len // mamba_info.remote_num_fa_reads
929
931
- rank_offset = transfer_cfg.fa_rank_offset(remote_kv_block_len)
930
+ rank_offset = transfer_topo.fa_rank_offset(
931
+ remote_engine_id, remote_kv_block_len
932
+ )
933
934
num_blocks = nixl_agent_meta.num_blocks
935
page_size = nixl_agent_meta.block_lens[i]
@@ -937,12 +938,12 @@ class NixlConnectorWorker:
938
addr = base_addr + block_offset + rank_offset
939
result.append((addr, local_block_len, nixl_agent_meta.device_id))
940
940
- if kv_topo.is_kv_layout_blocks_first:
941
+ if transfer_topo.is_kv_layout_blocks_first:
942
second_split = self.get_backend_aware_kv_block_len(
943
layer_idx=i, first_split=False, mamba_view=False
944
)
945
if tp_ratio < 0 and not self.use_mla:
945
- second_split = second_split // transfer_cfg.physical_fa_num_reads
946
+ second_split = second_split // mamba_info.remote_num_fa_reads
947
for block_id in range(num_blocks):
948
block_offset = block_id * page_size
949
addr = base_addr + block_offset + rank_offset
@@ -981,15 +982,17 @@ class NixlConnectorWorker:
982
conv_offsets = [(0, xb_p), (xb_p, bb_p), (xb_p + bb_p, bb_p)]
983
ssm_read_size = nixl_agent_meta.ssm_sizes[1]
984
984
- remote_ratio = self._mamba_phys_ratio[nixl_agent_meta.engine_id]
985
- num_blocks = nixl_agent_meta.num_blocks // remote_ratio
985
+ remote_physical_per_logical = self._physical_blocks_per_logical[
986
+ nixl_agent_meta.engine_id
987
+ ]
988
+ num_blocks = nixl_agent_meta.num_blocks // remote_physical_per_logical
989
device_id = nixl_agent_meta.device_id
990
991
result: list[tuple[int, int, int]] = []
992
# NOTE (ZhanqiuHu): use per-layer block_lens[i], not [0], in case
993
# block lengths vary across layers (e.g. MLA).
994
for i, base_addr in enumerate(nixl_agent_meta.kv_caches_base_addr):
992
- page_stride = nixl_agent_meta.block_lens[i] * remote_ratio
995
+ page_stride = nixl_agent_meta.block_lens[i] * remote_physical_per_logical
996
for off, sz in conv_offsets:
997
for blk in range(num_blocks):
998
result.append((base_addr + blk * page_stride + off, sz, device_id))
@@ -1019,8 +1022,8 @@ class NixlConnectorWorker:
1022
register another local_xfer_handler using remote block len to ensure
1023
data copy correctness.
1024
"""
1022
- assert self.kv_topo is not None
1023
- kv_topo = self.kv_topo
1025
+ assert self.transfer_topo is not None
1026
+ transfer_topo = self.transfer_topo
1027
1028
block_size_ratio = self.block_size // block_size
1029
blocks_data: list[tuple[int, int, int]] = []
@@ -1051,7 +1054,7 @@ class NixlConnectorWorker:
1054
# (addr, len, device id)
1055
blocks_data.append((addr, kv_block_len, self.device_id))
1056
1054
- if kv_topo.is_kv_layout_blocks_first:
1057
+ if transfer_topo.is_kv_layout_blocks_first:
1058
second_split = self.get_backend_aware_kv_block_len(
1059
layer_idx=i, first_split=False, mamba_view=mamba
1060
)
@@ -1153,11 +1156,29 @@ class NixlConnectorWorker:
1156
)
1157
return self._remote_agents[engine_id][remote_tp_rank]
1158
1156
- ### Register remote agent metadata
1157
- if engine_id not in self._tp_size:
1158
- self._tp_size[engine_id] = remote_tp_size
1159
- if engine_id not in self._block_size:
1160
- self._block_size[engine_id] = nixl_agent_meta.block_size
1159
+ ### Register remote engine in TransferTopology (idempotent).
1160
+ assert self.transfer_topo is not None
1161
+ transfer_topo = self.transfer_topo
1162
+ physical_blocks_per_logical = (
1163
+ compute_physical_blocks_per_logical(
1164
+ nixl_agent_meta.ssm_sizes,
1165
+ nixl_agent_meta.block_lens[0],
1166
+ )
1167
+ if self._has_mamba
1168
+ else 1
1169
+ )
1170
+ transfer_topo.register_remote_engine(
1171
+ remote_engine_id=engine_id,
1172
+ remote_tp_size=remote_tp_size,
1173
+ remote_block_size=nixl_agent_meta.block_size,
1174
+ remote_block_len=nixl_agent_meta.block_lens[0],
1175
+ remote_physical_blocks_per_logical=physical_blocks_per_logical,
1176
+ local_block_len=self.block_len_per_layer[0],
1177
+ )
1178
+ if self._has_mamba and engine_id not in self._physical_blocks_per_logical:
1179
+ self._physical_blocks_per_logical[engine_id] = physical_blocks_per_logical
1180
+
1181
+ logger.info("Transfer plan: %s", transfer_topo.describe(engine_id))
1182
1183
remote_agent_name = self.nixl_wrapper.add_remote_agent(
1184
nixl_agent_meta.agent_metadata
@@ -1170,16 +1191,10 @@ class NixlConnectorWorker:
1191
# remote: | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|
1192
# local origin:| 0| 1| 8| 12|
1193
# local mapped:| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|13|14|15|
1173
- assert self.kv_topo is not None
1174
- kv_topo = self.kv_topo
1175
- block_size_ratio = kv_topo.block_size_ratio_from_engine_id(engine_id)
1194
+ block_size_ratio = transfer_topo.block_size_ratio(nixl_agent_meta.block_size)
1195
1196
if engine_id not in self.dst_num_blocks:
1197
self.dst_num_blocks[engine_id] = nixl_agent_meta.num_blocks
1179
- if self._has_mamba:
1180
- self._mamba_phys_ratio[engine_id] = compute_mamba_phys_ratio(
1181
- nixl_agent_meta.ssm_sizes, nixl_agent_meta.block_lens[0]
1182
- )
1198
1199
# Keep track of remote agent kv caches base addresses.
1200
self.kv_caches_base_addr[engine_id][remote_tp_rank] = (
@@ -1189,28 +1204,13 @@ class NixlConnectorWorker:
1204
1205
# This is 1 when P and D `--tensor-parallel-size` match. Otherwise,
1206
# this is the ratio between the two sizes.
1192
- tp_ratio = self.kv_topo.tp_ratio_from_engine_id(engine_id)
1207
+ tp_ratio = transfer_topo.tp_ratio(remote_tp_size)
1208
1209
# Handle tp_size>num_kv_heads: replicate KV cache.
1210
indexes_into_remote = (
1196
- not self.kv_topo.replicates_kv_cache(engine_id) and tp_ratio > 0
1211
+ not transfer_topo.replicates_kv_cache(engine_id) and tp_ratio > 0
1212
)
1213
1199
- # Create transfer config (single source of truth for descriptor sizes).
1200
- if self._has_mamba and engine_id not in self._transfer_configs:
1201
- self._transfer_configs[engine_id] = HeteroTPTransferConfig(
1202
- tp_ratio=tp_ratio,
1203
- K=kv_topo.total_num_kv_heads,
1204
- d_tp=self.world_size,
1205
- p_tp=remote_tp_size,
1206
- d_rank=self.tp_rank,
1207
- use_mla=self.use_mla,
1208
- d_block_len=self.block_len_per_layer[0],
1209
- p_block_len=nixl_agent_meta.block_lens[0],
1210
- is_blocks_first=kv_topo.is_kv_layout_blocks_first,
1211
- )
1212
- logger.info("Created %s", self._transfer_configs[engine_id].describe())
1213
-
1214
logger.debug(
1215
"Registering remote agent (%s, rank %s) memory regions with tp_ratio %s",
1216
engine_id,
@@ -1231,12 +1231,10 @@ class NixlConnectorWorker:
1231
self.src_xfer_handles_by_tp_ratio[tp_ratio] = []
1232
1233
if self._has_mamba:
1234
- transfer_cfg = self._transfer_configs.get(engine_id)
1235
- assert transfer_cfg is not None
1236
- if transfer_cfg.needs_split_handles:
1234
+ if transfer_topo.needs_split_handles(engine_id):
1235
# Mamba-HMA: FA and Mamba use different split factors.
1238
- for handle_data in transfer_cfg.compute_split_handle_data(
1239
- self.src_blocks_data, self.num_descs, abs_tp
1236
+ for handle_data in transfer_topo.compute_split_handle_data(
1237
+ engine_id, self.src_blocks_data, self.num_descs, abs_tp
1238
):
1239
descs = self.nixl_wrapper.get_xfer_descs(
1240
handle_data, self.nixl_memory_type
@@ -1247,12 +1245,8 @@ class NixlConnectorWorker:
1245
self.src_xfer_handles_by_tp_ratio[tp_ratio].append(handle)
1246
1247
logger.info(
1250
- "Mamba-HMA split handles: targets=%s, fa_reads=%s, "
1251
- "fa_entry=%s, mamba_reads=%s, num_descs=%s",
1252
- transfer_cfg.transfer_targets,
1253
- transfer_cfg.physical_fa_num_reads,
1254
- transfer_cfg.fa_entry_size,
1255
- transfer_cfg.mamba_num_reads,
1248
+ "Mamba-HMA split handles: %s, num_descs=%s",
1249
+ transfer_topo.describe(engine_id),
1250
self.num_descs,
1251
)
1252
else:
@@ -1321,7 +1315,7 @@ class NixlConnectorWorker:
1315
(addr, local_block_len, nixl_agent_meta.device_id)
1316
)
1317
1324
- if kv_topo.is_kv_layout_blocks_first:
1318
+ if transfer_topo.is_kv_layout_blocks_first:
1319
# With FlashInfer index V separately to allow head splitting.
1320
second_split = self.get_backend_aware_kv_block_len(
1321
layer_idx=i, first_split=False, mamba_view=mamba
@@ -1360,14 +1354,12 @@ class NixlConnectorWorker:
1354
engine_id,
1355
remote_tp_rank,
1356
)
1363
- transfer_cfg = self._transfer_configs.get(engine_id)
1364
- assert transfer_cfg is not None
1357
blocks_data.extend(
1358
self._build_fa_remote_for_mamba(
1359
nixl_agent_meta,
1368
- transfer_cfg,
1360
block_size_ratio,
1370
- kv_topo,
1361
+ transfer_topo,
1362
+ engine_id,
1363
)
1364
)
1365
blocks_data.extend(
@@ -1403,18 +1395,19 @@ class NixlConnectorWorker:
1395
"""
1396
remote_engine_id = nixl_agent_meta.engine_id
1397
1406
- assert self._tp_size[remote_engine_id] == remote_tp_size
1407
- assert self.kv_topo is not None
1398
+ assert self.transfer_topo is not None
1399
+ remote_info = self.transfer_topo.get_engine_info(remote_engine_id)
1400
+ assert remote_info.remote_tp_size == remote_tp_size
1401
1409
- tp_ratio = self.kv_topo.tp_ratio_from_engine_id(remote_engine_id)
1410
- block_size_ratio = self.kv_topo.block_size_ratio_from_engine_id(
1411
- remote_engine_id
1402
+ tp_ratio = self.transfer_topo.tp_ratio(remote_tp_size)
1403
+ block_size_ratio = self.transfer_topo.block_size_ratio(
1404
+ nixl_agent_meta.block_size
1405
)
1406
# num_kv_heads > tp_size with P_TP > D_TP not supported for non-mamba.
1407
# Mamba models can have replicated FA KV with tp_ratio < 0.
1408
if not self._has_mamba:
1409
assert not (
1417
- tp_ratio < 0 and self.kv_topo.is_kv_replicated(remote_engine_id)
1410
+ tp_ratio < 0 and self.transfer_topo.is_kv_replicated(remote_engine_id)
1411
)
1412
1413
if self._is_hma_required:
@@ -1467,7 +1460,7 @@ class NixlConnectorWorker:
1460
if (
1461
abs(tp_ratio) != 1
1462
and not self.use_mla
1470
- and not self.kv_topo.is_kv_replicated(remote_engine_id)
1463
+ and not self.transfer_topo.is_kv_replicated(remote_engine_id)
1464
and kv_cache_layout != "HND"
1465
and not self.enable_permute_local_kv
1466
):
@@ -1478,7 +1471,7 @@ class NixlConnectorWorker:
1471
1472
# Block len can only vary across layers when using MLA.
1473
remote_block_len = nixl_agent_meta.block_lens[0]
1481
- if self.use_mla or self.kv_topo.is_kv_replicated(remote_engine_id):
1474
+ if self.use_mla or self.transfer_topo.is_kv_replicated(remote_engine_id):
1475
# With replicated KV cache, only the number of blocks can differ.
1476
# TODO (ZhanqiuHu): For mamba models, validate FA and mamba
1477
# block_lens separately.
@@ -1594,7 +1587,7 @@ class NixlConnectorWorker:
1587
if len(self.device_kv_caches) == 0:
1588
return
1589
assert block_size_ratio >= 1, "Only nP < nD supported currently."
1597
- assert self.kv_topo is not None
1590
+ assert self.transfer_topo is not None
1591
if self.enable_permute_local_kv and block_size_ratio > 1:
1592
logger.debug(
1593
"Post-processing device kv cache on receive by converting "
@@ -1614,7 +1607,7 @@ class NixlConnectorWorker:
1607
block_size_ratio,
1608
)
1609
1617
- split_k_and_v = self.kv_topo.split_k_and_v
1610
+ split_k_and_v = self.transfer_topo.split_k_and_v
1611
1612
for block_ids in block_ids_list:
1613
indices = torch.tensor(block_ids, device=self.device_type, dtype=torch.long)
@@ -1661,7 +1654,7 @@ class NixlConnectorWorker:
1654
The scheduler process (via the MultiprocExecutor) will use this output
1655
to track which workers are done.
1656
"""
1664
- assert self.kv_topo is not None
1657
+ assert self.transfer_topo is not None
1658
done_sending = self._get_new_notifs()
1659
done_recving = self._pop_done_transfers(self._recving_transfers)
1660
@@ -1689,8 +1682,9 @@ class NixlConnectorWorker:
1682
self.sync_recved_kv_to_device(req_id, meta)
1683
1684
# post processing for heteroblocksize
1692
- block_size_ratio = self.kv_topo.block_size_ratio_from_engine_id(
1693
- meta.remote.engine_id
1685
+ remote_info = self.transfer_topo.get_engine_info(meta.remote.engine_id)
1686
+ block_size_ratio = self.transfer_topo.block_size_ratio(
1687
+ remote_info.remote_block_size
1688
)
1689
if not self.use_mla and (
1690
block_size_ratio > 1 or self.enable_permute_local_kv
@@ -1741,7 +1735,7 @@ class NixlConnectorWorker:
1735
are reading from the same producer (heterogeneous TP scenario), wait
1736
for all consumers to be done pulling.
1737
"""
1744
- assert self.kv_topo is not None
1738
+ assert self.transfer_topo is not None
1739
notified_req_ids: set[str] = set()
1740
for notifs in self.nixl_wrapper.get_new_notifs().values():
1741
for notif in notifs:
@@ -1760,7 +1754,7 @@ class NixlConnectorWorker:
1754
1755
# NOTE: `tp_ratio` is the opposite when swapping local<>remote
1756
n_consumers = int(tp_size)
1763
- tp_ratio = self.kv_topo.tp_ratio(n_consumers)
1757
+ tp_ratio = self.transfer_topo.tp_ratio(n_consumers)
1758
1759
# Number of reads *per producer* to wait for.
1760
# When remote D TP > local P TP we expect `tp_ratio` reads.
@@ -1901,17 +1895,17 @@ class NixlConnectorWorker:
1895
self._reqs_to_send[req_id] = expiration_time
1896
1897
def _read_blocks_for_req(self, req_id: str, meta: ReqMeta):
1904
- assert meta.remote is not None and self.kv_topo is not None
1905
- remote_ranks = self.kv_topo.get_target_remote_ranks_from_engine_id(
1906
- meta.remote.engine_id
1907
- )
1908
- tp_ratio = self.kv_topo.tp_ratio_from_engine_id(meta.remote.engine_id)
1898
+ assert meta.remote is not None and self.transfer_topo is not None
1899
+ engine_id = meta.remote.engine_id
1900
+ remote_ranks = self.transfer_topo.target_remote_ranks(engine_id)
1901
+ remote_info = self.transfer_topo.get_engine_info(engine_id)
1902
+ tp_ratio = self.transfer_topo.tp_ratio(remote_info.remote_tp_size)
1903
1904
if self._has_mamba:
1905
# Expand remote logical → kernel block IDs.
1906
meta.remote.block_ids = self._logical_to_remote_kernel_block_ids(
1907
meta.remote.block_ids,
1914
- self._mamba_phys_ratio[meta.remote.engine_id],
1908
+ self._physical_blocks_per_logical[meta.remote.engine_id],
1909
)
1910
else:
1911
meta.remote.block_ids = self._logical_to_kernel_block_ids(
@@ -1924,7 +1918,7 @@ class NixlConnectorWorker:
1918
# the first remote rank (cache is duplicated)..
1919
break
1920
1927
- remote_block_size = self.kv_topo.remote_block_size[meta.remote.engine_id]
1921
+ remote_block_size = remote_info.remote_block_size
1922
logger.debug(
1923
"Remote agent %s available, calling _read_blocks"
1924
" on remote rank %s with remote block size %s for req %s",
@@ -1955,9 +1949,8 @@ class NixlConnectorWorker:
1949
remote_ids: BlockIds = meta.remote.block_ids
1950
if self._has_mamba:
1951
# Mamba-HMA: zero out FA groups for P ranks outside fa_read_targets.
1958
- transfer_cfg = self._transfer_configs.get(meta.remote.engine_id)
1959
- assert transfer_cfg is not None
1960
- local_ids, remote_ids = transfer_cfg.filter_block_ids_for_rank(
1952
+ local_ids, remote_ids = self.transfer_topo.filter_block_ids_for_rank(
1953
+ engine_id,
1954
remote_rank,
1955
local_ids,
1956
remote_ids,
@@ -1999,8 +1992,11 @@ class NixlConnectorWorker:
1992
Post a READ point-to-point xfer request from a single local worker to
1993
a single remote worker.
1994
"""
2002
- assert self.kv_topo is not None
2003
- block_size_ratio = self.kv_topo.block_size_ratio_from_engine_id(dst_engine_id)
1995
+ assert self.transfer_topo is not None
1996
+ remote_info = self.transfer_topo.get_engine_info(dst_engine_id)
1997
+ block_size_ratio = self.transfer_topo.block_size_ratio(
1998
+ remote_info.remote_block_size
1999
+ )
2000
if block_size_ratio > 1:
2001
# TODO (NickLucche) assume HMA is off. Change to handle multiple KV groups.
2002
assert not self._is_hma_required
@@ -2190,8 +2186,8 @@ class NixlConnectorWorker:
2186
# This is like having two "low-level views" of the same storage.
2187
# `num_fa_descs` offset must be computed per-engine since P and D can
2188
# have different num_blocks (and thus different FA descs counts).
2193
- ratio = self._mamba_phys_ratio[engine_id]
2194
- logical_blocks = num_blocks // ratio
2189
+ physical_per_logical = self._physical_blocks_per_logical[engine_id]
2190
+ logical_blocks = num_blocks // physical_per_logical
2191
num_fa_descs = self.num_regions * num_blocks
2192
# 3-read mamba: 4 regions per unique cache tensor (x, B, C, ssm).
2193
mamba_region_ids = np.arange(len(self.block_len_per_layer) * 4)[:, None]
@@ -2234,21 +2230,22 @@ class NixlConnectorWorker:
2230
]
2231
2232
def _logical_to_remote_kernel_block_ids(
2237
- self, block_ids: BlockIds, remote_ratio: int
2233
+ self, block_ids: BlockIds, remote_physical_per_logical: int
2234
) -> BlockIds:
2235
"""Map logical block IDs to physical kernel block IDs on the remote.
2236
2237
Args:
2238
block_ids: per-group lists of logical block IDs.
2243
- remote_ratio: remote engine's physical blocks per logical block.
2239
+ remote_physical_per_logical: remote engine's physical blocks
2240
+ per logical block.
2241
2242
Returns:
2243
Same structure with FA groups expanded (each logical block L
2247
- becomes kernel blocks [L*remote_ratio .. L*remote_ratio +
2248
- local_ratio - 1]). Mamba groups are passed through unchanged.
2244
+ becomes kernel blocks [L*ratio .. L*ratio + local_ratio - 1]).
2245
+ Mamba groups are passed through unchanged.
2246
"""
2247
local_ratio = self._physical_blocks_per_logical_kv_block
2251
- if remote_ratio == 1:
2248
+ if remote_physical_per_logical == 1:
2249
return block_ids
2250
local_arange = np.arange(local_ratio).reshape(1, -1)
2251
group_specs = self.kv_cache_config.kv_cache_groups
@@ -2256,7 +2253,7 @@ class NixlConnectorWorker:
2253
for i, group in enumerate(block_ids):
2254
if not isinstance(group_specs[i].kv_cache_spec, MambaSpec):
2255
arr = np.array(group).reshape(-1, 1)
2259
- expanded = (arr * remote_ratio + local_arange).flatten()
2256
+ expanded = (arr * remote_physical_per_logical + local_arange).flatten()
2257
result.append(expanded.tolist())
2258
else:
2259
# Mamba blocks are 1:1 logical-to-physical (no expansion).
@@ -2296,8 +2293,8 @@ class NixlConnectorWorker:
2293
+-------------------+ +--------------------+
2294
|1st_split-2nd_split| |1st_split-2nd_split |
2295
"""
2299
- assert self.kv_topo is not None
2300
- if self.kv_topo.is_kv_layout_blocks_first:
2296
+ assert self.transfer_topo is not None
2297
+ if self.transfer_topo.is_kv_layout_blocks_first:
2298
# For indexing only half (either just the K or V part).
2299
if mamba_view:
2300
# NOTE (NickLucche) Mamba Opt: this is already skipping the padding so
vllm/distributed/kv_transfer/kv_connector/v1/ssm_conv_transfer_utils.py
+3
-1
@@ -151,7 +151,9 @@ def derive_mamba_conv_split(
151
)
152
153
154
-def compute_mamba_phys_ratio(ssm_sizes: tuple[int, ...], block_len: int) -> int:
154
+def compute_physical_blocks_per_logical(
155
+ ssm_sizes: tuple[int, ...], block_len: int
156
+) -> int:
157
"""Derive _physical_blocks_per_logical_kv_block from remote metadata.
158
159
The remote engine's ratio is not sent directly in the handshake, so we