Skip to content
Draft
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
15 changes: 11 additions & 4 deletions docs/dfx/dep-gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ inputs to each submit are captured and the graph is reconstructed afterwards.
tensor metadata (producer/consumer shape + offset, dtype, version).
Per-record semantics mirror runtime `submit_task` exactly: STEP 1
(explicit deps), STEP 3 (creator retention + tensormap lookup),
STEP 4 (register outputs). Per-successor dedup matches
STEP 4 (register reader/writer accesses). Per-successor dedup matches
`PTO2FaninBuilder::append_fanin_or_fail`. After both passes finish per
record, the replay asserts the two producer-id → `DepFlags` mappings are
equal (same producers and same per-producer flags); if they diverge,
Expand All @@ -86,8 +86,10 @@ nothing to capture-then-reconstruct.
- **Capture point.** `submit_task_common` opens the task's entry, and
`compute_task_fanin`'s `Annotate` hooks 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 from `compute_task_fanin` semantics.
the declared dependencies at STEP 1. A host-write node additionally records
its runtime-derived writer-consumer drain with source
`host_write_consumer`. The edges are the runtime's own, not a replay's
inference, so they cannot drift from dependency construction semantics.
- **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
Expand Down Expand Up @@ -159,7 +161,7 @@ The standard SceneTest path
"consumer_start_offset": "0", "consumer_strides": [1]},
{"pred": "4294967296", "succ": "4294967298", "arg": 0, "source": "tensormap",
"flags": ["wait"],
"overlap": "covered",
"overlap": "covered", "hazard": "WAR", "access_kind": "READER",
"tensor_id": "9514117477438350967", "consumer_dtype": "FLOAT32",
"consumer_shape": [16384],
"consumer_start_offset": "0", "consumer_strides": [1],
Expand All @@ -178,6 +180,11 @@ 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.

TensorMap edges additionally contain `hazard` (`RAW`, `WAW`, or `WAR`) and
`access_kind` (`WRITER` or `READER`). These fields describe why the consumer
must wait and which access index produced the match; creator and explicit
edges do not carry them.

Task ids encode `(ring_id << 32) | local_id` — the same layout as
`PTO2TaskId::raw`:

Expand Down
15 changes: 5 additions & 10 deletions docs/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,16 +560,11 @@ private:
first — `erase_task_outputs` therefore drops a key only while it still maps
to the consumed slot, leaving the later writer's entry for new consumers to
find.
- **WAR (write-after-read)** is not tracked directly. Read tasks don't
register in TensorMap; write tasks only look up current producer. If a
consumer reads `X` (recording fanin on producer P1) and then a later task
writes `X` (new producer P2 in TensorMap), there's no P1 → P2 edge. This is
correct: the reader only needs P1 to have completed, the new writer only
needs its own prior producer. Simultaneous read and write races are a user
bug, not a scheduler concern. When a workload genuinely needs the reader
ordered ahead of the overwrite, express it explicitly — see
[WAR anti-dependencies](war-anti-dependency.md) (issue #1306) for the
`add_dep` vs `INOUT` trade-off.
- **WAR (write-after-read)** is opt-in. Plain `INPUT` tasks do not register as
readers, so a later writer cannot discover them. `TRACKED_INPUT` publishes a
read-only reader entry; a later overlapping `INOUT`, existing-tensor
`OUTPUT`, or `set_tensor_data` then acquires the WAR dependency. See
[WAR anti-dependencies](war-anti-dependency.md) for the full access matrix.

### Thread safety

Expand Down
10 changes: 7 additions & 3 deletions docs/task-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ hierarchy levels.
```cpp
class TaskArgs {
std::vector<ChipTensor> tensors_;
std::vector<TensorArgType> tags_; // per-tensor: INPUT/OUTPUT/INOUT/OUTPUT_EXISTING/NO_DEP
std::vector<TensorArgType> tags_; // per-tensor access/dependency policy
std::vector<uint64_t> scalars_;
public:
void add_tensor(const ChipTensor&, TensorArgType tag = TensorArgType::INPUT);
Expand All @@ -116,8 +116,12 @@ public:
};
```

`TensorArgType` has five values (matches existing `tensor.h:45-51`):
`INPUT`, `OUTPUT`, `INOUT`, `OUTPUT_EXISTING`, `NO_DEP`.
The C++ `TensorArgType` has six values: `INPUT`, `OUTPUT`, `INOUT`,
`OUTPUT_EXISTING`, `NO_DEP`, and `TRACKED_INPUT`. `TRACKED_INPUT` has the same
read permission as `INPUT`; on A2/A3 and A5, both `host_build_graph` and
`tensormap_and_ringbuffer` publish it as a reader for a later WAR lookup. The
opt-in tag is currently a C++ `CoreTaskArgs` API and is not exposed by the
Python `TensorArgType` binding.

For remote L3 submits, public Python still uses the same `TaskArgs` builder.
`TaskArgs.add_tensor(RemoteTensorRef(...), tag)` appends a normal
Expand Down
188 changes: 71 additions & 117 deletions docs/war-anti-dependency.md
Original file line number Diff line number Diff line change
@@ -1,127 +1,81 @@
# WAR (Write-After-Read) Anti-Dependencies

**Decision record for issue #1306.**

When a pure `INPUT` reader of a buffer is followed by a later task that
overwrites that same buffer, the runtime does **not** guarantee a
write-after-read (WAR) ordering on its own. This is a deliberate performance
trade-off, not a bug. Express the ordering explicitly — the recommended way is
a manual `add_dep`, not promoting the reader to `INOUT`.

## The scenario
The runtime can track unfinished tensor readers and writers in TensorMap. A
pure reader that may overlap a later write must use `add_tracked_input()` so
the writer can discover it and create the WAR edge.

```text
R : reads X (add_input(X)) ── task R
W : writes X (add_inout(X) / add_output_existing(X)) ── task W, submitted later
W0: INOUT X ──RAW──> R0: TRACKED_INPUT X ──WAR──> W1: INOUT X
```

`W` overwrites `X` while `R` may still be reading it. Correctness requires
`W` to wait for `R` (WAR / anti-dependency). The automatic dependency
generator does not create this edge for a pure `INPUT` reader.

## Why the runtime does not track this automatically

The automatic dep-gen in
[`pto_dep_compute.h`](../src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_dep_compute.h)
tracks the two hazards that a producer-keyed map answers in O(1):

- **RAW** (read-after-write): an `INPUT`/`INOUT` looks up the current writer
of `X` and takes an edge on it.
- **WAW** (write-after-write): an `INOUT`/`OUTPUT_EXISTING` replaces the
writer entry for `X` and takes an edge on the prior writer.

Tracking **WAR** for pure readers is fundamentally different: a writer would
have to find *every reader that is still in flight*, which means keeping a
reader set per buffer and walking it on every write. Recording each pure read
as its own tensormap entry and walking the whole same-buffer chain on every
write is an `O(chain)` cost paid on the orchestration hot path — for an edge
that most workloads never need, because their reads are already ordered ahead
of the overwrite by the RAW/WAW chain that produced the new data.

The orchestrator contract states this directly:
[`docs/orchestrator.md` §7 "Semantics"](orchestrator.md#semantics) — *"WAR is
not tracked directly … Simultaneous read and write races are a user bug, not a
scheduler concern."* This document is the how-to for the cases where you own
that WAR ordering.

## Expressing the WAR edge — two options

### Option A — promote the reader to `INOUT`

Change the reader's argument from `add_input(X)` to `add_inout(X)`. An `INOUT`
access is treated as a writer: it registers `X` in the tensormap, so the later
write takes a WAW edge on it, and the host-side `set_tensor_data` path becomes
aware of the reader through the producer's `fanout_refcount`.

**Cost — unnecessary read serialization.** Because `INOUT` is a write, two or
more readers of the same buffer no longer run concurrently: each becomes the
tensormap writer in turn, so the second reader takes a WAW edge on the first
and they serialize. A workload that reads `X` from several tasks in parallel
loses that parallelism purely to satisfy the anti-dependency. Reach for this
only when the reader genuinely also writes `X`, or when you specifically need
`set_tensor_data` on the host side to observe the reader.

### Option B — manual `add_dep` (recommended)

Capture the reader's task id and make the later writer depend on it
explicitly. This creates exactly the WAR edge and nothing else — the reads
stay pure `INPUT` and run concurrently.

```cpp
// Reader: keep it a pure INPUT.
CoreTaskArgs r_args;
r_args.add_input(ext_X);
r_args.add_inout(ext_Y);
TaskOutputTensors r = rt_submit_aic_task(FUNC_READ_X, r_args);

// Later writer: depend on the reader so the overwrite waits for the read.
// add_dep() lives on the convenience wrapper CoreTaskArgsWithDeps<N>.
CoreTaskArgsWithDeps<> w_args;
w_args.add_inout(ext_X);
w_args.add_dep(r.task_id()); // <-- the WAR edge R -> W
rt_submit_aic_task(FUNC_WRITE_X, w_args);
```
On A2/A3 and A5, both `host_build_graph` and `tensormap_and_ringbuffer` use the
same opt-in rule: plain `add_input()` queries prior writers but does not publish
a reader. Use `add_tracked_input()` when a later overlapping write must wait.

## Access semantics

| Argument | Queries | Registers | Meaning |
| -------- | ------- | --------- | ------- |
| `INPUT` | overlapping writers (RAW) | nothing | ordinary read-only access |
| `TRACKED_INPUT` | overlapping writers (RAW) | reader | read-only access that must order a later write |
| `INOUT` | overlapping writers (RAW/WAW) and readers (WAR) | writer | read-modify-write |
| existing-tensor `add_output` | overlapping readers (WAR) | writer | pure overwrite (`OUTPUT_EXISTING`) |
| runtime-created `add_output` | nothing | nothing | fresh allocation (`OUTPUT`) |
| `NO_DEP` | creator only | nothing | retains the allocator but skips TensorMap lookup/publication |

Independent input tasks do not depend on each other and can still execute in
parallel. Accesses are registered only after the task's complete fanin has been
computed, preventing aliases within one task from creating a self-dependency.

The annotation belongs on the reader, not on the later writer. A writer cannot
retroactively discover an earlier plain input that left no reader entry.

`OUTPUT_EXISTING` retains its existing unordered-writer contract: it waits for
readers but does not acquire WAW edges on older writers. Consequently, a fully
covered reader entry can be retired after its WAR edge is created, while an
unordered writer entry must remain discoverable.

## `INOUT` versus pure overwrite

Use `INOUT` when the final value can depend on the target's old contents, such
as accumulation, an in-place operator, a partial update that preserves other
elements, or conditional writeback. Use existing-tensor `add_output(X)` when a
task fully determines the bytes it writes without reading the previous value.

## Host access

- `get_tensor_data()` is a host read and waits only for overlapping writers.
- `set_tensor_data()` is a host write and waits for overlapping writers, the
existing writer-consumer drain, and every overlapping tracked reader task to
complete. TMR performs the wait synchronously; HBG emits an equivalent host
write graph node. Neither waits for a reader's downstream consumers.

Use `add_tracked_input()` for any pure reader followed by an overlapping
`set_tensor_data()` call.

## Explicit overrides

Manual scopes skip automatic dependency computation. Tensors marked
`manual_dep` and `add_no_dep()` retain a valid creator but skip automatic
reader/writer lookup and registration. In these modes, use
`CoreTaskArgs::set_dependencies()` or `CoreTaskArgsWithDeps::add_dep()` to state
the required task ordering explicitly. An explicit task edge is not a
buffer-keyed access record, so it cannot make a task visible to a later host
`set_tensor_data()` call.

## Capacity and diagnostics

Tracked-reader and writer entries share the existing 65,536-entry pool and
retire at the existing `CONSUMED` watermark. The indexes have separate bucket
heads so reader fan-out does not make subsequent readers scan older readers.
HBG keeps 128 fanins inline and spills additional deduplicated fanins into its
scheduler pool; fanins are never silently truncated.

`add_dep` is the convenience layer over the primitive
`CoreTaskArgs::set_dependencies(ptr, count)`; both are documented in
[`pto_arg_with_deps.h`](../src/a5/runtime/tensormap_and_ringbuffer/orchestration/pto_arg_with_deps.h).
Multiple readers each contribute one `add_dep(reader.task_id())` on the writer
and still run in parallel with each other — only the writer waits.

**`add_dep` is a task-to-task edge only — it is invisible to the host-side
`set_tensor_data`.** `set_tensor_data(X)` is a host write, not a task in the
graph, so a "writer depends on reader" edge places no constraint on it. Its
only channel for discovering in-flight readers is buffer-keyed: it looks up
`X`'s producer in the TensorMap and waits for that producer *and its
consumers* (`wait_for_tensor_ready` with `wait_for_consumers=true`, see
[`pto_runtime2.cpp`](../src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2.cpp)).
`add_dep` writes no TensorMap entry for `X` and does not touch that producer's
fanout, so a reader wired only through `add_dep` is not in the set
`set_tensor_data` waits on. If a buffer that a reader touches may later be
written from the host via `set_tensor_data`, that reader must use `add_inout`
(Option A) to register itself in the TensorMap — `add_dep` cannot substitute
here.

## Recommendation

Prefer **Option B (`add_dep`)**. It is precise (one edge, no side effects on
the tensormap), and it preserves read parallelism. Use **Option A (`INOUT`)**
only when the tensor is semantically read-modify-write anyway, or when the
reader must be visible to the host-side `set_tensor_data` WAR guard (see the
`set_tensor_data` note in
[`pto_orchestration_api.h`](../src/a5/runtime/tensormap_and_ringbuffer/orchestration/pto_orchestration_api.h)).

| Concern | Option A: `INOUT` | Option B: `add_dep` |
| ------- | ----------------- | ------------------- |
| Creates the WAR edge | ✓ | ✓ |
| Keeps readers concurrent | ✗ (readers serialize as WAW) | ✓ |
| Visible to host `set_tensor_data` | ✓ | ✗ |
| Extra tensormap entry per read | ✓ | ✗ |
| Effort | change one arg tag | capture id + one `add_dep` |
Dependency capture labels TensorMap edges with `hazard` (`RAW`, `WAW`, or
`WAR`) and `access_kind` (`READER` or `WRITER`). TensorMap also retains
reader/writer live counts and high-water marks for capacity diagnosis.

## See also

- [`docs/orchestrator.md` §7](orchestrator.md#semantics) — TensorMap RAW/WAW/WAR
semantics.
- [`docs/manual-scope.md`](manual-scope.md) — manual scopes, where automatic
dep tracking is off and `add_dep` is the primary ordering tool.
- [Dependency generation DFX](dfx/dep-gen.md)
- [Manual scopes](manual-scope.md)
10 changes: 5 additions & 5 deletions examples/a2a3/tensormap_and_ringbuffer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ For the `Worker` API underneath the framework, see
| Example | What it teaches |
| ------- | --------------- |
| [`vector_example/`](vector_example/) | The smallest complete kernel: `f = (a+b+1)*(a+b+2) + (a+b)`. Runs on sim. |
| [`scalar_data/`](scalar_data/) | Orchestration-level data manipulation — `get_tensor_data` / `set_tensor_data` round-trips, runtime-created outputs with initial values, and automatic WAW / WAR waits. Also the reference for the one case where they **don't** fire: `add_input` on an external tensor registers no TensorMap entry, so a later `set_tensor_data` races the reader. |
| [`scalar_data/`](scalar_data/) | Orchestration-level data manipulation — `get_tensor_data` / `set_tensor_data` round-trips, runtime-created outputs with initial values, and opt-in WAR tracking through `add_tracked_input`. |

## Compute

Expand Down Expand Up @@ -70,14 +70,14 @@ Wrap hardware runs in `task-submit` on a shared box; see

## Relationship to `examples/a5/`

Five examples exist under both architectures with the same name:
Six examples exist under both architectures with the same name:
`vector_example`, `paged_attention`, `paged_attention_manual_scope`,
`paged_attention_unroll_manual_scope`, and `sdma_async_completion_demo`. They
`paged_attention_unroll_manual_scope`, `scalar_data`, and
`sdma_async_completion_demo`. They
are ports of each other and differ mainly in tile shapes and platform strings
— `vector_example` differs by two lines. When you change one, check whether its
sibling needs the same change.

Only here: `benchmark_bgemm` (a5 has `bgemm` instead),
`deepseek_v4_flash_decode`, `merge_pipeline_barrier`,
`paged_attention_ringbuffer`, `prefetch_async_demo`, `qwen3_14b_decode`,
`scalar_data`.
`paged_attention_ringbuffer`, `prefetch_async_demo`, `qwen3_14b_decode`.
Loading
Loading