1
+# SPDX-License-Identifier: Apache-2.0
2
+import contextlib
3
+import math
4
+import threading
5
+import time
6
+import uuid
7
+from collections import defaultdict
8
+from dataclasses import dataclass
9
+from typing import TYPE_CHECKING, Any, Iterator
10
+
11
+import msgspec
12
+import torch
13
+import zmq
14
+from typing_extensions import Optional
15
+
16
+from vllm import envs
17
+from vllm.config import VllmConfig
18
+from vllm.distributed.kv_transfer.kv_connector.v1.base import (
19
+ KVConnectorBase_V1, KVConnectorMetadata, KVConnectorRole, KVTransferParams)
20
+from vllm.distributed.parallel_state import (
21
+ get_tensor_model_parallel_rank, get_tensor_model_parallel_world_size,
22
+ get_tp_group)
23
+from vllm.logger import init_logger
24
+from vllm.utils import round_down
25
+from vllm.v1.core.sched.output import SchedulerOutput
26
+from vllm.v1.request import RequestStatus
27
+
28
+if TYPE_CHECKING:
29
+ from vllm.attention.backends.abstract import AttentionMetadata
30
+ from vllm.forward_context import ForwardContext
31
+ from vllm.v1.core.kv_cache_manager import KVCacheBlocks
32
+ from vllm.v1.request import Request
33
+
34
+GET_META_MSG = b"get_meta_msg"
35
+
36
+logger = init_logger(__name__)
37
+
38
+# Lazy import nixl_wrapper to avoid loading nixl_bindings if nixl is not used
39
+try:
40
+ from nixl._api import nixl_agent as NixlWrapper
41
+ logger.info("NIXL is available")
42
+except ImportError:
43
+ logger.warning("NIXL is not available")
44
+ NixlWrapper = None
45
+
46
+
47
+@dataclass
48
+class NixlKVTransferParams(KVTransferParams):
49
+
50
+ def __init__(
51
+ self,
52
+ do_remote_prefill: bool,
53
+ do_remote_decode: bool,
54
+ remote_block_ids: Optional[list[int]] = None,
55
+ remote_host: Optional[str] = None,
56
+ remote_port: Optional[int] = None,
57
+ remote_engine_id: Optional[str] = None,
58
+ ):
59
+ self.do_remote_prefill = do_remote_prefill
60
+ self.do_remote_decode = do_remote_decode
61
+ self.remote_block_ids = remote_block_ids
62
+ self.remote_host = remote_host
63
+ self.remote_port = remote_port
64
+ self.remote_engine_id = remote_engine_id
65
+
66
+ @staticmethod
67
+ def from_raw_dict(
68
+ raw_dict: Optional[dict[str,
69
+ Any]]) -> Optional["NixlKVTransferParams"]:
70
+
71
+ # If no raw transfer params passed, return None.
72
+ if raw_dict is None:
73
+ return None
74
+
75
+ # Validate the request is formatted properly.
76
+ if (("do_remote_prefill" not in raw_dict)
77
+ or ("do_remote_decode" not in raw_dict)
78
+ or ("remote_block_ids" not in raw_dict)
79
+ or ("remote_host" not in raw_dict)
80
+ or ("remote_port" not in raw_dict)
81
+ or ("remote_engine_id" not in raw_dict)):
82
+ logger.warning(
83
+ "Got invalid KVTransferParams: %s. This "
84
+ "request will not utilize KVTransfer", raw_dict)
85
+ return None
86
+
87
+ return NixlKVTransferParams(
88
+ do_remote_prefill=raw_dict["do_remote_prefill"],
89
+ do_remote_decode=raw_dict["do_remote_decode"],
90
+ remote_block_ids=raw_dict["remote_block_ids"],
91
+ remote_host=raw_dict["remote_host"],
92
+ remote_port=raw_dict["remote_port"],
93
+ remote_engine_id=raw_dict["remote_engine_id"],
94
+ )
95
+
96
+
97
+class NixlAgentMetadata(
98
+ msgspec.Struct,
99
+ omit_defaults=True, # type: ignore[call-arg]
100
+ # required for @cached_property.
101
+ dict=True):
102
+ engine_id: str
103
+ agent_metadata: bytes
104
+ kv_caches_base_addr: list[int]
105
+ num_blocks: int
106
+
107
+
108
+@dataclass
109
+class ReqMeta:
110
+ local_block_ids: list[int]
111
+ remote_block_ids: list[int]
112
+ remote_host: str
113
+ remote_port: int
114
+ remote_engine_id: str
115
+
116
+
117
+class NixlConnectorMetadata(KVConnectorMetadata):
118
+
119
+ def __init__(self):
120
+ self.requests: dict[str, ReqMeta] = {}
121
+
122
+ def add_new_req(
123
+ self,
124
+ request_id: str,
125
+ local_block_ids: list[int],
126
+ kv_transfer_params: NixlKVTransferParams,
127
+ ):
128
+ assert request_id not in self.requests
129
+ assert kv_transfer_params.remote_block_ids is not None
130
+ assert kv_transfer_params.remote_engine_id is not None
131
+ assert kv_transfer_params.remote_host is not None
132
+ assert kv_transfer_params.remote_port is not None
133
+
134
+ self.requests[request_id] = ReqMeta(
135
+ local_block_ids=local_block_ids,
136
+ remote_block_ids=kv_transfer_params.remote_block_ids,
137
+ remote_engine_id=kv_transfer_params.remote_engine_id,
138
+ remote_host=kv_transfer_params.remote_host,
139
+ remote_port=kv_transfer_params.remote_port,
140
+ )
141
+
142
+
143
+class NixlConnector(KVConnectorBase_V1):
144
+ _KVTransferParams: type[NixlKVTransferParams] = NixlKVTransferParams
145
+
146
+ def __init__(self, vllm_config: VllmConfig, role: KVConnectorRole):
147
+ assert vllm_config.kv_transfer_config is not None
148
+ self.engine_id = vllm_config.kv_transfer_config.engine_id
149
+
150
+ if role == KVConnectorRole.SCHEDULER:
151
+ self.connector_scheduler : Optional[NixlConnectorScheduler] = \
152
+ NixlConnectorScheduler(vllm_config, str(self.engine_id))
153
+ self.connector_worker: Optional[NixlConnectorWorker] = None
154
+ elif role == KVConnectorRole.WORKER:
155
+ self.connector_scheduler = None
156
+ self.connector_worker = NixlConnectorWorker(str(self.engine_id))
157
+
158
+ ############################################################
159
+ # Scheduler Side Methods
160
+ ############################################################
161
+
162
+ def get_num_new_matched_tokens(
163
+ self, request: "Request",
164
+ num_computed_tokens: int) -> tuple[int, bool]:
165
+ assert self.connector_scheduler is not None
166
+ return self.connector_scheduler.get_num_new_matched_tokens(
167
+ request, num_computed_tokens)
168
+
169
+ def update_state_after_alloc(self, request: "Request",
170
+ blocks: "KVCacheBlocks",
171
+ num_external_tokens: int):
172
+ assert self.connector_scheduler is not None
173
+ return self.connector_scheduler.update_state_after_alloc(
174
+ request, blocks, num_external_tokens)
175
+
176
+ def build_connector_meta(
177
+ self,
178
+ scheduler_output: SchedulerOutput,
179
+ ) -> KVConnectorMetadata:
180
+ assert self.connector_scheduler is not None
181
+ return self.connector_scheduler.build_connector_meta(scheduler_output)
182
+
183
+ def request_finished(
184
+ self,
185
+ request: "Request",
186
+ block_ids: list[int],
187
+ ) -> tuple[bool, Optional[dict[str, Any]]]:
188
+ assert self.connector_scheduler is not None
189
+ return self.connector_scheduler.request_finished(request, block_ids)
190
+
191
+ ############################################################
192
+ # Worker Side Methods
193
+ ############################################################
194
+ def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
195
+ assert self.connector_worker is not None
196
+ self.connector_worker.register_kv_caches(kv_caches)
197
+
198
+ def get_finished(self,
199
+ finished_req_ids: set[str]) -> tuple[set[str], set[str]]:
200
+ """Get the finished recving and sending requests."""
201
+ assert self.connector_worker is not None
202
+ return self.connector_worker.get_finished()
203
+
204
+ def start_load_kv(self, forward_context: "ForwardContext",
205
+ **kwargs) -> None:
206
+ assert self.connector_worker is not None
207
+ assert isinstance(self._connector_metadata, NixlConnectorMetadata)
208
+ self.connector_worker.start_load_kv(self._connector_metadata)
209
+
210
+ def wait_for_layer_load(self, layer_name: str) -> None:
211
+ """NixlConnector does not do layerwise saving."""
212
+ pass
213
+
214
+ def save_kv_layer(self, layer_name: str, kv_layer: torch.Tensor,
215
+ attn_metadata: "AttentionMetadata", **kwargs) -> None:
216
+ """NixlConnector does not save explicitly."""
217
+ pass
218
+
219
+ def wait_for_save(self):
220
+ """NixlConnector does not save explicitly."""
221
+ pass
222
+
223
+
224
+class NixlConnectorScheduler:
225
+ """Implementation of Scheduler side methods"""
226
+
227
+ def __init__(self, vllm_config: VllmConfig, engine_id: str):
228
+ self.vllm_config = vllm_config
229
+ self.block_size = vllm_config.cache_config.block_size
230
+ self.engine_id = engine_id
231
+ logger.info("Initializing NIXL Scheduler %s", engine_id)
232
+
233
+ # Requests that need to start recv.
234
+ # New requests are added by update_state_after_alloc in
235
+ # the scheduler. Used to make metadata passed to Worker.
236
+ self._reqs_need_recv: dict[str, tuple[Request, list[int]]] = {}
237
+
238
+ def get_num_new_matched_tokens(
239
+ self, request: "Request",
240
+ num_computed_tokens: int) -> tuple[int, bool]:
241
+ """
242
+ For remote prefill, pull all prompt blocks from remote
243
+ asynchronously relative to engine execution.
244
+
245
+ Args:
246
+ request (Request): the request object.
247
+ num_computed_tokens (int): the number of locally
248
+ computed tokens for this request
249
+ Returns:
250
+ * the number of tokens that can be loaded from the
251
+ external KV cache beyond what is already computed.
252
+ * true if the external KV cache tokens will be loaded
253
+ asynchronously (between scheduler steps).
254
+ """
255
+
256
+ # No KVTransfer for this request.
257
+ if request.kv_transfer_params is None:
258
+ return 0, False
259
+ assert isinstance(request.kv_transfer_params, NixlKVTransferParams)
260
+
261
+ # Remote prefill: get all prompt blocks from remote.
262
+ if request.kv_transfer_params.do_remote_prefill:
263
+ assert num_computed_tokens % self.block_size == 0
264
+ rounded_num_prompt_tokens = round_down(
265
+ len(request.prompt_token_ids), self.block_size)
266
+ count = max(rounded_num_prompt_tokens - num_computed_tokens, 0)
267
+ return count, count > 0
268
+
269
+ return 0, False
270
+
271
+ def update_state_after_alloc(self, request: "Request",
272
+ blocks: "KVCacheBlocks",
273
+ num_external_tokens: int):
274
+ if request.kv_transfer_params is None:
275
+ return
276
+
277
+ assert isinstance(request.kv_transfer_params, NixlKVTransferParams)
278
+ if request.kv_transfer_params.do_remote_prefill:
279
+ # NOTE(rob): if prompt < block_size, no remote blocks
280
+ # since the remote only sends fully computed blocks, so
281
+ # skip recving for this request. num_external_tokens
282
+ # should be 0 if there are no remote blocks.
283
+ if request.kv_transfer_params.remote_block_ids:
284
+ # Get unhashed blocks to pull from remote.
285
+ self._reqs_need_recv[request.request_id] = (
286
+ request, blocks.get_unhashed_block_ids())
287
+ else:
288
+ assert num_external_tokens == 0
289
+ # Only trigger 1 KV transfer per request.
290
+ request.kv_transfer_params.do_remote_prefill = False
291
+
292
+ def build_connector_meta(
293
+ self,
294
+ scheduler_output: SchedulerOutput,
295
+ ) -> KVConnectorMetadata:
296
+ meta = NixlConnectorMetadata()
297
+
298
+ # Loop through scheduled reqs and convert to ReqMeta.
299
+ for req_id, (req, block_ids) in self._reqs_need_recv.items():
300
+ assert isinstance(req.kv_transfer_params, NixlKVTransferParams)
301
+ meta.add_new_req(
302
+ request_id=req_id,
303
+ local_block_ids=block_ids,
304
+ kv_transfer_params=req.kv_transfer_params,
305
+ )
306
+
307
+ # Clear the list once workers start the transfers
308
+ self._reqs_need_recv.clear()
309
+
310
+ return meta
311
+
312
+ def request_finished(
313
+ self,
314
+ request: "Request",
315
+ block_ids: list[int],
316
+ ) -> tuple[bool, Optional[dict[str, Any]]]:
317
+ """
318
+ Once a request is finished, determine whether request blocks
319
+ should be freed now or will be sent asynchronously and freed later.
320
+ """
321
+
322
+ if request.kv_transfer_params is None:
323
+ return False, None
324
+ assert isinstance(request.kv_transfer_params, NixlKVTransferParams)
325
+
326
+ if ((not request.kv_transfer_params.do_remote_decode)
327
+ or (request.status != RequestStatus.FINISHED_LENGTH_CAPPED)):
328
+ return False, None
329
+
330
+ # Get computed blocks.
331
+ all_full = request.num_computed_tokens % self.block_size == 0
332
+ computed_block_ids = (block_ids if all_full else block_ids[:-1])
333
+
334
+ # If prompt < block_size, no xfer so free blocks immediately.
335
+ delay_free_blocks = len(computed_block_ids) > 0
336
+
337
+ return delay_free_blocks, NixlKVTransferParams(
338
+ do_remote_prefill=True,
339
+ do_remote_decode=False,
340
+ remote_block_ids=computed_block_ids,
341
+ remote_engine_id=self.engine_id,
342
+ remote_host=envs.VLLM_NIXL_SIDE_CHANNEL_HOST,
343
+ remote_port=envs.VLLM_NIXL_SIDE_CHANNEL_PORT,
344
+ ).__dict__
345
+
346
+
347
+class NixlConnectorWorker:
348
+ """Implementation of Worker side methods"""
349
+
350
+ def __init__(self, engine_id: str):
351
+ if NixlWrapper is None:
352
+ logger.error("NIXL is not available")
353
+ raise RuntimeError("NIXL is not available")
354
+ logger.info("Initializing NIXL wrapper")
355
+ logger.info("Initializing NIXL worker %s", engine_id)
356
+
357
+ # Agent.
358
+ self.nixl_wrapper = NixlWrapper(str(uuid.uuid4()), None)
359
+ # Map of engine_id -> agent_name.
360
+ self._remote_agents: dict[str, str] = {}
361
+
362
+ # Metadata.
363
+ self.engine_id = engine_id
364
+ self.rank = get_tensor_model_parallel_rank()
365
+ self.world_size = get_tensor_model_parallel_world_size()
366
+ self.tp_group = get_tp_group()
367
+
368
+ # KV Caches and nixl tracking data.
369
+ self.kv_caches: dict[str, torch.Tensor] = {}
370
+
371
+ # Map of engine_id -> kv_caches_base_addr
372
+ self.kv_caches_base_addr: dict[str, list[int]] = {}
373
+
374
+ # Number of NIXL regions. Currently one region per cache
375
+ # (so 1 per layer for MLA, otherwise 2 per layer)
376
+ self.num_regions = 0
377
+
378
+ # nixl_prepped_dlist_handle (int).
379
+ self.src_xfer_side_handle: int = 0
380
+ # Map of engine_id -> nixl_prepped_dlist_handle (int)].
381
+ self.dst_xfer_side_handles: dict[str, int] = {}
382
+
383
+ # Map of engine_id -> num_blocks.
384
+ self.dst_num_blocks: dict[str, int] = {}
385
+ self._registered_descs: list[Any] = []
386
+
387
+ # In progress transfers.
388
+ # [req_id -> list[handle]]
389
+ self._recving_transfers: defaultdict[str, list[Any]] = defaultdict(
390
+ list[Any])
391
+
392
+ # Complete transfer tracker. Used by the rank 0 to track finished
393
+ # transactions on ranks 1 to N-1.
394
+ # [req_id -> count]
395
+ self._done_recving_count: defaultdict[str,
396
+ int] = defaultdict(lambda: 0)
397
+ self._done_sending_count: defaultdict[str,
398
+ int] = defaultdict(lambda: 0)
399
+
400
+ # Background thread for establishing new connections.
401
+ self._nixl_handshake_listener_t: Optional[threading.Thread] = None
402
+
403
+ @staticmethod
404
+ def _nixl_handshake_listener(metadata: NixlAgentMetadata,
405
+ ready_event: threading.Event, rank: int):
406
+ """Background thread for getting new NIXL handshakes."""
407
+ # NOTE(rob): this is a simple implementation. We will move
408
+ # to a better approach like an ETCD server in the future.
409
+
410
+ # NOTE(rob): to support heterogeneous TP, we will have to
411
+ # move this into the scheduler rather than worker, since
412
+ # each rank needs the metadata of all other ranks (whereas
413
+ # in this setup, each rank only gets one other rank's meta.
414
+
415
+ encoder = msgspec.msgpack.Encoder()
416
+ encoded_data = encoder.encode(metadata)
417
+ size_in_bytes = len(encoded_data)
418
+ logger.debug("Size of encoded NixlAgentMetadata: %s bytes",
419
+ str(size_in_bytes))
420
+
421
+ # Listen for new requests for metadata.
422
+ host = envs.VLLM_NIXL_SIDE_CHANNEL_HOST
423
+ # NOTE(rob): we need each rank to have a unique port. This
424
+ # hack to keeps us moving. We will switch when moving to etcd
425
+ # or where we have a single ZMQ socket in the scheduler.
426
+ port = envs.VLLM_NIXL_SIDE_CHANNEL_PORT + rank
427
+ path = f"tcp://{host}:{port}"
428
+ logger.debug("Starting listening on path: %s", path)
429
+ with zmq_ctx(zmq.ROUTER, path) as sock:
430
+ ready_event.set()
431
+ while True:
432
+ identity, _, msg = sock.recv_multipart()
433
+ if msg != GET_META_MSG:
434
+ logger.warning(
435
+ "Connection listener got unexpected message %s", msg)
436
+ sock.send_multipart((identity, b"", encoded_data))
437
+
438
+ def _nixl_handshake(self, host: str, port: int):
439
+ """Do a NIXL handshake with a remote instance."""
440
+
441
+ start_time = time.perf_counter()
442
+ # NOTE(rob): we need each rank to have a unique port. This is
443
+ # a hack to keep us moving. We will switch when moving to etcd
444
+ # or where we have a single ZMQ socket in the scheduler.
445
+ path = f"tcp://{host}:{port + self.rank}"
446
+ logger.debug("Querying metadata on path: %s", path)
447
+ with zmq_ctx(zmq.REQ, path) as sock:
448
+ # Send query for the request.
449
+ sock.send(GET_META_MSG)
450
+ metadata_bytes = sock.recv()
451
+ decoder = msgspec.msgpack.Decoder(NixlAgentMetadata)
452
+ metadata = decoder.decode(metadata_bytes)
453
+ got_metadata_time = time.perf_counter()
454
+
455
+ # Register Remote agent.
456
+ self.add_remote_agent(metadata)
457
+ setup_agent_time = time.perf_counter()
458
+
459
+ logger.debug("NIXL handshake: get metadata took: %s",
460
+ got_metadata_time - start_time)
461
+ logger.debug("NIXL handshake: add agent took: %s",
462
+ setup_agent_time - got_metadata_time)
463
+
464
+ def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
465
+ """Register the KV Cache data in nixl."""
466
+
467
+ _, first_kv_cache = next(iter(kv_caches.items()))
468
+ kv_elem_size = first_kv_cache.element_size()
469
+
470
+ # TODO(tms): Find a more robust way to detect and handle MLA
471
+ use_mla = len(first_kv_cache.shape) == 3
472
+ if use_mla:
473
+ # MLA case.
474
+ self.num_blocks = first_kv_cache.shape[0]
475
+ block_rank = 2 # [block_size, latent_dim]
476
+ block_shape = first_kv_cache.shape[-block_rank:]
477
+ else:
478
+ # [2 (k and v), num_blocks, ...]
479
+ self.num_blocks = first_kv_cache.shape[1]
480
+ block_rank = 3 # [block_size, kv_heads, head_dim]
481
+ block_shape = first_kv_cache.shape[-block_rank:]
482
+
483
+ # TODO(tms): self.block_len needs to be per-layer for sliding window,
484
+ # hybrid attn, etc
485
+ self.block_len = kv_elem_size * math.prod(block_shape)
486
+
487
+ logger.debug("Registering KV_Caches. use_mla: %s, shape %s", use_mla,
488
+ first_kv_cache.shape)
489
+ logger.debug("num_blocks: %s, block_shape: %s", self.num_blocks,
490
+ block_shape)
491
+ logger.debug("Per layer kv cache size: %s", first_kv_cache.shape)
492
+ self.dst_num_blocks[self.engine_id] = self.num_blocks
493
+ self.kv_caches = kv_caches
494
+ kv_caches_base_addr = []
495
+ caches_data = []
496
+
497
+ # Note(tms): I modified this from the original region setup code.
498
+ # K and V are now in different regions. Advantage is that we can
499
+ # elegantly support MLA and any cases where the K and V tensors
500
+ # are non-contiguous (it's not locally guaranteed that they will be)
501
+ # Disadvantage is that the encoded NixlAgentMetadata is now larger
502
+ # (roughly 8KB vs 5KB).
503
+ for cache_or_caches in kv_caches.values():
504
+ # Normalize to always be a list of caches
505
+ cache_list = [cache_or_caches] if use_mla else cache_or_caches
506
+ for cache in cache_list:
507
+ base_addr = cache.data_ptr()
508
+ region_len = self.num_blocks * self.block_len
509
+ caches_data.append((base_addr, region_len, self.rank, ""))
510
+ kv_caches_base_addr.append(base_addr)
511
+ self.kv_caches_base_addr[self.engine_id] = kv_caches_base_addr
512
+ self.num_regions = len(caches_data)
513
+
514
+ descs = self.nixl_wrapper.get_reg_descs(caches_data, "VRAM")
515
+ logger.debug("Registering descs: %s", caches_data)
516
+ self.nixl_wrapper.register_memory(descs)
517
+ logger.debug("Done registering descs")
518
+
519
+ self._registered_descs.append(descs)
520
+
521
+ # After KV Caches registered, listen for new connections.
522
+ metadata = NixlAgentMetadata(
523
+ engine_id=self.engine_id,
524
+ agent_metadata=self.nixl_wrapper.get_agent_metadata(),
525
+ kv_caches_base_addr=self.kv_caches_base_addr[self.engine_id],
526
+ num_blocks=self.num_blocks,
527
+ )
528
+ ready_event = threading.Event()
529
+ self._nixl_handshake_listener_t = threading.Thread(
530
+ target=self._nixl_handshake_listener,
531
+ args=(metadata, ready_event, self.rank),
532
+ daemon=True,
533
+ name="nixl_handshake_listener")
534
+ self._nixl_handshake_listener_t.start()
535
+ ready_event.wait()
536
+
537
+ def add_remote_agent(self, nixl_agent_meta: NixlAgentMetadata):
538
+ engine_id = nixl_agent_meta.engine_id
539
+ if engine_id in self._remote_agents:
540
+ return
541
+
542
+ self._remote_agents[engine_id] = self.nixl_wrapper.add_remote_agent(
543
+ nixl_agent_meta.agent_metadata)
544
+ self.kv_caches_base_addr[
545
+ engine_id] = nixl_agent_meta.kv_caches_base_addr
546
+
547
+ # Create src descs and xfer side handles.
548
+ blocks_data = []
549
+ for base_addr in self.kv_caches_base_addr[self.engine_id]:
550
+ for block_id in range(self.num_blocks):
551
+ block_offset = block_id * self.block_len
552
+ # (addr, len, device id)
553
+ blocks_data.append(
554
+ (base_addr + block_offset, self.block_len, self.rank))
555
+ logger.debug("Created %s blocks for src engine %s and rank %s",
556
+ len(blocks_data), self.engine_id, self.rank)
557
+
558
+ # Register with NIXL.
559
+ descs = self.nixl_wrapper.get_xfer_descs(blocks_data, "VRAM")
560
+ self.src_xfer_side_handle = self.nixl_wrapper.prep_xfer_dlist(
561
+ "NIXL_INIT_AGENT", descs)
562
+
563
+ # Create dst descs and xfer side handles.
564
+ self.dst_num_blocks[engine_id] = nixl_agent_meta.num_blocks
565
+ blocks_data = []
566
+ for base_addr in self.kv_caches_base_addr[engine_id]:
567
+ for block_id in range(nixl_agent_meta.num_blocks):
568
+ block_offset = block_id * self.block_len
569
+ # (addr, len, device id)
570
+ blocks_data.append(
571
+ (base_addr + block_offset, self.block_len, self.rank))
572
+ logger.debug("Created %s blocks for dst engine %s and rank %s",
573
+ len(blocks_data), engine_id, self.rank)
574
+
575
+ # Register with NIXL.
576
+ descs = self.nixl_wrapper.get_xfer_descs(blocks_data, "VRAM")
577
+ self.dst_xfer_side_handles[
578
+ engine_id] = self.nixl_wrapper.prep_xfer_dlist(
579
+ self._remote_agents[engine_id], descs)
580
+
581
+ def get_finished(self) -> tuple[set[str], set[str]]:
582
+ """
583
+ Get requests that are done sending or recving.
584
+
585
+ In TP>1 setup, each rank exchanges KVs with its counterpart
586
+ ranks independently. get_finished() runs in a worker creates
587
+ the done_sending and done_recving sets that are sent to the
588
+ scheduler via ModelRunnerOutput by Rank 0. To ensure trnxs
589
+ are done before adding to finished, Ranks 1 to N-1 communicate
590
+ to Rank 0 once their transaction is done + Rank 0 returns
591
+ finished sets to Scheduler only once all ranks are done.
592
+ """
593
+ done_sending = self._get_new_notifs()
594
+ done_recving = self._pop_done_transfers(self._recving_transfers)
595
+ if len(done_sending) > 0 or len(done_recving) > 0:
596
+ logger.debug(
597
+ "Rank %s, get_finished: %s requests done sending "
598
+ "and %s requests done recving", self.rank, len(done_sending),
599
+ len(done_recving))
600
+
601
+ if self.world_size == 1:
602
+ return done_sending, done_recving
603
+
604
+ # Rank 0: get finished from all other ranks.
605
+ if self.rank == 0:
606
+ for req_id in done_sending:
607
+ self._done_sending_count[req_id] += 1
608
+ for req_id in done_recving:
609
+ self._done_recving_count[req_id] += 1
610
+
611
+ # Keep track of how many other ranks have finished.
612
+ other_ranks_finished_ids: list[str] = []
613
+ for i in range(1, self.world_size):
614
+ other_ranks_finished_ids.extend(
615
+ self.tp_group.recv_object(src=i))
616
+ for req_id in other_ranks_finished_ids:
617
+ if (req_id in self._done_recving_count
618
+ or req_id in self._recving_transfers):
619
+ self._done_recving_count[req_id] += 1
620
+ else:
621
+ self._done_sending_count[req_id] += 1
622
+
623
+ # Return ids that finished on all ranks to the scheduler.
624
+ all_done_recving: set[str] = set()
625
+ for req_id in list(self._done_recving_count.keys()):
626
+ if self._done_recving_count[req_id] == self.world_size:
627
+ del self._done_recving_count[req_id]
628
+ all_done_recving.add(req_id)
629
+
630
+ all_done_sending: set[str] = set()
631
+ for req_id in list(self._done_sending_count.keys()):
632
+ if self._done_sending_count[req_id] == self.world_size:
633
+ del self._done_sending_count[req_id]
634
+ all_done_sending.add(req_id)
635
+
636
+ return all_done_sending, all_done_recving
637
+
638
+ # Ranks 1 to N-1: send finished ids to Rank 0.
639
+ else:
640
+ finished_req_ids = list(done_recving.union(done_sending))
641
+ self.tp_group.send_object(finished_req_ids, dst=0)
642
+
643
+ # Unused as only Rank 0 results are sent to scheduler.
644
+ return done_sending, done_recving
645
+
646
+ def _get_new_notifs(self) -> set[str]:
647
+ """Get req_ids which got a remote xfer message."""
648
+
649
+ notified_req_ids: set[str] = set()
650
+ for req_ids in self.nixl_wrapper.get_new_notifs().values():
651
+ for req_id in req_ids:
652
+ assert req_id not in notified_req_ids
653
+ notified_req_ids.add(req_id.decode("utf-8"))
654
+ return notified_req_ids
655
+
656
+ def _pop_done_transfers(self, transfers: dict[str, list[int]]) -> set[str]:
657
+ """
658
+ Pop completed xfers by checking for DONE state.
659
+ Args:
660
+ transfers: dict of req_id -> list[running_xfer]
661
+ Returns:
662
+ set of req_ids that have all done xfers
663
+ """
664
+ done_req_ids: set[str] = set()
665
+ for req_id, handles in list(transfers.items()):
666
+ running_reqs = []
667
+ for handle in handles:
668
+ xfer_state = self.nixl_wrapper.check_xfer_state(handle)
669
+ if xfer_state == "DONE":
670
+ # TODO ptarasiewicz: why abort is throwing errors?
671
+ # self.nixl_wrapper.release_xfer_handle(handle)
672
+ continue
673
+ if xfer_state == "PROC":
674
+ running_reqs.append(handle)
675
+ else:
676
+ raise RuntimeError("Transfer failed with state %s",
677
+ xfer_state)
678
+ if len(running_reqs) == 0:
679
+ done_req_ids.add(req_id)
680
+ del transfers[req_id]
681
+ else:
682
+ transfers[req_id] = running_reqs
683
+ return done_req_ids
684
+
685
+ def start_load_kv(self, metadata: NixlConnectorMetadata):
686
+ """
687
+ Start loading by triggering non-blocking nixl_xfer.
688
+ We check for these trnxs to complete in each step().
689
+ """
690
+ for req_id, meta in metadata.requests.items():
691
+ logger.debug(
692
+ "start_load_kv for request %s from remote engine %s. "
693
+ "Num local_block_ids: %s. Num remote_block_ids: %s. ", req_id,
694
+ meta.remote_engine_id, len(meta.local_block_ids),
695
+ len(meta.remote_block_ids))
696
+ self._read_blocks(
697
+ request_id=req_id,
698
+ dst_engine_id=meta.remote_engine_id,
699
+ local_block_ids=meta.local_block_ids,
700
+ remote_block_ids=meta.remote_block_ids,
701
+ remote_host=meta.remote_host,
702
+ remote_port=meta.remote_port,
703
+ )
704
+
705
+ def _read_blocks(
706
+ self,
707
+ local_block_ids: list[int],
708
+ remote_block_ids: list[int],
709
+ remote_host: str,
710
+ remote_port: int,
711
+ dst_engine_id: str,
712
+ request_id: str,
713
+ ):
714
+ # NOTE(rob): this takes ~2s. We need to get this off the hotpath.
715
+ if dst_engine_id not in self._remote_agents:
716
+ self._nixl_handshake(remote_host, remote_port)
717
+
718
+ # NOTE(rob): having the staging blocks be on the READER side is
719
+ # not going to work well (since we will have to call rearrange tensors).
720
+ # after we detect the txn is complete (which means we cannot make the
721
+ # read trxn async easily). If we want to make "READ" happen cleanly,
722
+ # then we will need to have the staging blocks on the remote side.
723
+
724
+ # NOTE(rob): according to nvidia the staging blocks are used to
725
+ # saturate IB with heterogeneous TP sizes. We should remove the staging
726
+ # blocks until we are ready.
727
+
728
+ # Full prefix cache hit: do not need to read remote blocks,
729
+ # just notify P worker that we have the blocks we need.
730
+ num_local_blocks = len(local_block_ids)
731
+ if num_local_blocks == 0:
732
+ self.nixl_wrapper.send_notif(dst_engine_id,
733
+ notif_msg=request_id.encode("utf-8"))
734
+ return
735
+
736
+ # Partial prefix cache hit: just read uncomputed blocks.
737
+ num_remote_blocks = len(remote_block_ids)
738
+ assert num_local_blocks <= num_remote_blocks
739
+ if num_local_blocks < num_remote_blocks:
740
+ remote_block_ids = remote_block_ids[-num_local_blocks:]
741
+
742
+ # Get side handles.
743
+ local_xfer_side_handle = self.src_xfer_side_handle
744
+ remote_xfer_side_handle = self.dst_xfer_side_handles[dst_engine_id]
745
+
746
+ # Get descs ids.
747
+ remote_block_descs_ids = self._get_block_descs_ids(
748
+ dst_engine_id, remote_block_ids)
749
+ local_block_descs_ids = self._get_block_descs_ids(
750
+ self.engine_id, local_block_ids)
751
+ assert len(local_block_descs_ids) == len(remote_block_descs_ids)
752
+
753
+ # Prepare transfer with Nixl.
754
+ handle = self.nixl_wrapper.make_prepped_xfer(
755
+ "READ",
756
+ local_xfer_side_handle,
757
+ local_block_descs_ids,
758
+ remote_xfer_side_handle,
759
+ remote_block_descs_ids,
760
+ notif_msg=request_id.encode("utf-8"),
761
+ )
762
+
763
+ # Begin async xfer.
764
+ self.nixl_wrapper.transfer(handle)
765
+
766
+ # Use handle to check completion in future step().
767
+ self._recving_transfers[request_id].append(handle)
768
+
769
+ def _get_block_descs_ids(self, engine_id: str,
770
+ block_ids: list[int]) -> list[int]:
771
+ """Get the descs ids for a set of block ids."""
772
+
773
+ # range(1) for MLA, range(2) otherwise.
774
+ region_ids = range(self.num_regions)
775
+ num_blocks = self.dst_num_blocks[engine_id]
776
+
777
+ # Compute the desc ids for each block.
778
+ descs_ids: list[int] = []
779
+ for reg_id in region_ids:
780
+ for block_id in block_ids:
781
+ descs_ids.append(reg_id * num_blocks + block_id)
782
+ return descs_ids
783
+
784
+
785
+@contextlib.contextmanager
786
+def zmq_ctx(socket_type: Any, addr: str) -> Iterator[zmq.Socket]:
787
+ """Context manager for a ZMQ socket"""
788
+
789
+ ctx: Optional[zmq.Context] = None
790
+ try:
791
+ ctx = zmq.Context() # type: ignore[attr-defined]
792
+
793
+ if socket_type == zmq.ROUTER:
794
+ socket = ctx.socket(zmq.ROUTER)
795
+ socket.bind(addr)
796
+ elif socket_type == zmq.REQ:
797
+ socket = ctx.socket(zmq.REQ)
798
+ socket.connect(addr)
799
+ else:
800
+ raise ValueError(f"Unexpected socket type: {socket_type}")
801
+
802
+ yield socket
803
+ finally:
804
+ if ctx is not None:
805
+ ctx.destroy(linger=0)