Skip to content

Reduce redundant host_build_graph fanin edges - #2067

Open
ChaoZheng109 wants to merge 1 commit into
hw-native-sys:mainfrom
ChaoZheng109:feat/hbg-fanin-transitive-reduction
Open

Reduce redundant host_build_graph fanin edges#2067
ChaoZheng109 wants to merge 1 commit into
hw-native-sys:mainfrom
ChaoZheng109:feat/hbg-fanin-transitive-reduction

Conversation

@ChaoZheng109

Copy link
Copy Markdown
Collaborator

Summary

A fanin edge P -> C in host_build_graph carries readiness only. When another
producer Q of C already reaches P, the chain P -> ... -> Q -> C orders
C behind P on its own and the direct edge decides nothing — yet the device
still scans it in classify_fanin_state and still moves C between wake lists
for it. Both paths that build an edge now drop those edges.

This is the host_build_graph counterpart of #2009, which does the same for
tensormap_and_ringbuffer. hbg reaches further for two reasons: it builds the
whole graph on the host before the device starts, so it sees redundancy a ring
runtime never gets to observe (a producer tmr had already reclaimed builds no
edge at all); and it reclaims nothing, so an edge is pure readiness and can be
dropped outright rather than demoted to a lifetime-only edge.

Two paths, two resolutions

global submit path recorded Graph body
runs per submit_task once per body, in record_node
edges live in the payload's fanin region the Definition's fanin CSR
ancestors one 64-bit word (FANIN_REACH_WINDOW) exact closure, no window
storage 8 B/slot, 128 KiB at the default table 128 B/task, 128 KiB per recorder thread

The global path publishes one ancestor word per task, indexed by task local id.
A submit folds its producers' words — each shifted by that producer's distance,
which is distance addition — into its own, then drops every producer the fold
covers. Two properties keep that to a single word of state: a task id is its
slot index, handed out by a forward-only bump allocator and never reclaimed, so
it doubles as the global submission order and a distance is a subtraction; and
a producer's word, published by its own submit, is never rewritten, so reading
it needs no proof that the slot still holds the task that wrote it. That is the
whole of what #2009 needs a (bitmap, seq) entry and a pin protocol for.

A recorded body carries the exact closure instead of a window, because a
body is capped at MAX_IN_GRAPH_TASKS = 1024 (so a row is a fixed 128 B) and a
Graph is recorded once against however many replays read the shortened CSR. A
producer arbitrarily far back in the body is reduced too.

The two compose without either knowing about the other: a body is ordered
against everything before the Graph by the outer Graph task's own fanin, and a
producer outside the recording window contributes no in-body edge at all.

Because each ancestor row is already a closure, a chain of any length collapses
in one pass rather than one hop at a time.

What is preserved

Reachability. Every dropped producer is reached from another producer with a
strictly larger id, so following the cover relation up terminates at one nothing
covers. A task with producers therefore always keeps an edge, and both paths
assert it — emptying a fanin would make the device treat the task as a root and
dispatch it against unfinished producers, a data race rather than a hang.

Retention. Reduction rewrites readiness only. A global producer's buffer
lifetime rides last_consumer_local_id, raised when the edge was appended and
never lowered here, so a producer whose edge is dropped still waits for that
consumer to retire before the host may overwrite it. A body's buffers come out
of the Graph's own heap and are released when the Graph completes, never per
task, so a dropped in-body edge holds no lifetime either.

Conservatism at the window edge. A global producer further back than
FANIN_REACH_WINDOW keeps its edge and contributes no bit — it and all its
ancestors are unrepresentable in one word.

Observability

A SIMPLER_DFX build reports the edges built and dropped once per
orchestration; each Definition logs its shipped and reduced edge counts at DEBUG
as it is laid out. On the a2a3sim graph_execution scene a 5-task body ships 5
internal edges with 2 reduced away.

Testing

  • test_hbg_fanin_reduction, 12 cases per arch (a2a3 + a5): diamond,
    multi-hop chain, independent producers kept, chained coverage still
    leaving one edge, both sides of the window boundary, order-preserving
    compaction, dropped-edge reclaim gate; and for recorded bodies the CSR
    diamond, a chain longer than FANIN_REACH_WINDOW still reduced,
    independent body producers, and body roots left alone
  • each path stubbed out in turn to confirm the assertions fail without it
    (6/8 and 2/4 respectively; the conservative cases stay green, as they
    should)
  • full C++ unit suite: 124/126. test_hbg_graph_submit_failure and its a5
    twin fail 3 cases each on a clean upstream/main with the same build —
    pre-existing, unrelated to fanin
  • a2a3sim host_build_graph: 10 passed / 7 skipped; a5sim: 7 passed
  • onboard a2a3 qwen3-14B decode: passed
  • onboard a2a3 DeepSeek-V4 FLASH decode (2 dies): passed — this is the
    Graph-recording path
  • Performance A/B — not yet measured; the window width is deliberately left
    at 64 for this PR

Follow-up

Widening FANIN_REACH_WINDOW past one word is a constant-size change (the fold
becomes multiword) and now only affects tasks outside a Graph, since bodies
already carry an exact closure. Worth revisiting once an A/B says the extra
removals repay the per-submit work.

A fanin edge P -> C carries readiness only. When another producer Q of C
already reaches P, the chain P -> ... -> Q -> C orders C behind P on its
own and the direct edge decides nothing: the device still scans it in
classify_fanin_state and still moves C between wake lists for it. Both
paths that build an edge now drop such edges, at the resolution each can
afford.

The global submit path publishes one 64-bit ancestor word per task,
indexed by task local id. A submit folds its producers' words -- each
shifted by that producer's distance, which is distance addition -- into
its own, then drops every producer the fold covers. Two host_build_graph
properties keep this to a single word of state: a task id is its slot
index, handed out by a forward-only bump allocator and never reclaimed,
so it doubles as the global submission order and a distance is a
subtraction; and a producer's word, published by its own submit, is never
rewritten, so reading it needs no proof that the slot still holds the
task that wrote it. FANIN_REACH_WINDOW bounds the proof at 64 ids, and a
producer further back keeps its edge.

A recorded Graph body reduces into the Definition's own fanin CSR, and
carries an exact closure rather than a window: a body is capped at
MAX_IN_GRAPH_TASKS, so a row is a fixed 128 B and the array 128 KiB of
recorder scratch, and a Graph is recorded once against however many
replays read the shortened CSR. A producer arbitrarily far back in the
body is therefore reduced too. The two compose without either knowing
about the other -- a body is ordered against everything before the Graph
by the outer Graph task's own fanin, and a producer outside the recording
window contributes no in-body edge at all.

Because each ancestor row is already a closure, a chain of any length
collapses in one pass rather than one hop at a time.

Reduction rewrites readiness only. A global producer's buffer lifetime
rides last_consumer_local_id, raised when the edge was appended and never
lowered here, so a producer whose edge is dropped still waits for that
consumer to retire before the host may overwrite it. A body's buffers come
out of the Graph's own heap and are released when the Graph completes,
never per task, so a dropped in-body edge holds no lifetime either.

Every dropped producer is reached from another producer with a strictly
larger id, so following the cover relation up terminates at one nothing
covers: a task with producers always keeps an edge. Both paths assert
that, because emptying a fanin would make the device treat the task as a
root and dispatch it against unfinished producers -- a data race rather
than a hang.

A SIMPLER_DFX build reports the edges built and dropped once per
orchestration; each Definition logs its shipped and reduced edge counts at
DEBUG as it is laid out.
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 66b635ca-1dca-4452-b2a8-5274c6409fbe

📥 Commits

Reviewing files that changed from the base of the PR and between a64147b and 281d25a.

📒 Files selected for processing (8)
  • src/a2a3/runtime/host_build_graph/docs/RUNTIME_LOGIC.md
  • src/a5/runtime/host_build_graph/docs/RUNTIME_LOGIC.md
  • src/common/host_build_graph/orchestrator.h
  • src/common/host_build_graph/runtime_types.h
  • src/common/host_build_graph/shared/orchestrator.cpp
  • src/common/host_build_graph/shared/runtime_init.cpp
  • tests/ut/cpp/CMakeLists.txt
  • tests/ut/cpp/common/test_hbg_fanin_reduction.cpp

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The runtime now reduces redundant fanin edges for global tasks and recorded Graph tasks. It adds bounded and exact reachability storage, preserves consumer retention gates, reports reduction statistics, updates documentation, and adds unit tests for both architectures.

Changes

Fanin transitive reduction

Layer / File(s) Summary
Reachability storage and configuration
src/common/host_build_graph/runtime_types.h, src/common/host_build_graph/orchestrator.h, src/common/host_build_graph/shared/runtime_init.cpp
Adds FANIN_REACH_WINDOW, per-task fanin_reach storage, initialization checks, and SIMPLER_DFX fanin counters.
Global submit reduction
src/common/host_build_graph/shared/orchestrator.cpp
Reduces fanin for ordinary tasks and outer Graph tasks before publishing counts. The reduction preserves append order and updates ancestor reachability.
Recorded Graph reduction
src/common/host_build_graph/shared/orchestrator.cpp
Adds exact per-task closure storage for recorded Graph bodies. Reduces internal fanin before Definition CSR materialization and logs shipped and reduced edges.
Validation and runtime documentation
tests/ut/cpp/CMakeLists.txt, tests/ut/cpp/common/test_hbg_fanin_reduction.cpp, src/a2a3/runtime/host_build_graph/docs/RUNTIME_LOGIC.md, src/a5/runtime/host_build_graph/docs/RUNTIME_LOGIC.md
Adds tests for diamonds, chains, independent producers, window boundaries, retention gates, ordering, and recorded Graph CSR output. Documents both reduction paths and updates the TensorMap size estimate.

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

Merge Risk: ⚪ Minimal · up to 281d2

The PR removes redundant dependency edges while preserving task ordering and buffer-lifetime behavior for ordinary submissions and recorded Graphs. No actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant TaskSubmit
  participant Orchestrator
  participant FaninReach
  participant SharedMemory
  TaskSubmit->>Orchestrator: submit task with producer list
  Orchestrator->>FaninReach: read producer ancestor words
  Orchestrator->>Orchestrator: remove covered producers
  Orchestrator->>FaninReach: publish task ancestor word
  Orchestrator->>SharedMemory: publish reduced fanin region
Loading

Poem

A rabbit trims edges with care

Chains grow lighter in the air
Ancestors fit in words so neat
Graphs record the paths complete
Tests watch each hop and gate
Clean fanin arrives on schedule, straight

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.42% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 5 files. (3 skipped: … 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 summarizes the primary change: reducing redundant fanin edges in host_build_graph.
Description check ✅ Passed The description directly explains the fanin reduction changes, affected paths, preserved behavior, observability, and testing status.
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 68.42% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 5 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI

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.

@ChaoZheng109

Copy link
Copy Markdown
Collaborator Author

Measured on a2a3: qwen3-14B decode and DeepSeek-V4 FLASH decode

Fills in the Performance A/B item left unchecked in the description. Both
workloads are the in-repo examples/a2a3/host_build_graph/ cases, measured
against merge-base a64147b7 with pto-isa pinned at be5ccb76 on both arms.

Edge removal (static, noise-free)

Counted from this PR's own DFX output — fanin edges: N built, M reduced per
orchestration and Definition key=...: T tasks, E edges shipped, R reduced away
per Definition.

qwen3-14B decode

path edges built removed kept removed
global submit (47 tasks) 204 158 46 77.5%
Graph body (1 Definition, 277 tasks) 583 0 583 0%

DeepSeek-V4 FLASH decode (per rank, 2 ranks)

path edges built removed kept removed
global submit (129 tasks) 414 215 199 51.9%
Graph body (8 Definitions) 4,922 2,352 2,570 47.8%

Per-Definition for dsv4: 877→456 (×3), 876→456 (×2), 164→78, 169→98, 206→114.

The two workloads have opposite shapes, which is the useful part:

  • qwen3's body is one regular 277-task Definition with zero internal
    redundancy, but its 47 global tasks — the Graph shells and the init tasks —
    carry 77.5% redundant edges. Only the global path reaches those.
  • dsv4 is redundant on both. Its body's mean fanin per task drops from
    3.0 to 1.57, and the shipped Definition image loses ~9.4 KB per rank.

For scale: #2009 measured 40 redundant edges total on qwen3 under TMR, of which
BL64 removed 1. Building the whole graph on the host before the device starts is
what exposes the rest — a producer TMR had already reclaimed never forms an edge
to begin with.

End-to-end latency (dynamic)

Neutral on both workloads. 6 independent samples per arm, 20 rounds each,
both arms on the same die inside one task-submit allocation, ABBA-interleaved
(base head head base ×3) after one discarded warm-up per arm so any monotonic
drift cancels between arms.

qwen3-14B decode

metric base head delta change
Host 40304.1 ± 263.8 40207.4 ± 247.0 −96.7 us −0.24%
Device 38961.9 ± 178.2 38982.4 ± 100.9 +20.5 us +0.05%

DeepSeek-V4 FLASH decode

metric base head delta change
Host 42150.9 ± 355.5 42249.8 ± 242.7 +98.8 us +0.23%
Device 40445.6 ± 328.9 40601.9 ± 139.5 +156.3 us +0.39%

Every delta sits inside the run-to-run band: Welch t is 0.24σ for qwen3
Device and 1.07σ (p≈0.32) for dsv4 Device. The signs also disagree across
workloads — qwen3 Host improves while its Device regresses, dsv4 does neither —
which a real effect would not do.

Stated plainly: dsv4's Device row leans slow by 156 us. It is not
significant, but it is not excluded either. Separating a delta that size from
this variance needs roughly 40 samples per arm rather than 6 (~2 h of machine
time); say the word if that is worth doing before merge.

A first pass ran the four arms strictly serially (base→head→base→head) and
reported +0.74% Device on both workloads. That ordering puts both head arms
after their base, so thermal drift biases one direction systematically — the
numbers above replace it, and the interleaved design is why.

Why large static removal and neutral latency are consistent

Reduction preserves reachability exactly, so no task's earliest start time
moves: the schedule executed is identical, instruction for instruction. What it
can change is only the cost of maintaining that schedule — shorter
classify_fanin_state scans and fewer wake-list transfers. On these two
workloads the Device window is 39–40 ms of overwhelmingly AICore compute, and
that bookkeeping is not on its critical path.

So the 48–78% of edges removed is real, and the work removed with them was
already off the critical path here. This matches what #2009 found on the TMR
side, for the same structural reason.

What the measurement does establish is that the reduction costs nothing to
run
: hbg orchestrates on the host, so the fold is paid in the Host column,
and that column moved −0.24% / +0.23% — inside noise on both workloads. The
per-submit word fold and the once-per-body closure are both free at these
graph sizes.

The payoff scales with fanin density and with how much of the device window is
scheduling rather than compute. Neither is true of these two cases; a denser,
compute-lighter graph is where it would show.

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