ADR-0064 Revision 2 second-pass review fixes: - D7 added 1024-byte cap rationale: explicitly framed as a *safe engineering limit* (intentionally above all known composites at ~322 bytes, finite descriptor capacity placeholder), not a measured HW number. Default is the discipline; topology override for real HW. - D7 segment ordering: strict FIFO is the *ordering source*; rw_handles is dependency metadata, not the ordering primitive. Explicit note for the future RW-aware reorder migration path. - Tests rewritten around the formula (D1) instead of specific numbers: - Test #1 → "formula preservation" (parametrized across composites, asserts dispatch == FIXED + bytes × R, not anchor 43 ns). - Test #2 → robust "opt3 > 2 × opt2" qualitative gate; ≈ 4.0× is informative only, not the gate, so calibration changes don't break. - Test #9 → qualitative (opt2 < opt3 at all R), not a numeric ratio. - Anchor description in D3 stays as *informative* (shows the model produces ≈ 43 ns at defaults), but is no longer a test gate. ADR-0065 second-pass review: - D5 step 5 aligned with D6.6 invariant: explicit conflict check before auto-binding (previously written as if auto-bind always happens). Mirrors D6.6 wording: error if both kernel explicit `a` and prologue primary_out are present. - Test #7 made robust: `opt3 > 2 × opt2` gate (was ≈ 4.0×). Default-calibration model expectation (≈ 4.0×) recorded as informative reference, not the gate. DDD-0065 follow-on robust thresholds: - §1.4 success criteria: ratio condition rewritten as `opt3 > 2 × opt2` with model-expected ≈ 4.0× as informative. - §9 P6 phase gate: `opt3 > 2 × opt2` (was 4.0× ± 15%). - §10 dispatch ratio test: assert `opt3 > 2 × opt2`; record observed ratio for performance tracking but do not gate on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
ADR-0065: Flat-ops CompositeCmd + first stateful recipe (softmax_merge)
Status
Proposed (verification gated on ADR-0064 Revision 2 land)
Supporting ADR for ADR-0060 (AHBM GQA Fused Attention). Implements the §5.6 / §8 item 4 carve-out exactly: opt2 of decode = (#1 existing GEMM composite for Q·Kᵀ) + (#2 a single composite carrying softmax + P·V + online-softmax merge). The "ex_composite" / "flash_pv_merge" name referenced in ADR-0060 §5.6 is retired — this ADR fixes the canonical shape using the existing
tl.compositeentry with two structural additions: (a) a flatopstuple, and (b) a first stateful recipesoftmax_mergethat lowers to a sequence of MATH micro-ops.
Context
What ADR-0060 left to this ADR
ADR-0060 §5.6 shipped opt3 (software pipelining) as the recommended now, and explicitly carved out opt2 — fewer per-tile dispatches, K-before-V DMA priority, measurable only after ADR-0064's cost model — as the follow-up.
§8 item 4 sized the carve-out: "if revisited, split it into two
composites — #1 = Q·Kᵀ (existing composite + scale), #2 = softmax +
P·V + online-softmax accumulator merge". This ADR implements #2.
Why composite needs a structural change
Today's CompositeCmd has the shape (op, a, b, out_addr, ops=(head, *epi)) with implicit conventions:
opselects the head engine (gemm | math)ops[0]is the head OpSpec,ops[1:]are epilogue OpSpecs that run after the head per-output-tile or per-K-tile (scope-determined)
For decode opt2 we need MATH ops to run before the head GEMM (the
online-softmax m, l, O update + emit P for the GEMM input). The
current shape has no slot for that.
Two failed paths considered earlier:
- Embedding softmax_merge as an epilogue of #1 (Q·Kᵀ) — wrong: it
needs to read
Sj(= #1's output) and writes a freshPconsumed by the next GEMM (#2's P·V); circular. - A single mixed-engine recipe
flash_pv_mergeabsorbing MATH + GEMM + MATH — violates the engine boundary, complicates PE_SCHEDULER.
The clean shape is: drop the head/epilogue distinction from the
command format. Make ops a flat ordered tuple; let each op's
scope + position determine its tile-loop placement. The "prologue"
concept stays in the user-facing tl.composite(...) API as
ergonomics — but the HW command never sees it.
Why a recipe (not a generic op list)
Decode opt2's #2 MATH chain is 8 steps with a fixed structure (online
softmax merge). Letting the kernel author wire those 8 steps would be
both verbose and a hazard surface (correctness depends on step order).
A recipe — a single named op (softmax_merge) that TLContext expands
to the 8 steps with addresses pre-filled — keeps the kernel concise and
the structure under one source of truth. The recipe table lives in
TLContext (the compiler analog); PE_SCHEDULER never reads it. See
boundary in D5.
Decision
D1. CompositeCmd is a flat ordered op list
@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: ... # ADR-0064 D2
The previous (op, a, b, out_addr, out_nbytes, math_op) fields are
absorbed into ops. The first OpSpec in ops (if kind == "gemm") is
the head GEMM; preceding OpSpecs are pre-loop ops, following OpSpecs
are post-loop / per-tile epilogue ops depending on scope.
D2. OpSpec carries named operands
@dataclass(frozen=True)
class OpSpec:
kind: str # "gemm" | "rmax" | ...
scope: Scope # KERNEL | K_TILE | OUTPUT_TILE
operands: dict[str, TensorHandle] # named — see D5
extra: dict[str, Any] # scalars, axes, m/k/n, etc.
out: TensorHandle | None = None # explicit write-back handle
@property
def logical_bytes(self) -> int: ...
Named operands let PE_SCHEDULER route inputs to engine ports (e.g., GEMM
takes operands["a"], operands["b"]) without positional convention.
D3. Position + scope determines tile-loop placement
Semantics split. Position determines the phase (where in the
tile-loop lifecycle the op fires); scope determines the repetition
within that phase. Scope.KERNEL means "once per CompositeCmd
invocation", not "once per kernel launch" — its single firing happens
at the op's position in the sequence. The same KERNEL value carries
different phases depending on whether the OpSpec sits before, at, or
after the GEMM op.
PE_SCHEDULER scans cmd.ops for the GEMM op (≤1 per CompositeCmd, see
D6). Calling its index g:
| OpSpec position | scope | Phase | Placement in plan |
|---|---|---|---|
0 .. g-1 |
KERNEL | pre-tile-loop | run once before tile loop |
g |
KERNEL | head GEMM | drives tile loop with extra["m"], extra["k"], extra["n"] |
g+1 .. |
K_TILE | in-accumulation | per K-tile epilogue (inside K accumulation loop) |
g+1 .. |
OUTPUT_TILE | per-output-tile | per (m, n) tile epilogue (after K accumulation) |
g+1 .. |
KERNEL | post-tile-loop | run once after tile loop |
A composite with no GEMM op (e.g., MATH-only composite) treats all ops as KERNEL-scope sequential — phase collapses to "single-shot in order".
D4. PE_SCHEDULER auto-inserts DMAs from operand space
PE_SCHEDULER scans the GEMM's operands and out. For each:
space == "hbm"→ emit DMA_READ Stage (or DMA_WRITE forout) before the FETCH/GEMM Stagesspace == "tcm"→ no DMA Stage, operand consumed in place
This is already partially modelled today via a_pinned/b_pinned; D4
makes the rule uniform and visible: the kernel never emits explicit
DMA cmds for composite operands; PE_SCHEDULER infers them from
handles. tl.ref(addr, shape) returns a handle with space="hbm";
tl.zeros / tl.full / tl.load outputs return space="tcm".
Non-GEMM ops are TCM-only (Phase 1 limit, enforced at TLContext emit).
Every operand of every non-GEMM (MATH) OpSpec must have
space == "tcm". If a kernel passes a handle with space == "hbm" as a
MATH operand, TLContext raises a validation error at emit time. This
keeps DMA auto-insertion bounded to GEMM operands and head output
handles in Phase 1; a MATH-with-HBM path would require a future
extension (split into explicit tl.load + MATH composite, or a new
"DMA-as-prologue" recipe variant). This invariant is also restated in
D6 #7.
D5. RECIPE_DESCRIPTORS — TLContext-internal, NOT in pe_commands.py
Recipes live in a new TLContext-adjacent module
(src/kernbench/triton_emu/tl_recipes.py). PE_SCHEDULER does not
import it. The first recipe:
@dataclass(frozen=True)
class RecipeDescriptor:
# Operand contract: name → "R" (read-only) | "RW" (read-write)
operands: dict[str, str]
# Type / shape rule for the implicit primary output (auto-bound to
# head's first matrix operand). None means recipe emits no value.
primary_out: PrimaryOutSpec | None
# Single-shot prologue (full reduction) vs tile-aligned execution.
tile_alignment: Literal["single_shot", "tile_aligned"]
# Internal scratch budget (bytes), function of operand dims.
internal_scratch_bytes_fn: Callable[..., int]
# Engine micro-op sequence: TLContext expands this into flat OpSpecs.
engine_seq: tuple[EngineOp, ...]
RECIPE_DESCRIPTORS["softmax_merge"] = RecipeDescriptor(
operands={"s": "R", "m": "RW", "l": "RW", "O": "RW"},
primary_out=PrimaryOutSpec(
from_shape="s", from_dtype="s", transform="identity",
),
tile_alignment="single_shot",
internal_scratch_bytes_fn=lambda G, TILE, d, bpe: bpe * (
G + G + G + G * TILE + G # m_loc + m_new + corr + P + l_loc
),
engine_seq=(
EngineOp("MATH", "rmax", src="s", dst="m_loc", reduce_axis=-1),
EngineOp("MATH", "max_elem", src_a="m", src_b="m_loc", dst="m_new"),
EngineOp("MATH", "exp_diff", src_a="m", src_b="m_new", dst="corr"),
EngineOp("MATH", "exp_diff", src_a="s", src_b="m_new", dst="P", bcast_axis=0),
EngineOp("MATH", "rsum", src="P", dst="l_loc", reduce_axis=-1),
EngineOp("MATH", "fma", src_a="l", src_b="corr", src_c="l_loc", dst="l"),
EngineOp("MATH", "mul_bcast", src_a="O", src_b="corr", dst="O", bcast_axis=1),
EngineOp("MATH", "copy", src="m_new", dst="m"),
),
)
TLContext, on tl.composite(prologue=[{"op": "softmax_merge", ...}], op="gemm", ...):
- Validates operand R/RW against
RECIPE_DESCRIPTORS["softmax_merge"]. - Allocates scratch (m_loc, m_new, corr, P, l_loc) via the scratch_scope helper (ADR-0063).
- Derives the primary output P's shape from
s.shape(identity). - Expands
engine_seqinto 8 flat MATH OpSpecs with all addresses / sizes filled. Each OpSpec getsscope=KERNEL. - Auto-binds (with conflict check, D6.6). If the head GEMM does
not explicitly provide
operands["a"], auto-bindoperands["a"] = P_handle. If the kernel did provideaexplicitly, emit a validation error — ambiguous primary-output replacement. - Emits
CompositeCmd(ops=(8 MATH + 1 GEMM + epilogue), rw_handles= (m, l, O)).
RECIPE_DESCRIPTORS appears nowhere in the HW path. PE_SCHEDULER sees only the flat ops list.
D6. Invariants
- GEMM count.
0 ≤ count(op.kind == "gemm" in cmd.ops) ≤ 1. A composite with 0 GEMMs is MATH-only. - softmax_merge has no V operand.
RECIPE_DESCRIPTORS["softmax_merge" ].operandsdoes not include any HBM-resident handle → no DMA inserted during the prologue → V DMA can only happen during the GEMM head → natural K-before-V DMA priority across #1 / #2 of decode opt2. - Strict FIFO across composites. PE_SCHEDULER tracks in-flight
rw_handles. A new composite whoserw_handles(or any of its op inputs) intersect with an in-flight composite'srw_handleswaits for all earlier composites to complete — no out-of-order reorder. - No backwards-incompatibility for legacy callers. The user-facing
API
tl.composite(op="gemm", a, b, epilogue=[...])is preserved. TLContext internally lowers it to a flat-opsCompositeCmd. - PE_SCHEDULER recipe-free. PE_SCHEDULER does not import RECIPE_DESCRIPTORS, does not know that "softmax_merge" exists, and has no recipe-specific code branches.
- No implicit operand replacement (auto-bind conflict). If a
prologue recipe declares a
primary_outAND the head op's auto-bind target operand (e.g., GEMMa) is also provided by the kernel explicitly, TLContext raises a validation error at emit time. The kernel must either let the recipe bind (omit the operand) or specify it explicitly (omit the prologue, or use a recipe withoutprimary_out). This prevents the ambiguity of "which value wins". - MATH operands TCM-only. Restatement of D4: every operand of a
non-GEMM OpSpec must have
space == "tcm". Phase 1 enforced at TLContext emit.
D7. Boundary summary (compiler vs scheduler vs engine)
HOST DEVICE
TLContext (compiler analog) PE_SCHEDULER (scheduler/dispatcher)
- validate against RECIPE_DESC. - scan flat ops list
- derive primary_out shape - identify GEMM (≤1)
- allocate scratch - position-based stage placement
- expand recipe → flat OpSpecs - auto-insert DMA stages from space
- compute rw_handles - strict-FIFO RW hazard tracking
- emit CompositeCmd - emit Stage list
imports: RECIPE_DESCRIPTORS imports: nothing recipe-related
ENGINES (PE_MATH / PE_GEMM / PE_DMA)
- read Stage.params (op_kind, addresses, n_elements)
- existing latency models unchanged
imports: nothing recipe-related
Alternatives
A1. Three composites per tile (no prologue concept)
Split #2 further into softmax_merge + gemm(P·V) + add(O) as three
separate CompositeCmds. Pros: no flat-ops restructuring (add reuses
existing epilogue path). Cons: 3 dispatches per tile instead of 2 —
diverges from ADR-0060 §8 item 4 sizing ("split into two"); loses
~32 ns × N_tiles of fixed-cost savings. Rejected.
A2. Mixed-engine recipe (flash_pv_merge)
One recipe absorbing MATH + GEMM + MATH (ADR-0060 §5.6 original phrasing). Pros: tightest dispatch (1 composite). Cons: PE_SCHEDULER must know engine-boundary recipes, violates the "engines see only Stage.op_kind" boundary, recipe table becomes ISA-like and pollutes HW interface. Rejected.
A3. Generic op-list DAG (tl.ex_composite([...]))
User-defined sequence of any ops with named slots and dataflow analysis. Pros: maximally flexible. Cons: a single user (softmax_merge) does not justify a generic DAG language; PE_SCHEDULER would need dataflow analysis; reintroduces ADR-0060 §8 item 4's "absorb the inner loop" trap. Rejected (Simplicity First).
A4. RW-aware scheduler reorder (not strict FIFO)
PE_SCHEDULER could allow a later CompositeCmd to overtake an in-flight
one when their rw_handles don't intersect (e.g., next-tile #1 could
overlap with this-tile #2 since #1 doesn't touch m/l/O). Pros: more
overlap. Cons: more scheduler state, gains are bounded (next #1 waits
for K DMA anyway). Deferred — strict FIFO ships first; RW-aware
reorder may be a future follow-up ADR.
A5. Embedding softmax_merge as an epilogue of #1 (Q·Kᵀ)
Wrong: softmax_merge's output P is the input of #2's P·V GEMM, which
runs after #1's epilogue. Embedding it would force #2 to read its own
input from #1's epilogue scratch — circular tile-loop dependency.
Rejected (incorrect).
Consequences
Positive
- ADR-0060 §5.6 / §8 item 4 carve-out implemented exactly: opt2 = 2 composites per tile, K-before-V DMA priority natural.
- Composite contract becomes uniform: flat ops list with scope-based placement. No structural distinction between "head" and "epilogue" in the HW cmd.
- DMAs auto-inferred from operand
space; kernel surface simpler. - HW interface (
CompositeCmdshape) stays small even as recipes grow — recipe expansion happens host-side. - New fused patterns (linear+gelu+norm, rmsnorm+linear, …) can be added
by inserting one
RECIPE_DESCRIPTORSentry; no PE_SCHEDULER changes.
Negative
- CompositeCmd's struct shape changes — a refactor for all current callers. Semantics preserved (D6.4); existing goldens unchanged once ADR-0064 Revision 2 lands.
OpSpec.operandschanges from positionaltuple[Any, ...]todict[str, TensorHandle]— touches the existing epilogue lowering.- New TLContext code: recipe expansion + scratch-slot allocation + primary_out auto-bind. ~80–120 LOC.
- PE_SCHEDULER's
_generate_plangets a position-based scan + DMA auto-insertion + strict-FIFO RW tracker. ~60–100 LOC. - Strict-FIFO RW hazard tracking is safe but conservative. When
two composites share any
rw_handle, the new one waits for all prior overlapping composites to fully complete — even when their compute could in principle overlap (e.g., next-tile #1 = Q·Kᵀ does not touch m/l/O and could overlap with this-tile's #2, but FIFO still serializes any path that touches the same handles down the line). This may under-expose composite-level overlap relative to a smarter scheduler. RW-aware reorder (A4) is the deferred improvement.
Open review items
- Recipe shape derivation rule beyond identity. Future recipes may
need non-identity transforms (e.g., transposed primary_out). The
PrimaryOutSpec.transformfield is forward-compat; add new transforms as needed. - Recipe scratch allocator integration with ADR-0063's
scratch_scope. Initial wiring: TLContext allocates inside the currentscratch_scopecontext if active, else in a kernel-scope persistent slot. tl_recipes.pylocation. New module undertriton_emu/. Keeps recipe knowledge co-located with the compiler analog (TLContext).- GEMM
out=TensorHandle. New form alongside legacyout_ptr: int. Both accepted; handle form preferred for new code (enables strict-FIFO RW tracking by handle identity).
Test Requirements
- CompositeCmd flat-ops refactor — meaning preservation. Every
existing bench's op_log is byte-equal pre/post refactor (legacy API
tl.composite(op="gemm", a, b, epilogue=[...])lowers to the same Stage sequence). - softmax_merge recipe lowering. TLContext call
tl.composite( prologue=[{"op": "softmax_merge", "s": Sj, "m": m, "l": l, "O": O}], op="gemm", b=V_ref, out=O, epilogue=[{"op": "add", "other": O}])produces aCompositeCmdwith exactly 10 ops in the expected order (8 MATH + 1 GEMM + 1 MATH(add)) andrw_handles == (m, l, O). - K-before-V DMA priority invariant. op_log of decode opt2 shows no V-related DMA during #2's MATH prologue (#2's V DMA begins only when the GEMM head starts).
- Strict-FIFO RW serialization. Two consecutive composites sharing
any
rw_handlecomplete in dispatch order; their stages do not interleave. - DMA auto-insertion from
space. A GEMM composite withb=tl.ref(...)(space=hbm) emits a DMA_READ Stage; withbfromtl.zeros(...)(space=tcm) it does not. Output handle withspace=hbmemits DMA_WRITE;space=tcmdoes not. - opt2 numeric parity with opt3. In data mode, opt2's final
(m, l, O)matches opt3's within fp tolerance. - opt2 dispatch ratio (after ADR-0064 Rev2) — robust. opt3 vs
opt2 per-tile PE_CPU dispatch cycles satisfies
opt3 > 2 × opt2(qualitative gate; calibration-independent). The default-calibration model expectation is ≈ 4.0× (FIXED=40 cycles, R=0.0625 cycles/byte) — see DDD-0065 §11 for the model-derived numbers; only the loose> 2×bound is the test gate so it survives calibration changes. Ratio is FIXED-dominated, reflecting command-count reduction as the primary signal. - GEMM-count invariant. A composite with two GEMM OpSpecs raises a validation error at TLContext emit.
- Within composite size cap (ADR-0064 D7). Decode opt2's
#2composite (10 ops, ~322 logical bytes — per-op summation of operand handles) fits comfortably under the defaultMAX_COMPOSITE_LOGICAL_BYTES=1024— no segmentation. The recipe lowering test assertscmd.logical_bytes < 1024. - Auto-bind conflict.
tl.composite(op="gemm", a=A_handle, prologue=[{"op": "softmax_merge", ...}], ...)where softmax_merge declaresprimary_outraises a validation error at emit time (D6.6). - MATH operand TCM invariant.
tl.composite(op="math", a=tl.ref(addr, shape), ...)(passing an HBM-resident handle as a MATH operand) raises a validation error at emit time (D6.7).
Dependencies
- ADR-0060 §5.6, §8 item 4 — the carve-out this ADR implements.
- ADR-0064 Revision 2 — dispatch cost model; lands first, makes opt2's fewer-issues win measurable.
- ADR-0063
scratch_scope— recipe intermediates allocated inside;m, l, Oallocated outside as persistent arena (per DDD-0060 §6.2). - ADR-0046
tl_contextcontract — extended byprologue=[...]kwarg,out=TensorHandle, RECIPE_DESCRIPTORS lookup. - ADR-0042 tile plan generators — extended (not redesigned) to process the flat ops list and auto-insert DMAs.
- ADR-0014 PE pipeline — boundary preserved: scheduler stays recipe-free, engines stay op_kind-opaque.
Migration
Land order:
- ADR-0064 Revision 2 (separate PR): structural dispatch cost +
logical_bytesproperty + topology config override + one-time goldens regeneration. - ADR-0065 Phase 1 (this ADR, PR a):
CompositeCmdflat-ops restructure + legacy-API lowering. Refactor only; goldens unchanged. - ADR-0065 Phase 2 (this ADR, PR b):
tl_recipes.py+softmax_mergerecipe + PE_SCHEDULER position-scan + DMA auto-insertion + strict-FIFO RW tracker. - ADR-0065 Phase 3 (this ADR, PR c):
_gqa_decode_long.pyopt2 variant + numeric parity test + dispatch-ratio measurement against opt3 baseline.
Detailed implementation plan: see DDD-0065.