Skip to content

Rank SDPA plans in one place, and give SM120 one measured tile rule - #527

Closed
YangXu1990uiuc wants to merge 10 commits into
NVIDIA:developfrom
YangXu1990uiuc:sm120-f16-tiles
Closed

Rank SDPA plans in one place, and give SM120 one measured tile rule#527
YangXu1990uiuc wants to merge 10 commits into
NVIDIA:developfrom
YangXu1990uiuc:sm120-f16-tiles

Conversation

@YangXu1990uiuc

Copy link
Copy Markdown
Collaborator

Before submitting

  • I agree to license this contribution under the terms of LICENSE.txt.
  • I ran pre-commit run and committed any formatting changes. (black 26.3.1 -l 160 on all touched python files)
  • I added GitHub labels: one cat-*, one or more mod-*, and one orig-*. (token lacks label permission — suggested: cat-feature, mod-python-fe, orig-nv-eng)

Affected area

FE OSS kernels or CuTeDSL — python dispatch (cudnn.pygraph plan ranking) and the SM120 SDPA-forward tile rule.

Draft, stacked on #509. GitHub will not take a fork branch as a base, so this targets develop and the diff carries #509's six commits too. Review only the six commits from ce8b3e10e onward — everything at or below d7dd85ee5 belongs to #509 and disappears from this diff once it merges.

Summary

An engine cannot rank plans. It sees neither its siblings nor the backend's entries, so propose_plans could only order its own knobs — and something downstream still had to merge the two sides, which heuristics_sort did by concatenating and calling it ranking. This moves the whole decision to one function per engine family and deletes what that makes redundant.

create_execution_plans([heur_mode.A, ...])
├─ _finalize_backend_layout() / _freeze() / _attach_facts()
└─ heuristics.rank(graph, engines, backend_plans, modes)     # engines/heuristics.py
   └─ <family>.recommend(modes, facts, offered, backend_plans)
        for mode in modes:
          OPENSOURCE -> our candidates only (no backend recommendation)
          A | B      -> ours + theirs, order decided by measurement
          FALLBACK   -> our safe config + the backend's fallbacks

What comes back is graph.plans, position for position. An engine answers two questions only: can I serve this graph (check_support) and compile me this config (build_plan).

The backend's plans now arrive tagged by mode

Ranking the two sides against each other needs to know which backend entries are mode-A recommendations and which are fallbacks. That needed no C++ change: C++ appends each query to the same plan list and get_execution_plan_count() already exists, so asking one mode at a time gives the boundaries. Measured on a 512³ bf16 matmul (sm90, cuDNN 9.25):

mode plans shape
heur_mode.A [0:15] all knob-bearing
heur_mode.FALLBACK [15:17] bare eng0/eng7, no knobs

The two segments do not overlap.

One SM120 tile rule instead of two, with a causal term

The f16/bf16 cell shared the fp8 cell's knob domain but had no rule, so it took whatever fit — 128x128 everywhere on this part. Measuring it produced the same rule plus one term: a causal mask halves the work per CTA, so the machine empties sooner and the finer Q tile keeps paying further out. Regret against the best of the enumerated {64,128}² domain:

dataset without the causal term with it
RTX PRO 6000, 24 bf16 cells 1.023x mean / 1.175x worst 1.009x / 1.054x
RTX PRO 6000, 28 fp8 cells 1.0078x / 1.107x 1.0066x / 1.107x (2 fixed, 2 broken)
RTX 5090 (170 SM), 14 fp8 cells 1.0082x / 1.062x 1.0007x / 1.006x
22 held-out fp8 shapes 1.009x / 1.089x flips the 3 worst cells to optimal

On the part the rule was trained on the term is a wash; it earns its place on the second part and the held-out set, where it fixes exactly the causal cells that were the previous misses. So fp8_tile_choice and f16_tile_choice collapse into one tile_choice.

Which side leads is a measurement

_MEASURED_BEHIND names cells timed slower than the backend; those follow it. sm120 fp8 is not there because it measures 1.20–1.83x the backend's native fp8 fprop across 28 shapes. A cell that has not been timed keeps the historical order and says so in a comment — moving one is an experiment, not an edit.

Coverage no longer rides on that ordering: heur_mode.OPENSOURCE puts everything FROST can build first whatever the measurements say, so a test asks for it and any graph still landing on a backend plan is one FROST cannot serve.

Deleted

why
BaseEngine.propose_plans + its 3 implementations 2 were verbatim copies of the default; nothing ever used the hook to decide anything
BaseEngine.default_knobs only fed propose_plans
EngineSpec.knob_order, knob_candidates ranking, in the wrong place
heuristics_sort merging is part of ranking, not a step after it
Router class, default_router, set_router, pygraph(router=), engines/router.py policy has one home; decline_types moved to base.py where the contract is
graph.engine pure alias of selected_engine, zero callers
graph.from_serialized zero callers; serialize/deserialize are the pybind-era API and stay
api_dsl's shape-driven tile choice the tile rule ran twice — here and when ranking

knobs=None no longer means "engine, pick for me". A None field survives only on an axis whose capability row declares no domain.

Testing

sdpa suites (RTX PRO 6000, sm120) 171 passed, 4 failed — see below
test_mhas_v2 routing 302 passed, tally unchanged: frost sm120 x87, fp8 x4, native x158/x53
dispatch guards (opt-in OFF) 69 passed
router / graph_native / gemm-frost (CPU) 125 passed

Known, not yet fixed: test_dsl_sm120_representative_head_dimensions[208..256]. At those head dims the adapter used to drop to a KV tile that fits; the heuristics now name 128 and the test pins the first entry. Traceback pending.

Pre-existing, not from this change: test_a_replayed_plan_reports_its_own_notes fails here and also fails at the base commit d7dd85ee5 — C++ on 9.25 no longer raises for an index one past the plan count.

Still to come on this branch

  • Remove the out-of-tree engine concept: register_backend / pygraph(backends=) / OUT_OF_TREE_ID_BASE go, _owners_for_id decodes ids straight from MANIFEST, and test fakes inject a fake family instead
  • Time the sm120 f16 cell against the backend and put the result in _MEASURED_BEHIND (today it is empty, i.e. "unmeasured keeps the old order")
  • FALLBACK is a placeholder (smallest admissible tile) until someone picks the configs that cover the plane

note to self: claude::304e9e55-1db7-4285-967f-001cb21032f3 — "审计 Frost Python DSL 引擎调用流程"
cwd /home/scratch.yanxu_libs/cudnn_frontend · workspace /home/scratch.yanxu_libs/fe_sm120

YangXu1990uiuc and others added 10 commits August 7, 2026 15:29
New engine sdpa_fwd_prefill_sm120_fp8 (slot 7): the per-tensor FP8 sibling
of the f16/bf16 SM120 prefill kernel, lowered to mma.sync.aligned.m16n8k32
e4m3 with the SDPA_FP8 node convention (scalar descales folded host-side,
in-kernel Amax_S/Amax_O via bitcast-int32 atomic max).

Kernel (prefill_fp8_sm120.py, cloned from prefill_f16_sm120.py):
- e4m3 rides as Uint8 storage end to end (TMA/ldmatrix/MMA consume bit
  patterns; no Float8 element support needed in the DSL plumbing)
- K B-fragments via byte-preserving classic ldmatrix.m8n8.x4.b16 (sm_120a
  has no non-transposed 8-bit ldmatrix; the m8n16 .b8 form is the FP4
  nibble-expansion load)
- V B-fragments via the hardware 8-bit transposed ldmatrix.m16n16.x2.trans.b8
  (SASS LDSM.8.MT1616), register map (0,2,1,3), two MMAs per issue
- P restaged through a per-warp SMEM tile (cvt.rn.satfinite.e4m3x2 at the
  C layout, warp-fenced, reloaded as A fragments) -- the k32 C->A
  fragment-column mismatch defeats the f16 in-register repack; the softmax
  denominator stays fp32
- FP16 O; SMEM alias and epilogue sized in bytes (KV 1B vs O 2B)

Wiring: manifest slot, _sm120_fp8_spec capability row (E4M3/FP16-out only,
exact d128, causal/BR/SWA/KV-padding, no sink/THD), SdpaFwdDslSm120 fp8
compile+execute path mirroring the SM100 _execute_fp8 host math, shared
ptx_mma_m16n8k32_e4m3_f32 / ptx_cvt_e4m3x2 helpers in tile_dsl.mma (with an
Int32-bitcast armor for a cutlass-dsl 4.7.0 inline_ptx constant-f32 bug).

RTX 5080 kernel-time speedup vs the bf16 f16 kernel (CUPTI): 1.74-1.79x
non-causal, 1.70x causal; O err vs fp32-dequant reference 3e-4..1e-2, LSE
~1e-6. Details and optimization options in
docs/fe-oss-apis/attention/sdpa-fp8-sm120.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
propose_plans returned one entry, so the capability row's tile_ms/tile_ns
domain was unreachable: SdpaFwdKnobs validation, PlanConfig.knobs and
create_execution_plan replay were all in place with nothing enumerating the
points. The adapter then took SEQ_*_TILES[0] -- the largest tile that fits,
not the fastest.

knob_candidates() reads the domain off Capabilities (no second table to drift
from it), filters through mismatch(), and orders it via a new per-spec
EngineSpec.knob_order. Entry 0 still carries knobs=None: a delegation and a
named knob set are different requests, one may improve with the library and
the other must be honored verbatim, so existing name-pinning is untouched.

config_sm120.fp8_tile_choice picks (q_tile, kv_tile) from shape, causality and
SM count. kv_tile=64 unconditionally -- the kernel is L1-bound on the P
restage and halving the KV tile halves that traffic, faster in 47 of 48
shapes measured (the exception by 0.15%). q_tile=64 while the grid cannot
fill the machine, with a wider window under a causal mask where the last Q
tile does several times the work of the first. FP8 only: the f16 cell wants
kv_tile=128 at long sequences and the two share SEQ_*_TILES.

Measured on RTX PRO 6000 Blackwell (sm120, 188 SMs, cuDNN 9.25), CUPTI kernel
time, 2 s clock warm-up, both measurement orders agreeing within 0.5%:

- 22 held-out shapes (none used to read the rule off): regret against the
  best of the enumerated domain is 1.005x mean, 1.045x worst.
- versus the backend's native fp8 fprop: ahead on 19 of 22, 1.5x at small
  grids. The previous default lost 11-25% to its own domain at every shape.

test_mhas_v2 serial and seeded: identical routing tally and identical failure
set against the committed files, so this changes which tile a graph runs on,
not which engine claims it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule is arithmetic over shape, causality and SM count, so it needs no
device -- SM count is an argument, not something read off the machine, which
is why it is a pure function. Both directions of the causal clause are
asserted: checking only the causal side would pass for a rule that ignored
causality entirely.

propose_plans now offers four tile points but any one shape runs exactly one
of them, so the other three would ship unexercised. test_fp8_sm120_every_-
enumerated_tile pins each named entry through select_plan and checks it
against the same fp32-dequant reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The QK C-fragment owns columns 2*(t%4)+{0,1} while the k32 A-fragment wants
four consecutive bytes, so v1 staged P through a per-warp 16 x kv_tile SMEM
tile: 32 STS.U16 + 4 LDSM.x4 + 2 warp barriers per warp per KV tile. The two
layouts differ only by an exchange inside each thread quad, so pack_f8x2_pairs
+ two shfl.sync.idx + one prmt.b32 build each A register straight from the
cvt results the softmax already leaves in registers.

Measured 1.07-1.43x over 24 shape x tile combinations on an RTX PRO 6000
Blackwell (188 SMs, cuDNN 9.25), largest where the restage traffic was
largest. The design notes predicted 1.04-1.08x from an instruction count; ncu
says the kernel was L1-bound (72-77% L1/TEX against the backend kernel's
45-51%, DRAM 5-13% on both), so the binding resource was the L1 port and
removing 36 memory ops per warp per tile was worth several times the
issue-count estimate.

It also frees 16 KB of SMEM (49 -> 33 KB at 128x128), which moves the tile
optimum: kv_tile is now 128 in all 28 shapes measured, having been 64 in 47 of
48 on the old kernel. fp8_tile_choice is re-derived accordingly, and both it
and its tests now say that a tile rule is a property of the kernel it was
measured on. Held-out regret against the best of the enumerated domain is
1.009x mean / 1.089x worst over 22 shapes; against the backend's own fp8
fprop the engine is ahead on all 22, by 1.15x-1.93x.

Three further optimizations from the sibling DKG kernel were measured and
NOT taken. Prefetching K or V fragments one step ahead lands inside +-0.5%,
and loading Q coalesced with a shfl transpose is reproducibly 0.5-2.3% slower
at s=512 -- the shape it should help most, since Q is small enough to sit in
L2. The run-to-run floor is ~1% at s>=2048 and was 12% at s=512 until the
probe warmed the device first, which is what made these calls possible.

Also from review:
- Capabilities gains out_dtypes. facts.dtype_o never reached mismatch(), so a
  graph asking for fp8 O was claimed and then declined at build. The default
  is empty rather than unconstrained: a quantized row that forgets to declare
  serves nothing, which fails loudly.
- The post-kernel amax_o.div_ and O copy-back move onto the launch stream,
  next to the resets that were already there (NVIDIA#510).
- The negative tests the module docstring promised now exist (fp8 O, D != 128).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ells

The f16/bf16 cell shared the fp8 cell's knob domain but had no rule of its own,
so it took whatever fit: 128x128 everywhere on this part. Measuring it produced
the same rule the fp8 cell uses, plus one term.

A causal mask halves the work per CTA, so the machine empties sooner and the
finer Q tile keeps paying further out. Folded in as a halved effective grid.
Regret against the best of the enumerated {64,128}^2 domain:

                                  without         with
  RTX PRO 6000, 24 bf16 cells   1.023 / 1.175   1.009 / 1.054
  RTX PRO 6000, 28 fp8 cells    1.0078/ 1.107   1.0066/ 1.107   (2 fixed, 2 broken)
  RTX 5090 (170 SM), 14 fp8     1.0082/ 1.062   1.0007/ 1.006
  22 held-out fp8 shapes        1.009 / 1.089   flips the 3 worst cells to optimal

(mean / worst.) On the part the rule was trained on the term is a wash and the
worst case is unchanged; it earns its place on the second part and the held-out
set, where it fixes exactly the causal cells that were the previous misses.

So fp8_tile_choice and f16_tile_choice collapse into one tile_choice, and the
two knob_order callbacks into one. That also drops a leftover: the fp8 callback
ranked its runner-up entries by tile_n != 64, from before the shfl change moved
the KV optimum to 128.

The tile-rule test file covers both cells now, so it loses the fp8 in its name.
propose_plans called check_support(graph) and then knob_candidates(spec, graph),
and each of those ran analyze_for -> mismatch(caps, facts, None). The facts
parse itself is memoized per graph (_attach_facts -> _facts_for), so the waste
was the capability match, once per engine per plan walk.

knob_candidates now takes the facts the caller already has. _facts_or_decline
is the single place the engine asks, returning the facts it just proved
eligible; check_support is that call with the answer dropped.

No behaviour change: knob_candidates could only be reached through a passing
check_support, so its own decline branch was unreachable.
Ranking the two sides against each other needs to know which backend entries
are mode-A recommendations and which are fallbacks -- "the backend's A ahead of
ours, its fallbacks behind" cannot be said about one opaque list. Until now the
whole thing arrived from a single create_execution_plans([A, FALLBACK]).

No C++ change is needed. C++ appends each query to the same plan list, and
get_execution_plan_count() already exists, so asking one mode at a time and
reading the count after each gives the boundaries. Measured on a 512^3 bf16
matmul (sm90, cuDNN 9.25): A -> plans[0:15], all knob-bearing; FALLBACK ->
plans[15:17], bare eng0/eng7 with no knobs; the two segments do not overlap.

A mode with no configs raises, which is not a decline while another mode still
has entries -- an OPENSOURCE-only query legitimately leaves the cuDNN modes
empty. Only every mode failing means the backend has nothing, and then the last
error is re-raised so the caller still reports why.
An engine cannot rank. It sees neither its siblings nor the backend's entries,
so propose_plans could only ever order its own knobs -- and then something
downstream had to merge the sides anyway, which heuristics_sort did by
concatenating and calling it ranking.

Now create_execution_plans() gathers the inputs (parsed facts, this family's
offered ids, the backend's entries tagged by mode) and hands all of it to the
graph's family in ONE call. What comes back IS graph.plans, position for
position. sdpa/fwd/heuristics.py is the first such hook: mode A returns
candidates worth timing with the tile rule's guess first, FALLBACK returns
configs expected to build where A's choice may not, and both sides' fallbacks
sort behind both sides' tuned entries.

Which side leads is now a measurement rather than a default. sm120 fp8 is timed
1.20-1.83x the backend's native fp8 fprop, so it leads; a cell that has not been
timed against the backend keeps the historical order and says so. Moving one is
an experiment, not an edit.

Deleted, all superseded or never used:
  BaseEngine.propose_plans + its 3 implementations (2 were verbatim copies of
    the default, the third was added last week -- nothing ever used the hook to
    decide anything)
  BaseEngine.default_knobs        only fed propose_plans
  EngineSpec.knob_order           ranking, in the wrong place
  knob_candidates                 ditto
  engines.probe() (fwd + bwd)     zero callers, superseded by check_support
  heuristics_sort                 merging is part of ranking, not a step after
  Router.python_plans / Router class / default_router / set_router /
    pygraph(router=)              policy has one home now; the router is plumbing
  graph.engine                    pure alias of selected_engine, zero callers
  graph.from_serialized           zero callers; serialize/deserialize are the
                                  pybind-era API and stay
  api_dsl's shape-driven tile choice   the tile rule ran twice, once here and
                                  once when ranking; resource feasibility stays

knobs=None no longer means "engine, pick for me" -- heuristics always names a
concrete config. A None field survives only on an axis whose capability row
declares no domain.

Tests are updated in the next commit; test_engine_router still builds Router
subclasses to inject orderings, which is now done by replacing heuristics.rank.
… retired

Ranking has one home, so a test that wants a specific order replaces
heuristics.rank instead of subclassing Router. The _ranking() helper does that;
it is the same monkeypatch idiom the rest of the suite already uses (326 calls
across 20 files).

Deleted rather than translated:
  test_set_router_frozen_after_planning   the API it tested is gone
  test_a_claiming_engine_is_tried_before_the_backend
                                          asserted that python plans always
                                          outrank the backend, which is now a
                                          measurement per cell, not a rule

FROST coverage no longer rides on that ordering. heur_mode.OPENSOURCE asks for
an open-source implementation, which is what these cells are, so it puts
everything FROST can build ahead of the backend whatever the measurements say.
A test asks for it and whatever still lands on a backend plan is a graph FROST
does not cover -- coverage measured rather than asserted, and the plan walk
falls through to the backend so the test still checks numerics either way.

Renamed for what they now test: test_mixed_ranking_dispatch,
test_empty_ranking_output_rejected, test_mixed_ranking_backend_slot_executes,
test_constructor_backends_validated_and_ranking_ids_checked.

One assertion changed meaning: the backend is queried once PER MODE now, so
_create_backend_plans records two create_execution_plans calls for [A, FALLBACK].

Known: test_a_replayed_plan_reports_its_own_notes fails, and it already failed
at d7dd85e (verified by running it against that tree) -- C++ no longer raises
for an index one past the plan count on 9.25. Pre-existing, not this change.

Out-of-tree engines lose the ability to offer several knob candidates: they
declare no Capabilities, so nothing can enumerate their domain. register_backend
remains what the tests use to install a fake engine.
…n lookup

OPENSOURCE is mode A without the backend's recommendation: these cells ARE the
open-source implementation and the backend's engines are not, so there is
nothing of the backend's to place. Combine it to measure coverage --
[OPENSOURCE, A, FALLBACK] tries every FROST config first and still has the
backend behind it, so a graph that runs on a backend plan is one FROST does not
cover.

recommend() is now a loop over the caller's modes rather than three blocks
spliced together. Each mode contributes its own, so [A, FALLBACK] puts both
sides' tuned candidates ahead of both sides' fallbacks by construction. An
untagged backend entry is the delegating one C++ tries before its own
engine_configs; it belongs to no mode and keeps the lead, or an OPENSOURCE
caller would get a native kernel instead of the OSS one.

Plan names now always read <engine>[<knobs>], since the heuristics name a
concrete config for every entry -- there is no knobs=None entry left to print
as a bare engine name. Six test files each carried their own verbatim copy of
_select_engine matching that bare name exactly; they now share
frost_test_utils.select_engine, which matches on the engine.

probe() is restored to its callers rather than deleted: I claimed it had none,
which was wrong -- test_sdpa_graph_analyzer used it twice. Those two call
analyze_for directly now, so no production API exists only for tests.

sm120: sdpa suites 62 failed -> 4 (171 passed); test_mhas_v2 302 passed with the
routing tally unchanged (frost sm120 x87, fp8 x4, native x158/x53). The 4 are
head_dim 208-256, where the adapter used to drop to a KV tile that fits and the
heuristics now name 128 -- traceback pending, not yet diagnosed.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 09777aa1-cc1e-4189-bf03-8f6a96229b25

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@YangXu1990uiuc

Copy link
Copy Markdown
Collaborator Author

For a diff without #509's commits (GitHub will not take a fork branch as a base, so this PR targets develop): YangXu1990uiuc#1 — same six commits, based on fp8-sm120. Once #509 merges, this PR's diff narrows to those six on its own and that mirror can be closed.

@YangXu1990uiuc

Copy link
Copy Markdown
Collaborator Author

Superseded by #528, which is the same framework change based on develop instead of stacked on #509. The dependency on #509 turned out to be scope, not code: the tile rule belongs in #509, and knob_candidates/knob_order are deleted by this work rather than modified by it. #528 carries no tuning rules at all -- just the ranking frame and the cleanup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant