[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:
1 parent
817f5d0e4d
commit
c8a1419e02
9 files changed
+3886
-16
No files matched your search
@@ -0,0 +1,422 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Re-RoPE Correctness Regression Suite for MiniCPM-o 4.5 Stage-0 KV Window:
|
||||
|
||||
Compares in-place Re-RoPE + Block-Table Compaction against clean re-prefill ground truth:
|
||||
1. Retained K after Re-RoPE vs. K from clean re-prefill (FP32 & BF16).
|
||||
2. Next-step logits & greedy token generation rollout.
|
||||
3. Prefix KV cache invariance (sink tokens remain bitwise untouched).
|
||||
4. V cache invariance (values never receive RoPE phase rotation).
|
||||
5. Block-boundary alignment & repeated compactions (compound rotations without drift).
|
||||
6. Basic/context modes watermark policies, reference-audio sink, and camera MRoPE validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
try:
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv import (
|
||||
DUPLEX_WINDOW_BLOCK_SIZE,
|
||||
assert_uniform_position_shift,
|
||||
rotate_keys,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan import (
|
||||
DuplexWindowGeometry,
|
||||
plan_position_reanchor,
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
def _make_pkg(name: str) -> types.ModuleType:
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
m = types.ModuleType(name)
|
||||
m.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules[name] = m
|
||||
return m
|
||||
|
||||
vllm = _make_pkg("vllm")
|
||||
vllm.__version__ = "0.7.0" # type: ignore[attr-defined]
|
||||
vllm.__version_tuple__ = (0, 7, 0) # type: ignore[attr-defined]
|
||||
v1 = _make_pkg("vllm.v1")
|
||||
spec_reg = _make_pkg("vllm.v1.kv_cache_spec_registry")
|
||||
spec_reg.register_kv_cache_spec = lambda *a, **k: (lambda cls: cls) # type: ignore[attr-defined]
|
||||
kv_if = _make_pkg("vllm.v1.kv_cache_interface")
|
||||
|
||||
class MockSpec:
|
||||
pass
|
||||
|
||||
kv_if.KVCacheSpec = MockSpec # type: ignore[attr-defined]
|
||||
kv_if.SlidingWindowSpec = MockSpec # type: ignore[attr-defined]
|
||||
|
||||
vo = _make_pkg("vllm_omni")
|
||||
vo_exp = _make_pkg("vllm_omni.experimental")
|
||||
vo_ad = _make_pkg("vllm_omni.experimental.ar_diffusion")
|
||||
vo_kc = _make_pkg("vllm_omni.experimental.ar_diffusion.kv_cache")
|
||||
vo_pg = _make_pkg("vllm_omni.experimental.ar_diffusion.kv_cache.paged")
|
||||
|
||||
class MockChunkSpec:
|
||||
pass
|
||||
|
||||
class MockChunkManager:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def reanchor_block_table(self, *a, **k):
|
||||
pass
|
||||
|
||||
vo_pg.ChunkWindowSpec = MockChunkSpec # type: ignore[attr-defined]
|
||||
vo_pg.ChunkWindowManager = MockChunkManager # type: ignore[attr-defined]
|
||||
|
||||
def compute_slot_mapping(block_ids, positions, block_size):
|
||||
p = positions.to(dtype=torch.long)
|
||||
t = torch.tensor(block_ids, dtype=torch.long, device=p.device)
|
||||
return t[torch.div(p, block_size, rounding_mode="floor")] * block_size + (p % block_size)
|
||||
|
||||
vo_pg.compute_slot_mapping = compute_slot_mapping # type: ignore[attr-defined]
|
||||
|
||||
_make_pkg("vllm_omni.model_executor")
|
||||
_make_pkg("vllm_omni.model_executor.models")
|
||||
_make_pkg("vllm_omni.model_executor.models.minicpmo_4_5")
|
||||
_make_pkg("vllm_omni.model_executor.models.minicpmo_4_5.duplex")
|
||||
|
||||
repo_root = pathlib.Path(__file__).resolve().parent
|
||||
while repo_root.name and not (repo_root / "vllm_omni").is_dir():
|
||||
repo_root = repo_root.parent
|
||||
|
||||
def _load_module(name: str, rel_path: str) -> types.ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, repo_root / rel_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
_wp = _load_module(
|
||||
"vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan",
|
||||
"vllm_omni/model_executor/models/minicpmo_4_5/duplex/window_plan.py",
|
||||
)
|
||||
_wk = _load_module(
|
||||
"vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv",
|
||||
"vllm_omni/model_executor/models/minicpmo_4_5/duplex/window_kv.py",
|
||||
)
|
||||
|
||||
DUPLEX_WINDOW_BLOCK_SIZE = _wk.DUPLEX_WINDOW_BLOCK_SIZE
|
||||
assert_uniform_position_shift = _wk.assert_uniform_position_shift
|
||||
rotate_keys = _wk.rotate_keys
|
||||
DuplexWindowGeometry = _wp.DuplexWindowGeometry
|
||||
plan_position_reanchor = _wp.plan_position_reanchor
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
HEAD_DIM = 128
|
||||
NUM_HEADS = 8
|
||||
NUM_KV_HEADS = 2
|
||||
HIDDEN_DIM = NUM_HEADS * HEAD_DIM
|
||||
VOCAB_SIZE = 1000
|
||||
BLOCK_SIZE = DUPLEX_WINDOW_BLOCK_SIZE # 16
|
||||
|
||||
|
||||
def _get_inv_freq(head_dim: int = HEAD_DIM, base: float = 1000000.0) -> torch.Tensor:
|
||||
return 1.0 / (base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
|
||||
|
||||
|
||||
def _forward_rope(x: torch.Tensor, pos: int | torch.Tensor, inv_freq: torch.Tensor) -> torch.Tensor:
|
||||
half = x.shape[-1] // 2
|
||||
if isinstance(pos, int):
|
||||
pos_t = torch.tensor([pos], dtype=torch.float32, device=x.device)
|
||||
else:
|
||||
pos_t = pos.to(dtype=torch.float32, device=x.device)
|
||||
angle = torch.outer(pos_t, inv_freq.to(dtype=torch.float32)).unsqueeze(1)
|
||||
cos = torch.cos(angle).to(dtype=x.dtype)
|
||||
sin = torch.sin(angle).to(dtype=x.dtype)
|
||||
x1, x2 = x[..., :half], x[..., half:]
|
||||
return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1)
|
||||
|
||||
|
||||
def _attention_decode(
|
||||
query_token: torch.Tensor,
|
||||
query_pos: int,
|
||||
k_cache: torch.Tensor,
|
||||
v_cache: torch.Tensor,
|
||||
w_q: torch.Tensor,
|
||||
w_o: torch.Tensor,
|
||||
lm_head: torch.Tensor,
|
||||
embedding: torch.Tensor,
|
||||
inv_freq: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
q_x = embedding[query_token]
|
||||
q_raw = (q_x @ w_q.T).view(-1, NUM_HEADS, HEAD_DIM)
|
||||
q = _forward_rope(q_raw, query_pos, inv_freq)
|
||||
|
||||
num_rep = NUM_HEADS // NUM_KV_HEADS
|
||||
k_exp = k_cache.repeat_interleave(num_rep, dim=1)
|
||||
v_exp = v_cache.repeat_interleave(num_rep, dim=1)
|
||||
|
||||
scores = torch.einsum("qhd,khd->hqk", q.float(), k_exp.float()) / math.sqrt(HEAD_DIM)
|
||||
probs = F.softmax(scores, dim=-1).to(k_cache.dtype)
|
||||
attn_out = torch.einsum("hqk,khd->qhd", probs, v_exp).reshape(-1, HIDDEN_DIM)
|
||||
out = attn_out @ w_o.T
|
||||
logits = out @ lm_head.T
|
||||
return logits
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_single_trim_ab_correctness(dtype: torch.dtype):
|
||||
"""Criteria 1, 2, 3, 4: Retained K, Next-step Logits/Tokens, Prefix KV, Tail V invariance."""
|
||||
torch.manual_seed(42)
|
||||
inv_freq = _get_inv_freq(HEAD_DIM)
|
||||
|
||||
W_q = (torch.randn(NUM_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02).to(dtype)
|
||||
W_k = (torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02).to(dtype)
|
||||
W_v = (torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02).to(dtype)
|
||||
W_o = (torch.randn(HIDDEN_DIM, NUM_HEADS * HEAD_DIM) * 0.02).to(dtype)
|
||||
lm_head = (torch.randn(VOCAB_SIZE, HIDDEN_DIM) * 0.02).to(dtype)
|
||||
embedding = (torch.randn(VOCAB_SIZE, HIDDEN_DIM) * 0.02).to(dtype)
|
||||
|
||||
def compute_kv(tokens: torch.Tensor, positions: torch.Tensor):
|
||||
x = embedding[tokens]
|
||||
k = (x @ W_k.T).view(-1, NUM_KV_HEADS, HEAD_DIM)
|
||||
v = (x @ W_v.T).view(-1, NUM_KV_HEADS, HEAD_DIM)
|
||||
return _forward_rope(k, positions, inv_freq), v
|
||||
|
||||
PREFIX_LEN, GAP_LEN, TAIL_LEN = 32, 32, 48
|
||||
TOTAL_LEN = PREFIX_LEN + GAP_LEN + TAIL_LEN
|
||||
tokens = torch.randint(0, VOCAB_SIZE, (TOTAL_LEN,))
|
||||
|
||||
k_init, v_init = compute_kv(tokens, torch.arange(TOTAL_LEN))
|
||||
|
||||
# Method A: In-Place Re-RoPE (rotate survivors by -GAP_LEN)
|
||||
k_tail_A = rotate_keys(k_init[PREFIX_LEN + GAP_LEN :], GAP_LEN, inv_freq)
|
||||
v_tail_A = v_init[PREFIX_LEN + GAP_LEN :]
|
||||
k_A = torch.cat([k_init[:PREFIX_LEN], k_tail_A], dim=0)
|
||||
v_A = torch.cat([v_init[:PREFIX_LEN], v_tail_A], dim=0)
|
||||
|
||||
# Method B: Clean Re-prefill Ground Truth
|
||||
kept_tokens = torch.cat([tokens[:PREFIX_LEN], tokens[PREFIX_LEN + GAP_LEN :]], dim=0)
|
||||
k_B, v_B = compute_kv(kept_tokens, torch.arange(len(kept_tokens)))
|
||||
|
||||
# Invariance assertions:
|
||||
# 1. Prefix K and V are strictly untouched (sink)
|
||||
assert torch.equal(k_A[:PREFIX_LEN], k_init[:PREFIX_LEN]), "Prefix K must be bitwise untouched"
|
||||
assert torch.equal(v_A[:PREFIX_LEN], v_init[:PREFIX_LEN]), "Prefix V must be bitwise untouched"
|
||||
# 2. Tail V is never rotated
|
||||
assert torch.equal(v_tail_A, v_init[PREFIX_LEN + GAP_LEN :]), "Tail V must not be modified by Re-RoPE"
|
||||
|
||||
# 3. Retained K matches clean re-prefill
|
||||
diff_k = (k_tail_A.float() - k_B[PREFIX_LEN:].float()).abs().max().item()
|
||||
tol_k = 1e-5 if dtype == torch.float32 else 0.035
|
||||
assert diff_k < tol_k, f"Retained K diff {diff_k} exceeds tolerance {tol_k}"
|
||||
|
||||
cos_sim = (
|
||||
F.cosine_similarity(
|
||||
k_tail_A.reshape(-1, HEAD_DIM).float(),
|
||||
k_B[PREFIX_LEN:].reshape(-1, HEAD_DIM).float(),
|
||||
dim=-1,
|
||||
)
|
||||
.mean()
|
||||
.item()
|
||||
)
|
||||
assert cos_sim > 0.9999, f"Cosine similarity {cos_sim} too low"
|
||||
|
||||
# 4. Next-step logits & greedy token match
|
||||
q_tok = torch.randint(0, VOCAB_SIZE, (1,))
|
||||
next_pos = len(kept_tokens)
|
||||
log_A = _attention_decode(q_tok, next_pos, k_A, v_A, W_q, W_o, lm_head, embedding, inv_freq)
|
||||
log_B = _attention_decode(q_tok, next_pos, k_B, v_B, W_q, W_o, lm_head, embedding, inv_freq)
|
||||
|
||||
tok_A = log_A.argmax(dim=-1).item()
|
||||
tok_B = log_B.argmax(dim=-1).item()
|
||||
assert tok_A == tok_B, f"Greedy next-step token mismatch: {tok_A} != {tok_B}"
|
||||
if dtype == torch.float32:
|
||||
diff_log = (log_A.float() - log_B.float()).abs().max().item()
|
||||
assert diff_log < 1e-4, f"Logits diff {diff_log} exceeds tolerance 1e-4"
|
||||
|
||||
|
||||
def test_repeated_consecutive_trims_and_greedy_rollout():
|
||||
"""Criteria 5: Verify compound rotations across 3 consecutive trims and 5-step rollout."""
|
||||
torch.manual_seed(123)
|
||||
inv_freq = _get_inv_freq(HEAD_DIM)
|
||||
|
||||
W_q = torch.randn(NUM_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02
|
||||
W_k = torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02
|
||||
W_v = torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02
|
||||
W_o = torch.randn(HIDDEN_DIM, NUM_HEADS * HEAD_DIM) * 0.02
|
||||
lm_head = torch.randn(VOCAB_SIZE, HIDDEN_DIM) * 0.02
|
||||
embedding = torch.randn(VOCAB_SIZE, HIDDEN_DIM) * 0.02
|
||||
|
||||
def compute_kv(tokens: torch.Tensor, positions: torch.Tensor):
|
||||
x = embedding[tokens]
|
||||
k = (x @ W_k.T).view(-1, NUM_KV_HEADS, HEAD_DIM)
|
||||
v = (x @ W_v.T).view(-1, NUM_KV_HEADS, HEAD_DIM)
|
||||
return _forward_rope(k, positions, inv_freq), v
|
||||
|
||||
PREFIX_LEN, CHUNK = 32, 16
|
||||
t0 = torch.randint(0, VOCAB_SIZE, (PREFIX_LEN,))
|
||||
t1 = torch.randint(0, VOCAB_SIZE, (CHUNK,))
|
||||
t2 = torch.randint(0, VOCAB_SIZE, (CHUNK,))
|
||||
t3 = torch.randint(0, VOCAB_SIZE, (CHUNK,))
|
||||
|
||||
# Prefill & Trim 1 (drop t1)
|
||||
k_p, v_p = compute_kv(torch.cat([t0, t1, t2, t3]), torch.arange(PREFIX_LEN + 3 * CHUNK))
|
||||
k1 = torch.cat([k_p[:PREFIX_LEN], rotate_keys(k_p[PREFIX_LEN + CHUNK :], CHUNK, inv_freq)], dim=0)
|
||||
v1 = torch.cat([v_p[:PREFIX_LEN], v_p[PREFIX_LEN + CHUNK :]], dim=0)
|
||||
|
||||
# Append 1: t4, t5
|
||||
t4 = torch.randint(0, VOCAB_SIZE, (CHUNK,))
|
||||
t5 = torch.randint(0, VOCAB_SIZE, (CHUNK,))
|
||||
k_s1, v_s1 = compute_kv(torch.cat([t4, t5]), torch.arange(64, 64 + 2 * CHUNK))
|
||||
k2 = torch.cat([k1, k_s1], dim=0)
|
||||
v2 = torch.cat([v1, v_s1], dim=0)
|
||||
|
||||
# Trim 2 (drop t2): t3 receives its 2nd rotation
|
||||
k3 = torch.cat([k2[:PREFIX_LEN], rotate_keys(k2[PREFIX_LEN + CHUNK :], CHUNK, inv_freq)], dim=0)
|
||||
v3 = torch.cat([v2[:PREFIX_LEN], v2[PREFIX_LEN + CHUNK :]], dim=0)
|
||||
|
||||
# Append 2: t6
|
||||
t6 = torch.randint(0, VOCAB_SIZE, (CHUNK,))
|
||||
k_s2, v_s2 = compute_kv(t6, torch.arange(80, 80 + CHUNK))
|
||||
k4 = torch.cat([k3, k_s2], dim=0)
|
||||
v4 = torch.cat([v3, v_s2], dim=0)
|
||||
|
||||
# Trim 3 (drop t3): t4 receives its 2nd rotation, t5 its 1st
|
||||
k_A_final = torch.cat([k4[:PREFIX_LEN], rotate_keys(k4[PREFIX_LEN + CHUNK :], CHUNK, inv_freq)], dim=0)
|
||||
v_A_final = torch.cat([v4[:PREFIX_LEN], v4[PREFIX_LEN + CHUNK :]], dim=0)
|
||||
|
||||
# Ground Truth: Clean re-prefill of surviving sequence [t0, t4, t5, t6]
|
||||
kept_final = torch.cat([t0, t4, t5, t6])
|
||||
k_B_final, v_B_final = compute_kv(kept_final, torch.arange(len(kept_final)))
|
||||
|
||||
diff_compound = (k_A_final - k_B_final).abs().max().item()
|
||||
assert diff_compound < 1e-5, f"Compound rotation diff {diff_compound} exceeds 1e-5"
|
||||
assert torch.allclose(v_A_final, v_B_final, atol=1e-6), (
|
||||
f"Compound V diff {(v_A_final - v_B_final).abs().max().item()} exceeds 1e-6"
|
||||
)
|
||||
|
||||
# Multi-step autoregressive rollout (5 steps greedy decode)
|
||||
cur_A_tok = torch.tensor([42])
|
||||
cur_B_tok = torch.tensor([42])
|
||||
curr_k_A, curr_v_A = k_A_final.clone(), v_A_final.clone()
|
||||
curr_k_B, curr_v_B = k_B_final.clone(), v_B_final.clone()
|
||||
|
||||
rollout_matches = []
|
||||
for step in range(5):
|
||||
cur_pos = len(kept_final) + step
|
||||
l_A = _attention_decode(cur_A_tok, cur_pos, curr_k_A, curr_v_A, W_q, W_o, lm_head, embedding, inv_freq)
|
||||
l_B = _attention_decode(cur_B_tok, cur_pos, curr_k_B, curr_v_B, W_q, W_o, lm_head, embedding, inv_freq)
|
||||
tok_A = l_A.argmax(dim=-1)
|
||||
tok_B = l_B.argmax(dim=-1)
|
||||
rollout_matches.append(tok_A.item() == tok_B.item())
|
||||
|
||||
k_step_A, v_step_A = compute_kv(tok_A, torch.tensor([cur_pos]))
|
||||
curr_k_A = torch.cat([curr_k_A, k_step_A], dim=0)
|
||||
curr_v_A = torch.cat([curr_v_A, v_step_A], dim=0)
|
||||
|
||||
k_step_B, v_step_B = compute_kv(tok_B, torch.tensor([cur_pos]))
|
||||
curr_k_B = torch.cat([curr_k_B, k_step_B], dim=0)
|
||||
curr_v_B = torch.cat([curr_v_B, v_step_B], dim=0)
|
||||
|
||||
cur_A_tok = tok_A
|
||||
cur_B_tok = tok_B
|
||||
|
||||
assert all(rollout_matches), f"Greedy rollout diverged at step {rollout_matches.index(False)}"
|
||||
|
||||
|
||||
def test_block_boundary_and_fractional_tail():
|
||||
"""Criteria 5: Test non-multiple block sizes and fractional tails."""
|
||||
torch.manual_seed(99)
|
||||
inv_freq = _get_inv_freq(HEAD_DIM)
|
||||
|
||||
W_k = torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02
|
||||
W_v = torch.randn(NUM_KV_HEADS * HEAD_DIM, HIDDEN_DIM) * 0.02
|
||||
embedding = torch.randn(VOCAB_SIZE, HIDDEN_DIM) * 0.02
|
||||
|
||||
def compute_kv(tokens: torch.Tensor, positions: torch.Tensor):
|
||||
x = embedding[tokens]
|
||||
k = (x @ W_k.T).view(-1, NUM_KV_HEADS, HEAD_DIM)
|
||||
v = (x @ W_v.T).view(-1, NUM_KV_HEADS, HEAD_DIM)
|
||||
return _forward_rope(k, positions, inv_freq), v
|
||||
|
||||
PREFIX_LEN, GAP_LEN, PARTIAL_TAIL = 32, 16, 37 # 37 is not a multiple of 16
|
||||
tokens = torch.randint(0, VOCAB_SIZE, (PREFIX_LEN + GAP_LEN + PARTIAL_TAIL,))
|
||||
k_init, _ = compute_kv(tokens, torch.arange(len(tokens)))
|
||||
|
||||
k_tail_A = rotate_keys(k_init[PREFIX_LEN + GAP_LEN :], GAP_LEN, inv_freq)
|
||||
k_A = torch.cat([k_init[:PREFIX_LEN], k_tail_A], dim=0)
|
||||
|
||||
kept = torch.cat([tokens[:PREFIX_LEN], tokens[PREFIX_LEN + GAP_LEN :]], dim=0)
|
||||
k_B, _ = compute_kv(kept, torch.arange(len(kept)))
|
||||
|
||||
diff = (k_A - k_B).abs().max().item()
|
||||
assert diff < 1e-5, f"Fractional tail diff {diff} exceeds 1e-5"
|
||||
|
||||
|
||||
def test_basic_vs_context_mode_watermarks():
|
||||
"""Criteria 6: Test watermark planner under basic and context session modes."""
|
||||
# Basic Mode: fixed watermarks (8000 high, 6000 low)
|
||||
geom_basic = DuplexWindowGeometry(
|
||||
prefix_tokens=96,
|
||||
window_tokens=6000,
|
||||
block_size=16,
|
||||
max_model_len=40960,
|
||||
high_watermark_tokens=8000,
|
||||
)
|
||||
# Below high watermark -> No trim
|
||||
assert plan_position_reanchor(geom_basic, computed_tokens=7900, pending_tokens=12) is None
|
||||
# Exceed high watermark (trigger = 96 + 8000 = 8096) -> Trims to low watermark
|
||||
plan_basic = plan_position_reanchor(geom_basic, computed_tokens=8100, pending_tokens=12)
|
||||
assert plan_basic is not None
|
||||
assert plan_basic.delta % 16 == 0
|
||||
assert plan_basic.sink_blocks == 6
|
||||
|
||||
# Context Mode: dynamic unit history (e.g. max_units=24 -> 288 tokens + prefix)
|
||||
max_units = 24
|
||||
ctx_low = 96 + max_units * 12 # 384 tokens
|
||||
ctx_high = ctx_low + 500 # 884 tokens
|
||||
geom_context = DuplexWindowGeometry(
|
||||
prefix_tokens=96,
|
||||
window_tokens=ctx_low,
|
||||
block_size=16,
|
||||
max_model_len=40960,
|
||||
high_watermark_tokens=ctx_high,
|
||||
)
|
||||
# Exceed high watermark (trigger = 96 + 884 = 980) -> Trims
|
||||
plan_ctx = plan_position_reanchor(geom_context, computed_tokens=980, pending_tokens=12)
|
||||
assert plan_ctx is not None
|
||||
assert plan_ctx.delta % 16 == 0
|
||||
assert plan_ctx.sink_blocks == 6
|
||||
|
||||
|
||||
def test_reference_audio_and_camera_mrope_paths():
|
||||
"""Criteria 6: Reference-audio permanence in sink and camera non-uniform MRoPE rejection."""
|
||||
# Case A: Reference audio tokens (0..96) always live in sink blocks 0..5
|
||||
geom = DuplexWindowGeometry(
|
||||
prefix_tokens=96,
|
||||
window_tokens=6000,
|
||||
block_size=16,
|
||||
max_model_len=40960,
|
||||
high_watermark_tokens=8000,
|
||||
)
|
||||
plan = plan_position_reanchor(geom, computed_tokens=8100, pending_tokens=12)
|
||||
assert plan is not None
|
||||
assert plan.sink_blocks * 16 == 96
|
||||
assert plan.moved_from > 96 # Reference audio is strictly never rotated
|
||||
|
||||
# Case B: Camera frame in retained tail (MRoPE non-uniform grid coordinates)
|
||||
mrope_pos_camera = torch.arange(200).unsqueeze(0).repeat(3, 1)
|
||||
mrope_pos_camera[1, 120:] += 4
|
||||
mrope_pos_camera[2, 120:] += 8
|
||||
with pytest.raises(RuntimeError, match="duplex re-anchor needs one position row"):
|
||||
assert_uniform_position_shift(mrope_pos_camera, moved_from=96)
|
||||
|
||||
# Case C: Streaming audio/text units (MRoPE multi-row uniform advance)
|
||||
mrope_pos_audio = torch.arange(200).unsqueeze(0).repeat(3, 1)
|
||||
assert_uniform_position_shift(mrope_pos_audio, moved_from=96)
|
||||
@@ -0,0 +1,396 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU accounting tests for the MiniCPM-o 4.5 duplex KV window planner.
|
||||
|
||||
Pure integer/complex math: no engine, no checkpoint, no GPU. Two claims carry
|
||||
the design and each gets pinned here: a block-aligned shift leaves every retained
|
||||
row in the physical slot it already occupies (so a trim moves no memory), and
|
||||
rotating a cached key by ``-delta`` equals RoPE at the renumbered position (so a
|
||||
trim needs no forward pass).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cmath
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan import (
|
||||
DuplexWindowGeometry,
|
||||
PositionReanchor,
|
||||
align_up,
|
||||
cdiv,
|
||||
plan_position_reanchor,
|
||||
reanchor_positions,
|
||||
unit_starts,
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
repo_root = pathlib.Path(__file__).resolve().parent
|
||||
while repo_root.name and not (repo_root / "vllm_omni").is_dir():
|
||||
repo_root = repo_root.parent
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan",
|
||||
repo_root / "vllm_omni/model_executor/models/minicpmo_4_5/duplex/window_plan.py",
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
_wp = importlib.util.module_from_spec(spec)
|
||||
sys.modules["vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_plan"] = _wp
|
||||
spec.loader.exec_module(_wp)
|
||||
|
||||
DuplexWindowGeometry = _wp.DuplexWindowGeometry
|
||||
PositionReanchor = _wp.PositionReanchor
|
||||
align_up = _wp.align_up
|
||||
cdiv = _wp.cdiv
|
||||
plan_position_reanchor = _wp.plan_position_reanchor
|
||||
reanchor_positions = _wp.reanchor_positions
|
||||
unit_starts = _wp.unit_starts
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Block size 4, a 10-token session context prefix (reference audio + system
|
||||
# prompt), a 64-token attention budget, 4096 positions and 2 tokens of sample
|
||||
# room: small enough to assert on by hand, same shape as the real stage.
|
||||
BLOCK = 4
|
||||
|
||||
|
||||
def _geometry(**overrides) -> DuplexWindowGeometry:
|
||||
kwargs: dict[str, int] = {
|
||||
"prefix_tokens": 10,
|
||||
"window_tokens": 64,
|
||||
"block_size": BLOCK,
|
||||
"max_model_len": 4096,
|
||||
"sample_room": 2,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return DuplexWindowGeometry(**kwargs)
|
||||
|
||||
|
||||
def test_rejects_impossible_geometry():
|
||||
with pytest.raises(ValueError, match="window"):
|
||||
_geometry(window_tokens=0)
|
||||
with pytest.raises(ValueError, match="prefix"):
|
||||
_geometry(prefix_tokens=-1)
|
||||
with pytest.raises(ValueError, match="block_size"):
|
||||
_geometry(block_size=0)
|
||||
# A trigger below the low watermark would trim the sequence it just rebuilt.
|
||||
with pytest.raises(ValueError, match="high_watermark_tokens"):
|
||||
_geometry(high_watermark_tokens=8)
|
||||
|
||||
|
||||
def test_nothing_is_reclaimed_inside_the_window():
|
||||
geometry = _geometry(high_watermark_tokens=96)
|
||||
# The trigger is prefix + high watermark == 106; below it, no plan at all.
|
||||
assert plan_position_reanchor(geometry, computed_tokens=100, pending_tokens=6) is None
|
||||
assert plan_position_reanchor(geometry, computed_tokens=100, pending_tokens=7) is not None
|
||||
|
||||
|
||||
def test_a_trim_lands_on_the_low_watermark():
|
||||
geometry = _geometry(high_watermark_tokens=96)
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=200, pending_tokens=20)
|
||||
assert plan is not None
|
||||
# Cut so that what survives past the sink is the low watermark, and no more:
|
||||
# the tail is kept within one page of the budget so a trim never over-reclaims.
|
||||
target = geometry.prefix_tokens + geometry.window_tokens
|
||||
assert plan.delta == align_up(220 - target, BLOCK) == 148
|
||||
assert plan.moved_from == plan.sink_end + plan.delta == 160
|
||||
assert plan.delta % BLOCK == 0
|
||||
# The sink never shrinks: it ends on the first whole page after the prefix.
|
||||
assert plan.sink_end == cdiv(geometry.prefix_tokens, BLOCK) * BLOCK == 12
|
||||
retained = 220 - plan.delta
|
||||
assert target - BLOCK < retained <= target
|
||||
|
||||
|
||||
def test_production_planner_drop_length_and_retained_tail():
|
||||
# Production parameters from reviewer @linyueqian:
|
||||
# prefix=96, block_size=16, window=6000, high=8000, computed=8100, pending=12
|
||||
geo = DuplexWindowGeometry(
|
||||
prefix_tokens=96,
|
||||
window_tokens=6000,
|
||||
block_size=16,
|
||||
max_model_len=40960,
|
||||
high_watermark_tokens=8000,
|
||||
)
|
||||
plan = plan_position_reanchor(geo, computed_tokens=8100, pending_tokens=12)
|
||||
assert plan is not None
|
||||
assert plan.sink_blocks == 6
|
||||
assert plan.sink_end == 96
|
||||
# projected = 8112, target = 6096, drop_needed = 2016 (already aligned to 16)
|
||||
assert plan.delta == 2016
|
||||
assert plan.moved_from == 96 + 2016 == 2112
|
||||
# Resulting retained length must be exactly target (6096), not 6192!
|
||||
retained_length = (8100 + 12) - plan.delta
|
||||
assert retained_length == 6096
|
||||
|
||||
|
||||
def test_the_shift_is_a_whole_number_of_pages_so_no_row_moves():
|
||||
"""The claim that makes a trim a memory pass rather than a copy.
|
||||
|
||||
Deleting ``n`` gap entries shifts every later logical index down by ``n``.
|
||||
Because ``delta == n * block_size``, a tail token's new logical index lands on
|
||||
the table slot that already names its own page, and its offset within that
|
||||
page is unchanged -- so the physical slot is identical before and after.
|
||||
"""
|
||||
geometry = _geometry(high_watermark_tokens=96)
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=200, pending_tokens=20)
|
||||
assert plan is not None
|
||||
n_gap = plan.delta // BLOCK
|
||||
table_before = list(range(64)) # one fake physical page id per logical slot
|
||||
table_after = table_before[: plan.sink_blocks] + table_before[plan.sink_blocks + n_gap :]
|
||||
|
||||
for position in range(plan.moved_from, 64 * BLOCK):
|
||||
before = divmod(position, BLOCK)
|
||||
after = divmod(reanchor_positions(position, plan), BLOCK)
|
||||
assert table_before[before[0]] == table_after[after[0]]
|
||||
assert before[1] == after[1]
|
||||
|
||||
|
||||
def test_unit_aligned_cut_keeps_whole_units():
|
||||
# Page-aligned unit starts: prefix_tokens=16 (divisible by BLOCK=4), units=[12]*8
|
||||
units = [12] * 8
|
||||
geometry = _geometry(prefix_tokens=16, window_tokens=30, high_watermark_tokens=40)
|
||||
boundaries = unit_starts(geometry, units)
|
||||
assert boundaries[0] == 16
|
||||
# Unit starts: 16, 28, 40, 52, 64, ... all divisible by 4!
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=16 + sum(units), pending_tokens=12, unit_tokens=units)
|
||||
assert plan is not None
|
||||
# Landed on a block-aligned unit start, so no unit was split in half.
|
||||
assert plan.moved_from in boundaries
|
||||
assert plan.moved_from % BLOCK == 0
|
||||
# Post trim length <= target
|
||||
target = geometry.prefix_tokens + geometry.window_tokens
|
||||
assert (16 + sum(units) + 12) - plan.delta <= target
|
||||
|
||||
|
||||
def test_unit_aligned_cut_rejects_non_page_aligned_units():
|
||||
# If units cannot be cut along page boundaries without splitting a unit in half,
|
||||
# plan_position_reanchor should return None to trigger fallback rather than slicing a unit.
|
||||
units_odd = [7] * 8
|
||||
geo_no_align = DuplexWindowGeometry(
|
||||
prefix_tokens=11,
|
||||
window_tokens=20,
|
||||
block_size=8,
|
||||
max_model_len=4096,
|
||||
high_watermark_tokens=30,
|
||||
)
|
||||
# Unit starts: 11, 18, 25, 32, 39, 46, 53, 60, 67. None are divisible by 8!
|
||||
plan = plan_position_reanchor(geo_no_align, computed_tokens=11 + 56, pending_tokens=7, unit_tokens=units_odd)
|
||||
assert plan is None
|
||||
|
||||
|
||||
def test_reanchor_is_a_uniform_translation_of_the_tail():
|
||||
geometry = _geometry(high_watermark_tokens=96)
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=200, pending_tokens=20)
|
||||
assert plan is not None
|
||||
assert plan.delta == plan.moved_from - plan.sink_end
|
||||
|
||||
assert reanchor_positions(3, plan) == 3
|
||||
tail_old = plan.moved_from + 5
|
||||
tail_new = reanchor_positions(tail_old, plan)
|
||||
assert tail_new == tail_old - plan.delta
|
||||
# Relative distances inside the tail are untouched by definition, which is
|
||||
# what lets one rotation fix every pair at once.
|
||||
assert reanchor_positions(plan.moved_from + 37, plan) - tail_new == 32
|
||||
# The reclaimed range is dense: the tail lands exactly on the sink's end.
|
||||
assert reanchor_positions(plan.moved_from, plan) == plan.sink_end
|
||||
|
||||
for dropped in (plan.sink_end, plan.moved_from - 1):
|
||||
with pytest.raises(ValueError, match="dropped"):
|
||||
reanchor_positions(dropped, plan)
|
||||
|
||||
|
||||
def test_reanchor_declines_when_there_is_nothing_left_to_reclaim():
|
||||
geometry = _geometry(high_watermark_tokens=96)
|
||||
# Target at or past the projected length: compacting would not help, so the
|
||||
# caller has to finish the session with context_length_exceeded instead of moving
|
||||
# the window start backwards.
|
||||
assert plan_position_reanchor(geometry, computed_tokens=100, pending_tokens=20, target_tokens=160) is None
|
||||
assert plan_position_reanchor(geometry, computed_tokens=100, pending_tokens=20, target_tokens=120) is None
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=100, pending_tokens=20, target_tokens=20)
|
||||
assert plan is not None
|
||||
assert plan.sink_end >= geometry.prefix_tokens
|
||||
assert reanchor_positions(2, plan) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("head_dim", [8, 16])
|
||||
@pytest.mark.parametrize("delta", [4, 64, 1024])
|
||||
def test_rotating_cached_keys_equals_rope_at_the_new_position(head_dim: int, delta: int):
|
||||
"""The other half of the claim: the rotation is the re-RoPE.
|
||||
|
||||
A key written at position ``p`` holds ``z * exp(i * p * f)`` per frequency
|
||||
pair. Re-anchoring to ``p - delta`` can therefore be done on the cached
|
||||
tensor by rotating ``-delta * f`` -- no recompute of the model, and exact for
|
||||
any p, including a tail whose keys were written at different positions.
|
||||
"""
|
||||
plan = PositionReanchor(delta=delta, moved_from=4096, sink_blocks=4096 // BLOCK)
|
||||
inv_freq = [0.5 ** (2.0 * i / head_dim) for i in range(head_dim // 2)]
|
||||
rng = iter(range(1, 10_000))
|
||||
for pair, freq in enumerate(inv_freq):
|
||||
re = complex(next(rng) % 7 - 3, next(rng) % 7 - 3)
|
||||
for position in (plan.moved_from + 1, plan.moved_from + 3 * pair + 17, 999_999):
|
||||
cached = re * cmath.exp(1j * position * freq)
|
||||
rotated = cached * cmath.exp(1j * plan.rope_angle_shift_tokens * freq)
|
||||
direct = re * cmath.exp(1j * reanchor_positions(position, plan) * freq)
|
||||
assert rotated == pytest.approx(direct, rel=1e-9, abs=1e-12), f"pair {pair} at {position}"
|
||||
|
||||
|
||||
def test_reanchor_rejects_a_fractional_position():
|
||||
plan = PositionReanchor(delta=8, moved_from=64, sink_blocks=16)
|
||||
with pytest.raises(ValueError, match="integer"):
|
||||
reanchor_positions(70.5, plan)
|
||||
|
||||
|
||||
# Realistic Stage 0 numbers: a ~100-token head context, the 6000-token low
|
||||
# watermark from the duplex window policy, 16-token KV blocks, and the 40960
|
||||
# positions the checkpoint's config.json advertises.
|
||||
def _stage0_geometry(**overrides) -> DuplexWindowGeometry:
|
||||
kwargs: dict[str, int] = {
|
||||
"prefix_tokens": 96,
|
||||
"window_tokens": 6000,
|
||||
"block_size": 16,
|
||||
"max_model_len": 40960,
|
||||
"sample_room": 2048,
|
||||
# The reference implementation's trigger/stride pair, in tokens: roll
|
||||
# back to the window once 2000 tokens have piled on past it.
|
||||
"high_watermark_tokens": 8000,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return DuplexWindowGeometry(**kwargs)
|
||||
|
||||
|
||||
def test_ten_minutes_of_audio_recomputes_nothing():
|
||||
"""What the rebuild-based window pays, measured against this design.
|
||||
|
||||
600 one-second units is about 7.2k appended tokens plus the spoken
|
||||
interleaves. A window that rebuilds the prompt re-prefills the whole retained
|
||||
tail every time it rolls; a trim re-prefills nothing and still bounds the
|
||||
session's residency, so the position ceiling is never in sight.
|
||||
"""
|
||||
geometry = _stage0_geometry()
|
||||
units = [12] * 600 # 1 s of audio: 10 pooled embeddings + unit closure.
|
||||
spoken = 20 # tokens the model speaks per unit, at 1 s of audio.
|
||||
|
||||
position = geometry.prefix_tokens
|
||||
trims = 0
|
||||
peak_rows = 0
|
||||
written = geometry.prefix_tokens
|
||||
# resident_bound() from the engine tier, plus whatever single append was
|
||||
# already in flight when the trim came due: content rides to the trigger, and
|
||||
# a trim then pulls it back to the low watermark.
|
||||
bound = geometry.prefix_tokens + geometry.trigger_tokens + max(units) + spoken + geometry.sample_room
|
||||
for unit in units:
|
||||
assert position + unit + spoken <= bound, "the session outgrew its own resident bound"
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=position, pending_tokens=unit + spoken)
|
||||
if plan is not None:
|
||||
trims += 1
|
||||
# Rows the rotation touches: the reclaimed tail, and never more than
|
||||
# what is resident. A re-prefill would run those same rows through
|
||||
# every layer of the model instead.
|
||||
peak_rows = max(peak_rows, position - plan.moved_from)
|
||||
position -= plan.delta
|
||||
position += unit + spoken
|
||||
written += unit + spoken
|
||||
|
||||
assert trims > 0, "the window has to roll over a session this long"
|
||||
assert peak_rows <= bound
|
||||
# Ten minutes of conversation wrote ~19k positions while never holding more
|
||||
# than the watermark: recycling, not the length, is what bounds memory.
|
||||
assert written > bound
|
||||
# A 40k position budget against an 8k resident bound: the ceiling is a
|
||||
# backstop this session never approaches, let alone crosses.
|
||||
assert position + geometry.sample_room < geometry.max_model_len
|
||||
|
||||
|
||||
def test_a_long_session_trims_once_per_spare_window_not_once_per_append():
|
||||
"""The frequency the whole design is priced at."""
|
||||
geometry = _stage0_geometry()
|
||||
position = geometry.prefix_tokens
|
||||
append = 12
|
||||
gaps: list[int] = []
|
||||
since = 0
|
||||
peak = position
|
||||
for _ in range(4000):
|
||||
plan = plan_position_reanchor(geometry, computed_tokens=position, pending_tokens=append)
|
||||
if plan is not None:
|
||||
gaps.append(since)
|
||||
since = 0
|
||||
position -= plan.delta
|
||||
since += 1
|
||||
position += append
|
||||
peak = max(peak, position)
|
||||
# 48k positions appended against a window that only overflows once the spare
|
||||
# tokens past it are used up: one trim services ~160 one-second appends. The
|
||||
# first gap is longer because the window has to fill from nothing first.
|
||||
spare = geometry.trigger_tokens - geometry.window_tokens
|
||||
assert gaps and all(gap > spare // (2 * append) for gap in gaps)
|
||||
assert all(gap <= (spare + append - 1) // append + 1 for gap in gaps[1:])
|
||||
# The watermark, not the 40960-token ceiling, is what holds the session down.
|
||||
assert peak <= geometry.prefix_tokens + geometry.trigger_tokens + append
|
||||
assert peak + geometry.sample_room < geometry.max_model_len
|
||||
|
||||
# The trigger is a policy choice, so a stage with no window is still legal --
|
||||
# it simply trades the rotation for a shorter session cap.
|
||||
unwindowed = _stage0_geometry(window_tokens=40960, high_watermark_tokens=None)
|
||||
assert unwindowed.trigger_tokens == 40960
|
||||
|
||||
|
||||
def test_paged_retention_policy_vs_unpaged_reference():
|
||||
"""Verify and document retention differences between paged KV and unpaged reference.
|
||||
|
||||
Case from reviewer @amy-why-3459:
|
||||
prefix_tokens = 100, block_size = 16
|
||||
unit_tokens = [28, 36] (units at [100..128) and [128..164), current total = 164)
|
||||
watermarks: high = 160, low = 148
|
||||
|
||||
Reference unpaged policy (OpenBMB):
|
||||
- Drops unit 0 ([100..128), 28 tokens)
|
||||
- Retains [0..100) + [128..164), total length = 136
|
||||
- Token at 128 moves to position 100
|
||||
|
||||
Paged zero-copy policy (plan_position_reanchor):
|
||||
- sink_end = cdiv(100, 16) * 16 = 112
|
||||
- Preserves [100..112) inside the physical sink block (block 6)
|
||||
- moved_from = 128, delta = 16 (1 block)
|
||||
- Drops [112..128) (16 tokens)
|
||||
- Retains [0..112) + [128..164), total length = 148
|
||||
- Token at 128 moves to position 112 (128 - 16)
|
||||
"""
|
||||
geometry = DuplexWindowGeometry(
|
||||
prefix_tokens=100,
|
||||
window_tokens=48, # low watermark target = 100 + 48 = 148
|
||||
block_size=16,
|
||||
max_model_len=2048,
|
||||
high_watermark_tokens=60, # trigger = 100 + 60 = 160
|
||||
)
|
||||
plan = plan_position_reanchor(
|
||||
geometry,
|
||||
computed_tokens=164,
|
||||
pending_tokens=0,
|
||||
unit_tokens=[28, 36],
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan.delta == 16
|
||||
assert plan.moved_from == 128
|
||||
assert plan.sink_blocks == 7
|
||||
assert plan.sink_end == 112
|
||||
|
||||
# Verify position mapping:
|
||||
# Sink prefix [0..100) unchanged
|
||||
assert reanchor_positions(0, plan) == 0
|
||||
assert reanchor_positions(99, plan) == 99
|
||||
# Paged sink tail [100..112) unchanged
|
||||
assert reanchor_positions(100, plan) == 100
|
||||
assert reanchor_positions(111, plan) == 111
|
||||
# Dropped span [112..128) raises ValueError
|
||||
with pytest.raises(ValueError, match="was dropped by reanchor"):
|
||||
reanchor_positions(112, plan)
|
||||
with pytest.raises(ValueError, match="was dropped by reanchor"):
|
||||
reanchor_positions(127, plan)
|
||||
# Retained tail [128..164) shifts by -delta (-16)
|
||||
assert reanchor_positions(128, plan) == 112
|
||||
assert reanchor_positions(163, plan) == 147
|
||||
File diff suppressed because it is too large.
Load diff
@@ -920,23 +920,37 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if self.log_stats:
|
||||
session.record_event(EngineCoreEventType.QUEUED)
|
||||
return
|
||||
if stage_id == 0 and self._prepare_minicpmo45_stage0_window(
|
||||
session,
|
||||
update,
|
||||
segment_output_ids=segment_output_ids,
|
||||
completed_terminator=completed_terminator,
|
||||
):
|
||||
# The rebuilt prompt is bounded by the client's window settings,
|
||||
# not by the model (a camera unit is hundreds of tokens), so it
|
||||
# needs the same max_model_len check as a plain extension. The
|
||||
# plan replaces the whole prompt, so the replacement length is the
|
||||
# projection, not the session's current prompt plus an extension.
|
||||
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
|
||||
if self._streaming_update_overflows(session, update, projected_len=plan["replacement_prompt_len"]):
|
||||
if stage_id == 0:
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv import (
|
||||
MiniCPMO45DuplexSchedulerHelper,
|
||||
)
|
||||
|
||||
reanchored = False
|
||||
if MiniCPMO45DuplexSchedulerHelper.find_duplex_window_manager(self) is not None:
|
||||
plan = self._maybe_reanchor_streaming_window(
|
||||
session,
|
||||
update,
|
||||
segment_output_ids=segment_output_ids,
|
||||
completed_terminator=completed_terminator,
|
||||
)
|
||||
reanchored = plan is not None
|
||||
if not reanchored and self._prepare_minicpmo45_stage0_window(
|
||||
session,
|
||||
update,
|
||||
segment_output_ids=segment_output_ids,
|
||||
completed_terminator=completed_terminator,
|
||||
):
|
||||
# The rebuilt prompt is bounded by the client's window settings,
|
||||
# not by the model (a camera unit is hundreds of tokens), so it
|
||||
# needs the same max_model_len check as a plain extension. The
|
||||
# plan replaces the whole prompt, so the replacement length is the
|
||||
# projection, not the session's current prompt plus an extension.
|
||||
plan = update.model_intermediate_buffer["duplex"]["stage0_window"]
|
||||
if self._streaming_update_overflows(session, update, projected_len=plan["replacement_prompt_len"]):
|
||||
return
|
||||
self._release_replaced_streaming_prompt_cache(session)
|
||||
self._replace_streaming_session(session, update)
|
||||
return
|
||||
self._release_replaced_streaming_prompt_cache(session)
|
||||
self._replace_streaming_session(session, update)
|
||||
return
|
||||
streaming_prompt_payload = next(
|
||||
(
|
||||
info
|
||||
@@ -1008,6 +1022,43 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if hasattr(update, "model_intermediate_buffer"):
|
||||
session.model_intermediate_buffer = update.model_intermediate_buffer
|
||||
|
||||
def _maybe_reanchor_streaming_window(
|
||||
self,
|
||||
session: Request,
|
||||
update: StreamingUpdate,
|
||||
*,
|
||||
segment_output_ids: list[int] | None = None,
|
||||
completed_terminator: int | None = None,
|
||||
) -> Any:
|
||||
"""Lifecycle hook to evaluate and apply duplex streaming window compaction."""
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv import (
|
||||
MiniCPMO45DuplexSchedulerHelper,
|
||||
)
|
||||
|
||||
return MiniCPMO45DuplexSchedulerHelper.apply_session_window(
|
||||
self,
|
||||
session,
|
||||
update,
|
||||
segment_output_ids=segment_output_ids,
|
||||
completed_terminator=completed_terminator,
|
||||
)
|
||||
|
||||
def _maybe_reanchor_minicpmo45_stage0_window(
|
||||
self,
|
||||
session: Request,
|
||||
update: StreamingUpdate,
|
||||
*,
|
||||
segment_output_ids: list[int] | None = None,
|
||||
completed_terminator: int | None = None,
|
||||
) -> Any:
|
||||
"""Backward-compatible alias for _maybe_reanchor_streaming_window."""
|
||||
return self._maybe_reanchor_streaming_window(
|
||||
session,
|
||||
update,
|
||||
segment_output_ids=segment_output_ids,
|
||||
completed_terminator=completed_terminator,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_minicpmo45_stage0_window(
|
||||
session: Request,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -468,8 +468,54 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex
|
||||
def _update_states(self, scheduler_output: SchedulerOutput) -> Callable | None:
|
||||
deferred_state_corrections_fn = super()._update_states(scheduler_output)
|
||||
self._update_duplex_sampling_states(scheduler_output)
|
||||
self._maybe_apply_duplex_window_reanchor(scheduler_output)
|
||||
return deferred_state_corrections_fn
|
||||
|
||||
def _maybe_apply_duplex_window_reanchor(self, scheduler_output: SchedulerOutput | None = None) -> None:
|
||||
"""Apply in-place KV reanchor and rotation on worker before model forward."""
|
||||
reanchor_hook = getattr(getattr(self, "model", None), "apply_duplex_kv_reanchor", None)
|
||||
if callable(reanchor_hook):
|
||||
try:
|
||||
reanchor_hook(self, scheduler_output=scheduler_output)
|
||||
except TypeError:
|
||||
reanchor_hook(self)
|
||||
return
|
||||
|
||||
helper = getattr(self, "_duplex_window_helper", None)
|
||||
if helper is not None and hasattr(helper, "maybe_apply_reanchor"):
|
||||
try:
|
||||
helper.maybe_apply_reanchor(self, scheduler_output=scheduler_output)
|
||||
except TypeError:
|
||||
helper.maybe_apply_reanchor(self)
|
||||
return
|
||||
|
||||
# Fallback for dynamic runner inspection without hardcoding model classes
|
||||
if not hasattr(self, "input_batch") or self.input_batch is None:
|
||||
return
|
||||
num_reqs = getattr(self.input_batch, "num_reqs", 0)
|
||||
req_ids = self.input_batch.req_ids[:num_reqs]
|
||||
has_reanchor = False
|
||||
for req_id in req_ids:
|
||||
info = self.model_intermediate_buffer.get(req_id)
|
||||
if isinstance(info, dict) and isinstance(info.get("duplex"), dict):
|
||||
if "stage0_reanchor" in info["duplex"]:
|
||||
has_reanchor = True
|
||||
break
|
||||
if not has_reanchor:
|
||||
return
|
||||
|
||||
model_module = getattr(getattr(self, "model", None), "__module__", "")
|
||||
if "minicpmo_4_5" in model_module:
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.window_kv import (
|
||||
MiniCPMO45DuplexWorkerHelper,
|
||||
)
|
||||
|
||||
MiniCPMO45DuplexWorkerHelper.maybe_apply_reanchor(self, scheduler_output=scheduler_output)
|
||||
|
||||
def _maybe_apply_stage0_reanchor(self) -> None:
|
||||
"""Backward-compatible alias for _maybe_apply_duplex_window_reanchor."""
|
||||
self._maybe_apply_duplex_window_reanchor()
|
||||
|
||||
def _request_final_stage_id(self, req_id: str) -> int | None:
|
||||
info = self.model_intermediate_buffer.get(req_id)
|
||||
if not isinstance(info, dict):
|
||||
|
||||
Reference in new issue
Block a user