Skip to content

Optimize: pack HBG Graph replay data into one arena block - #1923

Closed
sunkaixuan2018 wants to merge 3 commits into
hw-native-sys:mainfrom
sunkaixuan2018:codex/pr-b-graph-block-20260820
Closed

sunkaixuan2018 wants to merge 3 commits into
hw-native-sys:mainfrom
sunkaixuan2018:codex/pr-b-graph-block-20260820

Conversation

@sunkaixuan2018

Copy link
Copy Markdown
Contributor

Summary

Reduce host-side allocation and H2D-copy overhead for host-built Graph replay data.

  • Carry replay boundary tensors and scalars in the outer GRAPH task payload, leaving GraphSubmission as a fixed-size Definition reference plus device replay state.
  • Compute and validate the complete [Definitions][Submissions] layout, including every size, alignment, and offset, before acquiring device storage.
  • Store one grow-only GraphBlock on the arena bank selected for the run. Reuse its allocation when capacity is sufficient and grow it only when required; the block is not Worker-owned.
  • Address all internal objects as GraphBlock base + offset and overwrite the complete live range with exactly one H2D copy on every run.
  • Release or abandon the GraphBlock with its arena bank on normal or fatal teardown for both onboard and simulator runners.

This deliberately does not add content-based copy skipping, Task Window packing, queue sizing, heap sizing, or environment-variable-driven allocation.

Motivation

Host rtMalloc is expensive, and one large rtMemcpy is materially cheaper than many small copies. The previous path retained each Definition separately and allocated/uploaded every submission independently. Qwen3 performs 40 Graph replays, producing 41 allocation/H2D operations per run.

The new path computes the whole GraphBlock first, performs at most one allocation/grow operation for the selected arena bank, and then performs one H2D upload.

Performance

Measured with GraphExecutionBatch16Seq3500 from the Qwen3-14B decode HBG example on A2A3. Baseline and PR were rebuilt from adjacent runs against current main (93adc386).

Metric main this PR Change
Graph upload operations 41 1 -40 calls
Graph upload bytes 232,312 B 131,832 B -43.25%
graph_upload host span 1,381.044 us 243.623 us -82.36%
Device wall 39.920 ms 39.925 ms no regression observed

The direct graph_upload span is the relevant result; end-to-end device execution is expected to remain unchanged because this PR only changes host preparation and upload.

Validation

  • Rebased cleanly onto current main (93adc386).
  • Rebuilt A2A3 and A5 runtime/bindings after the rebase.
  • C++ no-hardware suite: 107/107 passed.
  • Graph execution simulator coverage passed on both A2A3 and A5:
    • record_then_replay_1d
    • record_then_replay_2d (multiple Definitions and submissions)
  • A2A3 Qwen3 GraphExecutionBatch16Seq3500 correctness and profiling passed.
  • A three-round A2A3 Qwen3 run passed and covered arena-bank rotation followed by reuse of an already provisioned bank.

A5 onboard hardware was not available on the A2A3 validation host; A5 compilation, C++ tests, and A5 simulator coverage passed.

@coderabbitai

coderabbitai Bot commented Aug 20, 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: c4594467-455b-42d3-a9ae-d1c4cae1a150

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

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: 5c1fb59d-fe16-43cb-a1cc-608c625fefcc

📥 Commits

Reviewing files that changed from the base of the PR and between 93adc38 and 2a1a0f2.

📒 Files selected for processing (18)
  • src/a2a3/platform/sim/host/device_runner.cpp
  • src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp
  • src/a2a3/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp
  • src/a5/platform/sim/host/device_runner.cpp
  • src/a5/runtime/host_build_graph/host/runtime_maker.cpp
  • src/a5/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp
  • src/common/host_build_graph/docs/GRAPH_EXECUTION.md
  • src/common/host_build_graph/graph_execution.cpp
  • src/common/host_build_graph/graph_execution.h
  • src/common/platform/include/common/host_api.h
  • src/common/platform/onboard/host/c_api_shared.cpp
  • src/common/platform/onboard/host/device_runner_base.cpp
  • src/common/platform/onboard/host/device_runner_base.h
  • src/common/platform/sim/host/c_api_shared.cpp
  • src/common/platform/sim/host/device_runner_base.cpp
  • src/common/platform/sim/host/device_runner_base.h
  • tests/ut/cpp/common/test_hbg_graph_cache.cpp
  • tests/ut/cpp/common/test_host_api.cpp

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


📝 Walkthrough

Walkthrough

Graph submissions now contain only graph metadata. Boundary arguments reside in outer task payloads. Definitions and submissions are packed into one arena-bank GraphBlock and uploaded in one operation. Host APIs and runner cleanup now manage reusable per-bank GraphBlocks.

Changes

GraphBlock submission migration

Layer / File(s) Summary
Submission contract and boundary payload
src/common/host_build_graph/graph_execution.*, src/*/runtime/host_build_graph/runtime/orchestrator_core/pto_orchestrator.cpp, tests/ut/cpp/common/test_hbg_graph_cache.cpp
GraphSubmission now uses a fixed-size metadata header. Outer task payloads carry boundary tensors and scalars. Replay localization and tests use the new payload path.
Packed GraphBlock upload
src/*/runtime/host_build_graph/host/runtime_maker.cpp, src/common/host_build_graph/docs/GRAPH_EXECUTION.md
Definitions and submissions are validated, aligned, packed into one GraphBlock, and uploaded with one device copy.
Arena-bank GraphBlock storage
src/common/platform/include/common/host_api.h, src/common/platform/{onboard,sim}/host/*, src/*/platform/sim/host/device_runner.cpp
Host API callbacks now acquire GraphBlocks by arena bank. Runner implementations reuse, reallocate, and release per-bank GraphBlock storage.
Host API and concurrency validation
tests/ut/cpp/common/test_host_api.cpp
Tests register the new operation and verify arena-bank forwarding across concurrent runners.

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

Merge Risk: 🔵 Low · up to 2a1a0

The PR changes graph replay preparation to use one packed upload. It is mergeable with owner awareness that the packed submissions retain borrowed data pointers across the preparation loop; if that storage is temporary, uploads could read invalid graph data.

Sequence Diagram(s)

sequenceDiagram
  participant graph_submit_outer
  participant upload_graph_block
  participant HostApi
  participant DeviceRunnerBase
  participant GraphExecution
  graph_submit_outer->>graph_submit_outer: write boundary tensors and scalars to outer payload
  upload_graph_block->>HostApi: acquire_graph_block(arena_bank, bytes, alignment)
  HostApi->>DeviceRunnerBase: acquire graph-block storage
  upload_graph_block->>DeviceRunnerBase: upload packed definitions and submissions
  GraphExecution->>graph_submit_outer: read boundary arguments from outer payload
  GraphExecution->>GraphExecution: localize and materialize graph execution
Loading

Poem

Poem

I’m a rabbit with a GraphBlock bright,
Packing definitions neat and tight.
Tensors hop to the payload queue,
One upload carries the whole crew.
Arena banks now cleanly rest—
A tidy burrow passed the test.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.91% 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
Description check ✅ Passed The description clearly explains the GraphBlock packing optimization, payload changes, performance results, teardown behavior, and validation.
Title check ✅ Passed The title concisely and accurately summarizes the main change: packing HBG Graph replay data into one arena block.
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.

@ChaoWao ChaoWao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The GraphBlock half of this looks right to me, and it goes further than the batching
I had staged locally — one grow-only block on the arena bank with base + offset
addressing and reuse-when-large-enough is the shape the "one rtMalloc, one
rtMemcpy" constraint asks for, where mine only collapsed the per-submission
allocations into a single fresh one per run. I'll drop mine.

One problem in the other half, though: graph_submit_outer writes the boundary
args into payload.tensors / payload.scalars with no bound check, and those arrays
are four times smaller than what GraphTaskArgs can legally hold.

// pto_orchestrator.cpp, this PR
payload.tensor_count = args.tensor_count();
for (int32_t i = 0; i < args.tensor_count(); ++i)
    payload.tensors[i] = args.tensor(i).ref();
std::memcpy(payload.scalars, args.scalar_data(), args.scalar_count() * sizeof(uint64_t));
capacity where
GraphTaskArgs (the source) 128 tensors / 64 scalars pto_types.h:689-690
PTO2TaskPayload.tensors / .scalars (the destination) 32 / 16 CORE_MAX_TENSOR_ARGS / CORE_MAX_SCALAR_ARGS

The only cap on the path in between is a debug_assert(args.tensor_count() <= GRAPH_MAX_TENSOR_ARGS) in rt_submit_graph_impl, i.e. 128.

examples/a2a3/host_build_graph/deepseek_v4_flash_decode exceeds both. Measured on
a2a3 by logging the counts at the write site and running the case's host path only
(SIMPLER_SKIP_DEVICE_RUN=1), on this PR's head:

[PROBE] graph boundary: tensors=118 (payload holds 32)  scalars=31 (payload holds 16)
[PROBE] would write tensors[0..117] into a 32-slot array: bytes [640, 15744)
        against sizeof(payload)=4864, i.e. 10880 bytes past the payload
        (2.24 following payloads at stride 4864)

40 occurrences per run — 2 ranks x 20 submissions. tensors starts at offset 640 and
scalars at 4736, so tensors[32..117] runs straight over the scalar region and then
10,880 bytes past the end of a 4,864-byte payload, into the following entries of the
payload array. The counts come from the case's own orchestration
(decode_fwd_graph.cpp:8788-8919: 113 add_input + 5 add_inout, and 31 scalars
across 8 add_scalar calls), so they are static, not workload-dependent. My probe
returned early instead of performing the write, so nothing above is an observed
corruption — it is the extent of the write the code would make.

main also states the invariant this breaks, in the comment on the type being read
here:

Boundary arguments of a Graph. Sized independently of CoreTaskArgs because a Graph
boundary never becomes a task payload
— graph_reset_outer_payload zeroes the outer
GRAPH task's tensor_count, and the boundary is read host-side only […] Widening it
costs orchestration stack, not PTO2TaskPayload […]
— pto_types.h:692-696

That paragraph would need to change with this PR either way; right now the tree would
carry a comment that contradicts its own code, and GRAPH_MAX_TENSOR_ARGS = 128
becomes a number no caller can actually use.

Four things in fairness, because I do not think this is a regression of a green test:

  • dsv4 does not pass on main either. Its device path fails with
    sched_error_code=5 INVALID_ARGS ("an orchestration API rejected its arguments").
    So the change does not turn a passing case red; it turns an already-failing case
    from a clean rejection into an out-of-bounds host write whose bytes then ship H2D.
  • It is not silent-wrong-results. graph_execution_localize does gain
    tensor_count > MAX_TENSOR_ARGS → return nullptr, so the replay is refused. But
    that check runs on device, after the host has already written past the array, so the
    reported failure point and the actual fault are one step apart.
  • The validation set cannot see it. qwen3-14b's boundary is 26 tensors / 0 scalars
    and fits in 32/16, and record_then_replay_1d/2d are smaller still. dsv4 is
    manual: True, so the default pytest examples tests/st sweep excludes it — CI will
    stay green on this indefinitely.
  • The benefit and the bug share a root. The 232,312 -> 131,832 B (-43%) saving comes
    from deleting the duplicate variable-size arg image, and that is exactly the
    mechanism a >32-arg boundary needs. So it is a real design tension, not an oversight
    to patch with a clamp: bounding the boundary at MAX_TENSOR_ARGS would cap every
    Graph at 32 boundary args and exclude dsv4 outright.

What I have locally for this is a pooled GraphBoundaryImage — a POD
{total_bytes, tensor_count, scalar_count, off_tensors, off_scalars} header in a
host-written slot of a boundary pool, addressed base + index x stride with the index
being the outer task's submission order, bounded by GRAPH_MAX_* rather than
MAX_TENSOR_ARGS. It keeps the args out of GraphSubmission (so the fixed-size
submission object this PR introduces stays fixed-size) without putting them in a
32-slot array, and the pool's base and stride can live in the same GraphBlock this PR
adds. Happy to rebase it onto this and hand it over, or to just supply the bound check
and a failing test if you would rather keep the scope here as-is.

Reproduction, if useful:

# log args.tensor_count()/scalar_count() at the write site in graph_submit_outer, then:
SIMPLER_SKIP_DEVICE_RUN=1 SIMPLER_LOG_LEVEL=TIMING task-submit --device auto --device-num 2 --run \
  "python examples/a2a3/host_build_graph/deepseek_v4_flash_decode/test_deepseek_v4_flash_decode.py \
     -p a2a3 --manual only -d \$TASK_DEVICE \
     --case TestDeepseekV4FlashDecodeHostBuildGraph:: --runtime host_build_graph --level 3"

Note that SIMPLER_SKIP_DEVICE_RUN is presence-based, and that a skip_golden case
reports PASSED under it having executed nothing — so read the probe output, not the
exit status.

- Store boundary tensors and scalars in the outer GRAPH task payload\n- Keep per-replay device state in a fixed-size submission object\n- Remove the duplicate variable-size argument wire image
Compute Definition and submission offsets before acquiring device storage. Store a grow-only GraphBlock on the selected arena bank, reuse its capacity, and overwrite the complete live range with one H2D copy per run.
@Crane-Liu
Crane-Liu force-pushed the codex/pr-b-graph-block-20260820 branch from 2a1a0f2 to 1b4278b Compare August 20, 2026 15:39
@Crane-Liu

Copy link
Copy Markdown
Contributor

TaskPayloadSpace follow-up

Addressed the DSV4 boundary-argument overflow identified in review without changing GraphSubmission in this follow-up.

  • Added one packed TaskPayloadSpace; every task payload gets an aligned offset.
  • Graph payload layout is [PTO2TaskPayload][extra ChipTensor...][extra uint64_t...], so the DSV4 boundary of 118 tensors / 31 scalars no longer overruns the ordinary 32 / 16 ABI arrays.
  • Slot-to-payload references are relative offsets and remain valid when the SM image moves to device GM.
  • H2D copies only the live TaskPayloadSpace prefix; ordinary Qwen3 payload traffic remains unchanged.
  • No environment-variable capacity, Worker ownership, GraphSubmission lifecycle change, or unrelated expansion was added.

Validation:

  • A2A3/A5 runtime builds passed.
  • 21 focused HBG/Graph unit tests plus six companion tests passed.
  • Qwen3 three-round device run passed: 39.439 / 41.145 / 38.961 ms (39.848 ms mean); profile sample was +0.94% versus the previous PR sample, within run variance.
  • DSV4 passed the required host-only method from docs: how to measure the host_build_graph bind path #1934: direct L3 invocation, six rounds, SIMPLER_SKIP_DEVICE_RUN=1, two ranks / twelve bind passes. After excluding first-round warm-up, the best complete control-plane pass was 3.054369 ms.

DSV4 device execution is a known-broken path and is not an acceptance criterion for this PR.

Updated head: 1b4278b2.

@ChaoWao

ChaoWao commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closing as superseded by #1952 (1b637ef0, "move payload arguments into pools named by delta"), which landed the same idea in a form that also covers what this PR could not.

To be clear about what this PR did achieve, because I measured it rather than inferring it. On a2a3, qwen3-14b decode, its head 1b4278b2:

  • graph_upload bytes 232,320 → 131,840 (−43%) — matching the −43.25% in the PR body. That saving is real, and it is exactly the arg de-duplication: boundary args moved into the outer GRAPH task's payload so they cross the boundary once instead of twice.

What it left in place, also measured on that head:

  • static_arena still reserved sm=81,412,352, and sm_h2d still shipped 233,799 B — unchanged from the main of the time
  • host_orch heap_used 127,673,344 — unchanged
  • the relocate segment still present (455 ns): the PR makes payload a PTO2RelativePtr but leaves task a raw pointer, so relocate_host_orch_image still walks every slot. Worth noting that the relative pointer here is required by variable payload placement (a payload is no longer at task_payloads[slot], so the host cannot find it by index) rather than being an optimization — it does not retire that pass.

Why #1952 supersedes it: instead of making the payload variable-size to fit a wide Graph boundary, it moves the variable-extent regions (fanin, tensors, scalars) out of the payload into three pools at the image tail, each shipping only its live prefix, with the payload naming them by delta. That subsumes the arg de-duplication, keeps PTO2TaskPayload fixed-size so task_payloads[i] is indexable again, and handles dsv4's 118-tensor boundary without an oversized payload. On current main the same case now ships shared_mem bytes=19,712 and heap_used=80,405,504.

One item from this line of work is still not in main and is worth carrying forward on its own: folding the Definition copy into the single arena H2D. main today still performs a separate 130,240-byte graph_upload copy, and that is the largest single item left in the bind-stage H2D. If you want to pick that up, it is a small, self-contained change now that the pools have settled the payload question.

Thanks — the arg de-duplication was the right observation, and it is in main now, just by a different mechanism.

@ChaoWao ChaoWao closed this Aug 23, 2026
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