Files
vllm-omni/vllm_omni/core/prefix_cache.py
T

738 lines
32 KiB
Python

"""
Utilities for Prefix Caching in Omni models.
"""
from dataclasses import dataclass, field
import torch
from vllm.logger import init_logger
from vllm.v1.worker.gpu_input_batch import InputBatch
from vllm_omni.utils.mm_outputs import build_mm_cpu, to_payload_element
logger = init_logger(__name__)
@dataclass
class _PendingAsyncWrite:
"""One in-flight async write into the prefix cache.
Holds the CPU tensors that PyTorch implicitly allocated via
``.to("cpu", non_blocking=True)`` together with a CUDA event recorded
on the copy stream. The consumer calls ``event.synchronize()`` once
(blocking) to guarantee the D2H has completed before reading.
"""
event: "torch.cuda.Event"
num_tokens: int
slots_cpu: torch.Tensor
hidden_cpu: torch.Tensor | None
mm_cpu: dict[str, torch.Tensor] = field(default_factory=dict)
class OmniTensorPrefixCache:
"""Prefix cache for hidden states (model outputs) and model specific
multimodal outputs.
This class implements prefix caching in a non-invasive way on top of
vLLM by leveraging the same slot mappings that the vLLM scheduler uses
for the KV Cache.
Conceptually, this means we are mapping vLLM's cache mapping:
(num_blocks, block_size)
to 3D tensors of shape:
(num_blocks, block_size, feature_size)
Note that feature_size may vary across multimodal_outputs.
"""
def __init__(
self,
num_blocks: int,
block_size: int,
hidden_size: int,
hs_dtype: torch.dtype,
):
self.num_blocks = num_blocks
self.block_size = block_size
self.default_hidden_size = hidden_size
# Initialize the hidden states cache immediately
self.hidden_states_cache = self._get_cache_tensor(dtype=hs_dtype)
# Defer initialization of the mm_outputs_cache until we
# actually see mm output tensors dependent on num tokens.
self.mm_outputs_cache = {}
self.mm_cache_keys = set()
self._new_req_cache_hit_ids: set[str] = set()
self._deferred_mm_outputs: dict[str, dict[str, list[tuple[int, torch.Tensor]]]] = {}
# Async write pipeline state:
# - one dedicated copy stream
# - one in-flight pending write at a time (the producer is exactly
# one forward step ahead of the consumer)
# - schedule_async_write() consumes the *previous* pending write
# (via event.synchronize() + scatter) before kicking off the new
# D2H. By the time we hit synchronize, the previous step's GPU
# work has been done for ~one forward step, so the sync is
# near-zero wall-clock.
self._async_initialized: bool = False
self._async_copy_stream: torch.cuda.Stream | None = None
self._pending_write: _PendingAsyncWrite | None = None
def maybe_init_missing_mm_cache_keys(self, multimodal_outputs: dict, seq_len: int):
"""Given multimodal outputs from executing the model, dynamically
determine which multimodal outputs are tensors depending on sequence
length and should be cached, and initialize the cache tensors
accordingly.
NOTE: This is done to avoid the need for explicit specification of
cache keys for every model/stage and aligns with the current way
that we slice the multimodal outputs based on the first dimension.
This will usually be called by the first forward pass, i.e.,
determined by the warmup.
"""
for key, val in multimodal_outputs.items():
# Only cache per-token feature tensors: 2D+ with first dim == seq_len.
# A 1D tensor of shape (seq_len,) is a broadcast scalar (per-request
# metadata such as ref_code_len / codec_streaming), not per-token data;
# caching it by slot causes a shape mismatch when a later request has a
# different scheduled seq length.
if (
isinstance(val, torch.Tensor)
and val.ndim >= 2
and val.shape[0] == seq_len
and key not in self.mm_cache_keys
):
feat_dim = val.shape[-1]
self.mm_outputs_cache[key] = self._get_cache_tensor(
dtype=val.dtype,
hidden_size=feat_dim,
)
self.mm_cache_keys.add(key)
new_tensor_shape = self.mm_outputs_cache[key].shape
logger.info("Initializing multimodal output cache of size %s for key: %s", list(new_tensor_shape), key)
def _get_cache_tensor(self, dtype: torch.dtype, hidden_size: int | None = None) -> torch.Tensor:
"""Allocate a CPU cache tensor for a specific key.
When CUDA is available the tensor is pinned: this is what lets the
async-write pipeline ride the GPU->CPU copy on the dedicated copy
stream as a true async ``cudaMemcpyAsync`` and lets the post-copy CPU
scatter avoid page-resolution stalls. On CPU-only builds pinning is
unsupported (``torch.zeros(pin_memory=True)`` raises), and the async
pipeline is disabled anyway (``schedule_async_write`` early-returns
when ``torch.cuda.is_available()`` is False), so we fall back to a
plain pageable allocation with no behavioral change.
"""
actual_hidden_size = hidden_size if hidden_size is not None else self.default_hidden_size
return torch.zeros(
(self.num_blocks, self.block_size, actual_hidden_size),
dtype=dtype,
device="cpu",
pin_memory=torch.cuda.is_available(),
)
# =====================================================================
# Async write pipeline:
# schedule_async_write : called per forward step, enqueues a
# non-blocking D2H + records a CUDA event
# drain_ready_async_writes : non-blocking poll, applies any writes
# whose D2H has completed
# =====================================================================
def _ensure_async_resources(self) -> None:
"""Lazily allocate the dedicated copy stream.
The per-step CPU staging tensors are allocated by PyTorch on demand
inside ``.to("cpu", non_blocking=True)``, which gives them
implicitly-pinned memory and lets PyTorch's allocator amortize /
reuse them.
"""
if self._async_initialized:
return
if not torch.cuda.is_available():
return
self._async_copy_stream = torch.cuda.Stream()
self._async_initialized = True
logger.info("Omni prefix cache async-write copy stream initialized.")
def schedule_async_write(
self,
hidden_states_gpu: torch.Tensor | None,
multimodal_outputs_gpu: dict[str, torch.Tensor] | None,
slot_mapping_gpu: torch.Tensor,
num_tokens_unpadded: int,
num_tokens_padded: int,
skip_mm_cache_keys: set[str] | None = None,
) -> None:
"""Kick off a non-blocking GPU->CPU copy for this step's writes.
1. **Consume the previous step's write** (if any): block on its
event and scatter the CPU tensors into the cache. By the time we
reach this point, the previous step's GPU work has been done for
~one forward step, so the synchronize is near-zero wall-clock.
2. **Schedule THIS step's D2H** on a dedicated copy stream using
``.to("cpu", non_blocking=True)``. PyTorch allocates the pinned
destination implicitly.
3. **Record an event** and stash everything for the next step's
consume.
Holding exactly one in-flight write at a time keeps the design
simple: no ring, no event-polling, no driver-mutex pressure from
repeated event.query() calls.
"""
if not torch.cuda.is_available():
return
self._ensure_async_resources()
if not self._async_initialized:
return
# Decide which mm keys we'll cache. Init the dest cache tensors
# before scheduling the copies so the apply step has somewhere
# to scatter into.
skip = skip_mm_cache_keys or set()
cacheable_mm_keys: list[str] = []
if multimodal_outputs_gpu:
self.maybe_init_missing_mm_cache_keys(multimodal_outputs_gpu, seq_len=num_tokens_padded)
for k in self.mm_cache_keys:
if k in skip:
continue
v = multimodal_outputs_gpu.get(k)
if not isinstance(v, torch.Tensor) or v.ndim < 2:
continue
if v.shape[0] < num_tokens_unpadded:
continue
cacheable_mm_keys.append(k)
# Step 1: consume the previous step's write FIRST. The previous
# step's event was recorded ~one forward step ago, so
# synchronize() is effectively a no-op cost-wise; the scatter
# into pinned CPU caches is also fast.
self._consume_pending_write()
if hidden_states_gpu is None and not cacheable_mm_keys:
return # nothing to schedule this step
# Step 2: schedule the D2H on the copy stream. PyTorch implicitly
# allocates pinned host destinations for the ``.to("cpu",
# non_blocking=True)`` results.
copy_stream = self._async_copy_stream
default_stream = torch.cuda.current_stream()
with torch.cuda.stream(copy_stream):
copy_stream.wait_stream(default_stream)
# slot_mapping: copy as-is; the int64 cast required by
# ``index_copy_`` is deferred to ``_consume_pending_write``
# so we avoid launching an extra GPU kernel here that would
# contend the driver mutex with the compute stream.
slots_cpu = slot_mapping_gpu[:num_tokens_unpadded].to("cpu", non_blocking=True)
hidden_cpu: torch.Tensor | None = None
if hidden_states_gpu is not None:
hidden_cpu = hidden_states_gpu[:num_tokens_unpadded].to("cpu", non_blocking=True)
mm_cpu: dict[str, torch.Tensor] = {}
for k in cacheable_mm_keys:
mm_cpu[k] = multimodal_outputs_gpu[k][:num_tokens_unpadded].to("cpu", non_blocking=True)
event = torch.cuda.Event()
event.record()
# Step 3: stash for next step's consume.
self._pending_write = _PendingAsyncWrite(
event=event,
num_tokens=num_tokens_unpadded,
slots_cpu=slots_cpu,
hidden_cpu=hidden_cpu,
mm_cpu=mm_cpu,
)
def drain_ready_async_writes(self) -> int:
"""Consume one in-flight write if it has finished (non-blocking).
Exposed so the model runner can drain at the top of
``execute_model`` regardless of whether a new write will be
scheduled this step.
"""
if self._pending_write is None:
return 0
if not self._pending_write.event.query():
return 0
self._consume_pending_write()
return 1
def _consume_pending_write(self) -> None:
"""Synchronize on the pending event and scatter the CPU tensors
into the destination caches. Drops state on completion.
"""
entry = self._pending_write
if entry is None:
return
self._pending_write = None
entry.event.synchronize()
n = entry.num_tokens
if n <= 0:
return
slots = entry.slots_cpu
if slots.dtype != torch.int64:
slots = slots.to(torch.int64)
# NOTE: ``index_copy_(0, idx, src)`` is the row-scatter primitive in
# PyTorch. It's semantically identical to ``flat[idx] = src`` but
# dispatches to a much simpler CPU implementation (single dim,
# no broadcasting, no accumulate), which on pinned memory at large
# row sizes is observed to be much faster than the generic
# ``aten::index_put_`` path used by advanced-indexing assignment.
if entry.hidden_cpu is not None:
flat = self.hidden_states_cache.view(-1, self.hidden_states_cache.shape[-1])
flat.index_copy_(0, slots, entry.hidden_cpu)
for k, src_cpu in entry.mm_cpu.items():
mm_cache = self.mm_outputs_cache.get(k)
if mm_cache is None:
continue
flat = mm_cache.view(-1, mm_cache.shape[-1])
flat.index_copy_(0, slots, src_cpu)
def add_prefix_cached_new_req_id(self, req_id: str):
"""Adds a new request ID to the set of prefix cache hits on the batch."""
self._new_req_cache_hit_ids.add(req_id)
def reset_prefix_cached_new_req_ids(self):
"""Clears the cache hit IDs to prepare for a new engine step."""
self._new_req_cache_hit_ids.clear()
def has_prefix_cached_new_req_ids(self) -> bool:
"""Return True when this step contains a newly scheduled prefix hit."""
return bool(self._new_req_cache_hit_ids)
@staticmethod
def _coerce_to_cpu_tensor(maybe_gpu_tensor: torch.Tensor) -> torch.Tensor:
"""Convert GPU tensors -> contiguous CPU tensors if needed."""
return maybe_gpu_tensor.detach().cpu().contiguous()
@staticmethod
def _resolve_hidden_states_cpu(
hidden_states: torch.Tensor,
num_tokens: int,
hidden_states_cpu: torch.Tensor | None,
) -> torch.Tensor:
if hidden_states_cpu is None:
return OmniTensorPrefixCache._coerce_to_cpu_tensor(hidden_states[:num_tokens])
if hidden_states_cpu.device.type != "cpu":
raise RuntimeError("hidden_states_cpu must be a CPU tensor.")
if not hidden_states_cpu.is_contiguous():
raise RuntimeError("hidden_states_cpu must be contiguous.")
if hidden_states_cpu.dtype != hidden_states.dtype:
raise RuntimeError(
"hidden_states_cpu has an incompatible dtype: "
f"got {hidden_states_cpu.dtype}, expected {hidden_states.dtype}."
)
if hidden_states_cpu.shape[1:] != hidden_states.shape[1:]:
raise RuntimeError(
"hidden_states_cpu has an incompatible feature shape: "
f"got {tuple(hidden_states_cpu.shape[1:])}, expected {tuple(hidden_states.shape[1:])}."
)
if hidden_states_cpu.shape[0] < num_tokens:
raise RuntimeError(
"hidden_states_cpu does not cover the requested hidden states "
f"slice: got {hidden_states_cpu.shape[0]} tokens, need {num_tokens}."
)
return hidden_states_cpu[:num_tokens]
def update_omni_tensor_prefix_cache(
self,
hidden_states: torch.Tensor | None,
multimodal_outputs: dict[str, torch.Tensor] | None,
num_tokens_unpadded: int,
slot_mapping: torch.Tensor,
num_tokens_padded: int | None = None,
skip_mm_cache_keys: set[str] | None = None,
hidden_states_cpu: torch.Tensor | None = None,
):
"""Updates the hidden cache state for the provided hidden states and multimodal outputs.
Args:
hidden_states: Hidden states tensor to cache (if any)
multimodal_outputs: Multimodal dict whose tensors may be cached
num_tokens_unpadded: Number of tokens without padding
slot_mapping: Slot mapping for the input sequence
num_tokens_padded: Total number of tokens including padding
skip_mm_cache_keys: Multimodal keys whose CPU cache writes are deferred
hidden_states_cpu: Optional pre-staged CPU view of hidden_states.
When provided, it must be contiguous, live on CPU, match the
feature shape of hidden_states, and cover num_tokens_unpadded.
"""
unpadded_slot_mapping = slot_mapping[:num_tokens_unpadded]
if num_tokens_padded is None:
num_tokens_padded = num_tokens_unpadded
skip_mm_cache_keys = skip_mm_cache_keys or set()
if hidden_states is not None:
# Slice to unpadded portion before caching
hidden_states = OmniTensorPrefixCache._resolve_hidden_states_cpu(
hidden_states,
num_tokens_unpadded,
hidden_states_cpu,
)
# View the cache as 2D so that we can treat our slots as row indices.
# ``index_copy_`` is the row-scatter primitive — it's semantically
# ``cache[idx] = src`` but dispatches to a faster CPU path than the
# generic advanced-indexing assignment (which goes through
# ``aten::index_put_`` and is dominated by per-row dispatch
# overhead on large feature widths).
flat_cache = self.hidden_states_cache.view(-1, self.hidden_states_cache.shape[-1])
slot_idx = unpadded_slot_mapping
if slot_idx.dtype != torch.int64:
slot_idx = slot_idx.to(torch.int64)
flat_cache.index_copy_(0, slot_idx, hidden_states)
logger.debug("Writing to hidden states for %s tokens", num_tokens_unpadded)
# Do the same for the stage's cached multimodal outputs
if multimodal_outputs is not None:
# If we haven't initialized the keys already, do it now
# We check against the padded token count since we haven't sliced yet
self.maybe_init_missing_mm_cache_keys(
multimodal_outputs,
seq_len=num_tokens_padded,
)
for mm_out_key, mm_cache in self.mm_outputs_cache.items():
if mm_out_key in multimodal_outputs:
if mm_out_key in skip_mm_cache_keys:
continue
# Slice to unpadded portion before caching
mm_state = multimodal_outputs[mm_out_key][:num_tokens_unpadded]
mm_state = OmniTensorPrefixCache._coerce_to_cpu_tensor(mm_state)
flat_cache = mm_cache.view(-1, mm_cache.shape[-1])
slot_idx = unpadded_slot_mapping
if slot_idx.dtype != torch.int64:
slot_idx = slot_idx.to(torch.int64)
flat_cache.index_copy_(0, slot_idx, mm_state)
logger.debug("Writing to mm output cache for %s tokens", num_tokens_unpadded)
def stage_deferred_mm_outputs(
self,
query_start_loc: torch.Tensor,
input_batch: InputBatch,
multimodal_outputs: dict[str, torch.Tensor] | None,
num_scheduled_tokens: dict[str, int],
deferred_mm_cache_keys: set[str],
) -> None:
"""Keep GPU multimodal chunks until a request finishes.
The normal prefix-cache write path copies every cached multimodal tensor
to CPU on every step. For model outputs that are only needed by future
full-block prefix hits, we can keep detached GPU chunks and materialize
the CPU cache once when the request completes.
"""
if not multimodal_outputs or not deferred_mm_cache_keys:
return
for mm_key in deferred_mm_cache_keys:
mm_state = multimodal_outputs.get(mm_key)
if not isinstance(mm_state, torch.Tensor) or mm_state.ndim < 2:
continue
for req_id in input_batch.req_ids:
req_idx = input_batch.req_id_to_index[req_id]
sched = int(num_scheduled_tokens.get(req_id, 0))
if sched <= 0:
continue
start = int(query_start_loc[req_idx])
end = start + sched
if start >= int(mm_state.shape[0]):
continue
end = min(end, int(mm_state.shape[0]))
if end <= start:
continue
computed_end = int(input_batch.num_computed_tokens_cpu[req_idx])
token_start = max(0, computed_end - sched)
chunk = mm_state[start:end].detach()
self._deferred_mm_outputs.setdefault(req_id, {}).setdefault(mm_key, []).append((token_start, chunk))
def commit_deferred_mm_outputs(
self,
finished_req_ids: set[str] | list[str],
input_batch: InputBatch,
) -> None:
"""Write deferred multimodal chunks into the CPU prefix cache.
This must run before finished requests are removed from ``input_batch``,
because the block table is needed to map logical token positions to KV
cache slots.
"""
if not finished_req_ids or not self._deferred_mm_outputs:
return
for req_id in tuple(finished_req_ids):
per_key = self._deferred_mm_outputs.pop(req_id, None)
if not per_key:
continue
req_idx = input_batch.req_id_to_index.get(req_id)
if req_idx is None:
continue
for mm_key, chunks in per_key.items():
self._commit_deferred_mm_key(req_idx, input_batch, mm_key, chunks)
def discard_deferred_mm_outputs(self, req_id: str) -> None:
"""Drop deferred chunks for requests that leave without a cache commit."""
self._deferred_mm_outputs.pop(req_id, None)
def _commit_deferred_mm_key(
self,
req_idx: int,
input_batch: InputBatch,
mm_key: str,
chunks: list[tuple[int, torch.Tensor]],
) -> None:
if not chunks:
return
chunks = sorted(chunks, key=lambda item: item[0])
first_start = chunks[0][0]
expected_start = first_start
tensors: list[torch.Tensor] = []
for token_start, tensor in chunks:
if tensor.numel() == 0:
continue
if token_start != expected_start:
if tensors:
self._write_deferred_mm_tensor(req_idx, input_batch, mm_key, tensors, first_start)
first_start = token_start
expected_start = token_start
tensors = []
tensors.append(tensor)
expected_start += int(tensor.shape[0])
if tensors:
self._write_deferred_mm_tensor(req_idx, input_batch, mm_key, tensors, first_start)
def _write_deferred_mm_tensor(
self,
req_idx: int,
input_batch: InputBatch,
mm_key: str,
tensors: list[torch.Tensor],
token_start: int,
) -> None:
mm_state = tensors[0] if len(tensors) == 1 else torch.cat(tensors, dim=0)
if mm_state.ndim < 2 or mm_state.shape[0] == 0:
return
if mm_key not in self.mm_cache_keys:
self.mm_outputs_cache[mm_key] = self._get_cache_tensor(
dtype=mm_state.dtype,
hidden_size=int(mm_state.shape[-1]),
)
self.mm_cache_keys.add(mm_key)
logger.info(
"Initializing deferred multimodal output cache of size %s for key: %s",
list(self.mm_outputs_cache[mm_key].shape),
mm_key,
)
slots = self._get_slot_ids_for_token_range(
req_idx=req_idx,
input_batch=input_batch,
token_start=token_start,
num_tokens=int(mm_state.shape[0]),
)
if slots.numel() == 0:
return
mm_state_cpu = OmniTensorPrefixCache._coerce_to_cpu_tensor(mm_state[: slots.numel()])
mm_cache = self.mm_outputs_cache[mm_key]
flat_cache = mm_cache.view(-1, mm_cache.shape[-1])
if slots.dtype != torch.int64:
slots = slots.to(torch.int64)
flat_cache.index_copy_(0, slots, mm_state_cpu)
def _get_slot_ids_for_token_range(
self,
req_idx: int,
input_batch: InputBatch,
token_start: int,
num_tokens: int,
) -> torch.Tensor:
if num_tokens <= 0:
return torch.empty((0,), dtype=torch.long)
block_table = input_batch.block_table[0].block_table.cpu
token_positions = torch.arange(token_start, token_start + num_tokens, dtype=torch.long)
block_offsets = token_positions // self.block_size
max_blocks = int(block_table.shape[1])
valid = block_offsets < max_blocks
if not bool(valid.all()):
token_positions = token_positions[valid]
block_offsets = block_offsets[valid]
if token_positions.numel() == 0:
return torch.empty((0,), dtype=torch.long)
block_ids = block_table[req_idx, block_offsets].to(torch.long)
return block_ids * self.block_size + (token_positions % self.block_size)
def _coerce_to_payload_dict(
self,
element: object,
query_start_loc: torch.Tensor,
input_batch: InputBatch,
num_scheduled_tokens: dict[str, int],
) -> dict[str, object]:
"""Build the multimodal passthrough data per request for
the object under consideration. This mirrors the no-prefix-cache
path: a tensor whose first dimension matches the total scheduled
token count is sliced per request, so 1D (seq_len,) metadata that
is intentionally not cached (e.g. ref_code_len, codec_streaming)
is still split per request instead of leaking the whole batch.
"""
total_scheduled_tokens = sum(int(num_scheduled_tokens[r]) for r in input_batch.req_ids)
elem_dict = {}
for req_id in input_batch.req_ids:
req_idx = input_batch.req_id_to_index[req_id]
start = query_start_loc[req_idx]
end = start + num_scheduled_tokens[req_id]
elem_dict[req_id] = to_payload_element(
element,
req_idx,
start=start,
end=end,
pass_lists_through=True,
seq_len=total_scheduled_tokens,
)
return elem_dict
def get_merged_multimodal_states(
self,
query_start_loc: torch.Tensor,
input_batch: InputBatch,
multimodal_outputs: dict | None,
num_scheduled_tokens: dict[str, int],
):
"""Get the merged multimodal states if hidden state prefix caching is enabled."""
combined_multimodal_outputs = {}
# Talkers that produce multimodal outputs only at decode (e.g. Higgs
# Audio v3's audio codes) have ``multimodal_outputs is None`` at the
# initial prefill step. Treat it as an empty mapping so the merger
# short-circuits cleanly and the cache merge still runs once codes
# start arriving.
mm_outputs = multimodal_outputs if multimodal_outputs is not None else {}
# First get the prefix cached tensors that are present in the mm data
for mm_key in self.mm_cache_keys:
if mm_key in mm_outputs:
combined_multimodal_outputs[mm_key] = self._get_merged_tensors(
query_start_loc=query_start_loc,
input_batch=input_batch,
cache=self.mm_outputs_cache[mm_key],
hidden_states=mm_outputs[mm_key],
num_scheduled_tokens=num_scheduled_tokens,
)
# Then, get everything else (passthrough data); first, convert to CPU
# tensors similarly to the non prefix cached path, and then populate
# the subdicts mapping request IDs -> payload objects
passthrough_keys = set(mm_outputs.keys()) - self.mm_cache_keys
passthrough_mm_data = {k: v for k, v in mm_outputs.items() if k in passthrough_keys}
mm_cpu = build_mm_cpu(multimodal_outputs=passthrough_mm_data)
for mm_key, mm_val in mm_cpu.items():
combined_multimodal_outputs[mm_key] = self._coerce_to_payload_dict(
element=mm_val,
query_start_loc=query_start_loc,
input_batch=input_batch,
num_scheduled_tokens=num_scheduled_tokens,
)
return combined_multimodal_outputs
def get_merged_hidden_states(
self,
query_start_loc: torch.Tensor,
input_batch: InputBatch,
hidden_states: torch.Tensor,
num_scheduled_tokens: dict[str, int],
hidden_states_cpu: torch.Tensor | None = None,
) -> dict[str, torch.Tensor]:
"""Get merged hidden states, optionally reusing pre-staged CPU states.
When provided, hidden_states_cpu follows the same contract as
update_omni_tensor_prefix_cache: CPU, contiguous, same dtype and feature
shape as hidden_states, and covering every scheduled-token span derived
from query_start_loc and num_scheduled_tokens.
"""
return self._get_merged_tensors(
query_start_loc=query_start_loc,
input_batch=input_batch,
hidden_states=hidden_states,
num_scheduled_tokens=num_scheduled_tokens,
staged_cpu_tensor=hidden_states_cpu,
cache=self.hidden_states_cache,
)
def _get_merged_tensors(
self,
query_start_loc: torch.Tensor,
input_batch: InputBatch,
cache: torch.Tensor,
hidden_states: torch.Tensor,
num_scheduled_tokens: dict[str, int],
staged_cpu_tensor: torch.Tensor | None = None,
) -> dict[str, torch.Tensor]:
"""When hidden state caching is enabled, takes the input hidden_states,
which only correspond to the scheduled tokens, and returns a mapping
from request IDs to their full hidden states. This is accomplished by
looking up the block IDs & scheduled token counts to split the
hidden_states. staged_cpu_tensor can supply the already materialized CPU
scheduled-token view; None preserves the legacy per-call CPU conversion.
"""
# We do not support hybrid caches at the moment.
if len(input_batch.block_table.block_tables) > 1:
logger.warning_once(
"Omni prefix caching is enabled, but the batch block table appears to"
" have multiple kv groups; only the first group will be used!"
)
combined_hidden_states = {}
required_tokens = 0
for req_id in input_batch.req_ids:
req_idx = input_batch.req_id_to_index[req_id]
start = query_start_loc[req_idx]
end = start + num_scheduled_tokens[req_id]
required_tokens = max(required_tokens, int(end))
hidden_states = OmniTensorPrefixCache._resolve_hidden_states_cpu(
hidden_states,
required_tokens,
staged_cpu_tensor,
)
for req_id in input_batch.req_ids:
req_idx = input_batch.req_id_to_index[req_id]
if req_id in self._new_req_cache_hit_ids:
block_ids = self._get_cached_block_ids(req_idx, input_batch)
cached_hs = cache[block_ids].reshape(-1, cache.shape[-1])
# Slice the hidden states corresponding to this request;
# we do this by using the query start
start = query_start_loc[req_idx]
new_hs = hidden_states[start : start + num_scheduled_tokens[req_id]]
combined_hidden_states[req_id] = torch.cat([cached_hs, new_hs], dim=0)
else:
# cache miss for this request, pass through normally
start = query_start_loc[req_idx]
new_hs = hidden_states[start : start + num_scheduled_tokens[req_id]]
combined_hidden_states[req_id] = new_hs
return combined_hidden_states
def _get_cached_block_ids(self, req_idx: int, input_batch: InputBatch) -> torch.Tensor:
"""Given an input batch and request index in the batch (not ID), get the
block IDs corresponding to the cache hit.
"""
num_computed = input_batch.num_computed_tokens_cpu[req_idx]
# NOTE: vLLM only caches full blocks
num_cached_blocks = num_computed // self.block_size
# Get the block IDs attached to this cache hit and reindex into
# the flattened cached hidden states (i.e., 1 row per token).
return input_batch.block_table[0].block_table.cpu[req_idx, :num_cached_blocks]