The cuh file to run:
#pragma once
// =====================================================================
// tmem_drain_repro.cuh -- standalone SM100 (B200) repro:
// TMEM (tcgen05) fp32 accumulator -> bf16 register tile drain.
//
// THE ISSUE (CHUNKED=0, "naive"): draining a (TILE_M x TILE_N) fp32
// accumulator from Tensor Memory and converting to bf16 with one
// whole-tile cute::copy materializes the FULL per-thread fp32 fragment
// in registers before any conversion happens. At TILE_M=256 that is
// 512 fp32/thread + the 256-element bf16 destination tile -> far past
// the 255-register cap -> ptxas spills to local memory (see -Xptxas -v
// "spill stores/loads" and STL/LDL in the SASS).
//
// THE WORKAROUND (CHUNKED=1): slice the tmem-side partition into
// 16-value chunks (one 32dp32b16x LDTM each), reuse ONE 16-fp32 staging
// fragment, and convert into the bf16 tile IMMEDIATELY after each chunk
// load, before the next chunk's load is issued. ptxas then keeps the
// staging fragment in the same 16 registers for every iteration
// (load -> convert -> load -> convert).
//
// The bf16 tile is kept alive after the drain by a few rounds of
// arbitrary math + a store, so nothing is dead-code-eliminated.
//
// Build variants (see build_tmem_repro.sh):
// TILE_M = 128 | 256 (accumulator rows; N fixed at 256)
// CHUNKED = 0 | 1 (naive whole-tile copy | chunked copy+convert)
//
// Environment: CUDA 13, CUTLASS 4.x includes, sm_100a. Compile-only
// repro (no launch needed to inspect PTX/SASS). Same behavior under
// NVRTC and nvcc (ptxas is the common backend).
// =====================================================================
#include <cute/tensor.hpp>
#include <cute/atom/mma_traits_sm100.hpp> // SM100 UMMA atoms + tmem fragments
#include <cute/atom/copy_traits_sm100.hpp> // SM100_TMEM_LOAD_* + make_tmem_copy
#include <cute/arch/tmem_allocator_sm100.hpp>
#include <cutlass/numeric_types.h>
#include <cuda_bf16.h>
using namespace cute;
#ifndef TILE_M
#define TILE_M 256
#endif
#ifndef CHUNKED
#define CHUNKED 0
#endif
constexpr int TILE_N_R = 256;
extern "C" __global__ void __launch_bounds__(128, 1)
tmem_drain_repro(__nv_bfloat16* __restrict__ out, __nv_bfloat162 mulk, __nv_bfloat162 addk) {
// ---- TMEM allocation (full 512 columns), one warp allocates ----
__shared__ uint32_t tmem_base;
cute::TMEM::Allocator1Sm ta{};
if ((threadIdx.x >> 5) == 0u) ta.allocate(cute::TMEM::Allocator1Sm::Sm100TmemCapacityColumns, &tmem_base);
__syncthreads();
if ((threadIdx.x >> 5) == 0u) ta.release_allocation_lock();
__syncthreads();
// ---- a (TILE_M x TILE_N) fp32 accumulator in TMEM, shaped by the 1SM UMMA atom (M=128, N=256).
// TILE_M=256 -> MMA_M=2 reps. No MMA is ever issued; only the fragment SHAPES matter here. ----
auto tiled_mma = make_tiled_mma(SM100_MMA_F16BF16_SS<cutlass::bfloat16_t, cutlass::bfloat16_t, float,
128, TILE_N_R, UMMA::Major::K, UMMA::Major::K>{});
auto cta_mma = tiled_mma.get_slice(0);
Tensor gC = make_tensor(make_gmem_ptr(reinterpret_cast<cutlass::bfloat16_t*>(out)),
make_layout(make_shape(Int<TILE_M>{}, Int<TILE_N_R>{}),
make_stride(Int<TILE_N_R>{}, Int<1>{})));
Tensor tCgC = cta_mma.partition_C(gC); // ((M,N), MMA_M, MMA_N) gmem view (also the final store target)
Tensor tAcc = cta_mma.make_fragment_C(tCgC); // fp32 TMEM accumulator tensor
tAcc.data() = tmem_base;
// ---- TMEM->RMEM tiled copy: 32dp32b16x atom = 16 fp32 per thread per LDTM ----
TiledCopy t2r = make_tmem_copy(SM100_TMEM_LOAD_32dp32b16x{}, tAcc);
ThrCopy thr = t2r.get_slice((int)threadIdx.x);
Tensor tS = thr.partition_S(tAcc); // TMEM source (V, iter modes...)
Tensor tD = thr.partition_D(tCgC); // gmem reference (V, iter modes...)
constexpr int R = decltype(rank(tD))::value;
auto tSg = group_modes<1, R>(tS); // (V, (all chunks))
auto tDg = group_modes<1, R>(tD);
constexpr int NCH = decltype(size<1>(tDg))::value; // 16 chunks at TILE_M=128, 32 at TILE_M=256
// per-thread bf16 destination tile: ((V)=16 bf16, NCH) -> 128/256 bf16 = 64/128 registers
Tensor held = make_tensor<cutlass::bfloat16_t>(make_shape(shape(tDg(_, 0)), Int<NCH>{}));
#if CHUNKED == 0
// ================= NAIVE (the problem): whole-tile copy, then convert =================
Tensor rAll = make_tensor<float>(shape(tDg)); // FULL per-thread fp32 fragment (256/512 fp32!)
copy(t2r, tSg, rAll); // all LDTMs land before any conversion
{
auto f2 = recast<float2>(rAll);
auto b2 = recast<__nv_bfloat162>(held);
CUTE_UNROLL
for (int e = 0; e < (int)size(b2); ++e) b2(e) = __float22bfloat162_rn(f2(e));
}
#else
// ================= CHUNKED (the fix): one chunk load -> convert immediately =================
Tensor rC = make_tensor<float>(shape(tDg(_, 0))); // 16 fp32 staging, REUSED by every chunk
CUTE_UNROLL
for (int c = 0; c < NCH; ++c) {
copy(t2r, tSg(_, c), rC); // one LDTM.x16
auto f2 = recast<float2>(rC);
auto b2 = recast<__nv_bfloat162>(held(_, c));
CUTE_UNROLL
for (int e = 0; e < (int)size(f2); ++e) b2(e) = __float22bfloat162_rn(f2(e)); // consume BEFORE the next chunk's load
}
#endif
// ---- keep the bf16 register tile ALIVE: a few rounds of arbitrary math ----
{
auto b2 = recast<__nv_bfloat162>(held);
CUTE_UNROLL
for (int it = 0; it < 5; ++it) {
CUTE_UNROLL
for (int e = 0; e < (int)size(b2); ++e) b2(e) = __hfma2(b2(e), mulk, addk);
}
}
// ---- store the tile (prevents DCE; plain elementwise STG) ----
CUTE_UNROLL
for (int c = 0; c < NCH; ++c) copy(held(_, c), tDg(_, c));
// ---- release TMEM ----
__syncthreads();
if ((threadIdx.x >> 5) == 0u) ta.free(tmem_base, cute::TMEM::Allocator1Sm::Sm100TmemCapacityColumns);
}
The script that iterates 4 scenarios to compare & produce sass/ptx:
#!/usr/bin/env bash
# =====================================================================
# build_tmem_repro.sh -- compile tmem_drain_repro.cuh in 4 variants and
# dump PTX + SASS + register/spill usage. Compile-only (no GPU needed).
# usage: CUTLASS=/path/to/cutlass ./build_tmem_repro.sh
# Outputs (per variant m{128|256}_{naive|chunked}):
# repro_<tag>.ptx / .sass / .resusage / .buildlog + a summary table.
# =====================================================================
set -e
cd "$(dirname "$0")"
CUTLASS="${CUTLASS:-$HOME/Desktop/cutlass}"
ARCH=sm_100a
FLAGS="-std=c++20 -O3 --expt-relaxed-constexpr -I$CUTLASS/include -I$CUTLASS/tools/util/include"
echo "== building against CUTLASS at: $CUTLASS =="
for TM in 128 256; do
for CH in 0 1; do
tag="m${TM}_$([ "$CH" -eq 1 ] && echo chunked || echo naive)"
echo "--- $tag ---"
nvcc -arch=$ARCH $FLAGS -DTILE_M=$TM -DCHUNKED=$CH -x cu tmem_drain_repro.cuh \
-cubin -o repro_$tag.cubin -Xptxas -v 2> repro_$tag.buildlog || { cat repro_$tag.buildlog; exit 1; }
nvcc -arch=compute_100a $FLAGS -DTILE_M=$TM -DCHUNKED=$CH -x cu tmem_drain_repro.cuh \
-ptx -o repro_$tag.ptx
cuobjdump -sass repro_$tag.cubin > repro_$tag.sass
cuobjdump -res-usage repro_$tag.cubin > repro_$tag.resusage
done
done
echo
echo "===================== SUMMARY ====================="
for f in repro_*.buildlog; do
tag=${f#repro_}; tag=${tag%.buildlog}
regs=$(grep -oE "Used [0-9]+ registers" "$f" | head -1)
spill=$(grep -oE "[0-9]+ bytes spill stores, [0-9]+ bytes spill loads" "$f" | head -1)
stack=$(grep -oE "[0-9]+ bytes stack frame" "$f" | head -1)
sass=repro_$tag.sass
stl=$(grep -c " STL" "$sass" || true); ldl=$(grep -c " LDL" "$sass" || true)
ldtm=$(grep -c "LDTM" "$sass" || true)
printf "%-14s %-20s %-14s spills:[%s] SASS: STL=%s LDL=%s LDTM=%s\n" \
"$tag" "${regs:-?}" "${stack:-0 stack}" "${spill:-none}" "$stl" "$ldl" "$ldtm"
done
echo
echo "The tmem->rmem loop: grep -n -A12 'LDTM' repro_m256_chunked.sass | head -80"
The result:
== building against CUTLASS at: /home/tugrul-b200/Desktop/cutlass ==
--- m128_naive ---
tmem_drain_repro.cuh:1:9: warning: #pragma once in main file
1 | #pragma once
| ^~~~
--- m128_chunked ---
tmem_drain_repro.cuh:1:9: warning: #pragma once in main file
1 | #pragma once
| ^~~~
--- m256_naive ---
tmem_drain_repro.cuh:1:9: warning: #pragma once in main file
1 | #pragma once
| ^~~~
--- m256_chunked ---
tmem_drain_repro.cuh:1:9: warning: #pragma once in main file
1 | #pragma once
| ^~~~
===================== SUMMARY =====================
m128_chunked Used 148 registers 0 bytes stack frame spills:[0 bytes spill stores, 0 bytes spill loads] SASS: STL=0 LDL=0 LDTM=16
m128_naive Used 255 registers 64 bytes stack frame spills:[64 bytes spill stores, 64 bytes spill loads] SASS: STL=8 LDL=16 LDTM=16
m256_chunked Used 255 registers 72 bytes stack frame spills:[68 bytes spill stores, 68 bytes spill loads] SASS: STL=17 LDL=17 LDTM=32
m256_naive Used 255 registers 2280 bytes stack frame spills:[2828 bytes spill stores, 2828 bytes spill loads] SASS: STL=571 LDL=707 LDTM=32
The tmem->rmem loop: grep -n -A12 'LDTM' repro_m256_chunked.sass | head -80
So, despite the chunking, 256x256 version spills registers (and this is without any metadata register usage is in a real kernel with more logic than this, I had more spill really). It’s even worse register pressure than naive conversion of 128x256 fp32 to 128x256 bf16 (all at once, maximum spill).
I could not add the generated sass/ptx as forum api doesn’t allow them to be attached as files here.
Naive-m256:
/*0580*/ LOP3.LUT P0, RZ, R0, 0x3, R17, 0x48, !PT ;
/*0590*/ LDTM.x16 R20, tmem[UR4] ;
/*05a0*/ STL.64 [R1+0x400], R20 ;
/*05b0*/ STL.64 [R1+0x408], R22 ;
/*05c0*/ STL.64 [R1+0x410], R24 ;
/*05d0*/ STL.64 [R1+0x418], R26 ;
/*05e0*/ STL.64 [R1+0x420], R28 ;
... (tmem -> registers -> local memory -> reloaded later for F2FP)
Chunked-m256:
/*2690*/ F2FP.BF16.F32.PACK_AB R5, R5, R4 ;
/*26a0*/ F2FP.BF16.F32.PACK_AB R4, R7, R6 ;
/*26b0*/ F2FP.BF16.F32.PACK_AB R3, R13, R12 ;
/*26c0*/ STL [R1+0x4], R5 ; <-- converted bf16x2 word spilled
/*26d0*/ F2FP.BF16.F32.PACK_AB R0, R15, R14 ;
/*26e0*/ F2FP.BF16.F32.PACK_AB R252, R9, R8 ;
/*26f0*/ STL [R1], R4 ; <-- spilled
/*2700*/ F2FP.BF16.F32.PACK_AB R251, R11, R10 ;
/*2710*/ F2FP.BF16.F32.PACK_AB R250, R17, R16 ;
/*2720*/ STL [R1+0xc], R3 ; <-- spilled
/*2730*/ F2FP.BF16.F32.PACK_AB R249, R19, R18 ;
/*2740*/ STL [R1+0x8], R0 ; <-- spilled
... (17 STL total, local slots [R1+0x0 .. R1+0x40] = 68 bytes)
In real kernel, it clones chunk registers many times, with higher total spill, defeating purpose of chunking.
Real kernel:
// source: one reused 16-fp32 staging fragment per chunk.
// but, it's assigning a fresh 16-register window to each:
/*74c0*/ LDTM.x16 R232, tmem[UR4+0x10] ;
/*7650*/ LDTM.x16 R216, tmem[UR4+0x20] ;
/*77e0*/ LDTM.x16 R200, tmem[UR4+0x30] ;
/*7970*/ LDTM.x16 R184, tmem[UR4+0x40] ;
/*7b00*/ LDTM.x16 R168, tmem[UR4+0x50] ;
/*7c90*/ LDTM.x16 R152, tmem[UR4+0x60] ;
/*7e20*/ LDTM.x16 R136, tmem[UR4+0x70] ;
/*7fb0*/ LDTM.x16 R120, tmem[UR4+0x80] ;
/*8140*/ LDTM.x16 R104, tmem[UR4+0x90] ;
/*82d0*/ LDTM.x16 R88, tmem[UR4+0xa0] ;
/*84a0*/ LDTM.x16 R72, tmem[UR4+0xb0] ;
/*85f0*/ LDTM.x16 R56, tmem[UR4+0xc0] ; // ... register file exhausted along the way:
/*72c0*/ STL.64 [R1], R24 ; // some chunks spilled straight after their load
/*72d0*/ STL.64 [R1+0x8], R26 ;
// the conversions only appear after the whole load ladder (~1.5 KB later):
/*8ca0*/ F2FP.BF16.F32.PACK_AB R232, R233, R232 ;
/*8cb0*/ F2FP.BF16.F32.PACK_AB R233, R235, R234 ;
/*8cc0*/ F2FP.BF16.F32.PACK_AB R234, R237, R236 ;
Despite different ways to separate LDTM from F2FP, it always groups all LDTM together with different registers and F2FP together again with different registers.
Is there a way to stop ptxas from batching the LDTMs (a scheduling barrier that survives optimization), or better, a drain path that converts on load — e.g. a tcgen05.ld variant or CUTLASS copy atom emitting packed 16-bit values — so the fp32 staging never occupies registers? At 256×256 even perfect chunking can’t help, since the bf16 destination tile alone is 128 registers/thread.