Skip to content

Perf: 40 层算完后一次 bulk H2D(替代 PR 1872 逐层 eager H2D) - #7

Open
yanghaoran29 wants to merge 11 commits into
perf/hbg-orch-pinned-h2dfrom
perf/hbg-orch-bulk-h2d-40layers
Open

Perf: 40 层算完后一次 bulk H2D(替代 PR 1872 逐层 eager H2D)#7
yanghaoran29 wants to merge 11 commits into
perf/hbg-orch-pinned-h2dfrom
perf/hbg-orch-bulk-h2d-40layers

Conversation

@yanghaoran29

Copy link
Copy Markdown
Owner

背景

PR 1872(perf/hbg-orch-pinned-h2d)已把每层 Graph POD 就地写进 16MB pinned bump,但构图过程中每写完一层就 copy_to_device 一次(eager)。本 PR 改为:40 层 POD 全部写完后,对 [pinned_base, pinned_used) 连续 host 区做 一次 H2D。

改动

文件 变更
host/runtime_maker.cpp GraphPodH2d::upload_allentry() 后一次 blob H2D,device blob 跨 round 复用;去掉 eager 上传
runtime/orchestrator_core/pto_orchestrator.cpp 删除构图期 eager 上传回调
runtime/graph_host_state.h 删除 eager hook,保留 pinned bump API

核心路径:

  1. entry() 只构图、写 pinned bump,不 H2D
  2. 校验 40 个 POD 全部落在 pinned 区间
  3. device_malloc(used)(只拷 used 字节,不拷整 16MB),跨 round 复用
  4. copy_to_device(dev, host, used) 一次
  5. graph_context = dev + (pinned − host),scheduler 仍按 GraphSubmission* 语义使用
  6. 任一层写不下 bump、回退 std::vector 时,仍走逐层 upload_one(qwen 稳态不走此路径)

优化效果

50 轮交叉 trim-mean(qwen3_14b_decode,去最高/最低 10,剩 30 取 mean)

HEAD(1872 eager) NEW(一次 H2D) 变化
H2dGraph 646.5 µs 276.7 µs −57%(40 次小拷 → 1 次 pinned 大拷)
HostOrch 1.526 ms 1.422 ms per-layer H2D 不再嵌进 HostOrch
H2dImage 2.016 ms 1.315 ms −35%
Gate 3.559 ms 2.711 ms −0.85 ms(−24%)
bind−args 276.8 ms 294.6 ms 本改动不针对 args(此树无 hw-native-sys#1854 args 复用)
Relocate 0.5 µs 0.5 µs 持平

拷贝形状核对:HEAD 每次推理 41h2d_graph(40 层 + leftover),NEW 每次推理 1 次。

上板泳道(NEW vs 1872,device 侧不受影响)

1872 eager NEW
task 数 19208 19208
orch_phase_threads 0 0
scheduler 线程 4 4
device span 44.83 ms 44.28 ms

device 侧执行不因 host H2D 形状变化而变。

测试方法(与 PR 1872 相同)

  • 用例:examples/a2a3/host_build_graph/qwen3_14b_decode--skip-goldenSIMPLER_SKIP_TENSOR_COPY_BACK=1
  • 50 轮交叉:10×(5 HEAD + 5 NEW),只换 libhost_runtime.so,同位置 STRACE span(仅测量 overlay,不进本 PR)
  • 泳道:--enable-chip-swimlane,rounds=1,PASSED

🤖 Generated with Claude Code

lwDavid and others added 11 commits August 17, 2026 03:02
…est (hw-native-sys#1838)

* Add: DeepSeek-V4 FLASH full 43-layer decode network as an EP2 scene test

Port the complete DeepSeek-V4 FLASH decode forward from pypto-lib
(models/deepseek_v4_flash_mtp/decode_fwd.py) as the first pypto-harvested
distributed example: a level-3 scene test on 2 dies with expert-parallel MoE
dispatch/combine and a TP2 LM head through a comm domain.

- examples/a2a3/tensormap_and_ringbuffer/deepseek_v4_flash_decode/: harvested
  chip orchestration (7.8k lines) + 367 incore kernels, verbatim pypto codegen
  plus license headers, clang-format, and the whole-word renames
  L2TaskArgs->ChipTaskArgs / L0TaskArgs->CoreTaskArgs / Tensor->ChipTensor
  (pypto's pinned runtime predates the role-based naming contract).
- The scene orch fn transcribes the generated host_orch.py: one comm domain
  (12 window buffers, ~6.6 MiB), 92 tensors + 13 scalars per rank, per-rank
  submits. Stacked per-rank slices become independent _r0/_r1 host tensors
  (the rehost passes whole buffers), and pypto's device-resident weight
  upload becomes plain host tensor args.
- simpler_setup/goldens/deepseek_v4_flash_decode.py: deterministic fixture for
  the 80 network parameters (~100 GiB at full size), metadata transcribed
  exactly, weight distributions matching the upstream spec builders.
- The case is manual (367-kernel compile takes minutes) and skip_golden
  (upstream runs the same fixture with golden_fn=None; numeric coverage lives
  with the standalone kernel harnesses in pypto-lib).

- .pre-commit-config.yaml: exclude the harvested orchestration translation unit
  from check-added-large-files (708 KiB is inherent to the 43-layer program).

* Raise check-added-large-files cap to 750 kB instead of excluding the orchestration

The harvested whole-network chip orchestration decode_fwd.cpp is 725 350
bytes (~708 kB) — one generated translation unit whose size is inherent
to the 43-layer program. Keep it under the hook's scrutiny by raising the
global cap from the 500 kB default to 750 kB rather than excluding its
directory.
Runs the tensormap_and_ringbuffer DeepSeek-V4 case under host_build_graph:
same 43-layer network, same 367 kernels, same fixture, same comm-window
protocol, only the runtime differs.

decode_fwd_hostbuild.cpp is that case's orchestration with two edits, 51
lines in all, and the runtime untouched:

- The ten get_tensor_data reads of recv_count_out stand in a constant.
  host_build_graph builds the whole graph before the device executes
  anything, so a read of a task-produced tensor has no value to return.
  The constant holds the per-expert tile loops at their real trip count,
  ceil(16/16) == 1, which is what the h_i8 [512, 2048] layout budgets per
  expert.
- The six set_initial_value calls are dropped. Their target is a GM-heap
  device address the host orchestrator cannot store to; leaving them in
  segfaults the chip subprocess.

The other 31 get_tensor_data reads are external tensors the runtime stages
with a host view, and are left alone. Loop structure, submit order,
dependencies and scope nesting are byte-identical to the source, so the
graph keeps the size and shape of the real one (15971 tasks) but not the
fixture's routing — hence skip_golden. manual because the 367-kernel
compile takes minutes.

Host-side construction completes and the graph uploads. Device execution
stalls 12 tasks from the end, in hc_head_linear. README.md records the
measurement, the ten causes ruled out one at a time on hardware, and the
two threads still open.
simpler_run stops after simpler_prepare_run when the variable is set:
orchestration, graph construction, image relocation and the SM H2D all
run, the kernel launch and its completion wait do not, and
simpler_finalize_run still releases the run's resources. No outputs are
produced, so a run under this variable is a timing harness, not a test.

The host_build_graph DeepSeek-V4 case builds its graph but stalls partway
through device execution, which otherwise makes the host side of that case
unmeasurable. This is a temporary handle on that, and goes away with the
stall; see that case's README.md.
alloc_tensors marked the recording unsupported, which graph_end turns into
"the recorded Graph contains a construct that Graph Execution does not
support" — a hard failure, not the fallback its comment described.

The stated reason, that runtime-allocated outputs cannot be replayed, does
not hold. submit_dummy_task records the identical shape — the same
graph_record_submit_node call with INVALID_KERNEL_ID in all three slots —
and poisons nothing. Replay already reserves the intermediate heap for every
internal node, so an allocation node's outputs land at addresses the
replayed Definition derives for itself, exactly as a kernel node's do.

Verified on a2a3 hardware and in simulation with an alloc_tensors call
inside a Graph body: the Definition records, replays, and the numerics hold.
Without this, an orchestration that allocates anywhere inside a Graph body
cannot use Graph Execution at all.
A Graph boundary carried at most 32 tensors and 16 scalars because it was a
CoreTaskArgs, whose capacity every task's payload inherits. Widening that
capacity to hold a larger boundary would have grown PTO2TaskPayload from 4864
to 17152 bytes and, with it, every byte of payload shipped to the device — for
a graph spanning a decoder layer, hundreds of megabytes.

Nothing forces that coupling. graph_reset_outer_payload zeroes the outer GRAPH
task's tensor_count, so the boundary never reaches a payload at all; it is read
host-side only, by graph_boundary_matches and graph_build_submission_image.
Give it its own type, GraphTaskArgs, holding 128 tensors and 64 scalars.
PTO2TaskPayload is unchanged at 4864 bytes, the device sees the same bytes it
saw before, and the cost lands on orchestration stack alone: 920 bytes per
CoreTaskArgs as before, 2888 for the one Graph boundary.

The scalar half is not hypothetical headroom. A Graph body is a free function,
so every loop-invariant local the enclosing orchestration holds has to cross
the boundary too — it can reach neither the caller's frame nor the loop
variable. A decoder layer needs 31: four layer indices, twelve per-layer
scales, and fifteen locals that would otherwise be in scope.

graph_classify_scalar becomes a template because it takes both an internal
node's CoreTaskArgs and the boundary's GraphTaskArgs; its identity test on the
two now compares addresses through void, the types no longer matching.

The three graph_execution scene tests and the qwen3-14B decode case declare
their boundary arguments and Graph body signatures with the new type.
SIMPLER_SKIP_DEVICE_RUN lived in simpler_run, but the L3 multi-chip
subprocess drives a run through the split prepare/launch/wait/finalize
entry points and never calls simpler_run, so the variable never stopped
anything there: the AICPU op launched, executed, and failed on the
device a run under this variable was meant to leave untouched.

The check now sits at the top of simpler_launch_run, which both paths
pass through. It marks the run Complete with rc 0 before any execution
claim is taken, so wait returns immediately and finalize walks its
not-launched branch (kernel-args release, retire of the unused stream,
SM pointer cleared, validate reads a null header and returns 0).
decode_fwd_graph.cpp recasts the 20-iteration decoder layer loop (40 of
the 43 layers) of the hostbuild baseline as one rt_submit_graph per
iteration. The layer's task set becomes the Graph body, a free function
that reads its per-layer weight views, scales, indices and the loop's
invariant locals through GraphTaskArgs; the boundary is positional —
args.tensor(i) in the body and the i-th add_input/add_inout at the
submit site are the same slot, and both lists are emitted from one
ordered walk so they cannot drift.

The submit-everything form made the host hand 15991 tasks to the
device scheduler per run; the Graph form records a 744-node Definition
once and boots with 1131 host-submitted tasks (measured on both ranks
on a2a3 hardware, ASCEND_GLOBAL_LOG_LEVEL=1, host-orch boot line).
The two hostbuild edits carry over unchanged: the ten
get_tensor_data(recv_count_out) reads stand in HBG_RECV_ROWS_PER_EXPERT
and the six set_initial_value calls are dropped.

The non-Graph baseline stays in the tree and the README documents both:
the baseline is what the stall investigation measured against, and the
Graph variant is what the case now runs. Device-side replay of a
Definition this size is not yet exercised — an unskipped run fails in
Graph activation before the tail stall — so SIMPLER_SKIP_DEVICE_RUN
host-side measurement is the verification path for now.
A chip child that received SHUTDOWN still has to release everything it
imported before exiting. On a large-scope run (the dsv4 host_build_graph
case: ~80 shm backings incl. a 2 GiB ring heap) that teardown measured
~12 s — past the 10 s _ROLLBACK_GRACEFUL_TIMEOUT_S the close path was
sharing, so every teardown ended in "child process(es) did not exit
within the close budget" even though the children were healthy and
merely draining (py-spy showed them inside multiprocessing's
SharedMemory close the whole window; no CANN call was involved).

The close path now uses its own 60 s constant. Rollback keeps the
tighter 10 s: its graceful wait guards an unlink-only path where
exceeding the budget means the child is stuck and should be killed,
which is the opposite trade-off from a child doing proportional
exit work.
…-port

Add: DeepSeek-V4 FLASH decode on host_build_graph, in Graph form
Write each Graph layer into a retained pinned host arena and copy_to_device
as soon as the POD is ready, so bind skips a later gather of unpinned images.
Remove the per-layer eager upload from PR 1872. During graph build,
layer PODs are only written into the pinned host bump. After entry()
completes, wire execution storage into each host POD, then issue a
single copy_to_device over [pinned_base, pinned_used) into a device
blob reused across rounds; graph_context points into that blob at
each layer's pinned offset. Layers that fall back to std::vector
keep the per-layer upload path.

Co-Authored-By: Claude <noreply@anthropic.com>
@yanghaoran29
yanghaoran29 force-pushed the perf/hbg-orch-pinned-h2d branch 5 times, most recently from 30ac448 to 67df8ec Compare August 18, 2026 12:16
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