@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / c56ed8bb0e
Fork 0

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 15, 07:15 UTC

[Bugfix][Nixl] Fix full prefix cache hit bug (#18632)

Signed-off-by: rshaw@neuralmagic.com <robertgshaw2@gmail.com> Signed-off-by: Nick Hill <nhill@redhat.com> Co-authored-by: Nick Hill <nhill@redhat.com>

Robert Shaw committed Jun 4, 2025 at 22:07 UTC c56ed8bb0e0e335033797044e33acc2b00e06b79
4 files changed +97 -81
tests/v1/kv_connector/unit/test_multi_connector.py
+47 -29
@@ -12,6 +12,7 @@ from vllm.distributed.kv_transfer.kv_connector.factory import (
12 KVConnectorFactory)
13 from vllm.distributed.kv_transfer.kv_connector.v1.shared_storage_connector import ( # noqa
14 SharedStorageConnector)
15 +from vllm.v1.core.kv_cache_manager import KVCacheBlocks
16
17 MODEL_NAME = "meta-llama/Llama-3.2-1B-Instruct"
18
@@ -32,7 +33,7 @@ class TestSharedStorageConnector(SharedStorageConnector):
33 self.call_record: dict[str, int] = defaultdict(int)
34 # Use a unique temp file per connector
35 self._event_file = tempfile.gettempdir(
35 - ) + f"/connector_{self.name}_events.log"
36 + ) + f"/connector_{self.name}-{self.role.name}_events.log"
37 # Start with an empty file
38 with open(self._event_file, "w") as _:
39 pass
@@ -52,10 +53,19 @@ class TestSharedStorageConnector(SharedStorageConnector):
53
54 def wrapper(*args, **kwargs):
55 self.call_record[name] += 1
56 +
57 + # Include args that we're interested in
58 + to_log = [name]
59 + for arg in args:
60 + if isinstance(arg, int):
61 + to_log.append(str(arg))
62 + elif isinstance(arg, KVCacheBlocks):
63 + to_log.append(f"num_blocks={len(arg.blocks)}")
64 +
65 # Log the event as a line to the file
66 try:
67 with open(self._event_file, "a") as f:
58 - f.write(name + "\n")
68 + f.write(' '.join(to_log) + "\n")
69 except Exception as e:
70 print(f"[ERROR] Could not log event {name} "
71 f"for {self.name}: {e}")
@@ -162,15 +172,23 @@ def test_multi_shared_storage_connector_consistency():
172 f"{storage_1_path} and {storage_2_path}")
173
174 events = get_connector_events()
165 - # get_num_new_matched_tokens will be called on each connector in turn.
166 - # neither of them have hits so update_state_after_alloc won't be called.
167 - assert events["storage1"][:3] == [
168 - 'get_num_new_matched_tokens', 'build_connector_meta',
169 - 'bind_connector_metadata'
175 + # get_num_new_matched_tokens and update_state_after_alloc will be called
176 + # on each connector in turn.
177 + assert events["storage1-SCHEDULER"][:3] == [
178 + 'get_num_new_matched_tokens 0',
179 + 'update_state_after_alloc num_blocks=0 0', 'build_connector_meta'
180 + ]
181 + assert events["storage1-WORKER"][:5] == [
182 + 'register_kv_caches', 'bind_connector_metadata', 'start_load_kv',
183 + 'wait_for_layer_load', 'save_kv_layer'
184 + ]
185 + assert events["storage2-SCHEDULER"][:3] == [
186 + 'get_num_new_matched_tokens 0',
187 + 'update_state_after_alloc num_blocks=0 0', 'build_connector_meta'
188 ]
171 - assert events["storage2"][:3] == [
172 - 'get_num_new_matched_tokens', 'build_connector_meta',
173 - 'bind_connector_metadata'
189 + assert events["storage2-WORKER"][:5] == [
190 + 'register_kv_caches', 'bind_connector_metadata', 'start_load_kv',
191 + 'wait_for_layer_load', 'save_kv_layer'
192 ]
193
194 # Reset prefix cache or else we'll just get the tokens back from there.
@@ -182,16 +200,16 @@ def test_multi_shared_storage_connector_consistency():
200
201 events = get_connector_events()
202 # get_num_new_matched_tokens will return new tokens from the first
185 - # connector so update_state_after_alloc will be called once blocks
186 - # are allocated for the first connector.
187 - # get_num_new_matched_tokens *won't* be called on the second connector
188 - # in this case.
189 - assert events["storage1"][:4] == [
190 - 'get_num_new_matched_tokens', 'update_state_after_alloc',
191 - 'build_connector_meta', 'bind_connector_metadata'
203 + # connector so update_state_after_alloc will be with allocated blocks
204 + # on that one but with zero blocks for others (first nonzero match is
205 + # chosen).
206 + assert events["storage1-SCHEDULER"][:3] == [
207 + 'get_num_new_matched_tokens 0',
208 + 'update_state_after_alloc num_blocks=7 96', 'build_connector_meta'
209 ]
193 - assert events["storage2"][:2] == [
194 - 'build_connector_meta', 'bind_connector_metadata'
210 + assert events["storage2-SCHEDULER"][:3] == [
211 + 'get_num_new_matched_tokens 0',
212 + 'update_state_after_alloc num_blocks=0 0', 'build_connector_meta'
213 ]
214
215 # Delete storage1 connector state
@@ -205,17 +223,17 @@ def test_multi_shared_storage_connector_consistency():
223 _ = llm.generate(PROMPTS, SAMPLING_PARAMS)
224
225 events = get_connector_events()
208 - # get_num_new_matched_tokens will be called for the first connector but it
209 - # won't have a hit so update_state_after_alloc won't be called.
210 - # get_num_new_matched_tokens will also be called on the second connector,
211 - # but it should have a hit so update_state_after_alloc will be called.
212 - assert events["storage1"][:3] == [
213 - 'get_num_new_matched_tokens', 'build_connector_meta',
214 - 'bind_connector_metadata'
226 + # get_num_new_matched_tokens will be called for both connectors but will
227 + # return 0 from the first connector, but the second connector should have
228 + # a hit, so update_state_after_alloc will only be called with allocated
229 + # blocks for the second connector.
230 + assert events["storage1-SCHEDULER"][:3] == [
231 + 'get_num_new_matched_tokens 0',
232 + 'update_state_after_alloc num_blocks=0 0', 'build_connector_meta'
233 ]
216 - assert events["storage2"][:4] == [
217 - 'get_num_new_matched_tokens', 'update_state_after_alloc',
218 - 'build_connector_meta', 'bind_connector_metadata'
234 + assert events["storage2-SCHEDULER"][:3] == [
235 + 'get_num_new_matched_tokens 0',
236 + 'update_state_after_alloc num_blocks=7 96', 'build_connector_meta'
237 ]
238
239 # Clean up
vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py
+26 -15
@@ -12,12 +12,12 @@ from vllm.distributed.kv_transfer.kv_connector.factory import (
12 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
13 KVConnectorBase_V1, KVConnectorMetadata, KVConnectorRole)
14 from vllm.logger import init_logger
15 +from vllm.v1.core.kv_cache_manager import KVCacheBlocks
16 from vllm.v1.core.sched.output import SchedulerOutput
17
18 if TYPE_CHECKING:
19 from vllm.attention.backends.abstract import AttentionMetadata
20 from vllm.forward_context import ForwardContext
20 - from vllm.v1.core.kv_cache_manager import KVCacheBlocks
21 from vllm.v1.request import Request
22
23 logger = init_logger(__name__)
@@ -51,8 +51,9 @@ class MultiConnector(KVConnectorBase_V1):
51 self._connectors.append(
52 KVConnectorFactory.create_connector_v1(temp_config, role))
53
54 - # A mapping from request id to the connector that is assigned to it.
55 - self._requests_to_connector: dict[str, KVConnectorBase_V1] = {}
54 + # A mapping from request id to the index of the connector chosen to
55 + # load the request from (if any).
56 + self._requests_to_connector: dict[str, int] = {}
57
58 # Keeps track of *additional* remaining async saves (beyond 1) to be
59 # finished per request. Not needed for async loads since we only allow
@@ -136,25 +137,31 @@ class MultiConnector(KVConnectorBase_V1):
137 request: "Request",
138 num_computed_tokens: int,
139 ) -> tuple[int, bool]:
139 - for c in self._connectors:
140 + to_return = (0, False)
141 + for i, c in enumerate(self._connectors):
142 toks, load_async = c.get_num_new_matched_tokens(
143 request, num_computed_tokens)
144 # The first connector that has new matched tokens will be assigned
145 # to this request.
144 - if toks > 0:
145 - self._requests_to_connector[request.request_id] = c
146 - return toks, load_async
147 - return 0, False
146 + if to_return[0] == 0 and toks > 0:
147 + self._requests_to_connector[request.request_id] = i
148 + to_return = (toks, load_async)
149 + return to_return
150
151 def update_state_after_alloc(self, request: "Request",
152 blocks: "KVCacheBlocks",
153 num_external_tokens: int):
152 - # If the request is not assigned to any connector, we do nothing.
153 - if request.request_id not in self._requests_to_connector:
154 - return
155 - # We assume that the request is assigned to only one connector.
156 - c = self._requests_to_connector.pop(request.request_id)
157 - c.update_state_after_alloc(request, blocks, num_external_tokens)
154 + chosen_connector = self._requests_to_connector.get(
155 + request.request_id, -1)
156 + for i, c in enumerate(self._connectors):
157 + if i == chosen_connector:
158 + # Forward call to the chosen connector (if any).
159 + c.update_state_after_alloc(request, blocks,
160 + num_external_tokens)
161 + else:
162 + # Call with empty blocks for other connectors.
163 + c.update_state_after_alloc(request,
164 + KVCacheBlocks.create_empty(), 0)
165
166 def build_connector_meta(
167 self,
@@ -170,7 +177,7 @@ class MultiConnector(KVConnectorBase_V1):
177 def request_finished(
178 self,
179 request: "Request",
173 - blocks: "KVCacheBlocks",
180 + blocks: list[int],
181 ) -> tuple[bool, Optional[dict[str, Any]]]:
182 async_saves = 0
183 kv_txfer_params = None
@@ -187,4 +194,8 @@ class MultiConnector(KVConnectorBase_V1):
194 kv_txfer_params = txfer_params
195 if async_saves > 1:
196 self._extra_async_saves[request.request_id] = async_saves - 1
197 +
198 + # Clean up other state for this request.
199 + self._requests_to_connector.pop(request.request_id, None)
200 +
201 return async_saves > 0, kv_txfer_params
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+20 -33
@@ -221,15 +221,6 @@ class NixlConnectorScheduler:
221 if count > 0:
222 return count, True
223
224 - # NOTE: if count is 0 here, we have less than block_size
225 - # tokens to pull after subtracting the local prefix cache hit.
226 - # The remote only sends fully computed blocks, so there is
227 - # nothing to transfer but we still need to notify the
228 - # prefill worker so that the remote blocks are freed.
229 - if all(p in params for p in ("remote_engine_id", "remote_host",
230 - "remote_port")):
231 - self._reqs_need_recv[request.request_id] = (request, [])
232 -
224 # No remote prefill for this request.
225 return 0, False
226
@@ -247,9 +238,14 @@ class NixlConnectorScheduler:
238 if params.get("remote_block_ids"):
239 if all(p in params for p in ("remote_engine_id", "remote_host",
240 "remote_port")):
241 + # If remote_blocks and num_external_tokens = 0, we have
242 + # a full prefix cache hit on the D worker. We need to call
243 + # send_notif in _read_blocks to free the memory on the P.
244 + local_block_ids = (blocks.get_unhashed_block_ids()
245 + if num_external_tokens > 0 else [])
246 # Get unhashed blocks to pull from remote.
247 self._reqs_need_recv[request.request_id] = (
252 - request, blocks.get_unhashed_block_ids())
248 + request, local_block_ids)
249 else:
250 logger.warning(
251 "Got invalid KVTransferParams: %s. This "
@@ -268,15 +264,6 @@ class NixlConnectorScheduler:
264 # Loop through scheduled reqs and convert to ReqMeta.
265 for req_id, (req, block_ids) in self._reqs_need_recv.items():
266 assert req.kv_transfer_params is not None
271 - # For the case where there are no remote blocks to pull
272 - # (block_ids is empty), we don't need to schedule
273 - # an async read on the worker side.
274 - if not block_ids:
275 - logger.debug(
276 - "Skipping adding request %s to NixlConnectorMetadata, "
277 - "as there are no remote blocks to pull", req_id)
278 - continue
279 -
267 meta.add_new_req(
268 request_id=req_id,
269 local_block_ids=block_ids,
@@ -660,26 +647,26 @@ class NixlConnectorWorker:
647
648 # Number of D TP workers reading from a single P TP worker. This is
649 # 1 when P and D `--tensor-parallel-size` match.
663 - assert self._tp_size[self.engine_id] % self._tp_size[engine_id] == 0, \
664 - "Local TP size must be divisible by remote TP size."
650 + assert self._tp_size[self.engine_id] % self._tp_size[engine_id] == 0, (
651 + "Local TP size must be divisible by remote TP size.")
652 tp_ratio = self._tp_size[self.engine_id] // self._tp_size[engine_id]
666 - assert tp_ratio > 0, "Decode TP cannot be smaller than"
667 - " prefill TP"
653 + assert tp_ratio > 0, "Decode TP cannot be smaller than prefill TP"
654 if self.use_mla:
655 # With MLA the only difference is in the number of blocks.
670 - remote_block_size = nixl_agent_meta.block_len / (
656 + remote_block_size = nixl_agent_meta.block_len // (
657 self.slot_size_bytes)
658 assert self.block_len == nixl_agent_meta.block_len
659 else:
674 - remote_block_size = nixl_agent_meta.block_len / (
660 + remote_block_size = nixl_agent_meta.block_len // (
661 self.slot_size_bytes * tp_ratio)
662
677 - assert nixl_agent_meta.block_len == self.block_len * tp_ratio, \
678 - "Remote P worker KV layer cache must be of shape [2, N, \
679 - local_kv_heads*tp_ratio, block_size, head_dim] and same dtype."
663 + assert nixl_agent_meta.block_len == self.block_len * tp_ratio, (
664 + "Remote P worker KV layer cache must be of shape [2, N, "
665 + "local_kv_heads*tp_ratio, block_size, head_dim] and same dtype."
666 + )
667
681 - assert self.block_size == remote_block_size, "Remote P worker with \
682 - different block size is not supported"
668 + assert self.block_size == remote_block_size, "Remote P worker with "
669 + "different block size is not supported"
670
671 assert self.num_blocks >= nixl_agent_meta.num_blocks
672
@@ -712,9 +699,9 @@ class NixlConnectorWorker:
699 # (addr, len, device id)
700 blocks_data.append((addr, self.block_len, remote_tp_rank))
701 logger.debug(
715 - "Created %s blocks for dst engine %s with remote rank %s and " \
716 - "local rank %s",
717 - len(blocks_data), engine_id, remote_tp_rank, self.tp_rank)
702 + "Created %s blocks for dst engine %s with remote rank %s and "
703 + "local rank %s", len(blocks_data), engine_id, remote_tp_rank,
704 + self.tp_rank)
705
706 # Register with NIXL.
707 descs = self.nixl_wrapper.get_xfer_descs(blocks_data, "VRAM")
vllm/v1/core/sched/scheduler.py
+4 -4
@@ -424,11 +424,11 @@ class Scheduler(SchedulerInterface):
424 # The request cannot be scheduled.
425 break
426
427 - # KVConnector: update internal state after allocation.
427 + # KVTransfer: the connector uses this info to determine
428 + # if a load is needed. Note that
429 # This information is used to determine if a load is
430 # needed for this request.
430 - if num_external_computed_tokens:
431 - assert self.connector is not None
431 + if self.connector is not None:
432 self.connector.update_state_after_alloc(
433 request,
434 new_computed_blocks + new_blocks,
@@ -841,7 +841,7 @@ class Scheduler(SchedulerInterface):
841 }
842
843 finished_req_ids = self.finished_req_ids_dict
844 - if finished_req_ids is not None:
844 + if finished_req_ids:
845 # Include ids of requests that finished since last outputs
846 # were sent.
847 for client_index, finished_set in finished_req_ids.items():