Prhub

#55624 [V1][Metrics] Support Sliding Window Attention (SWA) and hybrid layers in MFU/MBU estimation

原始 PR 作者 thillai-c 合并时间 2026-09-15 00:04 文件变更 2 提交数 4 评论 13 代码增减 +640 / -48

执行摘要

为 MFU/MBU 引入滑窗与混合层记账,修正 SWA 模型指标高估

issue #38170 报告在 8×H20 上跑 Step-3.5-Flash 时 --enable-mfu-metrics 给出平均 MFU 55.81%,而 MiniMax-M2.5 只有 32.61%,差距异常大;issue 作者定位到两点:MFU 统计「没有考虑 Sliding Window Attention (SWA)」和「没有考虑 Multi-Token Prediction (MTP)」,并判断 Step-3.5-Flash 的 MFU 高于实际值。PR body 进一步量化:SWA 层中 token 最多只看 min(context_len, sliding_window) 个 token,32k/128k 上下文配 4096 窗口时注意力核心 FLOPs 被高估 8-30 倍,decode 阶段 KV-cache 读流量同样被高估,直接扭曲 MFU/MBU;同时该 PR 也顺带解决 perf.py 中「TODO: discern cases where we have mixture of different attention layer types such as SWA, MLA, etc.」这条历史遗留。

值得精读,尤其是做性能看板、容量规划或 v1 metrics 维护的同学。重点看三处:(1) SlidingWindowAttentionParser.parse() 的三分支层类型判定及其启发式边界(Gemma 2 硬编码、layer_types 缺失时的默认「全 SWA」);(2) AttentionMetrics 中「全局层数先拆分、再各自除以 pp_size」的不变量设计,以及它顺带取消的向下取整对稠密模型数值口径的影响(建议确认浮点层数在 dict[str, int] 契约下是否安全);(3) ExecutionContext 同时保留聚合标量与逐请求明细的双轨设计——快路径零回归、窗口路径才回放明细,是低风险扩展指标能力的好范式。建议后续补一个与真实 profiler 计数对拍的 e2e 校验,并确认是否为 MLA + SWA 混合模型补齐同样的记账。

讨论亮点

核心交锋集中在流水并行下的层数拆分。markmc 借助 Codex 给出 P1:

"For a 6-layer model with one SWA layer and pp_size=2, L becomes 3 and round(3 * 1 / 6) becomes 0. The estimate therefore treats all three layers as full attention, misreporting both attention FLOPs and attn_input reads."

即先用 L //= pp_size 再按比例拆分会把少数派 SWA 层舍成 0。作者 thillai-c 复现后采纳第一种方案(从全局层数拆分再各自除以 pp_size),并解释为何不做 PP-rank 感知:

"these metrics are constructed by from_vllm_config() from config alone, with no pipeline rank available at that point. So the per-GPU number here represents the average pipeline stage rather than a specific stage."

修复后 6 层 / 1 SWA / PP=2 得到 2.5 个全注意力层与 0.5 个 SWA 层。CodeRabbit 另提出两条:per-GPU 拆分需保持 L_full + L_swa == L 不变量,以及补一个 hybrid + pp_size>1 的回归用例——作者在 commit 95e08e5 一并处理,并更新了原有编码旧 8/7 拆分的测试。此外 markmc 提醒按 contributing 指南标注 AI 协助,作者随后在 PR body 补上说明并承诺后续 commit 使用 Assisted-by: trailer;markmc 最终 APPROVED。

实现拆解

  1. 变更入口:解析链扩展。AttentionMetrics.get_parser() 返回的 ParserChain 在 BaseAttentionConfigParser() 与 AttentionQuantizationConfigParser() 之间插入新的 SlidingWindowAttentionParser(),顺序为 AttentionDetectionParser → BaseConfigParser → BaseAttentionConfigParser → SlidingWindowAttentionParser → AttentionQuantizationConfigParser。AttentionMetrics 的 pydantic 模型同时删掉原 TODO 注释,新增 sliding_window: int | None = Field(None) 与 num_swa_layers: int = Field(0, ge=0) 两个字段,component_type() 仍返回 "attn",注册表键不受影响。
  2. 上下文层:ExecutionContext 增加逐请求明细与窗口截断。dataclass 新增 requests: list[tuple[int, int, bool]] = field(default_factory=list),add() 在累加原有聚合量的同时追加 (num_tokens, context_len, is_prefill);新增 total_token_context_product(sliding_window=None)、prefill_context_len_for_window()、decode_context_len_for_window() 三个方法。当 sliding_window is None 时直接返回原有聚合标量(快路径、零回归),仅在传入窗口时回放逐请求明细并做 min(context_len, sliding_window) 截断,因此非 SWA 模型既不变行为也不付额外遍历成本。
  3. 配置解析:SlidingWindowAttentionParser.parse() 三分支判定层类型。窗口大小先取 model_config.get_sliding_window(),为空再回退 vllm_config.cache_config.sliding_window,且 model_config.disable_sliding_window 为真时直接按无窗口处理;层数拆分分三档——显式 hf_text_config.layer_types 列表时统计名字含 "sliding" 的层;model_type == "gemma2" 时按交替结构取 (num_layers + 1) // 2(26 层 → 13 层);其余有窗口的模型(Mistral、Qwen2.5)视为全部层 SWA。该判定结果写回 args.sliding_window / args.num_swa_layers,经 model_validate 进入 AttentionMetrics。
  4. 指标计算:按层比例拆分 AttentionMetrics。get_num_flops_breakdown() 与 get_read_bytes_breakdown() 里把原来的 TC = ctx.total_token_context_product() 拆成 TC_full = ctx.total_token_context_product()(不截断)与 SWA 层使用的窗口截断值;层数从全局拆分为 L_full = L - num_swa_layers 与 L_swa = num_swa_layers 后,per-GPU 时各自除以 pp_size(而不是先把 L //= pp_size 再拆分),保证 L_full + L_swa == L。qkv_proj、out_proj 以及新生成 token 的 KV 写入仍按全部 L 层计费,只有 attn_qk、attn_av 与 attn_input 读流量按两种 context 规模加权混合,既保住投影项不被误减,也让核心项随窗口收敛。
  5. 测试与配套。tests/v1/metrics/test_perf_metrics.py 为 MockModelConfig / create_mock_vllm_config() 增加 disable_sliding_window 参数,新增 9 个用例覆盖:无 SWA 默认、Mistral 全层 SWA、显式 layer_types、Gemma 2 交替(26→13)、disable_sliding_window 关闭、FLOPs 窗口钳制(8192 vs 1024 恰为 8 倍)、decode KV 读流量钳制、50%/50% 混合层解析式校验、以及 TP/PP 下的缩放。perf.py 内多个 parser 与抽象方法顺带补齐了 docstring(component_type、get_num_flops_breakdown、ParserChain.parse 等),属于同批次的文档化清理。
文件 模块 状态 重要度
vllm/v1/metrics/perf.py 性能指标 modified 8.75
tests/v1/metrics/test_perf_metrics.py 指标测试 modified 7.38

关键符号

ExecutionContext.add ExecutionContext.total_token_context_product ExecutionContext.prefill_context_len_for_window ExecutionContext.decode_context_len_for_window SlidingWindowAttentionParser.parse AttentionMetrics.get_parser AttentionMetrics.get_num_flops_breakdown AttentionMetrics.get_read_bytes_breakdown AttentionMetrics._layer_counts create_mock_vllm_config

关键源码片段

vllm/v1/metrics/perf.py core-logic

唯一源码文件,承载 SWA/ 混合层记账的全部核心逻辑:ExecutionContext 窗口截断、SlidingWindowAttentionParser 层类型判定、AttentionMetrics 的 FLOPs/ 读流量比例拆分,并解决了文件内关于混合注意力类型的 TODO。

ExecutionContext 的窗口截断能力(新增逐请求明细 + 三个窗口方法,未传窗口时走原有聚合标量快路径):

@dataclass
class ExecutionContext:
    """一次批调度的统计上下文,分别聚合 prefill / decode 两个阶段。"""
​
    # ... 既有聚合字段(prefill/decode 的 token 数、context 数和乘积)保持不变 ...
​
    # 新增:逐请求明细 (num_tokens, context_len, is_prefill),
    # 只有需要按滑动窗口截断时才会回放这批记录
    requests: list[tuple[int, int, bool]] = field(default_factory=list)
​
    def add(self, num_tokens: int, context_len: int, is_prefill: bool) -> None:
        """把一个请求的统计加入批上下文。"""
        self.requests.append((num_tokens, context_len, is_prefill)) # 明细与聚合同步累加
        if is_prefill:
            self.num_prefill_requests += 1
            self.prefill_num_tokens += num_tokens
            self.prefill_context_len += context_len
            self.prefill_token_context_product += num_tokens * context_len
        else:
            self.num_decode_requests += 1
            self.decode_num_tokens += num_tokens
            self.decode_context_len += context_len
            self.decode_token_context_product += num_tokens * context_len
​
    def total_token_context_product(self, sliding_window: int | None = None) -> int:
        """所有请求 num_tokens * context_len 之和;给窗口时对 context_len 截断。"""
        if sliding_window is None:
            # 快路径:直接用累计标量,等价于改动前实现,非 SWA 模型零回归
            return (
                self.prefill_token_context_product + self.decode_token_context_product
            )
        # 慢路径:逐请求把 context_len 钳到 sliding_window,再乘 num_tokens
        return sum(
            num_tokens * min(context_len, sliding_window)
            for num_tokens, context_len, _ in self.requests
        )
​
    def prefill_context_len_for_window(self, sliding_window: int | None = None) -> int:
        """prefill 请求的 context_len 之和,给窗口时按窗口截断。"""
        if sliding_window is None:
            return self.prefill_context_len
        return sum(
            min(context_len, sliding_window)
            for _, context_len, is_prefill in self.requests
            if is_prefill
        )
​
    def decode_context_len_for_window(self, sliding_window: int | None = None) -> int:
        """decode 请求的 context_len 之和,给窗口时按窗口截断(用于 KV 读流量)。"""
        if sliding_window is None:
            return self.decode_context_len
        return sum(
            min(context_len, sliding_window)
            for _, context_len, is_prefill in self.requests
            if not is_prefill
        )
tests/v1/metrics/test_perf_metrics.py test-coverage

新增 9 个 SWA 相关单测并为 mock 配置加上 disable_sliding_window,覆盖层类型判定三分支、窗口钳制比例(8 倍)与 TP/PP 缩放;审阅提出的 6 层 / 1 SWA / pp_size=2 回归用例也落在这里,是本 PR 语义正确性的主要护栏。

代表性用例:窗口生效时核心 attention FLOPs 被钳住而投影项保持不变,以及 mock 配置新增的 disable_sliding_window 开关:

class MockModelConfig:
    def __init__(self, hf_config, dtype, disable_sliding_window=False):
        """新增开关,用于构造 --disable-sliding-window 场景。"""
        ...
        self.disable_sliding_window = disable_sliding_window
​
​
def test_attention_metrics_sliding_window_flops_bounds():
    """context 超过窗口时,核心 attention FLOPs 被钳到窗口,投影项不受影响。"""
    sw = 1024
    dense_config = MistralConfig(
        hidden_size=2048, num_attention_heads=16, num_key_value_heads=16,
        num_hidden_layers=8, sliding_window=None,
    )
    swa_config = MistralConfig(
        hidden_size=2048, num_attention_heads=16, num_key_value_heads=16,
        num_hidden_layers=8, sliding_window=sw,
    )
    dense_metrics = AttentionMetrics.from_vllm_config(
        create_mock_vllm_config(dense_config)
    )
    swa_metrics = AttentionMetrics.from_vllm_config(create_mock_vllm_config(swa_config))
​
    # 1) context 未超过窗口时,两者数值必须完全一致(零回归)
    short_ctx = ExecutionContext.from_single_request(
        num_tokens=1, context_len=512, is_prefill=False
    )
    assert swa_metrics.get_num_flops(short_ctx) == dense_metrics.get_num_flops(short_ctx)
​
    # 2) context 8192 > 窗口 1024:核心项恰好缩小 8 倍,投影项不变
    long_ctx = ExecutionContext.from_single_request(
        num_tokens=1, context_len=8192, is_prefill=False
    )
    swa_flops = swa_metrics.get_num_flops_breakdown(long_ctx)
    dense_flops = dense_metrics.get_num_flops_breakdown(long_ctx)
    assert swa_flops["qkv_proj"] == dense_flops["qkv_proj"]
    assert swa_flops["out_proj"] == dense_flops["out_proj"]
    assert swa_flops["attn_qk"] * 8 == dense_flops["attn_qk"]
    assert swa_flops["attn_av"] * 8 == dense_flops["attn_av"]
​
​
# 另有一组解析器用例校验层类型判定:
# - Gemma 2:26 层中 13 层走滑窗(偶数层),见 test_sliding_window_parser_gemma2
# - 显式 layer_types 混合:6 层中 3 层滑窗,见 test_sliding_window_parser_explicit_layer_types
# - disable_sliding_window=True:sliding_window 归 None、num_swa_layers 归 0

评论区精华

流水并行下混合层模型的 SWA 层被舍入成 0(P1) 正确性

markmc 借助 Codex 指出:先用 `L //= pp_size` 再按 `round(L * num_swa_layers / num_hidden_layers)` 拆分,会把少数派 SWA 层舍掉——6 层 / 1 SWA / pp_size=2 时 `L` 变 3、`round(3 * 1 / 6)` 为 0,于是三条层全部按全注意力计费,`attn_qk`/`attn_av` 与 `attn_input` 同时失真;CodeRabbit 也独立给出同一结论并建议补 6 层 / 1 SWA / PP=2 的回归用例。

结论:作者复现确认后采纳「从全局层数拆分、再各自除以 pp_size」的方案(commit 95e08e5),保证 `L_full + L_swa == L`,该形状下得到 2.5 个全注意力层与 0.5 个 SWA 层;未采用 PP-rank 感知方案,理由是 `from_vllm_config()` 只有配置、拿不到 pipeline rank,现值为流水 stage 平均值。 · 已解决

per-GPU 拆分需保持 L_full + L_swa == L 不变量 正确性

CodeRabbit 指出若把 `L`、`L_full`、`L_swa` 各自独立整除 `pp_size`,在不能整除时 `L_full + L_swa` 不再等于 per-GPU 的 `L`,注意力项会少计而投影项仍按完整 `L` 计费,建议由已除过的 `L` 反推 `L_swa`。

结论:最终实现改为从全局层数拆分再各自除以 `pp_size`(作者说明这也替换了原先 `L //= pp_size` 的向下取整),并新增 6 层 / 1 SWA / pp_size=2 的回归测试,同时更新了原先把旧 8/7 拆分写死的 hybrid PP 用例。 · 已解决

补充 hybrid 层 + pipeline_parallel_size > 1 的测试覆盖 测试

CodeRabbit nitpick:原有并行测试用的是统一 SWA 模型(`num_swa_layers == num_hidden_layers`,整除后仍精确),混合层测试又只用 `pp_size=1`,恰好漏掉了「层数拆分取整」真正出问题的组合,建议加一个 layer_types 混合 + `pipeline_parallel_size=2` 的用例并断言 `attn_qk` 等于全局值除以 2。

结论:作者按 P1 修复补上了 6 层 / 1 SWA / pp_size=2 的回归测试,并同步更新原有 hybrid PP 断言,覆盖该组合。 · 已解决

AI 辅助贡献的署名与透明度 documentation

markmc 引用 contributing 文档:「please take a look at docs.vllm.ai/en/latest/contributing/#ai-assisted-contributions - personally I like to see attribution of AI assistance tools」,作者此前仅在 PR body 末尾写了 AI assistance 章节。

结论:作者在 PR body 补充说明(Claude Code 协助、逐行复核并自行运行测试),并承诺后续 commit 使用 `Assisted-by:` trailer;由于修复提交已在历史中,未重写其 commit message。该实践随后被维护者接受并 APPROVED。 · 已解决

风险与影响

  1. 近似边界未与真实 kernel 计数对齐。SWA 层用 num_tokens * min(context_len, sliding_window) 记账,忽略 prefill 三角边界(首个 token 只能看自己)与窗口按 block 对齐的差异,是上界近似;对长上下文相对误差小,但没有任何与 profiler/nsys 实际计数对拍的 e2e 校验,单元测试只验证公式自洽。
  2. PP 口径变化可能波及非 SWA 模型。修复把「先 L //= pp_size 再拆分」改成「全局层数各自除以 pp_size」(作者明确说明这同时替换了原先的向下取整),于是 per-GPU 层数可能变成小数(如 2.5 / 0.5),对层数不能被 pp_size 整除的稠密模型也会改变数值口径;而 get_num_flops_breakdown() 的返回注解仍是 dict[str, int],浮点值是否能被下游 Prometheus 指标路径安全消费需要复核。
  3. 层类型判定含硬编码启发式。model_type == "gemma2" 分支写死交替模式,其他同为交替结构但 layer_types 缺失的模型会落到「全部层都是 SWA」分支,反而系统性低估注意力开销;"sliding" in lt.lower() 也依赖 HF 侧字符串命名约定,未来重命名会静默失效。
  4. MLA 路径未同步。SWA 字段与拆分只加在 AttentionMetrics,MLAAttentionMetrics 保持原状,若混合配置下 MLA 与滑窗并存,相关模型指标仍会失真;MTP 记账在 issue #38170 中也已声明单独跟踪。
  5. ExecutionContext 逐请求列表的开销。requests 会在每个 batch 的每次 add() 中追加元组,在 --enable-mfu-metrics 打开且高频 step 下增加少量分配与内存;量级远小于模型计算,但属于常驻结构变化。

对用户:开启 --enable-mfu-metrics 的部署中,Mistral、Qwen2.5、Gemma 2、Step-3.5-Flash 等 SWA/混合层模型的 MFU、MBU 会从明显偏离的高位回落到接近真实水平,长上下文任务尤其明显;由于数字本身变小,跨版本的历史看板与容量规划基线不可直接对比,需要重新标定。对系统:改动只落在 vllm/v1/metrics/perf.py 的指标估算路径,不触碰调度、KV cache 管理与 attention kernel,推理正确性与性能不受影响;但解析异常会以 InvalidComponent 形式让 AttentionMetrics 整块跳过,属于既有的容错语义。对团队:为后续 MTP 记账、MLA 滑窗记账以及「PP-rank 精确语义」预留了扩展点(当前 per-GPU 值代表流水stage平均值而非某一 rank 的真实构成),同时该 PR 也确立了在 vLLM 中显式标注 AI 协助贡献的实践样例。

PP 口径变化 层类型判定启发式 近似上界未对拍 MLA 路径未覆盖 指标基线需重标

关联 Issue

#38170 [Bug]: MFU statistics on the Step-3.5-Flash model are inaccurate

完整报告

参与讨论