Lower non-MoE, workspace-free gemm flavors off one build-time recipe - #559
Conversation
📝 WalkthroughWalkthroughThe PR replaces slot-oriented variant-pack APIs with operand-buffer APIs. It adds immutable GEMM recipes, cached lowered execution, stream-ordered reduction initialization, workspace unknown-size handling, alignment and layout validation, and broad FROST execution tests. ChangesFROST execution and operand buffers
Estimated code review effort: 5 (Critical) | ~90 minutes Mergeability Score: 🟡 Moderate · up to The PR consolidates GEMM execution and validation across several flavors, but merge readiness is reduced by a rank-validation test that may pass without exercising the intended execution path and by a large parameter sweep placed in the fastest test tier; these should be corrected or explicitly accepted before merging. Sequence Diagram(s)sequenceDiagram
participant graph_execute
participant _FrostGemmPlan
participant CompiledFusedGemm
participant GemmRecipe
graph_execute->>_FrostGemmPlan: execute variant pack
_FrostGemmPlan->>CompiledFusedGemm: invoke lowered launcher
CompiledFusedGemm->>GemmRecipe: validate runtime operands
GemmRecipe-->>CompiledFusedGemm: return validation result
CompiledFusedGemm-->>graph_execute: launch or report rejection
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
4574501 to
d37ee40
Compare
|
Thanks — all three taken. Pushed 1. The aux-rank bug is real, and wider than rank-1. It is not rank-1 specific: Same graph, same data, only the handed-over buffer's shape differs:
Fixed with (The C++ diff is larger than the one line it should be: the longer 2. Block-scale evidence added. An nvfp4 lowered-vs-interpreted differential for one and two GEMMs — the two-GEMM case also asserting 3. Full suite. Still running; I will post counts here. Worth recording that my first two attempts were invalidated by my own runner flags, not by code:
So the run in flight is plain Title narrowed to "non-MoE, workspace-free" as suggested. On the two-track structure and the lazy UID→index: agreed, both are intermediate. Not touching them here — the end state is the interpreter consuming the same tagged plan (or a codegen'd native PreparedLaunch) and then deleting Thanks for measuring FP4 block-scale (44.77 → 26.14) — that is the one flavor I had not timed, and for confirming GPU event time is unchanged, which is the check that says the win is host-side. |
|
Full-suite result, plus a correction to what I said above. Result
The 89 are pre-existing and outside this PR's surface — 87 numerical tolerance failures in the cuteDSL OSS kernels ( CorrectionAbove I wrote that What is actually true about One more commit:
|
|
Pushed The deferral counters added in
Three build-time declines also kept
The invariant tightens from "the fast path may accept a subset" to: the set of calls the launch path refuses must equal the set of illegal calls. The rules are still written twice (fused in the guards, readable in One capability removed, deliberately: The lowered-vs-interpreted differential goes with the interpreter, which I think is the right trade rather than a loss: two readings of the same wrong plan agreeing proves nothing, and that is exactly how the axis-order bug survived one — both paths read Cost, unchanged (min over 25 reps of a 64-call burst, 256×256×128 bf16, SM100): plain 19.76, epilogue 19.69, aux 22.26, 2 outputs 22.65, reduction 26.12, multi-gemm 22.54. Test: |
|
@cudnn-ci-bot run frost |
|
🚀 Running mirror pipeline Branch: cudnn-gh/pr-559-02a47d2 |
|
All three blockers fixed in Your framing is right that they are one shape: a per-ROLE fact treated as a per-SLOT one, or a write issued before every rule has had its say. 1. Shared operand — fixed, and it was mine
The test is Worth noting: your repro surfaced as the drift RuntimeError, which is the guard doing exactly the job it was added for. Without it this would have been a silent wrong answer. 2. Seed before validation — fixed, three separate holes
One ordering I did not change and want to flag rather than hide: a call the fast guards accept but the tvm-ffi front door then rejects (your TMA outer-stride case) still seeds first. Seeding has to precede the launch, so closing that means checking what the front door checks — which is the same "one ordered list" work as below, not a local fix. 3. Guard/checker coverage — fixed
4. multi-GEMM without a dense output — supported, not narrowedYou are right that this was a second capability removal and I described only On the invariant claim — you are right, and I have corrected it
I agree the real answer is generating both the fused guards and the readable diagnostics from one ordered Both design docs now carry a SUPERSEDED header: Test
|
02a47d2 to
5d41d3e
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
test/python/gemm/frost/test_execute_recipe.py (4)
513-516: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDrop the unused unpack target.
cis never used in_padded_rows. Ruff reports RUF059.♻️ Proposed change
- a, b, c = _operands() + a, b, _ = _operands() return a, b, torch.empty(1, M, N * 2, dtype=torch.bfloat16, device="cuda")[:, :, :N]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 513 - 516, Update _padded_rows to unpack only the operands it uses, removing the unused c target that triggers Ruff RUF059 while preserving the returned tensor construction.Source: Linters/SAST tools
160-165: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract the "last node output" lookup into a helper.
The idiom
[t for t in g._nodes[-1].outputs.values()][0]repeats at lines 162, 185, 204, 212, and 381. Ruff reports RUF015 on each occurrence. A single helper removes the duplication and the hints.♻️ Proposed helper
+def _last_output(g): + return next(iter(g._nodes[-1].outputs.values())) + + def _reduction_graph(): g = _plain_graph() - Y = [t for t in g._nodes[-1].outputs.values()][0] + Y = _last_output(g)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 160 - 165, Extract the repeated last-node output lookup into a shared helper near the graph test utilities, then replace each occurrence in _reduction_graph and the other affected test helpers with that helper. Ensure the helper preserves the current behavior of returning the first output from g._nodes[-1].outputs.Source: Linters/SAST tools
838-848: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the deferral reason for the batch mismatch.
Other rejection tests pin the counter, for example line 334 and line 607. Add the same assertion here so a rejection raised by an unrelated rule cannot pass this test.
♻️ Proposed change
with pytest.raises(ValueError): compiled.lowered(_bound_buffers(compiled, a, one_batch_b, c), stream=None) + assert dict(compiled.deferrals) == {"operand batch": 1}Use the exact reason string the launch path records for a batch mismatch.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 838 - 848, Update test_operand_batch_is_checked to capture the ValueError from compiled.lowered and assert its exact message matches the launch path’s recorded batch-mismatch deferral reason, following the counter assertions used by the other rejection tests.
716-719: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove the degenerate-extent sweep to a higher test level.
This sweep runs 16 cases, and each case builds and executes both a backend plan and a FROST plan. The file mark is
L0(line 40). Keep a small subset atL0and mark the full sweep at a higher level.As per coding guidelines: "Mark every new Python test with a level from
L0throughL4; keepL0tests fast and place large parameter sweeps at higher levels."♻️ Proposed change
`@requires_sm100` +@pytest.mark.L2 `@pytest.mark.parametrize`("batch", (1, 2), ids=("b1", "b2")) `@pytest.mark.parametrize`("m,n,k", [(m, n, k) for m in (1, 128) for n in (1, 128) for k in (1, 128)], ids=str) def test_a_degenerate_extent_is_refused_or_matches_the_backend(monkeypatch, batch, m, n, k):🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 716 - 719, Update test_a_degenerate_extent_is_refused_or_matches_the_backend so the full 16-case parameter sweep runs at a higher test level than L0, while retaining a small representative subset marked L0. Ensure the test-level markers follow the project’s L0–L4 conventions without changing the test’s backend/FROST validation behavior.Source: Coding guidelines
test/python/gemm/frost/test_public_execute_flavors.py (1)
26-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReuse the shared capability mark.
gemm_test_utils.requires_sm100already gates SM100 tests, and test/python/gemm/frost/test_execute_recipe.py imports it at line 30. A second local definition can drift from the shared one.♻️ Proposed change
-_GPU = pytest.mark.skipif( - not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] != 10, - reason="the FROST gemm engine claims SM100", -) +from gemm_test_utils import requires_sm100 as _GPUKeep the import at the top of the module.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_public_execute_flavors.py` around lines 26 - 29, Replace the local _GPU skipif definition with the shared gemm_test_utils.requires_sm100 capability mark, keeping the existing top-level import and applying the shared mark to the same tests.python/cudnn/frost/buffers.py (1)
348-407: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueThe fill planner looks correct, including the overlap rule.
collapse_layoutmerges an outer axis into an inner run only when the outer stride equals the inner span, and it keeps the inner stride for the merged run.strided_fill_planrejects a zero stride over a real extent and rejects any outer stride that does not clear the axis below it, soshape (2, 2) stride (2, 2)is refused as documented. The width, pitch, and height selection maps a rank-3 padded tap onto one 2D memset per outer point.One note for a follow-up:
fill_word_asyncand_fill_word_2d_asyncimportcuda.bindings.driveron every call. The seeding path runs per launch, so binding the driver module once at import time would remove that lookup from the hot path.Also applies to: 410-435
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@python/cudnn/frost/buffers.py` around lines 348 - 407, Move the cuda.bindings.driver import out of the per-call paths in fill_word_async and _fill_word_2d_async, binding the driver module once at module import time and reusing that module in both functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/python_graph_and_execution_backends.md`:
- Around line 181-188: Update the fallback descriptions in
docs/python_graph_and_execution_backends.md lines 181-188 and
python/cudnn/frost/README.md lines 243-249: replace claims that unsupported
graphs are handed to an interpreter with wording that CompiledFusedGemm.explain
provides diagnostics, names the reason, and raises; preserve the surrounding
explanation of lowered-path behavior and rejection ownership.
In `@python/cudnn/gemm/frost/compiler.py`:
- Around line 2060-2071: Update the sfs validation loop around operands[idx] to
reject any scale-factor operand whose rank is not exactly 3 before the existing
alignment, density, and size checks. Add the same rank-3 requirement to
recipe._sf_blob_reject so explain reports the scale-factor blob rejection
instead of falling through to the drifted-apart error, while preserving the
existing MoE launcher contract.
In `@python/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.py`:
- Around line 2620-2622: Insert a sentence break in the docstring for the
descriptor-building kernel in
python/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.py lines 2620-2622,
placing “Launched” on a new line after “tensormap_workspace”. Apply the same
docstring-only correction in
python/cudnn/linear_attention/frost/kernel/gdn_bprop_f16.py lines 3960-3962 and
python/cudnn/linear_attention/frost/kernel/kda_prefill_f16.py lines 2543-2545.
---
Nitpick comments:
In `@python/cudnn/frost/buffers.py`:
- Around line 348-407: Move the cuda.bindings.driver import out of the per-call
paths in fill_word_async and _fill_word_2d_async, binding the driver module once
at module import time and reusing that module in both functions.
In `@test/python/gemm/frost/test_execute_recipe.py`:
- Around line 513-516: Update _padded_rows to unpack only the operands it uses,
removing the unused c target that triggers Ruff RUF059 while preserving the
returned tensor construction.
- Around line 160-165: Extract the repeated last-node output lookup into a
shared helper near the graph test utilities, then replace each occurrence in
_reduction_graph and the other affected test helpers with that helper. Ensure
the helper preserves the current behavior of returning the first output from
g._nodes[-1].outputs.
- Around line 838-848: Update test_operand_batch_is_checked to capture the
ValueError from compiled.lowered and assert its exact message matches the launch
path’s recorded batch-mismatch deferral reason, following the counter assertions
used by the other rejection tests.
- Around line 716-719: Update
test_a_degenerate_extent_is_refused_or_matches_the_backend so the full 16-case
parameter sweep runs at a higher test level than L0, while retaining a small
representative subset marked L0. Ensure the test-level markers follow the
project’s L0–L4 conventions without changing the test’s backend/FROST validation
behavior.
In `@test/python/gemm/frost/test_public_execute_flavors.py`:
- Around line 26-29: Replace the local _GPU skipif definition with the shared
gemm_test_utils.requires_sm100 capability mark, keeping the existing top-level
import and applying the shared mark to the same tests.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: f6e7e898-1b82-4479-9f45-7bd94a0f15db
📒 Files selected for processing (18)
docs/python_graph_and_execution_backends.mdpython/cudnn/_pygraph.pypython/cudnn/engines/base.pypython/cudnn/frost/README.mdpython/cudnn/frost/buffers.pypython/cudnn/frost/workspace.pypython/cudnn/gemm/frost/compiler.pypython/cudnn/gemm/frost/dtypes.pypython/cudnn/gemm/frost/engine.pypython/cudnn/gemm/frost/recipe.pypython/cudnn/linear_attention/engine_utils.pypython/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.pypython/cudnn/linear_attention/frost/kernel/gdn_bprop_f16.pypython/cudnn/linear_attention/frost/kernel/kda_prefill_f16.pypython/pygraph/variant_pack.cpptest/python/gemm/frost/test_execute_recipe.pytest/python/gemm/frost/test_matmul_epilogue_fusion.pytest/python/gemm/frost/test_public_execute_flavors.py
5d41d3e to
5dfa3c1
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (4)
test/python/gemm/frost/test_execute_recipe.py (3)
449-452: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDiscard the unused unpacked value.
Ruff reports RUF059 at Line 451 because
cis never read. The neighboring builders at Lines 422, 427, 432, and 437 already use_for this.♻️ Proposed change
- a, b, c = _operands() + a, b, _ = _operands() return a, b, torch.empty(1, M, N * 2, dtype=torch.bfloat16, device="cuda")[:, :, :N]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 449 - 452, Update the _padded_rows function to unpack the unused second value from _operands() into `_`, matching the neighboring operand-builder functions and resolving RUF059.Source: Linters/SAST tools
98-98: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReplace the single-element list comprehensions.
Ruff reports RUF015 at Lines 98, 121, 140, 148, and 317. Use
next(iter(...))to read the first output tensor. This keeps the lint clean if the repository gate runs Ruff on tests.♻️ Proposed change (apply the same edit at each site)
- Y = [t for t in g._nodes[-1].outputs.values()][0] + Y = next(iter(g._nodes[-1].outputs.values()))Also applies to: 121-121, 140-140, 148-148
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` at line 98, Replace the single-element list comprehensions at the affected output-tensor assignments, including the sites around lines 98, 121, 140, 148, and 317, with next(iter(...)) over the corresponding outputs collection so each assignment retrieves the first tensor without constructing a list.Source: Linters/SAST tools
652-654: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove this sweep above L0.
Line 39 marks the whole module
L0. This test expands to 16 parameter combinations, and each combination builds and compiles up to two execution plans through_matmul_on. That is a large sweep for the fast tier.Add a higher level marker to this test, or reduce the extent set at
L0and keep the full sweep at a higher level.♻️ Proposed change
`@requires_sm100` +@pytest.mark.L2 `@pytest.mark.parametrize`("batch", (1, 2), ids=("b1", "b2")) `@pytest.mark.parametrize`("m,n,k", [(m, n, k) for m in (1, 128) for n in (1, 128) for k in (1, 128)], ids=str)As per coding guidelines: "Mark every new Python test with a level from
L0throughL4; keepL0tests fast and place large parameter sweeps at higher levels."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 652 - 654, Move the parameterized test using the m/n/k sweep above the module-level L0 marker by assigning it a higher test level, or restrict its L0 parameter set while retaining the full sweep at that higher level. Update the test’s existing markers near the requires_sm100 and parametrization declarations, preserving coverage for all 16 combinations outside L0.Source: Coding guidelines
test/python/gemm/frost/test_public_execute_flavors.py (1)
141-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert that the FROST iteration used the lowered path.
The test compares numbers between the two plan kinds. It does not check that the FROST plan accepted the operand. The docstring at Lines 125-128 names the defect: an operand buffer with no
ndimlooked like it already matched, so these calls were refused. A refusal is reported throughdeferrals, and the numeric comparison would still pass. The sibling test at Line 297 already asserts the lowered path.Add the same assertion for the
want_frostiteration so a regression fails here.♻️ Proposed change
y = torch.zeros(1, M, N, dtype=torch.bfloat16, device="cuda") _run(g, {A: a, B: b, bias: bias_buf, Y: y}) + if want_frost: + plan = g._compiled_plans[g._plan_index] + assert plan._lowered is not None + assert dict(plan._compiled.deferrals) == {} out[want_frost] = y🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_public_execute_flavors.py` around lines 141 - 155, Update the want_frost iteration in the test around g.select_plan and _run to assert that the selected FROST plan reports the expected lowered-path execution through deferrals, matching the assertion used by the sibling test. Keep the existing numeric comparison and output collection unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@test/python/gemm/frost/test_execute_recipe.py`:
- Around line 449-452: Update the _padded_rows function to unpack the unused
second value from _operands() into `_`, matching the neighboring operand-builder
functions and resolving RUF059.
- Line 98: Replace the single-element list comprehensions at the affected
output-tensor assignments, including the sites around lines 98, 121, 140, 148,
and 317, with next(iter(...)) over the corresponding outputs collection so each
assignment retrieves the first tensor without constructing a list.
- Around line 652-654: Move the parameterized test using the m/n/k sweep above
the module-level L0 marker by assigning it a higher test level, or restrict its
L0 parameter set while retaining the full sweep at that higher level. Update the
test’s existing markers near the requires_sm100 and parametrization
declarations, preserving coverage for all 16 combinations outside L0.
In `@test/python/gemm/frost/test_public_execute_flavors.py`:
- Around line 141-155: Update the want_frost iteration in the test around
g.select_plan and _run to assert that the selected FROST plan reports the
expected lowered-path execution through deferrals, matching the assertion used
by the sibling test. Keep the existing numeric comparison and output collection
unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: a5341e07-5cab-4733-af60-40d956a83429
📒 Files selected for processing (6)
python/cudnn/gemm/frost/compiler.pypython/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.pypython/cudnn/linear_attention/frost/kernel/gdn_bprop_f16.pypython/cudnn/linear_attention/frost/kernel/kda_prefill_f16.pytest/python/gemm/frost/test_execute_recipe.pytest/python/gemm/frost/test_public_execute_flavors.py
🚧 Files skipped from review as they are similar to previous changes (4)
- python/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.py
- python/cudnn/linear_attention/frost/kernel/kda_prefill_f16.py
- python/cudnn/linear_attention/frost/kernel/gdn_bprop_f16.py
- python/cudnn/gemm/frost/compiler.py
…e line Which axis of each operand carries M/N/K, each major, the fp4 packing factor, every output's required alignment and shape rule, which outputs are reductions: all settled by the time cute hands back a launchable, and all re-derived on every execute. gemm/frost/recipe.py reads them once, and gives that table two consumers -- run_views interprets it and serves every flavor, _lower emits a straight line for the single-GEMM shape. 256x256x128 bf16, host enqueue, min over 25 reps of a 64-call burst from a drained queue, one process and one plan: 44.13 -> 34.79 interpreted -> 17.54 lowered. The lowered path never raises. Anything it is not certain of it hands to run_views, which owns every rejection message, so it can only accept a subset of what the general path accepts and there is no second set of error strings. The version of this I wrote by hand first had two such divergences -- it lost the operand batch check and pinned an fp4 output at N where the graph says N/2. Also closes the bare-address xfail: cuDNN declares a matmul's B as [batch, K, N] while a caller allocates it (batch, N, K), so reading an extent by axis position answered one of those and not the other. The recipe records both orders and picks by where stride 1 landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…shape A matmul's B arrives either as cuDNN's declared [b, K, N] or as this engine's own direct-call (b, N, K), and at N == K those are the same shape AND the same stride. Inferring which from the description read one as the other and computed a transpose, silently -- and both call paths read that inference, so the differential between them agreed and stayed green. The tie-break is the backend's own rule: the graph's tensor descriptor defines the tensor and the variant pack supplies only a pointer. So a buffer that reports the declared (dim, stride) is read as the declaration. Measured on the ambiguous case: that agrees with the backend to bf16 tolerance, where reading it as (b, N, K) differs by 65. VariantPack.graph_described names the slots the pack described FROM the graph, which a bare address's live shape cannot show once override_shapes has moved it. That closes the other half: a bare pointer running a smaller problem inside its allocation was refused as a layout mismatch. This also fixes a pre-existing divergence, not introduced here: test_every_variant_pack_form_still_works fails all four forms on develop with CUDNN_FRONTEND_ENABLE_FROST_ENGINES=1, because it builds its graph with tensor_like() on a [b, K, N] tensor -- the matmul ABI, literally -- and the engine assumed its own order. Same call, two answers, decided by plan selection, which is what that test exists to deny. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"slot" was doing two jobs -- an int POSITION in the pack, and the OBJECT at that position -- so neither read as anything. They are now index_of() / operands() and OperandBuffer, and the C type, the native methods and every caller follow. PortSlots is PortIndices for the same reason. The pair that mattered most is on the compiled object: `run_views` (the interpreter) and `launch` (the emitted straight line) gave no hint they were two implementations of one thing. They are now `launch` and `lowered`, and the engine binds `_launch = lowered or launch` once at build. Also splits the per-call gate along the line the two halves actually fall on: check_shapes asks whether the extents agree with the problem size this call runs, check_alignment asks whether each buffer meets the width its role's accesses were compiled for. Neither name is new vocabulary. test_execute_recipe gains a degenerate-extent sweep. An extent of 1 leaves its axis's stride free, so two majors can look alike; what keeps that from mattering is that the TMA modulus is at least 4 and 1 divides none of them, so a unit contiguous extent never reaches a launch. The test asserts the property rather than the argument: every degenerate shape is refused or matches the backend. All eight K == 1 shapes take the refusal, at plan time. docs/python_graph_and_execution_backends.md gets the measured budget a python execute path has to fit in, and the pattern that fits it. An engine that re-derives its per-call facts lands near 40 us; the same kernel reading them once is 17.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lowered call path served one graph shape: single-GEMM, one dense output, no aux, no scale factors, no reduction seed. Every other flavor fell back to the interpreter at 39-50 us of host time per execute, against 17.7 for the shape that lowered -- and for a single-kernel op that gap is most of what the caller pays. What actually differed between the flavors was which buffers the launch passes and in what order, so that is a recipe field now: `arg_plan`, one (operand index, aux ref) per positional argument after `problem_size`. Three smaller fields carry the rest -- `stride_ins` (whose permuted strides ride in `problem_size`), `shared_layout` (block-scale multi-GEMM collapses its A operands to one stride triple and requires the others to match), and `seeds` (a reduction's identity, packed as its output dtype). With the launch shape as data the emitted body is one loop over flat tuples, and the hand-unrolled straight line is deleted. Measured against it on plain gemm the loop costs 12% and saves one closure body per operand shape; source codegen off the same table is how to buy that back for every flavor at once rather than only for the one worth hand-writing. 256x256x128 bf16, min over 25 reps of a 64-call burst from a drained queue: flavor before now plain 17.73 19.95 epilogue (relu) 17.69 19.89 aux (bias tensor) 38.70 22.25 2 dense outputs 43.89 22.57 reduction output 49.90 26.29 multi-gemm 42.58 22.38 Reduction sits ~4 us above the rest because it still pays one cuMemsetD32Async per tap; that goes when the kernel seeds itself. Still declined, each by a named guard: no tvm-ffi front door, a plan that wants workspace, a norm2 output (its post-kernel sqrt_ is a device operation the engine does not own), and a multi-GEMM with no dense output -- the two multi-GEMM launchers read the batch off cs[0] where the single-GEMM one branches on output_specs, and declining keeps one clean rule in the table instead of that quirk. `device` moves into the recipe alongside `workspace_bytes`, so the only thing the closure captures that is not a table entry is the kernel itself. Tests: test_execute_recipe.py runs all six flavors through both paths and requires the same numbers. A reduction output is compared at the noise floor instead of bit-exactly, because its taps land through cross-CTA float atomics -- measured, the same path run six times spreads 0.0049 while the two paths differ by 0.00024. test_public_execute_flavors.py gains aux, two-output and multi-GEMM cases, so the newly-lowered flavors are exercised through graph.execute() and not only through the direct call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not have An aux operand whose buffer rank differs from the graph's declaration was refused where the backend accepts it. Reported for a rank-1 bias, but it is not rank-1 specific: `_reshape_aux_to_fake` asked for the rank with `getattr(t, "ndim", <the fake's rank>)`, and the pack's `OperandBuffer` carries no `ndim` -- `__len__` is the first EXTENT, not the rank. The default therefore meant "already matches", so EVERY aux arriving through `graph.execute()` skipped the reshape. The existing public aux test passed only because a rank-3 bias needs no reshape. Same graph, same data, only the handed-over buffer's shape differs: bias declared [1,1,N], given (1,1,N) backend OK frost OK bias declared [1,1,N], given (1,N) backend OK frost ValueError bias declared [1,1,N], given (N,) backend OK frost ValueError That is the rule this branch already settled for operand axis order, applied to rank: the descriptor defines the tensor and the variant pack supplies only a pointer. So the test is a differential against the backend across all three ranks, and `OperandBuffer` grows an `ndim` so the next `getattr` for one cannot silently take a default instead. Also adds the block-scale evidence the recipe claim needs: an nvfp4 lowered-vs -interpreted differential for one and two GEMMs (the two-GEMM case additionally asserting `shared_layout`, which only it populates), and an nvfp4 matmul through the public `graph.execute()` that asserts the plan really took the lowered path. Block scale is the flavor with the most per-call table in it -- its scale factors ride in the launch argument list but not in `problem_size`, and their blob size is re-synthesized from M/N/K rather than read off the buffer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interpreter is migration scaffolding: the fast path hands it anything it is not certain of. That makes "no legal call needs the interpreter" the claim the whole two-track structure rests on, and until now it was only an assertion. The day the fallback is deleted is the day an unnoticed dependency on it becomes a regression. So both halves of "why not the fast path" are named data now: - `compiled.declined` says which rule denied this graph a fast path at build -- "needs workspace", "post-kernel sqrt", "multi-GEMM without a dense output", "no tvm-ffi front door", "scale factors without a block size", "declared layout is indistinguishable from the kernel's" -- instead of a bare None. - `compiled.deferrals` counts the eight per-call sites, by reason. Incremented only on the path that is already giving up, so the fast path pays nothing. The counts separate two populations that reading the code does not. Six of the eight sites mean the CALL IS ILLEGAL -- wrong major, a TMA-misaligned extent, a misaligned output base, an SF blob that is not a dense run -- and exist only so the interpreter can produce the message. The other two are calls that are perfectly LEGAL and that the fast path cannot serve: an operand described from the graph (a bare device address, or a buffer that reports the declaration), and a strided reduction output, which needs a fill the engine does not own. Only the second population is a reason to widen the plan; the first never will be. Three tests: every flavor's legal call defers for nothing, a decline names its rule, and a bare address is counted under exactly that reason and no other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A call the fast path was unsure of went to an interpreter that ran it anyway, so "the fast path refused a legal call" was a performance bug at worst and nothing asserted otherwise. The deferral counters from 98578b5 split the eight hand-over sites into two populations that reading the code does not: six mean the call is illegal and only need a message, two meant it was legal and the loop could not serve it. Both of the second are gone. An operand in the GRAPH's axis order. cuDNN declares a matmul's B [b, K, N] where this kernel reads (b, N, K), so a bare device address -- and any buffer reporting the declared (dim, stride) -- arrived the other way round and paid a whole interpreted pass for being legal. `lowered` re-labels it with one permute whose shape the recipe already carries, before any check runs, so everything below still reads one order. Measured on the plain flavor: 19.95 -> 19.76 us, i.e. the pre-pass is free on a call that does not use it. That also retires the "declared layout is indistinguishable from the kernel's" build-time decline, which existed because the stride guard alone cannot tell the two orders apart at a unit extent. Comparing the full (dim, stride) can. A strided reduction seed. fill_word_strided_async collapses the layout to the runs a memset can cover and issues cuMemsetD2D32Async, which takes a pitch: one call per remaining outer point (the batch, usually 1) rather than one per row, which is the reading that made this look expensive. It was the last place the engine wrote through a buffer it does not own -- tensor.fill_() works only while the caller passes a torch tensor, and queues on torch's stream rather than the one the kernel will run on. Three build-time declines also kept launch alive as an executor: no tvm-ffi front door, a norm2 output, a multi-GEMM with no dense output. They are _check_executable now, called from probe_supported, so the ENGINE declines the graph and it goes to the backend -- where it would have gone had FROST never been asked. _lower reads that same function instead of restating it. Then launch loses its second half. explain() runs the same recipe.problem / check_shapes / check_alignment and raises; _call_positional, _call_multi_gemm, _call_block_scale_multi_gemm and launch's assembly are deleted, 267 lines. MoE is untouched -- it is >= 2 launches with its own workspace and no recipe, which is why _maybe_wrap_layout, _wrap_raw_tensor, _initialize_reduction_outputs and _finalize_reductions all survive. The invariant tightens from "the fast path may accept a subset" to: the set of calls the launch path refuses must EQUAL the set of illegal calls. `deferrals` enforces it -- a legal call of every flavor must leave it empty, asserted per flavor and for the bare-address form. Refusing a legal call is now a bug. Execution stops being duplicated but the RULES are still written twice, fused in lowered's guards and readable in explain. If they drift, a call is refused and the checker finds nothing wrong, so explain falling off the end raises a distinct RuntimeError naming which guard fired. The lowered-vs-interpreted differential goes with the interpreter. Two readings of the same wrong plan agreeing proves nothing; that is exactly how the axis-order bug survived one. What replaces it is fast-vs-BACKEND, already here, plus the per-flavor torch references in test_public_execute_flavors.py, which run through the public graph.execute() rather than beside it. All six flavors still lower and their host cost is unchanged: plain 19.76, epilogue 19.69, aux 22.26, 2 outputs 22.65, reduction 26.12, multi-gemm 22.54 (min over 25 reps of a 64-call burst, 256x256x128 bf16, SM100). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three are the same shape: a per-ROLE fact treated as a per-SLOT one, or a write issued before every rule has had its say. Found by review on the pushed head, not by the suite -- each is recorded here with the test that now pins it. matmul(A, A) binds ONE slot as both operands: recipe.build keys the operand map on id(tensor), so the A role and the B role share an index. The graph-order pre-pass re-labelled that slot in place, so permuting it for B also handed A a transposed view of the same memory -- wrong numbers, no guard fired, and the checker then correctly reported that no rule explained the refusal. `lowered` now builds one view per ROLE and never rewrites a slot; `shared_layout` moved to role positions for the same reason. The extra list costs nothing measurable. The reduction seed wrote before the launch could reject the call. A fp32 tap bound to a fp16 buffer took a 32-bit word times numel and ran past the end of the allocation; the tvm-ffi front door does refuse the dtype, but only after the fill. Two taps could also leave the first seeded and the second refused. And the overlap rule was too weak -- `pitch >= width` accepts shape (2, 2) stride (2, 2), whose two axes land on the same element. So the fill is planned before it is issued: strided_fill_plan returns a verified plan or None, the element width is a rule of the call, and every seed is planned before any is written. Two fast guards had no matching rule in the checker: block-scale shared_layout, and a rank-2 operand, which additionally made recipe.problem raise IndexError from an axis index rather than a message. Both now have one, and problem checks the rank before it indexes anything. A multi-GEMM with no dense output is supported again rather than declined. The decline was written when two multi-GEMM launchers read the batch off cs[0]; those launchers are deleted, the recipe already carries a batch for the case, and it is a flavor in the test table now. That makes norm2 the only capability this branch removes, which is what the PR claims. The invariant claim was too strong and is corrected in the code, the tests, the handoff and the PR: `deferrals` empty on a legal call proves nothing legal is REFUSED. It cannot prove nothing illegal is ACCEPTED, because an accepted call never reaches the counter. That direction is covered case by case and by the backend differentials; closing it properly means generating the fused guards and the readable diagnostics from one ordered list of checks, which is still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things review found on the pushed head. A scale-factor operand had no rank check. Its blob rules -- a 16-byte base, one dense run, at least the size the template re-synthesizes from M/N/K -- all pass for a flat rank-1 blob, and it then reached permute(1, 2, 0) in the launch argument list, because build() puts scale factors in `heads` alongside the inputs. So it raised from inside the body whose whole contract is that it does not raise, and the checker could not name a cause. Both sides answer it now, which makes it an ordinary rejection with a reason. Two documents still described the interpreter this branch deletes. docs/python_graph_and_execution_backends.md and python/cudnn/frost/README.md both said the lowered path hands what it is unsure of to an interpreting path that "serves every flavor and owns every rejection message". They now say what is actually there: one launch, a checker that names a rule and raises without running anything, and a graph the closure cannot serve declined when the engine is asked to support it. Both also say why a reference executor kept for diagnostics is not the cheaper option -- it is a second answer to what the graph computes, and a differential between two readings of one plan cannot catch a misconception they share. The docstring edit in the three linear-attention kernels left a 103-character line. It is a paragraph break now, which is what the rest of those docstrings do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5dfa3c1 to
1667f73
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
test/python/gemm/frost/test_execute_recipe.py (1)
449-452: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOptional: clear the Ruff hints in this file.
Line 451 unpacks
cand never uses it (RUF059). Lines 98, 121, 140, 148, 238, 317, and 536 take a single element through a list comprehension and an index (RUF015). Confirm whether Ruff gates CI fortest/python; if it does, these fail the lint job.♻️ Proposed fix for line 451
def _padded_rows(): """Legal: the outer stride is free, only the contiguous extent is pinned.""" - a, b, c = _operands() + a, b, _ = _operands() return a, b, torch.empty(1, M, N * 2, dtype=torch.bfloat16, device="cuda")[:, :, :N]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/python/gemm/frost/test_execute_recipe.py` around lines 449 - 452, Clear the Ruff warnings in test_execute_recipe.py: update _padded_rows to avoid binding the unused c value, and replace the single-element list comprehensions at the referenced locations with direct element access. Confirm whether Ruff gates test/python in CI and ensure these changes satisfy that lint configuration.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/python_graph_and_execution_backends.md`:
- Around line 149-178: Align the staged optimization figures in the “What a
per-execute path costs” section with the headline baseline: make the first-step
numbers start at the stated 40 µs baseline, or explicitly explain why the 44 µs
measurement differs. Ensure the sequence maps consistently to the reported 20 µs
target.
In `@test/python/gemm/frost/test_execute_recipe.py`:
- Around line 674-708: Raise the test level for
test_a_degenerate_extent_is_refused_or_matches_the_backend above L0 so its full
16-case parameter sweep is not included in the fast tier. Preserve the existing
parameterization and test behavior.
---
Nitpick comments:
In `@test/python/gemm/frost/test_execute_recipe.py`:
- Around line 449-452: Clear the Ruff warnings in test_execute_recipe.py: update
_padded_rows to avoid binding the unused c value, and replace the single-element
list comprehensions at the referenced locations with direct element access.
Confirm whether Ruff gates test/python in CI and ensure these changes satisfy
that lint configuration.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 73b6fb8d-97c9-43fc-aca9-50ce1df67be7
📒 Files selected for processing (8)
docs/python_graph_and_execution_backends.mdpython/cudnn/frost/README.mdpython/cudnn/gemm/frost/compiler.pypython/cudnn/gemm/frost/recipe.pypython/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.pypython/cudnn/linear_attention/frost/kernel/gdn_bprop_f16.pypython/cudnn/linear_attention/frost/kernel/kda_prefill_f16.pytest/python/gemm/frost/test_execute_recipe.py
🚧 Files skipped from review as they are similar to previous changes (6)
- python/cudnn/linear_attention/frost/kernel/gdn_bprop_f16.py
- python/cudnn/linear_attention/frost/kernel/kda_prefill_f16.py
- python/cudnn/linear_attention/frost/kernel/gdn2_prefill_f16.py
- python/cudnn/frost/README.md
- python/cudnn/gemm/frost/compiler.py
- python/cudnn/gemm/frost/recipe.py
| #### What a per-execute path costs | ||
|
|
||
| An engine owns its internals, and this section does not change that. It exists | ||
| because the default outcome is expensive: an engine that re-derives its per-call | ||
| facts lands around **40 µs of host time per execute**, and for a single-kernel | ||
| op that is most of what the caller pays. The same kernel with those facts read | ||
| once is **20**. Both numbers are `frost_gemm` at 256×256×128 bf16, host | ||
| enqueue, min over 25 reps of a 64-call burst from a drained queue. | ||
|
|
||
| The budget it has to fit in, all measured on SM100: | ||
|
|
||
| | | µs | | ||
| |---|---| | ||
| | `cuLaunchKernelEx`, untraced | 1.85 | | ||
| | one CuTe-DSL entry | ~3.6 | | ||
| | `graph.execute()` entry + `_normalize` | ~8 | | ||
| | **everything else is the engine's** | | | ||
|
|
||
| Do not read a per-call cost out of an nsys trace: CUPTI adds ~2.2 µs per traced | ||
| API call, which is more than the call. | ||
|
|
||
| **Split the facts by when they are decided.** Operand roles and majors, packing | ||
| factors, alignment requirements, output shape rules, which outputs need a seed — | ||
| all fixed when the kernel compiled. M/N/K, strides and pointers arrive per call. | ||
| Read the first set into a table at build (`gemm/frost/recipe.py` is the worked | ||
| example) and let the call read the table. That alone is 44 → 35. | ||
|
|
||
| **Then lower the table into one closure per plan**, with its constants captured | ||
| and the operand structure flattened into the loop headers, so the call does no | ||
| attribute lookup and takes no branch the build already settled. That is 35 → 20. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Align the stated per-execute baseline.
Lines 153-155 give the re-deriving baseline as 40 µs and the target as 20 µs. Line 174 gives the first step as "44 → 35". The staged numbers (44 → 35 → 20) do not start from the headline number, so a reader cannot map the steps onto the summary. Use one baseline in both places, or state why the two measurements differ.
📝 Proposed wording fix
-Read the first set into a table at build (`gemm/frost/recipe.py` is the worked
-example) and let the call read the table. That alone is 44 → 35.
+Read the first set into a table at build (`gemm/frost/recipe.py` is the worked
+example) and let the call read the table. That alone is 40 → 35.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| #### What a per-execute path costs | |
| An engine owns its internals, and this section does not change that. It exists | |
| because the default outcome is expensive: an engine that re-derives its per-call | |
| facts lands around **40 µs of host time per execute**, and for a single-kernel | |
| op that is most of what the caller pays. The same kernel with those facts read | |
| once is **20**. Both numbers are `frost_gemm` at 256×256×128 bf16, host | |
| enqueue, min over 25 reps of a 64-call burst from a drained queue. | |
| The budget it has to fit in, all measured on SM100: | |
| | | µs | | |
| |---|---| | |
| | `cuLaunchKernelEx`, untraced | 1.85 | | |
| | one CuTe-DSL entry | ~3.6 | | |
| | `graph.execute()` entry + `_normalize` | ~8 | | |
| | **everything else is the engine's** | | | |
| Do not read a per-call cost out of an nsys trace: CUPTI adds ~2.2 µs per traced | |
| API call, which is more than the call. | |
| **Split the facts by when they are decided.** Operand roles and majors, packing | |
| factors, alignment requirements, output shape rules, which outputs need a seed — | |
| all fixed when the kernel compiled. M/N/K, strides and pointers arrive per call. | |
| Read the first set into a table at build (`gemm/frost/recipe.py` is the worked | |
| example) and let the call read the table. That alone is 44 → 35. | |
| **Then lower the table into one closure per plan**, with its constants captured | |
| and the operand structure flattened into the loop headers, so the call does no | |
| attribute lookup and takes no branch the build already settled. That is 35 → 20. | |
| #### What a per-execute path costs | |
| An engine owns its internals, and this section does not change that. It exists | |
| because the default outcome is expensive: an engine that re-derives its per-call | |
| facts lands around **40 µs of host time per execute**, and for a single-kernel | |
| op that is most of what the caller pays. The same kernel with those facts read | |
| once is **20**. Both numbers are `frost_gemm` at 256×256×128 bf16, host | |
| enqueue, min over 25 reps of a 64-call burst from a drained queue. | |
| The budget it has to fit in, all measured on SM100: | |
| | | µs | | |
| |---|---| | |
| | `cuLaunchKernelEx`, untraced | 1.85 | | |
| | one CuTe-DSL entry | ~3.6 | | |
| | `graph.execute()` entry + `_normalize` | ~8 | | |
| | **everything else is the engine's** | | | |
| Do not read a per-call cost out of an nsys trace: CUPTI adds ~2.2 µs per traced | |
| API call, which is more than the call. | |
| **Split the facts by when they are decided.** Operand roles and majors, packing | |
| factors, alignment requirements, output shape rules, which outputs need a seed — | |
| all fixed when the kernel compiled. M/N/K, strides and pointers arrive per call. | |
| Read the first set into a table at build (`gemm/frost/recipe.py` is the worked | |
| example) and let the call read the table. That alone is 40 → 35. | |
| **Then lower the table into one closure per plan**, with its constants captured | |
| and the operand structure flattened into the loop headers, so the call does no | |
| attribute lookup and takes no branch the build already settled. That is 35 → 20. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/python_graph_and_execution_backends.md` around lines 149 - 178, Align
the staged optimization figures in the “What a per-execute path costs” section
with the headline baseline: make the first-step numbers start at the stated 40
µs baseline, or explicitly explain why the 44 µs measurement differs. Ensure the
sequence maps consistently to the reported 20 µs target.
| @requires_sm100 | ||
| @pytest.mark.parametrize("batch", (1, 2), ids=("b1", "b2")) | ||
| @pytest.mark.parametrize("m,n,k", [(m, n, k) for m in (1, 128) for n in (1, 128) for k in (1, 128)], ids=str) | ||
| def test_a_degenerate_extent_is_refused_or_matches_the_backend(monkeypatch, batch, m, n, k): | ||
| """An extent of 1 leaves its axis's stride free, so two majors can look alike. | ||
|
|
||
| ``(batch, M, 1)`` k-major carries stride 1 on axis 1 AND axis 2, and nothing | ||
| in the description says which one the kernel should read as K. What keeps | ||
| that from mattering is the TMA rule: the contiguous extent must divide | ||
| ``128 // bits``, whose smallest value is 4, and 1 divides none of them -- so | ||
| a unit contiguous extent never reaches a launch. This asserts the property | ||
| that argument implies rather than the argument: every degenerate shape is | ||
| either refused or agrees with the backend. | ||
| """ | ||
| monkeypatch.setenv("CUDNN_FRONTEND_ENABLE_FROST_ENGINES", "1") | ||
| torch.manual_seed(0) | ||
| a = torch.randn(batch, m, k, dtype=torch.bfloat16, device="cuda") | ||
| b = torch.randn(batch, n, k, dtype=torch.bfloat16, device="cuda") | ||
|
|
||
| got = {} | ||
| for want_frost in (False, True): | ||
| c = torch.zeros(batch, m, n, dtype=torch.bfloat16, device="cuda") | ||
| try: | ||
| # None = no plan of that kind. For FROST that IS a refusal, and the | ||
| # one every K == 1 shape takes: the graph-time gate applies the same | ||
| # TMA rule, so the degenerate contiguous extent never gets a plan. | ||
| ran = _matmul_on(batch, m, n, k, want_frost, a, b, c) | ||
| got[want_frost] = "refused" if ran is None else ran | ||
| except (ValueError, NotImplementedError, cudnn.cudnnGraphNotSupportedError): | ||
| got[want_frost] = "refused" | ||
| if got[False] == "refused": | ||
| pytest.skip("the backend has nothing to compare against for this shape") | ||
| if got[True] == "refused": | ||
| return # refusing is always allowed; computing something else is not | ||
| torch.testing.assert_close(got[True].float(), got[False].float(), atol=2e-1, rtol=2e-2) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Move this sweep above L0.
The module marks every test L0 at line 39. This case runs 16 combinations (2 batches x 8 shape triples), and each combination builds backend plans and FROST plans and executes both. That is a large parameter sweep in the fast tier.
Mark this test at a higher level, or reduce the L0 set to a few representative degenerate shapes and move the full sweep up.
📝 Proposed level override
`@requires_sm100`
+@pytest.mark.L2
`@pytest.mark.parametrize`("batch", (1, 2), ids=("b1", "b2"))
`@pytest.mark.parametrize`("m,n,k", [(m, n, k) for m in (1, 128) for n in (1, 128) for k in (1, 128)], ids=str)
def test_a_degenerate_extent_is_refused_or_matches_the_backend(monkeypatch, batch, m, n, k):As per coding guidelines: "Mark every new Python test with a level from L0 through L4; keep L0 tests fast and place large parameter sweeps at higher levels."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/python/gemm/frost/test_execute_recipe.py` around lines 674 - 708, Raise
the test level for test_a_degenerate_extent_is_refused_or_matches_the_backend
above L0 so its full 16-case parameter sweep is not included in the fast tier.
Preserve the existing parameterization and test behavior.
Source: Coding guidelines
|
@cudnn-ci-bot run frost |
|
🚀 Running mirror pipeline Branch: cudnn-gh/pr-559-1667f73 |
#558 has landed, so this is no longer stacked — the diff is this PR alone.
Rebased onto
eb43b2c73. Clean: the three files both sides touched turned out to be disjoint regions, and #566's diff has no lines inside the execute path this PR rewrites. #566 is orthogonal to the recipe — every catalog entry keepsnum_mma_m == 1, soselect_confignever picks the new geometries, and what the recipe reads off the compiled object (use_tma_store,vec_bytes_epi, the alignment requirements) follows automatically.What
A compiled gemm's per-call path was re-deriving, on every execute, facts that a
runtime value cannot change: which axis of each operand carries M/N/K, each
major, the fp4 packing factor, every output's required alignment and its shape
rule, which outputs are reductions, and what order the kernel takes its
parameters in. This reads them once into
gemm/frost/recipe.pywhen the kernelcompiles, and
_lowercaptures that table into the closure that launches —which by the end of this PR is the only thing that launches at all.
256×256×128 bf16, host enqueue, min over 25 reps of a 64-call burst from a
drained queue:
The "before" column is this branch's own earlier state, where only the plain
single-GEMM shape lowered. Against
developevery row starts at 42–50.Reduction sits ~4 µs above the rest because it still pays one
cuMemsetD32Asyncper tap; that goes when the kernel seeds itself.The field that made six flavors one path
What actually differed between plain, aux, multi-output, multi-GEMM and block
scale was which buffers the launch passes and in what order, so that is data
now:
arg_plan, one(operand index, aux ref)per positional argument afterproblem_size. Three smaller fields carry the rest —stride_ins(whosepermuted strides ride in
problem_size),shared_layout(block-scalemulti-GEMM collapses its A operands to one stride triple and requires the others
to match),
seeds(a reduction's identity, packed as its output dtype).Before that, each flavor had its own launcher assembling its own argument tuple,
which is why the emitted path served exactly one of them.
Loop, not unrolled — and the measurement behind that
The first version of
_lowerhand-wrote the plain flavor's checks as a straightline with the structure unrolled. Measured three ways on the same plan and
buffers in one process, all bit-exact:
The loop is worth 42–49%; unrolling adds 12% on top and costs one closure body
per operand shape. Six flavors would have been six bodies to keep in agreement,
so the unrolled body is deleted. Source codegen off this same table is how to
buy the 12% back later, for every flavor at once rather than for the one that
was worth hand-writing.
One executor, and a checker beside it
The first version of this kept an interpreter behind the lowered path: anything
it was unsure of went to
launch, which ran the call anyway. That made "thefast path refused a legal call" a performance bug at worst, with nothing
asserting otherwise — so the last commit closes it.
The deferral counters split the eight hand-over sites into two populations that
reading the code does not. Six mean the call is illegal (wrong major, a
TMA-misaligned contiguous extent, a misaligned output base, an SF blob that is
not a dense run) and only ever needed a message. Two meant the call was legal
and the loop could not serve it, and both are gone:
[b, K, N]wherethe kernel reads
(b, N, K), so a bare device address — and any bufferreporting the declared
(dim, stride)— arrived the other way round and paida whole interpreted pass for being legal. It is re-labelled with one permute
whose shape the recipe already carries, before any check runs, so everything
below still reads one order. Plain gemm 19.95 → 19.76 µs: free on a call that
does not use it.
fill_word_strided_asynccollapses the layoutto the runs a memset can cover and issues
cuMemsetD2D32Async, which takes apitch — one call per remaining outer point (the batch, usually 1) rather than
one per row. It was the last place the engine wrote through a buffer it does
not own:
tensor.fill_()works only while the caller passes a torch tensor,and queues on torch's stream rather than the one the kernel will run on.
Build-time declines also kept
launchalive as an executor — no tvm-ffi frontdoor, a
norm2output. Those are_check_executablenow, called fromprobe_supported, so the engine declines the graph and it goes to thebackend, which is where it would have gone had this engine never been asked.
_lowerreads that same function rather than restating it.A multi-GEMM with no dense output was declined too and is supported again: the
decline existed because two multi-GEMM launchers read the batch off
cs[0],those launchers are deleted, and the recipe already carries
max(a_batch, b_batch)for the case. It is a flavor in the test table now.Then
launchloses its second half.explain()runs the samerecipe.problem/check_shapes/check_alignmentand raises;_call_positional,_call_multi_gemm,_call_block_scale_multi_gemmandlaunch's assembly are deleted — 267 lines. MoE is untouched: it is ≥ 2launches with its own workspace and no recipe.
The invariant tightens from "the fast path may accept a subset" to:
compiled.deferralsgets one direction of that and only one: a legal call ofevery flavor must leave it empty, asserted per flavor and for the bare-address
form, so nothing legal is refused. It says nothing about the other direction,
because a call the fast path ACCEPTS never reaches the counter — that is covered
case by case in
test_the_launch_path_accepts_exactly_the_legal_callsand bythe backend differentials. Closing it properly means generating the fused guards
and the readable diagnostics from one ordered list of checks; they are still
written twice by hand, and that is the open item.
Execution is no longer duplicated but the RULES still are, fused in
lowered'sguards and readable in
explain. If they drift, a call is refused and thechecker finds nothing wrong — so
explainfalling off the end raises a distinctRuntimeErrornaming which guard fired. That guard has already earned itsplace: it is what surfaced the shared-operand bug below, which would otherwise
have been a silent wrong answer.
Four defects review found in this, three of them one shape — a per-ROLE fact
treated as a per-SLOT one, or a write issued before every rule had its say:
matmul(A, A)binds ONE slot as both operands (recipe.buildkeys theoperand map on
id(tensor)). Re-labelling that slot for the B role alsotransposed what the A role read.
loweredbuilds one view per role nowand never rewrites a slot;
shared_layoutmoved to role positions for thesame reason.
tap on a fp16 buffer took a 32-bit word × numel and ran past the allocation —
the front door refuses the dtype, but after the fill. Two taps could also
leave the first seeded and the second refused. And
pitch >= widthis theinnermost pair only: it accepts
shape (2,2) stride (2,2), whose two axesland on the same element. So the fill is planned before it is issued, the
element width is a rule of the call, and every axis must clear the span of the
one below it.
shared_layout,and a rank-2 operand, which additionally made
problemraiseIndexErrorfrom an axis index. Both have one, and
problemchecks the rank first.build()puts scale factors inheadsalongside the inputs, so they reachpermute(1, 2, 0)in the launchargument list — and the blob rules (16-byte base, one dense run, the size the
template re-synthesizes) all pass for a flat rank-1 blob. It raised from
inside the body whose contract is that it does not raise, and the checker
could not name a cause. Same shape as the rank hole above: a rule reading
three named axes off a buffer whose rank nothing had established. Inputs and
outputs were covered; scale factors were the head left over.
What a differential does not buy, and why the interpreted one is gone. It
catches divergence, never a misconception the two paths share — which is how the
axis-order bug below survived one, with both paths reading
Operand.axesandboth agreeing and both wrong. Two readings of the same wrong plan agreeing
proves nothing. What replaces it is fast-vs-backend
(
test_a_degenerate_extent_is_refused_or_matches_the_backend,test_an_operand_reporting_the_declaration_agrees_with_the_backend) plus theper-flavor torch references in
test_public_execute_flavors.py, which runthrough the public
graph.execute(). When a check discriminates between twoencodings, test it at the shape where the encodings coincide.
Two earlier divergences are what that drift looks like, both found by review
rather than by test: the hand-written path dropped the operand batch check, and
pinned an fp4 output's last axis at N where the graph says N/2. Both are single
facts in the recipe now (
Operand.batch,Output.rule).What it fixes
test_bare_address_operandsxfail isgone). cuDNN declares a matmul's B as
[batch, K, N]while a caller allocatesit
(batch, N, K)— the same memory, two axis orders, and at N == K the same(shape, stride)tuple. The tie-break is the backend's own rule: thedescriptor defines the tensor and the pack supplies only a pointer, so a
buffer that reports the declaration is read as the declaration. Measured, that
agrees with the backend to bf16 tolerance where the other reading differs by
65. This also fixes a pre-existing divergence — with FROST enabled,
test_every_variant_pack_form_still_worksfails all four forms ondevelop.contiguous_modulus), read byboth the graph-time gate and the per-call one. It was the one number with a
packing factor in it, and so the one most likely to drift.
Where the rest of the win came from
_check_plan_devicedid itsfrom cudnn.frost.device import current_deviceper call: 1.1 µs of the 1.7 that function took. Moved to module scope.
tensor_alignment's layout half is memoized on(shape, stride, elem_bytes, cap)— a function of values, with nothing to invalidate. The pointer half isstill computed per call.
{id(tensor): buffer}and looking each operand back up.Naming
slotwas doing two jobs — an int POSITION and the OBJECT at it — so neitherread.
pack.slot(t)→pack.index_of(t),pack.views(...)→pack.operands(...), and the C++VariantPackSlot→OperandBuffer(with itsset_slot/read_slot/… accessors renamed to match).What it does not do
a
norm2output. The graph goes to the backend rather than being compiledinto a kernel only a second executor could run.
norm2is the one capability this removes, and it was direct-call only.Its taps land through cross-CTA atomics, so the square root cannot go in the
epilogue — it has to run after every CTA has contributed, which is a second
launch. Nothing public could reach it anyway: the backend refuses a norm2
reduction descriptor at
build_operation_graph, so noexecute()ever gets aplan for one (that is
test_norm2_reduction_is_refused_at_build, alreadyhere). The two
test_reduction_mode_coveragecases that computed it bycalling the compiled object with torch tensors are now one test asserting the
decline and saying why.
CompiledMoeGemmis a separate class with norecipe; it is ≥ 2 launches with its own workspace and a different problem,
which is why
_maybe_wrap_layout,_wrap_raw_tensor,_initialize_reduction_outputsand_finalize_reductionsall survive.override_shapesthat changes it is refused. The kernel does not need that —
matmul_a_batch/matmul_b_batchreach the template only inside acutlass.const_expr(… == 1),so what is baked is the broadcast predicate and not the value.
developpinsit the same way, so relaxing it is a widening, not a regression to fix here.
Test
test/python/gemm/froston the tip: 5768 passed / 2860 skipped / 0 failed(4212 before the rebase — #566 added its
num_mma_m=2configs to the testtables).
pytest test/python -q -n 8 --ignore=test/python/test_mhas.pyon SM100 (B200):89 failed, 15829 passed, 7123 skipped, and zero failures anywhere under
test/python/gemm/— the directory this PR changes.Pin
CUDA_VISIBLE_DEVICESto the Blackwell for the full run: unpinned, xdistspreads across every GPU on the box and lands most of it on an L40S, which gives
238 failures by 68% and reads exactly like a large regression.
All 89 are pre-existing and outside this PR's surface: 87 are numerical
tolerance failures in the cuteDSL OSS kernels (
fe_api/gemm/test_gemm_swiglu64,fe_api/sdpa/test_sdpa_bwd12,fe_api/test_rubin_kernel_dispatch9,fe_api/dsa/test_DSA_indexer_top_k2) and 2 aretest_mxfp8_quant::test_te_parityfailing on a missing
transformer_engine. Established rather than assumed: thosetests call
GemmSwigluSm100.executeand friends directly, reference nopygraph/variant_pack/_normalize, and an import trace over three of themshows no
cudnn.gemm.frost.*module is ever loaded.test/python/test_mhas.pyis excluded on the maintainer's instruction —test_mhas_v2is the SDPA suite that counts. Worth recording why: the old filewrites shared pytest config state mid-test (
request.config.option.left_bound = None,...dropout = None) which every later test in the same xdist worker readsback as its own parameters, so its outcome depends on ordering and worker
assignment. Three runs of one unchanged commit gave 331 / 155 / 173 failures, all
from that file.
One tolerance is not bit-exact and the reason is measured: a reduction output is
not bit-reproducible against itself, because its taps land through cross-CTA
float atomics. The same path run six times spreads 0.0049 while the two paths
differ by 0.00024, so that comparison is at the noise floor rather than at 0.
note to self: claude::774e8e99-23ad-4a94-be0d-53ed5ee4def9 — "cuDNN FE variant-pack normalization"
cwd /home/scratch.yanxu_libs/cudnn_frontend · workspace /home/scratch.yanxu_gpu/fe_pr1