API Card: Blackwell Pipeline (tcgen05)#
Note
This chapter is part of the agent-targeted Agent BLAS Performance Guide and is also published as Markdown for
agent consumption, in the markdown/ folder of the HTML documentation tree.
This card covers the Blackwell tcgen05 (utcmma) pipeline path on SM 1000, 1030, 1070
and 1100 — SM<1000, arch_specific> built for sm_100a, and the 1030 / sm_103a,
1070 / sm_107a and 1100 / sm_110a equivalents (1070 is
Rubin SM107, which uses the SM 1030 instruction set: TMA and floating-point UTCMMA, no integer
tcgen05).
SM 1200-class parts are not on this path and resolve to supermma. The host and device calls match
the Hopper card; this card repeats them so that it reads alone and states every difference.
The shipped example whose descriptor passes the gates below when compiled for a Blackwell architecture
is example/cublasdx/19_gemm_batched/batched_gemm_pipeline.cu (128 threads, EnableInputStreaming(), 16-byte alignment, get_tile once and
reset_tile per iteration). Its Blackwell build needs CUDA Toolkit 13.1 or newer and 100a
in CUBLASDX_CUDA_ARCHITECTURES.
Descriptor. The utcmma instruction class has silent gates. Missing any one of them leaves the
tcgen05 path with no error; the result is the older supermma class.
WithPipeline(),EnableInputStreaming()andSM<1000, arch_specific>()(or1030/1070/1100);family_specificis not accepted.BlockDim<128>exactly. This differs from Hopper: 256 or 512 threads silently leave the path.16-byte alignment of
A,BandC(Alignment<16, 16, 16>=MaxAlignment()); a smallerCalignment deselects the path.Precisionfrom the supported real combinations:halfinputs with ahalforfloataccumulator,bfloat16withfloat,tf32withfloat,int8/uint8withint32(not on SM 1030 or 1070, which have no integertcgen05instruction), andfp8(e4m3ore5m2, any pairing) withhalforfloat.doubleand complexTypeare off the path.Size:Ma multiple of 64 up to 256 (fp8with CUTLASS older than 4.5.1: a multiple of 128, or exactly 64);Na multiple of 8 between 8 and 256 for 16-bit andtf32inputs (forint8/uint8either 8 with a K-majorBor a multiple of 16; forfp8a multiple of 8 with a K-majorBand of 16 otherwise);Ka multiple of 16 for 16-bit inputs, of 8 fortf32, of 32 for 8-bit inputs, and at most 256 (a largerMorKleaves the path forsupermmainstead of failing; TMA box modes cap at 256).The accumulator must also fit tensor memory:
M / 128blocks forMa multiple of 128, otherwiseM / 64blocks, timesNcolumns (one 32-bit column per accumulator element,halfincluded) must not exceed 512 columns.With
floataccumulation256 x 256and192 x 128fit and192 x 256does not.Arrangementonly fortf32, which needs K-majorAandB(row_majorA,col_majorB, that is TN).half,bfloat16,int8/uint8andfp8reachtcgen05in either majorness; this differs from Hopper, where the 8-bit types need TN.
The descriptor below satisfies all of them:
using BLAS = decltype(cublasdx::Size<128, 256, 64>()
+ cublasdx::Precision<__nv_bfloat16, __nv_bfloat16, float>()
+ cublasdx::Type<cublasdx::type::real>() + cublasdx::Function<cublasdx::function::MM>()
+ cublasdx::Arrangement<cublasdx::row_major, cublasdx::col_major, cublasdx::row_major>() // TN; free for bf16
+ cublasdx::Block() + cublasdx::BlockDim<128>() // exactly 128
+ cublasdx::Alignment<16, 16, 16>() // = MaxAlignment()
+ cublasdx::WithPipeline() + cublasdx::EnableInputStreaming()
+ cublasdx::SM<1000, cublasdx::arch_specific>()); // or 1030, 1070, 1100
Confirm the path in the SASS, per kernel, as the porting workflow describes (/UTC[A-Z]*MMA/).
As on Hopper, a descriptor that misses a gate above, and a launch whose global tensors’ element types
are not exactly BLAS::a_value_type and BLAS::b_value_type (a same-size cutlass::bfloat16_t* passes every other
check), both silently run supermma and show no match.
A build that lands on plain sm_100 instead of sm_100a is rejected by ptxas:
the tcgen05 instructions exist only on the a target.
Host side. Identical to the Hopper card:
auto A = cublasdx::make_gmem_tensor<cublasdx::row_major>(a_ptr, M, K, lda); // a_ptr: BLAS::a_value_type*
auto B = cublasdx::make_gmem_tensor<cublasdx::col_major>(b_ptr, K, N, ldb);
auto p = cublasdx::suggest_pipeline<Depth, BLAS>(A, B); // Depth: 1..16, explicit
if (!p) { /* cublasdx::pipeline_error_string(p.error().code) */ }
size_t smem = cublasdx::make_shared_storage_calculator()
.add(p->buffer_alignment(), p->buffer_size()) // stages + barriers + TMEM handle
.add(scratch_alignment, scratch_bytes) // your epilogue / schedule scratch
.get();
cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, int(smem));
kernel<<<grid, p->get_block_dim(), smem, stream>>>(..., p->get_device_handle());
The shape checks, the error codes (including
alignment_unfulfilled), the freeDepthand the compile-time checks are those of the Hopper card, and so are the lifetime, stream and launch rules (Pipeline Lifetime, Streams And Launch): one handle serves the whole grid, it is passed by value, and a pipeline withRequiredMantissaBitsallocates and slices on the stream passed at creation.get_block_dim()returns 192 threads forBlockDim<128>: the 128 epilogue threads plus one warp that issuestcgen05.mmaand one that issues TMA. Use it, not the descriptor’s thread count, and declare the kernel with__launch_bounds__(DevicePipeline::max_threads_per_block, 1).Passing
cublasdx::fixed_blocksizeas the template argument after the result-storage mode (suggest_pipeline<Depth, BLAS, cublasdx::internal_accumulator, cublasdx::fixed_blocksize>) gives a 128-thread block instead, in which warps 0 and 1 issue the MMA and TMA work in addition to their share of the epilogue; it stays on thetcgen05path and is the slower configuration.The pipeline allocates all 512 columns of tensor memory when the tile pipeline is constructed and frees them when it is destroyed, which is one more reason the kernel runs one CTA per SM. Do not allocate tensor memory in the same kernel.
Pass the handle as
__grid_constant__ DevicePipeline const device_pipeline.
Depth and shared memory. One stage holds one A tile and one B tile, (M*K + N*K) * sizeof(input) bytes, 48 KiB
for Size<128, 256, 64> in bf16, as on Hopper;
buffer_size() adds 8 bytes per barrier, two per stage plus two per tensor-memory stage, and a 4-byte
tensor-memory handle: 196,708 B at depth 4 for that tile.
The opt-in limit on a B200 is the H100’s 232,448 B for everything in the block; compute buffer_size()
for a candidate depth on the host and compare it with cudaDevAttrMaxSharedMemoryPerBlockOptin instead of launching.
On SM 1070 (Rubin SM107) the depth suggest_pipeline picks without an explicit depth is budgeted to 227
KiB;
a tile whose single stage plus fixed storage does not fit that but fits below 327 KiB gets depth 1, and
such a launch needs cudaFuncSetAttribute(kernel, cudaFuncAttributeSharedMemoryMode, cudaSharedMemoryModeAllowOversizedSharedMemory) instead of the cudaFuncAttributeMaxDynamicSharedMemorySize call, which is ignored once a
shared-memory mode is set.
Check the requested size against cudaDevAttrOversizedSharedMemoryPerBlock (CUDA runtime 13.4 or newer; the examples handle both
paths in example::set_max_dynamic_shared_memory_size from example/cublasdx/common/common.hpp). Explicit depths may use more than 227 KiB.
Tensor memory is staged separately: the accumulator gets 512 / (columns per stage) stages (at most 16), two for 128 x 256
in float, so the epilogue of one tile overlaps the MMAs of the next without any shared memory
of its own.
Device side. Use the loop from the Hopper card:
extern __shared__ __align__(device_pipeline.buffer_alignment()) cublasdx::byte smem[];
auto tile = device_pipeline.get_tile(smem, row, col); // ONCE per CTA
for (;;) {
tile.execute([&](auto& acc) { // runs on the 128 epilogue threads only
auto out = cublasdx::get_tile(C, BLAS::c_shape, row, col);
acc.partition_and_store(out);
});
/* advance (row, col) from a host-computed tile count; break uniformly for all threads */
device_pipeline.reset_tile(tile, row, col); // re-point; no barrier re-init
}
get_tileonce per CTA,reset_tilefor every further tile, a host-computed bound and a uniform exit across the whole block, MMA and TMA warps included: as on Hopper.The epilogue functor runs on the 128 threads of
BlockDim, and every one of them owns part of the result:acc.is_thread_active()is true for all 128 and exists for code shared with the other paths. The MMA and TMA warps never enter the functor;tile.epilogue_sync()is the barrier among the 128 and__syncthreads()inside the functor deadlocks against the other two warps.Epilogue accessors:
reduce_and_store(out, lambda),process_and_store(out, op),partition_and_store(out),acc.axpby(alpha, beta, out)andacc.get_results()have the meaning given in the Hopper card, with two differences.Each of them acquires the tile’s tensor-memory stage, reads it and releases it, so each of the 128 threads makes exactly one such call per tile, unconditionally: a second call waits for the next tile’s accumulation, and a thread that skips the call leaves the stage barrier one arrival short, so the kernel hangs.
Read the fragment once with
get_results()and keep the register copy when the values are needed more than once;acc.map_fragment_index(i)andacc.is_index_in_bounds(i)do not touch tensor memory and may be called freely.The output of
partition_and_storemust be a memory-backed tensor (shared or global);process_and_store,reduce_and_storeandaxpbytake memory or a register fragment of exactly the accumulator’s fragment size, and any other destination is a compile error (partition_and_storeinto a register fragment points you toprocess_and_storewithidentity{}).With
cublasdx::reusable_accumulatoras the template argument afterBLASofsuggest_pipeline, calltile.get_accumulator()once before the loop,tile.execute(acc)per tile, thenacc.finish_accumulation()from every thread of the block, the MMA and TMA warps included and outside anyis_thread_active()guard: the commit it performs is issued by the MMA warp, which that guard excludes.Then make the one consuming call above. Do not construct a second accumulator inside the loop.
The pipeline owns named barriers 1 and 3 (the Hopper pipeline owns 1 and 2); named-barrier ids of your own must avoid them.
Issue no
tcgen05.ld/tcgen05.stand no tensor-memory fences of your own; the accessors above carry them. Asynchronous stores you issue in the epilogue (cp.async.bulk) must be waited on before the CTA reuses their source or exits.A reference that uses
cta_group::2(2-CTA UMMA) or TMA multicast across a cluster is matched withBlocksPerCluster<M, N>(), described in the Hopper card. On SM 1000, 1030, 1070 and 1100 an even clusterMrequests the 2-CTA atom, which covers a pair of per-CTASizetiles;Bis then multicast over clusterM / 2, soBlocksPerCluster<2, 1>is one pair with no multicast and<4, 1>multicastsBacross two pairs, whileAis still multicast alongN.If the shape cannot take it (
Nnot a multiple of 16; of 32 forint8/uint8, and forfp8with an MN-majorB), the pipeline silently uses the 1-CTA atom with no diagnostic; measure before assuming the pair is active.The pair needs the same
EnableInputStreaming()descriptor as anytcgen05path, and the grid must be whole clusters of per-CTA tiles (cluster_grid_divisibility). A CUTLASS256 x N2SM tile corresponds toSize<128, N, K>with an even clusterM, not to aSize<256, ...>tile.UTCHMMA(16-bit inputs) andUTCIMMA(8-bit integers) are the SASS spellings seen for the check above, so match the/UTC[A-Z]*MMA/pattern rather than a name.