Prhub

#2027 dashboard: advisory v2 — run-health alarms before config tuning

原始 PR 作者 yueming-yuan 合并时间 2026-08-13 02:27 文件变更 9 提交数 6 评论 2 代码增减 +493 / -31

执行摘要

advisory v2 以健康告警优先,门控并抑制误导性调优建议

PR body 的 Motivation 记录了一次真实事故:744B agentic 运行中 rollout 管线崩溃(dp-rank 饥饿 → client-timeout 风暴 → 整组丢弃 → backfill 反馈循环),训练侧在 open train_wait 停留 11.5 小时、35% 引擎请求被 client 中止,而 v1 面板只给出 "Peak concurrency (14) stayed under 30% of --sglang-max-running-requests (256); consider lowering it"。作者核心判断是低利用率在饥饿场景下是症状而非余量——降低上限会把真正的瓶颈锁死。因此 v2 按“规则所主张的内容”排序,并用健康门控保证同一观察在健康与饥饿运行上不会导出相反结论。

值得精读。核心设计决策值得学习:按“主张”分层 + 告警门控调优;stall watchdog 用自身中位数基线且对“常开相位”不作主张;counter 差分容忍引擎重启;缺失数据与退化组的语义区分。建议读者关注评审中 DumpStillWriting 边界是否跟进,以及 follow-up 的 per-dp-rank 标签粒度问题。

讨论亮点

唯一一条实质 review 评论来自合并者 Zhichenzzz(miles/dashboard/advisory.py:72):_rollout_advisories 使用 train_ids[-1],最新 rollout 最可能仍在写盘,summary() 会抛 DumpStillWriting 并被 server 映射为 503;前端 follow 模式以 4 请求/4.5s 的成本重试后 catch {} 静默保留旧列表——面板冻结在过期快照,而丢失的恰好是只在 live run 上重要的 stall/abort 告警。他建议用 try/except (DumpStillWriting, FileNotFoundError, KeyError) 隔离 dump 侧规则,并顺带指出 groups() 内部已调 summary(),同一 parquet 每请求读两遍、可传一次减半。从最终 head 代码看,compute_advisories 仍为裸调用 _rollout_advisories(reader, args),未见该包裹,此建议尚未落入实现。另有流程性评论询问 2022-2027 各 dashboard PR 的合入顺序,无实质技术内容。

实现拆解

  1. 入口重构与规则分层miles/dashboard/advisory.pycompute_advisories 重写为“告警优先、调优门控”结构:先收集 _stall_advisories(critical 管线停滞)、_engine_alarm_advisories(warning 引擎侧)、_rollout_advisories(warning dump 侧),任一告警存在即短路返回,只有全空时才进入 _tuning_advisories 输出 v1 的 info 级调优建议。合并 main 时与 #2353 的 MFU 规则、#2023 的 dp-imbalance 规则冲突,最终 MFU 作为调优层种子(无 ENGINE_SERIES 时仍可触发),dp-imbalance 保留在调优层尾部。

  2. Stall watchdog(停滞看门狗)_stall_advisories 基于新增的 MetricStore.phase_events()(返回未展开的原始相位事件)计算每个 open 相位的年龄,与同名相位历史中位数对比:超过 STALL_FACTOR=6×、不低于 STALL_MIN_AGE_S=1800s、且至少 3 个 closed 样本才判 critical。关键边界:设计上永不关闭的相位(fully-async rollout)没有基线、不作主张;用 meta.start_ts 过滤上一次崩溃 attempt 遗留的 open marker,防止断点续跑后永久锁死 critical;带配对 closed 事件的 open marker 不算停滞。train_wait 相位额外附加“训练被 rollout 数据饿死”的提示。

  3. 引擎与 rollout 告警_engine_alarm_advisories 从累计计数器 sglang_num_requests_total / sglang_num_aborted_requests_total 计算最近 30 分钟 abort 比,_counter_delta 按区间累加正增量以容忍引擎重启导致计数重置;告警文本展开 group-discard 放大效应(n_samples_per_prompt 整组丢弃 + backfill 重提交)。KV-pool-bound 签名(token 使用率 >90% 且峰值并发 <50% 上限)输出“提高 --sglang-mem-fraction-static,不要降低请求上限”的 warning。_rollout_advisories 检查最新训练 rollout 的 zero-std 组比例(≥50% 告警,全零奖励额外提示系统性失败)与截断比例(≥25% 告警,引用 --rollout-max-response-len)。

  4. 支撑修复与配套DumpReader.groups()zero_std 判定加入 reward_mean.is_not_null() 条件,区分“训练 dump 缺失导致的 null 奖励”与真实退化组;miles/dashboard/args.pyrollout_max_response_len 持久化到 meta args 快照;miles/dashboard/server.py/api/advisory 现在把 DumpReader 传入 compute_advisories;前端 views_timeline.js 面板更名 “Run advisory”,info 淡显、critical 加粗。

  5. 测试配套test_advisory.py 新增 13 个用例(停滞判定、双子 marker、跨 attempt、无基线相位、abort 风暴及引擎重启、风暴消退、低流量窗口、KV 绑定、zero-std、全零奖励、null 奖励、截断引用),用 _StubReader 隔离 dump 侧规则;test_dump_reader_views.py 新增 null-reward 用例;test_server.py 更新为预期截断 warning。PR body 声称 60 项 dashboard 测试通过。

文件 模块 状态 重要度
miles/dashboard/advisory.py 告警引擎 modified 8.65
tests/fast/dashboard/test_advisory.py 告警测试 modified 7.2
miles/dashboard/dump_reader.py 数据读取 modified 5.23
miles/dashboard/store.py 存储层 modified 4.96
miles/dashboard/server.py 服务端点 modified 4.79
miles/dashboard/static/views_timeline.js 前端面板 modified 4.95
miles/dashboard/args.py 参数层 modified 3.38
tests/fast/dashboard/test_dump_reader_views.py 读取测试 modified 4.42
tests/fast/dashboard/test_server.py 服务测试 modified 3.55

关键符号

compute_advisories _stall_advisories _engine_alarm_advisories _rollout_advisories _tuning_advisories _counter_delta MetricStore.phase_events DumpReader.groups

关键源码片段

miles/dashboard/advisory.py core-logic

advisory v2 的全部核心逻辑:compute_advisories 分层重构、stall watchdog、abort 风暴与 KV-pool-bound 告警、rollout 侧规则、调优门控。

# advisory.py —— v2 入口:先算告警,有告警直接返回;调优建议只在健康运行时输出。
# 同一观察(低并发、低缓存命中)在健康与饥饿运行上含义相反,必须被告警门控。
def compute_advisories(
    store: MetricStore,
    reader: DumpReader | None = None,
    *,
    t0: float | None = None,
    t1: float | None = None,
    mfu: dict | None = None,
    low_mfu: float = DEFAULT_LOW_MFU,
) -> list[Advisory]:
    args = store.meta.args if store.meta else {}
    alarms = _stall_advisories(store)
    alarms += _engine_alarm_advisories(store, args, t0, t1)
    if reader is not None:
        alarms += _rollout_advisories(reader, args)
    if alarms:
        return alarms # 有活动告警时,调优建议会指向错误方向,直接抑制
    return _tuning_advisories(store, args, t0, t1, mfu=mfu, low_mfu=low_mfu)
​
​
def _stall_advisories(store: MetricStore) -> list[Advisory]:
    """Open 相位超过自身历史中位数 6 倍(且至少 30 分钟)视为停滞。    以全 run 最新数据时间为判断基准,而非查询窗口:停滞是当前状态的陈述,
    open marker 可能比用户查看的任意窗口都旧得多。"""
    time_range = store.time_range()
    if time_range is None:
        return []
    edge = time_range[1]
    # 断点续跑会 append 到同一 dump 目录,crash attempt 留下的 open marker
    # 永不关闭;用本次 attempt 时钟去量它们会永久锁死 critical。meta.json 每
    # 次 attempt 重写,start_ts 是天然边界,同时把上次 attempt 的 closed 时长
    # 挡在 median 基线之外。
    attempt_start = store.meta.start_ts if store.meta else float("-inf")
    events = [event for event in store.phase_events() if event.t0 >= attempt_start]
    # 有配对 closed 事件的 open marker 说明相位已正常关闭(乱序写入),不算停滞
    closed_keys = {(e.node, e.rank, e.name, e.t0) for e in events if not e.open}
    durations: dict[str, list[float]] = {}
    open_ages: dict[str, list[float]] = {}
    for event in events:
        if event.open:
            if (event.node, event.rank, event.name, event.t0) not in closed_keys:
                open_ages.setdefault(event.name, []).append(edge - event.t0)
        else:
            durations.setdefault(event.name, []).append(event.t1 - event.t0)
​
    out = []
    for name, ages in sorted(open_ages.items()):
        closed = durations.get(name, [])
        # fully-async 的 rollout 相位设计上永不关闭:没有 closed 基线就没有主张
        if len(closed) < STALL_MIN_CLOSED:
            continue
        typical = median(closed)
        age = max(ages)
        if age <= max(STALL_FACTOR * typical, STALL_MIN_AGE_S):
            continue
        message = (
            f"Phase '{name}' has been open for {_fmt_duration(age)} on {len(ages)} rank(s) — "
            f"{age / typical:.0f}x its median duration ({_fmt_duration(typical)}); "
            "the pipeline is stalled, not slow"
        )
        if name == "train_wait":
            message += " (training is starved of rollout data — check the data buffer and engine health)"
        out.append(Advisory(level="critical", message=message))
    return out
​
​
def _counter_delta(series: list[dict], *, since: float = float("-inf")) -> float:
    """累计计数器在窗口内的总增量。    按区间逐段累加并丢弃负增量:引擎在同一地址重启会中途重置计数器,
    last-minus-first 会把重启前后的历史一并抹掉(与 MetricStore 派生序列
    同一条重置规则);since 只保留其后结束的区间。"""
    total = 0.0
    for one in series:
        for ts, (before, after) in zip(one["ts"][1:], pairwise(one["value"]), strict=True):
            if ts >= since:
                total += max(after - before, 0.0)
    return total
miles/dashboard/dump_reader.py bugfix

修复 zero_std 误判:null 奖励(训练 dump 缺失 / 未同步)不再被当作退化组,这是 rollout 视图出现虚假 '8/8 zero-std groups' 的根因。

# dump_reader.py —— zero_std 语义修复:缺失数据不是退化组。
def groups(self, rollout_id: int, *, evaluation: bool = False) -> pl.DataFrame:
    """Per-GRPO-group 聚合;zero_std 标记退化组(组内奖励全同,优势消失)。"""
    reward_column = "reward" if evaluation else "raw_reward"
    return (
        self.summary(rollout_id, evaluation=evaluation)
        .group_by("group_index")
        .agg(
            n=pl.len(),
            reward_mean=pl.col(reward_column).mean(),
            reward_std=pl.col(reward_column).std(),
            response_length_mean=pl.col("response_length").mean(),
            truncated_frac=pl.col("truncated").cast(pl.Float64).mean(),
        )
        # reward_mean 为 null 表示训练 dump 缺失 / 未同步,是缺失数据而非退化组;
        # reward_mean 有值但 std 为 null 表示单样本组,那才是真正退化
        .with_columns(
            zero_std=pl.col("reward_mean").is_not_null() & (pl.col("reward_std").fill_null(0.0) <= 1e-12)
        )
        .sort("group_index")
    )

评论区精华

最新 rollout 写盘中的 DumpStillWriting 会拖垮 liveness 告警 正确性

Zhichenzzz 指出 _rollout_advisories 使用 train_ids[-1](最新、最可能仍在写盘的 rollout),summary() 抛 DumpStillWriting 后 server 返回 503;前端 follow 模式 3 次 1.5s 重试后 catch {} 静默保留旧列表,面板以每 tick 4 请求 /4.5s 的成本冻结在过期快照,且丢失的恰好是 stall/abort 这些只在 live run 上重要的告警。建议用 try/except (DumpStillWriting, FileNotFoundError, KeyError) 隔离 dump 侧规则;次要建议是 groups() 与 summary() 重复读同一 parquet,可传一次减半。

结论:从最终 head 代码看 compute_advisories 仍为裸调用 _rollout_advisories(reader, args),未见 try/except 包裹,建议未落入实现,属未解决边界。 · 待处理

2022-2027 系列 dashboard PR 的合入顺序 question

Zhichenzzz 评论 'qq: will 2022-2027 be stacked or which order should we follow?',询问这批 dashboard 功能 PR 应叠加还是按顺序合入。

结论:未在评论区展开;从提交历史看各 PR 最终陆续合入 main,且 v2 在合并中吸收 #2353 与 #2023 的冲突。 · closed

风险与影响

正确性风险:_rollout_advisoriesDumpStillWriting 异常会拖垮整个告警链——不仅 503,还会让前端静默保留旧快照,且丢失的是最重要的 liveness 告警;该路径目前无测试覆盖。性能风险:follow 模式下 /api/advisory 每 tick 重复读同一 parquet(groups()summary() 双层),异常时额外 3 次重试,大 dump 下磁盘 I/O 放大。兼容性:Advisory.level 新增 critical 枚举,API 响应结构不变;面板对 critical 仅加粗,视觉区分度有限。阈值风险:STALL_FACTORABORT_RATIO_WARNZERO_STD_FRAC_WARN 等均为硬编码,v1 已声明“阈值需依真实运行调参”,新规则在真实运行覆盖前存在误报/漏报空间。已知缺口:dp-rank 队列不平衡告警因 scraper 标签粒度不足(collector 将引擎标签降为 engine_type)无法实现,作者自述留作 follow-up。

用户影响:训练/推理工程师在面板上获得三层告警,能第一时间区分“管线停滞”与“计算慢”,避免按误导性调优建议(如降低请求上限)锁死真正瓶颈。系统影响:dashboard 服务新增 dump 侧读取路径与一个 meta 参数键(rollout_max_response_len),无破坏性 API 变更。团队影响:advisory 规则按层级组织、阈值集中在文件顶部常量区,后续新增规则需明确归层并补短路测试;评审指出的 DumpStillWriting 边界是合入后应优先跟进的项目。

DumpStillWriting 未隔离(告警面板可能静默冻结) 硬编码阈值需真实运行调参 follow 模式重复读盘 + 重试开销 per-dp-rank 粒度缺失,dp 不平衡告警缺位

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论