The reconciliation table, corrected file/test lists, measured 5.22x ratio, and the new section 15 (data-mode numerics D8 + N1-N4) — the content the git-mv in 30a0451 left uncommitted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
34 KiB
Detailed Design Document — Flat-ops CompositeCmd + softmax_merge recipe (ADR-0065)
Status: Accepted (companion to ADR-0065, Accepted) — reconciled to
the as-built implementation.
Scope: the CompositeCmd flat-ops restructure, the softmax_merge
recipe, the decode opt2 variant in _gqa_attention_decode_opt2.py, and the
data-mode numeric path that makes opt2's recipe compute.
Audience: reviewer / implementer. Read ADR-0065 and ADR-0064
Revision 2 first.
0. As-built reconciliation (read first)
This DDD was authored in the design round; the implementation diverged in
several places. The sections below have been corrected, but the headline
deltas are collected here. (ADR-0065 itself was kept in sync; see commits
47e2c78,184f654,55f025c,17fb940,35453cc,4a55ae5,e8d6c28,
4089e18,3d4a3d4,5bbe293,e9d62b9,f0c5b9c.)
| Topic | Original DDD | As-built |
|---|---|---|
| Phase order | P0 (ADR-0064 Rev2) lands first | P1 (flat-ops) first, then P0 — logical_bytes is naturally defined on the flat shape, so the refactor goes first |
| Operand-input DMA | from handle.space (space=="hbm"→DMA_READ) |
from the pinned flag (already-in-TCM→skip). The space flip was avoided (it would break byte-equality); tl.ref→hbm, but the decision stays pinned-based |
| Output DMA / handle | (not addressed; out=O) |
ADR-0065 D8 — tl.composite returns the output TensorHandle; out=handle (HBM via tl.ref) | out_ptr (HBM shorthand) | omitted→auto-TCM; DMA_WRITE gated on out.space=="hbm" (TCM output stays on-chip, chainable) |
| Size cap (D7) | deterministic segmentation | hard cap → ValueError at emit (no segmentation) |
| Prologue execution | feeder yields prologue_stages first |
feeder feeds each prologue stage as a standalone 1-stage tile (folding broke PE_MATH→PE_DMA routing) |
| D6.7 (MATH TCM-only) | every non-GEMM op | prologue recipe ops only; the head op (gemm or math) keeps DMA-staged-from-HBM |
| Numeric parity | "just works" via the existing data executor | composites did not compute in data mode at all — built in N1–N4 (below): composite-as-gemm record, recipe MATH compute, GEMM accumulate, opt2 ordering |
| Dispatch ratio | model ≈ 4.0× | measured 5.22× per-tile (gate >2×); R-sweep 4.20×/5.22×/5.45× |
| opt2↔opt3 parity | exact in data mode | exact recipe math verified by N2/N3 (isolated, non-trivial); e2e opt2↔opt3 holds in the near-uniform regime (kernbench K reshape-as-transpose caveat, shared by both kernels) |
Data-mode numerics (N1–N4), absent from the original DDD — see §15.
1. Goal and success criteria
Goal: make decode opt2 runnable, numerically equivalent to opt3, and measurably cheaper in PE_CPU dispatch under the ADR-0064 Rev2 cost model. Existing benches keep their (post-ADR-0064 Rev2) goldens unchanged.
Success criteria:
- Refactor safety.
CompositeCmdflat-ops restructure preserves the op_log byte-for-byte for every existing bench (tests/<existing>all green, no goldens diff). - Recipe correctness. opt2 final
(m, l, O)matches opt3 within fp tolerance in data mode (enable_data=True). - Boundary preservation. PE_SCHEDULER imports nothing recipe-related;
PE_MATH/PE_GEMM/PE_DMA see only
Stage.params["op_kind"](no new engine code). - Dispatch ratio (robust). Per-tile PE_CPU dispatch cycles satisfy
opt3 > 2 × opt2(qualitative invariant, calibration-independent). Default ADR-0064 Rev2 calibration (FIXED=40 cycles, R=0.0625 cycles/byte) gives model-expected ≈ 4.0× — informative only, not the test gate. - K-before-V invariant. op_log of decode opt2 shows zero V-related DMA during #2's MATH prologue.
Non-goals: prefill (causal if in kernel control flow, separate
work); RW-aware scheduler reorder (strict FIFO ships); user-defined
recipes; new engine micro-ops in PE_MATH (it stays op_kind-opaque).
2. Where the design sits in the current code
runtime_api (host)
RuntimeContext.zeros/empty/launch context.py
│ KernelLaunchMsg
▼
sim_engine
GraphEngine(enable_data=…) engine.py
MemoryStore memory_store.py
│
▼
components / PE pipeline (per PE)
PE_CPU → PE_SCHEDULER → PE_{DMA,GEMM,MATH,IPCQ,TCM}
greenlet kernel ↔ SimPy kernel_runner.py, pe_cpu.py
CompositeCmd → tile plan → engines pe_scheduler.py (ADR-0014 D6)
│
▼
tl programming model tl_context.py
composite / dot / load / store / scratch_scope
── ADR-0065 NEW ──────────────────────────
tl_recipes.py RECIPE_DESCRIPTORS, EngineOp,
RecipeDescriptor, PrimaryOutSpec
The new module tl_recipes.py sits beside tl_context.py. PE_SCHEDULER
(pe_scheduler.py) and tiling.py extend to consume the flat ops list
without importing tl_recipes.
3. File plan
3.1 Modified production files
| File | Change | ADR ref |
|---|---|---|
src/kernbench/common/pe_commands.py |
CompositeCmd flat-ops dataclass; OpSpec.operands: dict; new Scope value semantics (KERNEL position-based); logical_bytes property on every PeCommand. |
0065 D1/D2, 0064 D2 |
src/kernbench/triton_emu/tl_context.py |
composite(prologue=[...], out=TensorHandle, …); recipe lowering pass (RECIPE_DESCRIPTORS lookup, scratch alloc, OpSpec expansion, primary_out auto-bind); legacy API preserved (op="gemm", a, b, epilogue=[...] still works, internally lowers to flat ops). |
0065 D1/D5 |
src/kernbench/triton_emu/tl_recipes.py (NEW) |
RecipeDescriptor, PrimaryOutSpec, EngineOp dataclasses; RECIPE_DESCRIPTORS["softmax_merge"] entry; expansion helper. |
0065 D5 |
src/kernbench/components/builtin/pe_scheduler.py |
_generate_plan rewritten for flat ops: scan for GEMM (≤1), position-based KERNEL placement, OUTPUT_TILE/K_TILE epilogue, strict-FIFO RW tracker keyed by handle id. |
0065 D3/D6 |
src/kernbench/components/builtin/tiling.py |
generate_plan_from_ops(ops, tile_*, bpe, pe_prefix) wraps generate_gemm_plan/generate_math_plan. Input DMA_READ from the pinned flag; output DMA_WRITE gated on out.space=="hbm" (D8). _math_stage carries operand+output addrs on recipe prologue stages (N2). |
0065 D3/D4/D8 |
src/kernbench/common/pe_cost_model.py (NEW) |
PeCostModel (FIXED/R/cap) + from_node_attrs; replaces the removed Rev1 cpu_issue_cost.py. |
0064 D1/D4 |
src/kernbench/components/builtin/pe_cpu.py |
reads pe_cost_model: + clock_freq_ghz from node attrs, threads to TLContext (legacy + greenlet KernelRunner). |
0064 D1/D4 |
src/kernbench/sim_engine/op_log.py |
_extract_op_info(CompositeCmd) scans for the GEMM op + accumulate flag (N1/N3); record_end promotes an address-carrying MATH stage to op_kind="math" (N2). |
0065 N1-N3 |
src/kernbench/sim_engine/data_executor.py |
_compute_math +5 recipe ops; _execute_gemm best-effort skip + accumulate (N1-N3). |
0065 N1-N3 |
src/kernbench/benches/_gqa_attention_decode_opt2.py (NEW) |
opt2 variant — tile 0 establishes (m,l,O) with primitives; the merge tile uses #1 scores = tl.composite(op="gemm", a=Q, b=K_T1) + #2 tl.composite(prologue=[softmax_merge], op="gemm", b=V_ref, out=O, epilogue=[add]). |
0065 §Migration P3 |
3.2 New tests
| File | Covers |
|---|---|
tests/test_composite_flat_ops_refactor.py |
P1 flat-ops lowering shape (legacy + math + epilogue) |
tests/test_pe_cost_model.py |
ADR-0064 Rev2 — logical_bytes/formula, override, PeCpuOverheadCmd bypass, D7 cap→error, live wiring/path-parity |
tests/test_tl_recipe_softmax_merge.py |
recipe lowering — 10 ops in order + rw_handles == (m, l, O) + size cap |
tests/test_tl_composite_validation.py |
auto-bind conflict (D6.6); prologue MATH operand TCM-only (D6.7) |
tests/test_pe_scheduler_flat_ops_plan.py |
position-scan; pinned-based DMA; prologue stages; MATH-only |
tests/test_pe_scheduler_strict_fifo.py |
_RwHazardTracker admit/retire (RAW/WAW/disjoint/legacy) |
tests/test_composite_output_handle.py |
D8 — composite returns handle (auto-TCM); chaining; output-space DMA; tl.ref→hbm |
tests/test_recipe_data_mode.py |
N2/N3 — recipe MATH compute m/l/O-rescale + full accumulate (O = O·corr + P@V) vs numpy |
tests/test_e2e_pipeline.py |
N1 — composite GEMM computes a@b in data mode |
tests/attention/test_gqa_decode_opt2.py |
dispatch ratio (>2×) + R-sweep; K-before-V; N4 opt2↔opt3 parity |
4. Data model — CompositeCmd flat-ops
4.1 New shape
@dataclass(frozen=True)
class CompositeCmd:
completion: CompletionHandle
ops: tuple[OpSpec, ...] # ordered MATH/GEMM ops
rw_handles: tuple[TensorHandle, ...] = () # cross-composite hazard
data_op: bool = True
@property
def logical_bytes(self) -> int:
return (
4 # framing
+ 1 + sum(op.logical_bytes for op in self.ops)
+ 1 + 8 * len(self.rw_handles)
)
The previous op, a, b, out_addr, out_nbytes, math_op fields
are removed. All info lives in ops[i].
4.2 OpSpec extended
@dataclass(frozen=True)
class OpSpec:
kind: str # "gemm" | "rmax" | "exp_diff" | ...
scope: Scope # KERNEL | K_TILE | OUTPUT_TILE
operands: dict[str, TensorHandle]
extra: dict[str, Any] = field(default_factory=dict)
out: TensorHandle | None = None
@property
def logical_bytes(self) -> int:
return (
1 + 1 # opcode + scope enum
+ 1 + 8 * len(self.operands) # named handles
+ (8 if self.out is not None else 0)
+ 1 + 4 * len(self.extra) # scalars
)
Backward compat: existing operands: tuple[Any, ...] use sites
(epilogue bias, scale, add, etc.) migrate to operands={"other": h} / operands={"src": h} per a small lookup in
pe_commands.EPILOGUE_OPS.
4.3 Scope semantics — phase vs repetition (ADR-0065 D3)
Scope keeps its three values (KERNEL, K_TILE, OUTPUT_TILE).
What changes is the semantics split:
- Position determines the phase (where in the tile-loop lifecycle the op fires — pre-loop / in-loop / post-loop).
- Scope determines the repetition within the phase
(KERNEL = once per
CompositeCmdinvocation; K_TILE = per K-tile iteration; OUTPUT_TILE = per output tile).
KERNEL does not mean "once per kernel launch" or "once anywhere".
It means "once per CompositeCmd invocation, at this op's position".
A composite with multiple KERNEL-scope ops (e.g., softmax_merge's 8
prologue ops) executes each exactly once, in sequence order, before
the GEMM tile loop begins.
Concretely:
- KERNEL ops before GEMM → pre-tile-loop (single shot, in order)
- KERNEL ops after GEMM → post-tile-loop (single shot, in order)
- K_TILE / OUTPUT_TILE ops after GEMM → inside tile loop as today
5. Recipe: softmax_merge
5.1 tl_recipes.py definitions
@dataclass(frozen=True)
class PrimaryOutSpec:
from_shape: str # operand name to copy shape from
from_dtype: str # operand name to copy dtype from
transform: str # "identity" | "trans" (forward-compat)
@dataclass(frozen=True)
class EngineOp:
engine: Literal["MATH", "GEMM", "DMA"]
op_kind: str # PE_MATH op_kind label (opaque to engine)
# Slot names: any of "src", "src_a", "src_b", "src_c", "dst" — these
# are RecipeDescriptor.operands names or internal scratch names.
src: str | None = None
src_a: str | None = None
src_b: str | None = None
src_c: str | None = None
dst: str | None = None
reduce_axis: int | None = None
bcast_axis: int | None = None
@dataclass(frozen=True)
class RecipeDescriptor:
operands: dict[str, str] # name → "R" | "RW"
primary_out: PrimaryOutSpec | None # implicit output spec
tile_alignment: Literal["single_shot", "tile_aligned"]
internal_scratch_bytes_fn: Callable[..., int]
engine_seq: tuple[EngineOp, ...]
RECIPE_DESCRIPTORS: dict[str, RecipeDescriptor] = { ... }
5.2 Engine sequence (8 MATH ops)
| # | engine | op_kind | reads (slots) | writes (slots) | extras |
|---|---|---|---|---|---|
| 1 | MATH | rmax |
s | m_loc | reduce_axis=-1 |
| 2 | MATH | max_elem |
m, m_loc | m_new | — |
| 3 | MATH | exp_diff |
m, m_new | corr | — |
| 4 | MATH | exp_diff |
s, m_new | P (primary_out) | bcast_axis=0 |
| 5 | MATH | rsum |
P | l_loc | reduce_axis=-1 |
| 6 | MATH | fma |
l, corr, l_loc | l | — |
| 7 | MATH | mul_bcast |
O, corr | O | bcast_axis=1 |
| 8 | MATH | copy |
m_new | m | — |
Intermediates m_loc, m_new, corr, P, l_loc are scratch slots allocated
by TLContext before lowering. P is the recipe's primary output; auto-
bound to the head GEMM's operands["a"].
5.3 PE_MATH op_kind additions
PE_MATH is op_kind-opaque (pe_math.py:90-99).
Latency = ceil(num_elements / vector_width) / clock. Adding new
op_kind labels (rmax, max_elem, exp_diff, rsum, fma, mul_bcast, copy)
requires no PE_MATH code changes — they pass through as string
labels in Stage.params["op_kind"]. Latency stays consistent for the
same num_elements.
(Reductions like rmax / rsum have log-depth final stages in real HW
not modelled by ceil(num_elements / vector_width); this is an existing
approximation, not introduced by ADR-0065.)
6. TLContext lowering pipeline
Input (user-facing API, preserved):
tl.composite(
op="gemm", b=tl.ref(v_tile(j), (TILE, d)), out=O,
prologue=[{"op": "softmax_merge", "s": Sj, "m": m, "l": l, "O": O}],
epilogue=[{"op": "add", "other": O}],
)
Lowering steps in TLContext.composite():
- Validate prologue items. For each, lookup
RECIPE_DESCRIPTORS[item["op"]]. Check all operands present, type tags (R / RW) match handle states (e.g., RW must not betl.ref). - Allocate internal scratch. Call
recipe.internal_scratch_bytes_fn(G, TILE, d, bpe); allocate viascratch_scopehelper (ADR-0063). Get slot addresses form_loc, m_new, corr, P, l_loc. - Derive primary_out handle. From
recipe.primary_out:Phandle =TensorHandle(shape=Sj.shape, dtype=Sj.dtype, addr=<P slot>, space="tcm"). - Expand
engine_seq→ flat OpSpecs. For eachEngineOp:- Resolve slot names (operands or scratch) to
TensorHandles. - Build
OpSpec(kind=op_kind, scope=Scope.KERNEL, operands={...}, extra={...}, out=...). outis the handle fordstslot;operandsis{src*: handle}.
- Resolve slot names (operands or scratch) to
- Auto-bind head GEMM (with conflict check, ADR-0065 D6.6). GEMM
OpSpec built from
op="gemm",operands={"a": P_handle, "b": V_handle},out=<output handle>,extra={"m": G, "k": TILE, "n": d}. Validation: if the kernel passeda=<some handle>explicitly AND there is a prologue recipe withprimary_outthat would auto-bind intoa, raise a validation error (auto-bind conflict). The kernel must omita(let recipe bind) or omit the prologue / use aprimary_out=Nonerecipe (no auto-bind). - Build epilogue OpSpecs. Each
epilogue=[{...}]entry maps toOpSpec(kind=..., scope=Scope.OUTPUT_TILE, operands={...}, ...). Theaddepilogue specifically getsoperands={"other": O_handle}so the Stage later carriesother_handle(GAP 9 fix). - Compute
rw_handles. Collect all handles tagged RW in prologue recipes + the head GEMM'sout(if accumulating) + any RW epilogue targets. For softmax_merge:rw_handles = (m, l, O). - Operand validation (ADR-0065 D6.7, as-built scope). Checked on the
prologue recipe ops only: every operand handle must be
space == "tcm"(HBM recipe operand →ValueError). The head op (gemm or math) is exempt — it may stream from HBM. softmax_merge's 8 MATH ops only reference TCM-resident handles → the invariant naturally holds. Also: GEMM-count ≤ 1 (D6.1). - Check composite size cap (ADR-0064 D7, as-built). If
cmd.logical_bytes > max_composite_logical_bytes(default 1024), raiseValueErrorat emit — hard cap, no auto-segmentation. softmax_merge's 10-op composite (~322 bytes) is well under the cap. - Resolve the output handle (ADR-0065 D8) + emit.
out=handle (HBMtl.ref| in-place TCM) |out_ptr(HBM shorthand) | omitted→auto-TCM scratch. EmitCompositeCmd(ops=(8 MATH + 1 GEMM + 1 MATH(add)), rw_handles=(m,l,O)), attach aCompositeFuture(carrying the completion) to the output handle, and return that handle so the result chains liketl.dot's (downstream auto-awaits via_await_pending).
Legacy lowering. Pre-existing
tl.composite(op="gemm", a=A, b=B, epilogue=[{"op":"bias","bias":h}])
goes through the same emit path: no prologue, head OpSpec from op="gemm",
epilogue OpSpecs built from epilogue=[...]. Output CompositeCmd has
flat ops list; semantics preserved.
7. PE_SCHEDULER tile plan generation (flat-ops)
def _generate_plan(cmd: CompositeCmd) -> PipelinePlan:
pp = self._pe_prefix
bpe = 2
# 1. Identify GEMM op (≤1).
gemm_idx = next(
(i for i, o in enumerate(cmd.ops) if o.kind == "gemm"),
None,
)
# 2. MATH-only composite (no GEMM): single tile of all ops in order.
if gemm_idx is None:
return _math_only_plan(cmd.ops, pp)
gemm = cmd.ops[gemm_idx]
M, K, N = gemm.extra["m"], gemm.extra["k"], gemm.extra["n"]
# 3. Partition ops by position.
pre_loop_ops = cmd.ops[:gemm_idx] # KERNEL scope
after_ops = cmd.ops[gemm_idx+1:] # mixed scopes
# 4. Pre-loop MATH Stages (single-shot, before tile loop).
pre_stages = [_math_stage_from_op(op, pp) for op in pre_loop_ops]
# 5. Tile loop (existing generate_gemm_plan pattern, but:
# - DMA_READ inserted only when operand.space == "hbm"
# - K_TILE epilogue from after_ops with scope == K_TILE
# - OUTPUT_TILE epilogue from after_ops with scope == OUTPUT_TILE
# - DMA_WRITE inserted only when out.space == "hbm")
tile_plans = _generate_tile_loop(
gemm, M, K, N, after_ops, pp, bpe,
)
# 6. Post-loop KERNEL Stages (after tile loop completes).
post_loop_ops = [op for op in after_ops if op.scope == Scope.KERNEL]
post_stages = [_math_stage_from_op(op, pp) for op in post_loop_ops]
return PipelinePlan(
prologue_stages=tuple(pre_stages),
tiles=tile_plans,
epilogue_stages=tuple(post_stages),
m_tiles=..., k_tiles=..., n_tiles=...,
)
PipelinePlan gets two new fields:
@dataclass(frozen=True)
class PipelinePlan:
tiles: list[TilePlan]
m_tiles: int
k_tiles: int
n_tiles: int
prologue_stages: tuple[Stage, ...] = ()
epilogue_stages: tuple[Stage, ...] = ()
Feeder change (_feed_loop): yield prologue_stages Stages first, then
tile loop as today, then epilogue_stages. For the single-Stage case
(no prologue/epilogue), behaviour identical to today.
8. Strict-FIFO RW hazard tracker
class _RwHazardTracker:
def __init__(self):
self._inflight: list[tuple[CompletionHandle, set[str]]] = []
# (completion, set of handle ids written or RW)
def admit(self, env, cmd: CompositeCmd) -> Generator:
"""Block until no in-flight composite has overlapping RW."""
rw_ids = {h.id for h in cmd.rw_handles}
# Strict FIFO: wait for ALL prior composites whose rw_set
# intersects, in order.
while True:
blocking = [c for c, s in self._inflight if s & rw_ids]
if not blocking:
break
# Wait on the *first* blocking composite's completion.
yield blocking[0].done_event
self._inflight.append((cmd.completion, rw_ids))
def retire(self, completion: CompletionHandle) -> None:
self._inflight = [
(c, s) for c, s in self._inflight if c != completion
]
PE_SCHEDULER invokes admit() before queueing a CompositeCmd's tile
plan and retire() upon done_event.succeed(). Strict FIFO: even when
overlap is partial, the new composite waits for all prior overlapping
composites to fully drain — simple, easy to reason about, easy to
test. (RW-aware reorder deferred per ADR-0065 A4.)
Conservatism trade-off (ADR-0065 Consequences/Negative). This rule
is safe but may under-expose overlap. Concretely for decode opt2:
next-tile's #1 (Q·Kᵀ) does not touch (m, l, O) and could in
principle run while this-tile's #2 is still computing on those handles.
Under strict FIFO, next-tile's #1 still gets dispatched (no rw overlap
with prior in-flight) — so #1 cross-tile pipelining is preserved.
However, if a future bench composed multiple #2-like recipes that share
handles, FIFO would serialize them even when their compute could
overlap. Acceptable for Phase 1; revisit when a real workload shows
the gap.
9. Phased implementation plan (test-first per CLAUDE.md)
Each phase is an independent Phase-1 → Phase-2 cycle per CLAUDE.md Part 1.
| Phase | Deliverable | Gate |
|---|---|---|
| P0 (= ADR-0064 Rev2) | logical_bytes property + dispatch formula + topology override + size cap; one-time goldens regeneration |
typical 1-OpSpec composite ≈ 43 ns; dispatch ratio test infrastructure in place |
| P1 | CompositeCmd flat-ops dataclass refactor + OpSpec.operands: dict + legacy tl.composite lowering preserved |
every existing bench's op_log byte-equal pre/post |
| P2 | tl_recipes.py + RECIPE_DESCRIPTORS["softmax_merge"] + TLContext lowering for prologue=[...] |
recipe lowering test (10 ops, rw_handles, addresses) passes |
| P3 | PE_SCHEDULER _generate_plan flat-ops + PipelinePlan.prologue_stages + DMA auto-insertion + _feed_loop extension |
all existing bench Stage sequences unchanged; new MATH chain visible in op_log for opt2 mock |
| P4 | Strict-FIFO RW hazard tracker + _RwHazardTracker integration |
two-composite RW conflict test serializes correctly |
| P5 | _gqa_decode_long.py opt2 variant; data-mode numeric parity check; K-before-V invariant check |
opt2 matches opt3 within fp tolerance; no V DMA during prologue |
| P6 | Dispatch-ratio measurement: opt3 vs opt2 per-tile PE_CPU cycles + R sensitivity sweep | opt3 > 2 × opt2 at default calibration (gate); model-expected ≈ 4.0× (informative); opt2 < opt3 at all R ∈ {0.25, 0.0625, 0.03125} (ADR-0064 Test #9) |
As-built ordering: the implementation reversed P0↔P1 — P1 (flat-ops
refactor) landed first, then P0 (ADR-0064 Rev2 cost model), because
logical_bytes is naturally defined on the flat shape (doing P1 first avoids
a throwaway transitional definition). P1 is a pure, byte-equal refactor; P0
then swaps in the structural formula and regenerates goldens (in practice no
golden churn occurred). P5 was split: P5(B) shipped the opt2 kernel +
op_log dispatch measurement first (numeric parity deferred), then D8 +
N1–N4 (§15) built the data-mode numeric path. P6 closed the measurement.
10. Verification plan (concrete)
Mirrors ADR-0065 §Test Requirements; grounded in SPEC R2/R5, ADR-0023/ 0025, ADR-0046, ADR-0054.
Refactor safety (P1):
- Parametrize over
tests/<existing bench>.pybaseline op_logs. - Pre/post refactor diff must be empty for every bench.
- Failure → revert P1 atomically; do not land partial.
Recipe lowering (P2):
- Given
Sj=(8,64), m=(8,), l=(8,), O=(8,128), lowertl.composite(prologue=[{"op":"softmax_merge", "s":Sj,...}], op="gemm", b=V_ref, out=O, epilogue=[{"op":"add", "other":O}]). - Assert
len(cmd.ops) == 10,cmd.ops[0..7].kind == [rmax, max_elem, exp_diff, exp_diff, rsum, fma, mul_bcast, copy],cmd.ops[8].kind == "gemm",cmd.ops[9].kind == "add". - Assert
cmd.rw_handles == (m, l, O). - Assert all OpSpec addresses are concrete (no None).
Scheduler plan (P3):
- For the above CompositeCmd, assert PipelinePlan has 8 prologue_stages (MATH), tile loop with FETCH/GEMM/MATH(add), no epilogue_stages.
- Assert no DMA_READ for prologue MATH stages (operands TCM-resident).
- Assert one DMA_READ for V (
b.space == "hbm"). - Assert no DMA_WRITE (
out.space == "tcm").
RW hazard (P4):
- Submit composite A (rw_handles=(O,)) then composite B (operands reading O). Assert B's Stages start only after A's done_event.
Numeric parity (P5):
- In data mode, run opt2 and opt3 for
(G=8, T_q=1, S_kv=64, C=1, P=1). - Assert
O_opt2 ≈ O_opt3withinfp16tolerance.
K-before-V invariant (P5):
- op_log inspection: for each tile's #2 CompositeCmd, no
memory/dma_readevents between #2 start and the GEMM compute begin that are not the V tile DMA itself.
Dispatch ratio (P6):
- For
S_kv=64, n_tiles=16, measure total PE_CPU dispatch cycles for opt3 vs opt2 paths. - Assert
opt3 > 2 × opt2(robust gate — calibration-independent). - Record observed ratio; the default-calibration model expects ≈ 4.0× (DDD §11). The recorded number is informative for performance tracking, not a test gate.
- Sensitivity sweep. Repeat with
R ∈ {0.25, 0.0625, 0.03125}cycles/byte; assert opt2 < opt3 in all three. (ADR-0064 Test #9.)
Invariant guards (P2):
- Auto-bind conflict:
tl.composite(op="gemm", a=A_handle, prologue=[{"op": "softmax_merge", "s": Sj, "m": m, "l": l, "O": O}], ...)→pytest.raises(ValidationError, match="auto-bind conflict"). - MATH operand TCM-only:
tl.composite(op="math", a=tl.ref(addr, shape), ...)→pytest.raises(ValidationError, match="MATH operand must be TCM"). - Per-op logical_bytes summation: a synthetic composite with the same
handle
Oin 3 OpSpecs + inrw_handlesreportslogical_byteshigher than a deduplicated equivalent by exactly3 × 8(operand references) +8(rw_handles entry) = 32 bytes.
Determinism (all phases):
- Identical inputs → identical op_log + latency (SPEC §0.1).
11. Performance model (expected)
Per-tile PE_CPU dispatch cycles, ADR-0064 Rev2 default calibration (FIXED=40 cycles, R=0.0625 cycles/byte, 1 cycle = 1 ns at 1 GHz):
opt3: 10 cmds × 40 + total_bytes(≈232) × 0.0625 = 400 + 14.5 ≈ 414.5 ns
opt2: 2 cmds × 40 + total_bytes(≈380) × 0.0625 = 80 + 23.75 ≈ 103.75 ns
ratio = 414.5 / 103.75 ≈ 4.0×
The FIXED term dominates (~96% of the differential), reflecting ADR-0064's framing: the primary signal is command-count reduction. The byte term is a secondary refinement (visible: opt2 carries more total bytes than opt3, so its byte cost is higher; this is more than offset by 8× fewer FIXED payments).
Per-tile engine timing dominates total latency; dispatch is one component. The ratio above is the dispatch-only relative cost. Total latency improvement depends on whether dispatch was on the critical path — measured per workload in P6.
R sensitivity (ADR-0064 Test #9 — sanity at neighbouring R):
| R (cycles/byte) | opt3 ns | opt2 ns | ratio |
|---|---|---|---|
| 0.25 (4 B/cy) | 458 | 175 | 2.6× |
| 0.0625 (16 B/cy) ✓ | 414.5 | 103.75 | 4.0× |
| 0.03125 (32 B/cy) | 407.25 | 91.875 | 4.4× |
opt2 < opt3 at all three; ratio is monotonic in 1/R (more
FIXED-dominated as R decreases).
As-built (measured, test_gqa_decode_opt2.py). The per-tile emitters
give opt3=960 ns, opt2=184 ns → 5.22× at the default calibration (higher
than the model's ≈4.0× because the opt3 emitter includes the full online
merge); R-sweep ratios 4.20× / 5.22× / 5.45× for R∈{0.25,0.0625,0.03125},
monotonically increasing as R decreases. The gate is the calibration-robust
opt3 > 2 × opt2.
12. Open items (status)
Most ADR-0065 §Open review items map to specific implementation choices already documented in §§5–8. Live remaining items:
| Item | Resolution venue |
|---|---|
Non-identity PrimaryOutSpec.transform (e.g., "trans") |
Future recipe addition; current code uses string dispatch in step 3 of §6 |
_gqa_decode_long.py opt2 — TILE size sweep |
Open (existing §B-3, §9 of ADR-0060) |
| Larger composite descriptors approaching ADR-0064 D7 cap (1024 bytes) | Hard cap → ValueError at emit (no segmentation); kernel must split the recipe |
scratch_scope budget vs softmax_merge intermediates (G·TILE for P) |
ADR-0063 sizing check at S_kv = 256K |
| Scheduler plan-gen cost for large composites (ADR-0064 Open Item 2) | If P6 measurements show scheduler-bound behaviour near the cap, expose per-op-count overhead_ns on PE_SCHEDULER |
13. Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
CompositeCmd refactor (P1) changes op_log for some bench |
med | Parametrized regression over all benches; revert atomically |
OpSpec.operands migration (positional → dict) breaks epilogue site |
med | Per-EPILOGUE_OPS migration table; existing epilogue tests cover |
_RwHazardTracker false-positive serializes too aggressively |
low | Strict FIFO is the safe direction; RW-aware reorder is a future ADR |
| ADR-0064 Rev2 and ADR-0065 P1 land in wrong order → stale goldens | med | PR ordering enforced; CI gate requires both to be green |
softmax_merge scratch budget exceeds scratch_scope at long context |
med | ADR-0063 sizing check; if needed, tile_alignment promotion (future) |
| PE_MATH op_kind label collision with new strings | low | Labels are opaque strings — no enum conflict; just add to allowed set if validated |
14. Glossary & references
- flat ops list —
CompositeCmd.ops: tuple[OpSpec, ...]with no head/epilogue field; placement determined by op position + scope. - prologue (user API) —
tl.composite(prologue=[...])kwarg for recipe ops that lower into pre-GEMM KERNEL-scope OpSpecs. HW command does not carry this concept. - recipe — a named macro op (e.g.,
softmax_merge) that TLContext expands to multiple flat OpSpecs at emit time. Registry =RECIPE_DESCRIPTORS. - primary_out — recipe's implicit output (e.g.,
Pfor softmax_merge). Auto-bound to head GEMM's first matrix operand. - handle-identity strict FIFO — cross-composite serialization rule:
a composite waits for all prior composites whose
rw_handlesintersect with its own to complete. - logical_bytes — HW-logical byte size of a PE command, derived
from a structural rule (ADR-0064 D2), not from Python's
sys.getsizeof.
Source anchors: tl_context.py (tl API), pe_scheduler.py:104-143
(composite tile plan), pe_dma.py:45,89 (read channel), pe_math.py
(op_kind opaque latency), pe_cpu.py (dispatch hook),
pe_commands.py (CompositeCmd, OpSpec, EPILOGUE_OPS).
ADRs: 0065 (this design), 0064 Revision 2 (dispatch cost), 0060 (GQA fused attention, §5.6 / §8 item 4 carve-out), 0063 (scratch_scope), 0046 (tl_context contract), 0042 (tile plan generators), 0014 (PE pipeline execution model).
15. Data-mode numerics (D8 + N1–N4) — as-built
The original DDD assumed opt2's numeric parity "just works" via the existing
DataExecutor. It does not: a composite in the builtin tiled path is recorded
only as latency TileTokens (op_kind="unknown", no addresses) — its
matmul/MATH is never computed; the output address is never written. Making
opt2's recipe compute required building the composite/recipe data-mode path.
D8 — output handle + output-space DMA (commit e8d6c28). Prerequisite:
a composite's output must be a real, addressable, chainable handle, and a
TCM-resident output must NOT be DMA_WRITTEN (its high-bit scratch address
crashes the DMA PA decoder). tl.composite returns the output TensorHandle
(auto-TCM if out omitted); the tile loop's DMA_WRITE is gated on
out.space=="hbm". This is what lets opt2 run in data mode at all.
N1 — composite GEMM record (commit 4089e18). op_log._extract_op_info (CompositeCmd) scans ops for the GEMM (not ops[0] — a recipe's GEMM sits
after the prologue) and emits one composite_gemm record (a/b/out
addrs+spaces). PE_SCHEDULER records the composite's numeric op (no-op
without an op_logger). DataExecutor._execute_gemm is best-effort: skips on
(KeyError, ValueError) reading inputs (torch.empty / sharded operands).
N2 — recipe MATH ops (commit 3d4a3d4). _compute_math gains
rmax/rsum (keepdims) + max_elem/exp_diff/mul_bcast (binary; numpy
broadcasting covers bcast_axis). tiling._math_stage carries operand+output
addrs/shapes/spaces+axis on the recipe prologue stages only;
op_log.record_end promotes a MATH stage to op_kind="math" only when it
carries input_addrs (legacy epilogue stages stay opaque → byte-equal). A
latent P2 bug was fixed: reductions keep the reduced axis as size 1 (keepdims)
so they broadcast against m=(G,1). Verified: the 8 MATH ops produce m, l
(full online-softmax update) and O (rescaled by corr) vs a numpy reference.
N3 — GEMM-epilogue accumulate (commit 5bbe293). The composite GEMM
record is marked accumulate=True when an add epilogue targets the GEMM's
own output; _execute_gemm then does O = O_existing + a@b. The composite's
numeric op is recorded at completion (_retire_on_done), not dispatch, so
its t_start sorts AFTER its prologue MATH ops → the GEMM reads the computed
P and the rescaled O. Verified: full recipe → O = O_old·corr + P@V vs numpy.
N4 — opt2 parity + ordering (commit e9d62b9). composite() awaits its
prologue recipe operands (the cross-composite RAW: #2's s is #1's score
tile) so #2 waits for #1. With N1–N3, opt2 computes a correct attention output
e2e in data mode and matches opt3 within fp tolerance. Scope: the e2e
opt2↔opt3 parity holds in the near-uniform-attention regime — kernbench's K
reshape-as-transpose (opt3 docstring: correct for zero/symmetric inputs)
makes opt2's per-sub-tile K interpretation differ from opt3's single-tile one
for high-contrast inputs (a pre-existing kernbench limitation shared by both
kernels, not the recipe). The exact recipe math is verified non-trivially
by N2/N3 (test_recipe_data_mode.py).