Research Engineer / ML · ML Coding

KV Cache:位置偏移、Chunk Mask和全序列等价

缓存已经投影的K/V,每次只计算新token,但新chunk仍需要内部因果mask。

题目重点:缓存已经投影的K/V,每次只计算新token,但新chunk仍需要内部因果mask。

题意与假设

这道练习题保留了面经中值得训练的核心约束,并将需要澄清的条件单独列出。现场拿到变体时,先和面试官确认这些条件。

  • 沿用单层causal decoder,无dropout,缓存为B×H×pastT×Dh的K/V;同batch、同参数、同前缀,训练没有cache淘汰或跨模型复用。
  • 新输入可一个token也可一个chunk,绝对位置从pastT开始。缓存不保存token logits或完整历史hidden,且示例不支持padding变长cache。

解题路线

  1. 新tokens只算新Q/K/V,K/V与cache沿序列维concat;query位置offset+i仅能看key_index≤offset+i。单token无future,chunk多token仍有未来位置,不能把cached模式统统设无mask。
  2. 位置embedding使用p.position[offset:offset+t],每次从0开始会让incremental输出与full forward不同。attention key长度past+t,scale仍√Dh,与past长度无关。
  3. cache需要绑定request、model revision、adapter和token prefix,不能按最后token简单复用。批处理不同有效长度需要position IDs和有效key mask,物理padded cache.shape不是每个用户真实position。
  4. 比对三个路径:完整T、逐token、任意切点prefix+suffix chunk,logits应在误差内一致;同时改变未来token验证因果性。拼接实现复制全部历史K/V,证明正确不等于高效生产paged cache。

手动走一遍

past4、新chunk3,query0可看key0..4、query1看0..5、query2看0..6;新chunk第一query不能看它后面的两token。同prompt、不同adapter产生不同K/V,因此cache命中必须包含adapter标识。请求取消后cache释放,但并发decode引用尚未结束时要引用计数/lease,不可提前复用内存页。

Python 参考实现

下面的代码按上文假设实现,适合本地运行并追加自己的测试用例。

代码区可横向滚动;使用键盘时,先聚焦代码区再按方向键查看。

import numpy as np

def layer_norm(x):
    return (x-x.mean(axis=-1,keepdims=True))/np.sqrt(x.var(axis=-1,keepdims=True)+1e-5)

def gelu(x):return .5*x*(1+np.tanh(np.sqrt(2/np.pi)*(x+.044715*x**3)))

def initialize(vocab,width,heads,max_length,seed=0):
    if min(vocab,width,heads,max_length)<1 or width%heads:raise ValueError('dimensions/heads')
    rng=np.random.default_rng(seed);scale=width**-.5
    return dict(token=rng.normal(size=(vocab,width))*scale,position=rng.normal(size=(max_length,width))*scale,
                q=rng.normal(size=(width,width))*scale,k=rng.normal(size=(width,width))*scale,
                v=rng.normal(size=(width,width))*scale,o=rng.normal(size=(width,width))*scale,
                ff1=rng.normal(size=(width,4*width))*scale,ff2=rng.normal(size=(4*width,width))*scale,heads=heads)

def forward(tokens,p,cache=None):
    ids=np.asarray(tokens)
    if ids.ndim!=2 or ids.shape[1]<1 or not np.issubdtype(ids.dtype,np.integer):raise ValueError('B,T integer tokens')
    if np.any(ids<0) or np.any(ids>=len(p['token'])):raise ValueError('token ID')
    b,t=ids.shape;d=p['token'].shape[1];h=p['heads'];dh=d//h
    offset=0 if cache is None else cache['k'].shape[2]
    if offset+t>len(p['position']):raise ValueError('position length')
    x=p['token'][ids]+p['position'][offset:offset+t];normalized=layer_norm(x)
    split=lambda a:a.reshape(b,t,h,dh).transpose(0,2,1,3)
    q=split(normalized@p['q']);k=split(normalized@p['k']);v=split(normalized@p['v'])
    if cache is not None:
        if cache['k'].shape!=(b,h,offset,dh) or cache['v'].shape!=cache['k'].shape:raise ValueError('cache shape')
        k=np.concatenate([cache['k'],k],axis=2);v=np.concatenate([cache['v'],v],axis=2)
    scores=q@k.swapaxes(-1,-2)/np.sqrt(dh)
    permitted=np.arange(k.shape[2])[None,:]<=offset+np.arange(t)[:,None]
    scores=np.where(permitted[None,None,:,:],scores,-np.inf)
    shifted=scores-scores.max(axis=-1,keepdims=True);weights=np.exp(shifted);weights/=weights.sum(axis=-1,keepdims=True)
    attended=(weights@v).transpose(0,2,1,3).reshape(b,t,d)
    hidden=x+attended@p['o'];hidden=hidden+gelu(layer_norm(hidden)@p['ff1'])@p['ff2']
    logits=layer_norm(hidden)@p['token'].T
    return logits,hidden,dict(k=k,v=v)

def classify(tokens,p,pooling_mask,head,bias):
    # Encoder-like use is a different contract; this classifier retains causal hidden states.
    _,hidden,_=forward(tokens,p);mask=np.asarray(pooling_mask,dtype=bool)
    if mask.shape!=hidden.shape[:2] or np.any(mask.sum(axis=1)==0):raise ValueError('nonempty pooling mask')
    # Mask must be right padding; masked suffix cannot influence earlier causal states.
    if np.any(mask[:,1:] & ~mask[:,:-1]):raise ValueError('right padding')
    pooled=(hidden*mask[:,:,None]).sum(axis=1)/mask.sum(axis=1,keepdims=True)
    return pooled@head+bias

复杂度

单tokenattention O(BpastT·D),新投影/FFN O(BD²),cacheO(BpastT·D)。示例concat每步O(BpastT·D)复制,累计生成T可O(BT²D)copy;预分配/paged结构减少copy但不消除attention读历史成本。

易错点与追问

容易失分的边界

  • cache offset错位是隐蔽错误,short T测试可能不明显。
  • 缓存Q并重算旧query没有带来decode节省。
  • 多tokenchunk不屏蔽未来会泄露后续输入。

继续深挖

  • GQA/MQA怎样改变K/V heads及显存估算?
  • 滑动窗口或RoPE cache必须保存哪些绝对位置与旋转状态?
✓已复制微信号!