Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dfx/chip-swimlane-profiling.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ microseconds, downstream code sees:

| Field | Meaning |
| ----- | ------- |
| `task_id` | Runtime task id (`(ring_id << 32) \| local_id`); also exposed split as`ring_id` |
| `task_id` | Runtime task id (`TaskId::raw`); its high 32 bits are also exposed split off as `ring_id`, which is a ring index under `tensormap_and_ringbuffer` and an id space under `host_build_graph` |
| `func_id` | Kernel function id. Always `-1` on disk; resolved post-process from `deps.json::tasks[].kernel_ids[3]` (see `swimlane_converter.resolve_func_id_from_kernel_map`) |
| `core_id` / `core_type` | Physical core index and `"aic"` / `"aiv"` string |
| `start_time_us` / `end_time_us` / `duration_us` | AICore execution window in microseconds |
Expand Down
10 changes: 7 additions & 3 deletions docs/dfx/dep-gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,15 @@ 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 encode `(ring_id << 32) | local_id` — the same layout as
`TaskId::raw`:
Task ids are `TaskId::raw`. The low 32 bits are a local id; the high 32 bits
mean whatever the runtime that minted the record says they mean — a ring index
(`tensormap_and_ringbuffer`, `0..CHIP_MAX_RING_DEPTH-1`) or an id space
(`host_build_graph`, `0 = RING`, `1 = GRAPH_NODE`). See
`src/common/{tensormap_and_ringbuffer,host_build_graph}/task_id_encoding.h`.
Which one a record carries is a property of its runtime, not of the value:

```python
ring = (raw >> 32) & 0xFF
high = (raw >> 32) & 0xFF
local = raw & 0xFFFFFFFF
```
Comment thread
ChaoWao marked this conversation as resolved.

Expand Down
167 changes: 82 additions & 85 deletions problems.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,123 +4,120 @@ Audit of `host_build_graph` for data structures inherited from
`tensormap_and_ringbuffer` whose actual hbg usage diverges from the tmr design
they were shaped for.

Original audit was against `upstream/main@761fdf8d`. **Updated 2026-08-24 against
`upstream/main@3069f1aff`**, after which four of the six items are closed. Note
that #1963 renamed most hbg files (`pto_ring_buffer.h` → `ring_buffer.h`,
`pto_orchestrator.h` → `orchestrator.h`, `pto_runtime2.h` → `runtime_core.h`,
`pto_runtime2_init.cpp` → `runtime_init.cpp`, …), so paths below use the new names.
Original audit was against `upstream/main@761fdf8d`. **Updated 2026-08-25 against
`upstream/main@fc42cd1dd`**, after the L2-tensor split (#1974) and the ring removal
(#2004). #1980 retired the `PTO2` *identifiers* this audit named, so the spellings
below are the current ones; the brand prose survives in ~100 places, see
`KNOWN_ISSUES.md`.

---

## CLOSED

### 1. Single-ring shape carried as a multi-ring one — FIXED (#1965)
### 1. Single-ring shape carried as a multi-ring one — FIXED (#1965, #2004)

`PTO2_MAX_RING_DEPTH` was `1` against tmr's `4`, yet the tree carried 38
`[PTO2_MAX_RING_DEPTH]` array declarations and 21 loops that ran exactly once.
Every shared-memory operation existed twice — a scalar wrapper that filled a
one-element array, and a `_per_ring` implementation reading `[0]` (one of which
cast the array to `void` without reading it). The macro is gone from this runtime;
each operation has one scalar form. `sum_ring_heap_sizes`, which summed one element
and checked it for overflow against itself, is deleted.

Boundaries deliberately held: the `PTO2_RING_*` knobs accept exactly what they did
before, the `RuntimeEnv` / `RUNTIME_ENV_RING_COUNT` ABI is untouched (shared with
tmr; this runtime reads slot 0), `bind_callable_to_runtime_impl`'s `extern "C"`
signature is unchanged, and the `[STALL]` grammar is byte-identical.
array declarations dimensioned by it and 21 loops that ran exactly once. #1965
collapsed each operation to one scalar form; #2004 went further and dropped the
ring itself — a task id is now its own table index, and `task_window_mask` /
`get_slot_by_task_id` / `TaskAllocResult::slot` are gone.

### 2. `PTO2RingSet` was a one-member wrapper named "Set" — FIXED (#1965)

Held a single `PTO2TaskAllocator`, with a doc comment claiming
"PTO2_MAX_RING_DEPTH instances exist, one per scope depth". The allocator now sits
on the orchestrator directly.

### 3. `DEP_POOL_OVERFLOW` reported for fanin exhaustion — FIXED UPSTREAM (#1963)

hbg has no dependency spill pool, yet latched a code named after tmr's. Renamed to
`SIMPLER_ERROR_FANIN_CAPACITY_EXCEEDED` — the mechanism both runtimes share, and
the words the log already printed. Value 4 held, so #1960's band contract and the
`-4` an existing caller sees are untouched. Not my change; landed independently
while #1965 was open.
Renamed to `SIMPLER_ERROR_FANIN_CAPACITY_EXCEEDED`, value 4 held.

### 4. `PTO2_DEP_POOL_SPIN_LIMIT` was a dead define — FIXED (#1965)

Defined in `ring_buffer.h`, referenced nowhere. Two struct summaries in that
file's header comment also described `FaninPool` / `DepListPool`, which the file
has not held since those pools were removed.
### 7. One byte layout shared by three unrelated structs — FIXED (#1974)

`ChipTensor`, `TensorMapEntry` and `TensorCreateInfo` shared a byte-level layout so
three copy paths could each be a single 64-byte `memcpy`, held by 21 hand-written
`offsetof` assertions across four trees. Two of the three were shaped backwards to
satisfy it, and the entry's `memcpy` wrote `ChipTensor::buffer.size` into a
`TensorMapEntry *`. Each struct now assigns its own fields;
`TensorMapEntry::copy_tensor_create_info` is deleted (it had no callers).

### 8. One tensor type served the boundary and both runtimes — FIXED (#1974)

`ChipTensor` carried `owner_task_id` / `version` / `manual_dep` and two derived
caches — what the *runtime* decides about an argument, on the type a *caller* hands
in. `create_from_chip_args` recorded the mismatch as
`debug_assert(!t.manual_dep && t.version == 0)`, and `docs/buffer-abi.md` called the
type "internal" while `ChipWorker.run` took a container of them.

Now: `ChipTensor` is 72 B of geometry plus a resolved address;
`simpler::{hbg,tmr}::Tensor` is 128 B and adds the runtime's own state;
`Runtime::set_orch_args` is the single adoption point and runs on the host in both
runtimes. `sizeof(ChipStorageTaskArgs)` fell from ~33.8 KB to 19464 B.

### 4b. A nonzero producer ring id becomes a ring-0 dependency — FIXED

`task_id.h` declared the encoding as `(ring_id << 32) | local_id` and named the
accessor `ring()`. **That was false for hbg**, which uses the high bits as an
id-space tag: `graph_execution.cpp` minted `TaskId::make(1, synthetic_local)` for a
Graph node. So every hbg guard reading `ring() != 0` looked like a bounds check on
a dimension that no longer exists, when it was really asking "is this a graph-node
id" — and `FaninBuilder::mark_seen` folded that question's answer into its dedup
return value, so `append_fanin_or_fail` read "not a ring task" as "not deduped
yet" and appended an edge to whatever `prod_slot` the foreign local id happened to
mask onto.

The bug survived #2004 unchanged: it moved `mark_seen` to key on the `TaskId` and
dropped the redundant ring/slot parameters, but left the space check folded into
the dedup return value.

`TaskId` is now an opaque 64-bit handle (`raw`, `invalid()`, `is_valid()`, `==`,
`sizeof == 8`); each runtime owns and names its own layout in
`src/common/{host_build_graph,tensormap_and_ringbuffer}/task_id_encoding.h` —
`TaskIdSpace{RING, GRAPH_NODE}` + `make_ring_task` / `make_graph_node` /
`is_ring_task` for hbg, a real `make_task_id(ring, local)` / `task_ring` for tmr.
`mark_seen` deduplicates and nothing else; `append_fanin_or_fail` rejects a
non-RING producer up front via `report_fatal(SIMPLER_ERROR_INVALID_ARGS, …)`.

Repro: `graph_node_dependency` in
`tests/st/host_build_graph_validation/`, verified to fail (`DID NOT RAISE` —
the bogus edge was built silently) with the new guard removed.

---

## STILL OPEN

### 5. `on_scope_end` is an empty stub — low value

- **Evidence**: `runtime/scheduler/scheduler.h`

```cpp
void on_scope_end(PTO2TaskSlotState ** /*task_slot_states*/, int32_t /*count*/) {}
```

still called from `end_scope` in `orchestrator_core/orchestrator.cpp`; its comment
says it is kept as a no-op so the orchestrator call site matches tmr's.
- An inline empty function costs nothing at runtime, so this is readability only.
Worth folding into the next change that touches either file, not worth its own PR.
`runtime/scheduler/scheduler.h`:
`void on_scope_end(TaskSlotState ** /*task_slot_states*/, int32_t /*count*/) {}`,
still called from `end_scope`, kept as a no-op so the orchestrator call site
matches tmr's. Inline empty function, so readability only — fold into the next
change that touches either file.

### 6. Docs name a field hbg does not have

- **Evidence**: `docs/RUNTIME_LOGIC.md` states hbg "never advances
`last_task_alive`". That identifier does not exist anywhere in the hbg tree
except that sentence. Pre-existing, one line.

### 4b. A nonzero producer ring id becomes a ring-0 dependency

- **Found by**: CodeRabbit on PR #1965, verified against the code.
- **Evidence**: `PTO2FaninBuilder::mark_seen` returns `false` for a producer whose
ring id is not 0, and `append_fanin_or_fail` reads `false` as "not deduped yet"
and appends the producer — with `prod_slot` already resolved against the one
shared-memory ring. A nonzero-ring producer id with a colliding local id would
create an edge to an unrelated ring-0 task.
- **Not a regression**: before #1965 the guard read
`prod_ring >= PTO2_MAX_RING_DEPTH` with that macro at `1`, which for a `uint8_t`
is the same test as `prod_ring != 0`.
- **Reachability**: `prod_ring` is `producer_task_id.ring()`. hbg builds every task
id on ring 0, and the Graph recorder already refuses an explicit dep whose
`ring() != 0`. Reaching this needs a foreign or malformed task id handed to
`set_dependencies` on the ordinary path.
- **Current state**: #1965 carries a `debug_assert(prod_ring == 0)` so UT and sim
builds trap a violation, and a comment that no longer implies rejection. The
fatal path CodeRabbit proposed (report `SIMPLER_ERROR_INVALID_ARGS` and refuse)
wants a failing repro first per `discipline.md` §3, so it needs its own change.
`docs/RUNTIME_LOGIC.md` states hbg "never advances `last_task_alive`". That
identifier does not exist anywhere in the hbg tree except that sentence.

---

## Already cleaned up by earlier work — not vestiges

Recorded so the next audit does not re-open them.

- `runtime/ring_buffer.h` is ~270 lines against tmr's 816: the reclaiming
allocator, its 500 ms deadlock backstop and the reclaim bookkeeping are gone,
with present-tense comments stating that nothing is reclaimed mid-run.
- The dep-pool arena region and the per-ring dep pools are gone; only comments
explaining their absence remain.
- The orchestrator and its TensorMap are host-owned rather than arena regions
(#1962), so the arena has two zones, both device-resident.

## Deliberate, do not "fix"

- `AsyncWaitList` — live in hbg (`scheduler_dispatch.cpp` reads
`async_wait_list.count`).
- `PTO2SchedulerState` and its thirteen queue slot arrays — the scheduler really
runs on the AICPU, so the arena/offset form is required.
- `PTO2TensorMapEntry`'s 128 B two-cache-line mirror of `ChipTensor` — a shared
performance design, not a shape inherited by accident.
- `AsyncWaitList` — live in hbg (`scheduler_dispatch.cpp` reads `.count`).
- `SchedulerState` and its thirteen queue slot arrays — the scheduler runs on the
AICPU, so the arena/offset form is required.
- `TensorMapEntry`'s two-cache-line mirror of the runtime `Tensor`'s hot fields — a
shared performance design. Since #1974 it mirrors `simpler::{hbg,tmr}::Tensor`,
not the boundary type.
- `ring_dep_pool` in the bind ABI — `[[maybe_unused]]` with "kept for ABI
stability"; a deliberate cross-repo contract decision.
- `TaskTensor` — not a third type. A per-translation-unit alias for the runtime
being built, so kernels (identical either way, and several compiled under both)
name no runtime. The alternative was duplicating 13 MB of generated MoE kernels
into the hbg tree, where the copies would drift.

## Open sizing question (not a vestige)

`PTO2_TENSORMAP_POOL_SIZE` is `65536` in both runtimes, but tmr spends it
per-ring across 4 rings while hbg uses one ring's worth. Since #1962 that is an
explicit ~8 MB allocation per bind. With `GRAPH_MAX_NODES = 1024` and a default
task window of 16384 it is not a hard bound on either side (a capacity check backs
it), so whether hbg wants its own number is a measurable question, not a guess.
`TENSORMAP_POOL_SIZE` is `65536` in both runtimes, but tmr spends it per-ring
across 4 rings while hbg uses one ring's worth. Since #1962 that is an explicit
~8 MB allocation per bind. With `GRAPH_MAX_NODES = 1024` and a default task window
of 16384 it is not a hard bound on either side (a capacity check backs it), so
whether hbg wants its own number is a measurable question, not a guess.
18 changes: 12 additions & 6 deletions simpler_setup/tools/deps_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,13 @@ def _normalize_small_int(v):


def _node_id(task_id):
"""DOT-safe node id: ``T{ring}_{local}``."""
"""DOT-safe node id: ``T{high}_{local}``.

The high field is a ring index under ``tensormap_and_ringbuffer`` (any ring in
``0..CHIP_MAX_RING_DEPTH-1``) and an id space under ``host_build_graph``
(0 = RING, 1 = GRAPH_NODE), so its meaning depends on which runtime wrote the
record. It is printed verbatim either way.
"""
tid = _normalize_task_id(task_id)
if tid is None:
return f"T_{task_id}"
Expand All @@ -134,12 +140,12 @@ def _node_id(task_id):
def _make_task_formatter(nodes):
"""Build a task-id → display-string formatter sized to the graph.

If every node lives in ring 0 the display is just ``{local}`` (the local
If every node has a 0 high field the display is just ``{local}`` (the local
counter alone — no noise on workloads that never enter a manual scope).
The moment any node is in ring ≥ 1 we switch to the explicit
``({ring}, {local})`` tuple for *every* node so the asymmetry is visible
instead of hidden (you can't have ``t0`` next to ``r1t3`` and know which
ring t0 lives in without context).
The moment any node has a nonzero one we switch to the explicit
``({high}, {local})`` tuple for *every* node so the asymmetry is visible
instead of hidden (you can't have ``t0`` next to ``r1t3`` and know what
t0's high field is without context).
"""
has_multi_ring = False
for n in nodes:
Expand Down
17 changes: 11 additions & 6 deletions simpler_setup/tools/swimlane_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def _task_display_name(func_id, func_id_to_name, tdisp, *, spmd=False):
def normalize_task_id_int(v):
"""Unsigned 64-bit task id (matches host JSON / device ``task_id.raw``).

Normalizes signed values to unsigned for ``(ring_id << 32) | local_id``.
Normalizes signed values to unsigned so the high field decodes correctly.
Returns None if ``v`` is not convertible to int.
"""
try:
Expand All @@ -104,10 +104,14 @@ def normalize_task_id_int(v):
def format_task_display(task_id):
"""Format a task_id for human-readable labels.

Layout: 64-bit raw = (ring_id << 32) | local_id (same as runtime TaskId).
The high 32 bits are a ring index under ``tensormap_and_ringbuffer`` (any ring in
``0..CHIP_MAX_RING_DEPTH-1``) and an id space under ``host_build_graph``
(0 = RING, 1 = GRAPH_NODE). The short form below therefore covers tmr ring 0 and
every hbg RING task; a tmr task on ring 2 is equally ordinary and gets the long
form.

Returns:
``r{ring}t{local}`` when ring != 0 (e.g. r2t100), else ``t{local}`` for single-ring (ring 0).
``r{high}t{local}`` when the high field != 0 (e.g. r2t100), else ``t{local}``.

For invalid or non-numeric values, returns str(task_id).
"""
Expand All @@ -122,10 +126,11 @@ def format_task_display(task_id):


def _decode_graph_node_task_id(task_id):
"""Decode Scheduler-owned ring-1 Graph-node ids.
"""Decode Scheduler-owned Graph-node ids.

Graph nodes use ``local=(outer_local << 10) | node_index`` while the
stream-visible outer Graph task remains on ring 0.
``host_build_graph`` puts a materialized node in id space 1 (GRAPH_NODE) with
``local=(outer_local << 10) | node_index``; the stream-visible outer Graph task
stays in space 0 (RING). See src/common/host_build_graph/task_id_encoding.h.
"""
tid = normalize_task_id_int(task_id)
if tid is None or ((tid >> 32) & 0xFFFFFFFF) != 1:
Expand Down
2 changes: 1 addition & 1 deletion src/a2a3/runtime/host_build_graph/docs/profiling_levels.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ header just like on onboard.
| 4 | + Orchestrator phases (full) |

At level 1 the AICore record carries the full `task_token_raw`
(`(ring_id << 32) | local_id`), read straight from
(a `TaskId::raw`; see `src/common/host_build_graph/task_id_encoding.h`), read straight from
`LocalContext.async_ctx.task_token.raw` inside the AICore helper —
already in cache from the dispatch payload, so no extra GM load.
Identity fields the AICPU side used to write at level 1 (`func_id`,
Expand Down
Loading
Loading