Skip to content

feat(ck-tile): make dispatcher LDS capacity budget architecture-aware - #12173

Merged
ozturkosu merged 9 commits into
developfrom
users/muozturk/ck/lds-capacity-arch-aware
Sep 17, 2026
Merged

ozturkosu merged 9 commits into
developfrom
users/muozturk/ck/lds-capacity-arch-aware

Conversation

@ozturkosu

@ozturkosu ozturkosu commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

JIRA ID : AICK-2249

Summary

The dispatcher's codegen rejects any GEMM tile whose LDS staging footprint exceeds a cap. The cap was keyed on the pipeline only and carried no architecture term, so newer targets with substantially more LDS than gfx942 were all held to gfx942's budget.

The effect is that the largest and deepest tiles — the ones most likely to win on large GEMM shapes — were never generated, never benchmarked and never selectable on those targets. On a gfx950 fp16 sweep, 160 of 288 offered configurations (56%) were rejected by this cap alone.

This PR makes the budget architecture-aware, adds gfx1250 to the spec, fixes both the Python and C++ validators together, and makes the check account for ping-pong LDS staging.

Scope: the check is not GEMM-specific. _validate_lds_capacity sits in the common validate_kernel chain, ungated by operator, and OperatorType covers 11 operators (5 GEMM variants, 6 conv variants). In Python it is reached by unified_gemm_codegen.py:1595 (universal GEMM) and unified_grouped_conv_codegen.py:1941 (grouped convolution); in C++ by Registry::filter_by_arch() (registry.cpp:160), which is operator-agnostic and covers every registered kernel. The ctypes bridge paths (quant variants, batched contraction, multi-ABD, FMHA) validate via validate_kernel_config in python/ctypes_utils.py, which has no LDS check at all, so they are unaffected either way.

Implements AICK-2249. Baseline evidence from AICK-2244.

Motivation

arch_specs.json had pipeline_lds_limits as a top-level key — a sibling of architectures, not nested inside it:

"pipeline_lds_limits": {
  "mem": <bytes>, "compv3": <bytes>, "compv4": <bytes>, ... "default": <bytes>
}

Every value was a fixed byte count, derived from gfx942 and applied to every target.

There was no architecture axis, and nowhere to put one. _validate_lds_capacity did LDS_CAPACITY_LIMITS.get(config.pipeline, ...) and nothing else.

Meanwhile arch.hpp has declared a per-architecture LDS capacity all along, via get_lds_size(). On gfx942 the hardcoded cap and that capacity coincide; on the newer targets they do not, and the shortfall is large. This PR consumes the existing declaration rather than introducing any new hardware figure.

This is the usual shape of this defect: a value that was correct when there was exactly one architecture, frozen into a schema with no slot for a second one. It survived because on gfx942 the correct answer and the hardcoded answer are the same number, so it only ever fails silently, by generating less.

The evidence that it is binding rather than theoretical: on the gfx950 sweep the cutoff falls exactly on the hardcoded cap for compv3/mem, and exactly on the tighter cap for compv4, with zero exceptions in either direction. And the winning kernels on that target were 128x256x64 and 128x128x128 under compv3 — precisely tiles that compv4 was forbidden from using. When the measured optimum sits on the constraint boundary, the constraint is probably binding.

Design note

I did not invent a new schema. A per-architecture lds_capacity_kb field already existed in arch_specs.json (gfx950 already correctly said 160), and ADDING_NEW_GPU.md already documented it as a required onboarding field. Nothing read it. Every new-GPU onboarding has been filling in a mandatory field that no code consumed — the contract was documented and unhonoured. So the fix is to honour it.

Pipelines now declare a basis rather than a byte count:

"compv3": { "basis": "fraction", "value": 1.0 },
"compv4": { "basis": "fraction", "value": 0.5 }

resolved against each architecture's capacity at generation time. A new GPU needs one number, not ten. The alternative — nesting the byte table under each architecture — would have reintroduced the same failure mode one level down: 8 arches x 10 pipelines of hand-maintained constants free to drift from arch.hpp independently.

Resolution happens in the generator, once, so the emitted Python and C++ get literal byte counts and cannot disagree about how to read the schema.

On the tighter-capped pipelines — I read the sources rather than guessing

Four pipelines sat at the tighter cap and the ticket flagged it as an open question: genuine double-buffering that should scale with capacity, or an independent absolute limit? On gfx942 the two readings are numerically identical, which is why it was never forced. The answer is not uniform across the four:

pipeline doubles LDS? evidence
compv4 yes GemmPipelineAgBgCrCompV4::GetSmemSize() returns 2 * Policy::GetSmemSize<Problem>()
preshufflev2 yes WeightPreshufflePipelineAGmemBGmemCRegV2::GetSmemSize() returns DoubleSmemBuffer ? 2 * smem_size : smem_size
compv6 no returns the policy size unmultiplied
preshufflev1 n/a no such pipeline exists; preshuffle_pipelines.supported lists only preshufflev2

This is corroborated by DOUBLE_SMEM_PIPELINES = {"compv4", "preshufflev2", "comp_async"}, which already exists in unified_batched_contraction_codegen.py and names exactly the same two.

So for compv4 and preshufflev2, half-of-capacity is the exact model, not a safety margin: the validator checks A+B against capacity/2, which is precisely equivalent to checking the real 2*(A+B) allocation against full capacity.

For compv6 and preshufflev1 the halved cap is unexplained by buffering. I preserved their ratio rather than widening them, because no evidence supports a larger budget and a blind raise that regresses is worse than the status quo. Widening those two is a separate change that needs a measurement behind it. The reasoning is recorded in arch_specs.json next to each value.

Ping-pong staging had to be threaded in

The check modelled a single staging buffer. But unified_grouped_conv_codegen.py:148 documents that mem, compv3, compv5 and compv6 make double buffering a configuration choice (--double-smem-buffer), not a property of the pipeline — and the conv path passed pipeline= into the validator without that flag, so the budget could not see it.

That was harmless while the cap matched gfx942's capacity, because twice that still fits the larger parts. Widening the budget removes the accidental headroom, so I had to close it in the same PR:

Writing C for a target's LDS capacity:

compv3 budget actually allocated vs capacity
develop gfx942's cap 2x that below C fits, by accident
arch-aware alone C 2C over C overflows
with this fix C/2 C exactly C fits exactly

So the flag is now threaded into both validators. Conv passes the value it already tracks; C++ reads algorithm.double_buffer, which registered kernels populate from SelectedKernel::DoubleSmemBuffer (kernel_registration.hpp:60).

Pipelines that always double already carry the halving in their per-pipeline budget, so the two signals are combined with a min()compv4 and preshufflev2 are never halved twice, which is what keeps gfx942 byte-identical. Independent confirmation that those are exactly the two: unified_gemm_codegen.py:1531 sets double_buffer = pipeline in ("compv4", "preshufflev2").

One deliberate non-use: KernelConfig::build_key() (kernel_config.hpp:280) and utils.hpp:676 hardcode double_buffer = true for any pipeline. Those build query keys, which never reach validate_lds — only registered-instance keys do — so the flag is trustworthy at the one site that reads it. Worth fixing separately.

A landmine worth calling out

arch_specs_generated.py already contained gfx1250 — family, warp configs, warp tile combos — but arch_specs.json, the file it is generated from, did not. The generated C++ header did not either. Three-way drift, in a file stamped AUTO-GENERATED - DO NOT EDIT DIRECTLY.

This means following the documented workflow (edit JSON, regenerate, commit) would have silently deleted gfx1250's warp tables. I transplanted them into the JSON verbatim rather than authoring anything, and the regeneration check below confirms every table came back byte-identical.

Test plan

  • New regression suite passes (15/15). Asserts budgets differ across gfx942/gfx950/gfx1250, that gfx942's budget is byte-identical to the historical table, that no budget exceeds the declared hardware capacity, that unknown targets get the smallest budget rather than the largest, and end-to-end that a large staging tile is rejected on gfx942 and accepted on gfx950
  • Double-buffer cases covered: configurable pipelines halve, 2 x budget <= capacity on every arch/pipeline pair, always-double pipelines are not halved twice, single-buffered remains the default, and end-to-end a large staging tile is accepted on gfx950 single-buffered but rejected double-buffered
  • Existing suites pass: test_arch_filter_constraints, test_gemm_utils, test_codegen_common, test_dispatcher_common, test_tile_math, test_grouped_conv_codegen, test_grouped_conv_utils
  • Generator is idempotent; regeneration leaves all non-LDS tables byte-identical (ARCH_FAMILY_MAP, WARP_SUPPORTED_COMBINATIONS, WARP_TILE_SUPPORTED_COMBINATIONS, PRESHUFFLE_WARP_TILE_SUPPORTED_COMBINATIONS, TRAIT_UNSUPPORTED_COMBINATIONS, ELEMENT_SIZE_MAP, DTYPE_COMBINATIONS, PRESHUFFLE_PIPELINES)
  • C++ compiles standalone and returns values identical to Python for every architecture and pipeline, single- and double-buffered, including the unknown-architecture fallback
  • clang-format-18 -style=file clean on both headers
  • GPU validation pending — see below

Equivalence proof

This edits a shared table, so I enumerated the validator's survivor set per architecture, before and after. The axes now include every pipeline the validators can seecomp_async and wavelet as well as the nine that were in the old table — and both settings of the ping-pong staging flag.

arch develop this PR gained lost
gfx908 4,613 7,386 +3,003 230
gfx90a 2,701 4,138 +1,595 158
gfx942 5,324 8,360 +3,322 286
gfx950 7,116 19,016 +11,900 0
gfx1100 1,155 1,870 +770 55
gfx1200 442 668 +253 27
gfx1201 442 668 +253 27
gfx1250 2,012 10,926 +8,914 0
total +30,010 783

There are losses, and an earlier version of this section claimed there were none. That claim was measured over the old parameter space, which could not contain comp_async because the pipeline had no entry in the old table to enumerate. It was true of what it measured and wrong as a general statement.

All 783 losses are comp_async, on the six architectures whose budget is unchanged, and they are intended. Attribution by pipeline across those six:

comp_async   783
(no other pipeline appears)

comp_async had no entry before, so it inherited the full-capacity default. It allocates two LDS buffers unconditionally — GetSmemSize() returns num_lds_buffers * smem_size with num_lds_buffers = 2 — so it was budgeted for twice the staging it can actually use. A tile such as conv 128x64x128 at fp16 needs 48 KB of staging and was accepted, while the kernel would then have asked the hardware for twice that. Rejecting it is the fix, not a regression.

The two architectures with a raised budget lose nothing, and no default configuration set in tree generates comp_async, so no shipped kernel disappears.

Explicitly out of scope

Two other filters in the same validation chain share this defect class (gfx942-derived constraints applied to every architecture). Neither rejected anything in the baseline campaign, so I left them alone:

  • TRAIT_UNSUPPORTED_COMBINATIONS — 10 tuples, no arch key
  • _cshuffle_store_ok — docstring says "GPU-verified on gfx942", applied to all architectures

A wider audit of the validation chain found this same pattern in several more places, including a hand-written warp-tile lookup on the native side that disagrees with the generated Python table on a majority of supported architectures. That work is tracked separately in AICK-2264 and AICK-2266, under epic AICK-2265, and is deliberately not part of this PR: it is a different table on a different axis, and unlike this change it removes configurations, so it needs its own before/after enumeration and device validation.

Also left alone: get_smem_capacity() in arch.hpp special-cases a single architecture and returns a fixed value otherwise, which disagrees with get_lds_size() for at least one target. It has three live consumers outside this ticket's scope (cshuffle_epilogue.hpp, moe_sorting_kernel.hpp, grouped_convolution_forward_kernel.hpp) and may legitimately encode a per-workgroup addressable limit rather than per-CU capacity. Flagging rather than changing it.

The codegen rejects any GEMM tile whose LDS staging footprint exceeds a
cap keyed on the pipeline alone. With no architecture term, gfx950
(160 KB of LDS) and gfx1250 (320 KB) were both held to gfx942's 64 KB,
so the largest and deepest tiles were never generated, benchmarked or
selectable on those targets.

Resolve the budget per architecture from each entry's lds_capacity_kb,
a field that arch_specs.json already carried and ADDING_NEW_GPU.md
already documented as mandatory, but that no code read. Pipelines now
declare a basis (fraction of capacity, or an absolute limit) instead of
a byte count, so a new GPU needs one number rather than ten.

compv4 and preshufflev2 keep half the capacity because they are
genuinely double-buffered: their GetSmemSize() returns twice the policy
size, so half-of-capacity is the exact model rather than a margin.
compv6 and preshufflev1 are not double-buffered; their historical
halved cap is unexplained, so the ratio is preserved rather than
widened without evidence.

Also add gfx1250 to arch_specs.json. Its warp tables already existed in
arch_specs_generated.py but not in the JSON they are generated from, so
regenerating would have deleted them; they are transplanted verbatim.

Both validators are updated together. Fixing only Python would emit
configurations that the C++ filter then rejects at dispatch.

Verified by enumerating the validator's survivor set before and after:
gfx908/gfx90a/gfx942/gfx1100/gfx1200/gfx1201 byte-identical, gfx950
+2712 and gfx1250 +2885 configurations, zero lost on any target, and
every gain attributable to the LDS check alone.
@therock-pr-bot

therock-pr-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

✅ All Checks Passed — Ready for Review

Check Status Details
📝 PR Description ✅ Pass
Forbidden Files ✅ Pass
🧪 Unit Test ✅ Pass
🔎 pre-commit ✅ Pass
🚫 Draft PR 🔜 To Be Enabled
🚩 Feature Flag 🔜 To Be Enabled
📊 Code Coverage 🔜 To Be Enabled
🤖 therock-pr-bot ✅ Pass

🎉 All checks passed! This PR is ready for review.

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

🙋 Wish to Override Policy?

@therock-pr-bot

therock-pr-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🎉 All checks passed! This PR is ready for review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The mandatory-double-buffered comp_async pipeline incorrectly receives the full LDS budget, and C++ parity lacks automated coverage.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Makes dispatcher LDS validation architecture-aware, enabling larger GEMM tiles on gfx950 and gfx1250.

Changes:

  • Adds per-architecture LDS budgets and gfx1250 specifications.
  • Updates Python and C++ validators and generated artifacts.
  • Adds architecture-aware LDS regression tests.
File summaries
File Description
tests/test_lds_capacity_arch_aware.py Adds LDS budget regression tests.
tests/CMakeLists.txt Registers the Python test suite.
include/ck_tile/dispatcher/arch_specs_generated.hpp Emits architecture-specific C++ budgets.
include/ck_tile/dispatcher/arch_filter.hpp Applies target architecture during validation.
codegen/unified_gemm_codegen.py Displays per-architecture budgets.
codegen/generate_arch_specs.py Resolves and emits LDS budgets.
codegen/arch_specs.json Defines budget policy and gfx1250 data.
codegen/arch_specs_generated.py Emits architecture-specific Python budgets.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread projects/composablekernel/dispatcher/codegen/arch_specs.json
Comment thread projects/composablekernel/dispatcher/codegen/arch_filter.py
Comment thread projects/composablekernel/dispatcher/codegen/unified_gemm_codegen.py Outdated
Comment thread projects/composablekernel/dispatcher/tests/test_lds_capacity_arch_aware.py Outdated
Comment thread projects/composablekernel/dispatcher/tests/CMakeLists.txt
The capacity check modelled a single staging buffer. Pipelines that
ping-pong two LDS buffers allocate 2 * (A + B), so mem, compv3, compv5
and compv6 -- which make double buffering a configuration choice rather
than a property of the pipeline -- could be budgeted for twice the LDS
they actually use.

This was harmless while the budget was capped at gfx942's 64 KB, since
twice that still fit gfx950 and gfx1250. Widening the budget to the real
capacity removed that accidental headroom: a double-buffered compv3 tile
of 160 KB on gfx950 would have been accepted and then needed 320 KB.

Thread the flag into both validators. The grouped convolution codegen
already tracks it and now passes it through; the C++ filter reads
algorithm.double_buffer, which registered kernels populate from
SelectedKernel::DoubleSmemBuffer.

Pipelines that always double already carry the halving in their
per-pipeline budget, so the two are combined with a min() and compv4
and preshufflev2 are never halved twice.

Defaults to single-buffered, so every existing caller is unaffected: the
survivor set is unchanged on all eight architectures relative to the
previous commit, and still identical to develop on the six whose LDS
capacity is 64 KB.
@github-actions github-actions Bot added the ck: convolution Used to tag composablekernel PRs that require approval from CK convolution review team. label Sep 16, 2026
comp_async had no entry in the pipeline budget table, so it inherited
the full-capacity default. It allocates two LDS buffers unconditionally
-- GemmPipelineAgBgCrCompAsync::GetSmemSize() returns num_lds_buffers *
smem_size with num_lds_buffers = 2 -- so it was budgeted for twice the
staging it can actually use.

That was already wrong before this branch: on gfx942 the inherited
64 KB let through tiles needing 128 KB. Widening the budget per
architecture made it worse rather than introducing it.

Give it the half-capacity budget its buffering requires. On the 64 KB
targets this removes configurations that were accepted but could never
have launched; no default configuration set generates comp_async today,
so nothing in tree loses a kernel. gfx1250 gains 185.

Also list wavelet explicitly at full capacity. It is single-buffered, so
the value is unchanged, but leaving it to fall through to "default" is
exactly how comp_async went unnoticed. A test now requires every
pipeline the validators can see to have a deliberate entry, and the
generator maps wavelet to its enumerator so a future change to it cannot
silently stop reaching C++.

Fix a KeyError this branch introduced: the architecture-info listing
indexed the budget table directly, so an unrecognised target raised
instead of falling back to the smallest budget the way the validator
does.

Add Python/C++ parity tests. They compile the generated header and diff
every architecture and pipeline value against the Python table, needing
only a host compiler, so the two validators can no longer drift apart
without CI noticing.
The C++ coverage added earlier compared budget accessor values only. One
of those tests called itself an end-to-end check in its docstring, which
claimed more than it did: it never reached ArchFilter::validate(), so a
validator that ignored its architecture would still have passed it.

Add a probe that builds a KernelKey and calls validate(), so the LDS
check is reached the way a caller reaches it, and assert both that the
96 KB tile is rejected on gfx942 and accepted on gfx950 and that the
rejection carries the LDS error rather than some other validator's. A
second test requires the C++ and Python verdicts to agree for the same
configuration.

Scoped to gfx942 and gfx950 deliberately: they are the targets whose
warp-tile tables agree between the two validators, so a rejection can
only come from the LDS budget. Widening this to the other architectures
needs the warp-tile lookup to be generated from arch_specs.json first.

Verified the new tests have teeth by reverting the C++ side to an
architecture-blind budget: both fail, and both pass again once restored.
Renamed the accessor-level test to say what it actually checks.
@ozturkosu

ozturkosu commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Update For Copilot Review

1. comp_async has no budget entry — correct, and the important one

Verified in source: GemmPipelineAgBgCrCompAsync::GetSmemSize() returns num_lds_buffers * smem_size with num_lds_buffers = 2, so it doubles unconditionally. It had no entry and inherited the full-capacity default. Now budgeted at fraction 0.5.

Being explicit about the consequence, because it is not a pure widening: on the 64 KB targets this removes configurations (gfx942 −250, gfx908 −210, gfx90a −138, and the RDNA parts). Those needed 2 × (A+B) > capacity and could never have launched, so the rejection is correct, but it does mean this PR is no longer "zero lost everywhere" — that claim now holds for the architecture-awareness change, while this is a deliberate correctness narrowing. I checked before pushing that no default configuration set generates comp_async today (the conv defaults are compv1/mem/compv3/compv4/compv6/wavelet), so nothing in tree loses a kernel. gfx1250 gains 185.

While fixing it I also gave wavelet an explicit entry. It is single-buffered so the value is unchanged, but letting it fall through to default is exactly how comp_async went unnoticed. There is now a test requiring every pipeline the validators can see to have a deliberate entry, and the generator maps wavelet to its enumerator so a future change to it cannot silently stop reaching the native side.

2. Fallback omits comp_async — correct

Replaced with a complete table plus a matching accessor, so the two copies cannot drift again.

3. KeyError in the architecture-info listing — correct, and mine

I introduced that when I replaced a .items() loop with direct indexing. It now falls back to the smallest shipped budget, matching the validator.

4. Test omits comp_async — correct

Added, plus the "every pipeline has a deliberate entry" test above, which is what would actually have caught the original omission.

5. Native test coverage — agreed, and you were more right than my first attempt

I initially added tests that compile the generated header and diff every architecture and pipeline value against the Python table. That covers your "per-architecture values" point, but my end-to-end test was accessor-level arithmetic and its docstring overstated it — it never reached ArchFilter::validate(), so an architecture-blind validator would still have passed it.

Fixed in 8c291ce3ff: a probe now builds a KernelKey and calls validate(), asserting the 96 KB tile is rejected on gfx942 and accepted on gfx950, and that the rejection carries the LDS error rather than another validator's. A second test requires the native and Python verdicts to agree for the same configuration. I confirmed they have teeth by reverting the native side to an architecture-blind budget — both fail, and both pass again once restored.

One deliberate limit: these are scoped to gfx942 and gfx950. Those are the two targets whose warp-tile tables agree between the validators, so a rejection there can only come from the LDS budget. Widening to the other architectures first needs the warp-tile lookup generated from arch_specs.json — the native table is hand-written and diverges from the spec on 5 of 8 architectures, which is tracked separately in #12174 and deliberately out of scope here.

I did not add a new C++ build target, since the .cpp files in that directory are not built by that CMakeLists; the probes need only a host compiler and skip cleanly without one, so they run on CPU-only runners.

The generator formats the C++ header it emits, but passed a bare
'-style=file'. clang-format resolves that by searching upward from the
file being formatted, so writing the header outside the repository with
--cpp-output-dir found no .clang-format, silently fell back to the
built-in style, and produced a differently formatted file that does not
satisfy the repository's format gate.

Regenerating in place was always byte-identical, so the committed header
was never stale; the output simply was not reproducible at an arbitrary
destination. That is enough to make a reviewer's regenerate-and-diff
check disagree with the tree, which is how this surfaced.

Resolve the style file from the script's own location instead. Out-of-
tree regeneration now matches the committed header exactly, where it
previously differed by 613 lines and carried 414 format violations.
@ozturkosu
ozturkosu marked this pull request as ready for review September 16, 2026 08:29
@ozturkosu
ozturkosu requested review from a team as code owners September 16, 2026 08:29
Comment thread projects/composablekernel/dispatcher/codegen/arch_specs.json Outdated
Comment thread projects/composablekernel/dispatcher/codegen/arch_specs.json Outdated
Review feedback: the product generation for this target is CDNA5, not
RDNA4. The value was carried over verbatim from the generated module
when gfx1250 was transcribed into arch_specs.json, so it predates this
branch, but the transcription is where it becomes reviewable.

Keep architecture as rdna and say why in the spec: arch.hpp states that
gfx1250 shares the RDNA architecture with the GFX12 family while being
its own standalone target family, because its MMA builtins and data-type
ABI differ. The two fields record different things, the ISA lineage and
the product generation, so they legitimately disagree here.

The field is metadata only. Nothing branches on its value; the sole
consumer checks that it is not None to reject unknown architectures in
strict mode, so this cannot change which kernels are generated.

Other references in the tree still describe gfx1250 as RDNA4, including
a comment in arch.hpp and several test headers. Those are left alone
here rather than renamed in an LDS-capacity change.
Review feedback: gfx1250 supports a 16x16x128 warp tile for fp8, and the
spec listed only 16x16x64.

Confirmed against the warp-gemm traits rather than taken on trust.
warp_gemm_attribute_wmma_impl_8bit_traits.hpp declares dense
WmmaTraits<gfx125_t, ...> specialisations for both shapes: fp8/fp8 at
16x16x64 and at 16x16x128, and likewise for bf8/bf8. The 128 form is a
plain specialisation with no scale tag, so it belongs on the dense GEMM
path this table governs, not only on the scaled path.

Like the rest of the gfx1250 entry, the previous value was transplanted
verbatim from the generated module, where it was already incomplete.
Transcribing it into the spec is what made it reviewable.

This widens the generated set on gfx1250: measured over a tile, wave and
pipeline sweep, fp8 and bf8 each gain 123 configurations and nothing is
lost. fp16 and bf16 are untouched, as are all other architectures.

The mixed fp8/bf8 and bf8/fp8 pairs have traits for both shapes too, but
are deliberately still absent. Adding them would newly generate kernels
for dtype combinations this bridge has never exercised, which needs its
own device validation rather than a spec edit. The reasoning is recorded
next to the table.
@ozturkosu
ozturkosu requested a review from andriy-ca September 16, 2026 19:18
@andriy-ca

andriy-ca commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Reviewed this on a worktree of 13799a8d226, executing both validators rather than reading them. I also benchmarked the branch on a real MI350X while reviewing — that data is at the bottom, and it's good news.

Six findings: two I'd call blocking, four worth fixing.


Critical

C1 — "zero configurations lost" is not true; comp_async narrows on six architectures

The equivalence table reports zero losses everywhere. That holds for the pipelines that existed in the old table, but comp_async is a new key, and adding it is not neutral.

Base bc358d85dae:

LDS_CAPACITY_LIMITS = {'mem': 65536, 'compv1': 65536, 'compv2': 65536, 'compv3': 65536,
                       'compv4': 32768, 'compv5': 65536, 'compv6': 32768,
                       'preshufflev1': 32768, 'preshufflev2': 32768, 'default': 65536}

No comp_async key, so .get("comp_async", default) returned 65536. The PR pins it to 32768 on every 64 KB architecture (gfx908, gfx90a, gfx942, gfx1100, gfx1200, gfx1201).

A concrete loss — conv tile 128x64x128 at fp16, which is a live entry in COMMON_TILES:

staging = (128*128 + 64*128) * 2 = 49152 bytes
  accepted before (<= 65536): True
  accepted now    (<= 32768): False

I believe the tightening is correctcomp_async.hpp:217-221 returns 2 * smem_size unconditionally, so it genuinely belongs at half capacity. The problem is that it is presented as a no-op. The 48,384-configuration enumeration cannot see it, because comp_async was not in the old parameter space to enumerate.

Suggested: re-run the equivalence enumeration with comp_async and double_smem_buffer in {False, True} in the axes, and report the losses explicitly. They look defensible — they just need to be stated rather than implied absent.

C2 — double_buffer is read uninitialized

dispatcher/include/ck_tile/dispatcher/kernel_key.hpp:164

bool double_buffer;           // DoubleSmemBuffer (true for compv4)
...
bool pad_m = true; // :171

No default member initializer, unlike pad_m/pad_n/pad_k immediately below it. Before this PR that was harmless because nothing in the validation path read the field. Now arch_filter.hpp:369 does:

if(alg.double_buffer)

Any KernelKey that reaches ArchFilter::validate without explicitly assigning the field reads an indeterminate bool — UB, and a nondeterministic LDS budget that could land on either 65536 or 32768.

Suggested: bool double_buffer = false;


Warnings

W1 — three key builders hardcode double_buffer = true

dispatcher/include/ck_tile/dispatcher/kernel_config.hpp:280
dispatcher/include/ck_tile/dispatcher/utils.hpp:676
dispatcher/bindings/ctypes/gpu_helper.cpp:106

All three do key.algorithm.double_buffer = true; unconditionally, regardless of pipeline. Previously inert; now load-bearing. A Mem/CompV3/CompV5/Wavelet key built through these paths on gfx942 gets 32768 instead of 65536, so previously-accepted 64 KB tiles are now rejected. This is the one route by which gfx942 behaviour changes, which cuts against the "gfx942 unchanged" claim.

The codegen already does it correctly at unified_gemm_codegen.py:1531:

key.algorithm.double_buffer = {pipeline in ("compv4", "preshufflev2")}

Suggested: derive it the same way in all three.

W2 — no headroom: the footprint model under-counts, and the budget is now exactly capacity

The validator checks tile_m*tile_k*elem_a + tile_n*tile_k*elem_b. The real allocation is larger: GetSmemSizeA/B apply integer_least_multiple(..., 16), and the gfx125 path adds PaddingDataAmount to the row stride (gemm_universal_pipeline_ag_bg_cr_policy.hpp:405-414, 1093-1116).

Under the old 64 KB cap on a 160 KB part there was 96 KB of slack absorbing that error. At "basis": "fraction", "value": 1.0 the slack is exactly zero, so a tile that saturates the budget can exceed the hardware limit and fail at compile time with local memory (N) exceeds limit (163840).

I looked for this specifically in my MI350X fp16 run and found zero occurrences across 256 successful compiles, so it did not bite at fp16. But fp8 reaches deeper K for the same staging, and gfx1250 has the extra padding path — I would expect it to surface there first.

Suggested: either model the padded/16-byte-rounded size, or use a fraction slightly below 1.0 for the single-buffered pipelines.

W3 — get_smem_capacity() gfx1250, and a host/device split

include/ck_tile/core/arch/arch.hpp:1483-1489

CK_TILE_HOST_DEVICE constexpr index_t get_smem_capacity()
{
#if defined(__gfx950__)
    return 163840;
#else
    return 65536;
#endif
}

Two problems, and the PR's widening makes both more reachable:

gfx1250 reads 65536 while this PR budgets it at 327680 — a 5x divergence in the permissive direction. grouped_convolution_forward_kernel.hpp:855 gates on this, so the dispatcher will emit gfx1250 conv kernels up to 320 KB that the host-side check then silently rejects. Conservative rather than corrupting, but a functional gap. Note there is no gfx1250 hardware in our SLURM cluster, so no amount of MI350X/MI355X testing can expose it.

It is CK_TILE_HOST_DEVICE, but __gfx950__ is undefined during the host pass. On gfx950, host sees 65536 and device sees 163840 — within one translation unit. It is consumed in a static constexpr initializer at cshuffle_epilogue.hpp:245, so shuffle_tile, SFC and EpiloguePipeline::GetSmemSize() can differ between passes whenever m_val*n_val*sizeof(CShuffleDataType) lands in (64 KB, 160 KB]. Host-computed lds_bytes at backends/tile_backend.hpp:113 would then disagree with the device __shared__ array.

I recognise the PR explicitly scopes this out, and the reasoning given is sound. Flagging because the larger gfx950/gfx1250 tiles this PR admits push more configurations into the divergent window than before.

Suggested: at minimum add the gfx125 case; ideally route through get_lds_size() or make it device-only.

W4 — mixed-dtype B term is under-counted 2x

gemm_universal_pipeline_ag_bg_cr_policy.hpp:1104-1111

using BDataType = std::conditional_t<IsBCastPolicyBeforeLDSWrite,
                                     typename Problem::ADataType,
                                     BLdsDataType_<Problem>>;

When IsBCastPolicyBeforeLDSWrite_v<Problem> holds, B is staged as ADataType, not BDataType. The validator always uses elem_size_b, so an A=fp16 / B=fp8 configuration under-counts the B term by 2x. Same root cause as W2 — previously masked by slack, now not.

Suggested: use max(elem_a, elem_b) for the B term, or gate on the cast policy.


Things I checked that are fine

  • Fraction resolution is exact. Only 1.0 and 0.5 appear; 64/160/320 KB halve exactly and all results are 1024-aligned. min(budget, capacity_bytes) correctly caps absolute_kb.
  • Unknown-arch fallback is the smallest, in both languages. Python get_lds_limit("gfxBOGUS", ...) returns 65536/32768 via _SMALLEST_LDS_BUDGET; C++ GpuArch::UNKNOWN (arch_specs_generated.hpp:278-293, 311-312) returns identically. Verified by execution, and this is the safe direction.
  • Python and C++ agree exactly. I executed get_lds_limit over 9 arches x 12 pipelines x {single, double} and compiled a probe against the generated header doing the same: zero mismatches, including the unknown-arch path.
  • The double-buffer premise is conservative, not permissive. mem.hpp:238, comp_v3.hpp:214, comp_v5.hpp:119, comp_v6.hpp:189 all return Policy::GetSmemSize() plain — DoubleSmemBuffer is a dead alias in those four. So halving them when the flag is set is unjustified but safe. comp_v4.hpp:202-206 and comp_async.hpp:217-221 do return 2 * smem_size unconditionally, so their 0.5 fractions are exact and min() correctly avoids halving twice.
  • The rdna4 to cdna5 relabel is inert. ARCH_FAMILY_MAP values are only compared against None (arch_filter.py:453-458) and C++ carries no family at all. Separately: codegen/fmha/validation.py:854 gates an MFMA rule on family.startswith("cdna"). Nothing consumes ARCH_FAMILY_MAP that way today, but an explicit matrix_core: wmma|mfma field would be more robust than inferring from the family string on a wave32/WMMA part.
  • gfx1250 warp-table transplant is verbatimwarp_configs and warp_tile_combos byte-match the pre-PR generated module.
  • Tests: 21 pass, not 15. python3 tests/test_lds_capacity_arch_aware.py gives Ran 21 tests / OK. They drive the real ArchFilter.validate_kernel, not a reimplementation. Two notes: the C++ probes skipTest when no host compiler is present, so 5 of 21 can vanish while CTest still reports PASS; and GFX942_FROZEN pins comp_async: 32768, i.e. the new value, so the test named as a freeze would not have caught C1.

GPU validation — the pending checkbox

I ran the AICK-2244 benchmark harness against this branch on a real MI350X (bg-1w300-h3-2a, gfx950, ROCm 10.1.0), same 7 GEMM shapes and same sweep configs as the published baseline:

run                          codegen_ok   rejected   %rejected
baseline gfx950 fp16 (64 KB)        128        160         56%
this PR   MI350X fp16 (160 KB)      256         32         11%

Searched configurations doubled, 128 to 256. No compile failures, no local memory exceeds limit, and all 32 remaining rejections are accounted for:

compv4  A+B =  96 KB  n=16   vs 80 KB cap -> correctly rejected
compv4  A+B = 128 KB  n=16   vs 80 KB cap -> correctly rejected

Worth noting the sweep configs are byte-identical to the baseline run — our generator already scoped by real hardware LDS, so it was offering these tiles all along and the tooling was refusing them. The 2x gain is attributable to this PR alone.

… the B term

Review findings, all confirmed by reading the sources rather than taken
on trust.

kernel_key.hpp declared double_buffer with no member initializer, unlike
the padding flags immediately below it. That was harmless while nothing
in the validation path read the field. The LDS check now does, so a key
that reached validation without assigning it would read an indeterminate
value and pick a budget at random. Defaulted to false.

Two generic key builders hardcoded double_buffer = true regardless of
pipeline. A single-buffered pipeline built through them would be handed
half the budget it is entitled to. Both now derive it from the pipeline,
matching what the codegen already does. The ctypes helper also sets it
true but pins the pipeline to CompV4, where true is correct, so it is
left alone.

The footprint model charged B at sizeof(BDataType). When the B cast
policy runs before the LDS write, B is staged as ADataType instead
(GetSmemSizeB), so a mixed-precision pair was under-counted. Both
validators now charge B at the wider of the two, which is unchanged for
equal dtypes.
@ozturkosu

ozturkosu commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Thank you — this is the most useful review this PR has had, and the MI350X numbers are the missing checkbox. Taking the findings in order.

C1 — you are right

Confirmed and fixed in the PR body. I re-ran the enumeration with comp_async and wavelet in the pipeline axis and both settings of the staging flag:

arch develop this PR gained lost
gfx908 4,613 7,386 +3,003 230
gfx90a 2,701 4,138 +1,595 158
gfx942 5,324 8,360 +3,322 286
gfx950 7,116 19,016 +11,900 0
gfx1100 1,155 1,870 +770 55
gfx1200 442 668 +253 27
gfx1201 442 668 +253 27
gfx1250 2,012 10,926 +8,914 0
total +30,010 783

Attribution across the six 64 KB architectures: 783 losses, all comp_async, no other pipeline appears. Your diagnosis of why the old enumeration could not see it is exactly right — the pipeline had no entry in the old table, so it was not in the space being enumerated. The claim was true of what it measured and wrong as a general statement, which is the worse kind of wrong.

C2 — real, fixed

bool double_buffer = false;. You are right that this became live the moment validate_lds started reading it, and right that the padding flags directly below it show what the declaration should have looked like.

W1 — fixed in two of three

kernel_config.hpp and utils.hpp both take a generic pipeline and now derive the flag the same way the codegen does. bindings/ctypes/gpu_helper.cpp:106 also assigns true, but it pins pipeline = Pipeline::CompV4 two lines earlier, so true is correct there and I left it.

This also corrects something I asserted earlier in this PR. I had argued these builders were harmless because they produce query keys that never reach validate_lds. That reasoning does not survive your point that the field is now load-bearing, and it was the wrong thing to lean on regardless.

W4 — real, fixed

GetSmemSizeB stages B as ADataType under IsBCastPolicyBeforeLDSWrite, so the B term was under-counted for a mixed pair. Both validators now charge B at max(elem_a, elem_b), which is a no-op for equal dtypes.

W2 — agreed in principle, and I would like your view on the remedy

Your reading is correct: integer_least_multiple(..., 16) plus the gfx125 padding means the model under-counts, and value: 1.0 leaves no slack. W4 removes one contributor but not the rounding or the padding.

I have not applied a fix here because both options have a real cost. Modelling the padded size duplicates policy logic in the validator, which is its own drift risk. Dropping the fraction below 1.0 is arbitrary unless the number comes from somewhere — and it would discard headroom on exactly the tiles this PR exists to unlock. Your zero-occurrence result over 256 compiles is reassuring for fp16 but, as you say, says nothing about fp8 or gfx1250.

Would you rather I take a conservative fraction now and refine it with data, or hold at 1.0 and gate it behind fp8 evidence on gfx1250 hardware when that exists?

W3 — agreed, still scoping out, but the host/device split is new to me

I had flagged the gfx1250 divergence and deliberately left it. The CK_TILE_HOST_DEVICE observation is one I had not made: __gfx950__ being undefined during the host pass means host and device disagree within a single translation unit, and it feeds a static constexpr initializer. That is a sharper problem than the one I scoped out.

It still belongs outside an LDS-capacity change — it has three consumers in kernels this PR does not touch. But your point that this PR pushes more configurations into the divergent window is fair, so I will file it rather than leave it as a paragraph here.

On the tests

Both notes are correct and both are on me. The C++ probes do skipTest without a host compiler, so CTest can report PASS on a subset. And GFX942_FROZEN pins comp_async: 32768 — the new value — so a test named as a freeze would not have caught C1. I will fix the freeze to hold the historical values and let the intended change be the thing that has to be justified.

On the benchmark

Thank you for running it. Doubling the searched space, 128 to 256, with no compile failures and every remaining rejection accounted for by the deliberate half-capacity cap, is exactly the evidence the pending checkbox needed. The point that the sweep configs are byte-identical to the baseline is the part I would not have thought to check, and it is what makes the gain attributable to this change rather than to a different input.

@andriy-ca andriy-ca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@ozturkosu
ozturkosu enabled auto-merge (squash) September 16, 2026 21:21
@ozturkosu
ozturkosu merged commit 4606f83 into develop Sep 17, 2026
123 of 124 checks passed
@ozturkosu
ozturkosu deleted the users/muozturk/ck/lds-capacity-arch-aware branch September 17, 2026 01:55
assistant-librarian Bot pushed a commit to ROCm/composable_kernel that referenced this pull request Sep 17, 2026
feat(ck-tile): make dispatcher LDS capacity budget
 architecture-aware (#12173)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

JIRA ID : AICK-2249

## Summary

The dispatcher's codegen rejects any GEMM tile whose LDS staging
footprint exceeds a cap. The cap was keyed on the **pipeline only** and
carried no architecture term, so newer targets with substantially more
LDS than gfx942 were all held to gfx942's budget.

The effect is that the largest and deepest tiles — the ones most likely
to win on large GEMM shapes — were never generated, never benchmarked
and never selectable on those targets. On a gfx950 fp16 sweep, **160 of
288 offered configurations (56%) were rejected by this cap alone**.

This PR makes the budget architecture-aware, adds gfx1250 to the spec,
fixes both the Python and C++ validators together, and makes the check
account for ping-pong LDS staging.

**Scope:** the check is not GEMM-specific. `_validate_lds_capacity` sits
in the common `validate_kernel` chain, ungated by operator, and
`OperatorType` covers 11 operators (5 GEMM variants, 6 conv variants).
In Python it is reached by `unified_gemm_codegen.py:1595` (universal
GEMM) and `unified_grouped_conv_codegen.py:1941` (grouped convolution);
in C++ by `Registry::filter_by_arch()` (`registry.cpp:160`), which is
operator-agnostic and covers every registered kernel. The ctypes bridge
paths (quant variants, batched contraction, multi-ABD, FMHA) validate
via `validate_kernel_config` in `python/ctypes_utils.py`, which has no
LDS check at all, so they are unaffected either way.

Implements AICK-2249. Baseline evidence from AICK-2244.

## Motivation

`arch_specs.json` had `pipeline_lds_limits` as a **top-level** key — a
sibling of `architectures`, not nested inside it:

```json
"pipeline_lds_limits": {
  "mem": <bytes>, "compv3": <bytes>, "compv4": <bytes>, ... "default": <bytes>
}
```

Every value was a fixed byte count, derived from gfx942 and applied to
every target.

There was no architecture axis, and nowhere to put one.
`_validate_lds_capacity` did `LDS_CAPACITY_LIMITS.get(config.pipeline,
...)` and nothing else.

Meanwhile `arch.hpp` has declared a per-architecture LDS capacity all
along, via `get_lds_size()`. On gfx942 the hardcoded cap and that
capacity coincide; on the newer targets they do not, and the shortfall
is large. This PR consumes the existing declaration rather than
introducing any new hardware figure.

This is the usual shape of this defect: a value that was correct when
there was exactly one architecture, frozen into a schema with no slot
for a second one. It survived because on gfx942 the correct answer and
the hardcoded answer are the same number, so it only ever fails
*silently, by generating less*.

The evidence that it is binding rather than theoretical: on the gfx950
sweep the cutoff falls **exactly** on the hardcoded cap for compv3/mem,
and exactly on the tighter cap for compv4, with zero exceptions in
either direction. And the winning kernels on that target were
`128x256x64` and `128x128x128` under compv3 — precisely tiles that
compv4 was forbidden from using. When the measured optimum sits on the
constraint boundary, the constraint is probably binding.

## Design note

**I did not invent a new schema.** A per-architecture `lds_capacity_kb`
field already existed in `arch_specs.json` (gfx950 already correctly
said `160`), and `ADDING_NEW_GPU.md` already documented it as a required
onboarding field. Nothing read it. Every new-GPU onboarding has been
filling in a mandatory field that no code consumed — the contract was
documented and unhonoured. So the fix is to honour it.

Pipelines now declare a **basis** rather than a byte count:

```json
"compv3": { "basis": "fraction", "value": 1.0 },
"compv4": { "basis": "fraction", "value": 0.5 }
```

resolved against each architecture's capacity at generation time. A new
GPU needs **one** number, not ten. The alternative — nesting the byte
table under each architecture — would have reintroduced the same failure
mode one level down: 8 arches x 10 pipelines of hand-maintained
constants free to drift from `arch.hpp` independently.

Resolution happens in the generator, once, so the emitted Python and C++
get literal byte counts and cannot disagree about how to read the
schema.

### On the tighter-capped pipelines — I read the sources rather than
guessing

Four pipelines sat at the tighter cap and the ticket flagged it as an
open question: genuine double-buffering that should scale with capacity,
or an independent absolute limit? On gfx942 the two readings are
numerically identical, which is why it was never forced. **The answer is
not uniform across the four:**

| pipeline | doubles LDS? | evidence |
|---|---|---|
| `compv4` | **yes** | `GemmPipelineAgBgCrCompV4::GetSmemSize()` returns
`2 * Policy::GetSmemSize<Problem>()` |
| `preshufflev2` | **yes** |
`WeightPreshufflePipelineAGmemBGmemCRegV2::GetSmemSize()` returns
`DoubleSmemBuffer ? 2 * smem_size : smem_size` |
| `compv6` | **no** | returns the policy size unmultiplied |
| `preshufflev1` | n/a | no such pipeline exists;
`preshuffle_pipelines.supported` lists only `preshufflev2` |

This is corroborated by `DOUBLE_SMEM_PIPELINES = {"compv4",
"preshufflev2", "comp_async"}`, which already exists in
`unified_batched_contraction_codegen.py` and names exactly the same two.

So for compv4 and preshufflev2, half-of-capacity is the **exact** model,
not a safety margin: the validator checks `A+B` against `capacity/2`,
which is precisely equivalent to checking the real `2*(A+B)` allocation
against full capacity.

For compv6 and preshufflev1 the halved cap is **unexplained by
buffering**. I preserved their ratio rather than widening them, because
no evidence supports a larger budget and a blind raise that regresses is
worse than the status quo. Widening those two is a separate change that
needs a measurement behind it. The reasoning is recorded in
`arch_specs.json` next to each value.

### Ping-pong staging had to be threaded in

The check modelled a *single* staging buffer. But
`unified_grouped_conv_codegen.py:148` documents that `mem`, `compv3`,
`compv5` and `compv6` make double buffering a **configuration choice**
(`--double-smem-buffer`), not a property of the pipeline — and the conv
path passed `pipeline=` into the validator without that flag, so the
budget could not see it.

That was harmless while the cap matched gfx942's capacity, because twice
that still fits the larger parts. **Widening the budget removes the
accidental headroom**, so I had to close it in the same PR:

Writing `C` for a target's LDS capacity:

| | compv3 budget | actually allocated | vs capacity | |
|---|---|---|---|---|
| develop | gfx942's cap | 2x that | below `C` | fits, by accident |
| arch-aware alone | `C` | `2C` | **over `C`** | **overflows** |
| with this fix | `C/2` | `C` | exactly `C` | fits exactly |

So the flag is now threaded into both validators. Conv passes the value
it already tracks; C++ reads `algorithm.double_buffer`, which registered
kernels populate from `SelectedKernel::DoubleSmemBuffer`
(`kernel_registration.hpp:60`).

Pipelines that *always* double already carry the halving in their
per-pipeline budget, so the two signals are combined with a `min()` —
`compv4` and `preshufflev2` are never halved twice, which is what keeps
gfx942 byte-identical. Independent confirmation that those are exactly
the two: `unified_gemm_codegen.py:1531` sets `double_buffer = pipeline
in ("compv4", "preshufflev2")`.

One deliberate non-use: `KernelConfig::build_key()`
(`kernel_config.hpp:280`) and `utils.hpp:676` hardcode `double_buffer =
true` for *any* pipeline. Those build **query** keys, which never reach
`validate_lds` — only registered-instance keys do — so the flag is
trustworthy at the one site that reads it. Worth fixing separately.

### A landmine worth calling out

`arch_specs_generated.py` **already contained gfx1250** — family, warp
configs, warp tile combos — but `arch_specs.json`, the file it is
generated from, did not. The generated C++ header did not either.
Three-way drift, in a file stamped `AUTO-GENERATED - DO NOT EDIT
DIRECTLY`.

This means following the documented workflow (edit JSON, regenerate,
commit) would have **silently deleted gfx1250's warp tables**. I
transplanted them into the JSON verbatim rather than authoring anything,
and the regeneration check below confirms every table came back
byte-identical.

## Test plan

- [x] New regression suite passes (15/15). Asserts budgets differ across
gfx942/gfx950/gfx1250, that gfx942's budget is byte-identical to the
historical table, that no budget exceeds the declared hardware capacity,
that unknown targets get the *smallest* budget rather than the largest,
and end-to-end that a large staging tile is rejected on gfx942 and
accepted on gfx950
- [x] Double-buffer cases covered: configurable pipelines halve, `2 x
budget <= capacity` on every arch/pipeline pair, always-double pipelines
are not halved twice, single-buffered remains the default, and
end-to-end a large staging tile is accepted on gfx950 single-buffered
but rejected double-buffered
- [x] Existing suites pass: `test_arch_filter_constraints`,
`test_gemm_utils`, `test_codegen_common`, `test_dispatcher_common`,
`test_tile_math`, `test_grouped_conv_codegen`, `test_grouped_conv_utils`
- [x] Generator is idempotent; regeneration leaves all non-LDS tables
**byte-identical** (`ARCH_FAMILY_MAP`, `WARP_SUPPORTED_COMBINATIONS`,
`WARP_TILE_SUPPORTED_COMBINATIONS`,
`PRESHUFFLE_WARP_TILE_SUPPORTED_COMBINATIONS`,
`TRAIT_UNSUPPORTED_COMBINATIONS`, `ELEMENT_SIZE_MAP`,
`DTYPE_COMBINATIONS`, `PRESHUFFLE_PIPELINES`)
- [x] C++ compiles standalone and returns values identical to Python for
every architecture and pipeline, single- and double-buffered, including
the unknown-architecture fallback
- [x] `clang-format-18 -style=file` clean on both headers
- [ ] **GPU validation pending** — see below

### Equivalence proof

This edits a shared table, so I enumerated the validator's survivor set
per architecture, before and after. The axes now include **every
pipeline the validators can see** — `comp_async` and `wavelet` as well
as the nine that were in the old table — and both settings of the
ping-pong staging flag.

| arch | develop | this PR | gained | lost |
|---|---:|---:|---:|---:|
| gfx908 | 4,613 | 7,386 | +3,003 | **230** |
| gfx90a | 2,701 | 4,138 | +1,595 | **158** |
| gfx942 | 5,324 | 8,360 | +3,322 | **286** |
| gfx950 | 7,116 | 19,016 | +11,900 | 0 |
| gfx1100 | 1,155 | 1,870 | +770 | **55** |
| gfx1200 | 442 | 668 | +253 | **27** |
| gfx1201 | 442 | 668 | +253 | **27** |
| gfx1250 | 2,012 | 10,926 | +8,914 | 0 |
| **total** | | | **+30,010** | **783** |

**There are losses, and an earlier version of this section claimed there
were none.** That claim was measured over the old parameter space, which
could not contain `comp_async` because the pipeline had no entry in the
old table to enumerate. It was true of what it measured and wrong as a
general statement.

**All 783 losses are `comp_async`, on the six architectures whose budget
is unchanged**, and they are intended. Attribution by pipeline across
those six:

```
comp_async   783
(no other pipeline appears)
```

`comp_async` had no entry before, so it inherited the full-capacity
default. It allocates two LDS buffers unconditionally — `GetSmemSize()`
returns `num_lds_buffers * smem_size` with `num_lds_buffers = 2` — so it
was budgeted for twice the staging it can actually use. A tile such as
conv `128x64x128` at fp16 needs 48 KB of staging and was accepted, while
the kernel would then have asked the hardware for twice that. Rejecting
it is the fix, not a regression.

The two architectures with a raised budget lose nothing, and no default
configuration set in tree generates `comp_async`, so no shipped kernel
disappears.

## Explicitly out of scope

Two other filters in the same validation chain share this defect class
(gfx942-derived constraints applied to every architecture). Neither
rejected anything in the baseline campaign, so I left them alone:

- `TRAIT_UNSUPPORTED_COMBINATIONS` — 10 tuples, no arch key
- `_cshuffle_store_ok` — docstring says "GPU-verified on gfx942",
applied to all architectures

A wider audit of the validation chain found this same pattern in several
more places, including a hand-written warp-tile lookup on the native
side that disagrees with the generated Python table on a majority of
supported architectures. That work is tracked separately in AICK-2264
and AICK-2266, under epic AICK-2265, and is deliberately not part of
this PR: it is a different table on a different axis, and unlike this
change it removes configurations, so it needs its own before/after
enumeration and device validation.

Also left alone: `get_smem_capacity()` in `arch.hpp` special-cases a
single architecture and returns a fixed value otherwise, which disagrees
with `get_lds_size()` for at least one target. It has three live
consumers outside this ticket's scope (`cshuffle_epilogue.hpp`,
`moe_sorting_kernel.hpp`, `grouped_convolution_forward_kernel.hpp`) and
may legitimately encode a per-workgroup addressable limit rather than
per-CU capacity. Flagging rather than changing it.
@assistant-librarian

Copy link
Copy Markdown
Contributor

TheRock Submodule Bump Activity

Newest first

ozturkosu added a commit that referenced this pull request Sep 17, 2026
JIRA ID : AICK-2249

## Summary

gfx1250 is labelled **RDNA4** in several comments and one CI config
string. The product generation is **CDNA5**. This corrects the label.

Comments and one JSON `_comment` only — no code, no behaviour change.

Raised in review of #12173, where the same value was corrected in
`arch_specs.json`. Split out because renaming terminology across the
tree does not belong in an LDS-capacity change.

## Why the RDNA4 label was there

It was not arbitrary. gfx1250 has no MFMA units and multiplies through
**WMMA**, which is an RDNA-family instruction, and the `gfx12xx`
numbering points the same way. The observation about the *instruction
path* was right; using it as a *generation* label was not.

`arch.hpp` already draws exactly this distinction:

> GFX1250 is its own standalone family. Although it **shares the RDNA
architecture** with the GFX12 family, its MMA builtins and data-type ABI
differ, so it must not be treated as a GFX12-family device.

Sharing an ISA lineage is not the same as belonging to that generation.
So there are two separate claims in play, and only the second one holds.

## Changes

Where a generation label is meant, it now says CDNA5. Where only the
instruction path matters, the generation is dropped rather than replaced
— a WMMA path is a WMMA path regardless of which generation the part
belongs to.

| file | before | after |
|---|---|---|
| `dispatcher/tests/test_gemm_utils.py` | `gfx1250 (MI400 / RDNA4-WMMA)
enablement` | `gfx1250 (MI400 / CDNA5, WMMA) enablement` |
| `dispatcher/tests/test_batched_bridge.py` | same | same |
| `dispatcher/tests/test_batched_contraction_bridge.py` | same | same |
| `dispatcher/tests/test_multi_d_bridge.py` | `it runs the RDNA4 WMMA
path` | `it runs the WMMA path` |
| `dispatcher/codegen/grouped_conv/grouped_config_rules_full.py` | `such
as rdna4/gfx1250` | `such as gfx1250` |
| `tile_engine/.../bridge_default_ci_config_gfx1250.json` | `(MI400,
RDNA4/WMMA)` | `(MI400, CDNA5/WMMA)` |

## Deliberately not changed

- **`include/ck_tile/core/arch/arch.hpp`** and
**`.../mma/scale/wmma/selector.hpp`** describe the ISA lineage and the
selector strategy as RDNA. Both are accurate — the first is the quote
above, the second documents an RDNA-style selector. Renaming these would
replace a correct statement with a wrong one.
- **`dispatcher/codegen/arch_specs_generated.py`** still reads `rdna4`.
It is generated, and its source is the gfx1250 entry in
`arch_specs.json` that #12173 adds; the value is corrected there.
Hand-editing a generated file is the exact trap this codebase keeps
hitting, so it is not done here.

That last point means the tree is briefly inconsistent until #12173
lands. The affected field is metadata: nothing branches on its value,
and its only consumer is a `None` check that rejects unknown
architectures in strict mode.

## Test plan

- [x] `test_gemm_utils`, `test_batched_bridge`,
`test_batched_contraction_bridge`, `test_multi_d_bridge`,
`test_grouped_conv_codegen` all pass
- [x] The edited CI config still parses as JSON
- [x] No `gfx1250`/`MI400` reference to RDNA4 remains outside the two
files listed above as intentionally kept

## Note for the reviewer

The label originated in #10921, the gfx1250 bridge enablement PR, and
spread from there; AICK-2082 uses the same wording. So this is a
retroactive terminology correction rather than a one-off typo. If
**CDNA5 is not the right generation either**, say so before this merges
— I have taken it from review feedback on #12173 and have not
independently confirmed it.

---------

Co-authored-by: Muhammed Emin Ozturk <3836908+ozturkosu@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ck: convolution Used to tag composablekernel PRs that require approval from CK convolution review team. organization: ROCm project: composablekernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants