[Performance][MiniCPM-o] Zero-copy block-aligned KV sliding window with in-place Re-RoPE for duplex Stage-0 (#7821)

Signed-off-by: BeatSeat <wendavid552@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
BeatSeatandamy-why-3459 authored and GitHub committed 2026-09-28 10:11:54 +08:00
1 parent 817f5d0e4d
commit c8a1419e02
9 files changed
+3886 -16

No files matched your search

@@ -219,6 +219,7 @@ class MiniCPMO45Stage0DuplexRuntime:
is_speech: bool = False,
final: bool = False,
stage0_window: dict[str, object] | None = None,
stage0_reanchor: dict[str, object] | None = None,
) -> dict[str, object]:
"""Build scheduler-owned Stage0 input embeddings for one audio append.
@@ -435,6 +436,111 @@ class MiniCPMO45Stage0DuplexRuntime:
state.pending_window_unit = None
state.pending_window_generated_tokens.clear()
@staticmethod
def _tensor_rows(t: Any) -> int:
if hasattr(t, "shape"):
if getattr(t, "ndim", len(t.shape)) == 1:
return 1
if getattr(t, "ndim", len(t.shape)) == 3 and t.shape[0] == 1:
return int(t.shape[1])
return int(t.shape[0])
return 1
@classmethod
def _slice_single_tensor(cls, t: Any, start_row: int, end_row: int) -> Any:
if hasattr(t, "ndim"):
if t.ndim == 1:
return t
if t.ndim == 3 and t.shape[0] == 1:
return t[:, start_row:end_row]
return t[start_row:end_row]
return t
@classmethod
def _slice_embed_list(cls, embeds: list[Any], start_token: int, end_token: int) -> list[Any]:
if start_token >= end_token or not embeds:
return []
result: list[Any] = []
curr_offset = 0
for t in embeds:
num_rows = cls._tensor_rows(t)
t_start = curr_offset
t_end = curr_offset + num_rows
curr_offset = t_end
overlap_start = max(t_start, start_token)
overlap_end = min(t_end, end_token)
if overlap_start < overlap_end:
local_start = overlap_start - t_start
local_end = overlap_end - t_start
if local_start == 0 and local_end == num_rows:
result.append(t)
else:
result.append(cls._slice_single_tensor(t, local_start, local_end))
return result
def _evict_window_units_for_reanchor(
self,
state: _MiniCPMO45Stage0SessionState,
stage0_reanchor: dict[str, Any],
) -> None:
"""Evict completed window units that were dropped by zero-copy KV re-anchor.
Ensures worker history (state.window_units) stays bounded over long streaming sessions
when zero-copy Re-RoPE KV reuse is active and prompt rebuild is bypassed.
"""
reanchor_id = stage0_reanchor.get("reanchor_id")
if reanchor_id:
applied = getattr(state, "_applied_reanchor_ids", None)
if applied is None:
applied = state._applied_reanchor_ids = set()
if reanchor_id in applied:
return
applied.add(reanchor_id)
delta = int(stage0_reanchor.get("delta", 0) or 0)
if delta <= 0 or not state.window_units:
return
moved_from = stage0_reanchor.get("moved_from")
sink_end = stage0_reanchor.get("sink_end")
if sink_end is None and moved_from is not None:
sink_end = int(moved_from) - delta
if sink_end is None:
sink_blocks = int(stage0_reanchor.get("sink_blocks", 0) or 0)
block_size = int(stage0_reanchor.get("block_size", 16) or 16)
sink_end = sink_blocks * block_size
prefix_tokens = int(stage0_reanchor.get("prefix_tokens", 0) or len(state.context_token_ids))
drop_start = max(0, int(sink_end) - prefix_tokens)
drop_end = drop_start + delta
new_units: list[_MiniCPMO45WindowUnit] = []
curr_offset = 0
for unit in state.window_units:
u_len = len(unit.token_ids)
u_start = curr_offset
u_end = curr_offset + u_len
curr_offset = u_end
local_drop_start = max(0, drop_start - u_start)
local_drop_end = min(u_len, drop_end - u_start)
if local_drop_start >= local_drop_end:
new_units.append(unit)
elif local_drop_start == 0 and local_drop_end == u_len:
continue
else:
kept_tokens = unit.token_ids[:local_drop_start] + unit.token_ids[local_drop_end:]
kept_embeds = self._slice_embed_list(unit.embeds, 0, local_drop_start) + self._slice_embed_list(
unit.embeds, local_drop_end, u_len
)
unit.token_ids = kept_tokens
unit.embeds = kept_embeds
new_units.append(unit)
state.window_units = new_units
def _window_replacement_parts(
self,
state: _MiniCPMO45Stage0SessionState,
@@ -0,0 +1,966 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Native paged-KV window for MiniCPM-o 4.5 duplex Stage 0.
``window_plan.py`` holds the window arithmetic in plain integers; this file
holds the three things that need an engine: the KV-cache spec/manager pair the
scheduler resolves, the layer change that produces that spec, and the in-place
re-RoPE that follows a trim.
One mechanism, matching the reference implementation
----------------------------------------------------
A trim and a renumber are the same event here, which is what MiniCPM's HF
implementation does: cut the cached K/V, re-RoPE the survivors onto dense
positions. #7631 gets the same observable state from the other direction, by
rebuilding the prompt and letting the model recompute every retained row -- a
full forward pass over the window, per roll.
Here the cut is a block-table edit and the re-RoPE is one rotation per retained
key, because :class:`~vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan.PositionReanchor`
guarantees the shift is one uniform translation of a whole number of pages. Two
consequences are worth stating plainly:
* No physical row moves. After ``del table[sink:sink + n]``, logical index ``j``
resolves to the page that ``j + n`` named before the deletion -- which is the
page the row is already in -- and a block-aligned shift leaves ``pos %
block_size`` alone. So the rotation reads and writes the same slots, and V is
not touched at all, because only K carries a RoPE phase.
* The window needs no mask, because there is nothing to skip: the retained span
is contiguous and plain causal attention over it is what the model was trained
to do. See ``window_plan.py`` for why a *masked* window cannot express a duplex
session, and why R-SWA -- the one upstream type that can -- is not usable on a
FlashAttention-3 Stage 0.
What the sliding-window spec is then for is sizing. ``SlidingWindowSpec`` is the
only attention type in vLLM whose ``max_admission_blocks_per_request`` bounds a
request at its window rather than at ``max_model_len``, which is what lets
``max_sessions`` concurrent streams share a Stage 0 that advertises a 40k-token
context. Its own eviction is overridden to stay inert: a masked free and a
compaction must not race for the same pages, so the gap is freed by
:meth:`MiniCPMO45DuplexWindowManager.reanchor_block_table` and nothing else.
Reuse, not reimplementation
---------------------------
The base of everything below is the repo's own
:class:`~vllm_omni.experimental.ar_diffusion.kv_cache.paged.ChunkWindowSpec` /
:class:`~vllm_omni.experimental.ar_diffusion.kv_cache.paged.ChunkWindowManager`,
which already keeps a sink, already evicts on chunk boundaries, and already
compacts a block table. This project sets ``chunk_size`` to the cache block size,
so "chunk-aligned" and "page-aligned" are the same statement and the inherited
:meth:`compact_block_table` indexing applies as written.
Two constraints come with this
------------------------------
*Prefix caching stays off.* Not only because a compacted table is no longer the
table the block hashes were computed against, but because duplex appends are
prompts of ``[filler_id] * token_budget`` (``duplex/plugin.py``) whose real
content arrives as worker-side ``inputs_embeds``: the hashes would be identical
across sessions while the KV behind them is not. ``OmniTensorPrefixCache`` has
the same exposure through its block/slot mirroring, and it and async output
materialization are mutually exclusive anyway.
*Block size.* On CUDA, kernels typically use ``block_size == 16`` for a sliding window,
while Ascend NPU uses 128. The window geometry and compaction algorithms support any
positive block size. The conversion below reads that size back off the spec the
parent produced, so the kernel's choice -- not this module's -- is the one the
chunk arithmetic uses.
Nothing here has been executed: it needs an engine, a checkpoint and a GPU. The
arithmetic it delegates to is CPU-covered by
``tests/model_executor/models/minicpmo_4_5/duplex/test_window_plan.py``, which
also checks the RoPE identity behind :func:`rotate_cached_keys` numerically.
Wiring, in the order the pieces get used
----------------------------------------
1. Model module: import this module for its spec registration, call
:func:`install_duplex_window_layers` on the Stage 0 backbone right after
``init_vllm_registered_model`` so the profiler sees the windowed spec, and run
:func:`validate_duplex_window_install` as the startup assertion.
2. Scheduler: an append becomes an ordinary session *extension*, so
``_update_request_as_session`` runs instead of ``_replace_streaming_session``
and the request's token count grows rather than restarting. Nothing else
changes until
:func:`~vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan.plan_position_reanchor`
returns a plan, which is the single place a trim -- or a
``context_length_exceeded`` finish -- is decided.
3. Worker: for that step, check :func:`assert_uniform_position_shift` on the
request's real positions, call
:meth:`MiniCPMO45DuplexWindowManager.reanchor_block_table` once, then
:func:`rotate_cached_keys` per layer, and shift the request's positions,
``mrope_positions`` and ``mrope_position_delta`` in the same step so the next
forward reads the compacted layout.
4. Deploy config: ``enable_prefix_caching`` stays false, ``block_size`` matches
the engine's cache configuration, and the window's watermarks come from
the model's duplex policy rather than from a scheduler constant.
"""
from __future__ import annotations
import functools
from typing import Any
import torch
from vllm.v1.kv_cache_interface import KVCacheSpec, SlidingWindowSpec
from vllm.v1.kv_cache_spec_registry import register_kv_cache_spec
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import (
ChunkWindowManager,
ChunkWindowSpec,
)
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan import (
DuplexWindowGeometry,
PositionReanchor,
align_up,
cdiv,
plan_position_reanchor,
)
#: Default block size for sliding window on CUDA. Hardware like Ascend NPU may
#: use larger page sizes (e.g. 128).
DUPLEX_WINDOW_BLOCK_SIZE = 16
def compute_slot_mapping(
block_ids: list[int] | torch.Tensor,
positions: list[int] | torch.Tensor,
block_size: int,
) -> torch.Tensor:
"""Map token positions to physical slot indices across a block table."""
if block_size <= 0:
raise ValueError(f"block_size must be positive, got {block_size}")
pos = torch.as_tensor(positions, dtype=torch.long)
table = torch.as_tensor(block_ids, dtype=torch.long, device=pos.device)
block_index = torch.div(pos, block_size, rounding_mode="floor")
offset = pos % block_size
return table[block_index] * block_size + offset
def duplex_window_geometry(
*,
prefix_tokens: int,
window_tokens: int,
block_size: int,
max_model_len: int,
sample_room: int = 1,
high_watermark_tokens: int | None = None,
) -> DuplexWindowGeometry:
"""Bind the model's window policy to the engine's paging geometry."""
return DuplexWindowGeometry(
prefix_tokens=int(prefix_tokens),
window_tokens=int(window_tokens),
block_size=int(block_size),
max_model_len=int(max_model_len),
sample_room=int(sample_room),
high_watermark_tokens=None if high_watermark_tokens is None else int(high_watermark_tokens),
)
def validate_duplex_window_install(
cache_config,
model_config,
geometry: DuplexWindowGeometry,
) -> None:
"""Refuse a configuration the window cannot be enforced under.
Called from the model module once both configs exist and before the
KV-cache profile, so a wrong deploy config fails at startup rather than
leaving a session that grows without bound.
"""
if getattr(cache_config, "enable_prefix_caching", False):
raise ValueError("MiniCPM-o 4.5 duplex KV window requires enable_prefix_caching=False")
if geometry.block_size <= 0:
raise ValueError(f"duplex KV window needs positive block_size, got {geometry.block_size}")
configured_block_size = getattr(cache_config, "block_size", None)
if configured_block_size is not None and geometry.block_size != configured_block_size:
raise ValueError(
f"duplex KV window geometry block_size ({geometry.block_size}) "
f"does not match cache_config.block_size ({configured_block_size})"
)
needed = geometry.prefix_tokens + geometry.trigger_tokens + geometry.sample_room
max_model_len = getattr(model_config, "max_model_len", None) if model_config is not None else None
if max_model_len is not None and needed > max_model_len:
raise ValueError(
f"duplex window does not fit max_model_len={max_model_len}: "
f"prefix={geometry.prefix_tokens} + trigger={geometry.trigger_tokens} "
f"+ sample_room={geometry.sample_room} = {needed}"
)
def resident_bound(geometry: DuplexWindowGeometry) -> int:
"""Tokens a windowed session can hold at once, rounded to whole pages."""
return min(
geometry.prefix_tokens + geometry.trigger_tokens + geometry.sample_room,
geometry.max_model_len,
)
class MiniCPMO45DuplexWindowManager(ChunkWindowManager):
"""Chunk-window paging with the trim made explicit: free, delete, re-RoPE.
Inherited as-is: ``req_to_blocks`` for the table edit and
:meth:`compact_block_table` for the deletion itself. Overridden: the
automatic eviction, which for this model would free a head the session still
has to attend. It is inert rather than removed so that admission, which reads
the same spec, still sees a bounded request.
"""
def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
"""Never free behind the compaction's back."""
del num_computed_tokens
return 0
def plan_reanchor(
self,
geometry: DuplexWindowGeometry,
*,
computed_tokens: int,
pending_tokens: int,
unit_tokens: list[int] | None = None,
) -> PositionReanchor | None:
"""Whether the next append pushes the session past its window."""
return plan_position_reanchor(
geometry,
computed_tokens=computed_tokens,
pending_tokens=pending_tokens,
unit_tokens=unit_tokens,
)
def compact_block_table(self, request_id: str, sink_blocks: int | None = None) -> int:
if getattr(self, "enable_caching", False):
raise RuntimeError("duplex block-table compaction requires prefix caching to be disabled")
blocks = getattr(self, "req_to_blocks", {}).get(request_id)
if blocks is None:
blocks = getattr(self, "blocks", None)
if blocks is None:
return 0
spec_sink = getattr(getattr(self, "kv_cache_spec", None), "sink_chunks", 0)
start = spec_sink if sink_blocks is None else int(sink_blocks)
end = start
null_block = getattr(self, "_null_block", None)
while end < len(blocks) and blocks[end] == null_block:
end += 1
if end == start:
return 0
del blocks[start:end]
if hasattr(self, "num_cached_block") and request_id in self.num_cached_block:
cached = self.num_cached_block[request_id]
self.num_cached_block[request_id] = min(cached, start) + max(0, cached - end)
return (end - start) * getattr(self, "block_size", 16)
def compact_request_blocks(
self,
request_id: str,
*,
sink_blocks: int,
num_blocks: int,
) -> int:
"""Free and compact a gap of blocks after an explicit request-local sink."""
if getattr(self, "enable_caching", False):
raise RuntimeError("duplex block-table compaction requires prefix caching to be disabled")
blocks = getattr(self, "req_to_blocks", {}).get(request_id)
if blocks is None:
blocks = getattr(self, "blocks", None)
if blocks is None or num_blocks <= 0:
return 0
start = int(sink_blocks)
end = start + int(num_blocks)
null_block = getattr(self, "_null_block", None)
if (
start < 0
or end > len(blocks)
or (null_block is not None and any(block == null_block for block in blocks[start:end]))
):
return 0
if hasattr(self, "_remove_blocks_in_range"):
self._remove_blocks_in_range(request_id, start, end)
elif hasattr(self, "free_blocks_range"):
self.free_blocks_range(request_id, start, end)
else:
del blocks[start:end]
return num_blocks * getattr(self, "block_size", 16)
return self.compact_block_table(request_id, sink_blocks=start)
def reanchor_block_table(self, request_id: str, plan: PositionReanchor) -> int:
"""Free and delete one request's gap; return the tokens it held.
Returns 0 when the table disagrees with the plan -- the gap was already
removed, or the request is gone -- and a caller that asked for a non-zero
delta must then skip the rotation as well, since nothing moved.
"""
gap_blocks = plan.delta // self.block_size
return self.compact_request_blocks(
request_id,
sink_blocks=plan.sink_blocks,
num_blocks=gap_blocks,
)
@register_kv_cache_spec(manager_class=MiniCPMO45DuplexWindowManager, uniform_type_base_spec=None)
class MiniCPMO45DuplexWindowSpec(ChunkWindowSpec):
"""A :class:`ChunkWindowSpec` that resolves to the duplex manager above.
Adds no fields. Registration exists only because dispatch walks the spec's
MRO, so an unregistered subclass silently keeps the parent manager and the
trim entry point is never reachable.
"""
def duplex_window_spec(geometry: DuplexWindowGeometry, layer_spec: SlidingWindowSpec) -> MiniCPMO45DuplexWindowSpec:
"""Re-express a layer's sliding-window spec as the duplex one.
``layer_spec`` is what an upstream ``Attention`` reports once it has a
sliding window. Its ``block_size`` was chosen by the kernel, not by
``--block-size``, so every count below is in the pages the cache will
actually use -- and it has to be the pages the planner counts in too, or
``plan.sink_blocks`` and ``spec.sink_chunks`` describe different tables.
"""
if layer_spec.block_size != geometry.block_size:
raise ValueError(
f"duplex window plans in {geometry.block_size}-token pages but the kernel gave this "
f"group {layer_spec.block_size}-token pages; align cache_config.block_size with the geometry"
)
block_size = layer_spec.block_size
# The mask never bites (see the manager), so the window is set to the
# resident bound: long enough that a compacted session is always fully
# attended, short enough that the allocator admits a session by what it
# holds rather than by max_model_len.
bound = align_up(resident_bound(geometry), block_size)
return MiniCPMO45DuplexWindowSpec(
block_size=block_size,
num_kv_heads=layer_spec.num_kv_heads,
head_size=layer_spec.head_size,
head_size_v=layer_spec.head_size_v,
dtype=layer_spec.dtype,
kv_quant_mode=layer_spec.kv_quant_mode,
page_size_padded=layer_spec.page_size_padded,
sliding_window=bound,
chunk_size=block_size,
window_chunks=bound // block_size,
sink_chunks=cdiv(geometry.prefix_tokens, block_size),
reset_at_boundary=False,
)
def install_duplex_window_layers(model: torch.nn.Module, *, geometry: DuplexWindowGeometry) -> list[str]:
"""Point a decoder's attention layers at :class:`MiniCPMO45DuplexWindowSpec`.
Stage 0 builds its backbone through ``init_vllm_registered_model`` on a
Qwen3/Qwen2 text config, so its layers are stock vLLM ``Attention`` modules
reporting ``FullAttentionSpec``. ``RSWAAttention`` shows the way to change
that without a model file: the only difference is what ``get_kv_cache_spec``
returns. So each layer is re-classed and given the two attributes its
override reads -- the window length that makes the parent emit a
sliding-window spec at all, and the geometry that turns it into this one.
The window length is ``resident_bound(geometry)``, and it exists for sizing
only. The layer's ``impl`` was built before this call with no window, and
that is the point: the kernel stays causal over the block table, which is
correct precisely because the trim keeps the table dense.
Returns the modified layer names, for the startup log and for a test that the
spec really did change.
"""
from vllm.model_executor.layers.attention import Attention
from vllm.v1.attention.backend import AttentionType
windowed = _duplex_window_attention_class()
sliding_window = resident_bound(geometry)
renamed: list[str] = []
for name, module in model.named_modules():
if not isinstance(module, Attention) or isinstance(module, windowed):
continue
if module.attn_type != AttentionType.DECODER:
# Encoder-side attention (the vision/audio towers) keeps its own
# cache policy; only the decoder's KV is windowed.
continue
object.__setattr__(module, "__class__", windowed)
object.__setattr__(module, "sliding_window", int(sliding_window))
object.__setattr__(module, "_duplex_window_geometry", geometry)
renamed.append(name)
return renamed
@functools.lru_cache(maxsize=1)
def _duplex_window_attention_class():
"""``Attention`` that reports the duplex spec instead of a plain one.
Subclassing at call time leaves upstream in charge of the parts that are the
backend's business -- the kernel-chosen block size, page padding,
quantisation mode -- and converts only the class of the result.
"""
from vllm.model_executor.layers.attention import Attention
class MiniCPMO45DuplexWindowAttention(Attention):
_duplex_window_geometry: DuplexWindowGeometry
def get_kv_cache_spec(self, vllm_config) -> KVCacheSpec | None:
spec = super().get_kv_cache_spec(vllm_config)
if spec is None or not isinstance(spec, SlidingWindowSpec) or isinstance(spec, ChunkWindowSpec):
return spec
return duplex_window_spec(self._duplex_window_geometry, spec)
return MiniCPMO45DuplexWindowAttention
def assert_uniform_position_shift(positions: torch.Tensor, moved_from: int) -> None:
"""Guard the one case a single rotation cannot express.
MRoPE scores against three position rows, and a translation of the tail is
one rotation only while all three shift by the same amount. A retained span
carrying vision tokens with distinct height/width rows does not, so the
caller must re-prefill that session instead. Duplex audio and text advance
every row together, so the streaming tail satisfies this.
"""
if positions.dim() == 1:
return
if positions.dim() != 2:
raise ValueError(f"expected a (rows, tokens) position tensor, got {tuple(positions.shape)}")
tail = positions[:, int(moved_from) :]
if tail.shape[1] == 0:
return
if not bool(torch.all(tail == tail[0])):
raise RuntimeError(
"duplex re-anchor needs one position row across the retained tail; re-prefill this "
"session rather than rotating split MRoPE positions"
)
def rotate_keys(keys: torch.Tensor, delta: int, inv_freq: torch.Tensor) -> torch.Tensor:
"""Undo ``delta`` positions of rotation on keys that are already rotated.
``keys`` is ``(tokens, heads, head_dim)`` with NeoX-style pairing
(``rotate_half``), which is what the Qwen3-family RoPE Stage 0 registers
writes. RoPE composes, so multiplying a key's cached pair by
``exp(-i * delta * inv_freq)`` yields exactly the key that would have been
written ``delta`` positions earlier, for every frequency at once and without
reference to where the token came from.
"""
half = keys.shape[-1] // 2
if inv_freq.numel() != half:
raise ValueError(f"expected {half} RoPE frequencies for head_dim={keys.shape[-1]}, got {inv_freq.numel()}")
# Angle and trigonometric computations must stay in float32 to avoid catastrophic
# quantization error: delta * inv_freq can exceed 1024, where bfloat16 has ULP = 8
# (quantization error ~4-5.7 rad, completely randomizing trig values).
angle = float(delta) * inv_freq.to(device=keys.device, dtype=torch.float32)
cos = torch.cos(angle).to(dtype=keys.dtype).unsqueeze(0).unsqueeze(1)
sin = torch.sin(angle).to(dtype=keys.dtype).unsqueeze(0).unsqueeze(1)
k1, k2 = keys[..., :half], keys[..., half:]
# Complex multiply by exp(-i*angle): the inverse of the forward rotation.
return torch.cat([k1 * cos + k2 * sin, k2 * cos - k1 * sin], dim=-1)
def rotate_cached_keys(
k_pool: torch.Tensor,
*,
block_ids: list[int],
positions: torch.Tensor,
plan: PositionReanchor,
inv_freq: torch.Tensor,
block_size: int | None = None,
) -> int:
"""Re-RoPE the retained tail where it stands; return the rows touched.
Supports:
- vLLM 0.30+ packed FlashAttention KV layout:
(num_blocks, num_kv_heads, block_size, 2 * head_dim)
- Standard PagedAttention KV layouts:
(num_blocks, block_size, num_kv_heads, head_dim) and
(num_blocks, num_kv_heads, block_size, head_dim)
- 5D split layouts (2, num_blocks, ...) or (num_blocks, 2, ...)
Uses direct tensor advanced indexing assignment to write rotated keys in-place
into the underlying storage, preserving V cache, non-retained blocks, and avoiding
temporary copy allocation on non-contiguous strides.
"""
if positions.numel() == 0:
return 0
if k_pool.dim() == 5:
if k_pool.shape[0] == 2:
k_pool = k_pool[0]
elif k_pool.shape[1] == 2:
k_pool = k_pool[:, 0]
else:
raise ValueError(f"unsupported 5D cache shape {tuple(k_pool.shape)}")
if k_pool.dim() != 4:
raise ValueError(f"rotate_cached_keys requires a 4D key cache, got shape {tuple(k_pool.shape)}")
expected_head_dim = inv_freq.numel() * 2
last_dim = k_pool.shape[-1]
if last_dim not in (expected_head_dim, 2 * expected_head_dim):
raise ValueError(
f"expected head_dim={expected_head_dim} or {2 * expected_head_dim} for "
f"{inv_freq.numel()} RoPE frequencies, got {last_dim} (shape {tuple(k_pool.shape)})"
)
if block_size is None:
if last_dim == 2 * expected_head_dim:
# Packed FlashAttention: (num_blocks, num_kv_heads, block_size, 2 * head_dim)
block_size = k_pool.shape[2]
else:
block_size = k_pool.shape[1]
if k_pool.shape[2] == block_size:
layout = "BHND"
num_kv_heads = k_pool.shape[1]
elif k_pool.shape[1] == block_size:
layout = "BNHD"
num_kv_heads = k_pool.shape[2]
else:
raise ValueError(f"cannot resolve layout: block_size={block_size} not in shape {tuple(k_pool.shape)}")
positions = positions.to(device=k_pool.device, dtype=torch.long)
if bool((positions < plan.moved_from).any()):
raise RuntimeError(f"re-anchor given {int((positions < plan.moved_from).sum())} positions below moved_from")
shifted = positions - plan.delta
block_table = torch.as_tensor(block_ids, dtype=torch.long, device=k_pool.device)
target_blocks = block_table[shifted // block_size]
offsets = shifted % block_size
b_idx = target_blocks.unsqueeze(1)
h_idx = torch.arange(num_kv_heads, device=k_pool.device).unsqueeze(0)
o_idx = offsets.unsqueeze(1)
if layout == "BHND":
keys = k_pool[b_idx, h_idx, o_idx, :expected_head_dim]
rotated = rotate_keys(keys, plan.delta, inv_freq)
k_pool[b_idx, h_idx, o_idx, :expected_head_dim] = rotated
else:
keys = k_pool[b_idx, o_idx, h_idx, :expected_head_dim]
rotated = rotate_keys(keys, plan.delta, inv_freq)
k_pool[b_idx, o_idx, h_idx, :expected_head_dim] = rotated
return int(positions.numel())
class MiniCPMO45DuplexWindowPolicy:
"""Model-side retention policy and watermark geometry calculation."""
@classmethod
def plan_reanchor(
cls,
session: Any,
update: Any,
block_size: int,
max_model_len: int,
unit_tokens: list[int] | None = None,
segment_output_ids: list[int] | None = None,
) -> PositionReanchor | None:
"""Evaluate model retention decision and return PositionReanchor if due."""
info = getattr(update, "model_intermediate_buffer", None)
if not isinstance(info, dict):
return None
duplex = info.get("duplex")
if not isinstance(duplex, dict) or duplex.get("data_plane") is not True:
return None
runtime_config = duplex.get("runtime_config")
runtime_config = runtime_config if isinstance(runtime_config, dict) else {}
window = runtime_config.get("duplex_window_config")
if not isinstance(window, dict):
return None
mode = window.get("sliding_window_mode", "off")
if mode != "basic":
# Only basic mode uses zero-copy KV reuse with in-place Re-RoPE.
# Context mode relies on previous-text accumulation and partial forward,
# so it routes explicitly through the official fallback (_prepare_minicpmo45_stage0_window).
return None
base_len = int(getattr(session, "num_computed_tokens", 0) or 0)
prefix_tokens = int(runtime_config.get("duplex_window_prefix_tokens", 96) or 96)
high_watermark = int(window.get("basic_window_high_tokens", 8000) or 8000)
low_watermark = int(window.get("basic_window_low_tokens", 6000) or 6000)
# Normalize total-sequence watermarks to content-only budgets for DuplexWindowGeometry
# so that trigger_total == high_watermark and target_total == low_watermark,
# perfectly matching the fallback path (_prepare_minicpmo45_stage0_window).
content_low = max(block_size, low_watermark - prefix_tokens)
content_high = max(content_low, high_watermark - prefix_tokens)
geometry = DuplexWindowGeometry(
prefix_tokens=prefix_tokens,
window_tokens=content_low,
block_size=block_size,
max_model_len=max_model_len,
high_watermark_tokens=content_high,
)
pending_tokens = len(getattr(update, "prompt_token_ids", []) or [])
# Derive unit_tokens from session._minicpmo45_window_units if not explicitly given
if unit_tokens is None and hasattr(session, "_minicpmo45_window_units"):
raw_units = getattr(session, "_minicpmo45_window_units", ()) or ()
if raw_units:
unit_tokens = [int(u["length"]) for u in raw_units if isinstance(u, dict) and "length" in u]
seq = int(duplex.get("seq", 0) or 0)
if seq > 1 and segment_output_ids is not None:
preserve_len = int(runtime_config.get("duplex_first_append_context_tokens", 0) or 0)
old_prompt_tokens = int(getattr(session, "num_prompt_tokens", 0) or 0)
boundary_cur = old_prompt_tokens + len(segment_output_ids) + 2
recorded_open = getattr(session, "_minicpmo45_window_open_start", None)
open_start = preserve_len if recorded_open is None else int(recorded_open)
closing_len = boundary_cur - open_start
if closing_len > 0:
unit_tokens = [*unit_tokens, closing_len]
return plan_position_reanchor(
geometry,
computed_tokens=base_len,
pending_tokens=pending_tokens,
unit_tokens=unit_tokens,
)
class MiniCPMO45DuplexSchedulerHelper:
"""Scheduler-side window planning and request compaction helper for MiniCPM-o 4.5 duplex."""
@classmethod
def find_duplex_window_manager(cls, scheduler: Any) -> MiniCPMO45DuplexWindowManager | None:
coordinator = getattr(getattr(scheduler, "kv_cache_manager", None), "coordinator", None)
if coordinator is None:
return None
for mgr in getattr(coordinator, "single_type_managers", ()):
if isinstance(mgr, (MiniCPMO45DuplexWindowManager, ChunkWindowManager)):
return mgr
return None
@classmethod
def apply_session_window(
cls,
scheduler: Any,
session: Any,
update: Any,
*,
segment_output_ids: list[int] | None = None,
completed_terminator: int | None = None,
unit_tokens: list[int] | None = None,
) -> PositionReanchor | None:
"""Evaluate watermark policy, compact KV block table and session tokens if needed."""
cache_config = getattr(scheduler, "cache_config", None)
block_size = int(getattr(cache_config, "block_size", 16) or 16)
model_config = getattr(scheduler, "model_config", None)
max_model_len = int(getattr(model_config, "max_model_len", 40960) or 40960)
plan = MiniCPMO45DuplexWindowPolicy.plan_reanchor(
session=session,
update=update,
block_size=block_size,
max_model_len=max_model_len,
unit_tokens=unit_tokens,
segment_output_ids=segment_output_ids,
)
if plan is None:
return None
duplex_mgr = cls.find_duplex_window_manager(scheduler)
if duplex_mgr is None:
return None
# Free and compact blocks via explicit request-local sink boundary on KV manager.
# Rejection before state mutation: if compaction returns 0, no state is changed.
gap_blocks = plan.delta // block_size
if hasattr(duplex_mgr, "compact_request_blocks"):
freed_tokens = duplex_mgr.compact_request_blocks(
session.request_id,
sink_blocks=plan.sink_blocks,
num_blocks=gap_blocks,
)
else:
freed_tokens = duplex_mgr.reanchor_block_table(session.request_id, plan)
if freed_tokens == 0:
return None
old_computed = session.num_computed_tokens
sink_end = plan.sink_end
moved_from = plan.moved_from
# Scheduler owns corresponding token-history and counter updates derived from applied compaction result
old_prompt_tokens = (
len(session.prompt_token_ids)
if getattr(session, "prompt_token_ids", None) is not None
else int(getattr(session, "num_prompt_tokens", 0) or old_computed)
)
if getattr(session, "prompt_token_ids", None) is not None and len(session.prompt_token_ids) >= moved_from:
session.prompt_token_ids = session.prompt_token_ids[:sink_end] + session.prompt_token_ids[moved_from:]
session.num_prompt_tokens = len(session.prompt_token_ids)
if getattr(session, "_all_token_ids", None) is not None and len(session._all_token_ids) >= moved_from:
session._all_token_ids = session._all_token_ids[:sink_end] + session._all_token_ids[moved_from:]
session.num_computed_tokens -= freed_tokens
# Worker instruction derived from the same applied compaction result
info = update.model_intermediate_buffer
duplex = info.setdefault("duplex", {})
runtime_config = duplex.get("runtime_config")
runtime_config = runtime_config if isinstance(runtime_config, dict) else {}
preserve_len = int(runtime_config.get("duplex_first_append_context_tokens", 0) or 0)
seq = int(duplex.get("seq", 0) or 0)
reanchor_count = getattr(session, "_minicpmo45_reanchor_count", 0) + 1
session._minicpmo45_reanchor_count = reanchor_count
reanchor_id = f"{session.request_id}-r{reanchor_count}-{plan.moved_from}-{plan.delta}"
duplex["stage0_reanchor"] = {
"reanchor_id": reanchor_id,
"delta": plan.delta,
"moved_from": plan.moved_from,
"sink_blocks": plan.sink_blocks,
"sink_end": plan.sink_end,
"prefix_tokens": preserve_len,
"block_size": block_size,
"old_computed_tokens": old_computed,
}
# Synchronize stage0_window with worker so worker finalizes the completed unit
generated_ids = list(segment_output_ids) if segment_output_ids is not None else []
stage0_window = duplex.setdefault("stage0_window", {})
stage0_window["completed_token_ids"] = generated_ids
if completed_terminator is not None:
stage0_window["completed_terminator_token_id"] = int(completed_terminator)
# Synchronize scheduler window units and coordinates atomically with compaction
boundary_before = old_prompt_tokens + len(generated_ids) + 2
recorded_open_start = getattr(session, "_minicpmo45_window_open_start", None)
open_start = preserve_len if recorded_open_start is None else int(recorded_open_start)
unit_len = boundary_before - open_start
special_ids = {
int(token_id)
for token_id in runtime_config.get("duplex_window_special_token_ids", ())
if isinstance(token_id, int)
}
units = list(getattr(session, "_minicpmo45_window_units", ()))
if seq > 1 and unit_len > 0:
units.append(
{
"length": unit_len,
"generated_token_ids": [int(t) for t in generated_ids if t not in special_ids],
}
)
# Prune units dropped by compaction
drop_start = max(0, plan.sink_end - preserve_len)
drop_end = drop_start + freed_tokens
new_units: list[dict[str, Any]] = []
curr_offset = 0
for u in units:
u_len = int(u.get("length", 0))
u_start = curr_offset
u_end = curr_offset + u_len
curr_offset = u_end
local_drop_start = max(0, drop_start - u_start)
local_drop_end = min(u_len, drop_end - u_start)
if local_drop_start >= local_drop_end:
new_units.append(u)
elif local_drop_start == 0 and local_drop_end == u_len:
continue
else:
new_len = u_len - (local_drop_end - local_drop_start)
gen_ids = u.get("generated_token_ids", [])
kept_gen_ids = gen_ids if local_drop_end < u_len else []
new_units.append(
{
"length": new_len,
"generated_token_ids": kept_gen_ids,
}
)
session._minicpmo45_window_units = new_units
# Rebase open_start into compacted coordinates
if seq > 1:
session._minicpmo45_window_open_start = boundary_before - freed_tokens
elif recorded_open_start is not None:
session._minicpmo45_window_open_start = max(0, recorded_open_start - freed_tokens)
return plan
@classmethod
def maybe_reanchor_session(
cls,
scheduler: Any,
session: Any,
update: Any,
*,
segment_output_ids: list[int] | None = None,
completed_terminator: int | None = None,
unit_tokens: list[int] | None = None,
) -> PositionReanchor | None:
"""Backward-compatible alias for apply_session_window."""
return cls.apply_session_window(
scheduler,
session,
update,
segment_output_ids=segment_output_ids,
completed_terminator=completed_terminator,
unit_tokens=unit_tokens,
)
class MiniCPMO45DuplexWorkerHelper:
"""Worker-side KV cache rotation and position metadata helper for MiniCPM-o 4.5 duplex."""
@classmethod
def get_rope_inv_freq(cls, runner: Any) -> torch.Tensor:
inv_freq = getattr(runner, "_duplex_inv_freq", None)
if inv_freq is None:
model_config = getattr(runner, "model_config", None)
head_dim = (
model_config.get_head_size()
if model_config is not None and hasattr(model_config, "get_head_size")
else 128
)
hf_config = getattr(model_config, "hf_config", None) if model_config is not None else None
base = float(getattr(hf_config, "rope_theta", 1000000.0) or 1000000.0)
inv_freq = 1.0 / (
base ** (torch.arange(0, head_dim, 2, dtype=torch.float32, device=runner.device) / head_dim)
)
runner._duplex_inv_freq = inv_freq
return inv_freq
@classmethod
def resolve_group_block_ids(
cls,
runner: Any,
req_id: str,
req_idx: int,
group_idx: int = 0,
) -> list[int]:
"""Resolve the flat block ID table for a specific KV cache group of a request.
In production, req_state.block_ids is a tuple of lists, one per KV cache group.
This helper unpacks the appropriate group to ensure compute_slot_mapping receives
a flat 1D list of integer block IDs.
"""
req_state = getattr(runner, "requests", {}).get(req_id) if hasattr(runner, "requests") else None
if req_state is not None and getattr(req_state, "block_ids", None):
raw_blocks = req_state.block_ids
if isinstance(raw_blocks, (tuple, list)) and len(raw_blocks) > 0:
if isinstance(raw_blocks[0], (tuple, list)):
actual_group = group_idx if group_idx < len(raw_blocks) else 0
return [int(b) for b in raw_blocks[actual_group]]
return [int(b) for b in raw_blocks]
# Fallback to input_batch block_table
bt = getattr(getattr(runner, "input_batch", None), "block_table", None)
if bt is not None:
bt_tables = getattr(bt, "block_tables", None)
if bt_tables and len(bt_tables) > 0:
actual_group = group_idx if group_idx < len(bt_tables) else 0
bt_row = bt_tables[actual_group]
else:
bt_row = bt
num_blocks = int(bt_row.num_blocks_per_row[req_idx])
return [int(b) for b in bt_row.block_table.np[req_idx, :num_blocks]]
return []
@classmethod
def maybe_apply_reanchor(cls, runner: Any, scheduler_output: Any = None) -> None:
"""Apply in-place KV reanchor and rotation on worker before model forward."""
if not hasattr(runner, "input_batch") or runner.input_batch is None:
return
num_reqs = getattr(runner.input_batch, "num_reqs", len(runner.input_batch.req_ids))
req_ids = runner.input_batch.req_ids[:num_reqs]
applied_reanchors = getattr(runner, "_applied_stage0_reanchor_ids", None)
if applied_reanchors is None:
applied_reanchors = runner._applied_stage0_reanchor_ids = set()
for req_idx, req_id in enumerate(req_ids):
info = runner.model_intermediate_buffer.get(req_id)
if not isinstance(info, dict):
continue
duplex = info.get("duplex")
if not isinstance(duplex, dict):
continue
reanchor = duplex.pop("stage0_reanchor", None)
if reanchor is None:
continue
# Sanitize scheduled_new_reqs in scheduler_output so subsequent runner
# metadata refreshes (_update_additional_information in _preprocess)
# do not re-inject this already popped reanchor command.
if scheduler_output is not None and hasattr(scheduler_output, "scheduled_new_reqs"):
for new_req in scheduler_output.scheduled_new_reqs:
if getattr(new_req, "req_id", None) == req_id:
buf = getattr(new_req, "model_intermediate_buffer", None)
if isinstance(buf, dict) and isinstance(buf.get("duplex"), dict):
buf["duplex"].pop("stage0_reanchor", None)
reanchor_sig = (
f"{req_id}:{reanchor.get('moved_from')}:{reanchor.get('delta')}:{reanchor.get('old_computed_tokens')}"
)
reanchor_id = reanchor.get("reanchor_id") or reanchor_sig
if reanchor_id in applied_reanchors:
continue
applied_reanchors.add(reanchor_id)
reanchor["reanchor_id"] = reanchor_id
plan = PositionReanchor(
delta=reanchor["delta"],
moved_from=reanchor["moved_from"],
sink_blocks=reanchor["sink_blocks"],
)
# Scheduler is authoritative for logical state (block_table and computed tokens).
# The parent runner's _update_states() already installed the post-compaction block IDs
# and decremented num_computed_tokens_cpu. We do NOT double-compact or double-decrement here.
old_computed = int(
reanchor.get(
"old_computed_tokens",
int(runner.input_batch.num_computed_tokens_cpu[req_idx]) + plan.delta,
)
)
req_state = runner.requests.get(req_id) if hasattr(runner, "requests") else None
mrope_pos = getattr(req_state, "mrope_positions", None) if req_state is not None else None
if mrope_pos is not None:
assert_uniform_position_shift(mrope_pos, plan.moved_from)
block_size = int(getattr(getattr(runner, "cache_config", None), "block_size", 16) or 16)
sink_tokens = plan.sink_blocks * block_size
if mrope_pos.shape[1] >= old_computed:
req_state.mrope_positions = torch.cat(
[
mrope_pos[:, :sink_tokens],
mrope_pos[:, plan.moved_from : old_computed] - plan.delta,
],
dim=1,
)
if getattr(req_state, "mrope_position_delta", None) is not None:
req_state.mrope_position_delta = max(0, req_state.mrope_position_delta - plan.delta)
positions = torch.arange(plan.moved_from, old_computed, dtype=torch.long, device=runner.device)
if mrope_pos is None:
assert_uniform_position_shift(positions, plan.moved_from)
if positions.numel() > 0 and hasattr(runner, "kv_caches") and runner.kv_caches:
inv_freq = cls.get_rope_inv_freq(runner)
kv_groups = getattr(runner, "kv_cache_group_ids", None)
block_size = int(getattr(getattr(runner, "cache_config", None), "block_size", 16) or 16)
for layer_idx, kv_cache in enumerate(runner.kv_caches):
group_idx = kv_groups[layer_idx] if (kv_groups and layer_idx < len(kv_groups)) else 0
layer_block_ids = cls.resolve_group_block_ids(runner, req_id, req_idx, group_idx=group_idx)
rotate_cached_keys(
kv_cache,
block_ids=layer_block_ids,
positions=positions,
plan=plan,
inv_freq=inv_freq,
block_size=block_size,
)
# Evict worker-held unit history so state.window_units remains bounded
model = getattr(runner, "model", None)
helper = getattr(model, "_minicpmo45_duplex_data_plane_helper", None)
if helper is not None and isinstance(getattr(helper, "sessions", None), dict):
req_sessions = getattr(model, "_minicpmo45_duplex_request_sessions", {})
session_key = duplex.get("session_id") or (
req_sessions.get(req_id) if isinstance(req_sessions, dict) else None
)
session_state = helper.sessions.get(session_key) if session_key else None
if session_state is not None and hasattr(session_state, "window_units"):
if hasattr(helper, "_evict_window_units_for_reanchor"):
helper._evict_window_units_for_reanchor(session_state, reanchor)
else:
session_state.window_units.clear()
@@ -0,0 +1,252 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Position and block accounting for the MiniCPM-o 4.5 duplex KV window.
MiniCPM-o's native duplex streaming has a bounded attention budget: once a
session's context passes the window, the oldest content has to go. The HF
reference implementation does that by trimming the cached K/V tensors and
re-RoPE-ing the survivors (``slide_trigger_seconds`` / ``slide_stride_seconds``
in the streaming processor, which is a *separate* window living in the audio
CNN). This module is the same rule, in the units a paged KV cache schedules in:
tokens, blocks and watermarks.
Why trimming and renumbering are one event, not two
---------------------------------------------------
The alternative is to keep every position and only mask the out-of-window
range, which is what vLLM's sliding-window cache types do. Neither fits a
duplex session:
* ``SlidingWindowSpec`` masks a *contiguous* tail window -- the kernel is handed
``window_size=(W-1, 0)`` -- so it cannot keep a head prefix attended while
dropping what sits right after it.
* ``RSWASpec`` can, that being its whole shape, but it defines its sink as
``num_prompt_tokens``, which for a session that grows by appends is the first
append rather than the head context the duplex protocol preserves. It also
needs a backend that can express the mask: FA4's ``rswa_mask_mod``, Triton or
FlexAttention. On a FlashAttention-3 Stage 0 the branch is skipped and the
zeroed gap blocks are attended as ordinary context.
Renumbering removes the gap outright instead. The retained span is a contiguous
``[0, prefix + window)`` range, plain causal attention over it is what the model
was trained to do, and no backend has to know a window exists. The price is one
rotation of the retained keys, which is what :class:`PositionReanchor` plans.
Everything here is pure integer arithmetic so the invariants are testable
without a GPU, a checkpoint, or a vLLM engine. ``window_kv.py`` is the vLLM
tier that consumes it.
Paged KV Retention Semantics vs. Unpaged Reference
--------------------------------------------------
In the unpaged HuggingFace reference implementation (OpenBMB MiniCPM-o 4.5), KV
tensors are dense PyTorch tensors. The reference basic window drops whole units
immediately after the exact preserved prefix length:
`retained = [0, prefix_tokens) + [unit_end, total)`.
In vLLM, KV memory is paged into physical blocks of size `block_size` (e.g. 16 or 128).
To achieve zero-copy in-place KV reuse without allocating new blocks or shifting
physical slot rows across pages:
1. The sink region is aligned up to whole blocks:
`sink_end = cdiv(prefix_tokens, block_size) * block_size`.
2. Any tokens in `[prefix_tokens, sink_end)` reside in the physical sink block and
remain resident in-place (up to `block_size - 1` tokens).
3. The dropped interval is `[sink_end, moved_from)`.
4. The retained tail `[moved_from, total)` is translated down by `delta = moved_from - sink_end`,
where `delta` is an exact multiple of `block_size`.
This guarantees that physical pages in the block table require no data movement or
reallocation, at the cost of retaining up to `block_size - 1` tokens of the oldest
unit in the sink page.
"""
from __future__ import annotations
from dataclasses import dataclass
def align_up(value: int, unit: int) -> int:
if unit <= 0:
raise ValueError(f"unit must be positive, got {unit}")
return -((-int(value)) // unit) * unit
def cdiv(value: int, divisor: int) -> int:
return -((-int(value)) // int(divisor))
@dataclass(frozen=True)
class DuplexWindowGeometry:
"""Window policy for one duplex session, in tokens.
Attributes:
prefix_tokens: The head context that outlives every trim (system
instructions, reference audio, suffix). It stays at position 0.
window_tokens: Low watermark -- how much appended content survives a
trim, so a trim lands the session on ``prefix_tokens +
window_tokens``.
block_size: KV cache block size.
max_model_len: Position budget of the stage. A windowed session is not
supposed to run against this; it bounds the degenerate case where
the window is the whole context.
sample_room: Tokens that must stay free after the prompt to decode.
high_watermark_tokens: Trigger point -- a trim is planned once the
projected content passes this. Defaults to the low watermark, i.e.
hold the budget exactly; the reference implementation's
trigger/stride pair is the same knob measured in seconds.
"""
prefix_tokens: int
window_tokens: int
block_size: int
max_model_len: int
sample_room: int = 1
high_watermark_tokens: int | None = None
def __post_init__(self) -> None:
if self.prefix_tokens < 0 or self.window_tokens <= 0:
raise ValueError(f"invalid window geometry: prefix={self.prefix_tokens} window={self.window_tokens}")
if self.block_size <= 0 or self.max_model_len <= 0:
raise ValueError(
f"invalid window geometry: block_size={self.block_size} max_model_len={self.max_model_len}"
)
if self.sample_room < 0:
raise ValueError(f"sample_room must be non-negative, got {self.sample_room}")
if self.high_watermark_tokens is not None and self.high_watermark_tokens < self.window_tokens:
raise ValueError(
f"high_watermark_tokens {self.high_watermark_tokens} is below window_tokens {self.window_tokens}"
)
@property
def trigger_tokens(self) -> int:
"""Content tokens at which a trim becomes due, before the prefix."""
return self.window_tokens if self.high_watermark_tokens is None else self.high_watermark_tokens
def unit_starts(geometry: DuplexWindowGeometry, unit_tokens: list[int]) -> list[int]:
"""Absolute position of each unit's first token, after the fixed prefix.
``unit_tokens`` is one entry per accepted append: every processor chunk and
internal closure token that append wrote into the KV cache. The positions
are read after the last trim, so the caller owns the history this is derived
from; a session that has already re-anchored reports shifted lengths.
"""
starts: list[int] = []
position = geometry.prefix_tokens
for unit in unit_tokens:
starts.append(position)
position += int(unit)
return starts
@dataclass(frozen=True)
class PositionReanchor:
"""A renumbering of the retained tail, and the rotation it implies.
Attributes:
delta: Positions subtracted from every retained token at or after
``moved_from``. A multiple of ``block_size``, which is what lets the
tail keep the physical slots it is already written in: the block
table loses its gap entries, so logical index ``j`` becomes
``j - delta / block_size``, and ``block_table[j']`` still names the
page that holds the row.
moved_from: First absolute position that moves.
sink_blocks: Table prefix that stays where it is.
"""
delta: int
moved_from: int
sink_blocks: int
@property
def sink_end(self) -> int:
"""Position the retained tail lands on, i.e. the end of the sink."""
return self.moved_from - self.delta
@property
def rope_angle_shift_tokens(self) -> int:
"""Signed position offset to rotate retained keys by: ``-delta``.
RoPE composes, so a token that moves from ``p`` to ``p - delta`` needs
its cached key rotated by ``-delta * inv_freq`` and nothing else: the
sink did not move, and a uniform translation of the tail leaves every
within-tail relative distance untouched.
"""
return -self.delta
def reanchor_positions(position: int, plan: PositionReanchor) -> int:
"""Map one absolute position through a re-anchor.
The sink keeps its positions, everything from ``moved_from`` translates by
``-delta``, and the span in between ceases to exist.
"""
if int(position) != position:
raise ValueError(f"position must be an integer, got {position!r}")
position = int(position)
if plan.sink_end <= position < plan.moved_from:
raise ValueError(
f"position {position} was dropped by reanchor plan moved_from={plan.moved_from} delta={plan.delta}"
)
if position < plan.moved_from:
return position
return position - plan.delta
def plan_position_reanchor(
geometry: DuplexWindowGeometry,
*,
computed_tokens: int,
pending_tokens: int,
target_tokens: int | None = None,
unit_tokens: list[int] | None = None,
) -> PositionReanchor | None:
"""Plan the trim that keeps the next append inside the window.
Args:
computed_tokens: Positions written so far, after the last trim.
pending_tokens: What the next append is expected to add.
target_tokens: Where the sequence should land; defaults to the sink plus
the low-watermark window.
unit_tokens: Per-append committed lengths. When given, the cut snaps down
to a unit start, so a unit is either fully kept or fully gone --
dropping half a unit would be a behaviour change against a window
that drops whole ones.
Returns ``None`` while the session is inside the trigger watermark.
"""
projected = int(computed_tokens) + int(pending_tokens)
if projected <= geometry.prefix_tokens + geometry.trigger_tokens:
return None
sink_end_block = cdiv(geometry.prefix_tokens, geometry.block_size)
sink_end = sink_end_block * geometry.block_size
target = geometry.prefix_tokens + geometry.window_tokens if target_tokens is None else int(target_tokens)
target = max(sink_end + geometry.block_size, target)
if target >= projected:
# Nothing to reclaim: the caller must finish the session with
# context_length_exceeded instead of moving the window start backwards.
return None
drop_needed = projected - target
if unit_tokens:
boundaries = unit_starts(geometry, unit_tokens)
# To avoid splitting units in half, moved_from must land on an exact unit start
# that is ALSO a page boundary (b % block_size == 0) and at or after sink_end.
# We need moved_from >= sink_end + drop_needed so post-trim length <= target.
min_cut = sink_end + drop_needed
candidates = [b for b in boundaries if b >= min_cut and b % geometry.block_size == 0 and b < projected]
if not candidates:
# No page-aligned whole-unit boundary satisfies the drop budget without
# slicing into a unit. Return None so caller routes to fallback.
return None
moved_from = min(candidates)
delta = moved_from - sink_end
else:
delta = align_up(drop_needed, geometry.block_size)
moved_from = sink_end + delta
if moved_from >= projected:
return None
return PositionReanchor(
delta=delta,
moved_from=moved_from,
sink_blocks=sink_end_block,
)
@@ -110,6 +110,37 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
self.model = self.thinker
self.talker = None
if getattr(getattr(vllm_config, "model_config", None), "session_mode", None) == "duplex":
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv import (
DUPLEX_WINDOW_BLOCK_SIZE,
duplex_window_geometry,
install_duplex_window_layers,
validate_duplex_window_install,
)
cache_config = getattr(vllm_config, "cache_config", None)
model_config = getattr(vllm_config, "model_config", None)
block_size = int(
getattr(cache_config, "block_size", DUPLEX_WINDOW_BLOCK_SIZE) or DUPLEX_WINDOW_BLOCK_SIZE
)
max_model_len = getattr(model_config, "max_model_len", None) if model_config is not None else None
if max_model_len is None:
max_model_len = 8192
geometry = duplex_window_geometry(
prefix_tokens=96,
window_tokens=6000,
block_size=block_size,
max_model_len=max_model_len,
high_watermark_tokens=8000,
)
install_duplex_window_layers(self.thinker, geometry=geometry)
validate_duplex_window_install(
cache_config,
model_config,
geometry,
)
elif self.model_stage == "tts":
self.thinker = None
# The Talker is always the runner-owned continuous codec producer.
@@ -162,6 +193,14 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
return Sampler()
def apply_duplex_kv_reanchor(self, runner: Any, scheduler_output: Any = None) -> None:
"""Apply in-place Stage-0 KV reanchor and rotation on worker before model forward."""
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv import (
MiniCPMO45DuplexWorkerHelper,
)
MiniCPMO45DuplexWorkerHelper.maybe_apply_reanchor(runner, scheduler_output=scheduler_output)
def prepare_duplex_sampling(
self,
logits: torch.Tensor,
@@ -387,6 +426,9 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp
is_speech=bool(payload.get("is_speech", False)),
final=bool(duplex.get("final")),
stage0_window=(duplex.get("stage0_window") if isinstance(duplex.get("stage0_window"), dict) else None),
stage0_reanchor=(
duplex.get("stage0_reanchor") if isinstance(duplex.get("stage0_reanchor"), dict) else None
),
)
update_result = dict(result)
if result.get("stage0_window_replaced") is True: