Files
kernbench2/docs/adr/ADR-0064-perf-cpu-issue-cost-model.md
ywkang 2d8271c981 perf(cost-model): D8 single-op-cmd fast-path (FIXED=8 single-op / 40 composite)
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>
2026-06-17 15:04:53 -07:00

21 KiB
Raw Permalink Blame History

ADR-0064: Structural CPU dispatch cost model (logical_bytes + FIXED + R)

Status

Accepted (Revision 2)

Supporting ADR for ADR-0060 (AHBM GQA Fused Attention) and ADR-0065 (flat-ops composite + first stateful recipe). The hybrid decision there (GEMMs via tl.composite, softmax merge in the kernel) wins by offloading tiling to PE_SCHEDULER so the CPU issues coarse descriptors and runs ahead, keeping the engines saturated. That win is currently invisible in the simulator because per-op CPU issue cost is zero.

Revision 2 replaces the op-type calibration table (the original proposal) with a structural formula derived from each command's logical_bytes — no per-op-type calibration needed; new op kinds are covered automatically.

Context

What exists today

  • Every tl.* op calls _emit_dispatch_overhead() before emitting its command (tl_context.py:196-212), which emits PeCpuOverheadCmd(cycles=dispatch_cycles) only if `dispatch_cycles

    0. The knob is **uniform** across op kinds and hardcoded to **0** in both live execution paths (pe_cpu.py:101greenlet,:195` replay).

  • ⇒ issuing a command — constructing the descriptor and pushing it to the scheduler queue — currently costs 0 ns on PE_CPU.
  • PeCpuOverheadCmd is consumed as yield env.timeout(cmd.cycles) on PE_CPU (kernel_runner.py:131-132).

Why uniform-and-zero is wrong for the hybrid

ADR-0060 §1's argument is that one composite descriptor offloads N_tiles worth of GEMM tiling, so the CPU issues O(1) coarse commands instead of O(N_tiles × ops/tile) fine ones. With issue cost = 0, the model cannot show:

  • that the primitive path may fail to saturate the engines when the CPU cannot push fast enough, nor
  • that a composite costs more to construct than a single primitive but far less than the many primitives it replaces.

Why per-op-type calibration (Revision 1) was over-shaped

The original proposal had a cost_table[kind] keyed by op kind (composite, load, dot, math, …). That required:

  • a value per kind (calibration cost ≥ |kinds|),
  • a new entry every time a new kind appears,
  • and yet the ratio it tried to capture — "composite ≫ primitive, but ≪ the primitives it replaces" — is structurally a function of how many fields the command carries, not of the op kind.

A command's byte footprint is the natural proxy: a composite carrying N OpSpecs has ~N× the bytes of a primitive op with one OpSpec. The fixed part (queue head update, completion register, MMIO-class latency) is per-command. The two together compose: FIXED + bytes × R.

What this model actually exposes

The primary signal is command-count reduction through the per-command FIXED cost. The byte term is a secondary refinement that prevents pathologically-large composites from looking free. With realistic on-die queue bandwidth (16 B/cycle, D3), FIXED accounts for ≥85% of the dispatch cost differential between opt3 (≈10 cmds/tile) and opt2 (≈2 cmds/tile) for decode opt2.

This framing also bounds the model: if a single composite were allowed to grow unboundedly large, the byte term alone would not stop the formula from rewarding ever-bigger fused commands beyond what real HW supports — hence the descriptor-size cap in D7.

Decision

D1. Structural dispatch cost formula

Each PE command going to PE_SCHEDULER incurs PE_CPU dispatch cycles:

dispatch_cycles(cmd) = FIXED_PER_CMD + cmd.logical_bytes × R

where:

  • FIXED_PER_CMD (cycles per command) models queue-tail update, MMIO-class RTT, completion-event registration — fixed per command regardless of size.
  • R (cycles per byte) models the queue-write bandwidth — bytes of the command serialized into the scheduler queue.
  • cmd.logical_bytes (int) is each command's HW-logical byte size, computed from D2 below — not Python's sys.getsizeof.

PE_CPU emits PeCpuOverheadCmd(cycles=dispatch_cycles(cmd)) before dispatching, exactly as the existing hook (tl_context.py:_emit_dispatch_ overhead) — only the cycle value changes.

D2. logical_bytes rule

Each PE command dataclass exposes logical_bytes: int (property). The counting rule (HW-friendly, ignores Python overhead):

Field kind Bytes
Command framing (cmd-type discriminator + completion id ref) 4
Opcode (op kind enum) 1
Enum (scope, etc.) 1
TensorHandle reference (address only — shape/dtype assumed in descriptor table) 8
Scalar (int/float) 4
Tuple length marker 1

CompositeCmd recursively sums its ops and rw_handles:

@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)
    )

OpSpec:

@property
def logical_bytes(self) -> int:
    return (
        1 + 1                                       # opcode + scope
        + 1 + 8 * len(self.operands)                # named operand handles
        + (8 if self.out is not None else 0)        # out handle
        + 1 + sum(_extra_bytes(v) for v in self.extra.values())
    )


def _extra_bytes(v) -> int:
    """Type-aware byte count for OpSpec.extra values."""
    if isinstance(v, bool):              return 1
    if isinstance(v, (int, float)):      return 4
    if isinstance(v, (tuple, list)):     return 1 + 4 * len(v)   # shape, axes, …
    if isinstance(v, str):               return 1                # opcode-like tag
    return 4                                                     # default scalar


# Example types in extra:
#   m, k, n            int   → 4 each
#   reduce_axis        int   → 4
#   shape=(64, 64)     tuple → 1 + 8 = 9
#   factor=1.0         float → 4

(Identical rule for DmaReadCmd, MathCmd, etc. — one property per dataclass, ~3 lines each.)

Counting rule — per-op summation, no deduplication. Each TensorHandle reference inside an OpSpec is counted independently, even when the same handle appears in multiple OpSpecs or in rw_handles. The rw_handles block is metadata for cross-composite hazard tracking (ADR-0065 D6.3) and is counted separately from operand references in ops. There is no deduplication. This matches HW reality: the descriptor encodes each operand slot as an independent address field, and the dispatcher tracks rw_handles as a distinct metadata block. Example: OpSpec(kind="mul_bcast", operands={"src_a": O, "src_b": corr}, out=O) counts handle O twice (once for src_a, once for out); if O is also in the enclosing CompositeCmd's rw_handles, it is counted a third time.

D3. Defaults — anchored on a typical composite ≈ 43 ns

Anchor: a single-OpSpec composite for a DMA-staged GEMM path — one OpSpec(kind="gemm", ...); DMA stages are auto-inserted by PE_SCHEDULER from operand space (ADR-0065 D4) and do not appear in logical_bytes (the kernel does not issue them as separate cmds). Breakdown:

framing                  4
ops tuple length         1
GEMM OpSpec             40   (opcode 1 + scope 1 + 1 + 2 handles 16
                              + out 8 + 1 + extra m/k/n 12)
rw_handles tuple length  1
rw_handles content       8   (one RW handle for the output)
─────────────────────────────
total                  ~54 bytes

Target dispatch = ~43 ns. On-die producer→consumer queue at 16 bytes/cycle (typical on-die descriptor queue width).

FIXED_PER_CMD        = 40 cycles
R                    = 0.0625 cycles/byte  (= 16 bytes/cycle)

Verification: 40 + 54 × 0.0625 = 43.375 cycles ≈ 43 ns

Cycle→ns conversion uses the PE node's existing clock_freq_ghz attr (the same one used by PE_MATH _compute_ns). The cost-model knobs are cycle-domain only — they do not duplicate the clock setting.

D4. Topology config override

Defaults are baked into pe_cpu.py. Topology yaml may override under a pe_cost_model: section at the PE node attrs (cycle-domain knobs only; clock comes from the PE's existing clock_freq_ghz):

pe:
  attrs:
    clock_freq_ghz: 1.0                    # existing, used for cycle→ns
    pe_cost_model:
      fixed_per_cmd_cycles: 40
      byte_cycles_recip: 0.0625            # = 16 bytes/cycle
      max_composite_logical_bytes: 1024    # D7 — descriptor size cap

Missing keys fall back to defaults. The dispatch formula reads from node.attrs["pe_cost_model"] at PE_CPU init.

D5. Scope — what does and does not pay

Path Pays dispatch cost?
PE_CPU → PE_SCHEDULER for any PeCommand Yes
PeCpuOverheadCmd itself (already cycles-explicit) No (formula bypass)
Stages auto-generated by PE_SCHEDULER (DMA_READ/WRITE/FETCH/STORE) No (PE_SCHEDULER-internal)
Engine compute latency (DMA drain_ns, GEMM/MATH _compute_ns) No change — stays on engines (SPEC §0.1)

This preserves the "latency on modelled components" invariant — dispatch cost is additional CPU-side time, not folded into engine times.

D6. Configurable values; goldens regenerate

Turning issue cost non-zero changes every bench's latency. Golden latencies are regenerated once when this ADR lands — same posture as ADR-0062 D3 lazy-load. After regeneration, the same calibration is in effect for ADR-0065 opt2 measurement.

D7. Composite size cap (hard limit — validation error)

Each CompositeCmd's logical_bytes is capped at max_composite_logical_bytes (default 1024 bytes, overridable per D4). A composite whose logical_bytes exceeds the cap is rejected at emit time by the host-side TLContext with a ValueError — there is no automatic segmentation. The kernel author must restructure the recipe (e.g., split it into multiple smaller composites explicitly) so each CompositeCmd fits within the descriptor capacity.

Why a cap is needed. Real hardware imposes hard limits — descriptor queue entry size, scheduler parser buffer, command SRAM, firmware input. Without a cap, the FIXED + bytes × R model would reward arbitrarily large fused composites beyond what hardware accepts (e.g., fusing 100 primitive ops into one composite, paying one FIXED).

Why 1024 bytes specifically. This is a safe engineering limit, not a measured HW number — intentionally chosen to be well above all currently known composites (decode opt2's #2 at ~322 bytes is the largest in the kernbench codebase) while still representing a finite descriptor capacity that future recipes must respect. The number is overridable per topology (D4); when a real HW reference appears, the value should be recalibrated. The role of this default is to make the cap exist as a discipline, not to fit a specific HW.

Rationale for a hard error over auto-segmentation. Auto-splitting an oversized composite (the original Revision 2 proposal) added emitter complexity — inter-segment ordering, shared rw_handles, completion chaining — to paper over what is, in practice, a kernel that asked for a descriptor larger than the hardware allows. Surfacing it as an explicit error keeps the emitter simple and makes the HW constraint visible to the kernel author, who is best placed to decide how to split the work.

Decode opt2's #2 composite (10 ops, ~322 bytes) sits comfortably inside the 1024 cap — no error for the GQA workload.

D8. Single-op-cmd fast-path (per-cmd-type FIXED)

Every single-op command — the DMA descriptors (DmaReadCmd, DmaWriteCmd) and the single-op compute dispatches (GemmCmd, MathCmd, CopyCmd) — carries a separate, lighter FIXED than the general control-path cost: fixed_per_single_op_cmd_cycles (default 8 cycles) replaces fixed_per_cmd_cycles for all of them. CompositeCmd is the only command that stays on the general control-path cost fixed_per_cmd_cycles (= 40 cycles).

Concretely, the D1 formula becomes

dispatch_cycles(cmd) = FIXED(type(cmd)) + cmd.logical_bytes × R
FIXED(CompositeCmd) = fixed_per_cmd_cycles         (= 40)
FIXED(everything else, i.e. every single-op cmd:
  DmaReadCmd / DmaWriteCmd / GemmCmd / MathCmd / CopyCmd)
                    = fixed_per_single_op_cmd_cycles  (= 8)

Why a separate, lighter FIXED for single-op cmds. D1 treated every emitted command uniformly because that is the simplest defensible model. In practice an single-op command — whether a DMA descriptor or a single GEMM / elementwise / copy dispatch to an engine — is much cheaper than the generic control-path cost the 40-cycle FIXED was modeling. It is a single descriptor or instruction pushed to an engine queue, with no scheduler-side plan to build:

  • NVIDIA Hopper TMA: a single PTX instruction (cp.async.bulk) initiates a bulk async DMA — ~1 ISA cycle on the SM side, with the TMA engine doing fan-out and chunking itself.
  • NVIDIA Ampere cp.async: ~12 cycles per warp-level async copy.
  • A single MMA / tensor-core issue (wgmma, mma.sync): one instruction to the math pipe, not a control-path round trip.
  • AMD AQL packet write to a HSA queue: ~515 cycles.
  • Generic descriptor-ring-push designs (Synopsys/Xilinx-style DMA IP): ~520 cycles for the MMIO writes.
  • Tenstorrent tile descriptor emission, Habana Gaudi TPC descriptor RAM
    • start register: single-digit cycles per descriptor.

The 40-cycle FIXED is justified for CompositeCmd specifically, because a composite is not a single dispatch: the scheduler must build a tile-feeder plan, track per-tile read/write hazards across its internal DMA_READ → FETCH → GEMM → STORE → DMA_WRITE stages, and wire a completion handle. An single-op GemmCmd is one engine issue with none of that machinery; charging it the same 40 cycles as a composite conflates a single instruction with a whole scheduled plan.

Why this matters for kernel evaluation. A uniform 40-cycle FIXED inflates the dispatch overhead of user-orchestrated single-op-primitive kernels. A chunked-prefetching async GEMM at K = N_K · TILE_K emits, per K-tile, a DmaReadCmd (B-chunk load) and a GemmCmd (the per-chunk tl.dot) and a MathCmd (the running tl.add accumulate) — so the per-command FIXED dominates its dispatch budget. The single-op fast-path keeps the structural command-count signal that D1 was designed to capture (a recipe that emits N single-op commands does pay more than a recipe that emits one composite covering N tiles internally) without conflating it with a modeling overcharge that would be specific to a 40-cycle generic control path.

Why 8 cycles specifically. 8 sits at the mid-range of the survey above (TMA ~1 to descriptor-ring ~15). It is intentionally a modeling default, not a measured HW number — the role of this default is to distinguish the single-op dispatch path from the composite control path in qualitative behaviour. The value is overridable per topology (D4).

Effect on composite numbers. Composite tile-internal stages (DMA_READ, FETCH, GEMM, STORE, DMA_WRITE per HW tile) are emitted by the scheduler's tile-feeder loop, not by the host-side TLContext, so they do not go through _charge_dispatch. The single CompositeCmd itself still pays the 40-cycle control-path FIXED. D8 only changes the FIXED charged to user-side single-op primitives (tl.load / tl.store / tl.dot / tl.add / …). Composite measurements remain stable.

Alternatives

A1. Keep Revision 1's op-type calibration table

Rejected: calibration cost scales with |kinds|, and the ratio the table tried to capture is structurally a function of cmd size. The structural formula reaches the same qualitative behaviour with two calibratable numbers instead of N.

A2. Byte-only formula (no FIXED term)

Rejected. With FIXED = 0, opt2 (Option Y per ADR-0065) does not win over opt3 — the total bytes dispatched per tile are similar (opt3 ≈ 232, opt2 ≈ 380); the win is entirely in fewer per-cmd fixed costs. A byte-only formula erases the very signal the model needs to expose.

A3. Charge dispatch on PE_SCHEDULER instead of PE_CPU

Rejected: the saturation question is "can the CPU push descriptors fast enough to keep the engines busy?" — that is a PE_CPU issue-bandwidth property. Charging on the scheduler would not model CPU back-pressure.

A4. Model DMA program/setup time as a separate fixed per-descriptor cost

Deferred: initially fold the descriptor-program cost into the issuing op's dispatch cost. Split it out to a PE_DMA fixed setup only if calibration shows it matters.

Consequences

Positive

  • Hybrid's CPU-offload / saturation win (ADR-0060 §1) becomes measurable, with a structurally honest model (no calibration table).
  • Adding new op kinds (e.g., ADR-0065's softmax_merge 8-step recipe) costs zero — they fit the same formula automatically.
  • More faithful to hardware (queue-head MMIO RTT + queue-write bandwidth).

Negative

  • All bench goldens shift → one-time regeneration (D6); CI golden fixtures update.
  • Two calibration knobs (FIXED, R) need values; defaults are anchored on a documented assumption — treat absolute latencies as provisional until a reference exists; keep the ratios defensible.
  • Adds a small logical_bytes property to each PE command dataclass.

Open review items

  1. Calibration source for FIXED and R. Defaults from "typical composite ≈ 43 ns + on-die queue 16 bytes/cycle"; reasonable for an on-die descriptor queue. Revisit when a HW reference appears.
  2. Scheduler plan-gen cost vs large composites. Stays 0 — D5 keeps PE_SCHEDULER's plan-generation outside the dispatch formula. The D7 cap (1024 bytes ≈ 3035 OpSpecs) bounds the worst case, but a composite near the cap still costs the scheduler the same as a 1-op composite under the current zero-cost model. If a stress test (large-composite microbench) shows scheduler-bound behaviour, expose overhead_ns per-op-count.
  3. Where the override lives. pe_cost_model: block under PE node attrs in topology yaml — keeps all knobs in one place, reviewable. Clock comes from the PE's existing clock_freq_ghz attr, not duplicated here.
  4. Path parity. Both greenlet (_execute_legacy and kernel_runner) and replay paths must read the same cost model. Verify.
  5. Sensitivity of conclusions to R. opt2 < opt3 must hold across a reasonable range of R (queue-bandwidth assumption). Sensitivity sweep is part of Test Requirements (#9).

Test Requirements

Tests are written against the formula (D1), not against specific numeric anchors, so they remain valid when calibration changes or when OpSpec/CompositeCmd fields are added.

  1. Formula preservation. For any CompositeCmd c, PE_CPU's recorded dispatch overhead equals FIXED_PER_CMD + c.logical_bytes × R (within ±1 cycle for floor/round-off). Parametrized over several composites: a 1-OpSpec GEMM composite, a 5-op MATH chain, and a 10-op recipe composite. Default-calibration numbers (anchor ≈43 ns for the 1-OpSpec composite) are informative reference, not the test gate — the test gate is the formula equality.
  2. Qualitative ratio (robust). opt3 per-tile PE_CPU dispatch strictly exceeds opt2 per-tile dispatch by at least a 2× margin — opt3 > 2 × opt2. The default-calibration model predicts ≈4×; the gate is the loose 2× bound so the test does not break when calibration is moved (e.g., when a HW reference replaces the default). Informative numbers — see ADR-0065 §verification and DDD-0065 §11 for the model expectation.
  3. Override path. Topology yaml pe_cost_model: block changes the per-PE dispatch cost; default is recovered when block is missing. The formula identity from #1 must hold with the override values.
  4. PeCpuOverheadCmd bypass. Manual tl.cycles(n) issues exactly n cycles, not n + dispatch_cycles(...).
  5. No double-count. PE_DMA drain_ns, PE_GEMM/MATH _compute_ns identical to pre-ADR values.
  6. Determinism. Identical inputs → identical op_log + latency (SPEC §0.1).
  7. Path parity. Greenlet and replay paths produce identical dispatch-cycle accounting for the same kernel.
  8. Composite size cap (D7). A composite that would emit `logical_bytes

    max_composite_logical_bytesraises aValueError` at emit time (no segmentation). A composite within the cap emits normally.

  9. Sensitivity (qualitative). At R ∈ {0.25, 0.0625, 0.03125} cycles/byte, opt3 > opt2 at all three points. Direction (ratio monotonically increases as R decreases) is also asserted, but absolute ratio values are not required.

Migration

ADR-0064 Revision 2 lands as a single PR with:

  • logical_bytes property on each PeCommand dataclass (type-aware extra-field counting per D2)
  • formula application in pe_cpu.py dispatch path
  • pe_cost_model: override read at PE_CPU init (cycle-domain knobs + max_composite_logical_bytes)
  • composite size cap (D7) — TLContext-side hard cap with max_composite_logical_bytes default 1024: a composite over the cap raises a ValueError at emit time (no auto-segmentation). Not triggered by any existing bench (largest current composite is ~322 bytes), but the check lands so the descriptor-capacity limit is enforced as recipes grow.
  • one-time goldens regeneration

After this lands, ADR-0065 builds on top with no further goldens churn in existing benches (ADR-0065 is a meaning-preserving refactor of CompositeCmd for the existing path; only opt2 is a new bench).