Skip to content

[CUDA] Pack logical TMEM buffers into shared tcgen05.alloc arenas - #2831

Merged
LeiWang1999 merged 4 commits into
tile-ai:mainfrom
Rachmanino:feat/tmem-arena-packing
Aug 3, 2026
Merged

[CUDA] Pack logical TMEM buffers into shared tcgen05.alloc arenas#2831
LeiWang1999 merged 4 commits into
tile-ai:mainfrom
Rachmanino:feat/tmem-arena-packing

Conversation

@Rachmanino

@Rachmanino Rachmanino commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

#2827

Problem

LowerSharedTmem gives every T.alloc_tmem buffer its own tcgen05.alloc, rounded up to a power of two with a minimum of 32 columns. Narrow buffers therefore each burn a full 32-column allocation even when they need 4.

That is not just waste — it makes some kernels inexpressible. A 384-column accumulator (three 128-column pipeline stages) next to three 4-column block-scale buffers needs 396 columns, but as four separate allocations it asks the hardware for 512 + 32 + 32 + 32 = 608 of the 512 columns a CTA has. Today nothing reports this; the kernel just compiles and misbehaves on device.

The workaround has been to declare one wide TMEM buffer and slice scale factors out of it by hand, passing column offsets manually — which is what a compiler should be doing.

CUTLASS solves the same problem with a bump allocator over one allocation (cutlass::detail::find_tmem_tensor_col_offset, used by sm100_blockscaled_mma_warpspecialized.hpp to place accumulator → tCtSFA → tCtSFB), and DeepGEMM hand-writes the equivalent layout.

Approach

Plan the allocations before emitting them. PlanTmemArenas packs logical TMEM buffers into shared physical allocations, and each buffer's column offset inside the allocation it shares is folded into every address it forms.

Two addressing paths carry TMEM addresses today, and both are handled:

  • BufferLoad on a TMEM buffer — used by tcgen05.ld/tcgen05.st — gains the offset in the encoded coordinate.
  • The (base address Var, offset) argument pairs of the tl.ptx_tcgen05_* intrinsics (_mma_ss, _mma_ts, _mma_blockscaled_ss, cp_warpx4), which all codegen to *(uint32_t*)base + offset, get the offset added to the second half of the pair.

A base address that reaches anything else is rejected with a diagnostic rather than silently addressing the start of the allocation.

Packing policy. A buffer joins an existing allocation only when that strictly reduces the total number of allocated columns. Each placement therefore costs at most the buffer's own standalone allocation, so:

  • packing can never grow a kernel's TMEM footprint, and
  • a kernel whose buffers already fit lowers exactly as it did before.

Widest-first placement keeps wide buffers on 32-column boundaries.

Alignment. A buffer at least 32 columns wide starts on a 32-column boundary — the same alignment tcgen05.alloc would have given it alone — so sharing an allocation cannot leave an accumulator or a TMEM-resident A operand less aligned than it is today. Narrower buffers use the 4-column granularity of tcgen05.cp.32x128b.warpx4, the boundary CUTLASS and DeepGEMM place these very operands on.

Lifetimes. A buffer with an explicit T.deallocate_tmem keeps its own allocation: releasing it early would release whatever shares it. Every other buffer in a block is live for the whole block, so this needs no liveness analysis to be correct. It does leave value on the table — buffers with disjoint live ranges could reuse the same columns outright instead of only filling each other's power-of-two padding — which is marked as a TODO on the planner.

New diagnostic. A block that allocates more than 512 columns is now reported at compile time (unless it manages a lifetime by hand, where the peak is not the sum).

Effect on existing kernels

Measured by capturing device_kernel.cu for every TMEM example under baseline and under this change (separate TILELANG_CACHE_DIRs, so no cache hit crosses versions) and diffing:

example before after
gemm_mxfp8_blockscaled_1d1d 3 allocations, 320 columns 2 allocations, 288 columns
grouped_gemm_mxfp8_blockscaled_1d1d 3 allocations, 320 columns 2 allocations, 288 columns
fp8_fp4_gemm_1d1d_sm100 3 allocations, 320 columns 2 allocations, 288 columns
mha_fwd_bshd, gqa_fwd_bshd 128 + 128 + 64 / 128 + 128 same allocations at the same addresses; only the order the declarations and deallocs are emitted in changed
gemm_tcgen5mma{,_ws,_ws_persistent,_ws_clc} 1 allocation byte-identical generated CUDA

All of them still pass their own correctness checks.

The whole difference in the generated CUDA for the block-scaled GEMM is:

-  __shared__ __align__(16) uint sfa_data[1];
-    tl::tmem_allocate<true>((&(sfa_data[0])), 32);
-    tl::tcgen05_cp<true>(..., (*reinterpret_cast<uint32_t*>(sfa_data)) + 0);
+    tl::tcgen05_cp<true>(..., (*reinterpret_cast<uint32_t*>(sfb_data)) + 8);
-    tl::tcgen05mma_blockscaled_ss<...>(..., (*reinterpret_cast<uint32_t*>(sfa_data)) + 0, (*reinterpret_cast<uint32_t*>(sfb_data)) + 0);
+    tl::tcgen05mma_blockscaled_ss<...>(..., (*reinterpret_cast<uint32_t*>(sfb_data)) + 8, (*reinterpret_cast<uint32_t*>(sfb_data)) + 0);
-    tl::tmem_deallocate<true>((&(sfa_data[0])), 32);

One fewer address word, one fewer alloc/dealloc pair, and the MMA loads one base address instead of two — so this removes a shared-memory load rather than adding one.

Wall-clock is not a usable signal on the machine I measured on (shared with another tenant): three interleaved rounds of the block-scaled GEMM gave baseline 3032 / 2931 / 1303 TFLOP/s against 2824 / 3031 / 1324, i.e. ±7% within a round and 2.3x across rounds. The code-level facts above are the claim; the timings only confirm nothing catastrophic.

Tests

New testing/python/transform/test_tilelang_transform_tmem_arena_packing.py:

  • 396 columns across four buffers become one 512-column allocation at offsets 0 / 384 / 388 / 392, matching DeepGEMM's hand-written layout column for column
  • block-scale factors share an allocation while the accumulator keeps its own, because packing all three would cost more
  • equal-cost cases keep separate allocations
  • allocations are issued widest first (PTX forbids a later tcgen05.alloc from being larger)
  • the never-worse invariant over six shape combinations
  • explicit T.deallocate_tmem keeps its own allocation
  • a dynamic (pipeline-stage) coordinate still carries the constant offset
  • an over-budget block is reported

New GPU test in test_tilelang_language_tmem_copy.py: a store/load roundtrip through two buffers sharing one allocation. The wide buffer's roundtrip is the assertion that matters — the narrow one would still round-trip if its store and load dropped the offset together, but its store would land on the wide buffer's columns.

Existing coverage, on a B200:

  • testing/python/transform/: 380 passed, 7 skipped
  • tcgen05 GEMM / sliced-operand / TMEM-copy / atom-MMA language tests plus the bf16-TS and int8 TMEM kernel tests: 58 passed, 1 skipped
  • examples run end to end: mxfp8 and grouped mxfp8 block-scaled GEMM, mha_fwd_bshd, gqa_fwd_bshd, fp8_fp4_gemm_1d1d_sm100, and the three gemm_sm100 variants

The new tests were validated by mutation: dropping the column offset in LowerSharedTmem fails the GPU roundtrip and three of the planning tests.

Notes

  • The planning step lives inside LowerSharedTmem rather than in a separate pass, since buffer collection, column sizing and allocation emission are all already there. It is a self-contained function and can be split out if a second consumer appears.
  • Packing relies on the convention that a tl.ptx_tcgen05_* intrinsic passes a TMEM operand's base address immediately followed by its offset. That convention is now documented at the point where it is depended on, and a base address reaching any other context is a hard error.

LowerSharedTmem gave every T.alloc_tmem buffer its own tcgen05.alloc,
rounded up to a power of two.  Small buffers therefore each burned a
32-column allocation: a 384-column accumulator next to three 4-column
block-scale buffers needs 396 columns but asked the hardware for
512 + 32 + 32 + 32 = 608 of the 512 a CTA has, so that kernel could not
be expressed at all.  Kernels worked around it by declaring one wide
buffer and slicing scale factors out of it by hand.

Plan the allocations instead.  PlanTmemArenas packs logical buffers into
shared allocations, and each buffer's column offset inside the one it
shares is folded into every address it forms -- both the encoded
coordinate of a BufferLoad, which tcgen05.ld/st consume, and the
(base, offset) argument pairs of the tl.ptx_tcgen05_* intrinsics.

Buffers at least 32 columns wide keep the 32-column alignment
tcgen05.alloc would have given them alone; narrower ones use the
4-column granularity of tcgen05.cp.32x128b.warpx4, the same boundary
CUTLASS and DeepGEMM place these operands on.

A buffer joins an existing allocation only when that strictly reduces the
total column count, so packing can never grow a kernel's TMEM footprint
and a kernel whose buffers already fit lowers exactly as before.  The
mxfp8 block-scaled GEMM drops from 320 to 288 columns; flash attention
and single-accumulator GEMMs are untouched.

Buffers with an explicit T.deallocate_tmem keep their own allocation,
since releasing one early would release whatever shares it.  A base
address that reaches an operation this pass cannot offset is rejected
rather than silently addressing the start of the allocation.  A block
that allocates more than 512 columns is now reported at compile time
instead of failing inside tcgen05.alloc on device.
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the TileLang project.

Please remember to run pre-commit run --all-files in the root directory of the project to ensure your changes are properly linted and formatted. This will help ensure your contribution passes the format check.

We appreciate you taking this step! Our team will review your contribution, and we look forward to your awesome work! 🚀

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

TMEM lowering now packs compatible buffers into shared physical arenas. It tracks column offsets, preserves explicit deallocation lifetimes, emits arena-based allocation sequences, rebases supported accesses, and validates capacity. CUDA tests cover packing, addressing, allocation order, and roundtrip behavior.

Changes

TMEM arena packing and lowering

Layer / File(s) Summary
Arena planning and packing
src/cuda/transform/lower_shared_tmem.cc, testing/python/transform/test_tilelang_transform_tmem_arena_packing.py
TMEM buffers are sized and aligned in b32 columns. The planner packs compatible buffers, preserves declaration order, enforces capacity limits, and validates packing costs.
Arena allocation lifecycle
src/cuda/transform/lower_shared_tmem.cc, testing/python/transform/test_tilelang_transform_lower_shared_tmem.py, testing/python/transform/test_tilelang_transform_tmem_arena_packing.py
Each planned arena receives shared allocation, initialization, declaration, and deallocation handling. Explicitly deallocated buffers remain isolated.
Packed address rebasing and validation
src/cuda/transform/lower_shared_tmem.cc, testing/python/language/test_tilelang_language_tmem_copy.py, testing/python/transform/test_tilelang_transform_tmem_arena_packing.py, testing/python/transform/test_tilelang_transform_inject_tcgen05_fence.py, testing/python/transform/test_tilelang_transform_tmem_physical_shape.py
Buffer loads and supported tl.ptx_tcgen05_* calls include arena offsets. Dynamic coordinates and shared-allocation roundtrips are tested. CUDA capability gates restrict relevant tests to supported targets.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • tile-ai/tilelang#2827 — The PR implements the issue’s TMEM arena packing, column offsets, and explicit deallocation handling.

Possibly related PRs

  • tile-ai/tilelang#2774 — Both changes modify lower_shared_tmem.cc for TMEM buffer discovery and address lowering.
  • tile-ai/tilelang#2781 — Both changes modify TCGEN05 TMEM allocation, deallocation, and memory operations.
  • tile-ai/tilelang#2660 — Both changes address allocation sizing and packing, although this PR targets TMEM lowering.

Suggested reviewers: leiwang1999

Sequence Diagram(s)

sequenceDiagram
  participant TMEMBufferDiscovery
  participant ArenaPlanner
  participant TMEMLowering
  participant TCGEN05Operations
  TMEMBufferDiscovery->>ArenaPlanner: Provide ordered buffers and column sizes
  ArenaPlanner->>TMEMLowering: Provide arenas and per-buffer offsets
  TMEMLowering->>TCGEN05Operations: Emit rebased base and offset operands
  TCGEN05Operations-->>TMEMLowering: Execute packed buffer accesses
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main CUDA change: packing logical TMEM buffers into shared allocation arenas.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@Rachmanino Rachmanino changed the title [CUDA] Pack logical TMEM buffers into shared tcgen05.alloc arenas [CUDA] Pack logical TMEM buffers into shared tcgen05.alloc arenas Jul 31, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/cuda/transform/lower_shared_tmem.cc (1)

515-525: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use std::stable_sort for the allocation order.

std::sort is not stable, so arenas with equal num_cols_allocated can be emitted in an order that depends on the standard library implementation. PlanTmemArenas already uses std::stable_sort to keep the plan reproducible. Match that here so the emitted tcgen05.alloc sequence stays deterministic.

♻️ Proposed change
-    std::sort(init_mtmem_calls_.begin(), init_mtmem_calls_.end(),
-              compare_by_num_cols_desc);
+    std::stable_sort(init_mtmem_calls_.begin(), init_mtmem_calls_.end(),
+                     compare_by_num_cols_desc);
🤖 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 `@src/cuda/transform/lower_shared_tmem.cc` around lines 515 - 525, Replace
std::sort with std::stable_sort in the allocation ordering around
compare_by_num_cols_desc, preserving the descending num_cols ordering while
retaining the original order for equal-sized allocations and matching
PlanTmemArenas’ deterministic behavior.
🤖 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 `@src/cuda/transform/lower_shared_tmem.cc`:
- Around line 315-338: Update GetNumB32ColsRequired to retain the analyzer
bounds for both shape dimensions as int64_t, and validate each extent’s lower
bound and upper bound before converting to int; reject non-constant or
non-positive extents and values exceeding the TMEM row/column limits. Only
narrow to int after validation, preserving the existing row and rounded 32-bit
column calculations.

---

Nitpick comments:
In `@src/cuda/transform/lower_shared_tmem.cc`:
- Around line 515-525: Replace std::sort with std::stable_sort in the allocation
ordering around compare_by_num_cols_desc, preserving the descending num_cols
ordering while retaining the original order for equal-sized allocations and
matching PlanTmemArenas’ deterministic behavior.
🪄 Autofix (Beta)

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: Pro Plus

Run ID: 17aaa9bd-245e-4504-be22-403faf0c7d5a

📥 Commits

Reviewing files that changed from the base of the PR and between bdb769a and 72c32b3.

📒 Files selected for processing (3)
  • src/cuda/transform/lower_shared_tmem.cc
  • testing/python/language/test_tilelang_language_tmem_copy.py
  • testing/python/transform/test_tilelang_transform_tmem_arena_packing.py

Comment on lines +315 to +338
int GetNumB32ColsRequired(const Buffer &buffer) const {
ICHECK_EQ(buffer->shape.size(), 2U);

auto analyzer = std::make_shared<arith::Analyzer>();
arith::ConstIntBound phy_col_bounds =
analyzer->const_int_bound(buffer->shape[1]);
int num_value_cols_required = phy_col_bounds->max_value;
arith::Analyzer analyzer;
int num_rows_required =
analyzer.const_int_bound(buffer->shape[0])->max_value;
ICHECK(num_rows_required <= kTmemNumDatapaths)
<< "The number of rows required for tmem buffer " << buffer->name
<< " is " << num_rows_required << ", which exceeds the maximum of "
<< kTmemNumDatapaths << " rows";

int num_value_cols_required =
analyzer.const_int_bound(buffer->shape[1])->max_value;
// Layout column coordinates count values of buffer->dtype; PTX TMEM
// allocation counts 32-bit columns. Round up so a final partially
// occupied b32 column is included.
int num_cols_required =
(num_value_cols_required * GetValueBitWidth(buffer->dtype) + 31) / 32;
ICHECK(num_cols_required <= 512)
ICHECK(num_cols_required <= kTmemNumColumns)
<< "The number of columns required for tmem buffer " << buffer->name
<< " is " << num_cols_required
<< ", which exceeds the maximum of 512 columns";
<< " is " << num_cols_required << ", which exceeds the maximum of "
<< kTmemNumColumns << " columns";
return num_cols_required;
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the const-int bounds in int64_t and check the lower bound.

ConstIntBound::max_value is int64_t. A TMEM buffer whose shape is not a constant yields the analyzer's positive-infinity sentinel. The narrowing conversion to int can then produce a small or negative value, both ICHECKs pass, and the arena is sized from a wrong column count. Validate in int64_t and reject a non-constant or non-positive extent before narrowing.

🛡️ Proposed hardening
-    arith::Analyzer analyzer;
-    int num_rows_required =
-        analyzer.const_int_bound(buffer->shape[0])->max_value;
-    ICHECK(num_rows_required <= kTmemNumDatapaths)
+    arith::Analyzer analyzer;
+    int64_t num_rows_required =
+        analyzer.const_int_bound(buffer->shape[0])->max_value;
+    ICHECK(num_rows_required >= 1 && num_rows_required <= kTmemNumDatapaths)
         << "The number of rows required for tmem buffer " << buffer->name
         << " is " << num_rows_required << ", which exceeds the maximum of "
         << kTmemNumDatapaths << " rows";
 
-    int num_value_cols_required =
-        analyzer.const_int_bound(buffer->shape[1])->max_value;
+    int64_t num_value_cols_required =
+        analyzer.const_int_bound(buffer->shape[1])->max_value;
+    ICHECK_GE(num_value_cols_required, 1)
+        << "TMEM buffer " << buffer->name
+        << " must have a constant column extent, but got " << buffer->shape[1];
     // Layout column coordinates count values of buffer->dtype; PTX TMEM
     // allocation counts 32-bit columns.  Round up so a final partially
     // occupied b32 column is included.
-    int num_cols_required =
+    int64_t num_cols_required =
         (num_value_cols_required * GetValueBitWidth(buffer->dtype) + 31) / 32;
     ICHECK(num_cols_required <= kTmemNumColumns)
         << "The number of columns required for tmem buffer " << buffer->name
         << " is " << num_cols_required << ", which exceeds the maximum of "
         << kTmemNumColumns << " columns";
-    return num_cols_required;
+    return static_cast<int>(num_cols_required);
🤖 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 `@src/cuda/transform/lower_shared_tmem.cc` around lines 315 - 338, Update
GetNumB32ColsRequired to retain the analyzer bounds for both shape dimensions as
int64_t, and validate each extent’s lower bound and upper bound before
converting to int; reject non-constant or non-positive extents and values
exceeding the TMEM row/column limits. Only narrow to int after validation,
preserving the existing row and rounded 32-bit column calculations.

Pins the plan the packer produces, not just that it produces one:

- 396 columns across four buffers become one 512-column allocation at
  offsets 0 / 384 / 388 / 392, matching DeepGEMM's hand-written layout
  for this kernel column for column
- block-scale factors share an allocation while the accumulator keeps
  its own, because packing all three would cost more than it saves
- equal-cost cases keep separate allocations, so a kernel that already
  fits is untouched
- allocations are issued widest first, as PTX requires
- packing never allocates more than separate allocations would, over six
  shape combinations
- an explicit T.deallocate_tmem keeps its own allocation
- a dynamic pipeline-stage coordinate still carries the column offset
- a block over the 512-column budget is reported
Covers the tcgen05.st/ld address path for a buffer packed at a nonzero
column offset, which no existing kernel exercises: the block-scale
buffers that share an allocation today are never read back into
registers.

The wide buffer's roundtrip is the assertion that matters.  The narrow
one would still round-trip if its store and its load dropped the column
offset together -- but its store would land on the wide buffer's first
columns, so only the wide readback catches it.  Verified by mutation:
dropping the offset in LowerSharedTmem fails this test.
Gate every TMEM test the same way, matching the decorators the tcgen05
language and kernel tests already carry.

These three lower IR for a hardcoded sm_100 target and need no device, so
this does trade running them on any machine for consistency with the rest
of the TMEM suite.
@Rachmanino
Rachmanino force-pushed the feat/tmem-arena-packing branch from 72c32b3 to 50276e5 Compare July 31, 2026 11:42
@LeiWang1999
LeiWang1999 requested a review from Yongqi-Zhuo July 31, 2026 12:02
@Yongqi-Zhuo

Yongqi-Zhuo commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

LGTM cc @LeiWang1999

@LeiWang1999
LeiWang1999 merged commit a426ff3 into tile-ai:main Aug 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants