dep_gen — Complete Per-Submit Dependency Graph (Tensor-Annotated)¶
1. Background & Motivation¶
The swimlane profiler used to expose a per-task fanout[] array — the
obvious place to read "which tasks did task X feed into?" — but it was
structurally incomplete on real hardware, so it has been removed from
the record entirely. The device hot path no longer carries fanout;
deps.json is now the sole source of truth for swimlane edges.
When it existed, each producer task carried its own
ChipSwimlaneAicpuTaskRecord.fanout[], populated by the AICPU scheduler at the
moment it wired a downstream consumer. If a producer had already finished and
transitioned to CHIP_TASK_COMPLETED by the time a later submit wanted to
register a dependency on it, the consumer's edge had nowhere to go — the
record was sealed, the slot was closed, and the edge was silently dropped.
This was not a bug in fanout itself; fanout is "successors known at runtime",
not "successors discoverable from the orchestrator's input". Race window in
#599; see also the
PR #500 archive for the historical attempt to fix this in-place.
dep_gen sidesteps the race by building the graph from the
orchestrator's side of the dependency computation rather than the
scheduler's, so a producer that has already retired is still an edge. How
that is done depends on where the orchestrator runs — see §2 for the two
shapes — but the output is the same deps.json, and either way it was a
strict superset of the old fanout edges: every fanout edge appeared in
deps.json, plus the edges fanout dropped due to the race — which is why
deps.json now fully replaces the removed fanout[].
2. Overview¶
dep_gen has two shapes, chosen by where the orchestrator runs. Both emit the
same deps.json (§4), so every consumer — deps viewer, swimlane join — reads
either one the same way, with one documented exception: an edge's flags
(WAIT/RETAIN) describes tensormap_and_ringbuffer's own dependency computation,
which host_build_graph has no equivalent of, so only the device-orchestrated
shape emits it. A consumer must treat flags as optional.
2.1 Device orchestration (tensormap_and_ringbuffer)¶
The orchestrator runs on the AICPU, where the host cannot observe it, so the inputs to each submit are captured and the graph is reconstructed afterwards.
- Capture point.
pto_orchestrator::submit_taskwrites aDepGenRecord(task_id, scope flag, tensor blobs, arg types, explicit deps, kernel ids, and SPMD block num) into a per-thread shared-memory ring buffer for every call whenenable_dep_genis on. - In-memory drain. The host
DepGenCollector(background mgmt thread, ProfilerBase machinery shared with PMU / L2 Perf / Tensor Dump) drains the ring into astd::vector<DepGenRecord>resident on the runner. Nosubmit_trace.binlands on disk — the host already has the records once the run ends, and going through the filesystem would just be extra I/O. - Host replay (dual-pass, self-checking). After
reconcile_counters()confirms a clean trace (no drops, no leftovers),dep_gen_replay_emit_deps_jsonruns every record back through two parallel host-residentChipTensorMapinstances that evolve in lockstep: - Oracle pass drives the canonical
compute_task_fanintemplate fromdep_compute.hand collects the producer-id →DepFlagsmapping the runtime would have emitted (flags OR-accumulated per producer). - Annotated pass runs an inlined mirror of STEP A
(creator retention) + STEP B (tensormap lookup) against the second
map, with a wider callback so each edge gets recorded with its
tensor metadata (producer/consumer shape + offset, dtype, version).
Per-record semantics mirror runtime
submit_taskexactly: STEP 1 (explicit deps), STEP 3 (creator retention + tensormap lookup), STEP 4 (register outputs). Per-successor dedup matchesFaninBuilder::append_fanin_or_fail. After both passes finish per record, the replay asserts the two producer-id →DepFlagsmappings are equal (same producers and same per-producer flags); if they diverge,deps.jsonis not written and the function returns non-zero. This is the guarantee against silent shotgun modifications — anyone who changescompute_task_faninsemantics will trip the gate immediately and know to update the annotated mirror. Explicit deps are a common captured input to both passes, so this gate cannot independently validate their recorded kind bytes. The a2a3/a5 dep_gen chain scene tests validate that capture/replay round-trip across inline and overflow records. - Output.
<output_prefix>/deps.json— strided-Tensor schema withtasks[],tensors[], and tensor-annotatededges[](see §4). - Edges are as-constructed, not as-reduced. Both passes replay the same
fanin construction, so
deps.jsonrecords the pre-reduction edge set. Thetensormap_and_ringbufferruntime's bounded bitmap transitive reduction (reduce_wait_edges, applied when the builder flushes insubmit_task_common) runs after construction and clears thewaitflag on a direct edge already covered by a WAIT path through the producer's own transitive ancestors. A diamondA→B→C+A→Cstill showsA→Cwith its constructed flags. The differential gate is unaffected — both passes replay the same construction. What the reduction changes is the edge's flags: a redundantWAIT|RETAINedge demotes to RETAIN-only and a redundantWAIT-only edge drops toDEP_NONE, with the entry kept either way, so retention and pin-release accounting are preserved and only readiness enforcement is relaxed.wait_reduction_simreplays the reduction offline over a capture to measure its coverage against the full-DAG upper bound.
2.2 Host orchestration (host_build_graph)¶
The orchestrator runs on the host, to completion, before any scheduler thread starts. Every submit and every tensormap hit is therefore observable in-process and in order, so the graph is recorded as the runtime builds it — there is nothing to capture-then-reconstruct.
- Capture point.
submit_task_commonopens the task's entry, andcompute_task_fanin'sAnnotatehooks fire on each producer the runtime actually wires: creator retention (Step A) and tensormap lookup (Step B), plus the declared dependencies at STEP 1. The edges are the runtime's own, not a replay's inference, so they cannot drift fromcompute_task_faninsemantics. - No ring, no collector, no replay. The device-side dep_gen writer, its
shared-memory ring, and the drain thread are all skipped
(
dep_gen_host_graph_active()tells the runner). Nothing is dropped under back-pressure because nothing is streamed. - Output. The same
deps.json, written at the end of the run's ownbind— the point where host orchestration completes and the graph is final. The graph is thread-local, so it is written on the thread that captured it rather than waiting for the drain, which the run lane does not pin to that thread: it serializes with a mutex, which gives mutual exclusion but not thread affinity. A consequence worth knowing: a run whose device execution later fails still leaves itsdeps.json, because its orchestration did happen.
3. How to Enable¶
dep_gen is gated by CallConfig.enable_dep_gen (alongside
enable_chip_swimlane, enable_dump_args, enable_pmu). The CLI flag
is --enable-dep-gen:
# Standalone
python test_my_case.py --platform <a2a3|a5> --enable-dep-gen --enable-chip-swimlane
# Pytest
pytest tests/st/... --platform <a2a3|a5> --enable-dep-gen --enable-chip-swimlane
The --enable-chip-swimlane flag is independent but recommended in pair
because:
deps.jsonis the dep_gen artifact.chip_swimlane_records.json(from swimlane) is the timing artifact;merged_swimlane.json(the Perfetto trace) usesdeps.jsonfor dependency arrows when both files exist (without it, the trace has no dependency arrows — the record no longer carriesfanout[]).
For perf-sensitive runs where you'd rather measure each profiler in isolation, see the split workflow described in chip-swimlane-profiling §3.5 — one dep_gen capture per topology, then any number of swimlane runs that the converter joins back to that captured graph.
When --enable-dep-gen is on with any other diagnostic flag, an
output_prefix directory must be set (the runtime throws otherwise).
The standard SceneTest path
(outputs/<TestName>_<case>_<timestamp>/) handles that automatically.
3.1 Background output (collect_across_runs=True)¶
With Worker(collect_across_runs=True) on a level-3 worker, a
tensormap_and_ringbuffer run keeps every step that touches the device on its
own boundary — the receive drain and the terminal read — and hands the replay,
the serialization and the file write to one background writer, so the host
finishing run N can overlap run N+1's device execution. Device operators stay
serial and diagnostic exclusivity is unchanged. Off by default.
host_build_graph is covered too, by §3.2 — its graph is finished on the host
inside submit, so what moves off the submit path there is the serialization and
the write, not a device boundary.
run() returning no longer means deps.json exists.
Worker.flush_diagnostics() is the barrier that says the files up to that point
are published, and close() publishes and joins. Both report a failed
publication rather than reporting success over a missing file: the writer
records a run's verdict before it makes that run's drained state observable, so
a flush arriving between the two cannot see a settled collector with no error. Two runs may be unpublished at
once, counting the one still collecting; a third is refused before anything is
submitted, and the caller may flush first. Task ids are run-local, so two runs
of the same callable repeat them — a graph is identified by the directory it is
published under, never by its task ids.
Publication is atomic: the graph is written to deps.json.tmp opened
O_CREAT|O_EXCL and published with link, which never replaces an existing
name. A destination already holding a deps.json therefore fails the run with
that file untouched, and a partly written graph is never visible under the real
name.
3.2 Background output for the host-built graph¶
host_build_graph builds the whole graph on the host, during submit, and the
default path serializes and writes deps.json right there — before the run is
even launched. With collect_across_runs=True on a level-3 worker the finished
graph is instead moved out of the capturing thread's storage into an export
the runner owns, and a background writer publishes it. The hand-off still happens
on the thread that built the graph: capture lives in thread-local state, so that
is the only thread that can hand it over (test_dep_gen_thread_affinity.py
exists because an emit moved to drain produced no file at all).
What authorizes publication is therefore a completed host orchestration, not
a successful run: the device contributes nothing to this graph, and there is no
device record to reconcile or terminal state to read. So a deps.json can exist
for a run that later fails on the device, that was never launched because a
later step of prepare failed, or whose device completion is unknown. That is
what the default path already does, and it is deliberately not the
tensormap_and_ringbuffer rule above, whose quarantine exists because its
records live on the device.
Three states are told apart, where the default path has one error code for all of them:
| At hand-off | Result |
|---|---|
| the orchestration ran on this thread and closed every task | published — an orchestration that submitted no tasks publishes a well-formed empty graph |
| capture was never armed, or the orchestration ran on another thread | no file, and the flush reports it |
| a task was opened and never closed | no file, and the flush reports it |
Retention bounds what is kept, not what may run. At most two unpublished graphs, against the same 256 MiB per-exporter budget, charged from the actual capacity of the five vectors an export holds plus a 1 MiB serialization reservation and a fixed destination per slot. When both slots are taken, or a graph is larger than the budget can accept, the calling thread publishes it immediately rather than failing the run — that submit waits for the write, as it did before this existed, and nothing is dropped. A legitimately large graph can exceed the budget and take that route.
The budget covers what is retained after the hand-off and nothing else: the
capture's own peak during submit, an inline write's file buffering, thread
stacks, allocator metadata and the file on disk are all outside it.
Both routes publish the same way — an exclusive deps.json.tmp, the stream
flushed and closed with its state checked afterwards, then link, which never
replaces a name. So an occupied destination fails that run's diagnostic with the
existing file untouched, and a partly written graph is never visible under the
real name. A temporary left behind by something else makes the publication refuse
rather than be deleted. The output directory plus the file name must fit 4096
bytes; a longer one is refused by name.
flush_diagnostics() waits for every publication already under way and reports
sticky failures; close() closes admission, drains, and joins the writer. A
flush does not wait for a submit racing it that has not yet declared a write —
such a call finishes after the flush's linearization point, like any later
submit. No bounded-close() promise on a slow disk.
First step is local level 3 on a2a3/a5 (onboard and sim). Level 2 — which is
what tests/st/{a2a3,a5}/host_build_graph/dfx/dep_gen/test_dep_gen.py uses —
keeps the synchronous path, and so does every run with the option off.
One whole graph, or no file¶
deps.json has no metadata line and no completeness field, so there is nowhere
to mark a partial graph. Either the whole graph is published, or no new file is
produced for that run and an error is reported — and any file already at the
destination is left as it was, which is not the same as this run having produced
a graph. The refusals are: a device drop, a buffer the device still held, a
broken count identity, a device counter at its saturation sentinel, a failed
device read, an unresolvable in-flight buffer, a host-side record clamp, a
record stamped with a run this collector never admitted, a counter reset that
did not reach the device, a record whose ring or local id is out of domain, a
refused memory charge, and a failed replay or write. In background mode these
are sticky for the runner's life and reported by flush_diagnostics() and
close(); a run whose device completion is unproved reads nothing shared and
publishes nothing.
Memory¶
Each collector has its own 256 MiB host budget for retained records and output work — not shared with other collectors, and not a whole-Worker limit. It covers the record blocks, the two export slots and their bounded path storage, a 1 MiB serialization reservation, alignment, and the replay's actual working storage: the contiguous record layout the replay indexes, the two host tensormaps (about 17.1 MiB of floor per replay), and the task, tensor and edge tables. One tensor can name several producers, so edges and indexes are charged as they actually grow, including a reallocation's old and new blocks at once. Record blocks are charged before they are allocated and credited only after they are freed. The writer asks for a publication's working storage only once it has taken that export, so there is one working set at a time however many exports are sealed. Over-budget, out-of-domain and arithmetic-overflow conditions all refuse the graph rather than allocating, and no wait is introduced for storage a future credit might supply.
Outside that budget, and separately bounded: the fixed device buffer pool (about 18.5 MiB), its host shadow on non-SVM platforms, and the fixed control region — one of each per collector per device context. Thread stacks, allocator metadata and system file buffers are not accounted in the application budget, and the output file is disk rather than host memory.
Device counters¶
The three device counters saturate at UINT32_MAX instead of wrapping, and a
counter reading it means the run passed the countable limit: its counts are
unknown and no graph is published. This costs a compare at each increment on
every dep_gen-enabled run, and it changes bytes in the shared AICPU image that
host_build_graph also links.
With background output off¶
The default path is unchanged in filename, location, schema and the bytes a clean run produces, and it still writes at the original boundary. What changed is that the refusals listed above now withhold the file there too, reported on that path's existing log channel — no sticky error is added, the run's return code does not change, and no 256 MiB limit applies. The host-built graph's default path is unchanged in the same way, including its overwrite behaviour; what it gained is the write-error check the stream only reports at flush and close, which was previously read one step too early.
4. Output: deps.json¶
{
"runtime": "tensormap_and_ringbuffer",
"tasks": [
{"task_id": "0", "scope": "auto", "kernel_ids": [-1,-1,-1], "block_num": 1, "args": []},
{"task_id": "4294967296", "scope": "auto", "kernel_ids": [7,-1,-1], "block_num": 4, "args": [
{"idx": 0, "type": "INPUT", "tensor_id": "13451765318376212391",
"dtype": "FLOAT32", "shape": [16384],
"start_offset": "0", "strides": [1]}
]}
],
"tensors": [
{"tensor_id": "13451765318376212391",
"buffer_addr": "29204938752", "version": 0,
"dtype": "FLOAT32", "buffer_numel": "16384"}
],
"edges": [
{"pred": "0", "succ": "4294967296", "arg": 0, "source": "creator",
"flags": ["wait", "retain"],
"tensor_id": "13451765318376212391", "consumer_dtype": "FLOAT32",
"consumer_shape": [16384],
"consumer_start_offset": "0", "consumer_strides": [1]},
{"pred": "4294967296", "succ": "4294967298", "arg": 0, "source": "tensormap",
"flags": ["wait"],
"overlap": "covered",
"tensor_id": "9514117477438350967", "consumer_dtype": "FLOAT32",
"consumer_shape": [16384],
"consumer_start_offset": "0", "consumer_strides": [1],
"producer_shape": [16384],
"producer_start_offset": "0", "producer_strides": [1]}
]
}
All 64-bit unsigned fields (task_id, tensor_id, pred, succ,
buffer_addr) are serialized as JSON strings, not numbers. Many
JavaScript-based JSON parsers can only safely represent integers up to
Number.MAX_SAFE_INTEGER (2^53 − 1); tensor_id (FNV-1a hash) and
buffer_addr (hardware address) routinely exceed that limit and would
silently lose precision if encoded as numbers. Python consumers pass
these through int(v) which accepts either form, so the schema is
JS-safe without burdening Python.
Task ids are the handle's encoded word, as TaskId::to_uint64 yields it. The low
32 bits are a local id; the bits above it
mean whatever the runtime that minted the record says they mean — a ring index
in bits 39:32 (tensormap_and_ringbuffer, 0..CHIP_MAX_RING_DEPTH-1), or an id
space in bits 63:62 plus a parent task in bits 51:32 (host_build_graph,
0 = GLOBAL, 1 = SUB_TASK, 2 = PARAM). See
src/common/{tensormap_and_ringbuffer,host_build_graph}/task_id.h.
Which one a record carries is a property of its runtime, not of the value, so
the top-level runtime key names it. Decode against that key rather than
guessing from the number — deps_viewer does, which is what makes a sub-task
render as g{parent}t{index} instead of a ten-digit high field:
runtime = data["runtime"] # required; a capture without it is refused, not guessed
local = raw & 0xFFFFFFFF
if runtime == "host_build_graph":
space = (raw >> 62) & 0x3 # 0 GLOBAL, 1 SUB_TASK, 2 PARAM
parent = (raw >> 32) & 0xFFFFF # meaningful for SUB_TASK only
elif runtime == "tensormap_and_ringbuffer":
ring = (raw >> 32) & 0xFF
else:
raise ValueError(f"unsupported runtime: {runtime!r}")
tasks[]¶
One entry per task observed in the trace. scope is "manual" when the
submit happened inside a manual scope (no automatic dependency wiring)
and "auto" otherwise. kernel_ids is the per-subslot
[aic,aiv0,aiv1] kernel id triple, with inactive subslots encoded as
-1. block_num is the SPMD logical block num for the submit; 1
means a normal single-block task and values greater than 1 identify
SPMD tasks. Tools that only need task-pair edges can ignore this block.
tensors[]¶
One entry per unique (buffer_addr, version) pair touched by the trace.
tensor_id is a stable FNV-1a 64-bit hash of that pair — identical
inputs across runs yield the same id, making deps.json files diffable.
buffer_numel is the element count of the underlying buffer, not the
slice; per-edge slice geometry (shape + start_offset + strides)
lives in the edges[] entries.
edges[]¶
Each edge is {pred, succ} plus annotation. Fields:
| Field | Type | When present | Meaning |
|---|---|---|---|
pred, succ |
uint64 (string) | always | Producer's and consumer's encoded task id (TaskId::to_uint64) |
arg |
int32 | always | Consumer's arg-slot index; -1 for explicit source |
source |
string | always | explicit (from explicit_deps[]), creator (owner_task_id retention), or tensormap (overlap lookup hit) |
flags |
string array | tensormap_and_ringbuffer |
Subset of ["wait", "retain"] — the edge's DepFlags. wait = ordering (readiness); retain = producer lifetime held until the consumer releases. creator edges are ["wait","retain"]; tensormap edges ["wait"]. explicit edges start with the per-dependency kind captured at submit time, so CoreTaskArgsWithDeps::add_dep_wait() emits ["wait"] while the default dependency kind emits ["wait","retain"]. When the same producer is also the creator of an input, replay matches runtime fanin dedup by OR-accumulating the creator's flags into the explicit edge. DepFlags is a tensormap_and_ringbuffer runtime concept — it does not exist in the host_build_graph tree — so the host-orchestrated writer omits the field rather than lagging behind it. Consumers must treat it as optional. |
overlap |
string | source=tensormap |
covered (producer slice fully contains consumer slice) or other |
tensor_id |
uint64 (string) | not explicit |
Identity of the underlying tensor; cross-references tensors[] |
consumer_dtype |
string | not explicit |
Element type the consumer reads as |
consumer_shape |
uint32 array | not explicit |
Per-dim element count of the consumer slice |
consumer_start_offset |
uint64 (string) | not explicit |
Element offset of the consumer slice into the buffer |
consumer_strides |
uint32 array | not explicit |
Per-dim stride (in elements) of the consumer slice; runtime invariant > 0 |
producer_shape |
uint32 array | source=tensormap |
Per-dim element count of the producer slice |
producer_start_offset |
uint64 (string) | source=tensormap |
Element offset of the producer slice |
producer_strides |
uint32 array | source=tensormap |
Per-dim stride of the producer slice; runtime invariant > 0 |
A single (pred, succ) pair can appear in edges[] multiple times if
the producer drives the consumer through multiple slots, multiple
sources, or multiple tensormap matches (different slice / version). For
"is task X a successor of task Y at all?" questions, project edges down
to the (pred, succ) set; for "what specifically did Y feed into X?",
keep the full annotation.
deps.json can have "edges":[] when the workload's tasks have no
inter-task data dependencies (e.g. embarrassingly parallel kernels
under scope_end barriers). tasks[] and tensors[] still list every
observed task and tensor — that is not an error.
5. Visualizing — deps_viewer.py¶
simpler_setup/tools/deps_viewer.py turns deps.json into either a
plain-text dependency view (default) or a self-contained pan/zoom HTML page
(Graphviz SVG with inline vanilla-JS drag/scroll panning and Ctrl+scroll or
pinch zooming). The text view is optimized for grep / diff / "what does task X
depend on?" debugging; the HTML view stays available when you want a visual
layout.
# Newest deps.json under outputs/ -> deps_viewer.txt
python -m simpler_setup.tools.deps_viewer
# Specific path -> deps_viewer.txt next to deps.json
python -m simpler_setup.tools.deps_viewer outputs/.../deps.json
# Explicit HTML output
python -m simpler_setup.tools.deps_viewer outputs/.../deps.json --format html
# HTML output with per-task tensor details
python -m simpler_setup.tools.deps_viewer outputs/.../deps.json --format html --show-tensor-info
# Big HTML graphs: use force-directed layout (recommended >1000 nodes)
python -m simpler_setup.tools.deps_viewer deps.json --format html --engine sfdp
The default text output contains:
SUMMARY— input path plus task / edge / tensor counts.tasks: number of task ids rendered in the outputunique_task_edges: number of unique(pred, succ)task pairsannotated_edges: number of annotated edge rows across all task pairsperf_sidecar:yeswhenchip_swimlane_records.jsonwas successfully loadedfunc_name_map:yeswhen at least one task label resolved to a namedfunc_namefrom either an explicit--func-namesfile or an auto-discovered siblingname_map*.json. When thekernel_idsfallback is used,func_id=shows an aligned 3-slot integer array in[aic,aiv0,aiv1]order; inactive slots remain-1.func_name_mapstaysnounless a real human-readable name was resolved.TASK INDEX— one line per task withkind=+func_id=and uniquefanin=/fanout=counts for quick grep. SPMD tasks additionally carrySPMD block num = N, whereNis the captured logical block num.TASK DETAILS— one block per task withFANIN/FANOUTpeer task references (task-adjacency-only, no tensor detail).
In text mode, HTML-only flags such as --engine and --direction are
rejected rather than silently ignored.
Node visual encoding (legend top-right of the rendered HTML):
| Shape + color | Meaning |
|---|---|
| Blue rounded box | AIC (cube) — kernel ran on the matrix unit |
| Orange ellipse | AIV (vector) — kernel ran on the vector unit |
| Green diamond | mix — single submit_task with MixedKernels spanning both core types |
| Gray dashed note | alloc — task from alloc_tensors (got a task_id, references downstream via owner_task_id, but never dispatched a kernel so has no perf record) |
Red border + right-side xN label |
SPMD task with block_num=N |
Node labels read as (ring, local) with a red border and right-side xN block num label for SPMD
tasks. The text output (deps_viewer.txt) carries the full kind= +
func_id= labels and adds SPMD block num = N only for SPMD tasks; the HTML
view keeps the graph focused on structure and the SPMD marker.
When you need per-task tensor rows (storage / shape / strides / start_offset)
and arg-port edge routing, rerun the HTML export with --show-tensor-info.
Browser controls in the HTML viewer:
- drag → pan
- scroll / two-finger swipe → pan
- Ctrl+scroll / trackpad pinch → zoom about cursor
fkey → fit to viewrkey → reset to 1:1
The HTML scales to graphs the browser's SVG renderer can handle — in
practice, ~50k nodes with --engine sfdp. Past that, you want a
canvas/WebGL viewer (Cytoscape.js, sigma.js), which is out of scope
for this tool. The default text output does not depend on Graphviz and is the
preferred mode for large graphs that need fast generation or grep-first
inspection.
6. Relationship to fanout[] + Validation Gate¶
When checking fanout coverage, project annotated edges down to a
{(pred, succ)} set first — the per-edge annotation distinguishes
sources / args / slices, so the raw edges[] count is a superset of the
underlying task-pair count.
deps.json (projected) was a superset of the now-removed fanout[]
edges — which is exactly why it replaced them as the sole edge source:
| Edge source | Captures | Drops on race? |
|---|---|---|
task.fanout[] (removed; formerly on ChipSwimlaneAicpuTaskRecord) |
Successors known at producer-retire time | Yes — sealed when producer retires |
deps.json (this feature) |
Every consumer → producer reachable via tensormap / explicit_deps | No — replay sees every submit |
tests/st/{a2a3,a5}/tensormap_and_ringbuffer/dfx/dep_gen/test_dep_gen.py
enforces the 6-edge expectation against the vector_example
orchestration as a validation gate: any edge missing from deps.json
is a replay-side regression and fails the test. (The record no longer
carries fanout[], so there is no longer a fanout ⊆ deps cross-check —
deps.json is the only edge source.) The
swimlane_converter.py uses deps.json (when present) as the source
of flow events in the Perfetto trace, and flags any edge whose
pred.end_time > succ.start_time as hb_violation (rendered as a
distinct flow event name so Perfetto colors it apart from regular
dependencies). Dependency flows connect the source and destination
bar starts; the completion timestamps are used only for the
hb_violation classification, not for flow geometry.
7. Big-Fanin Submits: Overflow Chain¶
A DepGenRecord carries up to 64 explicit_deps inline. Submits with
more than 64 explicit deps spill into a chain of DepGenOverflowRecord
slots that overlay normal record slots in the buffer.
Submit explicit_dep_count |
Chain shape | Per-submit slots used |
|---|---|---|
0 ≤ dc ≤ 64 |
base only — fast path, no chain bookkeeping | 1 |
65 ≤ dc ≤ 588 |
base (64 deps) + 1 overflow (up to 524 deps) | 2 |
589 ≤ dc ≤ 1112 |
base + 2 overflow | 3 |
| general | base + ⌈(dc − 64) / 524⌉ overflow |
1 + ⌈(dc − 64) / 524⌉ |
Wire format. An overflow record reinterprets the same 4736-byte slot
as { task_id (8) + flags (4) + dep_count (2) + _reserved (2) +
deps[524] + kinds[524] }; kinds[] is a byte-for-byte parallel array of
DepFlags. There are no tensor blobs (those live on the base record). Replay distinguishes
the two views by flags & DEP_GEN_FLAG_OVERFLOW. Chain records always
share the base's task_id, and the last one sets
DEP_GEN_FLAG_LAST_OVERFLOW so replay knows the chain is complete
without peeking ahead.
Atomicity. The AICPU writer reserves all chain slots up front: if
the current buffer can't hold the full chain, it switches buffer first.
buf->count is published with a single store at the end, so the host
either sees the old count (chain invisible) or the new count with the
full chain committed. Chain records are therefore always contiguous
within one buffer — replay scans linearly and ties them back to the
base via task_id.
Truncation tail. A submit whose chain exceeds the buffer's slot
budget (PLATFORM_DEP_GEN_RECORDS_PER_BUFFER = 1024 slots → roughly
64 + 1023 × 524 = 536116 deps max in the best case) is logged via
LOG_ERROR and truncated to the largest dc that fits. Runtime
correctness is unaffected — CoreTaskArgs::set_dependencies keeps the full dep
list; only the dep_gen replay graph loses the tail.
8. Architecture Touchpoints¶
| Layer | File | Role |
|---|---|---|
| Shared-mem layout | src/common/platform/include/common/dep_gen.h |
DepGenRecord (4736 B base, cache-line aligned, ≤64 inline explicit deps and parallel kinds, per-task block_num) + DepGenOverflowRecord chain view (≤524 dep/kind pairs per slot) + SPSC ring + per-thread ready queue. The layout is shared across platforms. |
| AICPU writer | src/{a2a3,a5}/platform/include/aicpu/dep_gen_collector_aicpu.h, src/common/platform/shared/aicpu/dep_gen_collector_aicpu.cpp |
Single-instance write path; weak-fallback exported to host build. Both platforms share the same writer implementation — the writer accesses its own device-side view of shared memory, independent of how host↔device transport is implemented. |
| Host collector | src/common/platform/include/host/dep_gen_collector.h, src/common/platform/shared/host/dep_gen_collector.cpp |
ProfilerBase<DepGenCollector, DepGenModule> — drains ring → records_ vector. On non-SVM platforms it uses the base alloc_paired_buffer, which malloc's a host shadow + copy_to_device's it and registers it via add_malloc_shadow so teardown can free it; reconcile_counters explicitly copy_from_device's the BufferState before reading, and finalize lets BufferPoolManager::clear_mappings() release all shadows as the single source of truth. |
| Capture call site (device-orch) | src/{a2a3,a5}/runtime/tensormap_and_ringbuffer/runtime/orchestrator.cpp submit_task_common |
One conditional block that snapshots inputs into the ring when is_dep_gen_enabled(); fires for both submit_task and submit_dummy_task. The schema carries kernel_ids[3] = {aic, aiv0, aiv1} so the swimlane post-processor can resolve task_id → kernel from deps.json at level=1 where the AICore record is the sole device-side identity source. Inactive subslots stay at INVALID_KERNEL_ID = -1. It also carries the SPMD logical block num from the launch spec as tasks[].block_num. |
| Replay | src/common/tensormap_and_ringbuffer/host/dep_gen_replay.{h,cpp} |
Pure CPU; runs dual-pass differential replay — compute_task_fanin (oracle) + inlined STEP A/B mirror (annotated) against two ChipTensorMap instances. Emits deps.json when both passes agree per record. One source for both platforms: it includes dep_compute.h, tensormap.h and tensor.h by bare name, so each architecture's host target compiles it against its own runtime headers. |
| Host-direct capture (host-orch) | src/common/host_build_graph/host/dep_gen_host_graph.h, src/common/host_build_graph/host/dep_gen_host_graph.cpp |
Task / tensor / edge tables filled from submit_task_common + compute_task_fanin's Annotate hooks (src/common/host_build_graph/host/dep_compute.h), reset per orchestration by run_host_orchestration, serialized by the same deps.json writer. The runtime translation unit carries weak no-op fallbacks so the AICPU build links without it. |
| Device-runner hookup | src/{a2a3,a5}/platform/{onboard,sim}/host/device_runner.cpp |
dep_gen_host_graph_active() picks the shape: host-orch calls dep_gen_host_graph_emit(deps_path) at teardown — reading the thread-local graph its own orchestration built on the same child progress thread — and skips collector init/start/reconcile entirely; device-orch calls dep_gen_replay_emit_deps_json(records.data(), records.size(), deps_path) post-reconcile_counters. The c_api latches the CallConfig before the bind so host capture is armed before the orchestration it records. |
| Viewer | simpler_setup/tools/deps_viewer.py |
deps.json → text (default) or pan/zoom HTML |
| Test | tests/st/{a2a3,a5}/tensormap_and_ringbuffer/dfx/dep_gen/test_dep_gen.py + test_dep_gen_chain.py, tests/st/{a2a3,a5}/host_build_graph/dfx/dep_gen/test_dep_gen.py |
Smoke test + edge validation against a vector_example-shaped orchestration. Each host_build_graph case runs an orchestration through host-direct capture and asserts its declared edges, so a divergence between the two shapes fails a test. The orchestrations are not shared across the two cases — a2a3's dep_gen test carries its own 5-task / 6-edge copy, a5's reuses the 4-task / 4-edge vector_example scene test's — so each asserts its own graph. |
The device-orch shape is supported on both a2a3 and a5. The a5 host collector differs from a2a3 only in its host↔device transport path (a5 has no SVM, so all transfers go through profiling_copy_to_device / profiling_copy_from_device instead of relying on halHostRegister's shared mapping); the AICPU writer, shared-memory ABI, runtime call site, and replay are platform-agnostic.
The host-direct shape is supported on both a2a3 and a5, and is what host_build_graph
uses on either. Both architectures reach it through the same
src/common/host_build_graph/ sources — neither src/a2a3/runtime/host_build_graph/
nor src/a5/runtime/host_build_graph/ carries a dep_gen implementation of its own.