2d8271c981
ADR-0064 D8 broadened from "DMA fast-path" to "single-op-cmd fast-path": every single-op command (DmaRead/DmaWrite/Gemm/Math/Copy) now pays the lighter FIXED=8; only CompositeCmd keeps the 40-cycle control-path FIXED (it alone needs scheduler plan generation + per-tile RW-hazard tracking + completion wiring). Renamed knob fixed_per_dma_cmd_cycles -> fixed_per_single_op_cmd_cycles. dispatch_cycles now branches on "is CompositeCmd" rather than enumerating DMA types. Term choice: "single-op" (not "atomic", which read as sync/async) — the axis is composition (one engine op vs fused multi-op plan), orthogonal to timing. single-op <-> composite. Tests: test_pe_cost_model.py updated to the single-op surface (defaults, fast-path over all 5 single-op cmd types, composite general path, yaml override). All green. Recalibrated tests/attention/test_gqa_decode_opt2.py ::test_opt3_dispatch_exceeds_opt2 — NOT a regression: D8 makes single-op cmds 5x cheaper, so opt2's two-composite fusion win over opt3's many single-ops narrowed from pre-D8 ~3.7x to ~1.87x (opt3=224 > opt2=120). The CPU-offload invariant (opt2 cheaper) still holds; only the model- dependent ">2x" constant was over-fit to the old uniform-40 model. Gate now: direction + >1.5x margin (matches sibling R-sweep test's stated "absolute ratio informative-only" philosophy). NOTE for review: ADR-0065's "2x CPU-offload win" headline may want a refresh to reflect the post-D8 ~1.87x — left to user (architectural doc). Full regression: 826 passed, 1 skipped (tests/ excl. tests/gemm). --- Remaining work (resume here if interrupted) --- 5. Re-run scripts/paper/paper_plot_gemm_async_vs_composite.py with new cost model; verify async-tiled dispatch overhead drops (~4576ns -> ~1536ns expected) and the composite-vs-async-tiled gap narrows from the prior ~6.3x at K=3072. 6. Copy regenerated gemm_composite_vs_async_tflops.png to docs/report/1H-codesign-paper/figures/. 7. Paper §3.4 (03-gemm.tex sec:gemm-vs-async): finish naive->async-full / chunked->async-tiled rename AND reframe FIXED_DMA wording to single-op vs composite (currently still says "lighter FIXED for DMA descriptors, FIXED_DMA=8"). Table 2 (02-platform) + §2 dispatch prose already done. 8. Paper §3.4 K=3072 corner para + mechanism #3: update dispatch breakdown to new model (96 DMA*8 + 95 single-op*8 ≈ 1.5us vs old 4.6us); update headline ratio if it changed. 9. Rebuild docs/report/1H-codesign-paper/build/main.pdf (tectonic) + verify via pdftotext. 10. Then this is the bench-harness + paper commits (Groups 2 & 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
8.3 KiB
Python
196 lines
8.3 KiB
Python
"""Phase 1 spec tests for ADR-0065 P5(B) — decode opt2 dispatch measurement.
|
|
|
|
opt2 replaces the per-tile primitive attention block (opt3: many ``tl.*``
|
|
ops) with **two composites**: #1 = Q·Kᵀ GEMM, #2 = ``softmax_merge`` recipe
|
|
(online-softmax merge) + P·V GEMM + ``add`` (ADR-0060 §8 item 4 / ADR-0065).
|
|
Fewer PE_CPU-issued commands → lower dispatch cost under the ADR-0064 Rev2
|
|
structural cost model. This is the headline CPU-offload win.
|
|
|
|
These tests measure **dispatch cost only** (op_log / command-emission level);
|
|
numeric parity (full data-mode recipe computation) is a separate follow-up.
|
|
|
|
The dispatch-ratio / R-sweep tests exercise already-shipped features (the
|
|
P0 cost model + the P2 recipe) and pass now. The e2e test needs the P5
|
|
production bench kernel and fails until it lands.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from kernbench.common.pe_commands import PeCpuOverheadCmd, TensorHandle
|
|
from kernbench.common.pe_cost_model import DEFAULT_PE_COST_MODEL, PeCostModel
|
|
from kernbench.triton_emu.tl_context import TLContext, run_kernel
|
|
|
|
G, T, D = 8, 64, 128
|
|
|
|
_HID = [0]
|
|
|
|
|
|
def _tcm(addr: int, shape: tuple[int, ...]) -> TensorHandle:
|
|
_HID[0] += 1
|
|
return TensorHandle(
|
|
id=f"s{_HID[0]}", addr=addr, shape=shape, dtype="f16",
|
|
nbytes=2 * math.prod(shape), space="tcm", pinned=True,
|
|
)
|
|
|
|
|
|
# ── per-tile attention emitters ──────────────────────────────────────
|
|
|
|
|
|
def _opt3_tile(*, tl) -> None:
|
|
"""opt3: primitive per-tile inner attention + online-softmax merge."""
|
|
K_T = tl.load(0x1000, shape=(D, T), dtype="f16")
|
|
Q = tl.load(0x2000, shape=(G, D), dtype="f16")
|
|
V = tl.load(0x3000, shape=(T, D), dtype="f16")
|
|
m_local = _tcm(0x10000, (G, 1))
|
|
l_local = _tcm(0x11000, (G, 1))
|
|
O_local = _tcm(0x12000, (G, D))
|
|
scores = tl.dot(Q, K_T)
|
|
m_tile = tl.max(scores, axis=-1)
|
|
centered = scores - m_tile
|
|
exp_s = tl.exp(centered)
|
|
l_tile = tl.sum(exp_s, axis=-1)
|
|
O_tile = tl.dot(exp_s, V)
|
|
m_new = tl.maximum(m_local, m_tile)
|
|
scale_old = tl.exp(m_local - m_new)
|
|
scale_new = tl.exp(m_tile - m_new)
|
|
l_new = l_local * scale_old + l_tile * scale_new
|
|
O_new = O_local * scale_old + O_tile * scale_new
|
|
tl.copy_to(m_local, m_new)
|
|
tl.copy_to(l_local, l_new)
|
|
tl.copy_to(O_local, O_new)
|
|
|
|
|
|
def _opt2_tile(*, tl) -> None:
|
|
"""opt2: #1 Q·Kᵀ composite + #2 softmax_merge recipe composite."""
|
|
K_T = tl.load(0x1000, shape=(D, T), dtype="f16")
|
|
Q = tl.load(0x2000, shape=(G, D), dtype="f16")
|
|
V = tl.ref(0x3000, shape=(T, D), dtype="f16")
|
|
m_local = _tcm(0x10000, (G, 1))
|
|
l_local = _tcm(0x11000, (G, 1))
|
|
O_local = _tcm(0x12000, (G, D))
|
|
scores = _tcm(0x13000, (G, T))
|
|
tl.composite(op="gemm", a=Q, b=K_T, out=scores) # #1
|
|
tl.composite( # #2
|
|
prologue=[{"op": "softmax_merge", "s": scores,
|
|
"m": m_local, "l": l_local, "O": O_local}],
|
|
op="gemm", b=V, out=O_local,
|
|
epilogue=[{"op": "add", "other": O_local}],
|
|
)
|
|
|
|
|
|
def _dispatch_cycles(emitter, cost_model) -> float:
|
|
tl = TLContext(pe_id=0, num_programs=1, cost_model=cost_model,
|
|
scratch_base=0x200000, scratch_size=1 << 20)
|
|
run_kernel(emitter, tl)
|
|
return sum(c.cycles for c in tl.commands if isinstance(c, PeCpuOverheadCmd))
|
|
|
|
|
|
# ── dispatch ratio (ADR-0064 Test #9 / ADR-0065 Test #7) ─────────────
|
|
|
|
|
|
def test_opt3_dispatch_exceeds_opt2():
|
|
"""ADR-0064 Test #9 / ADR-0065 Test #7 — fusing opt3's per-tile
|
|
primitives into opt2's two composites lowers dispatch cost (the
|
|
CPU-offload win).
|
|
|
|
ADR-0064 D8 (single-op fast-path) narrowed this win: single-op
|
|
commands now pay FIXED=8 while a composite pays FIXED=40, so opt2's
|
|
two composites no longer dominate opt3's many (now cheap) single-ops
|
|
by the pre-D8 2x. At the default model opt2 is still ~46% cheaper
|
|
(opt3 ≈ 1.87x opt2); the invariant that survives recalibration is the
|
|
direction plus a >1.5x margin. The sibling R-sweep test gates the
|
|
formula-level (direction + monotonicity) claim.
|
|
"""
|
|
opt3 = _dispatch_cycles(_opt3_tile, DEFAULT_PE_COST_MODEL)
|
|
opt2 = _dispatch_cycles(_opt2_tile, DEFAULT_PE_COST_MODEL)
|
|
assert opt2 > 0 and opt3 > 0
|
|
assert opt3 > 1.5 * opt2, f"opt3={opt3} must exceed 1.5x opt2={opt2}"
|
|
|
|
|
|
def test_dispatch_ratio_R_sensitivity():
|
|
"""ADR-0064 Test #9 — opt2 < opt3 across the queue-bandwidth range, and
|
|
the opt3/opt2 ratio increases as R decreases (the cost becomes more
|
|
FIXED-dominated, i.e. command-count-driven). Absolute ratio values are
|
|
informative only; the gate is the direction + opt2 < opt3."""
|
|
ratios = []
|
|
for R in (0.25, 0.0625, 0.03125): # strictly decreasing R
|
|
cm = PeCostModel(fixed_per_cmd_cycles=40, byte_cycles_recip=R)
|
|
opt3 = _dispatch_cycles(_opt3_tile, cm)
|
|
opt2 = _dispatch_cycles(_opt2_tile, cm)
|
|
assert opt2 < opt3, f"R={R}: opt2={opt2} !< opt3={opt3}"
|
|
ratios.append(opt3 / opt2)
|
|
assert ratios[0] < ratios[1] < ratios[2], (
|
|
f"opt3/opt2 ratio must increase as R decreases; got {ratios}"
|
|
)
|
|
|
|
|
|
# ── K-before-V DMA priority (ADR-0065 Test #3) ───────────────────────
|
|
|
|
|
|
def test_k_before_v_in_opt2_plan():
|
|
"""In opt2's #2 composite, the V (ref) DMA_READ is placed *after* the
|
|
softmax_merge prologue MATH stages — V is not streamed during the
|
|
prologue (K-before-V priority)."""
|
|
from kernbench.common.pe_commands import CompositeCmd
|
|
from kernbench.components.builtin.pe_types import StageType
|
|
from kernbench.components.builtin.tiling import generate_plan_from_ops
|
|
|
|
tl = TLContext(pe_id=0, num_programs=1, scratch_base=0x200000,
|
|
scratch_size=1 << 20)
|
|
run_kernel(_opt2_tile, tl)
|
|
composites = [c for c in tl.commands if isinstance(c, CompositeCmd)]
|
|
cmd2 = composites[-1] # the softmax_merge composite
|
|
plan = generate_plan_from_ops(
|
|
ops=cmd2.ops, tile_m=32, tile_k=32, tile_n=32,
|
|
bytes_per_element=2, pe_prefix="sip0.cube0.pe0",
|
|
)
|
|
# The softmax_merge prologue (8 MATH) carries NO DMA — V is not streamed
|
|
# during it. The prologue is fed before the GEMM tile loop, where the V
|
|
# (ref) DMA_READ lives — so K (in #1) loads before V (in #2's GEMM).
|
|
assert len(plan.prologue_stages) == 8, plan.prologue_stages
|
|
assert all(s.stage_type == StageType.MATH for s in plan.prologue_stages)
|
|
assert not any(s.stage_type in (StageType.DMA_READ, StageType.DMA_WRITE)
|
|
for s in plan.prologue_stages)
|
|
tile_reads = [s for s in plan.tiles[0].stages
|
|
if s.stage_type == StageType.DMA_READ]
|
|
assert len(tile_reads) == 1, "exactly the V tile DMA_READ in the loop"
|
|
|
|
|
|
# ── e2e: opt2 bench runs in op_log mode (needs P5 production kernel) ──
|
|
|
|
|
|
def test_opt2_bench_completes_oplog_mode():
|
|
from pathlib import Path
|
|
|
|
from kernbench.benches.gqa_helpers.shared._gqa_attention_decode_opt2 import ( # noqa: F401
|
|
gqa_attention_decode_opt2_kernel,
|
|
)
|
|
from kernbench.policy.placement.dp import DPPolicy
|
|
from kernbench.runtime_api.bench_runner import run_bench
|
|
from kernbench.runtime_api.types import resolve_device
|
|
from kernbench.sim_engine.engine import GraphEngine
|
|
from kernbench.topology.builder import resolve_topology
|
|
|
|
topo_path = Path(__file__).resolve().parents[2] / "topology.yaml"
|
|
topo = resolve_topology(str(topo_path))
|
|
S_KV = 16
|
|
|
|
def _bench_fn(ctx):
|
|
dp = DPPolicy(cube="replicate", pe="replicate", num_cubes=1, num_pes=1)
|
|
q = ctx.zeros((1, 8 * D), dtype="f16", dp=dp, name="q_opt2")
|
|
k = ctx.zeros((S_KV, D), dtype="f16", dp=dp, name="k_opt2")
|
|
v = ctx.zeros((S_KV, D), dtype="f16", dp=dp, name="v_opt2")
|
|
o = ctx.empty((1, 8 * D), dtype="f16", dp=dp, name="o_opt2")
|
|
ctx.launch("gqa_decode_opt2", gqa_attention_decode_opt2_kernel,
|
|
q, k, v, o, 1, S_KV, 8, 1, D, 1, 1, _auto_dim_remap=False)
|
|
|
|
result = run_bench(
|
|
topology=topo, bench_fn=_bench_fn, device=resolve_device(None),
|
|
engine_factory=lambda t, d: GraphEngine(getattr(t, "topology_obj", t),
|
|
enable_data=False),
|
|
)
|
|
assert result.completion.ok, f"opt2 decode failed: {result.completion}"
|
|
|
|
|