Files
kernbench2/docs/adr-proposed/ADR-0063-prog-tl-scratch-scope.md
T
mukesh d282144339 gqa: ADR-0060/0062/0063/0064 unified GQA kernels + CPU cost model
Land the new GQA fused-attention kernels (ADR-0060) for prefill/decode
across long and short context, the TL discipline primitives they depend
on (ADR-0062 lazy load, ADR-0063 scratch_scope + copy_to), and the
per-op-type CPU issue cost model (ADR-0064). Remove the pre-ADR-0060
mesh-attention baseline now that the unified kernels supersede it.

ADR-0060 (long context)
- _gqa_decode.py: M-fold + 2-level chain reduce-to-root (Level-2
  intra-CUBE row-then-col + Level-1 inter-CUBE) — root-only output.
- _gqa_prefill.py: head-parallel + Ring KV rotation around C CUBEs,
  online-softmax merge per ring step, per-CUBE distributed output.
- Each merge stage wraps in scratch_scope() and persists running
  (m, l, O) via copy_to() to lift the 1 MiB scratch ceiling.

ADR-0060 §B.split.2 (short context, kv_per_cube in {1,2,4,8})
- _gqa_decode_short.py / _gqa_prefill_short.py: no cube-SP; each CUBE
  owns whole KV heads; PE-parallel heads with intra-group chain
  reduce. Prefill has no Ring KV (each head fully resident).

ADR-0062 (lazy tl.load): future-bearing TensorHandle, auto-wait at
first consuming op (dot/MATH/store/send/copy_to/composite).

ADR-0063 (tl.scratch_scope + tl.copy_to): scoped per-tile arena with
copy_to writeback primitive for persistent running state.

ADR-0064 (CPU issue cost model)
- common/cpu_issue_cost.py: per-op-type table (composite=40 ns,
  primitives=5 ns); ratios are load-bearing per D1.
- TLContext: issue_cost_table param; _emit_dispatch_overhead(kind)
  consults table with dispatch_cycles fallback (ADR-0046 §D6
  back-compat).
- Live PE_CPU paths (greenlet + legacy) construct TLContext with
  DEFAULT_CPU_ISSUE_COST so saturation lever (ADR-0060 §1) is
  measurable end-to-end.

P7 headline bench: milestone-gqa-headline writes per-panel
op_log_summary to 1H_milestone_output/gqa_headline/sweep.json. No
figure renderers yet (deferred).

Removals (pre-ADR-0060 baseline now superseded):
- benches: _attention_mesh_kv.py, _attention_mesh_mlo.py,
  _attention_mesh_mlo_2d.py, milestone_gqa_llama70b.py
- tests: test_attention_*, test_mesh_*, test_milestone_gqa_llama70b
- topology: llama70b_4sip.yaml (only consumer was the deleted diag)
- artifacts: 1H_milestone_output/gqa/ (sweep.json + 5 PNGs)
- tests/gqa/ plot helper + test (broken on Windows Tcl/Tkinter)
- ADR-0060/0061 references to deleted file paths cleaned up
  (EN + KO kept in sync).

Tests: 124/124 focused regression green (attention + Phase E + TL
discipline + triton_emu + pe_components). Full regression: 764 pass,
2 pre-existing test_bench_registry failures (stale EXPECTED_NAMES
across multiple benches, not introduced here).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:15:59 -07:00

225 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0063: `tl.scratch_scope` — per-tile scratch recycling for long context
## Status
Proposed
> Supporting ADR for **ADR-0060** (AHBM GQA Fused Attention). Long-context
> attention sweeps many K/V tiles; each tile's intermediates
> (`scores`, `P`, `exp`, partial `O`, …) currently allocate fresh
> scratch that is never freed within a kernel invocation. The 1 MiB
> per-PE scratch budget is exhausted long before a realistic context
> length.
## Context
### The bump allocator
`TLContext` allocates every math/compute output handle from a per-PE
scratch pool with a **linear bump cursor**
(`src/kernbench/triton_emu/tl_context.py`; `_scratch_alloc`):
```python
def _scratch_alloc(self, nbytes):
aligned = (nbytes + 15) & ~15
addr = self._scratch_base + self._scratch_cursor
self._scratch_cursor += aligned
if self._scratch_cursor > self._scratch_size: # default 1 << 20 = 1 MiB
raise RuntimeError("TLContext scratch overflow: ...")
return addr
```
The docstring states the cursor **"resets on every kernel invocation"** —
i.e. only at kernel entry, never *within* the kernel body. Every
`tl.dot`, `tl.softmax`, `tl.exp`, `tl.sum`, `a - b`, `a * b`, … grabs a
fresh slice and nothing is reclaimed until the kernel returns.
### Why this bites the GQA kernel
The current milestone bench keeps `S_q = S_kv_per_rank = 16` **explicitly
because** of this limit
(`tests/attention/test_milestone_gqa_llama70b.py:123-148`):
> "S_q_prefill and S_kv_per_rank are deliberately small (16 each) so the
> simulator's 1 MB per-PE TCM kernel scratch is not exhausted by the
> bump-allocated handle outputs of softmax/exp/dot/sum chains over
> n_ranks ring steps."
A FlashAttention sweep over `n_tiles` tiles allocates O(`n_tiles` ×
per-tile-intermediates). For any realistic context this overflows. The
*math* of flash attention needs only **O(1)** live scratch — the running
`(m, l, O)` plus the current tile's working set — because each tile's
temporaries are dead once that tile is folded into the running state. The
allocator just doesn't know they're dead.
## Decision
Add a **scratch scope** that lets a kernel mark a region of allocations
as reclaimable, so per-tile temporaries are recycled while live running
state (and in-flight prefetch buffers) are preserved.
### D1. `tl` surface — context manager
```python
with tl.scratch_scope():
s = tl.dot(q, k_t) # all handles allocated inside the `with`
p = tl.softmax(s) # share a region that is rewound on exit
o_j = tl.dot(p, v)
# ... fold o_j into running (m,l,O) which live OUTSIDE the scope ...
# on __exit__: cursor rewinds to its value at __enter__
```
Semantics:
- `__enter__` records the current `_scratch_cursor` as a save-point.
- `__exit__` restores the cursor to the save-point, freeing everything
allocated inside.
- Handles allocated **outside** the scope (running `m,l,O`, prefetch
buffers held by `LoadFuture` from ADR-0062) keep their addresses —
they were allocated before the save-point or in an enclosing scope.
### D2. Safety contract
A handle allocated inside a scope **must not** be read after the scope
exits — its bytes may be overwritten by the next scope's allocations.
The flash loop respects this naturally: the only values that survive a
tile iteration are the running accumulators, which are allocated outside
the per-tile scope and updated by ops *inside* it writing to outside
addresses (the merge writes new running state — see D3).
### D3. Interaction with running accumulators
The online-softmax merge reads the old running `(m, l, O)` and the
current tile's `(m_j, l_j, O_j)`, producing new running values. To keep
the new running values outside the recycled region, the merge writes them
to **stable scratch** allocated once before the loop (a small fixed
"running-state" arena, distinct from the per-tile scope). Concretely the
kernel keeps two arenas:
- **persistent arena** (allocated once): `m, l, O` (and their
double-buffer if needed for the merge).
- **scoped arena** (rewound each tile): `scores, P, exp, O_j, scale_*`.
This mirrors how real flash-attention SRAM budgeting works: a small
persistent accumulator region + a recycled tile working set.
#### D3.1 The persistent-arena write mechanism: `tl.copy_to(dst, src)`
The merge ops (`tl.maximum`, `tl.exp`, binary `*` / `+`) all call
`_make_compute_out(...)` which allocates from the bump cursor (D1).
Inside a `scratch_scope`, their result handles therefore live **inside**
the scope and vanish on `__exit__`. To realise D3's two-arena split, the
kernel needs a way to **write a scoped result's bytes back to a
persistent address**.
The primitive that closes this gap:
```python
def copy_to(self, dst: TensorHandle, src: TensorHandle) -> None:
"""Copy ``src``'s bytes into ``dst``'s address (both TCM).
Shapes and dtypes must match. ``dst`` is typically a handle
allocated outside any active ``scratch_scope`` (the persistent
arena); ``src`` is a scoped handle whose bytes must outlive
scope ``__exit__``.
"""
```
Symmetric to `tl.store` (the HBM-side byte copy), kept TCM-only here so
the running-state writeback doesn't pollute op_log with spurious DMA.
**Mechanics:**
- New `CopyCmd(src, dst, nbytes, data_op=True)` command.
- op_log: `op_kind="math"`, `op_name="copy"` — runs on the vector engine.
- Latency: `pe_math._compute_ns(prod(shape))` — models on-chip register
writeback, not HBM transfer.
- Emit-time validation: `dst.shape == src.shape`, `dst.dtype == src.dtype`,
`dst.space == "tcm"`, `src.space == "tcm"`. Authoring errors surface in
Phase 1, not deep in Phase 2 data execution.
**Call-site pattern:**
```python
m, l, O = init_running(...) # persistent (outside scope)
for j in range(n_tiles):
with tl.scratch_scope():
... # per-tile work (recycled)
m_new = tl.maximum(m, mj) # scoped scratch
l_new = l * scale_old + l_step * scale_step
O_new = O * scale_old + O_step * scale_step
tl.copy_to(m, m_new) # ← persist new running state
tl.copy_to(l, l_new)
tl.copy_to(O, O_new)
# exit: scoped m_new/l_new/O_new gone; their bytes live in persistent m/l/O
```
The copy happens **before** `__exit__`, so the read of `src` (scoped) is
valid; after exit only `dst` (persistent) is read, satisfying D2's
no-read-after-exit safety contract.
**Why a dedicated primitive rather than `dst=` kwargs on every math op**
(considered, rejected): adding `dst=` to `tl.maximum`, `tl.exp`,
`_binary_math`, `_unary_math`, `_reduction` is ~25 LOC across 5
op-families and breaks the uniform "call returns a fresh handle"
pattern. `tl.copy_to` is one primitive, one command, one executor
handler — minimal surface area for the same effect.
### D4. Nesting
Scopes nest (stack of save-points). Inner scope exit rewinds to the inner
save-point; outer exit rewinds further. This supports an outer
"per-query-block" scope around an inner "per-KV-tile" scope for prefill.
## Alternatives
### A1. Round-trip temporaries through HBM
Store intermediates to HBM and reload to "free" TCM scratch. Rejected:
turns a TCM-resident streaming kernel into an HBM-bandwidth-bound one —
the opposite of the goal, and it pollutes op_log with spurious DMA.
### A2. Tiled `tl.composite` (scheduler-managed scratch)
`tl.composite` recycles per-tile scratch inside PE_SCHEDULER
automatically. As in ADR-0062 A1, this is attractive but blocked on a
flash-capable composite kind (two GEMMs + carried `(m,l,O)`), a much
larger change. `scratch_scope` gives the same memory behaviour for the
greenlet kernel with a tiny, general primitive. The two can coexist.
### A3. Grow the scratch budget
Bump `pe_tcm.kernel_scratch_mb`. Rejected as a fix: it only pushes the
context-length ceiling out linearly while still leaking O(`n_tiles`)
scratch, and it misrepresents the hardware (real TCM is small; the point
of flash attention is O(1) working set). Useful only as a coarse knob,
not a substitute for recycling.
## Consequences
### Positive
- Removes the artificial `S = 16` validation-scale ceiling; enables
realistic context lengths in both timing and data modes.
- Faithfully models flash attention's O(1) working-set property.
- Small, general primitive (any tiled kernel benefits).
### Negative
- A use-after-scope bug silently reads stale bytes. Mitigated by the D2
contract, by keeping the scope discipline inside a shared attention
helper, and (optionally) by a debug build that poisons rewound regions.
- Must coordinate with ADR-0062: prefetch buffers are live across tile
iterations, so they belong to the persistent arena, not the scoped one.
## Test Requirements
1. **Recycling**: a loop of `N` tiles inside `tl.scratch_scope()` keeps
peak `_scratch_cursor` bounded by one tile's footprint, independent of
`N` (today it grows linearly and overflows).
2. **Correctness**: a flash sweep with scopes produces the same `O` as
the same sweep without scopes at a small `N` that fits without
recycling (Phase 2).
3. **Long context**: a sweep at an `N` that would overflow 1 MiB without
scopes completes (the exact failure the `S=16` cap avoids today).
4. **Persistent-vs-scoped isolation**: running `(m,l,O)` allocated
outside the scope retains correct values across `__exit__`.
5. **Nesting**: nested scopes rewind to the correct save-points.