Skip to content

[Performance] host_build_graph: activate graph nodes incrementally as they materialize - #1804

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
huawei-csl:hbg-graph-incremental-activation
Aug 14, 2026
Merged

[Performance] host_build_graph: activate graph nodes incrementally as they materialize#1804
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
huawei-csl:hbg-graph-incremental-activation

Conversation

@SergioMartin86

Copy link
Copy Markdown
Contributor

Human Summary

In HBG the task graph is sent to the device and fully expanded before computation starts. This PR pipelines the expansion and the computation so that AI cores can start doing useful work as soon as the first task is processed. This saves a bunch of initialization time.

AI Summary

The on-device Graph scheduler expanded a GRAPH task into all its nodes before dispatching any of them — graph_execution_materialize_slice reached PREPARED only after the last node, and activate_prepared_graph routed the roots only at that point. For a large graph the AICores idle through the entire expansion. On the qwen3-14B 3-layer decode this front-loaded ramp is ~530 µs per layer with every core waiting.

This routes roots (and any node whose producers have all completed) as soon as they materialize, once the outer GRAPH task's external-dependency gate has opened, so compute overlaps the remaining expansion.

How it's made safe — and what makes it possible

Dispatching a node before the whole graph is materialized means a producer can complete while a later consumer is still being registered — the exact hazard the PREPARED gate existed to prevent. This change is only possible because of the polling-completion scheduler: the per-slot completion_flags byte + task_state completion mirror introduced for tensormap_and_ringbuffer in #1137 and adapted to host_build_graph in #1435. That model lets a consumer answer "has this producer finished?" by reading a flag, so a producer completing mid-expansion is discovered, not missed. Under the older pure wake-list model this would have been unsafe — which is precisely why the whole-graph PREPARED gate existed. Three things close the hazard:

  • Flag-safe registration. Materialization-time registration moves from register_initial_graph_waiter (a raw wake-list store that assumed no producer could complete during expansion) to register_graph_wake, which resolves a producer that completed mid-expansion through its task_state completion flag (the [Optimization] Replace wiring with polling-based task readiness test (~17% median device speedup) #1137/Add: polling completion scheduler for host_build_graph (a2a3) #1435 mechanism) instead of losing the wake. This machinery already existed on the live wake path; this change simply routes materialization through it.
  • Topological order. The definition is validated to be topologically ordered (every producer index < consumer index), so every producer a materialized node references is already constructed. execution.nodes is published at MATERIALIZING (under materialize_busy) so the wake path can read producer slots during expansion.
  • Idempotent root routing. Roots reach the ready queue through route_cursor, a monotonic per-execution cursor that makes routing idempotent across the per-slice calls and the final call at the activation meet — each root is pushed exactly once. Non-roots reach the queue only through their producers' wake list, unchanged.

Testing

  • Correctness: the three graph_execution scene tests (2D replay, AIC+AIV, MIX-SPMD) and the qwen3-14B 3-layer graph-execution golden pass, including a 3-round replay. A 45-run repeat of the three graph-exec tests passes 45/45 (initial rapid-repeat failures were host-side halMemCtl MMIO contention from process churn — a different binary, before the scheduler runs — and vanish once process starts are spaced).

  • Performance (qwen3-14B 3-layer, a2a3 onboard, same-device A/B, 15 rounds):

    Avg device_wall
    baseline 3397.8 µs
    incremental 3247.1 µs
    delta −150.7 µs (−4.4%)

    The chip swimlane confirms compute begins overlapping graph expansion (cores busy at ~400 µs) instead of waiting for it (~600 µs baseline). The first-compute start is still bounded by the outer task's external-dependency latency, so the recovered time is the expansion tail, not an earlier start.

Scope

  • a2a3 + a5 host_build_graph, same commit (identical graph scheduler). a5 is compile-verified only — the dev box is a2a3 silicon.
  • Only affects the graph-execution path; non-graph dispatch is untouched.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 36049098-ad05-4813-88b0-1b2eee3c26ef

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The graph scheduler now publishes materialized nodes incrementally. Atomic counters track publication and routing progress. Materialization registers or routes nodes as they become available, while graph activation routes published roots after the external dependency gate opens.

Changes

Incremental graph activation

Layer / File(s) Summary
Execution publication state
src/a2a3/.../graph_execution.h, src/a5/.../graph_execution.h, src/*/.../scheduler/graph_execution.cpp
GraphExecution tracks published nodes and route progress. Reset clears both counters. Materialization publishes node storage earlier and no longer registers initial graph waiters.
Incremental node publication
src/a2a3/.../scheduler/pto_scheduler.h, src/a5/.../scheduler/pto_scheduler.h
The scheduler registers nodes on unmet producers, routes ready nodes, publishes materialized ranges, and routes eligible roots with an atomic cursor.
Activation integration
src/a2a3/.../scheduler/pto_scheduler.h, src/a5/.../scheduler/pto_scheduler.h
Graph preparation publishes new materialized slices during pending or prepared states. Prepared activation delegates root handling to cursor-based routing.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant prepare_graph_task
  participant graph_execution_materialize_slice
  participant graph_incremental_publish
  participant graph_route_ready_roots
  participant GraphExecution
  prepare_graph_task->>graph_execution_materialize_slice: materialize graph slice
  graph_execution_materialize_slice->>GraphExecution: publish execution.nodes
  prepare_graph_task->>graph_incremental_publish: publish new node range
  graph_incremental_publish->>GraphExecution: register or route nodes
  graph_route_ready_roots->>GraphExecution: claim published roots with route_cursor
Loading

Possibly related PRs

Poem

A rabbit watches nodes appear,
Each published slice hops near.
Roots wait until the gate swings wide,
Then cursors guide them side by side.
“Incremental hops!” the rabbit cheers.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% 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
Title check ✅ Passed The title clearly and concisely describes the main change: incremental activation of host_build_graph nodes during materialization.
Description check ✅ Passed The description directly explains the incremental activation change, safety mechanisms, testing, performance results, and scope.
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.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/a2a3/runtime/host_build_graph/runtime/scheduler/graph_execution.cpp`:
- Around line 365-371: In both
src/a2a3/runtime/host_build_graph/runtime/scheduler/graph_execution.cpp:365-371
and
src/a5/runtime/host_build_graph/runtime/scheduler/graph_execution.cpp:365-371,
update the materialization flow so execution.nodes is assigned only before the
first range is published; remove subsequent slice assignments and the final
reassignment before PREPARED. Preserve byte-for-byte parity between both files.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 264c2df8-6b8d-4833-ac36-a91a2c09e61b

📥 Commits

Reviewing files that changed from the base of the PR and between 50c0660 and 7963ea5.

📒 Files selected for processing (6)
  • src/a2a3/runtime/host_build_graph/runtime/graph_execution.h
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/graph_execution.cpp
  • src/a2a3/runtime/host_build_graph/runtime/scheduler/pto_scheduler.h
  • src/a5/runtime/host_build_graph/runtime/graph_execution.h
  • src/a5/runtime/host_build_graph/runtime/scheduler/graph_execution.cpp
  • src/a5/runtime/host_build_graph/runtime/scheduler/pto_scheduler.h

Comment thread src/a2a3/runtime/host_build_graph/runtime/scheduler/graph_execution.cpp Outdated
@SergioMartin86
SergioMartin86 force-pushed the hbg-graph-incremental-activation branch 2 times, most recently from 72f7d25 to 9643511 Compare August 12, 2026 09:02
@ChaoZheng109

Copy link
Copy Markdown
Collaborator

Review: incremental graph activation

Verdict: Approve. Clean, well-scoped performance change; the two safety-critical assumptions both hold at the code level, verified below. One non-blocking test suggestion.

What it does

Instead of fully materializing a GRAPH task (reaching PREPARED) before routing any node, this routes each root — and any node whose producers have all completed — as soon as it materializes, once the outer task's external gate (0x2) is open, so compute overlaps the remaining expansion. register_initial_graph_waiter (a raw wake-list store that assumed no producer could complete during expansion) is removed cleanly; materialization-time registration now goes through the live-path register_graph_wake. Two new monotonic per-execution cursors (published_nodes, route_cursor, both reset per submission) make root routing idempotent across the per-slice calls and the final activation-meet call.

The new hazard, and why it's safe

Dispatching a node before the whole graph is materialized opens a window the PREPARED gate previously closed: a producer can complete while a later consumer is still being registered. I verified both load-bearing assumptions that close it:

  1. Topological order (producer index < consumer index) — validated in bind_graph_topology at definition-load time (before any materialization): graph_execution.cpp:222 if (fanin_indices[edge] >= consumer) return false;. So every producer slot a materializing node references is already constructed, and reading its task_state during expansion is safe.
  2. Single-owner publish per graphprepare_slot comes from graph_prepare_queue.pop_tagged() (exclusive until push-back), and the slot is only re-queued after prepare_graph_task (and its graph_incremental_publish) returns (scheduler_dispatch.cpp:1329). So publish calls for one graph are strictly sequential and published_nodes cannot regress. (graph_route_ready_roots can still run concurrently from the owner and from activate_graph_task, but the route_cursor CAS guarantees each index is claimed exactly once — safe.)

The "producer completes before consumer registers" case is then handled by register_graph_wake: a SENTINEL head falls through to graph_first_unmet_producer, which re-reads task_state and routes the consumer instead of losing the wake — the #1137/#1435 polling-completion mechanism, exactly as the description claims.

Should-fix (non-blocking): a deterministic test for the new interleaving

The four graph_execution scene tests + qwen3 golden mostly exercise the linear path; the new "producer completes mid-expansion" interleaving is only hit probabilistically (the 45× repeat raises the odds but doesn't force it). A deterministic end-to-end repro is genuinely hard (device timing isn't controllable without a runtime test hook), so I wouldn't ask for that.

But a host-side cpput unit test is easy here and targets exactly the dangerous window — the fixtures in tests/ut/cpp/a2a3/test_scheduler_state.cpp already construct PTO2SchedulerState + PTO2TaskSlotState directly. Sketch:

// producer already completed and its wake list already drained:
producerA.task_state.store(PTO2_TASK_COMPLETED);
producerA.wake_list_head.store(WAKE_LIST_SENTINEL);
// consumer registers only now — the "completes before register" order:
sched.register_graph_wake(exec, &producerA, &consumerC);
// assert C is routed to the ready queue, not lost.

A companion case over graph_incremental_publish (some producers pre-set COMPLETED → assert those consumers route immediately, the rest wake-chain correctly) would nail down the mechanism without any hardware or timing dependence. This is the test that actually proves the safety argument rather than relying on it not being hit.

Minor notes

  • a5 side is byte-identical to a2a3 (verified) and compile-only; consistent with the a2a3-silicon dev box. Fine to note in the merge.
  • New comments are present-tense invariants; no new PTO2-prefixed identifiers introduced. pto_isa.pin unchanged and no pto-isa header refs touched — no pin bump needed.

@SergioMartin86
SergioMartin86 force-pushed the hbg-graph-incremental-activation branch from 9643511 to 062cd8b Compare August 13, 2026 09:57
@SergioMartin86

Copy link
Copy Markdown
Contributor Author

Review: incremental graph activation

Verdict: Approve. Clean, well-scoped performance change; the two safety-critical assumptions both hold at the code level, verified below. One non-blocking test suggestion.

What it does

Instead of fully materializing a GRAPH task (reaching PREPARED) before routing any node, this routes each root — and any node whose producers have all completed — as soon as it materializes, once the outer task's external gate (0x2) is open, so compute overlaps the remaining expansion. register_initial_graph_waiter (a raw wake-list store that assumed no producer could complete during expansion) is removed cleanly; materialization-time registration now goes through the live-path register_graph_wake. Two new monotonic per-execution cursors (published_nodes, route_cursor, both reset per submission) make root routing idempotent across the per-slice calls and the final activation-meet call.

The new hazard, and why it's safe

Dispatching a node before the whole graph is materialized opens a window the PREPARED gate previously closed: a producer can complete while a later consumer is still being registered. I verified both load-bearing assumptions that close it:

  1. Topological order (producer index < consumer index) — validated in bind_graph_topology at definition-load time (before any materialization): graph_execution.cpp:222 if (fanin_indices[edge] >= consumer) return false;. So every producer slot a materializing node references is already constructed, and reading its task_state during expansion is safe.
  2. Single-owner publish per graphprepare_slot comes from graph_prepare_queue.pop_tagged() (exclusive until push-back), and the slot is only re-queued after prepare_graph_task (and its graph_incremental_publish) returns (scheduler_dispatch.cpp:1329). So publish calls for one graph are strictly sequential and published_nodes cannot regress. (graph_route_ready_roots can still run concurrently from the owner and from activate_graph_task, but the route_cursor CAS guarantees each index is claimed exactly once — safe.)

The "producer completes before consumer registers" case is then handled by register_graph_wake: a SENTINEL head falls through to graph_first_unmet_producer, which re-reads task_state and routes the consumer instead of losing the wake — the #1137/#1435 polling-completion mechanism, exactly as the description claims.

Should-fix (non-blocking): a deterministic test for the new interleaving

The four graph_execution scene tests + qwen3 golden mostly exercise the linear path; the new "producer completes mid-expansion" interleaving is only hit probabilistically (the 45× repeat raises the odds but doesn't force it). A deterministic end-to-end repro is genuinely hard (device timing isn't controllable without a runtime test hook), so I wouldn't ask for that.

But a host-side cpput unit test is easy here and targets exactly the dangerous window — the fixtures in tests/ut/cpp/a2a3/test_scheduler_state.cpp already construct PTO2SchedulerState + PTO2TaskSlotState directly. Sketch:

// producer already completed and its wake list already drained:
producerA.task_state.store(PTO2_TASK_COMPLETED);
producerA.wake_list_head.store(WAKE_LIST_SENTINEL);
// consumer registers only now — the "completes before register" order:
sched.register_graph_wake(exec, &producerA, &consumerC);
// assert C is routed to the ready queue, not lost.

A companion case over graph_incremental_publish (some producers pre-set COMPLETED → assert those consumers route immediately, the rest wake-chain correctly) would nail down the mechanism without any hardware or timing dependence. This is the test that actually proves the safety argument rather than relying on it not being hit.

Minor notes

  • a5 side is byte-identical to a2a3 (verified) and compile-only; consistent with the a2a3-silicon dev box. Fine to note in the merge.
  • New comments are present-tense invariants; no new PTO2-prefixed identifiers introduced. pto_isa.pin unchanged and no pto-isa header refs touched — no pin bump needed.

Thanks for the thorough review — and especially for independently verifying both load-bearing assumptions (the bind_graph_topology topological-order check at graph_execution.cpp:222 and the single-owner publish via the prepare-queue). That's exactly the safety argument the change rests on.

I've added the deterministic host-side tests you suggested (amended into the commit). Two TEST_Fs that force the dangerous "producer completes before its consumer registers" interleaving without any hardware or timing dependence:

  • WakeRoutesConsumerWhenProducerCompletedBeforeRegister — producer pre-set COMPLETED with its wake list already at WAKE_LIST_SENTINEL; asserts register_graph_wake falls through to graph_first_unmet_producer, re-reads task_state, and routes the consumer to the ready queue rather than losing it on the closed list.
  • IncrementalPublishRoutesCompletedDepsAndWakeChainsPending — graph_incremental_publish over mixed producers: the consumer whose producers are all COMPLETED routes immediately at publish time; the one with a still-pending producer wake-chains and routes exactly once that producer completes and drains. This nails down the mechanism end-to-end, as you framed it.

One correction on placement: tests/ut/cpp/a2a3/test_scheduler_state.cpp compiles against the tensormap_and_ringbuffer PTO2SchedulerState, which has none of the graph-execution surface (GraphExecution / register_graph_wake / graph_incremental_publish). Those live only in host_build_graph, so the tests are in a new HBG test target, tests/ut/cpp/{a2a3,a5}/test_graph_activation.cpp (registered via add_a2a3_hbg_runtime_test / add_a5_hbg_runtime_test, so they run in ut-a2a3 / ut-a5). Mirrored byte-for-byte across a5, clang-format/clang-tidy clean, both pass on a2a3 and a5.

@ChaoZheng109

Copy link
Copy Markdown
Collaborator

st-onboard-a2a3 is red on qwen3-14B hbg across re-runs — a merge blocker, likely on the graph path this PR touches

This is blocking merge (the check won't go green), and it's worth pinning down before another re-run: the failure reproduces on the host_build_graph graph path this PR changes.

Attempt qwen3-14B hbg Note
1 PASS a2a3 failed only on an unrelated timing-flaky TMR test (WholeRunFifoTmr::test_incompatible_runtime_env_falls_back_to_depth_one); the whole L2 host_build_graph group passed.
2 FAIL sched_error_code=5 INVALID_ARGS (generation=4)
3 FAIL sched_error_code=5 INVALID_ARGS (generation=1)

The failure (attempts 2 & 3, identical):

examples/a2a3/host_build_graph/qwen3_14b_decode::TestQwen314BDecodeHostBuildGraph::test_run
  RuntimeError: finalize_native_run failed with code -5
  device 507018 -> orch_error_code=0  sched_error_code=5  INVALID_ARGS
    "an orchestration API rejected its arguments (... unknown task id in set_dependencies,
     illegal nested scope, or a CoreTaskArgs carrying an error flag)"

The 1-2 tests failing right after it on the same xdist worker are just collateral (chip run lane is poisoned, same run_id/slot/generation) — there's one real failure here, on qwen3-14B hbg.

Why I think it points at this change rather than a flake:

  • sched_error_code=5 INVALID_ARGS isn't a timeout/deadlock class — it's an orchestration-args rejection, i.e. a logic/ordering fault, not device contention.
  • Reproduced 2 of 3 attempts, and attempt 3 failed on generation=1 (first run on a fresh lane), so it's not a dirty-device artifact from a prior test — qwen3 trips it on a clean lane.
  • qwen3-14B is the largest graph (longest materialization tail); the small graph_execution scene tests pass as primaries. That fits the incremental-activation window — a node routed before its set_dependencies/scope setup is fully constructed — though I haven't proven that's the exact mechanism.
  • The two host-side UTs added here cover the wake-list re-scan path; they don't seem to cover this "routed while deps still incomplete" case.

Could you check whether this is introduced by the incremental activation or already present on main? An A/B of the same qwen3-14B hbg run on this branch vs. the merge-base would settle it. If it's from this change, best fixed here before merge; if it's pre-existing, good to know so we can track it separately.

The on-device Graph scheduler expanded a GRAPH task into all its nodes before
dispatching any of them: graph_execution_materialize_slice reached PREPARED only
after the last node, and activate_prepared_graph routed the roots only at that
point. For a large graph the AICores idle through the whole expansion — on the
qwen3-14B 3-layer decode that front-loaded ramp is ~530 us per layer during
which every core waits.

Route roots (and any node whose producers have all completed) as soon as they
materialize, once the outer Graph task's external-dependency gate has opened,
so compute overlaps the remaining expansion.

This is possible only because of the polling-completion scheduler: the per-slot
completion_flags byte + task_state completion mirror introduced for
tensormap_and_ringbuffer in hw-native-sys#1137 and adapted to host_build_graph in hw-native-sys#1435. That
flag lets a consumer discover a producer that completed mid-expansion by reading
its state instead of relying solely on the producer's wake list. Under the older
pure wake-list model, dispatching before full materialization would have lost
wakes if a producer finished before its consumer registered — which is exactly
why the whole-graph PREPARED gate existed.

- Materialization-time registration moves from register_initial_graph_waiter (a
  raw wake-list store that assumed no producer could complete during expansion)
  to register_graph_wake, which resolves a producer that completed mid-expansion
  through its task_state completion flag (the hw-native-sys#1137 / hw-native-sys#1435 mechanism) instead of
  losing the wake. The graph's topological node order guarantees every producer a
  node references is already constructed.
- execution.nodes is published at MATERIALIZING (under materialize_busy) so the
  wake path can read producer slots while the graph is still expanding.
- Roots reach the ready queue through route_cursor, a monotonic per-execution
  cursor that makes routing idempotent across the per-slice calls and the final
  call at the activation meet, so each root is pushed exactly once. Non-roots
  reach the queue only through their producers' wake list, unchanged.

Because a node now dispatches before the graph reaches ACTIVE, it can complete
while the graph is still MATERIALIZING or PREPARED. hw-native-sys#1767's complete_task rejects
a node completion unless the graph is ACTIVE; that guard now accepts MATERIALIZING
and PREPARED as well, rejecting only SUBMITTED (execution not yet localized) and
COMPLETED (already retired). Without this, the largest graphs (longest
materialization tail) fault with sched_error_code=5 INVALID_ARGS while the small
graph_execution scene tests, which reach ACTIVE before any node completes, pass.

Correctness: the three graph_execution scene tests (2D replay, AIC+AIV,
MIX-SPMD) and the qwen3-14B 3-layer graph-execution golden pass, and the qwen3-14B
40-layer host-graph decode (the largest graph) passes 6/6 where it faulted 0/6
without the completion-state fix. Three host-side unit tests
(tests/ut/cpp/{a2a3,a5}/test_graph_activation.cpp) cover the interleavings
deterministically: a consumer registered after its producer completed and drained
routes through the task_state re-scan; graph_incremental_publish routes
all-producers-complete consumers and wake-chains the rest; and complete_task
accepts a node completion in MATERIALIZING/PREPARED/ACTIVE and rejects it in
SUBMITTED/COMPLETED.

Applies the identical change to the a5 host_build_graph sibling (same graph
scheduler; a5 is compile-verified, not perf-measured on this a2a3 box).
@SergioMartin86
SergioMartin86 force-pushed the hbg-graph-incremental-activation branch from 062cd8b to 2bd0c9b Compare August 13, 2026 13:08
@ChaoWao
ChaoWao merged commit 14703ce into hw-native-sys:main Aug 14, 2026
19 checks passed
@ChaoWao
ChaoWao deleted the hbg-graph-incremental-activation branch August 14, 2026 12:39
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.

3 participants