Skip to content

Add Graph Execution to host_build_graph - #1444

Open
TaoZQY wants to merge 1 commit into
hw-native-sys:mainfrom
Crane-Liu:feat/graph-execution
Open

Add Graph Execution to host_build_graph#1444
TaoZQY wants to merge 1 commit into
hw-native-sys:mainfrom
Crane-Liu:feat/graph-execution

Conversation

@TaoZQY

@TaoZQY TaoZQY commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add Graph Execution to the a2a3 host_build_graph runtime;
  • record a repeated C/V/MIX/Dummy task DAG once, including topological order,
    fanin/fanout, roots, argument sources, and static scalars;
  • submit one GRAPH task from the host Orchestrator on a cache hit, then let
    the device Scheduler materialize and execute the saved topology;
  • carry dynamic tensors and scalars through the existing L2TaskArgs API,
    without public GraphBindings, Patch, or GraphArgs types;
  • upload only the compact definition plus current args, and reuse bounded host
    submission and graph-affine AICPU execution pools;
  • expose Host Orchestrator, Graph Execution, and graph_prepare DFX lanes;
  • add simulation coverage, converter unit tests, API documentation, and a
    Qwen3-14B-style upstream integration example.

Execution flow

  1. First structurally compatible invocation: execute the Graph function through
    ordinary task submits and save the complete DAG definition.
  2. Later invocation: the host submits one outer GRAPH descriptor containing a
    compact definition and the current L2TaskArgs values.
  3. The Scheduler acquires an AICPU-local execution block, prepares four nodes
    per scheduling slice, activates the saved roots, and releases saved fanout
    edges as nodes complete.
  4. The outer Graph task completes only after all internal nodes retire, so its
    downstream dependencies retain normal task semantics.

Upstream API: Qwen decoder layer

void qwen_decoder_layer(const L2TaskArgs &args) {
    const Tensor &hidden = args.tensor(0).ref();
    const Tensor &weight = args.tensor(1).ref();
    const Tensor &output = args.tensor(2).ref();

    L0TaskArgs task_args;
    task_args.add_input(hidden, weight);
    task_args.add_output(output);
    task_args.add_scalar(args.scalar(0));  // dynamic layer_id
    task_args.add_scalar(args.scalar(1));  // dynamic token_position
    task_args.add_scalar(uint32_t{16});    // static Graph-definition value
    rt_submit_aic_task(FUNC_MATMUL, task_args);
}

void submit_qwen_decoder_layer(const L2TaskArgs &args) {
    rt_submit_graph(&qwen_decoder_layer, args);
}

The caller packages hidden state, weights, output, layer_id, and
token_position into L2TaskArgs; the wrapper does not repeat them in its
signature. The function pointer is the Graph ID for structurally identical
layers. Tensor addresses and scalar values may change; tensor shape, dtype,
stride, size, direction, and task topology must remain compatible. See
GRAPH_EXECUTION.md
for the complete three-layer Qwen-style example and current limitations.

Scope

  • exactly one commit on top of upstream/main;
  • Graph Execution behavior is confined to a2a3 host_build_graph;
  • shared platform/converter changes carry the new DFX records;
  • tensormap_and_ringbuffer changes are API-plumbing compatibility and two
    predicate-field build fixes, not Graph Execution enablement.

Testing

  • all pre-commit hooks on changed files;
  • python simpler_setup/build_runtimes.py --platforms a2a3sim a5sim;
  • Graph Execution on a2a3sim: 1 passed;
  • swimlane converter unit tests: 24 passed;
  • full a2a3 host_build_graph simulation: 12 passed, 1 deselected;
  • Qwen3-14B three-layer Graph Execution onboard run;
  • generated Perfetto trace contains cache-miss task_submit, cache-hit
    graph_submit, Scheduler graph_prepare, and Graph Execution lanes.
image

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds host-side graph capture and replay for host_build_graph, including cache-key generation, graph execution storage, device upload, graph-aware scheduling, scalar provenance, documentation, and an end-to-end record/replay test.

Changes

Graph Execution Runtime

Layer / File(s) Summary
Graph contracts and submission API
src/a2a3/runtime/host_build_graph/runtime/pto_graph_cache.h, src/a2a3/runtime/host_build_graph/runtime/pto_types.h, src/a2a3/runtime/host_build_graph/orchestration/pto_orchestration_api.h, src/a2a3/runtime/host_build_graph/runtime/pto_runtime2*.h, src/a2a3/runtime/host_build_graph/runtime/pto_orchestrator.h
Adds cache keys, cacheability checks, scalar provenance, graph task metadata, runtime graph callbacks, and rt_submit_graph wrappers.
Graph capture and replay construction
src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp
Records task topology and tensor/scalar sources, builds cached definitions, submits replay executions, and materializes graph nodes.
Execution storage and device upload
src/a2a3/runtime/host_build_graph/runtime/pto_graph_execution.*, src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp
Adds pooled execution images, lifecycle state transitions, pointer relocation, pending graph uploads, and device buffer ownership.
Graph scheduling and completion
src/a2a3/runtime/host_build_graph/runtime/scheduler/*, src/a2a3/runtime/host_build_graph/runtime/shared/pto_runtime2_init.cpp
Adds graph queues, sliced preparation and activation, graph-node fanout handling, and stream-aware completion accounting.
Graph execution test and documentation
src/a2a3/runtime/host_build_graph/docs/GRAPH_EXECUTION.md, tests/st/a2a3/host_build_graph/graph_execution/*
Documents graph capture/replay semantics and adds an end-to-end orchestration test with dynamic and fixed scalars.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Poem

A bunny hops through cached graphs,
With tensor trails and scalar tags.
Roots wake up, then nodes race,
Uploading dreams to device space.
The DAG remembers every way—
And replays hops another day.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding Graph Execution to host_build_graph.
Description check ✅ Passed The description is clearly aligned with the changeset and explains the Graph Execution workflow, scope, and testing.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@TaoZQY
TaoZQY force-pushed the feat/graph-execution branch 2 times, most recently from fd8fc05 to 66f183e Compare July 22, 2026 12:36
- Cache repeated task DAGs with saved topology and dynamic TaskArgs sources
- Submit one Graph task on cache hits and expand nodes in the device Scheduler
- Reuse compact host submissions and graph-affine AICPU execution blocks
- Add Graph/Host-Orchestrator DFX lanes, simulation coverage, and Qwen API docs
- Fix predicate-field compatibility in a2a3/a5 runtime builds

Co-authored-by: Crane-Liu <c.wliu@outlook.com>
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Jul 26, 2026
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>
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Jul 26, 2026
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>
ChaoWao added a commit that referenced this pull request Jul 26, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant