SWA(Sliding Window Attention)= 每个 query 只看最近 W 个 token。 这样每层 attention 的计算量从 O(T^2) 降到 O(TW),每层需要保留的 KV 从 T 条降到 W 条。实际模型会把 SWA 层和 full attention 层混合堆叠,推理引擎为此要管理”同一个 token、两种寿命”的 KV。下文先讲要解决的问题和设计,再对照 SGLang 的实现。

问题:Full Attention 的两笔账都随 T 增长

记 batch 为 B,序列长度为 T,层数为 L,头数为 H,head dim 为 D。只算 attention 核心(QK^\top 和 AV),单层的开销如下:

Full(因果) SWA(窗口 W)
prefill 计算量 \approx 2BT^2HD \approx 4BTWHD
decode 每步读 KV \propto T \propto W
KV cache 大小 B L T (d_{qk} + d_v) H_{kv} B L W (d_{qk} + d_v) H_{kv}

Full attention 的因果 mask 是一个下三角,有效打分约 T^2/2 个,所以是 2BT^2HD。SWA 的每个 query 固定看 W 个 key,两者之比约为 2W/T。比如 T=32\text{K}、W=128 时,SWA 层的 attention 计算量和 KV 都不到 full 的 1%。

代价是看不到窗口外的信息。单层 SWA 只能看 W 个 token;堆叠 L 层后,信息可以一层层往后传,理论感受野约为 L \cdot W。但这种间接传递是有损的,“大海捞针”式的远距离精确召回仍然要靠能看全局的层。

设计:局部交给 SWA,全局交给少数 full 层

Full mask 与 SWA mask

所以实际模型几乎都是混合结构:大部分层是 SWA,负责局部建模;每隔几层插一层 full attention,负责全局召回。

这类模型通常还会给 attention 加一个可学习的 sink(GPT-OSS、MiMo-V2 都有),让某个 query 在窗口内找不到相关 token 时,可以把权重”泄”到 sink 上,而不是被迫平均分给窗口内的 token。

混合结构带来了推理侧的核心问题:同一个 token 在不同层的 KV 寿命不同。 full 层的 KV 要跟着请求一直活下去,还可能被 prefix cache 复用;SWA 层的 KV 在它滑出窗口之后就再也不会被读到。如果所有层共用一个按 token 分配的 KV pool,SWA 层就会白白占着 T - W 条无用的 KV,混合结构省下来的显存全部浪费掉。

抽象:一个 token,两个地址

推理引擎的解法是把两种寿命拆到两个池里:

关键是以 full 索引为主地址。调度器、req_to_token、radix 树看到的都只有 full 索引,和非 SWA 模型完全一样。SWA 索引则通过一张映射表 full_to_swa[full_idx] 派生出来,只有 attention backend 在读写 SWA 层时才需要翻译一次。

于是”释放某个 token 的 SWA KV”就变成两步:把 full_to_swa[full_idx] 指向的 SWA slot 还给 SWA allocator,再把映射置 0。full 索引本身保持不变,上层的数据结构都不需要改。

SWA 双池与索引映射

这个抽象还顺带解决了”两个池谁先满”的问题:一个请求能否被调度,要同时看两个池的余量,两边分别记账、分别回收。

实现

以下路径都相对于 python/sglang/srt/。

哪些模型、哪些层

双池与映射表

显存怎么分:--swa-full-tokens-ratio

这个参数(默认 0.8)是每层的 token 数之比:SWA pool 的 token 容量 ÷ full pool 的 token 容量,与 SWA 层和 full 层的层数无关,也不是两者占用显存的比值。

model_executor/pool_configurator.py 中的 HybridSWAPoolConfigurator 按下面的方式求解两个池的大小(省略了投机解码 draft 层的项):

\text{cell} = F \cdot L_{\text{full}} + r \cdot S \cdot L_{\text{swa}}, \qquad N_{\text{full}} = \left\lfloor \frac{\text{可用显存}}{\text{cell}} \right\rfloor, \qquad N_{\text{swa}} = r \cdot N_{\text{full}}

其中 F、S 分别是 full 层和 SWA 层单个 token、单层的 KV 字节数(num_kv_heads × (head_dim + v_head_dim) × dtype_size),两个结果都向下对齐到 page。记 page 大小为 P,如果 N_{\text{swa}} \le W + P,说明 SWA pool 连一个请求都放不下,启动时直接报错。

以 MiMo-V2-Flash 估算(假设两类层的 head dim 相同,于是 S = 2F):

那 r 至少要多大?对正在运行的请求,SWA 层只需保留约 W + I + P 个 token,其中 I 是下一节的释放间隔(默认 128)。假设平均序列长度为 \bar{T},full pool 满载时大约有 N_{\text{full}}/\bar{T} 个请求,所需的 r 约为 (W + I + P) / \bar{T},长上下文场景下只有百分之几。多出来的 SWA 容量用来给 radix cache 保留已缓存前缀的 SWA KV:r 越小,full pool 的并发越高,但 prefix cache 越容易因为 SWA KV 被淘汰而无法命中。

一些模型会改写默认值,例如 DeepSeek-V4 和 Inkling 改成 0.1(arg_groups/overrides.py,仅当用户没有显式设置时生效)。另外,这个 fork 有一条 SWAChunkCapPoolConfigurator 路径:在关闭 radix cache、开启 chunked prefill 并设置了 max_running_requests 时,SWA pool 按”每请求窗口 + 余量”× 请求数直接封顶,剩下的显存全部给 full pool,此时 ratio 不生效。

运行时:把滑出窗口的 KV 还回去

每个请求在 req.kv.swa_evicted_seqlen 中记录”SWA KV 已经释放到哪个位置”。在 [0, swa_evicted_seqlen) 范围内,full 层的 KV 仍在,SWA 层的 KV 已经归还。

Prefix cache:tombstone 与窗口命中规则

普通 radix cache 的规则是:只要前缀在树里,就能复用。SWA 让这条规则变复杂了,因为树上一个节点可能处于”full KV 还在、SWA KV 已经被淘汰”的状态,这种节点称为 tombstone。

从匹配终点继续 prefill 时,SWA 层需要看到终点之前 W 个 token 的 KV;更早的 token 是否还有 SWA KV 无关紧要。所以命中规则是:匹配终点之前,必须有至少 W 个连续的非 tombstone token。 不满足时,匹配回退到上一个满足条件的位置。

SWA radix cache 的 tombstone 与命中规则

围绕这个规则,树的其他操作也都分成 full 和 SWA 两份:

这个 fork 里,开启 radix cache 的混合 SWA 模型走的是 UnifiedRadixCache(mem_cache/unified_radix_cache.py),由 mem_cache/registry.py 以 [FULL, SWA] 两个 component 组合构造。SWA 的逻辑在 mem_cache/unified_cache/components/swa_component.py:tombstone 表示为该 component 的 value 为 None,命中规则在 create_match_validator 中实现,遇到 tombstone 时把累计长度清零,累计到 sliding_window_size 才算有效。早期独立实现的 SWARadixCache(mem_cache/swa_radix_cache.py,用 swa_tombstone 字段和 full_lru_list / swa_lru_list)仍保留在代码里,设计相同,但已经没有工厂函数会构造它。纯 SWA 模型(没有 full 层)则用 PureSWARadixCache。

Attention backend:读写前翻译一次索引

backend 拿到的 page_table、out_cache_loc 都是 full 索引。对 SWA 层,需要先用 SWAKVPool.translate_loc_from_full_to_swa(就是 full_to_swa_index_mapping[idx])翻译成 SWA 索引:

写 KV 时,backend 把 full 和 SWA 两个位置打包成 KVWriteLoc 交给 pool。SWA 层要求 swa_loc 已经翻译好,pool 内部不再翻译,这样一个 batch 只需翻译一次,所有 SWA 层共用。

小结

SWA 用固定窗口换掉了 full attention 随长度增长的计算和 KV,再用少数 full 层补回全局召回。推理侧的关键抽象是”一个 token、两个地址”:full 索引是唯一的主地址,SWA 索引通过 full_to_swa_index_mapping 派生,释放 SWA KV 只需归还 slot 并把映射置 0。这样调度器、req_to_token、radix 树都不用改变形态,只需要多维护一份 SWA 预算。--swa-full-tokens-ratio 决定两个池的 token 容量之比:它设得越大,越多 SWA KV 能留给 prefix cache;设得越小,full pool 的并发越高。prefix cache 则通过 tombstone 和”终点前 W 个 token 连续有效”的命中规则,让两种寿命的 KV 共存在同一棵树上。