[Perf][PrefixCache] Avoid per-step blocking write in OmniTensorPrefix… (#4106)

Signed-off-by: LHXuuu <xulianhao.xlh@antgroup.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
LHXuuuandHongsheng Liu authored and GitHub committed 2026-06-17 16:53:47 +08:00
1 parent 890803ca06
commit dff757da9c
4 files changed
+257 -18

No files matched your search

@@ -20,7 +20,7 @@ stage_args:
trust_remote_code: true
engine_output_type: text
distributed_executor_backend: "mp"
enable_prefix_caching: false
enable_prefix_caching: true
hf_config_name: thinker_config
tensor_parallel_size: 2
final_output: true
+222 -5
View File
@@ -2,6 +2,8 @@
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
@@ -11,6 +13,23 @@ 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.
@@ -49,6 +68,19 @@ class OmniTensorPrefixCache:
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
@@ -84,14 +116,186 @@ class OmniTensorPrefixCache:
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."""
"""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)
@@ -173,9 +377,17 @@ class OmniTensorPrefixCache:
num_tokens_unpadded,
hidden_states_cpu,
)
# View the cache as 2D so that we can treat our slots as row indices
# 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])
flat_cache[unpadded_slot_mapping] = hidden_states
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
@@ -195,7 +407,10 @@ class OmniTensorPrefixCache:
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])
flat_cache[unpadded_slot_mapping] = mm_state
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(
@@ -331,7 +546,9 @@ class OmniTensorPrefixCache:
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])
flat_cache[slots] = mm_state_cpu
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,
+1 -1
View File
@@ -28,7 +28,7 @@ stages:
max_num_seqs: 64
gpu_memory_utilization: 0.9
trust_remote_code: true
enable_prefix_caching: false
enable_prefix_caching: true
devices: "0"
default_sampling_params:
temperature: 0.0
+33 -11
View File
@@ -500,6 +500,16 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
if hasattr(self.model, "_clear_warmup_state"):
self.model._clear_warmup_state()
# Async-write pipeline: apply any pending GPU->CPU prefix-cache writes
# whose copy event has already fired. Non-blocking — entries whose D2H
# is still in flight stay queued and will be picked up on the next
# step's drain. This guarantees any downstream read of
# ``omni_prefix_cache.hidden_states_cache`` /
# ``omni_prefix_cache.mm_outputs_cache`` in this step sees the
# state produced no later than the previous forward step.
if self.omni_prefix_cache is not None:
self.omni_prefix_cache.drain_ready_async_writes()
# [Omni] Handle KV transfer BEFORE updating states (which removes finished requests)
finished_reqs = getattr(scheduler_output, "finished_requests_needing_kv_transfer", {})
if finished_reqs and hasattr(self.model, "get_kv_transfer_metadata"):
@@ -796,18 +806,30 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
hidden_states, multimodal_outputs = self.extract_multimodal_outputs(model_output)
hidden_states_cpu = None
if self.omni_prefix_cache is not None and get_pp_group().is_last_rank:
hidden_states_cpu = hidden_states[:num_tokens_unpadded].detach().to("cpu").contiguous()
# Cache hidden states & multimodal outputs if we've enabled hidden state
# prefix caching unless this isn't the last pipeline parallelism rank.
self._maybe_update_prefix_cache(
hidden_states=hidden_states,
hidden_states_cpu=hidden_states_cpu,
multimodal_outputs=multimodal_outputs,
num_tokens_unpadded=num_tokens_unpadded,
num_tokens_padded=num_tokens_padded,
)
# Async-write pipeline (replaces the per-step blocking
# ``.to("cpu")`` + ``aten::index_put_`` on pageable host memory).
# Schedules non-blocking GPU->CPU copies on a dedicated stream;
# the actual CPU scatter into ``hidden_states_cache`` /
# ``mm_outputs_cache`` happens in ``drain_ready_async_writes``
# at the top of subsequent execute_model() calls.
if self.omni_prefix_cache is not None and get_pp_group().is_last_rank:
hs_for_cache = hidden_states if self._model_needs_full_prefix_hidden_states() else None
# Some models (e.g. qwen3-tts-talker) opt out of full-hidden-state
# prefix caching but the downstream pooler payload path still
# needs a CPU hidden-states view. Materialize it synchronously
# in that case; the legacy behavior is preserved.
if hs_for_cache is None:
hidden_states_cpu = hidden_states[:num_tokens_unpadded].detach().to("cpu").contiguous()
slot_mapping_gpu = self.input_batch.block_table[0].slot_mapping.gpu
self.omni_prefix_cache.schedule_async_write(
hidden_states_gpu=hs_for_cache,
multimodal_outputs_gpu=(flatten_payload(multimodal_outputs) if multimodal_outputs else None),
slot_mapping_gpu=slot_mapping_gpu,
num_tokens_unpadded=num_tokens_unpadded,
num_tokens_padded=num_tokens_padded,
skip_mm_cache_keys=self._deferred_prefix_cache_mm_keys(),
)
if not self.broadcast_pp_output:
# Common case.