Prhub

#34316 [metrics] Fix prefill FLOPs estimate to count prefix and per-request causal pairs

原始 PR 作者 Lenoplus42 合并时间 2026-08-18 06:18 文件变更 2 提交数 3 评论 4 代码增减 +62 / -4

执行摘要

修复 prefill FLOPs 估算:按请求计入 prefix 因果对与 KV 读取

关联 issue #34298 指出 _estimate_prefill_perf 只用 sum(extend_lens) 计算 attention 对数,产生两个独立错误:一是丢弃 chunk 与缓存 prefix 之间的 c * P attention 对,二是把 batch 内多个请求合并成一条序列而虚构跨请求 attention 对(例如两个 100 token 的 prefill 实际是 10,100 对,旧实现却按 200 token 序列估为 20,100 对)。PR body 强调:"the reported est. prefill TFLOPS/s then becomes proportional to 1 / gap_latency rather than to any real measure of throughput"——即固定 --chunked-prefill-size 下每 chunk 的估算 FLOPs 完全相同,指标退化为逆延迟曲线,与 docs 中推荐该指标做趋势分析的初衷背道而驰。

值得精读。本 PR 是 observability 指标"从数学正确性到物理真实性"的典型样本:数学修复本身直白,但 read_bytes 的演进(sum(c*p)sum(prefix_lens))展示了如何用 HBM 峰值带宽做合理性检验来校正建模假设。测试技巧也可借鉴——把所有系数清零、把 attention 系数置 1,让估计值归约成可断言的整数。建议关注 hnyls2002 对 FA 内核流式读取行为的分析,以及作者对 prefix_lens 索引对齐假设的明确声明。

讨论亮点

review 中 hnyls2002 给出了两条关键评论:

  1. 要求收敛变更范围(COMMENTED):"Rather than deferring to #34298, please fold the rest in here: read_bytes needs the matching sum(c * p) * _kv_cache_bytes_per_token term (decode already charges it), and the pair count should move into a shared helper since _prefill_sol_suffix needs it too." 作者据此在第二个提交中合入 read_bytes 修复,并抽出共享 pair 计数 helper。
  2. 自我修正 read_bytes 建模(COMMENTED):"Correcting my earlier comment on read_bytes: sum(c*p) doesn't hold at chunk scale. It corresponds to naive attention with zero KV reuse, while FA-class kernels stream the prefix roughly once per query block. At c=8192 / p=128K on a 70B-class TP8 config that's ~44 TB of KV reads for one chunk; divided by the chunk latency the implied bandwidth is well above HBM peak." 他建议以 sum(prefix_lens) 作为逻辑下限,并提到 issue 中的 p + c/2 也可行但 c/2 与现有 tokens * _prefill_attn_act_read_per_token 项重叠。最终方案采纳了 sum(prefix_lens),第三个提交即由此产生。

实现拆解

变更共分四步,涉及 python/sglang/srt/managers/scheduler_components/metrics_reporter.py(源码)与 test/registered/unit/observability/test_forward_pass_metrics.py(测试)两个文件。

  1. 抽取共享 pair 计数 helper:在 metrics_reporter.py 新增 @staticmethod _prefill_attention_pairs(batch),用 zip 同时遍历 batch.extend_lensbatch.prefix_lens,按请求累计两项:c * p(chunk 对缓存 prefix 的 attention 对)与 c * (c + 1) / 2.0(chunk 内部因果对),并返回总和。同步更新 _prefill_sol_suffix 的 docstring,将其契约从"子类自行计算 pair 数"改为"调用 _prefill_attention_pairs 获取精确因果对数",便于模型架构子类复用。
  2. 改写 _estimate_prefill_perf 的 FLOPs 项context_product 由原来的 tokens * (tokens + 1) / 2.0 替换为 self._prefill_attention_pairs(batch)。这一处替换同时修复两个数学错误:补上被丢弃的 prefix 因果对,并避免多请求被合并成单序列导致约 2 倍的虚高。
  3. read_bytes 增加 prefix KV 读取项并收敛语义:第二个提交(Lenoplus42)按 sum(c * p) * _kv_cache_bytes_per_token(每条 query-key 对计一次 KV 读取)实现;review 中 hnyls2002 指出该模型假设 FA 类 kernel 无 KV 复用,在 c = 8192 / p = 128K 时会算出远超 HBM 峰值带宽的读取量。最终第三个提交(hnyls2002 直接提交)改为 sum(batch.prefix_lens) * _kv_cache_bytes_per_token——每个 chunk 的 query 共享一次对前缀 KV 的流式读取,作为逻辑下限,与文档中 "modeled traffic" 措辞一致,也与 decode 路径按 total_context 计费的姿势对齐。
  4. 单元测试锁定语义:在 test_forward_pass_metrics.py 新增 TestEstimatedPrefillPerf 类,通过 setUp 把线性、权重、激活、KV 等系数全部清零、attention 系数置 1,使返回的 FLOPs 恰好等于因果 pair 数,便于精确断言;覆盖缓存 prefix 计费、prefix KV 每 chunk 只读一次、同 batch 多请求互不 attend、mixed prefill+decode 行使用各自上下文四个场景。测试继承 CustomTestCase 基类并注册到 base-a-test-cpu 套件。

配套说明:PR 无文档改动(body 未勾选 "Update documentation");CI 仅 CPU 路径;/rerun-test test_forward_pass_metrics.py 定向跑测通过。

文件 模块 状态 重要度
python/sglang/srt/managers/scheduler_components/metrics_reporter.py 指标上报 modified 6.85
test/registered/unit/observability/test_forward_pass_metrics.py 指标测试 modified 6.27

关键符号

_prefill_attention_pairs _estimate_prefill_perf

关键源码片段

test/registered/unit/observability/test_forward_pass_metrics.py test-coverage

新增 `TestEstimatedPrefillPerf` 类,用系数清零技巧把 FLOPs 归约为可断言的 pair 数,覆盖缓存 prefix 计费、prefix KV 按 chunk 只读一次、多请求不互相关注、mixed 批次使用各自上下文四个核心语义。

class TestEstimatedPrefillPerf(CustomTestCase):
    """覆盖 `est. prefill TFLOPS/s` 与 `estimated_flops` 背后的因果 pair 计数语义。"""
​
    def setUp(self):
        self.scheduler = types.SimpleNamespace()
        self.scheduler.waiting_queue = []
        self.scheduler.disaggregation_mode = DisaggregationMode.NULL
        self.reporter = _make_reporter(self, self.scheduler)
        # 把所有非 attention 系数清零、attention 系数置 1,
        # 这样返回的 FLOPs 就恰好等于因果 pair 数量,便于精确断言
        self.reporter._linear_flops_per_token = 0.0
        self.reporter._attn_dot_flops_coeff = 1.0
        self.reporter._weight_read_bytes_per_token = 0.0
        self.reporter._qkv_act_bytes_per_token = 0.0
        self.reporter._prefill_attn_act_read_per_token = 0.0
        self.reporter._kv_cache_bytes_per_token = 0.0
        self.reporter._ffn_act_bytes_per_token = 0.0
​
    def _pair_count(self, extend_lens, prefix_lens):
        batch = types.SimpleNamespace(extend_lens=extend_lens, prefix_lens=prefix_lens)
        flops, _, _ = self.reporter._estimate_prefill_perf(batch)
        return flops
​
    def test_chunk_is_charged_for_its_cached_prefix(self):
        # 4 个新 token 续接在 3 token 的缓存 prefix 之后:
        # 4 * 3(对前缀的 attention 对)+ 4 * 5 / 2(chunk 内部因果对)
        self.assertEqual(self._pair_count([4], [3]), 4 * 3 + 4 * 5 / 2)
​
    def test_prefix_kv_is_read_once_per_chunk(self):
        # prefix 的 KV 按 chunk 只流式读取一遍,而不是按 query-key 对计数
        self.reporter._kv_cache_bytes_per_token = 1.0
        batch = types.SimpleNamespace(extend_lens=[4], prefix_lens=[3])
        _, read_bytes, _ = self.reporter._estimate_prefill_perf(batch)
        self.assertEqual(read_bytes, 3)
​
    def test_requests_in_one_batch_do_not_attend_to_each_other(self):
        # 两个独立 100 token 的 prefill 应为 2 * (100 * 101 / 2),而不是
        # 200 * 201 / 2(旧实现会把它们合并成一条 200 token 的序列)
        self.assertEqual(self._pair_count([100, 100], [0, 0]), 2 * (100 * 101 / 2))
​
    def test_mixed_prefill_and_decode_rows_use_their_own_context(self):
        # mix_with_running 会把 running 请求追加为 extend_len = 1、
        # prefix_len = 当前上下文长度,估计器必须使用各自上下文
        self.assertEqual(
            self._pair_count([8, 1, 1], [0, 100, 200]),
            8 * 9 / 2 + (100 + 1) + (200 + 1),
        )

评论区精华

要求把 read_bytes 修复与共享 helper 合入本 PR 设计

hnyls2002 评论:"Rather than deferring to #34298, please fold the rest in here: read_bytes needs the matching `sum(c * p) * _kv_cache_bytes_per_token` term (decode already charges it), and the pair count should move into a shared helper since `_prefill_sol_suffix` needs it too."

结论:作者在第二个提交中合入 read_bytes 项并抽出共享 pair 计数 helper,原先的计划是留给后续 issue 处理。 · 已解决

read_bytes 按 sum(c*p) 计费会超出 HBM 峰值带宽,需改用逻辑下限 性能

hnyls2002 自我修正:"Correcting my earlier comment on read_bytes: `sum(c*p)` doesn't hold at chunk scale... FA-class kernels stream the prefix roughly once per query block. At c=8192 / p=128K on a 70B-class TP8 config that's ~44 TB of KV reads for one chunk; divided by the chunk latency the implied bandwidth is well above HBM peak." 建议用 `sum(prefix_lens)` 作为逻辑下限,或采用 issue 中的 `p + c/2`(但 `c/2` 与现有项重叠)。

结论:第三个提交由 hnyls2002 直接落地为 `sum(prefix_lens) * _kv_cache_bytes_per_token`,测试相应改为 `test_prefix_kv_is_read_once_per_chunk`。 · 已解决

CI 触发与测试范围确认 测试

Lenoplus42 说明变更为 CPU-only,新增两个单元测试用例,请求 maintainer 触发 `/tag-and-rerun-ci`;hnyls2002 随后通过 `/rerun-test test_forward_pass_metrics.py` 定向跑测。

结论:github-actions 回报测试通过(ubuntu-latest 1 test ✅)。 · 已解决

风险与影响

风险主要集中在指标语义与运维依赖上:

  • 指标绝对值大幅抬升:PR body 明确提示 sglang:estimated_flops_per_gpu_total 的绝对值在长上下文下可能上升 1-2 个数量级。任何基于旧值校准的告警阈值、面板 Y 轴、容量规划脚本都会失效,需要同步重校。
  • read_bytes 语义为逻辑下限而非物理流量:最终采用 sum(prefix_lens),代表"每个 chunk 对前缀 KV 流式读取一遍"的建模下限;真实的 FA/Triton 内核因 tiling 与 L2 复用会有差异,estimated_read_bytes_per_gpu_total 与 HBM 实际流量之间的换算关系并不严格,误用会导致带宽估算偏差。
  • 依赖列表索引对齐_prefill_attention_pairsread_bytes 均依赖 extend_lensprefix_lens 按请求一一对应;PR 有意省略了 issue 提出的 fallback 与 c <= 0 守卫,理由是两条列表在所有写入路径上同写。若未来出现单独修改其中一条列表的代码路径,可能引入静默错位。
  • 架构相关常量未建模:issue 中左开放的 MLA、MoE、稀疏 attention 对 pair 数或 FLOPs 系数的影响仍未处理,_attn_dot_flops_coeff 仍是统一系数,估算精度对特定架构(如 DeepSeek)仍有偏差。

影响范围集中在 observability 层,不改变任何推理行为:

  • 用户侧est. prefill TFLOPS/s 在 chunked-prefill 下恢复物理含义,能正确反映 prefix 增长带来的计算量变化;观测面板与告警需按新绝对值重校准。
  • 系统侧:估算器位于日志路径,改动仅把一次 batch 级 sum 换成一次对两个 list 的 generator 遍历,开销可忽略;无内存、显存或调度行为变化。
  • 团队侧_prefill_sol_suffix 的钩子契约变化(从"子类自行计算"到"调用共享 helper"),继承 SchedulerMetricsReporter 的架构子类(如 DSpark 等)需要同步调整以获得一致的 pair 计数。
指标绝对值预期上升 1-2 个数量级 read_bytes 采用逻辑下限语义 依赖 extend_lens 与 prefix_lens 索引对齐 架构相关 FLOPs 常量未建模

关联 Issue

#34298 [Bug] Prefill FLOPs estimate ignores `prefix_lens`, so `est. prefill TFLOPS/s` degenerates into 1/latency across chunked-prefill chunks

完整报告

参与讨论