CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
604 lines
29 KiB
Markdown
604 lines
29 KiB
Markdown
# StinkyWaitCntInsertionPass — SSA Def-Use + Dataflow Wait Count Insertion
|
||
|
||
`StinkyWaitCntInsertionPass` inserts `s_wait_dscnt`, `s_wait_loadcnt`, `s_wait_kmcnt`, and `s_wait_tensorcnt` so that asynchronous memory operations complete before their results are consumed. The pass reads dependencies from an SSA def-use chain over memory-token pseudo-registers and computes wait placement via a forward dataflow solver (`WaitDataflow`), followed by optional plan optimizers and a finalize replay step.
|
||
|
||
### Key characteristics
|
||
|
||
- **SSA def-use dependencies** via `buildUseDefChain(includePseudo=true)` — memtoken pseudo-registers become first-class edges, so `inst->getSources()` lists the memops a consumer depends on (including through PHIs at CFG joins)
|
||
- **Four counter types**: DS (`dlcnt`), buffer/load (`vlcnt`), scalar memory (`kmcnt`), tensor (`tlcnt`), tracked as `CounterKind` in `WaitDataflow`
|
||
- **Per-predecessor queues** — each counter keeps separate in-flight FIFOs tagged by CFG predecessor edge, so join consumers see each path's depth instead of a collapsed union queue
|
||
- **Tensor loop policy** — by default, the `CK_Tensor` slice of dataflow state is frozen after the first solver sweep so tensor tokens do not propagate around loop back-edges; a conservative option restores normal tensor fixed-point iteration
|
||
- **Anti-dependency scans** for hazards the SSA RAW chain does not capture (WAR-on-LDS, barrier ordering, untagged conservative fallbacks)
|
||
- **Analyze → Optimize → Finalize** — dataflow solve, then `ShallowPredPromotion`, then `finalizePlan()` to align the plan with post-optimizer FIFO simulation
|
||
- **Selective IR mutation**: `buildUseDefChain` and `WaitDataflow::solve()` run over every basic block so skipped preds still contribute in-flight state; `PassContext::shouldProcessBasicBlock` gates `emitWaits` and `removePHIs` only
|
||
|
||
---
|
||
|
||
## Hardware background
|
||
|
||
Asynchronous memory ops (LDS `ds_*`, global/buffer loads and stores, `tensor_load_to_lds`) retire out of order. The hardware exposes one FIFO counter per kind of async memop. Before a consumer reads a result, the compiler must insert the matching `s_wait_*` to drain the producer.
|
||
|
||
| `CounterKind` | Covers | Wait instruction | Modifier field |
|
||
|---------------|--------|------------------|----------------|
|
||
| `CK_DS` | `ds_read` / `ds_write` / `ds_atomic` | `s_wait_dscnt N` | `SWaitCntData.dlcnt` |
|
||
| `CK_Buffer` | global/buffer load + store, returning MUBUF/FLAT/GLOBAL atomic | `s_wait_loadcnt N` | `SWaitCntData.vlcnt` |
|
||
| `CK_KM` | scalar memory loads (`s_load_*`) | `s_wait_kmcnt N` | `SWaitCntData.kmcnt` |
|
||
| `CK_Tensor` | `tensor_load_to_lds` | `s_wait_tensorcnt N` | `SWaitTensorCntData.tlcnt` |
|
||
|
||
`s_wait_*cnt N` blocks until **at most `N`** ops of that kind remain outstanding (it keeps the `N` most-recently-issued in flight and drains everything older).
|
||
|
||
### Wait arithmetic
|
||
|
||
If producer `D` sits at index `i` (0 = oldest) in a counter FIFO of size `n`, the wait immediate that drains `D` is:
|
||
|
||
```
|
||
w(D) = n - i - 1
|
||
```
|
||
|
||
`PerPredQueue::countFrom(D)` returns the number of ops from `D` (inclusive) to the tail. The emitted wait is `countFrom(D) - 1`. When multiple deps constrain the same counter, the pass takes the **min** of their wait values (the most permissive value that still drains the oldest needed dep on every constrained path).
|
||
|
||
`classifyMemOp` maps an instruction to its counter; an instruction that is not a tracked memop returns `CK_Count` (no counter).
|
||
|
||
---
|
||
|
||
## Prerequisites: memtokens as SSA edges
|
||
|
||
The hard part of wait insertion is that dependencies do not flow through real registers — a `tensor_load_to_lds` writes an LDS region and a later `ds_read` of that region depends on it, but there is no vreg linking them.
|
||
|
||
`StinkyBuildImplicitDependencyPass` must run upstream. It attaches `MemTokenData` modifiers (integer token IDs) to tensor loads, DS ops, and barriers, materialising LDS ordering edges as pseudo-register defs/uses.
|
||
|
||
The wait pass then calls:
|
||
|
||
```cpp
|
||
buildUseDefChain(func, domInfo, /*clearExisting=*/true, /*includePseudo=*/true);
|
||
```
|
||
|
||
With `includePseudo=true`, memtoken pseudo-regs are treated like ordinary registers: PHIs are inserted at dominance-frontier join points and linked through `getSources()` / `getUsers()`. After this:
|
||
|
||
- A consumer's `inst->getSources()` directly contains the producer memops it depends on — a **RAW** edge — even across blocks and through joins.
|
||
- At a CFG merge, a memtoken value becomes a **PHI** whose incoming sources are the per-predecessor producers.
|
||
|
||
Token overlap (`MemTokenData`) is still consulted for **anti-dependencies** the SSA RAW chain does not capture (WAR-on-LDS, barrier ordering, untagged fallbacks).
|
||
|
||
---
|
||
|
||
## Pass flow
|
||
|
||
The orchestration in `StinkyWaitCntInsertionPass::run()`:
|
||
|
||
```cpp
|
||
buildUseDefChain(func, domInfo, /*clearExisting=*/true, /*includePseudo=*/true);
|
||
|
||
WaitDataflow df(func, domInfo, rpo);
|
||
df.setLoopCarriedTokenDepsEnabled(options.enableLoopCarriedTokenDeps);
|
||
|
||
const auto numWaves = passCtx.getGemmTileConfig().NumWaves;
|
||
df.setRawNeedsWait(CK_Tensor, [numWaves](const StinkyInstruction& i) {
|
||
return isBarrier(i) || numWaves == 1;
|
||
});
|
||
|
||
df.solve();
|
||
WaitInsertionPlan plan = df.materializePlan();
|
||
|
||
ShallowPredPromotion shallowPred;
|
||
std::vector<WaitPlanOptimizer*> optimizers = {&shallowPred};
|
||
for (auto* opt : optimizers) opt->rewrite(plan, df.getResult(), func);
|
||
|
||
df.finalizePlan(plan);
|
||
|
||
emitWaits(func, passCtx, arch, plan);
|
||
removePHIs(passCtx, rpo);
|
||
```
|
||
|
||
```
|
||
+---------------------------------------------------------------+
|
||
| StinkyWaitCntInsertionPass::run() |
|
||
+---------------------------------------------------------------+
|
||
|
||
Function Entry
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| buildUseDefChain() | Memtoken pseudo-regs as
|
||
| (includePseudo=true) | SSA edges + PHI placement
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| setRawNeedsWait() | Per-counter drain policy
|
||
| + tensor-loop option | (optional overrides)
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| WaitDataflow.solve() | Forward fixed-point (full RPO)
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| materializePlan() | Conservative WaitInsertionPlan
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| WaitPlanOptimizer(s) | e.g. ShallowPredPromotion
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| finalizePlan() | Replay with final plan + tail
|
||
| | drains (+ tensor freeze)
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| emitWaits() | Anchor waits + tail drains
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
+-----------------------+
|
||
| removePHIs() | Strip PHI pseudo-instructions
|
||
+-----------+-----------+
|
||
|
|
||
v
|
||
Function Exit
|
||
```
|
||
|
||
### Why three stages (Analyze → Optimize → Finalize)
|
||
|
||
`WaitDataflow::transferBlock` does two jobs in one walk:
|
||
|
||
1. **Plan** — decide `(anchor → WaitCountSpec)` using required waits from the current dataflow state.
|
||
2. **Simulate** — `trimQueues()` so later instructions (and successor blocks via the fixed point) see a FIFO that matches the **planned** drain.
|
||
|
||
`WaitPlanOptimizer`s run **after** convergence and may change what will actually be emitted. For example, at a join the baseline solver may emit `tlcnt = 0` (min across paths), but `ShallowPredPromotion` may relax that to `tlcnt = 2` and add a tail drain on the shallow predecessor. The solver's queue simulation used `0` (full trim); the final plan uses `2` (partial trim).
|
||
|
||
When an earlier anchor's simulated drain is **stronger** than the final emitted drain, every later anchor in the same block — on **any** counter — can be wrong:
|
||
|
||
- **Missing wait** — FIFO cleared in simulation but still in flight after promotion.
|
||
- **Redundant wait** — FIFO kept alive in simulation but fully drained in the final plan.
|
||
|
||
`finalizePlan()` closes this gap by replaying affected blocks with the post-optimizer plan values. Optimizers are **not** run inside `transferBlock` during the fixed point: they are inter-block (`ShallowPredPromotion` reads predecessor exit state and writes tail drains), and embedding them in the solve would couple promotion to stale or oscillating analysis snapshots.
|
||
|
||
### Tensor counter policy
|
||
|
||
Built-in default (`CounterPolicy` in `WaitDataflow.cpp`): tensor RAW deps drain only at barriers. The pass overrides this from tile config:
|
||
|
||
```cpp
|
||
df.setRawNeedsWait(CK_Tensor, [numWaves](const StinkyInstruction& i) {
|
||
return isBarrier(i) || numWaves == 1;
|
||
});
|
||
```
|
||
|
||
When `NumWaves == 1`, `rawNeedsWait` is true at **every** instruction, so tensor RAW deps drain at each consumer (not only at barriers). In multi-wave kernels, tensor counter drains are limited to barriers — cross-wave LDS visibility is handled by the barrier itself.
|
||
|
||
Loop-carried tensor-token dependencies are controlled separately:
|
||
|
||
```cpp
|
||
df.setLoopCarriedTokenDepsEnabled(options.enableLoopCarriedTokenDeps);
|
||
```
|
||
|
||
By default this option is **disabled**. In that mode, `WaitDataflow` computes `CK_Tensor` normally during solver sweep 0, then freezes only the tensor slice of `DataflowState` on later sweeps. This prevents tensor token state from propagating around loop back-edges and avoids loop-header waits such as an unnecessary first `s_wait_tensorcnt 0`.
|
||
|
||
When `enableLoopCarriedTokenDeps` is **enabled**, `CK_Tensor` participates in the normal fixed-point iteration just like the other counters. This is the conservative mode and can reintroduce loop-header tensor waits when a back-edge carries tensor token state.
|
||
|
||
Entry points for the conservative mode:
|
||
|
||
- `WaitDataflow::setLoopCarriedTokenDepsEnabled(true)`
|
||
- `WaitCntInsertionOptions::enableLoopCarriedTokenDeps = true`
|
||
- `ModuleOptions::EnableLoopCarriedTokenDeps = true`
|
||
- `stinkytofu-opt`: `--StinkyWaitCntInsertionPass=enableLoopCarriedTokenDeps`
|
||
|
||
DS, buffer, and KM counters use the default `rawNeedsWait` (drain at every consumer). Callers may override any counter via `WaitDataflow::setRawNeedsWait()` before `solve()`.
|
||
|
||
### Selective processing
|
||
|
||
The dataflow must see **every** block in RPO so a skipped predecessor still contributes its in-flight state to successors. `PassContext::shouldProcessBasicBlock` gates only `emitWaits` and `removePHIs` — disabled blocks participate in analysis but receive no IR mutations.
|
||
|
||
---
|
||
|
||
## Data structures
|
||
|
||
### PerPredQueue
|
||
|
||
One in-flight FIFO for a single counter, tagged with the CFG predecessor edge it arrived on.
|
||
|
||
```
|
||
+--------------------------------+
|
||
| PerPredQueue |
|
||
+--------------------------------+
|
||
| pred: BasicBlock* | CFG predecessor (nullptr = synthetic)
|
||
| ops: deque<Instruction*> | Memops in issue order
|
||
+--------------------------------+
|
||
| countFrom(op) -> int | Ops from op to tail (0 if absent)
|
||
+--------------------------------+
|
||
```
|
||
|
||
Per-pred tagging lets `ShallowPredPromotion` attribute waits to specific predecessors (for tail drains) and lets join consumers see each path's FIFO depth independently. `pred == nullptr` is a synthetic queue used when a block has no seeded preds yet (entry block, or local ops before any predecessor queue exists).
|
||
|
||
### PhiSummary
|
||
|
||
Summary of what a memtoken PHI implies for a consumer, per counter. `WaitCountSpec::kUnused` (-1) means no constraint on that counter.
|
||
|
||
### DataflowState
|
||
|
||
```
|
||
+----------------------------------------------+
|
||
| DataflowState |
|
||
+----------------------------------------------+
|
||
| queues[CK_Count]: vector<PerPredQueue> | Per-pred FIFOs per counter
|
||
| phiSummaries: map<PHI*, PhiSummary> | PHI wait bounds
|
||
+----------------------------------------------+
|
||
```
|
||
|
||
Per-pred queues are kept separate at block exit (not collapsed) so successors preserve per-path FIFO positions. Queues are capped at `kMaxInFlight` (64); exceeding the cap drops the oldest provably-complete op and emits a non-fatal diagnostic at convergence.
|
||
|
||
### DataflowResult
|
||
|
||
Converged per-block `entryState` and `exitState`. Optimizers read `exitState[pred]` for per-path queue depths; `finalizePlan` reads `entryState`. In default mode, the `CK_Tensor` slice of these states is the frozen post-sweep-0 tensor snapshot, while the other counters are fully converged.
|
||
|
||
### WaitCountSpec / WaitInsertionPlan
|
||
|
||
```
|
||
+--------------------------------+
|
||
| WaitCountSpec |
|
||
+--------------------------------+
|
||
| dsCount: int (dlcnt) | or kUnused (-1)
|
||
| bufferCount: int (vlcnt) |
|
||
| tensorCount: int (tlcnt) |
|
||
+--------------------------------+
|
||
|
||
+----------------------------------------------+
|
||
| WaitInsertionPlan |
|
||
+----------------------------------------------+
|
||
| anchorWaits: map<Inst*, WaitCountSpec> |
|
||
| tailDrains: vector<TailDrain> | Waits before pred terminators
|
||
+----------------------------------------------+
|
||
```
|
||
|
||
- `anchorWaits[I]` — waits to emit immediately before instruction `I`.
|
||
- `tailDrains` — extra waits an optimizer requested before a predecessor's terminator.
|
||
|
||
### CounterEmitState
|
||
|
||
Per-counter bookkeeping during a block walk. Tracks `lastEmittedWait` and `opsSinceLastWait` for redundancy elision:
|
||
|
||
```
|
||
needsNewWait(required) =
|
||
lastEmittedWait == kUnused
|
||
OR lastEmittedWait + opsSinceLastWait > required
|
||
```
|
||
|
||
Intuition: after emitting `s_wait N`, at most `N` old ops remain; each new op issued since bumps the effective residual by 1. If `lastEmittedWait + opsSinceLastWait <= required`, the hardware is already at least as drained as required demands.
|
||
|
||
---
|
||
|
||
## WaitDataflow solver
|
||
|
||
`WaitDataflow::solve()` runs a forward fixed-point over the **full** RPO block list:
|
||
|
||
1. Seed every block with empty state (lattice bottom).
|
||
2. Each iteration: for each block, `mergeFromPredecessors` builds entry state, then `transferBlock` walks instructions and mutates state.
|
||
3. In default mode, after sweep 0, restore the `CK_Tensor` slice of each block's entry/exit state from the frozen sweep-0 snapshot. DS, buffer, and KM continue normal fixed-point iteration.
|
||
4. Convergence = full RPO sweep with no exit-state change.
|
||
5. On convergence, report any counter that exceeded `kMaxInFlight` as a non-fatal diagnostic.
|
||
|
||
**Iteration cap**: `min(256, max(kMaxInFlight + 8, 2 * n))` where `n = |rpo|`. On cap hit, `materializePlan()` forces wait 0 at every emitting anchor and logs a warning.
|
||
|
||
If `enableLoopCarriedTokenDeps` is true, the tensor freeze step is skipped and `CK_Tensor` iterates to the same fixed point as the other counters.
|
||
|
||
### mergeFromPredecessors
|
||
|
||
At block entry:
|
||
|
||
1. **Seed per-pred queues** — copy each predecessor's exit queues, retagging `pred`. Self-predecessors (back-edges) are included; at fixed point the back-edge's exit is the loop body's true exit. Identical `(pred, ops)` queues are deduplicated so loop bodies converge (without dedup, back-edges would re-copy the same queue each iteration and the state would never stabilize).
|
||
2. **Forward PHI summaries** from all preds; collisions keep the strictest (min) wait per counter.
|
||
3. **Build PHI summaries** — for each PHI at the top of the block, compute per-counter wait as the **min** of `countFrom(src) - 1` over constrained incoming paths. Nested PHIs chain through the predecessor's already-computed summary.
|
||
|
||
Queues are **copied, not merged** — a join holds one queue per (predecessor, original-queue) so per-path depth survives. The old "collapse to union queue at exit" design was deliberately removed; collapsing would lose per-pred position info and force over-deep waits.
|
||
|
||
### transferBlock
|
||
|
||
For each non-PHI instruction in program order:
|
||
|
||
1. **`computeRequiredWaits`** — determine `required[CK_Count]` (see below).
|
||
2. **Emit decision** — for each counter with a required wait, apply redundancy elision; record `(anchor, WaitCountSpec)` in `emitPlan`.
|
||
3. **`trimQueues`** — model the hardware drain on all per-pred queues for that counter.
|
||
4. **Record producer** — append the instruction to its counter queue *after* the wait decision (so the wait's snapshot excludes its own consumer).
|
||
|
||
Per-pred queues are **not** collapsed at block exit.
|
||
|
||
---
|
||
|
||
## Dependency resolution
|
||
|
||
### RAW from SSA sources
|
||
|
||
RAW dependencies come from `inst->getSources()` after `buildUseDefChain(includePseudo=true)`:
|
||
|
||
- Concrete memop sources: `classifyMemOp(*src)` maps to a counter; `countFrom(src) - 1` from each per-pred queue contributes via `tightenRequired` (min across hits).
|
||
- PHI sources: `phiCurrentQueueWait` recurses through PHI inputs and scans **live** per-pred queues (not stored PHI summaries alone), so intervening in-block ops are counted.
|
||
|
||
```
|
||
v_wmma uses v[0:3]
|
||
-> getSources(): PHI
|
||
-> incoming[0]: ds_read (preloop)
|
||
-> incoming[1]: ds_read (loop body)
|
||
-> both ds_read ops contribute DS-counter waits
|
||
```
|
||
|
||
There is **no same-pipeline filter for RAW**: a `ds_store` consuming a `ds_load`'s vreg still needs the DS wait. Same-pipeline skip applies only to anti-deps.
|
||
|
||
Each RAW contribution is gated by `rawNeedsWait[c](*inst)` — the per-counter predicate that decides whether this consumer is an anchor where a drain may be emitted.
|
||
|
||
### computeRequiredWaits
|
||
|
||
Per instruction, `required[c]` starts at `kUnused` and is tightened to the min wait across all contributing deps on counter `c`:
|
||
|
||
1. **RAW from SSA** — walk `getSources()` as above; gated by `rawNeedsWait[c](*inst)`.
|
||
2. **Anti-deps (DS)** — `scanDsAntiDeps` for LDS writers (`tensor_load_to_lds`, `ds_write`) and barriers with `MemTokenData` token overlap against per-pred DS queues; same-pipeline pairs (`ds_write` vs `ds_read`) skipped.
|
||
3. **Tensor untagged scan** — tensor anchors with tagged tokens still scan for in-flight tensor loads lacking `MemTokenData`.
|
||
4. **Conservative fallbacks** — force wait 0 when disjointness cannot be proved (see table below).
|
||
|
||
### Anti-dependencies (WAR-on-LDS and barrier ordering)
|
||
|
||
The SSA RAW chain captures "consumer reads producer's result" but not anti-dependencies:
|
||
|
||
- An **LDS writer** (`tensor_load_to_lds` or `ds_write`) must wait for prior LDS readers/atomics on a matching token (WAR): the write must not clobber LDS a reader has not consumed yet.
|
||
- A **barrier** must wait for any prior DS op on a matching token.
|
||
- Each in-flight DS op whose token set overlaps the anchor's becomes an extra DS dep.
|
||
- **Same-pipeline pairs are skipped** (`isOnSamePipeline`): two DS ops are ordered by the DS FIFO in hardware.
|
||
- A DS op with no `MemTokenData` is treated as overlapping — disjointness cannot be proved.
|
||
|
||
Anchor helpers:
|
||
|
||
- `isTensorAnchor` — barrier, `ds_read`, `ds_write`, `ds_atomic`
|
||
- `isLdsWriterAnchor` — `tensor_load_to_lds`, `ds_write`
|
||
|
||
### Conservative fallbacks
|
||
|
||
| Site | Condition | Effect |
|
||
|------|-----------|--------|
|
||
| Tensor anchor | Anchor lacks `MemTokenData` and any tensor load in flight | `required[CK_Tensor] = 0` |
|
||
| LDS writer | Writer lacks `MemTokenData`, any DS op in flight, writer is not `ds_write` | `required[CK_DS] = 0` |
|
||
| Barrier | Any in-flight DS op and (barrier untagged OR any pending DS op untagged) | `required[CK_DS] = 0` |
|
||
|
||
These widen waits, never narrow them.
|
||
|
||
---
|
||
|
||
## Plan materialization
|
||
|
||
`materializePlan()` flattens `emitPlan` into `WaitInsertionPlan.anchorWaits`. On normal convergence, values are copied as-is. On iteration-cap hit, every counter field at each anchor is forced to 0 — a fully-drained, always-safe plan.
|
||
|
||
---
|
||
|
||
## ShallowPredPromotion
|
||
|
||
After `materializePlan()`, `WaitPlanOptimizer::rewrite` may relax the plan. `ShallowPredPromotion` is the only shipped optimizer.
|
||
|
||
**Problem**: at a CFG join, the baseline solver emits a single anchor wait sized for the shallowest constrained path (`min` across paths), over-draining deeper paths.
|
||
|
||
**Solution**: for each anchor wait at a join (≥2 predecessors), per constrained counter:
|
||
|
||
1. **Collect deps** — flatten PHIs to concrete memops via `collectMemOpDeps` (RAW SSA only).
|
||
2. **Per-pred required wait** — from `DataflowResult.exitState[pred]`, compute the strictest `w` that path alone would need (`perPredRequiredWait`).
|
||
3. **Classify predecessors** — *correctable* if not a self-pred and the pred has only this block as successor (otherwise a tail drain would also fire on the pred's other successors); otherwise *uncorrectable*.
|
||
4. **Choose relaxed anchor wait**:
|
||
- Only correctable preds → use the deepest (`maxCorrectable`); shallow preds get tail drains.
|
||
- Any uncorrectable pred → cannot exceed its requirement (`minUncorrectable`).
|
||
- Mixed → `min(maxCorrectable, minUncorrectable)`.
|
||
- Skip if the new wait is not looser than the current one.
|
||
5. **Record tail drains** — for every correctable pred whose requirement is below the relaxed anchor wait, keep the strictest (smallest) immediate per (pred, counter).
|
||
6. **Materialise `tailDrains`** deterministically (preds sorted by pointer).
|
||
|
||
**Net effect** on a diamond with shallow and deep paths: the shallow predecessor gets a tail drain (e.g. `s_wait_tensorcnt 0` before its branch), and the merge anchor relaxes from the shallow path's depth to the deep path's depth, preserving in-flight overlap on the deep path.
|
||
|
||
---
|
||
|
||
## finalizePlan
|
||
|
||
After optimizers, `WaitDataflow::finalizePlan()` replays blocks where the solver's queue simulation may disagree with the final plan.
|
||
|
||
### When a block is replayed
|
||
|
||
`blockNeedsFinalize` returns true when the block has any entry in `plan.anchorWaits` **or** ≥2 wait-anchor candidates (any instruction where `rawNeedsWait` is true for any counter).
|
||
|
||
### Per-block replay algorithm
|
||
|
||
For each replayed block in RPO order:
|
||
|
||
1. **`adjustedEntry`** — start from converged `entryState[bb]`. For each predecessor with a `TailDrain` in the plan, virtually trim only that pred's per-pred queues by the tail-drain wait values. Tail drains execute on the predecessor **before** control enters the successor; converged `entryState` does not include them.
|
||
2. **Tensor freeze restore** — in default mode and after replay sweep 0, restore only the `CK_Tensor` slice of the block entry from the frozen replay entry snapshot.
|
||
3. **Program-order walk** — for each non-PHI instruction:
|
||
- `computed = computeRequiredWaits(inst, state, rawNeedsWait)`
|
||
- `applySpec = mergePlanAndComputed(plan, inst, computed, emitState)`
|
||
- Trim queues with applied wait values (never with pre-optimizer solver values)
|
||
- Update `plan.anchorWaits[inst]` — add, update, or erase redundant fields
|
||
- Append producers to counter queues after the wait decision
|
||
4. **Tensor exit restore** — in default mode and after replay sweep 0, restore only the `CK_Tensor` slice of the block exit from the frozen replay exit snapshot before convergence comparison.
|
||
|
||
When `enableLoopCarriedTokenDeps` is true, both tensor restore steps are skipped and all counters are replayed to a fixed point.
|
||
|
||
### mergePlanAndComputed
|
||
|
||
| Planned wait | Computed need | `needsNewWait` | Action |
|
||
|--------------|---------------|----------------|--------|
|
||
| set | — | yes | Use **planned** value for trim and emit |
|
||
| set | — | no | Drop planned field (redundant) |
|
||
| unset | set | yes | **Add** computed wait to plan |
|
||
| unset | set | no | No wait |
|
||
|
||
Planned values win when redundancy allows — optimizers may intentionally relax below the replay recompute.
|
||
|
||
### Effects
|
||
|
||
- **Adds** missing later-anchor waits when promotion relaxed an earlier drain.
|
||
- **Removes** redundant waits when an earlier full drain already covered them.
|
||
|
||
---
|
||
|
||
## Multi-anchor diamond example
|
||
|
||
A diamond CFG with two barriers on disjoint tokens exercises per-path promotion and multi-anchor finalize together:
|
||
|
||
```
|
||
^entry
|
||
tensor_load [11]
|
||
/ \
|
||
v v
|
||
^p_shallow ^p_deep
|
||
(no extra ops) tensor_load [12] × 2
|
||
\ /
|
||
v v
|
||
^merge
|
||
s_barrier [11]
|
||
s_barrier [12]
|
||
```
|
||
|
||
### Per-path FIFO at `^merge` entry
|
||
|
||
| Path | In-flight tensor ops | Wait for token `[11]` barrier |
|
||
|------|----------------------|-------------------------------|
|
||
| `^p_shallow` | `[t11]` | `tlcnt = 0` |
|
||
| `^p_deep` | `[t11, t12, t12]` | `tlcnt = 2` (drain `t11`, keep the two `t12`) |
|
||
|
||
The baseline solver takes `min(0, 2) = 0` — sound but over-drains the deep path.
|
||
|
||
`ShallowPredPromotion`:
|
||
|
||
- Inserts `s_wait_tensorcnt 0` at `^p_shallow`'s tail (pre-drain the shallow path).
|
||
- Relaxes the merge anchor before barrier `[11]` from `0` to `2`.
|
||
|
||
After barrier `[11]` with `tlcnt = 2`, the deep path still has `[t12, t12]` in flight. Barrier `[12]` must emit `s_wait_tensorcnt 0`. Without `finalizePlan`, the solver already simulated a full drain at barrier `[11]` (`tlcnt = 0`) and would not add the second wait.
|
||
|
||
### Expected output
|
||
|
||
**With `ShallowPredPromotion` enabled (production default):**
|
||
|
||
```asm
|
||
^p_shallow:
|
||
s_wait_tensorcnt 0 ; tail drain
|
||
|
||
^merge:
|
||
s_wait_tensorcnt 2 ; relaxed merge wait for token [11]
|
||
s_barrier tokens = [11]
|
||
s_wait_tensorcnt 0 ; added by finalizePlan — drain token [12]
|
||
s_barrier tokens = [12]
|
||
```
|
||
|
||
**With optimizers disabled:**
|
||
|
||
The baseline solver emits a single `s_wait_tensorcnt 0` before barrier `[11]`, fully draining every path. `finalizePlan` replays the block, sees barrier `[12]` has no in-flight deps, and does not add a second wait:
|
||
|
||
```asm
|
||
^merge:
|
||
s_wait_tensorcnt 0
|
||
s_barrier tokens = [11]
|
||
s_barrier tokens = [12] ; no second wait
|
||
```
|
||
|
||
Approaches that were considered and rejected for this case:
|
||
|
||
| Approach | Problem |
|
||
|----------|---------|
|
||
| Selective trim on `wait 0` when a later anchor exists | Correct with optimizer on, wrong when optimizer is off (redundant second wait) |
|
||
| Run optimizers inside the fixed-point loop | Couples promotion to incomplete snapshots; hard to terminate |
|
||
| Always use `max` instead of `min` at joins in the solver | Breaks the baseline plan that promotion is designed to relax |
|
||
|
||
---
|
||
|
||
## Emit phase
|
||
|
||
### Anchor waits
|
||
|
||
Walk blocks and instructions in **program order** for deterministic placement when multiple anchors share a block. Only blocks passing `shouldProcessBasicBlock` are modified.
|
||
|
||
### Tail drains
|
||
|
||
For each `TailDrain`, insert waits before the predecessor's **branch terminator** (if the terminator is a branch; otherwise anchor is `nullptr`).
|
||
|
||
Each non-`kUnused` field in `WaitCountSpec` produces one instruction:
|
||
|
||
| WaitCountSpec field | Emitted instruction | Modifier |
|
||
|---------------------|---------------------|----------|
|
||
| `dsCount` | `s_wait_dscnt <N>` | `SWaitCntData.dlcnt` |
|
||
| `bufferCount` | `s_wait_loadcnt <N>` | `SWaitCntData.vlcnt` |
|
||
| `tensorCount` | `s_wait_tensorcnt <N>` | `SWaitTensorCntData.tlcnt` |
|
||
|
||
### removePHIs
|
||
|
||
Memtoken PHIs are pseudo-instructions used only to thread SSA edges. After emission they are erased from every processed block in RPO order.
|
||
|
||
---
|
||
|
||
## Worked examples
|
||
|
||
### DS reads consumed by WMMA
|
||
|
||
**Input:**
|
||
|
||
```assembly
|
||
label_LoopBeginL:
|
||
ds_read_b128 v[0:3], v8
|
||
ds_read_b128 v[4:7], v9
|
||
ds_read_b128 v[8:11], v10
|
||
ds_read_b128 v[12:15], v11
|
||
v_wmma_f32 v[16:23], v[0:3], v[4:7], v[16:23]
|
||
v_wmma_f32 v[24:31], v[8:11], v[12:15], v[24:31]
|
||
```
|
||
|
||
**Analysis:** After `buildUseDefChain`, each `v_wmma` lists its `ds_read` sources via `getSources()`. The dataflow records all four reads in the per-pred DS queue:
|
||
|
||
```
|
||
At first v_wmma (uses op0, op1):
|
||
op0: countFrom = 4, requiredWait = 3
|
||
op1: countFrom = 3, requiredWait = 2
|
||
-> Emit s_wait_dscnt 2
|
||
|
||
At second v_wmma (uses op2, op3):
|
||
op2: countFrom = 2, requiredWait = 1
|
||
op3: countFrom = 1, requiredWait = 0
|
||
needsNewWait(0): lastEmitted=2, opsSinceLastWait=0, 2+0 > 0 -> true
|
||
-> Emit s_wait_dscnt 0
|
||
```
|
||
|
||
**Output:**
|
||
|
||
```assembly
|
||
label_LoopBeginL:
|
||
ds_read_b128 v[0:3], v8
|
||
ds_read_b128 v[4:7], v9
|
||
ds_read_b128 v[8:11], v10
|
||
ds_read_b128 v[12:15], v11
|
||
s_wait_dscnt 2
|
||
v_wmma_f32 v[16:23], v[0:3], v[4:7], v[16:23]
|
||
s_wait_dscnt 0
|
||
v_wmma_f32 v[24:31], v[8:11], v[12:15], v[24:31]
|
||
```
|
||
|
||
### WAR-on-LDS across a CFG edge
|
||
|
||
```text
|
||
^entry:
|
||
v[0:1] = ds_load_b64 tokens=[0] ; DS reader R0
|
||
v[2:3] = ds_load_b64 tokens=[0] ; DS reader R1
|
||
-> ^body
|
||
^body:
|
||
tensor_load_to_lds tokens=[0] ; LDS writer W
|
||
```
|
||
|
||
`R0`/`R1` → `W` is a **WAR** (writer after readers), which the SSA RAW chain does not carry. Solving `^entry` leaves `state.queues[CK_DS] = [{entry, [R0, R1]}]` at exit. At `W`, `scanDsAntiDeps` finds `R0` → wait 1, `R1` → wait 0; min = **0**. Emit `s_wait_dscnt 0` before the `tensor_load_to_lds`.
|
||
|
||
### Per-path tail compensation
|
||
|
||
```text
|
||
^entry: T0 (token 0) --> ^A (deep): X1, X2 (token 1)
|
||
\-> ^B (shallow): (empty)
|
||
both --> ^merge: ds_load_b64 (token 0)
|
||
```
|
||
|
||
- `exitState[A]` tensor depth 3 for `T0` → `w = 2`; `exitState[B]` depth 1 → `w = 0`.
|
||
- Conservative plan: `s_wait_tensorcnt 0` at merge (over-drains `A`).
|
||
- After promotion: `s_wait_tensorcnt 0` at `^B`'s tail; `s_wait_tensorcnt 2` at merge before `ds_load_b64`.
|