sparkle/mlx_streaming/mtp/kv_cache.py
fiser_jun 4745f264b2
2026-08-04 14:34:00 +08:00

277 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MTP 投机解码的 KV/递归状态 cache 校验机制per-token checkpoint + 快照/恢复/提交。
speculative decoding 需要在「验证 K 个草稿」后只保留 accepted_len 个 token 对 cache 的
贡献。两类 cache 处理方式不同:
- 可裁剪 cacheKVCache直接 trim 掉 rejected 后缀。
- 递归状态 cacheQwen3-Next 线性注意力 ArraysCache必须在 verify 前向中逐 token 记录
`[conv, ssm]` checkpoint 才能精确提交,否则回退到「快照 + replay accepted prefix」。
"""
import mlx.core as mx
import mlx.nn as nn
_QWEN3NEXT_CHECKPOINTS_PATCHED = False
_EMPTY_CACHE = object()
def enable_qwen3next_speculative_checkpoints():
"""给 Qwen3-Next 线性注意力层加 verify-time per-token cache checkpoint。
普通前向仍走 mlx-lm 原实现;只有 `begin_speculative_checkpoints()` 标记过的
ArraysCache 会走逐 token gated-delta ops,并把每个 prefix 后的 `[conv, ssm]`
状态写入 `cache._spec_checkpoints`。
"""
global _QWEN3NEXT_CHECKPOINTS_PATCHED
if _QWEN3NEXT_CHECKPOINTS_PATCHED:
return
from mlx_lm.models.qwen3_next import Qwen3NextGatedDeltaNet
from mlx_streaming.core.linear_attn.gated_delta_multistate import (
gated_delta_update_multistate,
)
orig_call = Qwen3NextGatedDeltaNet.__call__
def patched_call(self, inputs, mask=None, cache=None):
capture = bool(getattr(cache, "_capture_spec_checkpoints", False))
if not capture:
return orig_call(self, inputs, mask=mask, cache=cache)
B, S, _ = inputs.shape
q, k, v, z, b, a = self.fix_query_key_value_ordering(
self.in_proj_qkvz(inputs), self.in_proj_ba(inputs)
)
if cache is not None and cache[0] is not None:
conv_state = cache[0]
else:
conv_state = mx.zeros(
(B, self.conv_kernel_size - 1, self.conv_dim),
dtype=inputs.dtype,
)
mixed_qkv = mx.concatenate(
[q.reshape(B, S, -1), k.reshape(B, S, -1), v.reshape(B, S, -1)], axis=-1
)
if mask is not None:
mixed_qkv = mx.where(mask[..., None], mixed_qkv, 0)
conv_input = mx.concatenate([conv_state, mixed_qkv], axis=1)
n_keep = self.conv_kernel_size - 1
conv_checkpoints = [
mx.contiguous(conv_input[:, i:i + n_keep, :])
for i in range(1, S + 1)
]
if cache is not None:
cache[0] = conv_checkpoints[-1]
conv_out = nn.silu(self.conv1d(conv_input))
q, k, v = [
t.reshape(B, S, h, d)
for t, h, d in zip(
mx.split(conv_out, [self.key_dim, 2 * self.key_dim], -1),
[self.num_k_heads, self.num_k_heads, self.num_v_heads],
[self.head_k_dim, self.head_k_dim, self.head_v_dim],
)
]
state = cache[1] if cache else None
inv_scale = k.shape[-1] ** -0.5
q = (inv_scale**2) * mx.fast.rms_norm(q, None, 1e-6)
k = inv_scale * mx.fast.rms_norm(k, None, 1e-6)
# 关键:用 multistate kernel 一次前向算出每个 token 处理后的 ssm 递归态。
# 它与 baseline 解码走的mlx gated_delta_kernel 是同一份 kernel、同序、fp32
# 因此 states_out[:, i] 与「逐 token 单步解码」逐 bit 等价(见
# tests/test_gated_delta_multistate.py——这是验证后能直接提交、零 replay 的根基。
# 注意kernel 路径内部处理 GQAhk_idx 映射),不在 Python 侧 repeat q/k
# 与 baseline kernel 路径对齐(旧 ops 路径的 repeat 会引入数值差异)。
out, state, states_out = gated_delta_update_multistate(
q, k, v, a, b, self.A_log, self.dt_bias, state, mask
)
if cache is not None:
cache[1] = state
cache.advance(S)
cache._spec_checkpoints = [
[conv_checkpoints[i], states_out[:, i]]
for i in range(S)
]
cache._capture_spec_checkpoints = False
out = self.norm(out, z)
return self.out_proj(out.reshape(B, S, -1))
Qwen3NextGatedDeltaNet.__call__ = patched_call
_QWEN3NEXT_CHECKPOINTS_PATCHED = True
def begin_speculative_checkpoints(caches):
"""标记 ArraysCache 在下一次 verify forward 中记录 per-token checkpoint。"""
for c in caches:
if not c.is_trimmable():
c._capture_spec_checkpoints = True
c._spec_checkpoints = None
# ----------------------------------------------------------------- cache 快照
def _copy_state(st):
"""深拷贝 cache.state(支持 None / array / list / tuple 嵌套)。"""
if st is None:
return None
if isinstance(st, (list, tuple)):
return type(st)(_copy_state(s) for s in st)
return mx.array(st)
def _iter_arrays(st):
if st is None:
return
if isinstance(st, (list, tuple)):
for s in st:
yield from _iter_arrays(s)
else:
yield st
def _snapshot(caches):
"""对每个 cache 深拷贝 state + meta_state(强制 eval,防 update_and_fetch 原地改写)。"""
snaps = []
arrays = []
for c in caches:
if hasattr(c, "keys") and hasattr(c, "empty") and c.empty():
snaps.append((_EMPTY_CACHE, c.meta_state))
continue
st_copy = _copy_state(c.state)
arrays.extend(_iter_arrays(st_copy))
snaps.append((st_copy, c.meta_state))
if arrays:
mx.eval(arrays)
return snaps
def _restore(caches, snaps):
for c, (st, meta) in zip(caches, snaps):
if st is _EMPTY_CACHE:
if hasattr(c, "keys") and hasattr(c, "values"):
c.keys = None
c.values = None
c.offset = 0
elif hasattr(c, "cache"):
c.cache = [None] * len(c.cache)
c.meta_state = meta
continue
# 必须装入副本:ArraysCache.state setter 是别名赋值(self.cache=v),后续前向
# 的 cache[idx]=new 会原地改写这个 list 的元素,从而污染快照本体。救回路径会对
# 同一 snap_m 恢复两次(救回时一次 + fallback 一次),若不复制,第二次恢复到的将是
# 被中间前向污染的状态 → 递归 cache 损坏、输出发散。
c.state = _copy_state(st)
c.meta_state = meta
def commit_verified_prefix(caches, verified_len: int, accepted_len: int) -> bool:
"""把验证前向产生的 cache 直接提交到 accepted prefix。
vLLM 语义下,验证 K 个 token 后只保留 accepted_len 个 token 对 cache 的贡献。
对 KVCache 这类可裁剪 cache,直接 trim 掉 rejected 后缀即可;对 ArraysCache 这类
递归状态 cache,必须有 per-token checkpoint 才能精确提交,否则返回 False 让调用方
走 fallback replay。
"""
rejected = verified_len - accepted_len
if rejected < 0:
raise ValueError("accepted_len cannot exceed verified_len")
def can_commit(c):
if c.is_trimmable():
return True
checkpoints = getattr(c, "_spec_checkpoints", None)
return checkpoints is not None and len(checkpoints) >= accepted_len
if not all(can_commit(c) for c in caches):
return False
for c in caches:
if c.is_trimmable():
if rejected:
c.trim(rejected)
else:
c.state = c._spec_checkpoints[accepted_len - 1]
if hasattr(c, "_spec_checkpoints"):
c._spec_checkpoints = None
if hasattr(c, "_capture_spec_checkpoints"):
c._capture_spec_checkpoints = False
return True
# ----------------------------------------------------------- 树形验证 batch 工具
def _tile_state(st, P: int):
"""把 cache.state 沿 batch 轴(axis0)复制 P 份((1,...) → (P,...))。"""
if st is None:
return None
if isinstance(st, (list, tuple)):
return type(st)(_tile_state(s, P) for s in st)
return mx.contiguous(mx.repeat(st, P, axis=0))
def _row_state(st, w: int):
"""取 cache.state 的第 w 行((P,...) → (1,...)),保持 batch 维。"""
if st is None:
return None
if isinstance(st, (list, tuple)):
return type(st)(_row_state(s, w) for s in st)
return mx.contiguous(st[w:w + 1])
def tile_caches(caches, P: int):
"""把 batch=1 的 cache 全部平铺到 batch=P,供 batch-of-paths 树验证并行前向。
KVCache 的 state setter 会按数组 shape 复位 offset;ArraysCache 无 offset。两者 meta
(lengths/left_padding) 在贪婪解码里恒为 None,tile 后语义不变。前提:所有 cache 当前 batch=1。
"""
for c in caches:
c.state = _tile_state(c.state, P)
def commit_tree_row(caches, verified_len: int, accepted_len: int, row: int) -> bool:
"""把 batched(batch=P)验证前向的第 `row` 条路径,按 accepted_len 提交回 batch=1 主 cache。
等价于「先按接受长度裁剪 rejected 后缀,再抽出赢家路径那一行」:
- 可裁剪 cache(KVCache):trim(rejected) 后取第 row 行 KV。
- 递归状态 cache(ArraysCache):取 verify 前向捕获的 per-token checkpoint[accepted_len-1]
的第 row 行 [conv, ssm]。要求 batched 前向前调过 begin_speculative_checkpoints。
提交后主 cache 变回 batch=1,后续解码逐 token 从赢家路径续。
"""
rejected = verified_len - accepted_len
if rejected < 0:
raise ValueError("accepted_len cannot exceed verified_len")
def can_commit(c):
if c.is_trimmable():
return True
cks = getattr(c, "_spec_checkpoints", None)
return cks is not None and len(cks) >= accepted_len
if not all(can_commit(c) for c in caches):
return False
for c in caches:
if c.is_trimmable():
if rejected:
c.trim(rejected)
c.state = _row_state(c.state, row)
else:
ck = c._spec_checkpoints[accepted_len - 1] # [conv(P,...), ssm(P,...)]
c.state = [_row_state(ck[0], row), _row_state(ck[1], row)]
if hasattr(c, "_spec_checkpoints"):
c._spec_checkpoints = None
if hasattr(c, "_capture_spec_checkpoints"):
c._capture_spec_checkpoints = False
return True
def commit_verified_snapshot(caches, snapshots, accepted_len: int,
verified_len: int | None = None) -> bool:
"""把 cache 恢复到 stepwise verify 的 accepted_len 后快照。"""
if verified_len is not None and accepted_len == verified_len:
return True
if accepted_len <= 0 or accepted_len > len(snapshots):
return False
_restore(caches, snapshots[accepted_len - 1])
return True