Prhub

#27710 Add UT guarding per-request bookkeeping clock ownership

原始 PR 作者 hnyls2002 合并时间 2026-06-10 08:11 文件变更 1 提交数 11 评论 2 代码增减 +252 / -0

执行摘要

新增 AST 契约测试防止 bookkeeping 时钟越权修改

PR body 指出,per-request bookkeeping 时钟的错误修改(如 draft worker 中重复运行)导致 SWA 前缀锁提前释放等静默问题,在 e2e CI 和 idle leak checker 中不可见。#27607 修复了 Frozen-KV MTP 中出现的 double-run bookkeeping 问题,此测试用于防止同类问题再次发生。

此 PR 值得精读,尤其是其使用 AST 静态分析实现契约测试的设计模式,可以推广到其他需要保护关键数据修改点的场景。白名单+计数检查的方式既严格又灵活,值得借鉴。

讨论亮点

PR 没有 review 评论。PR 评论中只有 gemini-code-assist 的配额警告和作者触发的 /tag-and-rerun-ci 指令,无实质性讨论。

实现拆解

  1. 定义跟踪属性与白名单

    • _TRACKED_ATTRS 中列出需要跟踪的 5 个属性(decode_batch_idxextend_batch_idxkv_committed_lenkv_allocated_lenspec_verify_ct)以及 maybe_evict_swa() 方法。
    • _OWNER_SITES 中枚举所有经过审查的修改点,每个条目包含(相对路径、作用域、变体类型)和期望的修改次数。
  2. AST 扫描与计数

    • _scan_srt() 递归扫描 python/sglang/srt 目录下的所有 .py 文件,使用 Python ast 模块解析每个文件。
    • _scan_tree() 对每个 AST 树遍历节点,识别对跟踪属性的赋值操作(ast.Assignast.AugAssign)以及对 maybe_evict_swa 的方法调用,并记录所在的作用域(类名+方法名)。
    • _is_zero_reset() 判断赋值是否为 = 0 的重置操作,这类操作被豁免(不计入白名单计数)。
  3. draft worker 独立检查

    • 通过 _draft_worker_classes() 发现所有继承自 EagleDraftInputV2Mixin 的子类(即 spec-v2 draft worker)。
    • _scan_class_subtree() 确保这些类中没有对跟踪属性的任何修改,因为其 bookkeeping 已由 scheduler-driven 的 prepare_for_decode mixin 或 resolve 路径负责。
  4. 断言与失败信息

    • 将实际计数与 _OWNER_SITES 对比,输出差异(新增、缺失、计数不等)并触发断言。
    • 任何未列入白名单的修改点都会导致测试失败,提示开发者审查并更新白名单。
  5. CI 注册

    • 通过 register_cpu_ci 注册为 CPU 测试,预估时间 8 秒,归入 base-a-test-cpu suite。
文件 模块 状态 重要度
test/registered/unit/spec/test_decode_bookkeeping_ownership.py 所有权测试 added 7.48

关键符号

_iter_scoped_nodes visit _is_zero_reset _scan_tree _parse _scan_srt _draft_worker_classes _scan_class_subtree

关键源码片段

test/registered/unit/spec/test_decode_bookkeeping_ownership.py test-coverage

唯一变更文件,实现完整的 AST 契约测试,用于防止 per-request bookkeeping 时钟的越权修改。

"""Ownership contract for per-request bookkeeping clocks.Per-request accounting state (`decode_batch_idx` / `extend_batch_idx` iter
clocks, `kv_committed_len` / `kv_allocated_len` KV watermarks,
`spec_verify_ct`, and the `maybe_evict_swa()` call) must only be advanced by
the reviewed owner sites in _OWNER_SITES; spec-v2 draft workers must not
repeat any of them (the scheduler-driven mixin / resolve path already does).
A clock that runs fast fires SWA eviction in the overlap race window and
releases the SWA prefix lock early; neither shows up in e2e CI or the idle
leak checker, hence this AST-level guard.
"""# 被跟踪的 bookkeeping 属性名(赋值操作会触发计数)
_TRACKED_ATTRS = (
    "decode_batch_idx",
    "extend_batch_idx",
    "kv_committed_len",
    "kv_allocated_len",
    "spec_verify_ct",
)
# 被跟踪的 SWA 逐出方法名(调用会触发计数)
_EVICT_METHOD = "maybe_evict_swa"# 白名单字典:{ ( 相对路径 , 作用域 , 变体类型 ): 期望次数 }
# 变体类型为属性名或 "evict"
_OWNER_SITES = {
    # 非 spec 调度器
    ("managers/schedule_batch.py", "ScheduleBatch.prepare_for_decode", "decode_batch_idx"): 1,
    ("managers/schedule_batch.py", "ScheduleBatch.prepare_for_decode", "kv_committed_len"): 1,
    ("managers/schedule_batch.py", "ScheduleBatch.prepare_for_decode", "kv_allocated_len"): 1,
    # 省略其他已审查条目 ...
}def _scan_srt() -> dict:
    """遍历 srt 目录下的所有 .py 文件,返回实际计数 dict。"""
    counts: dict = {}
    for py_file in sorted(_SRT_DIR.rglob("*.py")):
        if "site-packages" in py_file.parts:
            continue
        relative = py_file.relative_to(_SRT_DIR).as_posix()
        tree = ast.parse(py_file.read_text())
        # 对每个文件进行 AST 扫描
        _scan_tree(tree, relative, counts)
    return countsdef _scan_tree(tree: ast.AST, relative: str, counts: dict) -> None:
    """递归遍历 AST 树,记录赋值和 maybe_evict_swa 调用计数。"""
    scope_of = {}
    def visit(node, scope):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            # 作用域:类名 . 方法名
            scope = f"{scope}.{node.name}" if scope else node.name
        scope_of[node] = scope
        # 处理赋值语句:获取被赋值的属性名
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Attribute) and target.attr in _TRACKED_ATTRS:
                    # 判断是否为 = 0 的重置(豁免)
                    if not _is_zero_reset(node.value):
                        key = (relative, scope, target.attr)
                        counts[key] = counts.get(key, 0) + 1
        # 处理方法调用:maybe_evict_swa()
        elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
            if isinstance(node.value.func, ast.Attribute) and node.value.func.attr == _EVICT_METHOD:
                key = (relative, scope, "evict")
                counts[key] = counts.get(key, 0) + 1
        # 递归子节点
        for child in ast.iter_child_nodes(node):
            visit(child, scope)
    visit(tree, "")

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 白名单维护成本:当新增合法的 bookkeeping 操作时,必须同步更新 _OWNER_SITES,否则测试会失败,可能影响开发效率。
  2. AST 解析脆弱性:测试依赖 AST 树结构识别赋值和调用,如果代码风格变化(如使用 setattr 或动态属性)可能导致漏检或误报。
  3. 重构敏感:函数/类重命名、文件移动等会导致白名单条目失效,需同时更新测试。
  4. 无源码效率影响:测试仅在 CI 中运行约 8 秒,不影响运行时性能。

对用户无直接影响。对系统,增加了 bookkeeping 变更时的编译期契约,防止静默回归。对团队,在修改相关代码时需要维护该测试文件的允许列表,但能显著降低难以调试的 bookkeeping 错误风险。

白名单维护成本 AST 解析对重构敏感 无源码变更但需持续同步

关联 Issue

#27607 Support spec v2 for Frozen-KV MTP; remove v1 worker

完整报告

参与讨论