[Bugfix][NIXL] Include transfer mode (push/pull) in the compatibility hash (#50620)

Signed-off-by: Tzu-Ling <tzulingk@nvidia.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Tzu-Ling Kan committed Aug 13, 2026 at 23:11 UTC 653cc6faca6885e36760bf35a25bb63442519b14
8 files changed +76 -3
docs/features/nixl_connector_compatibility.md
+2
@@ -82,6 +82,8 @@ By default, a **compatibility hash** is checked during handshake. P and D instan
82 - Attention backend
83 - KV cache dtype (`cache_dtype`)
84 - EAGLE/MTP-style speculative method and draft-model configuration
85 +- NIXL transfer mode (push vs pull) — a push (WRITE) connector and a pull (READ)
86 + connector use incompatible transfer protocols and must never be paired
87
88 !!! warning
89 Disable the hash check with `--kv-transfer-config '{"kv_connector_extra_config": {"enforce_handshake_compat": false}}'` at your own risk.
tests/v1/kv_connector/unit/test_nixl_connector.py
+39
@@ -433,6 +433,9 @@ def test_kv_transfer_handshake(dist_init):
433 )
434 )
435 assert delay
436 + # Pull connector advertises its transfer mode in kv_transfer_params so
437 + # an external router can distinguish it from a push producer.
438 + assert kv_connector_metadata["transfer_mode"] == "pull"
439
440 # Decode connector will be able to create handshake with the prefill connector.
441 decode_connector = NixlConnector(
@@ -2906,6 +2909,42 @@ def test_speculative_attention_backend_not_in_compatibility_hash():
2909 assert local_hash == remote_hash
2910
2911
2912 +@pytest.mark.skip_global_cleanup
2913 +def test_transfer_mode_changes_compatibility_hash():
2914 + # push (WRITE) and pull (READ) connectors use incompatible transfer
2915 + # protocols, so their compatibility hashes must differ; identical modes
2916 + # must match. The default mode is pull.
2917 + config = create_vllm_config()
2918 +
2919 + pull_hash = compute_nixl_compatibility_hash(
2920 + config, "FLASH_ATTN", False, transfer_mode="pull"
2921 + )
2922 + push_hash = compute_nixl_compatibility_hash(
2923 + config, "FLASH_ATTN", False, transfer_mode="push"
2924 + )
2925 +
2926 + assert pull_hash != push_hash
2927 + assert pull_hash == compute_nixl_compatibility_hash(
2928 + config, "FLASH_ATTN", False, transfer_mode="pull"
2929 + )
2930 + assert compute_nixl_compatibility_hash(config, "FLASH_ATTN", False) == pull_hash
2931 +
2932 +
2933 +@pytest.mark.skip_global_cleanup
2934 +def test_scheduler_advertises_transfer_mode():
2935 + # Each scheduler advertises its transfer mode in kv_transfer_params so an
2936 + # external router can route pull (READ) vs push (WRITE) producers.
2937 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import (
2938 + NixlPullConnectorScheduler,
2939 + )
2940 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_scheduler import (
2941 + NixlPushConnectorScheduler,
2942 + )
2943 +
2944 + assert NixlPullConnectorScheduler._TRANSFER_MODE == "pull"
2945 + assert NixlPushConnectorScheduler._TRANSFER_MODE == "push"
2946 +
2947 +
2948 @pytest.mark.parametrize(
2949 "mismatch_type,config_overrides,version_override,should_fail,enforce_handshake_compat",
2950 [
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+5
@@ -51,6 +51,11 @@ logger = init_logger(__name__)
51 class NixlBaseConnectorScheduler:
52 """Base implementation of Scheduler side methods shared by pull and push."""
53
54 + # Emitted in kv_transfer_params so an external router can distinguish a
55 + # pull (READ) producer from a push (WRITE) one. Overridden by the push
56 + # scheduler.
57 + _TRANSFER_MODE: str = "pull"
58 +
59 def __init__(
60 self,
61 vllm_config: "VllmConfig",
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+10 -1
@@ -90,6 +90,11 @@ logger = init_logger(__name__)
90 class NixlBaseConnectorWorker:
91 """Base implementation of Worker side methods shared by pull and push."""
92
93 + # Transfer mode included in the NIXL compatibility hash so that a push
94 + # (WRITE) connector and a pull (READ) connector never handshake together.
95 + # Overridden by NixlPushConnectorWorker.
96 + _TRANSFER_MODE: str = "pull"
97 +
98 def _compute_desc_ids(
99 self,
100 block_ids: BlockIds,
@@ -976,6 +981,7 @@ class NixlBaseConnectorWorker:
981 self.vllm_config,
982 self.backend_name,
983 self.transfer_topo.cross_layers_blocks,
984 + transfer_mode=self._TRANSFER_MODE,
985 )
986
987 total_size = storage.nbytes()
@@ -1066,7 +1072,10 @@ class NixlBaseConnectorWorker:
1072 is_mamba=self._has_mamba,
1073 )
1074 self.compat_hash = compute_nixl_compatibility_hash(
1069 - self.vllm_config, self.backend_name, self.transfer_topo.cross_layers_blocks
1075 + self.vllm_config,
1076 + self.backend_name,
1077 + self.transfer_topo.cross_layers_blocks,
1078 + transfer_mode=self._TRANSFER_MODE,
1079 )
1080
1081 if self.use_host_buffer:
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+13 -2
@@ -42,8 +42,9 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
42 # 5: Add remote_blocks_expiry_time to kv_transfer_params + handshake
43 # clock-sync timestamp
44 # 6: Validate EAGLE/MTP speculative configuration compatibility
45 +# 7: Include NIXL transfer mode (push vs pull) in the compatibility hash
46 #
46 -NIXL_CONNECTOR_VERSION: int = 6
47 +NIXL_CONNECTOR_VERSION: int = 7
48
49
50 @dataclass
@@ -123,7 +124,10 @@ def _get_speculative_compatibility_factors(
124
125
126 def compute_nixl_compatibility_hash(
126 - vllm_config: VllmConfig, attn_backend_name: str, cross_layers_blocks: bool
127 + vllm_config: VllmConfig,
128 + attn_backend_name: str,
129 + cross_layers_blocks: bool,
130 + transfer_mode: str = "pull",
131 ) -> str:
132 """
133 Compute compatibility hash for NIXL KV transfer.
@@ -137,6 +141,11 @@ def compute_nixl_compatibility_hash(
141 - KV cache format (dtype, sliding window)
142 - Attention backend
143 - EAGLE/MTP configuration that affects transferred state
144 + - Transfer mode (push vs pull)
145 +
146 + The transfer mode is included because the push (WRITE) and pull (READ)
147 + connectors use incompatible transfer protocols; a push connector and a
148 + pull connector must never complete a handshake with each other.
149
150 Note: Factors like tensor_parallel_size, block_size, and kv_cache_layout
151 are validated at runtime in _validate_remote_agent_handshake and are not
@@ -171,6 +180,8 @@ def compute_nixl_compatibility_hash(
180 "cross_layers_blocks": cross_layers_blocks,
181 "is_hma_enabled": is_hma_enabled,
182 "speculative_config": _get_speculative_compatibility_factors(vllm_config),
183 + # push (WRITE) and pull (READ) connectors are protocol-incompatible
184 + "transfer_mode": transfer_mode,
185 }
186
187 compat_hash = hash_factors(factors)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py
+1
@@ -277,4 +277,5 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
277 tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
278 remote_num_tokens=remote_num_tokens,
279 remote_blocks_expiry_time=blocks_expiry_time,
280 + transfer_mode=self._TRANSFER_MODE,
281 )
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_scheduler.py
+3
@@ -60,6 +60,8 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
60 hooks.
61 """
62
63 + _TRANSFER_MODE: str = "push"
64 +
65 def __init__(
66 self,
67 vllm_config: VllmConfig,
@@ -290,6 +292,7 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
292 tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
293 pp_size=self.vllm_config.parallel_config.pipeline_parallel_size,
294 remote_num_tokens=remote_num_tokens,
295 + transfer_mode=self._TRANSFER_MODE,
296 )
297
298 def build_connector_meta(
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+3
@@ -77,6 +77,9 @@ _PUSH_WRITER_POLL_INTERVAL_MS = 1.0
77 class NixlPushConnectorWorker(NixlBaseConnectorWorker):
78 """Push-specific (WRITE) worker logic. See module docstring."""
79
80 + # Distinguishes push from pull in the NIXL compatibility hash.
81 + _TRANSFER_MODE: str = "push"
82 +
83 def __init__(
84 self,
85 vllm_config: "VllmConfig",