Device-side phase timing — fixed AICPU phases + the variable-phase plan¶
The AICPU cannot write the host log on its hot path — logging perturbs the
very timing it would measure, and the AICore side has no log path at all.
Instead it stamps raw get_sys_cnt_aicpu() cycles into a host-allocated
buffer; the host reads the buffer back after stream-sync and converts cycles
→ ns. This is how device_wall has always worked; this doc describes the
generalization to a fixed set of phases, and the design for variable phases.
Fixed phases (implemented)¶
The on-NPU portion of simpler_run's blocking wait subdivides into a small,
closed set of phases, each of which fires exactly once per run per AICPU
thread. Because the cardinality is fixed, a plain indexed store suffices — no
ring, no rotation, no recycling. This is the same model the original single
run-wall {start, end} pair used, generalized to N pairs.
enum class AicpuPhase (src/common/platform/include/common/device_phase.h):
| Phase | Window |
|---|---|
RunWall |
whole-run AICPU wall (the legacy device_wall, slot 0) |
Preamble |
executor init + wait-for-init before orchestration |
SoLoad |
orchestration-SO dlopen (real cost only on a first/changed callable; ~0 when cached) |
GraphBuild |
orchestrate() — submit tasks (the scheduler dispatches concurrently). A container for the three prep sub-phases below plus the OrchWindow/SchedWindow work. |
ConfigValidate |
(inside GraphBuild, orch thread) config_func() + arg-count validate |
ArenaWire |
(inside GraphBuild, orch thread) attach the prebuilt runtime arena + wire device pointers |
SmReset |
(inside GraphBuild, orch thread) SM/ring reset + finalize + bind, up to releasing the scheduler threads |
PostOrch |
last-thread teardown after the run completes — a2a3 deinit invalidates host-DMA/SDMA-published state before reset; a5 resets scheduler state without cache invalidation. Stamped only on the thread that finishes last, so it is the real teardown tail, strictly after sched. |
OrchWindow |
orchestrator thread's submit window (the former device-log orch_start/end) |
SchedWindow |
scheduler thread's dispatch window (the former device-log sched_start/end) |
ConfigValidate / ArenaWire / SmReset break out the per-run prep the
orchestrator thread does inside GraphBuild, before the OrchWindow opens
(the scheduler threads spin-wait on runtime_init_ready_ across this whole
region). Before they existed this was an unattributed front-matter gap — on a
qwen3-14B 3.5k decode step it is ~22 ms, ~31 % of the device wall. They are
stamped only on the orchestrator thread (sched threads leave the slots unset; the
host reducer ignores them).
OrchWindow / SchedWindow replace the device-log Orch / Sched lines for
everyday use: the orchestrator and each scheduler thread store their
already-measured window into the host buffer, the host reduces across threads
(Sched = min(start)..max(end) over the scheduler threads) and emits them as
markers. The verbose per-thread orch_start=… / sched_start=… / Scheduler
summary (loops, tasks_scheduled) device-log lines are gated behind
SIMPLER_ORCH_PROFILING / SIMPLER_SCHED_PROFILING (default off) as an opt-in
deep-dive — see l2-timing.md.
Buffer layout & flow¶
- Host allocates one
AicpuPhaseRecord[NUM_AICPU_PHASES]per launched AICPU thread (thread-major), resets it each run, and publishes its address into the AICPU SO viaset_platform_phase_base()— a plain global +extern "C"setter, exactly likeset_platform_dump_base/set_platform_l2_swimlane_base(onboard:kernel.cppfromKernelArgs::device_wall_data_base; sim: the host dlsym's the setter). No C++thread_local(per dynamic-linking.md); the per-thread slot is resolved fromplatform_aicpu_affinity_thread_idx()(a pthread-key index). - Each surviving AICPU thread stamps its own slots — plain stores, no
atomics.
kernel.cppstampsRunWall; the inneraicpu_execute/ scheduler stampPreamble/SoLoad/GraphBuild/PostOrch/OrchWindow/SchedWindowviaget_platform_phase_base()+ the affinity index — no change to anyextern "C"signature. - Host reduces each phase across threads as
max(end) - min(start)on readback (DeviceRunnerBase::read_device_wall_ns), caches per-phase ns (last_device_phase_ns), and emits each non-zero phase as aclk=dev[STRACE]marker nested undersimpler_run.runner_run.device_wall(see host-trace.md).
Gating: SIMPLER_DEVICE_STRACE_ENABLE (host/device split)¶
The device markers can be turned off independently of the host spans so a deployment can profile the two domains separately:
- Device (
clk=devmarkers): the runtime envSIMPLER_DEVICE_STRACE_ENABLE, read once byemit_device_phase_markersin the hostc_api_shared.=0suppresses the whole device-phase emit; any other value (or unset) keeps it on (default on). It does not affect the on-device stamping cost (cheap plain stores) — only whether the host re-emits the readback as markers. - Host (
simpler_run/bind/runner_run/validatespans): no new knob — they ride the compile-timeSIMPLER_HOST_STRACEmacro and the log level (LOG_TIMING), so raising the log threshold drops them.
RunWall is the whole on-NPU wall (the former RunTiming.device_wall); it is
emitted as the simpler_run.runner_run.device_wall marker, not returned.
Reading the phases — they nest and overlap, don't sum them¶
The phases are not a flat partition of the wall — they nest, and some run
concurrently. Use each span's ts (device-domain start offset) + dur to see
the structure, not the bare durations:
device_wall (RunWall) ── time flows top → bottom (sequential) ──
│
├─ preamble init + wait-for-init
│
├─ graph_build (CONTAINER):
│ │
│ ├─ config_validate ┐
│ ├─ arena_wire │ prep (sequential, orch thread)
│ ├─ sm_reset ┘
│ │
│ └─ Effective ← orch & sched run SIDE BY SIDE (concurrent):
│ ┌──── orch (OrchWindow) ────┐ ┌──── sched (SchedWindow) ────┐
│ │ orchestrator submits tasks│ │ scheduler dispatches/drains │
│ └───────────────────────────┘ └─────────────────────────────┘
│ (finishes first) (outlasts orch)
│
└─ post_orch last-thread teardown (deinit), after sched
The vertical axis is time: preamble → graph_build → post_orch are sequential
(top to bottom), and inside graph_build the prep phases are sequential too. Only
orch and sched are drawn side by side because they are concurrent — both
start when the schedulers are released, and sched outlasts orch (the scheduler
keeps dispatching after the orchestrator has submitted everything). «Effective»
is not a stamped marker — orch and sched are the two real spans, and
strace_timing derives Effective = max(orch_end, sched_end) −
min(orch_start, sched_start) (their merged window).
So:
device_wall ≈ preamble + graph_build + post_orch(left-to-right, sequential —post_orchis the teardown tail aftergraph_buildreturns).graph_build ≈ prep + Effective, whereprep = config_validate + arena_wire + sm_reset(sequential) andEffective = orch ∪ sched(the concurrent window). You cannot addorch + sched— they overlap; use their unionEffective.graph_build − Effective≈prep— the front matter, now broken out. See l2-timing.md for how the rounds-table reportsEffective.post_orchis stamped only on the thread that finishes last (gated onfinished_), so it captures just thedeinitteardown. (An earlier version stamped it on every thread; an orchestrator thread that finished submitting early then sat idle waiting for the scheduler, and the cross-threadmax(end) − min(start)reduction absorbed that orch-waits-for-sched overlap intopost_orch— inflating it to tens of ms. It is now the real teardown.)
Worked example — qwen3-14B, 3.5k-context decode step on a2a3 (per-step
means): device_wall ≈ 71 ms, of which graph_build ≈ 71 ms (the container),
Effective ≈ 49 ms (orch ≈ 25 ms started at +22 ms, sched ≈ 49 ms), so the
~22 ms before the windows open — config_validate + arena_wire + sm_reset — is a
fixed per-decode prep cost that is now directly visible instead of only inferable
as graph_build − Effective.
Sim¶
Sim captures the same phases as onboard. RunWall comes from the host
steady_clock (the way sim has always measured device_wall); the finer
subdivisions (preamble / so_load / graph_build / post_orch + orch / sched) are
stamped by the AICPU threads inside the dlopen'd runtime SO, exactly as onboard.
Sim drives those threads through the basic affinity gate, which — unlike the
onboard filter gate — does not populate platform_aicpu_affinity_thread_idx().
The executor resolves the thread's index anyway (via its own fallback counter)
and publishes it with platform_aicpu_affinity_set_thread_idx() before any
stamping, so the per-thread phase-record slot resolves correctly inside the SO.
A phase whose measured duration rounds to 0 (e.g. preamble / graph_build on a
trivial example) is simply not emitted.
Selective task-timing slots (implemented)¶
A lightweight way to measure a specific task's (or an interval's) AICPU
dispatch→finish window without enabling the L2 swimlane — no collector
threads, no per-task AICore records, works in SIMPLER_DFX=0. See
l2-timing.md for how it relates to the swimlane's finish_time.
- Opt-in is per task, by tagging. Orchestration calls
L0TaskArgs::set_task_timing_slot(id)(idin0..15; forwarded byL0TaskArgsWithDeps). Untagged is the default sentinel (TASK_TIMING_SLOT_NONE = -1); an out-of-range id fails through the standard invalid-arg path. No env var and no compile gate — tagging is the only switch. An untagged task's only added hot-path cost is one cache-hot sentinel compare; it never readsget_sys_cnt_aicpu(). - Transport reuses this same buffer. The id rides the scheduler's hot
PTO2TaskSlotStatein theTaskAttrsbyte (bit 3is_timed+ bits 4-7 the 0..15 tag), co-located with the other per-task scheduling flags. The 16 slots are a fixedTaskTimingRecord[16]tail appended after theAicpuPhaseRecordregion in the same device buffer — same base pointer, same per-run H2D reset and post-sync D2H readback. It is a distinct record type (dispatch/finish, not start/end) reduced by min(dispatch) / max(finish), not anAicpuPhase(those fire once per run/thread; a slot takes many block/subtask events). - Boundaries (match the L2 swimlane).
dispatch= the earliest Scheduler publication ofDATA_MAIN_BASE(after payload publish, immediately before the register write; the initial gated publication for early/speculative dispatch, not the doorbell release).finish= the latest Scheduler FIN observation across every required block/subtask (after the COND load +rmb(), before fanin/fanout/deferred-completion processing) — not the AICore kernel-end. - Aggregation is intentional. For each slot the host reduces
min(dispatch)/max(finish)across Scheduler threads. Reusing one slot for several tagged tasks yields the window from the earliest tagged dispatch to the latest tagged finish (duplicate-slot = merged window). Distinct slots keep each task's own window, so tooling recoversfinish(B) − dispatch(A). A MIX task's AIC/AIV0/AIV1 subtasks and an SPMD task's blocks (dispatched by different Scheduler threads) all fold into the one tagged slot. - Emission & reset. Every complete slot (dispatch set, finish > dispatch) is
emitted as a stable
clk=devspansimpler_run.runner_run.device_wall.task_slot_<N>, retaining start and duration so cross-slot intervals are recoverable;Worker.run()still returnsNone. All slots are reset every run, so a failed/short run cannot leak stale data; unset or incomplete slots are skipped. Slots share the phase timeline'soriginwhen phases were stamped, and fall back to the earliest tagged dispatch when they were not (e.g.host_build_graph, whose device side stamps no orch/sched phases). - Thread index. The fold uses the Scheduler's own thread index (not
platform_aicpu_affinity_thread_idx()):host_build_graphhands its schedulers a local index because the sim affinity gate leaves the affinity idx-1, so resolving via affinity there would drop every write. Any distinct valid index per thread yields the same min/max reduction. - Dummy tasks.
alloc_tensorsbuilds a kernel-less descriptor that never dispatches; itsTaskAttrsstart cleared (untagged) so a recycled ring slot cannot leak a stale tag.
Seams: common/device_phase.h (TaskTimingRecord, reduce_task_timing_slots,
buffer-layout helpers), aicpu/device_phase_aicpu.h
(aicpu_task_timing_dispatch/finish(slot, thread_idx)), the a2a3
scheduler_dispatch.cpp / scheduler_completion.cpp fold points, and the
host reset/readback/emit in the onboard + sim runners and c_api_shared.
Variable phases (design, not yet implemented)¶
Phases entered an unknown number of times — per-submit, per-scheduler-loop,
arbitrary nesting — cannot use the fixed-slot buffer: there is no slot count
that bounds them, and the fixed buffer has no rotation (nobody recycles a
full buffer). These already have a home: the L2 swimlane orch/sched pools
(L2SwimlaneAicpuOrchPhasePool / SchedPhasePool), which carry a
head + free_queue ring that the host drains continuously. That is where
l2_swimlane_aicpu_record_orch_phase / record_sched_phase already write.
The two tracks split cleanly by cardinality, and that split also resolves how each phase is tagged:
| aspect | fixed (this buffer) | variable (L2 swimlane pool) |
|---|---|---|
| cardinality | once per run per thread | many per run (loops / nesting) |
| storage | indexed slot, no rotation | ring with head + free_queue rotation |
| tag | a stable slot index → a small closed AicpuPhase enum is fine |
append-only → no slot needed; use a compile-time FNV-1a hash of the call-site name literal so adding a phase is a one-line change with no central registry |
The variable track is intentionally left as a follow-up: a name_hash-tagged
RAII scope writing the swimlane ring, with the host recovering hash → name from
the orch SO's .rodata (or a dedicated section) so no hand-maintained map is
needed.
Aligning host and device on one timeline (future)¶
Host spans use CLOCK_MONOTONIC ns; device phases use AICPU cycles. They are
reported in the same [STRACE] grammar (clk=dev marks the cycle-derived
durations). To place both on one Perfetto timeline later, the device side
would emit a periodic clock.anchor line pairing one host_ns with one
dev_cyc + dev_freq; the parser then maps device spans onto the host axis.
The marker v= field and k=v extensibility reserve room for this — it is not
part of the current host-only work.