Skip to content

Refactor: hbg records and ships Definitions in one retained block - #1988

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
ChaoWao:refactor/hbg-definitions-in-one-retained-block
Aug 24, 2026
Merged

Refactor: hbg records and ships Definitions in one retained block#1988
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
ChaoWao:refactor/hbg-definitions-in-one-retained-block

Conversation

@ChaoWao

@ChaoWao ChaoWao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Each distinct Graph Definition was uploaded as its own device object: for every one, bind acquired a retained device block, allocated a host staging vector, assembled [GraphDefinitionHeader][Definition image] into it, issued its own rtMemcpy, and freed the vector — and the image it copied from was itself a vector the recording had allocated and would return. A dsv4 bind has eight Definitions, so that is eight allocate-copy-free rounds for 1.0 MB, on a path whose cost is per-call rather than per-byte.

The objects now share one device block and one host staging block, both retained per pipeline slot and grown to a high-water mark, and the recordings build their images directly into that staging:

  • bind reads the retained staging before orchestration and hands it to the Graph host state as a GraphDefinitionArena. The capacity on offer is whatever the previous bind left, because a run's total is not known until every recording has ended.
  • graph_build_definition splits at the point the size becomes known. graph_layout_definition settles the counts, section offsets and total_bytes without writing anything; the recording thread claims an object of that size; graph_fill_definition writes the image at the claimed offset.
  • the upload writes the object headers into the room the arena left in front of each image and ships the used prefix with one copy_to_device. A steady-state bind copies no image at all, and acquires no host memory for its Definitions.

Three properties make the arena safe to write into from several recording threads while the submitting thread is still submitting shells:

  • the claim is a compare-exchange that fails rather than advancing the cursor past the capacity, so a Definition that does not fit costs the run its own slot and no one else's. It is built in a buffer of its own instead and copied in by the upload, which then grows the block — so outgrowing the retained capacity costs a copy, not the run, and the next bind is sized for it. graph_upload's new spilled= counts those, and is 0 on every bind after the first.
  • the arena never moves during orchestration, since a recorder holds addresses inside it. Only the upload may grow it, which preserves the content and is why a published record names an offset rather than an address; by then nothing is recording.
  • an object is aligned and padded to GRAPH_DEFINITION_OBJECT_ALIGN, a named constant beside the header whose size is a multiple of it, so every image base carries the alignment its section offsets assume. The fill asserts that rather than trusting the arena's provider.

The image is still zero-filled before it is written, and now for a second reason: the alignment slack between sections is inside the hashed range, so a slot a previous bind wrote would give two structurally identical Definitions unequal content hashes.

HostApi grows get_graph_definition_staging and replaces acquire_graph_definition_buffer with acquire_graph_definition_block, which takes no Definition key and returns both blocks, since the layout inside them belongs to the caller. The device block is still zeroed when it is (re)allocated, so a region no upload has covered reads as zero and fails the magic gate instead of being read as a stale object.

Measurement

dsv4, interleaved base, measure, base, measure at six rounds each per .claude/skills/hbg-bind-phases; both arms ran the same command string (the logs' [stamp] lines differ only in the commit) and did the same work — 8 Definitions, 86 Graph submissions, 129 host tasks per bind. Two figures per cell, one per repetition:

per warm bind base this branch
graph_upload minflt, min / median / max 2 / 38 / 47, 34 / 43 / 133 0 / 1 / 3, 0 / 1 / 5
graph_upload dur min (ms) 0.225, 0.282 0.141, 0.106
host_orch minflt, min / median 1100 / 1248, 1047 / 1138 1062 / 1107, 1082 / 1124
control plane, min of sums (ms) 1.194, 1.365 1.549, 1.199

Only graph_upload moved, and it moved consistently in both repetitions: the ~40 minor faults per bind it took allocating one staging vector per Definition are gone, and so is the copy.

host_orch did not move, and the investigation entry now records why: the two repetitions disagree in sign on both its fault count and its duration, which is that file's own criterion for "not resolvable". A freed 126 KB block is reused from the heap without re-faulting, so an image vector was never part of that segment's fault tail — size against glibc's mmap and trim thresholds, not byte count, decides what shows up there. The control-plane total is not resolvable either (+0.355 ms then −0.166 ms), which is what a ~0.1 ms change looks like on a box whose load average sat between 40 and 66.

Testing

  • Simulation tests pass — examples tests/st on a2a3sim (21/21 cases) and a5sim (17/17), both after the rebase onto 66ba5c4a1
  • Hardware tests pass — examples tests/st -m 'not sdma' --platform a2a3 --exclude-level 4 under task-submit, 150 selected / 57 resource-phase cases, twice
  • ctest -LE requires_hardware — 119/119, including the new test_hbg_graph_definition_arena for a2a3 and a5
  • pytest tests/ut -m "not requires_hardware" — 1886 passed, 7 skipped
  • spilled= verified across rounds: 2 of 12 binds (one per rank, cold) spill; the other 10 report spilled=0

test_hbg_graph_definition_arena covers what the scene tests cannot see, since they pass either way: objects land at aligned, disjoint offsets that the claimed prefix exactly covers, and a run with no arena or with one too small still publishes a valid image.

One caveat from the hardware runs, recorded rather than swept under: the first corpus run on this branch hit a device-side AIV ub address out of bounds in worker_async_endpoint, which cascaded to 507018 / SCHEDULER_TIMEOUT. It did not reproduce — that case passed 3/3 alone on 66ba5c4a1, the same corpus passed 57/57 on 66ba5c4a1, and the second corpus run on this branch passed 57/57 — and the case records no Graph at all, so this diff does not execute in it. Worth an eye on CI: a UB fault is not a flaky timeout.

Each distinct Graph Definition was uploaded as its own device object: for
every one, bind acquired a retained device block, allocated a host staging
vector, assembled [GraphDefinitionHeader][Definition image] into it, issued
its own rtMemcpy, and freed the vector -- and the image it copied from was
itself a vector the recording had allocated and would return. A dsv4 bind
has eight Definitions, so that is eight allocate-copy-free rounds for
1.0 MB, on a path whose cost is per-call rather than per-byte.

The objects now share one device block and one host staging block, both
retained per pipeline slot and grown to a high-water mark, and the
recordings build their images directly into that staging:

  - bind reads the retained staging before orchestration and hands it to the
    Graph host state as a GraphDefinitionArena. The capacity on offer is
    whatever the previous bind left, because a run's total is not known until
    every recording has ended.
  - graph_build_definition splits at the point the size becomes known.
    graph_layout_definition settles the counts, section offsets and
    total_bytes without writing anything; the recording thread claims an
    object of that size; graph_fill_definition writes the image at the
    claimed offset.
  - the upload writes the object headers into the room the arena left in
    front of each image and ships the used prefix with one copy_to_device.
    A steady-state bind therefore copies no image at all, and acquires no
    host memory for its Definitions.

Three properties make the arena safe to write into from several recording
threads while the submitting thread is still submitting shells:

  - the claim is a compare-exchange that fails rather than advancing the
    cursor past the capacity, so a Definition that does not fit costs the run
    its own slot and no one else's. It is built in a buffer of its own
    instead and copied in by the upload, which then grows the block -- so
    outgrowing the retained capacity costs a copy, not the run, and the next
    bind is sized for it. graph_upload's new `spilled=` counts those, and is
    0 on every bind after the first.
  - the arena never moves during orchestration, since a recorder holds
    addresses inside it. Only the upload may grow it, which preserves the
    content and is why a published record names an offset rather than an
    address; by then nothing is recording.
  - an object is aligned and padded to GRAPH_DEFINITION_OBJECT_ALIGN, a named
    constant beside the header whose size is a multiple of it, so every image
    base carries the alignment its section offsets assume. The fill asserts
    that rather than trusting the arena's provider.

The image is still zero-filled before it is written, and now for a second
reason: the alignment slack between sections is inside the hashed range, so a
slot a previous bind wrote would give two structurally identical Definitions
unequal content hashes.

HostApi grows get_graph_definition_staging and replaces
acquire_graph_definition_buffer with acquire_graph_definition_block, which
takes no Definition key and returns both blocks, since the layout inside them
belongs to the caller. The device block is still zeroed when it is
(re)allocated, so a region no upload has covered reads as zero and fails the
magic gate instead of being read as a stale object.

Measured on dsv4, interleaved base/measure/base/measure at six rounds each
(8 Definitions, 86 Graph submissions, 129 host tasks per bind on both arms):
graph_upload loses the ~40 minor faults per bind it took allocating those
staging vectors (median 38 and 43 -> 1 and 1) and its minimum drops 0.225 ->
0.141 ms and 0.282 -> 0.106 ms. host_orch does not move -- the two
repetitions disagree in sign on both its fault count and its duration -- and
the investigation entry records why: a freed 126 KB block is reused from the
heap without re-faulting, so an image vector was never part of that segment's
fault tail. The control-plane total is not resolvable from this A/B either
(+0.355 ms then -0.166 ms), which is what a ~0.1 ms change looks like on a
box whose load average sat between 40 and 66.

test_hbg_graph_definition_arena covers what the scene tests cannot see, since
they pass either way: objects land at aligned, disjoint offsets that the
claimed prefix exactly covers, and a run with no arena or with one too small
still publishes a valid image.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 5 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 49339433-3121-4578-a3b9-35ff44caa192

📥 Commits

Reviewing files that changed from the base of the PR and between 66ba5c4 and 311fcd7.

📒 Files selected for processing (23)
  • docs/dfx/hbg-bind-phases.md
  • docs/investigations/2026-08-hbg-graph-definition-single-upload.md
  • docs/investigations/2026-08-host-orch-phase-tail-is-page-faults.md
  • docs/investigations/README.md
  • 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/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/orchestrator.cpp
  • src/common/host_build_graph/graph_execution.h
  • src/common/host_build_graph/graph_host_state.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/CMakeLists.txt
  • tests/ut/cpp/common/test_hbg_graph_definition_arena.cpp
  • tests/ut/cpp/common/test_hbg_graph_submit_failure.cpp
  • tests/ut/cpp/common/test_hbg_slot_claim.cpp

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 merged commit a7510ca into hw-native-sys:main Aug 24, 2026
20 checks passed
@ChaoWao
ChaoWao deleted the refactor/hbg-definitions-in-one-retained-block branch August 24, 2026 11:39
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Aug 25, 2026
Every bind allocated the host mirror of the runtime shared memory from the
heap and freed it on the way out. The mirror is dimensioned for the run's
configured task count, which is 82 MB at the dsv4 case's 16384 — far above
every glibc reuse threshold, so each bind was one mmap and one guaranteed
munmap of that size. A bind's own faults then cost 14-33 us each instead of
~1.7 us, because the unmap holds mmap_lock for write and excludes every
faulting thread in the address space
(docs/investigations/2026-08-host-orch-phase-tail-is-page-faults.md).

The mirror is now the platform runner's, one buffer per pipeline slot,
grown to the high-water mark and mapped for the life of the process — the
same shape hw-native-sys#1988 gave the Graph Definition staging block, reached through a
new HostApi::acquire_sm_mirror.

The buffer is an uninitialized owning block rather than a container: the
layout is init-on-write, so a value-initializing resize would fault in
every page of a capacity whose live prefix is a fraction of it, and would
copy bytes that mean nothing between binds. First touch still commits the
mirror, so a run pays only for the bytes it writes.

Reuse needs no new invariant. A bind already cleared only the fixed-size
header and relied on init-on-write for the per-slot segments, which is why
compact_live_image can ship each segment's used prefix: prepare_task writes
the slot state and clears the completion flag as it claims a slot, and
TaskPayload::init is the single payload-init point. The one shipped range no
device-side reader reaches is the alignment padding the fanin cursor rounds
past, which lies outside every payload's fanin_count.

The buffer is released at Worker finalization on both the ordinary and the
force-reset path, since it is host memory that no device failure
invalidates.

Measured on dsv4, interleaved twice: mallinfo2's hblkhd stops returning to
its pre-bind value on 6 of 6 binds, which is the map/unmap going away, and
the cold-bind fault count matches the per-bind allocation it replaces.
host_orch's warm-bind fault count and the control-plane duration resolve in
neither direction — the mirror is ~6 THP faults of a ~1200-fault bind.

This closes the investigation entry's "Where a fix would go" list, so the
entry is amended to say so: items 1-3 shipped as hw-native-sys#1981 and item 4 as hw-native-sys#1988
plus this change, and none of them reached the ~1100 faults the submitting
thread takes per bind. hw-native-sys#1981 already reported those unmoved when the
recording's allocations went away; this is the second measurement of the
same thing. Attributing them needs mincore() on a buffer's pages before the
write, not another guess at which allocation it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChaoWao added a commit to ChaoWao/simpler-fork that referenced this pull request Aug 25, 2026
Every bind allocated the host mirror of the runtime shared memory from the
heap and freed it on the way out. The mirror is dimensioned for the run's
configured task count, which is 82 MB at the dsv4 case's 16384 — far above
every glibc reuse threshold, so each bind was one mmap and one guaranteed
munmap of that size. A bind's own faults then cost 14-33 us each instead of
~1.7 us, because the unmap holds mmap_lock for write and excludes every
faulting thread in the address space
(docs/investigations/2026-08-host-orch-phase-tail-is-page-faults.md).

The mirror is now the platform runner's, one buffer per pipeline slot,
grown to the high-water mark and held across binds until Worker
finalization — the same shape hw-native-sys#1988 gave the Graph Definition staging
block, reached through a new HostApi::acquire_sm_mirror.

The buffer is an uninitialized owning block rather than a container: the
layout is init-on-write, so a value-initializing resize would fault in
every page of a capacity whose live prefix is a fraction of it, and would
copy bytes that mean nothing between binds. First touch still commits the
mirror, so a run pays only for the bytes it writes.

Reuse needs no new invariant. A bind already cleared only the fixed-size
header and relied on init-on-write for the per-slot segments, which is why
compact_live_image can ship each segment's used prefix: prepare_task writes
the slot state and clears the completion flag as it claims a slot, and
TaskPayload::init is the single payload-init point. The one shipped range no
device-side reader reaches is the alignment padding the fanin cursor rounds
past, which lies outside every payload's fanin_count.

The buffer is released at Worker finalization on both the ordinary and the
force-reset path, since it is host memory that no device failure
invalidates.

Measured on dsv4, interleaved twice: mallinfo2's hblkhd stops returning to
its pre-bind value on 6 of 6 binds, which is the map/unmap going away, and
the cold-bind fault count matches the per-bind allocation it replaces.
host_orch's warm-bind fault count and the control-plane duration resolve in
neither direction — the mirror is ~6 THP faults of a ~1200-fault bind.

This closes the investigation entry's "Where a fix would go" list, so the
entry is amended to say so: items 1-3 shipped as hw-native-sys#1981 and item 4 as hw-native-sys#1988
plus this change, and none of them reached the ~1100 faults the submitting
thread takes per bind. hw-native-sys#1981 already reported those unmoved when the
recording's allocations went away; this is the second measurement of the
same thing. Attributing them needs mincore() on a buffer's pages before the
write, not another guess at which allocation it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChaoWao added a commit that referenced this pull request Aug 25, 2026
Every bind allocated the host mirror of the runtime shared memory from the
heap and freed it on the way out. The mirror is dimensioned for the run's
configured task count, which is 82 MB at the dsv4 case's 16384 — far above
every glibc reuse threshold, so each bind was one mmap and one guaranteed
munmap of that size. A bind's own faults then cost 14-33 us each instead of
~1.7 us, because the unmap holds mmap_lock for write and excludes every
faulting thread in the address space
(docs/investigations/2026-08-host-orch-phase-tail-is-page-faults.md).

The mirror is now the platform runner's, one buffer per pipeline slot,
grown to the high-water mark and held across binds until Worker
finalization — the same shape #1988 gave the Graph Definition staging
block, reached through a new HostApi::acquire_sm_mirror.

The buffer is an uninitialized owning block rather than a container: the
layout is init-on-write, so a value-initializing resize would fault in
every page of a capacity whose live prefix is a fraction of it, and would
copy bytes that mean nothing between binds. First touch still commits the
mirror, so a run pays only for the bytes it writes.

Reuse needs no new invariant. A bind already cleared only the fixed-size
header and relied on init-on-write for the per-slot segments, which is why
compact_live_image can ship each segment's used prefix: prepare_task writes
the slot state and clears the completion flag as it claims a slot, and
TaskPayload::init is the single payload-init point. The one shipped range no
device-side reader reaches is the alignment padding the fanin cursor rounds
past, which lies outside every payload's fanin_count.

The buffer is released at Worker finalization on both the ordinary and the
force-reset path, since it is host memory that no device failure
invalidates.

Measured on dsv4, interleaved twice: mallinfo2's hblkhd stops returning to
its pre-bind value on 6 of 6 binds, which is the map/unmap going away, and
the cold-bind fault count matches the per-bind allocation it replaces.
host_orch's warm-bind fault count and the control-plane duration resolve in
neither direction — the mirror is ~6 THP faults of a ~1200-fault bind.

This closes the investigation entry's "Where a fix would go" list, so the
entry is amended to say so: items 1-3 shipped as #1981 and item 4 as #1988
plus this change, and none of them reached the ~1100 faults the submitting
thread takes per bind. #1981 already reported those unmoved when the
recording's allocations went away; this is the second measurement of the
same thing. Attributing them needs mincore() on a buffer's pages before the
write, not another guess at which allocation it is.
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