Add Graph Execution to host_build_graph - #1444
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds host-side graph capture and replay for ChangesGraph Execution Runtime
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
fd8fc05 to
66f183e
Compare
A forked sub/child/chip worker never checked whether its parent was still alive. When the parent died without `Worker.close()` — SIGKILL from a `timeout`, an OOM kill, a cancelled CI job, an editor killing a wedged pytest — every child was reparented and kept polling a mailbox nobody would ever write to. Because that poll is an unbounded busy-wait, each survivor held a full core indefinitely. This is not theoretical. On a shared dev box six such processes belonging to two different users had been alive 5-7 days each at ~99.6% CPU, six cores permanently consumed. Four of the six carried `tests/ut/py/test_worker/test_startup_readiness.py` on their command line: runs whose parent was killed rather than allowed to finish. `_run_mailbox_loop` now samples `getppid()` on its idle path and leaves by the same SHUTDOWN route once it changes, so a child tears down exactly as it would on a clean shutdown. Compare against the pid captured at loop entry rather than testing for pid 1: a subreaper (container init, a systemd user session) adopts orphans instead of init, so the pid changes but never becomes 1. A live parent's pid cannot change, so the check cannot fire spuriously. Sampling every `_PARENT_LIVENESS_POLL_INTERVAL` idle polls puts a `getppid()` roughly every 100 us against a ~0.1 us poll — fast enough that an orphan goes away before it is noticeable, cheap enough to be lost in the noise of the poll itself. Residual gap: a parent that dies between `os.fork()` and the child reaching the loop is already gone when the pid is captured, so that child is not detected. Closing it needs the pid handed in from before the fork, or `PR_SET_PDEATHSIG`, which is Linux-only and therefore an optimisation on top of this rather than the mechanism. `test_orphan_child_reaping.py` SIGKILLs a subprocess parent and asserts every child is gone within 20 s. Three details are load-bearing: - It reads the pids from the Worker's own `_sub_pids` rather than `pgrep -P`. Enumerating children externally also catches the transient shell that runs pgrep, and inside a container's shallow pid namespace it picks up unrelated low pids: on CI that produced "1 of 6 children outlived ... [3]", waiting on and then SIGKILLing a container process that was never ours. - It writes to a file and never reads a pipe. Orphans inherit the parent's stdout, so on regression they hold a pipe's write end open and reading one would hang the test for as long as the bug survives instead of failing it. - Its `finally` kills whatever survived, so a regression does not hand spinning processes to the next test. It runs on macOS as well as Linux: reparenting on parent death is POSIX behaviour, and nothing here is platform-specific once the pid list comes from the Worker. Also drop `src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md`, which hw-native-sys#1494 added by accident: it is documentation from the open hw-native-sys#1444 (`feat/graph-execution`) that happened to be sitting untracked in the working tree, and a `git add -A` swept it into that commit. Removing it restores main to the state hw-native-sys#1444 expects to merge into. It is unrelated to everything else here and can be reviewed independently of the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A forked sub/child/chip worker never checked whether its parent was still alive. When the parent died without `Worker.close()` — SIGKILL from a `timeout`, an OOM kill, a cancelled CI job, an editor killing a wedged pytest — every child was reparented and kept polling a mailbox nobody would ever write to. Because that poll is an unbounded busy-wait, each survivor held a full core indefinitely. This is not theoretical. On a shared dev box six such processes belonging to two different users had been alive 5-7 days each at ~99.6% CPU, six cores permanently consumed. Four of the six carried `tests/ut/py/test_worker/test_startup_readiness.py` on their command line: runs whose parent was killed rather than allowed to finish. `_run_mailbox_loop` now samples `getppid()` on its idle path and leaves by the same SHUTDOWN route once it changes, so a child tears down exactly as it would on a clean shutdown. Compare against the pid captured at loop entry rather than testing for pid 1: a subreaper (container init, a systemd user session) adopts orphans instead of init, so the pid changes but never becomes 1. A live parent's pid cannot change, so the check cannot fire spuriously. Sampling every `_PARENT_LIVENESS_POLL_INTERVAL` idle polls puts a `getppid()` roughly every 100 us against a ~0.1 us poll — fast enough that an orphan goes away before it is noticeable, cheap enough to be lost in the noise of the poll itself. Residual gap: a parent that dies between `os.fork()` and the child reaching the loop is already gone when the pid is captured, so that child is not detected. Closing it needs the pid handed in from before the fork, or `PR_SET_PDEATHSIG`, which is Linux-only and therefore an optimisation on top of this rather than the mechanism. `test_orphan_child_reaping.py` SIGKILLs a subprocess parent and asserts every child is gone within 20 s. Three details are load-bearing: - It reads the pids from the Worker's own `_sub_pids` rather than `pgrep -P`. Enumerating children externally also catches the transient shell that runs pgrep, and inside a container's shallow pid namespace it picks up unrelated low pids: on CI that produced "1 of 6 children outlived ... [3]", waiting on and then SIGKILLing a container process that was never ours. - It writes to a file and never reads a pipe. Orphans inherit the parent's stdout, so on regression they hold a pipe's write end open and reading one would hang the test for as long as the bug survives instead of failing it. - Its `finally` kills whatever survived, so a regression does not hand spinning processes to the next test. - It asserts the parent died from its own SIGKILL. A parent that fails before forking otherwise surfaces as an unexplained "expected 3 pids, got []", which reads like a defect in the code under test rather than an environment problem in the harness. It runs on macOS as well as Linux: reparenting on parent death is POSIX behaviour, and nothing here is platform-specific once the pid list comes from the Worker. Also drop `src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md`, which hw-native-sys#1494 added by accident: it is documentation from the open hw-native-sys#1444 (`feat/graph-execution`) that happened to be sitting untracked in the working tree, and a `git add -A` swept it into that commit. Removing it restores main to the state hw-native-sys#1444 expects to merge into. It is unrelated to everything else here and can be reviewed independently of the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A forked sub/child/chip worker never checked whether its parent was still alive. When the parent died without `Worker.close()` — SIGKILL from a `timeout`, an OOM kill, a cancelled CI job, an editor killing a wedged pytest — every child was reparented and kept polling a mailbox nobody would ever write to. Because that poll is an unbounded busy-wait, each survivor held a full core indefinitely. This is not theoretical. On a shared dev box six such processes belonging to two different users had been alive 5-7 days each at ~99.6% CPU, six cores permanently consumed. Four of the six carried `tests/ut/py/test_worker/test_startup_readiness.py` on their command line: runs whose parent was killed rather than allowed to finish. `_run_mailbox_loop` now samples `getppid()` on its idle path and leaves by the same SHUTDOWN route once it changes, so a child tears down exactly as it would on a clean shutdown. Compare against the pid captured at loop entry rather than testing for pid 1: a subreaper (container init, a systemd user session) adopts orphans instead of init, so the pid changes but never becomes 1. A live parent's pid cannot change, so the check cannot fire spuriously. Sampling every `_PARENT_LIVENESS_POLL_INTERVAL` idle polls puts a `getppid()` roughly every 100 us against a ~0.1 us poll — fast enough that an orphan goes away before it is noticeable, cheap enough to be lost in the noise of the poll itself. Residual gap: a parent that dies between `os.fork()` and the child reaching the loop is already gone when the pid is captured, so that child is not detected. Closing it needs the pid handed in from before the fork, or `PR_SET_PDEATHSIG`, which is Linux-only and therefore an optimisation on top of this rather than the mechanism. `test_orphan_child_reaping.py` SIGKILLs a subprocess parent and asserts every child is gone within 20 s. Three details are load-bearing: - It reads the pids from the Worker's own `_sub_pids` rather than `pgrep -P`. Enumerating children externally also catches the transient shell that runs pgrep, and inside a container's shallow pid namespace it picks up unrelated low pids: on CI that produced "1 of 6 children outlived ... [3]", waiting on and then SIGKILLing a container process that was never ours. - It writes to a file and never reads a pipe. Orphans inherit the parent's stdout, so on regression they hold a pipe's write end open and reading one would hang the test for as long as the bug survives instead of failing it. - Its `finally` kills whatever survived, so a regression does not hand spinning processes to the next test. - It asserts the parent died from its own SIGKILL. A parent that fails before forking otherwise surfaces as an unexplained "expected 3 pids, got []", which reads like a defect in the code under test rather than an environment problem in the harness. It runs on macOS as well as Linux: reparenting on parent death is POSIX behaviour, and nothing here is platform-specific once the pid list comes from the Worker. Also drop `src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md`, which #1494 added by accident: it is documentation from the open #1444 (`feat/graph-execution`) that happened to be sitting untracked in the working tree, and a `git add -A` swept it into that commit. Removing it restores main to the state #1444 expects to merge into. It is unrelated to everything else here and can be reviewed independently of the fix
3a1347f to
f1fbc59
Compare
f1fbc59 to
70cf581
Compare
|
总体: Graph Execution 的核心实现质量很高 —— POD/wire 纪律严谨、wire 格式校验完整、poll/wake-list 的图节点调度器经并发追踪确认正确、scene 测试用 golden 真正验证了重放正确性(不是"不崩就过")。我的顾虑在架构层面:这个 PR 捆绑了一个可分离的 intra-run 流式优化,它带来实打实的复杂度和一类并发 bug,而它的收益系统里已有其他机制在提供。建议 Request changes。 1. 取消 intra-run 的 O/S 流式(核心请求)。 现在的做法是:设备对空 SM 镜像先启动,host 编排(O)在一个并发线程上跑,每提交一次(每个 问题是这个重叠已经由两个现成机制提供了,流式是重复的第三套:
而代价是具体的:
建议:GRAPH 作为与 AIC/AIV/MIX 同级的加法型 task kind,O→S 在调度层面串行——把完整任务窗口(含 GRAPH task)建好、上传、启动、再调度。隐藏 O 交给 run 流水 + graph 缓存。同步更新本 PR 的 2. device launch 与 O 并行的优化,请另起独立 PR。 取消流式后,device launch 回到常规的串行位置(O 之后、S 之前)。如果仍想隐藏 per-run 的 device launch 延迟,干净的做法与 graph 无关:让 launch 与 host 编排并发(两者本就在不同线程),再让 Scheduler 等一个单一的"编排完成"握手后再调度完整镜像。这能把 launch 延迟藏起来(对冷启动/第一个 run——run 流水唯一盖不到的场景),代价是设备短暂空等,而一个启动等待即可干净处理(启动握手豁免于 no-sleep-on-dispatch 规则)。这是一个聚焦、可分离的优化,不应搭在一个已 3800+ 行的 graph PR 里,单独评审才能审清它自己的并发。 3. 把 这是一个全新的 4. 三处小修。
5. 合入前 rebase —— 分支落后 备注: |
70cf581 to
73da979
Compare
|
@ChaoZheng109 已按意见更新,当前 head 为
本地验证:PR 变更文件 clang-format/clang-tidy 通过;77 个 C++ 无硬件测试通过;完整 a2a3 host_build_graph sim 为 20 passed / 4 skipped / 6 deselected;最终 rebase 后 Graph + converter 为 25 passed / 1 deselected。当前机器 DCMI 无法识别芯片,Qwen 真机项交由 PR onboard CI 验证。 |
|
补充:Graph 执行池在设备侧分配,游离于 host 的设备内存记账之外 graph 执行池( 为什么这是个问题:
建议方向: 编排(orch)在 host 上运行,且已经记录了每个 graph 的节点数——所以 host 天生知道所需块的精确大小。因此这个池应当在 orch 时由 host 侧分配、并纳入 host 的设备内存预算,而不是放在一个 host 看不到的设备侧堆上。为了保持"按需"而非"按最坏情况固定预留",可采用一个按 graph 身份(key)复用的持久池——跨 run 复用、在 worker 销毁时释放——它只增长到工作负载的实际需求,同时全程被记账。由于它是 host 拥有的 GM,也天生对 AICore 可见,从而消除上面那个隐式依赖。 |
73da979 to
1fcca25
Compare
|
@ChaoZheng109 已按补充意见修改:
|
1fcca25 to
afa26fb
Compare
|
补充:文档描述的 graph-affine 重放快路径未实现 —— 每次重放都在 AICPU 上全量 re-materialize
实现并没有这么做。在
差异集中在第 2、5、7–18、21 行(共 15 项静态):当前每次重放全量重建 / 重跑,而按文档应全部跳过。其中第 17/18 的逐 tensor 来源分类 + 双重 影响:每次重放都在 AICPU 调度器上做一次全量节点 re-materialize,而不是文档设想的"复用除动态地址外的一切"。由于 materialize 跑在 AICPU(常常是调度瓶颈)上,这等于每次重放都加重瓶颈处理器,削弱了 GRAPH 这个任务类型本应带来的重放复用价值。同时这也是一处文档与代码不一致。 方向:实现文档描述的快路径 —— affine 命中时,跳过静态的每节点字段和来源分类/校验(在首次 materialize 时预计算一张边界/内部地址补丁表),只刷新动态部分(task id、packed-buffer 基址、边界/内部 tensor 地址、调度态)。这还要求 affine 复用对重复的层确定命中,而目前它取决于 recycle 池的时序。 |
afa26fb to
742774c
Compare
|
@ChaoZheng109 已按补充意见实现 graph-affine 重放快路径:首次 materialize 在 host-owned 连续执行块中生成 POD tensor 地址补丁表;同 graph key/content hash/node+patch count 的块再次命中时,保留 Definition、拓扑绑定和所有静态 node/payload/slot 字段,跳过 count/offset 校验、tensor source 分类、两次 wire 校验及 scalar memcpy,只刷新 task id、packed-buffer base/end、边界/内部 tensor 地址、调度状态、dispatch 原子量和 wake 注册。内部 tensor 地址现在直接按 new_outer_base + 预计算偏移更新。host 复用仍由 (pipeline slot, graph key, occurrence) 直接索引,occurrence 每轮按提交顺序确定,不依赖 recycler 时序。新增回归测试会用写探针确认静态字段未被覆盖,并覆盖 boundary/internal 地址刷新。验证:pre-commit 全通过;C++ no-hardware 82/82;a2a3sim host_build_graph 27 passed/4 skipped;a2a3 onboard Graph 3/3;Qwen3-14B 三层 onboard 1/1。 |
742774c to
015a88c
Compare
- Accept CoreTaskArgs and cache fixed-shape AIC/AIV/MIX/SPMD DAGs - Build and upload the complete host graph before Scheduler launch - Retain Graph blocks in host-owned, worker-accounted GM pools - Reuse POD address patches and refresh only dynamic replay state - Schedule Graph preparation beside external fanin readiness - Validate fixed boundary contracts and reject empty boundaries - Cover Graph variants, DFX, and a three-layer Qwen3-14B workload Co-authored-by: Crane-Liu <c.wliu@outlook.com>
HBG's per-dispatch wall is 96-99.6% host bind, and bind was dominated by rebuilding and H2D-uploading full, ring-sized host structures every run -- the shared-memory mirror and the ~20 MB prebuilt runtime arena -- even though a run touches a tiny fraction. The device boots scheduler-only and reads no SM slot past total_tasks, so the SM mirror is made init-on-write and shipped bounded to the task count, and the arena's host-only orchestrator block is dropped from the upload. Shared memory: - descriptors and payloads are written per task at submit; slot_states and completion_flags are reset per slot in orch::prepare_task as it is claimed, dropping the window-wide reset loop in init_header_per_ring; only the header is zeroed on the host; each segment is H2D-uploaded bounded to [0, total_tasks). Runtime arena: - skip uploading the orchestrator block (fanin_seen_epoch / scope / tensormap, ~8.5 MB): host-only dep-computation scratch the AICPU scheduler never reads. The scheduler block onward (ready queues, runtime header, mailbox) still ships whole. - drop the redundant per-entry stores in the tensormap reset (the preceding memset already zeroes the link pointers and producer_task_id). The ready queues are shipped in full, not bounded to total_tasks: graph execution (hw-native-sys#1444) replays a cached GRAPH task that the device Scheduler expands into on-device nodes, and those nodes push into the ready queues past the host task count, so every queue slot must carry a valid Vyukov sequence on the device. Every scheduler-read field is initialized at submit, so nothing depends on the removed blanket zeros; reads are bounded by current_task_index. total_tasks is range-checked before it sizes the SM copies. Single-ring (PTO2_MAX_RING_DEPTH == 1). Guardrails and tests: - push_ready_routed latches PTO2_ERROR_READY_QUEUE_OVERFLOW when a push finds no free slot (a genuinely full queue, or a task window / graph expansion past ready-queue capacity), turning a would-be silent drop and forward-progress stall into a named error. - bind_callable_to_runtime_impl always_asserts orch_start <= orch_end before slicing the orchestrator block out of the upload. - test_hbg_submit_poison fills the SM window with 0xAA, submits a representative mix (real mixed task with tensors + scalar, multi-fanin consumer, hidden-alloc, dummy) and asserts every device-read slot field is written, not left poison -- pinning the init-on-write contract so a future unwritten device-read field fails in-tree rather than non-deterministically on device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
…1659) HBG's per-dispatch wall is 96-99.6% host bind, and bind was dominated by rebuilding and H2D-uploading full, ring-sized host structures every run -- the shared-memory mirror and the ~20 MB prebuilt runtime arena -- even though a run touches a tiny fraction. The device boots scheduler-only and reads no SM slot past total_tasks, so the SM mirror is made init-on-write and shipped bounded to the task count, and the arena's host-only orchestrator block is dropped from the upload. Shared memory: - descriptors and payloads are written per task at submit; slot_states and completion_flags are reset per slot in orch::prepare_task as it is claimed, dropping the window-wide reset loop in init_header_per_ring; only the header is zeroed on the host; each segment is H2D-uploaded bounded to [0, total_tasks). Runtime arena: - skip uploading the orchestrator block (fanin_seen_epoch / scope / tensormap, ~8.5 MB): host-only dep-computation scratch the AICPU scheduler never reads. The scheduler block onward (ready queues, runtime header, mailbox) still ships whole. - drop the redundant per-entry stores in the tensormap reset (the preceding memset already zeroes the link pointers and producer_task_id). The ready queues are shipped in full, not bounded to total_tasks: graph execution (#1444) replays a cached GRAPH task that the device Scheduler expands into on-device nodes, and those nodes push into the ready queues past the host task count, so every queue slot must carry a valid Vyukov sequence on the device. Every scheduler-read field is initialized at submit, so nothing depends on the removed blanket zeros; reads are bounded by current_task_index. total_tasks is range-checked before it sizes the SM copies. Single-ring (PTO2_MAX_RING_DEPTH == 1). Guardrails and tests: - push_ready_routed latches PTO2_ERROR_READY_QUEUE_OVERFLOW when a push finds no free slot (a genuinely full queue, or a task window / graph expansion past ready-queue capacity), turning a would-be silent drop and forward-progress stall into a named error. - bind_callable_to_runtime_impl always_asserts orch_start <= orch_end before slicing the orchestrator block out of the upload. - test_hbg_submit_poison fills the SM window with 0xAA, submits a representative mix (real mixed task with tensors + scalar, multi-fanin consumer, hidden-alloc, dummy) and asserts every device-read slot field is written, not left poison -- pinning the init-on-write contract so a future unwritten device-read field fails in-tree rather than non-deterministically on device.
Summary
host_build_graphruntime as a composite incore task alongside AIC, AIV, MIX, and SPMD;L0TaskArgsboundary directly; there are no publicGraphArgs,GraphBindings,Patch, orScalarReftypes;GRAPHtask for each cache hit;Public API
The function pointer is the default Graph identity. Optional trailing integral, floating-point, or boolean construction parameters are forwarded to the function and hashed by value into the Definition key.
Callers may use an explicit
GRAPH_KEY, but that key must be unique for every distinct Graph function in an orchestration callable. The explicit-key overload deliberately excludes the Graph function pointer so the key remains stable.Boundary Tensor addresses may change between invocations, while Tensor metadata and aliasing must match the recorded Definition. Scalars used by internal tasks are fixed Definition data. Dynamic scalars and empty boundaries are not cached and execute through the ordinary submit path.
See
GRAPH_EXECUTION.mdfor the complete contract and Qwen wrapper.Core execution flow
MemoryAllocator, writes its fixed-width device address into the submission POD, and uploads the complete task image.graph_prepare_queueduring initial classification, while its external fanin follows the ordinary ready/wake path intograph_ready_queue.Prepared-successor pipelining may overlap preparation of run N+1 with device execution of run N. This PR intentionally does not overlap orchestration and scheduling within one run.
The per-run Definition cache contains at most 16 actual-size STL-backed entries. Execution storage is retained per
(pipeline slot, Graph key, occurrence index), grows only to observed demand, contributes tocommitted_device_memory(), and is released during Worker finalization. Distinct pipeline slots never share an active block, and repeated uses of one key within a run receive distinct blocks. There is no device-sidemalloc/posix_memalign, persistent cache schema, orPTO2_GRAPH_CACHE_SCHEMA_VERSION.Scope
host_build_graph;HostApi/runner gains only the tracked retained-buffer capability; other runtimes do not use it and keep their existing behavior;Graph Executionenvelope and boundedgraph_preparescheduler phases without a cross-clock Host-Orchestrator lane;upstream/main.Validation
main;clang-format,clang-tidy,cpplint, and markdownlint;80/80 passed, including empty-boundary and execution-storage layout regressions;host_build_graphsimulation:27 passed, 4 skipped, 185 deselected;3 passed, 1 manual hardware case deselected;npu-smicannot return Chip/NPU Name; PR onboard CI is the authoritative hardware validation.