Prhub

#1965 dashboard: fix phase visibility for manager events and idle processes

原始 PR 作者 yueming-yuan 合并时间 2026-07-30 10:49 文件变更 2 提交数 2 评论 1 代码增减 +36 / -2

执行摘要

修复 dashboard 阶段可见性与空闲事件滞留

PR body 明确指出:“Two timeline bugs that together made a fully-async run's step-0 eval invisible and mislabeled”——utilization 视图把初始权重同步的 finalize_and_resume_engines / update_weights_implementation 画满整个 eval 期,而 eval 本身完全不显示。根因有三:manager 角色的 open 事件被 t1=-1 的裁剪逻辑丢弃;外部引擎 gpus:[] 导致覆盖集为空;进程空闲时 PhaseSink 不冲刷批量缓冲区。本 PR 的目标是让 fully-async 场景下阶段事件与真实执行周期对齐。

值得精读,尤其是 store.py 中 open 区间语义的处理和 hooks.py 中后台 flush 线程的设计:前者体现了“事件半开区间 + 渲染端负责延伸”的约定,后者解决批处理 sink 在静默进程上的滞留问题。建议后续为这三类场景补充针对性回归测试(open manager 事件、空 gpus 拓扑、空闲进程 flush),并将 t1=-1 的渲染约定写入模块文档。

讨论亮点

该 PR 的 review 通道没有产生实质技术讨论:Zhichenzzz 直接 APPROVED,未留下任何评论。唯一评论来自 gemini-code-assist[bot] 的 issue 级通知,说明 Gemini Code Assist 消费者版已停用,与本次变更新无内容关联,可忽略。

实现拆解

  1. 修正 manager 角色 open 区间的窗口裁剪(miles/dashboard/store.py):原逻辑对 t1=-1 的 open 事件直接计算 clip1 = min(event.t1, window["t1"]),导致 clip0 >= clip1 恒成立、事件被跳过。现改为对 event.t1 < 0 走单独分支:窗口右边界开放时保持 -1.0,由渲染器延伸到“现在”,与 train 角色事件的 open 语义保持一致。
  2. 空覆盖集回退到引擎节点 lane(miles/dashboard/store.py):外部 worker 拓扑快照携带 gpus: [] 时,covered 集合为空,manager 事件无法扩散到任何 lane。新增回退逻辑:从 engine["addr"] 解析节点名,再用 self.lanes() 取该节点上全部已知 (node, gpu) 作为覆盖集。
  3. PhaseSink 增加后台冲刷线程(miles/dashboard/hooks.py)PhaseSink 原本只在有新事件到达时调用 _take_batch_if_due() 检查批次,空闲进程(如 shared-engine eval 期间的 train rank)的最后一批关闭事件会无限滞留。新增 _ensure_flusher(),在首个事件落地后启动 daemon 线程 _loop,每 BATCH_MAX_SECONDS 检查 buffer 非空即调用 flush()
  4. 验证与测试:在 GLM-5.2 16x-GB300 fully-async 运行上验证,serve 重启后前两个修复生效,eval_rollout 在所有 12 条 engine lane 上渲染正确;tests/fast/dashboard 185 个用例全部通过。本 PR 未新增专门针对这三个场景的测试文件。
文件 模块 状态 重要度
miles/dashboard/hooks.py 仪表盘 modified 6.39
miles/dashboard/store.py 仪表盘 modified 5.26

关键符号

_ensure_flusher _loop

关键源码片段

miles/dashboard/hooks.py core-logic

为 PhaseSink 增加后台守护线程 _ensure_flusher/_loop,解决空闲进程最后一批 phase 关闭事件滞留缓冲区、UI 上长期显示为未关闭进度条的问题,是本次三大修复之一。

# miles/dashboard/hooks.py —— PhaseSink 空闲冲刷机制
# 原先只有新事件到达时才调用 _take_batch_if_due(),
# 进程一旦空闲,最后一批关闭事件会一直滞留在 buffer 中。
class PhaseSink:
    def __call__(self, name: str, t0: float, t1: float) -> None:
        try:
            with self._lock:
                # lazy: torch.distributed 通常尚未初始化,持续重解析直到真实 rank 出现
                if self._identity is None or (self._identity.rank < 0 and self.role == Role.TRAIN):
                    self._identity = _resolve_identity()
                identity = self._identity
                self._buffer.append(
                    PhaseEvent(name=name, t0=t0, t1=t1,
                               node=identity.node, gpus=identity.gpus,
                               rank=identity.rank, role=self.role)
                )
                batch = self._take_batch_if_due()
            # 首个事件落地后启动后台线程,保证空闲期也能 flush
            self._ensure_flusher()
            if batch:
                self._handle.push_phases.remote(batch)
        except Exception:
            self._warner.warn("dashboard phase sink failed; dropping events")
​
    def _ensure_flusher(self) -> None:
        """后台冲刷线程:进程静默时也能把关闭事件推给 collector。"""
        if self._flusher is not None:
            return
        import threading
​
        def _loop():
            while True:
                time.sleep(BATCH_MAX_SECONDS)
                with self._lock:
                    due = bool(self._buffer)
                if due:
                    # flush() 会把整个 buffer 通过 push_phases.remote 发出
                    self.flush()
​
        self._flusher = threading.Thread(target=_loop, daemon=True, name="dashboard-phase-flush")
        self._flusher.start()
miles/dashboard/store.py core-logic

修复 manager 角色 open 事件在窗口裁剪时被丢弃的问题,并在引擎无 GPU 身份(gpus:[])时回退到引擎节点上的已知 lane,是 eval 阶段可见性的核心修复。

# miles/dashboard/store.py —— manager 角色事件扩散到 engine lane 的核心分支
# manager 是 GPU-less 驱动进程,事件需要按拓扑窗口扩散到所有引擎 GPU
if event.role == Role.ROLLOUT_MANAGER:
    for window in windows:
        clip0 = max(event.t0, window["t0"])
        if event.t1 < 0:
            # 尚未关闭的区间:clip1 取窗口右边界,
            # 窗口本身开放时保持 -1.0,由渲染器画到“现在”
            clip1 = -1.0 if window["t1"] is None else window["t1"]
        else:
            clip1 = event.t1 if window["t1"] is None else min(event.t1, window["t1"])
        if clip1 >= 0 and clip0 >= clip1:
            continue
​
        covered = {(node, gpu) for engine in window["engines"] for node, gpu in engine["gpus"]}
        if not covered:
            # 外部引擎注册时没有 GPU 身份(gpus=[]),空覆盖集导致事件全部丢失;
            # 回退到引擎节点上所有已知 lane,节点名从 engine["addr"] 解析
            engine_nodes = {engine["addr"].split("//")[-1].split(":")[0] for engine in window["engines"]}
            covered = {(lane["node"], lane["gpu"]) for lane in self.lanes() if lane["node"] in engine_nodes}
        if lanes is not None:
            covered &= lanes
        for node, gpu in sorted(covered):
            out.append(dict(name=event.name, t0=clip0, t1=clip1,
                            node=node, gpu=gpu, rank=event.rank, role=event.role))

评论区精华

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

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

风险与影响

  1. 后台线程生命周期:每个 PhaseSink 实例(每个进程一个)都会启动一个 daemon 线程并常驻进程生命周期,多进程场景下线程数量较多,虽然每 BATCH_MAX_SECONDS 才唤醒一次、开销很小,但没有显式关闭机制。
  2. open 区间语义依赖渲染端:store.py 对 t1 < 0 的事件直接透传 -1.0,要求渲染端始终把 t1 < 0 解释为“延伸到 now”,未来渲染逻辑调整时需保持兼容。
  3. 节点解析依赖地址格式:engine["addr"].split("//")[-1].split(":")[0] 依赖 //host:port 格式,IPv6 或地址格式变化会导致解析失败或落到错误节点。
  4. 测试覆盖不足:仅依赖现有 185 个 dashboard 用例,没有为 open 区间、空 gpus 拓扑、空闲 flush 三个场景新增回归测试,后续回归风险较高。

用户侧:fully-async 运行下 dashboard 时间线能正确展示 eval / weight-sync 阶段,避免把权重同步误判为长期占用的 eval 过程,提升训练监控可信度。系统侧:PhaseSink 增加守护线程,带来轻微 CPU 与线程开销;manager 与 train 角色的事件语义统一为“open 事件延伸到 now”。团队侧:dashboard 模块对开放区间与空闲刷新的处理成为后续观测功能的基础,相关约定值得沉淀进模块文档。

守护线程常驻 open 区间语义变更 依赖引擎地址格式 缺少针对性测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论