Global DFX Backpressure — block-on-contention + dual-signal freeze¶
The shared design for how every DFX profiling subsystem (L2Swimlane, PMU, DepGen, ArgsDump, ScopeStats, tensor_dump) reacts when the host collector cannot keep the device-side buffer pool refilled. It replaces the older "bounded wait, then drop the record" model with a resident block-on-contention freeze: an AICPU writer that runs out of ready-queue space or free buffers parks at its buffer-switch gate until the host clears the contention, rather than dropping profiling data.
Read alongside the code it documents:
src/common/platform/include/common/dfx_backpressure_device.h— the sharedDfxBackpressureHeaderlayout and the AICPU-side primitives.src/common/platform/include/aicpu/profiler_device_engine.h— the device producer control flow that calls those primitives (wait_for_ready_queue_space,wait_for_free_queue_entry,enqueue_ready).src/common/platform/include/host/profiler_base.h—ProfilerAlgorithms::update_backpressure_freeze, the single host-side freeze state machine.- Capacity sizing that keeps this path cold in normal runs: dfx-buffer-capacity-audit.md.
Why block instead of drop¶
The device writers sit on the AICPU scheduler's critical path. The previous model gave them a short bounded wait for a ready-queue slot or a free buffer and, on expiry, counted a dropped record and moved on. That keeps the workload moving but silently loses profiling data exactly when the system is most interesting (contended). For DFX correctness we prefer to stall the producer and lose no records, provided the stall cannot deadlock and cannot hang forever on a host crash. The mechanism below delivers that: resident blocking, bounded only by a 30-second host-crash backstop, with no opt-out.
The two gate classes (issue #997)¶
An AICPU writer only ever blocks at one of two buffer-switch gates, so the coordination is split into two independent freezes — one per gate class:
| Gate | Blocks when | Device signal | Host freeze |
|---|---|---|---|
push (rq_*) |
the per-thread ready queue is full (next_tail == head) |
rq_contended |
rq_freeze_active |
pop (fq_*) |
the free queue is empty (head == tail) |
fq_contended |
fq_freeze_active |
*_freeze_active are host→device; *_contended are device→host leader signals
that the host consumes and clears. All four are volatile uint32_t 0/1 flags in
the DfxBackpressureHeader that every subsystem's DataHeader embeds once
(physically per-subsystem: each subsystem has its own shared-memory region, so
swimlane's freeze is independent of PMU's).
Splitting by gate class is what makes the freeze common-mode: every lane that reaches a given gate parks at the same gate, producing one lane-aligned gap in the swimlane instead of one lane sparsifying and skewing a bottleneck read.
Device side¶
Three primitives in dfx_backpressure_device.h, driven from
profiler_device_engine.h:
- Leader signal —
mark_rq_contended/mark_fq_contendedset the*_contendedflag once per wait (guarded by a localsignalledbool). The host observes the sticky flag on its next mgmt tick and opens the matching freeze. - Peer freeze barrier —
push_freeze_barrier/pop_freeze_barrierspin while the host holds the freeze open (*_freeze_active != 0), relaxing via the repo-wideSPIN_WAIT_HINT()(a no-op on onboard silicon,sched_yield()in sim). Each carries an acquirermb()after the gate so the host's pre-release queue writes are visible to the reads that follow. - Leader park —
wait_for_releaseis used only by a leader whose own reclaim depends on the host completing a full open→drain→release cycle on the free-queue side (only tensor_dump's arena barrier today). It spins on the disjunctionfq_contended || fq_freeze_active.
The push and pop loops in the engine bridge the host round-trip: raising
*_contended does not itself park the lane — until the host opens the freeze the
barrier sees *_freeze_active == 0 and returns at once, so the loop re-checks
the queue and picks up a slot the instant one appears. Once the host has opened
the freeze, the barrier holds the lane until release.
Timeout backstop¶
Every barrier and contention spin is bounded by
Module::kBackpressureWaitCycles, which every subsystem points at the single
PLATFORM_DFX_BACKPRESSURE_TIMEOUT_CYCLES constant
(PLATFORM_PROF_SYS_CNT_FREQ * 30, i.e. a 30-second wall-clock ceiling that
scales per arch — a2a3 50 MHz, a5 1000 MHz). The budget exists only to break
an infinite spin on host crash or hardware failure; it is not a normal-path
wait. When it expires the writer breaks to its single failure exit
(return false), and the caller (switch_buffer) accounts the dropped records.
A 30-second expiry therefore means "the host is gone", not "the buffer was
briefly full".
Host side — the freeze state machine¶
ProfilerAlgorithms::update_backpressure_freeze runs once per tick of the
single mgmt_replenish_loop. It must be single-writer for *_freeze_active,
which is why it lives on the one replenish thread, not the (possibly sharded)
drain threads. It is idle at zero cost until a lane raises *_contended.
Open (per gate, independent). If a gate's freeze is closed and its
*_contended reads non-zero, set *_freeze_active = 1, publish it, then clear
*_contended. For the pop gate the freeze is opened before fq_contended is
consumed so the disjunction wait_for_release spins on stays continuously true
— see the escape-window note below.
Release (conjunction, both gates together). While either freeze is open, release both only once:
- the ready queue is fully drained (
queue_heads[q] == queue_tails[q]for every thread), and - the free queues are refilled to their attainable initial upper limit
(
replenish_free_queuesreturnspushed == 0), and - the per-subsystem
backpressure_release_ready()predicate holds.
Order matters: RQ-empty is checked first, because once the ready queue is empty
the drain threads are idle and it is safe for the replenish thread to be the sole
free-queue writer (drain-driven top_up_free_queue is suppressed while either
freeze is open). Resuming into RQ-empty + FQ-at-limit is one clean common-mode
gap with no immediate re-stall.
Per-subsystem release predicate¶
backpressure_release_ready() is a CRTP hook, default true. A subsystem whose
collector owns a separate, independently-overwritten region must override it to
hold the release until that region is drained — otherwise the device resumes and
overwrites not-yet-pulled data. Only tensor_dump does this today: RQ-empty
alone does not imply its payload arena has been pulled. The hook is evaluated on
the replenish thread inside the freeze loop, so overrides must be cheap,
non-blocking, and read only atomics.
Two correctness arguments¶
No (0,0) escape window (pop gate). wait_for_release spins on the
disjunction fq_contended || fq_freeze_active. If the host cleared
fq_contended before opening fq_freeze_active, a leader could observe both
zero mid-transition and escape the park prematurely. Opening the freeze before
consuming the contention keeps the predicate continuously true from
mark_fq_contended() through release.
Deadlock-free at any pool size. The conjunction release cannot wedge regardless of how small the pool is:
- RQ-drain is host-driven and independent of any buffer a lane holds at a push gate — the host can always empty the ready queue.
- "FQ refilled" is defined as
pushed == 0against the attainable initial limitmin(kSlotCount, BUFFERS), not an unreachable exact-kSlotCountfill.
So even a one-buffer-per-pool stress shape drains and releases. The AICPU writer also publishes its full buffer to the ready queue before trying to acquire a replacement, so the host can always observe the full buffer and return a recycled one.
Memory ordering across the PCIe boundary¶
*_freeze_active (host→device) writes go through write_range_to_device and
*_contended / queue_tails (device→host) reads through read_range_from_device
so a5 (non-SVM) sees them; a2a3 (SVM) short-circuits both to no-ops
because the header already lives in shared device memory. Device-side barriers
(rmb() after a gate, wmb() before publishing a freeze/tail) pair with the
host's wmb() before each write_range_to_device so a released lane never reads
a stale queue.
What this observably does under stress¶
When the pool is deliberately shrunk until the host cannot keep up, the freeze
fires repeatedly and the AICPU scheduler parks at the pop gate. Because the
scheduler is parked, in-flight tasks whose FIN it has not yet polled have their
recorded finish_time pushed out to the freeze-release instant — i.e. the freeze
inflates the very finish_time it is collecting. Task dispatch→finish
durations in the swimlane are then a measurement artifact of the freeze, not real
kernel time; the unaffected AICore-side start/end timestamps remain the
ground truth for kernel duration. This is expected: the mechanism trades producer
stall (and skewed AICPU-observed timing) for zero record loss, and normal-sized
pools keep the path cold so it never triggers in production runs.