Emit the FROST gemm parameter tables, and a demo that launches from them - #582
Emit the FROST gemm parameter tables, and a demo that launches from them#582YangXu1990uiuc wants to merge 7 commits into
Conversation
FROST codegen decides, per kernel parameter, how wide it is and where its
value comes from -- and then throws both away: the signature is a string
join and the values are unpacked positionally by the generated host. A
caller that wants to marshal the parameter block itself has to rediscover
all of it.
Emit it instead, as data appended to the generated module:
SLOT_TABLE per parameter: name, kind, width, source. Enough to build
the device parameter block from scratch.
PATCH_GROUPS the transpose, plus the two things a per-slot view cannot
carry -- the descriptors a quantity was built into, and
the grid axis it sizes.
PROBLEM_FIELDS names each problem_size position, so a key is self-
describing.
Both tables are read off the signature just rendered rather than rebuilt
alongside it, so they cannot drift from the kernel they describe. A
parameter codegen cannot classify is emitted as kind 'unknown' with a null
source and a consumer must refuse the kernel; a guess would be worse than
the refusal, and this is what makes the mechanism safe to extend one
flavor at a time.
Output taps become cute.Pointer. They were cute.Tensor, which costs a
16-byte slot carrying the address plus an extent the kernel already takes
as a parameter -- the tap only ever needs the address.
benchmark/gemm/frost/benchmark_bare_launch.py is the demonstration: it
builds the parameter block from SLOT_TABLE, takes the kernel out of the
compiled cubin, computes the geometry from the generated module's own
closed form, and launches -- checking bit-identity against graph.execute()
at every step. Nothing is harvested from a captured launch. On SM100,
M=N=256 K=128 bf16: 19.6 us of host time per call becomes 2.4, against
2.1 for the bare cuLaunchKernelEx.
--vary-m serves a second token count from the same built block, driven by
PATCH_GROUPS: three fields move, one descriptor is re-encoded, gridDimX
changes, and the result is bit-identical to a plan built at that M.
The contract this trades on is documented in docs/frost_bare_launch.md and
never checked: between build and launch, only the addresses may change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Test result
That is the suite that covers the tap CUDNN_FRONTEND_ENABLE_FROST_ENGINES=1 CUDA_VISIBLE_DEVICES=<sm100> \
python -m pytest test/python/gemm/frost -q -n 8 |
The demo hardcoded the operand roles it knew about -- A, B and an STG output
tap -- and built only the K-major branch of a descriptor. That is exactly the
default shape and nothing else: at almost any other M/N/K the epilogue picks
a TMA store, the output arrives as tma_c_desc_0 rather than mC_tap_0, and the
run died on KeyError: ('c', 0).
Derive the roles from SLOT_TABLE instead of listing them, and build the C
descriptor as well, mirroring the template's own branches -- including
a_is_m_major / b_is_n_major / cd_out_is_m_major, which select DIFFERENT
stride components, so taking the wrong one would encode a valid-looking
descriptor that reads the wrong memory. Stride positions come from
PROBLEM_FIELDS rather than literal indices.
Verified over six shapes covering four tile configs, three distinct dynamic
SMEM sizes (149504 / 182272 / 215040), both the tap and TMA-store epilogues,
each with --vary-m: every one bit-identical to graph.execute() and to a plan
built at the target M, and 2.36-2.44 us of host time throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shape sweepThe first push only worked at the default shape — at almost any other M/N/K the epilogue Six shapes, four tile configs, three distinct dynamic-SMEM sizes, both epilogue paths,
Every row printed Three distinct SMEM values is the interesting column: the demo recovers that number by |
The doc claimed a number without an OK next to it meant the demo failed. Two checks did not honour that: the second-buffer-set rebind and the --vary-m result both printed MISMATCH and then carried on to print timings and exit 0. A fast number that was also wrong could reach the reader. Every check now returns non-zero at the first mismatch, and prints the max|diff| that made it fail. The doc says what is now true: the script exits non-zero at the first mismatch, so any timing it prints was preceded by a passing check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dynamic-SMEM number, checked against ground truthThis is the one field in the demo with no first-class source, and the one a bit-identity So it is checked directly: capture a real It still deserves the upstream fix the doc asks for — a property next to |
The table computed where output strides start as 4 + 3*na + 3*nb. That is what the dense and mainloop templates do -- `_stride_idx = 4`, advanced three per A and per B operand -- but not what the other two paths do, and the table has to describe the kernel as GENERATED, not as the layout implies. Two disagreements: MoE puts num_experts and num_groups where a dense problem_size carries batch, and starts its stride triples at 5. The table labelled index 3 'batch' and read every stride one slot early. It also cannot classify a MoE kernel's operands, so every such table already carried 'unknown' and a consumer had to refuse it -- but shipping wrong indices that happen to be inert is exactly what this mechanism is supposed to avoid. MoE now gets no table. The block-scale renderer hands its host a literal 10 while its templates set supports_multi_gemm=True. For na=nb=1 that agrees with the formula; for a multi-GEMM block-scale chain it does not, and the table would have named the output strides where the operands' still are. Each renderer now passes the base it actually used, and the table refuses to describe a kernel whose operand triples do not end exactly there. Whether that literal 10 is itself a bug in the block-scale host for multi-GEMM chains is a separate question, untouched here. Re-verified: eight gemm flavors still classify with zero unknowns and widths matching cuFuncGetParamInfo; the demo is still bit-identical at M=256 and --vary-m 512. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One more, found by reading the templates rather than running themThe table computed where output strides start as MoE puts # sm100_moe_grouped_matmul_fwd_1ctamma.py
num_experts = problem_size[3]
num_groups = problem_size[4]
_stride_idx = 5So the table labelled index 3 Block-scale hands its host a literal red_host_stride_unpack = _reduction_stride_host_unpack(chain) if chain.has_moe \
else _reduction_stride_host_unpack_from(chain, 10)For Separately, and untouched here: whether that literal Re-verified after the change: eight gemm flavors still classify with zero unknowns and |
The tap `cute.Tensor` -> `cute.Pointer` change removed a check, and an A/B
against gh/develop shows what it cost. Same graph, same output shape, three
runtime layouts for the output buffer:
layout gh/develop with the change
contiguous (the declaration) accepted, correct accepted, correct
padded rows, N unit-stride rejected: alignment rejected: alignment
transposed, N stride = M rejected: CUDA misaligned
Mismatched address
c_tap_0.strides[1]
The old compile fake declared `stride=(sym_int64(), 1, sym_int64())` for an
N-major dense tap. That literal 1 is static, so the DSL's front door had
something to compare a runtime tensor's layout against, and it rejected a
mis-strided output on the host. A `cute.Pointer` carries no layout, so nothing
compares, and the epilogue's vectorised store faults on the device instead.
Not silent corruption in this case, but a strictly worse failure mode, and
nothing rules out an aligned-but-mis-strided layout that would be silently
wrong. It also fires on the ordinary `graph.execute` path, which the demo's
documented contract does not cover -- that contract is between a caller and a
bare launch, not between a caller and the public API.
So the optimisation comes out. The tables are the deliverable; the tap is a
separate change that needs its own check to replace the one it removes.
Consequences, all of them honest rather than papered over:
* `mC_tap_i` is `cute.Tensor` again and is NOT classified, so the four
STG-epilogue flavors (aux bias, two dense outputs, amax reduction,
multi-gemm) now emit a table containing `unknown` and a consumer must
refuse them. That is the mechanism working. Classifying that fake is the
obvious next increment -- codegen writes its shape and stride expressions
a few lines from where the table is built.
* The demo's default shape moves to 128x256x64, which takes the TMA-store
epilogue and so has no tap at all. The headline is unchanged: 20.3 us of
host time becomes 2.5, bit-identical, against 2.1 for the bare launch.
* The doc's claim that varying M re-encodes A alone was only ever true for
the STG flavor. Under a TMA store the output descriptor spans M too, so
`--vary-m` re-encodes A and C and leaves B alone. Corrected, along with a
SLOT_TABLE example that showed a tap and a C descriptor in one kernel,
which no kernel has.
Also drops four cubins that `git add -A` swept into an earlier commit. They
remain in this branch's history; squashing them out needs a force push.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit reverted the tap change by hand-editing the four MoE
launchers back, and I wrote what I thought the original said instead of
reading it. It said
cs = [
(_wrap_raw_tensor(ci) if (spec.is_reduction or spec.is_quant_scale) else _maybe_wrap_layout(ci, _LEADING_DIM_C))
for spec, ci in zip(outputs_spec, c_perms)
]
not `[ci.permute(1, 2, 0) for ci in c_perms]`, so every MoE launch handed the
kernel a differently-shaped tensor and 203 tests failed on
`Mismatched c_tap_0.shape[0]`.
Taken from gh/develop and re-applied additively instead: compiler.py is now
that file plus the table block and the two renderer call sites, and nothing
else. The whole diff to base is +219/-2, and the only line mentioning a
pointer is the `cute.Pointer` entry in `_PARAM_ABI`, which is a table entry
rather than a codegen change.
test/python/gemm/frost: 5767 passed, 2861 skipped, 0 failed. The demo is
bit-identical at 128x256x64 (--vary-m 512) and 1024x1024x512 (--vary-m 2048),
20.3 us of host time against 2.4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I reverted part of this PR: the tap change removed a real checkWhile sweeping the demo I had an adversarial pass read the diff, and it flagged the
The old compile fake declared Not silent corruption in this case, but a strictly worse failure mode — and nothing rules What that costs, stated honestly
That is the mechanism working rather than a gap in it, and it is the obvious next The headline is unchanged. The demo's default moves to Two doc claims corrected
And one correction to my own earlier commentI suggested the block-scale renderer's literal HousekeepingFour Final state
(An intermediate commit had me hand-write the MoE launcher lines back from memory instead |
An adversarial pass over the doc found three claims it cannot support, all of
them the same shape: evidence gathered by probes that are not in this PR,
written as though the demo produced it.
* "the demo does, and diffs it against what a real launch passed, byte for
byte" -- it does not. It builds the block from SLOT_TABLE and checks slot
WIDTHS against cuFuncGetParamInfo plus output bit-identity against
graph.execute(). The byte-for-byte diff exists, but as a separate probe.
* the 10.7 us row sat in the table directly under "Run it:", and nothing here
can produce it -- it needs the engine's launch closure monkey-patched. It
is a real measurement and worth keeping, so it now says where it came from
and that this file does not reproduce it.
* "the TMA descriptors are built by device kernels into a workspace rather
than encoded on the host" overstates linear attention: the base descriptors
are host-encoded, and only the per-(batch x head) arrays are device-built.
Also notes on _MAP_DEPS that naming a stride triple per operand index is right
only where the host sends one per operand -- block-scale sends a single shared
A and B triple whatever na and nb are, and the stride-base guard is what keeps
those chains from reaching it.
The measurement table now carries the demo's own default shape and its own
numbers rather than the shape it had before the scope narrowed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doc corrections: three claims the demo could not supportThe adversarial pass over the diff also flagged the documentation, and it was right three
The measurement table also still carried One code comment added: Nothing about the headline changes; the demo is unaffected and still bit-identical. |
Review closed: 12 confirmed findings, all addressedThe adversarial pass finished — 5 dimensions, every finding put through 2 independent
The one worth readingFindings 1–3 are the sharpest thing the review produced, and they are not the bug I I had fixed this before the review returned, but from the wrong premise — I thought the Verified by building the chain the review named rather than by arithmetic: Where that leaves coverage
Narrower than the first push claimed, and every boundary is now a refusal rather than a |
Before submitting
pre-commit runand committed any formatting changes.cat-*, one or moremod-*, and oneorig-*(see label list).Affected area
FE OSS kernels or CuTeDSL / Benchmarks or performance
Summary
Don't take my word for it — run it:
SM100 required. Every result is checked bit-identical against
graph.execute()and thescript exits non-zero at the first mismatch, so any timing it prints was preceded by a
passing check.
20.3 us of host time per launch becomes 2.4, against 2.11 for the bare
cuLaunchKernelEx. Essentially all remaining host time is the driver.Two changes:
SLOT_TABLE,PATCH_GROUPS,PROBLEM_FIELDS— into the generated module. Purely additive:compiler.pyisdevelopplus a table block and two call sites, +219/-2, and no generated kernelbehaves differently.
the compiled cubin, computes the geometry from the generated module's own closed form,
and launches.
Why
Codegen already decides, per kernel parameter, how wide it is and where its value comes
from — and then throws both away: the signature is a string join and the values are
unpacked positionally by the generated host. Anyone wanting to marshal the block has to
rediscover all of it.
The 20 us
graph.execute()spends on this gemm is not the launch. It is re-deriving, percall, facts settled when the plan was built: what shape each buffer is, which axis is
innermost, how to spell a CUtensorMap, which kernel to run.
The tables are read off the signature just rendered, not rebuilt alongside it, so they
cannot drift from the kernel they describe. A parameter codegen cannot classify comes out
as
kind == 'unknown'with a null source and a consumer must refuse the kernel — a guesswould be worse than a refusal, and that is what makes this safe to extend one flavor at a
time.
The contract
Between
build_plans()and any launch: dtype, rank, extents, strides, innermost axis andbase alignment are what the graph declared, and only the addresses may change. Never
checked — checking it is most of the 20 us. Documented in
docs/frost_bare_launch.md.An inference server keeps this without effort.
--vary-mshows the next rung, where thetoken count moves and the caller re-supplies
problem_size;PATCH_GROUPSthen prunes thework to three stores, the descriptors that span M, and
gridDimX.Scope, and how it extends
The demo is deliberately one narrow case — dense bf16 matmul, TMA-store epilogue,
SM100 — so its claims can be checked in a minute.
What is classified today, measured by compiling each flavor and comparing the table's
widths against
cuFuncGetParamInfoon the resulting cubin:unknownEvery refusal is the same parameter: an STG epilogue passes its output as
mC_tap_i: cute.Tensor, whose fake this does not model. That is the mechanism working,not a gap in it, and it is the obvious next increment — codegen writes that fake's shape
and stride expressions a few lines from where the table is built.
MoE gets no table because its
problem_sizecarriesnum_expertsandnum_groupswhere adense one carries
batch, and its operands are unclassified. A per-call stream is also notdone; that is one more store into the launch configuration.
API and compatibility impact
None.
SLOT_TABLE/PATCH_GROUPS/PROBLEM_FIELDSare new module-level constants ingenerated FROST gemm kernels; nothing else changes. The benchmark needs
CUTE_DSL_KEEP=cubinbefore the first compile (it sets it itself) and points
CUTE_DSL_DUMP_DIRat a tempdirectory so it leaves nothing behind.
Testing
On one SM100 part.
test/python/gemm/frost: 5767 passed, 2861 skipped, 0 failed.128x256x64 --vary-m 512and1024x1024x512 --vary-m 2048: bit-identical tograph.execute()and to a plan built at the target M.distinct dynamic-SMEM sizes, all bit-identical; and the SMEM number the demo recovers was
checked against what a captured launch actually passed, 8/8 shapes exact.
SLOT_TABLEand diffed against a real launch byte for byte.pre-commit/ black clean.The comments below record what changed after the first push and why, including one part of
the original PR that I reverted after an A/B showed it removed a real check.
note to self: claude::774e8e99-23ad-4a94-be0d-53ed5ee4def9 — "审计前端Python API和Frost引擎代码复杂度"
cwd /home/scratch.yanxu_libs/cudnn_frontend · workspace /home/scratch.yanxu_gpu/fe_demo