Skip to content

The host_build_graph bind phases

host_build_graph builds the whole task graph on the host before the device executes anything, so the host-side bind stage — argument copy-in, orchestration, the Graph Definition, and every H2D copy — is a first-class cost. bind is the chip.run.bind [STRACE] span both runtimes emit; only this one subdivides it into segments, one chip.run.bind.<segment> span each. This page is what those segments are, how to measure them on the two decode networks that exercise them, and the traps that make a measurement wrong rather than merely noisy.

For the marker grammar and the tool's other views, see host-trace.md. For what the runtime records inside host_orch, see host_build_graph's profiling levels.

A new lesson about measuring these phases belongs on this page. The hbg-bind-phases skill holds the invocation and nothing else — it is loaded into context on every use — and hbg_bind_phases gets a comment only once the lesson is an invariant its code depends on.

What the segments are

SIMPLER_HBG_BIND_BREAKDOWN_ENABLE=1 makes the runtime emit one chip.run.bind.<segment> [STRACE] span per segment per bind, at depth 2 inside the chip.run.bind span:

Segment What it covers
args copying readable caller tensors in H2D, into slices of the pipeline slot's retained temporary buffer, and exposing their existing host buffers to orchestration; pure outputs skip both. The buffer grows to the high-water packed size and is reused, so a steady-state workload allocates no device memory here
arena_build, static_arena, gm_heap, shared_mem, runtime_init arena layout, GM heap and shared-memory bring-up
host_orch orchestration and recording: every task submitted and every sub-task recorded; excludes the later Definition packing and Graph task binding in bind_graph_definitions
graph_pack assembling this run's Definition section in bind_graph_definitions — staging growth, header writes, spill copies — and handing it to the AICPU launch package, which RTS copies with the launch. No H2D of its own: the Definition bytes travel as launch arguments, so there is no device Definition block to upload. Excludes Graph task binding. Absent when there is no Definition
arena_h2d one H2D of the arena's copied zone and the shared-memory image
host_view_close dropping the explicit HOST/NONE access registrations after host orchestration and recorder completion; no mapping or device-copy fallback is performed. Historical count, bytes, and devcopy attributes are no longer emitted

The parser's control plane label is the instrumented subtotal host_orch + graph_pack + arena_h2d, not the complete cost between "the caller's data is in place" and "the device can start". bind_graph_definitions runs after BindHostOrch is recorded: its Graph task binding is outside all three segments, while its staging growth, header writes and spill copies are now inside graph_pack. A5's resident scheduler preparation and publication also have no dedicated segment. These costs still exist; the subtotal does not measure them. The < 1 ms target for the complete control plane cannot be accepted from this subtotal alone.

args scales with caller tensor bytes and is excluded from the subtotal. host_view_close stays excluded for historical mapped-view logs, although the current bind path has no device mappings to close. The enclosing chip.run.bind span is useful for checking work absent from the segments, but also includes args and other bind work; it is not a replacement control-plane metric.

Two kinds were retired from the set, not merely from the output. relocate and sm_h2d dated from when the shared-memory image was relocated and copied on its own; it now travels inside the single arena_h2d copy as that segment's sm=. Both were removed from HostPhaseKind. The table lists ten possible segments; the parser selects three for its subtotal and reports missing ones.

The subtotal is a sum of measured costs, not an interval. On a successful A2/A3 bind with Definitions, execution follows this order (unmarked work is shown in brackets):

bind_callable_to_runtime_impl:
  args, arena_build, runtime_init, host_orch,
  graph_pack, [Graph task binding],
  static_arena, shared_mem, gm_heap,
  [compact execution-image preparation], host_view_close
publish_run_image_impl:
  arena_h2d

A5 additionally prepares resident scheduler state during bind. Onboard publish_run_image_impl snapshots that metadata before arena_h2d; the actual scheduler-state H2D queues in the later launch transaction, before the Runtime descriptor and entry fork. CPU transports without a staging callback retain synchronous publication before arena_h2d. Neither route has a dedicated scheduler segment, and the onboard enqueue cost is outside chip.run.bind. The legacy A5 path has no separate resident scheduler publication.

Execution order, emission order and parser display order differ. Each span's ts records execution time. The trace emits in HostPhaseKind order when publication closes it (or cleanup closes an abandoned bind). The parser's PHASE_ORDER retains its historical display order. Group by (pid, inv) and sum each bind's measured segments; neither log line order nor table row order is a timeline.

Prerequisites

python3 -m venv --system-site-packages .venv     # once per worktree
source .venv/bin/activate
pip install --no-build-isolation -e .            # after every source change
.claude/skills/onboard-arch-precheck/check.sh a2a3   # exit 0 ⇒ this box can run a2a3

Both cases are onboard-only and must run through task-submit, which holds the device lock for the whole job (see .claude/rules/running-onboard.md).

The two cases

Property qwen3-14b decode DeepSeek-V4 FLASH decode
Path examples/a2a3/host_build_graph/qwen3_14b_decode/ examples/a2a3/host_build_graph/deepseek_v4_flash_decode/
Entry point standalone main.py, which owns its L2 Worker standalone main.py, which owns its L3 Worker
Devices 1 2 (EP2/TP2)
Host tasks (host_orch tasks=) 47 129
Graph submissions (graph_pack submissions=/defs=) 40, of 1 Definition 86, of 8 Definitions
Graph boundary 26 tensors 118 tensors, 31 scalars
First-run compile seconds minutes (369 kernel sources + an 11.6k-line orchestration)
Parameters device memory; valid fixture streamed once before all rounds child memory, and --skip-golden leaves it uninitialized
Marked manual manual, and it has no golden

The entry point decides how a case's output is captured, which is what the recipe below has to work around.

The two count rows name the markers they come from, because they are properties of the cases and the cases get edited. They read 47 / 129 tasks and 40 / 86 submissions on 4d31f482; they previously read 1131 tasks and 20 replays for DeepSeek-V4, from before its orchestration moved most task submission onto the recording threads. Re-read them from a current log rather than trusting this table — a bind's host_orch and graph_pack spans carry both.

Recipe A — stable numbers, many rounds

The ready-made invocation for either case lives in the hbg-bind-phases skill; python -m simpler_setup.tools.hbg.bind_phases <log> turns its log into per-segment statistics. This section is what the switches mean and why the traps below exist.

Six rounds is the working minimum: this box is shared, and a single bind has been seen to land 3.5× off its own minimum. Which statistic to read depends on the question. For "how long does this path take", take the minimum of the per-round sums — the quietest bind is the closest this box gets to the machine's own cost. For "did a change move it", see Comparing two branches below; the answer there is not a minimum.

Four switches and one flag make the measurement, and each is load-bearing:

Switch Why
SIMPLER_HBG_BIND_BREAKDOWN_ENABLE=1 emits the segment spans at all
--log-level timing pins the level the report is read at, and is the only way it reaches the [stamp] line; TIMING is already the default, so this records a condition rather than enabling one
TORCH_DEVICE_BACKEND_AUTOLOAD=0 keeps CPU golden imports from loading torch_npu; the torch_backend_autoload timing record confirms the effective setting and observed module state
SIMPLER_SKIP_DEVICE_RUN=1 returns at simpler_launch_run, so the host path is measured without a working device run
--skip-golden with the device skipped the outputs a golden check compares are never produced. Qwen still streams its valid fixture once before the measured rounds; on dsv4 the flag also skips the 42.6 GiB-per-rank fixture upload

--log-level overrides a level that is already TIMING; it does not turn the records on. There is one control point and it is the Python logger named simpler: python/simpler/_log.py sets it to TIMING at import when nothing else has, and Worker snapshots that logger's effective level once — feeding it to the host log and to every forked chip child, which is why the same level governs the host and device sides. configure_logging() in simpler_setup/log_config.py is the only way a CLI moves that snapshot; there is no environment variable.

The recipe passes --log-level timing anyway, because the level is the one measurement condition with no record of its own in the log. Autoload state has torch_backend_autoload; the level has nothing, so a run that omits the flag leaves it implicit in whichever default that commit compiled in. Two arms of a cross-commit A/B could then run at different levels with neither [stamp] showing it — the same silent-mismatch failure the autoload record exists to prevent. Passing it puts the level in the stamp, where a diff of the two first lines catches a mismatch.

SceneTest and the standalone Qwen driver emit one torch_backend_autoload record per interpreter after torch-dependent argument preparation and before the first dispatch. effective reports the environment's dispatch-time intent using torch's current private autoload predicate; torch_npu_loaded reports the observed module state and is the authoritative field if the environment changed after torch was imported. raw is the JSON-encoded environment value (null when unset); values longer than 64 characters carry their first 64 characters and raw_truncated=true, keeping the record single-line and bounded.

The dsv4 driver is neither of those two, so a dsv4 log carries no such record — hbg_bind_phases prints "backend-autoload state must be established before comparing this log" on every dsv4 measurement. The check below that this is the one condition already known to have produced a wrong number therefore has no in-log witness on that case; the [stamp] line is all a dsv4 A/B has, so both arms must be read off it by hand.

SIMPLER_SKIP_DEVICE_RUN is presence-based. SIMPLER_SKIP_DEVICE_RUN=0 still skips; unset it. It is a temporary handle from the dsv4 bring-up and is deleted once that case's device execution works.

Both cases now run through standalone main.py drivers. Qwen owns its L2 Worker in the invoked process, while dsv4 owns its L3 Worker; neither needs module-runner --runtime / --level forwarding to expose the segment spans.

A 2-rank case emits one bind per rank per round, so six rounds is twelve binds. Pass --rounds to the parser so it infers the rank count and drops one cold bind per rank rather than one in total.

A skipped run still writes host_phase_records.jsonl, so Recipe B works without touching the device. Every phase in that artifact is produced on the host during bind, so the skip path writes it exactly as the device-run teardown does; what gates it is Recipe B's three conditions, none of which is the device.

Reading the segments out

The parser does this grouping; read it out by hand only to check something it does not report. The log lands in outputs/hbg_bind_stats_<sha>.log unless -o names it:

grep -oE 'name=chip\.run\.bind\.[a-z0-9_]+ ts=[0-9]+ dur=[0-9]+[^[]*' outputs/hbg_bind_stats_<sha>.log

The character class has to admit digits, or it drops every arena_h2d — the segment name containing digits, and the one that itemizes the execution-image upload.

Each span carries ts (a CLOCK_MONOTONIC timestamp), dur, pid and inv, plus the segment's own attributes — the six kernel counters on all of them, then tasks= and heap_used= on host_orch, defs=, bytes=, submissions= and spilled= on graph_pack, and arena_h2d's itemized upload. A bind is one (pid, inv), so grouping needs no inference from order: inv is the run epoch the enclosing chip.run.bind allocated, and two ranks writing one stream cannot be confused for each other. Sum the control-plane segments within each bind and take the minimum of those sums. Never sum minima taken across binds; that total belongs to no bind and can point the wrong way (see below).

spilled= should be 0 on every bind but the first. It counts the Definition objects the recorders could not build inside the retained staging, which bind_graph_definitions copies into staging before publication. The count is reported on graph_pack, and that host copy is inside its timing window. The first bind of a process has nothing retained and so spills all of them; a later bind that still spills means the run's Definitions outgrew the high-water mark the previous one left, and the copies are back. It is not spelled copied= on purpose: on arena_h2d that name means a zone, not a count.

A segment's bytes= is what that segment itself copied, so no copy is counted twice. graph_pack counts the Definition objects it packs, which are all it copies; arena_h2d's bytes= is its single copy, exactly partitioned by the copied= and sm= beside it. A Graph invocation's boundary values are inside that sm=: they live in the outer Graph task's ordinary argument pools, so they travel with every other task's arguments rather than as a population of their own. (shared_mem's bytes= is the image the arena grew to hold, which arena_h2d then ships as sm=; that is the one figure two segments both report, and neither is a copy count of the other.)

The first bind of each rank is warm-up and belongs in neither statistic; drop it explicitly rather than letting a minimum quietly exclude it.

Was the segment running or waiting?

Every line also carries the kernel counters, which say what a duration cannot. Each covers the same span the duration does.

Field Source What it says
cpu_ns the bind thread's CLOCK_THREAD_CPUTIME_ID how much of the segment that thread spent running, so dur_ns - cpu_ns is what it spent off CPU — blocked and runnable-but-preempted, which the two *csw counters separate
rec_cpu_ns every recording worker's CPU clock, summed how many threads' worth of work ran alongside — a ratio to dur_ns, never something to subtract from it
minflt / tminflt getrusage RUSAGE_SELF / RUSAGE_THREAD minor faults for the whole process, and the bind thread's share of them; the difference is the recorders'
nvcsw / nivcsw getrusage RUSAGE_SELF voluntary and involuntary context switches for the whole process, so the recorders' switches are in here too. A high nivcsw says the box was loaded, not that this code was; neither counter isolates the bind thread — tminflt is the only thread-scoped field

rec_cpu_ns and tminflt are Linux-only, and read zero elsewhere. Sampling another thread's CPU clock needs pthread_getcpuclockid and a per-thread fault count needs getrusage(RUSAGE_THREAD); Darwin provides neither. That costs nothing where it matters — a bind is profiled on the silicon it binds to — but on a *sim platform built for macOS a reccpu of 0 means "not measurable here", not "nothing ran alongside". cpu_ns uses CLOCK_THREAD_CPUTIME_ID for the calling thread and works everywhere.

python -m simpler_setup.tools.phase_time_split <log>          # cold and warm, per segment
python -m simpler_setup.tools.phase_time_split <log> --phase host_orch

CPU time comes from per-thread clocks and never from rusage. ru_utime and ru_stime are accounted per scheduler tick — 10 ms at CLK_TCK=100 — so on a segment of a millisecond they quantise to either zero or a whole tick, and the values look plausible one at a time while being noise. A per-thread clock reads the scheduler's running total in nanoseconds; measured against a busy and a sleeping thread over a 1.29 ms window it resolved both to under 10 µs. The three counters stay on rusage because they are event counts, which do not quantise.

On-CPU sends you somewhere different from off-CPU. A segment that is mostly on-CPU is running, so split it further by the fault count and by the syscalls inside its window. A segment that is mostly off-CPU is waiting, and nvcsw versus nivcsw says whether it blocked or merely lost the CPU. Reading a fault count without this split is what let a page-fault tail be chased three times on a segment whose faults were on threads with spare parallelism — see docs/investigations/2026-08-host-orch-phase-tail-is-page-faults.md.

Device wall clock for the same rounds comes from the [STRACE] markers, on a run that did not skip the device:

grep -oE 'device_wall ts=0 dur=[0-9]+' <log> | \
  awk -F'dur=' '{printf "%.2f\n", $2/1e6}' | sort -n

Comparing two branches

A branch comparison is a different measurement from a single reading, and two of its failure modes have already produced wrong answers on this box.

Check source coverage before comparing identically named segments. This segment's scope has moved twice, and a log carries only the name. At #2353 the segment was called graph_upload and changed from Definition packing/binding plus H2D to just the publication copy, leaving packing outside host_orch, graph_upload and arena_h2d — it had not disappeared or moved into another one of those segments. This PR renames it graph_pack and brings that packing back inside it, while the H2D leaves entirely: the Definition bytes now ride the AICPU launch arguments. So the segment has held three different scopes, and the parser, which matches names, can detect none of them. Across either boundary, neither the segment nor the reported control-plane total is a comparable host-cost metric, even with identical commands and case counts. A log written before this PR carries graph_upload, which the current parser reports as an unknown segment and leaves out of its total — so an old log's total is not merely differently scoped, it is also missing that segment's time. Use matching instrumentation in both arms, or report the separate scopes without claiming an end-to-end improvement. Do not apply the < 1 ms target to a subtotal taken with the #2353-era instrumentation, which excluded packing.

Both arms must be the same ruler, and the log is the only witness you get. A baseline missing TORCH_DEVICE_BACKEND_AUTOLOAD=0 produced a wrong number once: it alone paid for torch_npu grabbing a device on import, and the difference was attributed to the branch. Compare the torch_backend_autoload timing record in both logs; the measurement recipe produces setting=0 raw="0" raw_truncated=false effective=disabled torch_imported=true torch_npu_loaded=false. hbg_bind_phases prints every distinct record above its table and warns when the log does not carry one.

The recipe also echoes the command it is about to run, verbatim, as the log's first line, and hbg_bind_phases prints that line above the table:

diff <(head -1 base.log) <(head -1 measure.log)   # must differ only in the commit

An arm with no [stamp] line cannot take part in a comparison.

Interleave the conditions; never run one after the other. base then measure attributes every drift in host load to the branch, and the drift is larger than most effects worth measuring. Alternate instead — base, measure, base, measure — which gives one minimum-of-sums per arm per repetition, and require the delta between them to agree in sign across the repetitions. A repetition that disagrees says the run was contended, not that the effect is small: on one dsv4 bind the Definition segment came out +0.46 ms against −0.20 ms on the other three, and the same bind carried a run whose sm_h2d was 5.93 ms against a 0.6 ms norm.

One statistic decides: the minimum of the per-bind sums. Sum the control-plane segments within each bind, take the minimum across the warm binds, and compare those. A min of sums is not a sum of mins and the two can disagree in sign — each segment's minimum comes from whichever bind was quietest for that segment, so summing per-segment minima produces a total no bind achieved. On one dsv4 comparison the sum-of-minima moved −0.30 ms while the minimum-of-sums moved +0.16 ms, from the same log. hbg_bind_phases reports the minimum-of-sums as its total row; never assemble a total by hand from the per-phase min column.

The median and the max in that table are not a second decision rule. Read them for one thing only: a change that lowers the minimum while widening the range has made the cost less predictable, which is a cost of its own and worth reporting alongside the minimum.

Judge a segment the diff does not touch. host_orch's own scatter on dsv4 spans 2.6–4.9 ms across binds of an unmodified main — wider than most changes being tested — so a ±0.5 ms difference there is not resolvable by comparing durations however many rounds are run. When a segment matters and its scatter swamps it, instrument the mechanism instead: a sub-counter around the suspected work answers in one run what a duration comparison cannot answer in ten.

Recipe B — one round with a swimlane

The summed host-orch phase= lines cannot be placed on a timeline inside host_orch: they are cost shares. The per-event view comes from the runtime's per-producer record pool, written to outputs/<case>_<ts>/host_phase_records.jsonl — one record per orchestrator operation, each with its own interval.

Three conditions must all hold, and the first two produce an empty result silently:

  1. SIMPLER_HBG_HOST_PHASE_RECORDS_ENABLE=1, which is what arms the pool. SIMPLER_HBG_BIND_BREAKDOWN_ENABLE does not: it gates the summed the segment spans alone, so Recipe A's environment collects no records.
  2. A diagnostic flag must be on, because that is what makes CallConfig.output_prefix non-empty. --enable-scope-stats is the cheapest choice when only Host phase records are needed. --enable-chip-swimlane is also supported for same-host L3 and writes each ChipWorker capture below a separate rankN/dN directory, but it collects substantially more data.
  3. --rounds must be 1. rounds > 1 force-disables every diagnostic flag — this one does warn, <flag> disabled: --rounds > 1 per flag (simpler_setup/scene_test.py), but the warning sits in a log whose run otherwise passed.

The device run may be skipped or may fail; neither costs you the artifact. Every phase recorded is host work done during bind, so both SIMPLER_SKIP_DEVICE_RUN and the device-run teardown write it. That is what lets a case whose device execution does not complete still yield its prepare timing — and it is why a swimlane for a case that hangs on device is cheaper to take with the variable set than to take by waiting out the stall.

The flag that satisfies condition 2 also moves the log. A non-empty CallConfig.output_prefix enables one transient Host-log spool for the whole process tree under ${TMPDIR:-/tmp}/simpler-host-logs-<root-pid>-<uuid>/; it does not bind the logger to that capture. Segment and [STRACE] spans leave stderr together, so Recipe A's grep -c 'name=chip.run.bind\.' "$LOG" still reports 0 for a Recipe B run that worked perfectly. SceneTest consumes the spool before process exit. A manual workflow that parses after exit must prebind a persistent directory before the first output run; the automatic session binder preserves it:

import os
from pathlib import Path
from _task_interface import _set_host_log_directory

log_dir = Path(os.environ["SIMPLER_FULL_HOST_LOG_DIR"])
log_dir.mkdir(parents=True, exist_ok=True)
_set_host_log_directory(str(log_dir))

The skill's timeline mode uses that prebound directory and finishes with

D=outputs/<case>_<ts>
H="$D/full_host_logs"  # SIMPLER_FULL_HOST_LOG_DIR used by the runner above
cat "$LOG" "$H"/host.*.log > "$D/bind_timeline.log"
python -m simpler_setup.tools.strace_timing "$D/bind_timeline.log" \
    --host-phase-records "$D/host_phase_records.jsonl" \
    --swimlane "$D/host_swimlane.json"

Load the JSON in Perfetto or chrome://tracing. Each rank is its own pid lane, and every record is drawn inside its chip.run.bind. The same command with the dsv4 log and its records file gives the two-rank version; dropped in the artifact's header says whether the pool truncated anything (it is 0 for both cases: 337 records for qwen, 1887 per rank for dsv4).

Reading the result

The first round is cold, and not by a little. On dsv4's first bind static_arena spends 97.8 ms allocating the 2 GiB ring heap against 0.002 ms on later binds, and host_orch runs 8% long. Recipe B therefore describes structure — the order of operations, the per-operation distribution — while Recipe A gives the numbers.

Per-operation records show tails the sums hide. dsv4's submit_task has a median of 0.28 µs and a maximum of 54.49 µs, a 195× spread that a total_ns / count mean reports as 0.68 µs.

Not all of host_orch is instrumented. The gaps between records — fanin computation, tensormap registration, scope bookkeeping — are 22% of host_orch on qwen and 25–30% on dsv4, and they are the second-largest item inside it. Subtract the records' sum from the segment to see them.

heap_used= on host_orch is the run's real GM footprint, so it is the metric for any change to allocation or to the Graph expansion pool. It is exact and repeatable — byte-identical across runs — which makes it a better regression signal than any duration on a shared box.

Traps

Trap Symptom What to do
Any diagnostic flag on (so, every Recipe B run) $LOG has no [STRACE] markers at all, run passes the output run moved the host log to the process-session spool; parse it before exit or prebind a persistent directory as shown above
A SceneTestCase with device_count > 1 run through the module runner log has zero segment spans, test passes give the child command --runtime <rt> --level 3; a standalone main.py case needs nothing
SIMPLER_SKIP_DEVICE_RUN=0 run still skips the device, "PASSED" means nothing ran unset the variable
--rounds 6 with --enable-scope-stats no outputs/<case>_<ts>/ artifacts, plus a disabled: --rounds > 1 warning one round for artifacts, many rounds for numbers
Only SIMPLER_HBG_BIND_BREAKDOWN_ENABLE set for Recipe B segment spans present, no host_phase_records.jsonl the records are a separate switch: also export SIMPLER_HBG_HOST_PHASE_RECORDS_ENABLE=1
Comparing a log with no [stamp] first line the parser says so above the table re-run it through the recipe; conditions cannot be recovered from memory
Treating the parser total as the complete control plane work outside the three segments looks like a host-side win check the source coverage in both arms; the subtotal now includes Definition packing but still excludes Graph task binding, compact execution-image preparation and A5 scheduler work
Subtracting timestamps for the control-plane subtotal includes work outside the selected segments sum the measured segments within each bind; they do not form a contiguous interval
Summing per-segment minima by hand a total no bind achieved; can invert the sign read the tool's total row — the minimum of the per-bind sums
--rounds 1 for numbers the tool refuses: every bind is a rank's warm-up six rounds; --keep-first only to look at the cold bind deliberately
Single bind, or comparing across differently-loaded moments swings of 3.5× six rounds, compare minima, keep an untouched segment as a control
Reading a warm bind as a steady-state one the first few warm binds carry several hundred more minflt than the last ones, and a per-bind average built from them is a warm-up figure wearing a steady-state label the parser drops the cold bind per rank, not the decay after it: on dsv4 host_orch's minflt runs 989, 983 (cold), then 114, 173, 54, 3, 13, 13, 11, 8. Take twelve rounds and read the last binds, or check the count column is flat before quoting a duration
Dividing a total by the bind count a per-bind figure no bind ever had, inflated by the cold and decaying ones bucket by bind first and look at the sequence; only quote a mean over binds whose counts already agree
base then measure, sequentially a load drift reads as the branch's effect interleave the arms and require the sign to agree per repetition
Stale build mass collection errors, or a launch_aicpu_num (0) failure pip install --no-build-isolation -e . after every HEAD move

Reference numbers

Historical measurement scope. These numbers predate #2353, and the segment was named graph_upload when they were taken: its duration includes host preparation as well as H2D. #2353 then narrowed it to the publication copy alone, and this PR renamed it graph_pack, put preparation back inside it and removed its H2D. So the row below is neither the current segment's scope nor its name, and the current three-segment subtotal is not this table's. It is a record of the old measurement, not a current baseline; a smaller current value alone establishes no speedup.

Both columns are one measurement session on main at 777d4171, host host_build_graph, on one a2a3 die for qwen and two for dsv4, four rounds each with the warm-up bind dropped — 3 warm binds for qwen and 6 for dsv4, since a 2-rank case emits one bind per rank per round and the parser drops one cold bind per rank. Durations are the full range across those binds; heap_used, every bytes= and every count are exact and repeat byte-identically.

Warm, not steady-state. Dropping one cold bind per rank does not reach the steady state: the decay behind it takes about six binds on dsv4, so a four-round session spends most of its warm binds inside it. Both columns therefore sit somewhere in the warm-up, which is another reason to treat them as orientation rather than as a baseline — see the trap table above.

For orientation, not thresholds, and pinned to a commit for a reason. The machine's other tenants move every duration here, so the range is the point: a change smaller than the range beside it cannot be demonstrated by comparing two runs. The counts move too — they are properties of the cases, and the cases are edited. Re-measure rather than trusting this table; the recipes above are the part meant to outlive it.

Measurement qwen3-14b decode dsv4 FLASH decode
historical control-plane total † 1.11–1.53 ms 3.63–6.81 ms
host_orch 0.44–0.75 ms (47 tasks) 2.60–4.91 ms (1131 tasks)
graph_upload (this segment's name at that commit) 0.56–0.96 ms / 40 submissions, 232,320 B † 0.39–1.14 ms / 20 submissions, 671,144 B †
sm_h2d † 0.067–0.068 ms / 233,799 B 0.54–0.98 ms / 5,620,195 B
arena_h2d † 0.035–0.039 ms / 632 B 0.03–0.10 ms / 632 B
heap_used 127,673,344 2,038,508,544
device wall 39.3 ms does not complete yet (sched_error_code=5 INVALID_ARGS)
args (excluded) 1.37 s / 40.9 GB, 19 of 20 copied in 1.48 s / 45.8 GB, 77 of 92 copied in
host_view_close (excluded, legacy mapping path) 0.25 s / 40.9 GB 0.28 s / 45.8 GB

† The total and three upload rows use the measurement scopes and segment names at that commit. Before the upload was restructured, graph_upload's bytes= also counted the Graph submission block, sm_h2d was still a copy of its own, and arena_h2d was the copied zone alone. A run today has no sm_h2d kind at all, emits graph_pack rather than graph_upload, counts only the Definition objects in it, carries no submission block, and ships both remaining regions in arena_h2d. The segment name, the byte accounting and the timing boundaries have all changed, so no row here is comparable to a current reading of the similarly-placed segment.

dsv4's args and host_view_close rows no longer describe that case at this scale. Both are per-byte costs over what a bind copies in, and dsv4's parameters now live in child memory: allocated once before the first round, and passed through without malloc, H2D or a host view. What still crosses is num_tokens_per_owner, the one caller tensor the host orchestrator has to read — so a bind copies in 1 of its 92 tensors, 8 bytes. On dcf7559e8, 12 binds (--rounds 6, both ranks) measure args at 0.036–0.075 ms and host_view_close at 0.0012–0.0030 ms with count=0 bytes=0, against 1.48 s and 0.28 s over 45.8 GB above. The same run peaks at 1.31 GiB of host RSS across the whole process tree under --skip-golden, and at 23.4 GiB when the fixture is streamed in, where the row above cost ~45.5 GB per rank. qwen still copies in its fixture.

The rows also describe the legacy mapping behavior at the pinned commit. A current bind uses the caller's existing host buffers as its orchestration views, so it performs no halHostRegister calls and reports host_view_close count=0 bytes=0. On Qwen3-14B this makes the close marker 20.12–24.73 us instead of the 0.25 s shown above. The old args figure included 20 registrations in addition to copying 19 tensors in H2D; current args retains the H2D work but removes that registration side.

Three of these deserve reading together. host_orch is the whole story on dsv4 — 839 submit_task, 743 record_sub_task and 272 alloc_tensors per bind against qwen's 5, 277 and 2 — and its 2.3 ms of scatter is why a claim about it needs a sub-counter rather than a stopwatch. At the pinned commit, args plus host_view_close are two orders of magnitude above everything else while being excluded from the control plane: they are copy-in and legacy mapping costs over the ~41–46 GB of weights, not graph dispatch. Current qwen runs retain the copy-in cost in args but close no mappings; moving dsv4's parameters to child memory left its bind copying in one 8-byte tensor, whose caller-buffer view also needs no mapping. And dsv4's device wall is absent because the case did not complete on device at the pinned commit — it is a completion case with no golden whose host path is what these numbers describe, which is also why SIMPLER_SKIP_DEVICE_RUN appears in its recipe.