Using Pipelined GEMM#
Starting with cuBLASDx 0.5.0, the pipelining extension allows users to combine multiple
per-tile operations (the operations exposed by regular BLAS descriptors) into asynchronous pipelines that expose advanced CUDA
features such as TMA, Hopper WGMMA, Blackwell 1SM UTCMMA, Blackwell TMEM, and others.
The current API also exposes 2SM UTCMMA through BlocksPerCluster on supported architectures.
The pipelining interface emphasizes fusion opportunities by moving the level of cuBLASDx execution higher,
starting from the global memory level, while allowing users to wrap their device GEMM into a single fusable and optimized function call.
Important
cuBLASDx Pipelining API is an interface allowing the users to apply cuBLASDx routines to efficiently decompose and
compute big global-memory GEMM problems.
This section is based on the introduction_pipeline.cu example included with cuBLASDx. See the Examples section for additional cuBLASDx samples.
To measure GEMM device performance and compare with a reference implementation, see the device_gemm_performance.cu example included with cuBLASDx.
Pipelining#
The regular MathDx operations are performed on the shared memory tile level, assuming that inputs are ready to be safely
used, so it usually looks like this:
Load data from shared into registers
Perform computations using shared memory as scratchpad (if needed)
Store results back to shared memory
This allows the libraries to utilize the out of order capabilities only on the shared --> registers memory level,
leaving the remaining operations to the users.
Pipelining’s main pillar is overlapping staged data loading with staged computation, thus maximizing the number of bytes and instructions in flight. Load stages and compute stages can both be asynchronous; after the K-stage accumulation is complete, a single epilogue stage consumes the final accumulator and stores or updates the output tile. An advanced extension of pipelining is the producer-consumer model, where separate threads are responsible for loading data into buffers and separate threads for computing results of the loaded elements. cuBLASDx uses warp specialization to implement this model in selected cases.
cuBLASDx 0.2.0 introduced cublasdx::copy, an asynchronous copy mechanism allowing for limited overlapping of
per-tile computations and memory loads for next batch, allowing for fuller utilization of available hardware resources.
Although this release provided building blocks for asynchronous execution, it left construction of entire scaffold to
the user and did not provide space for more sophisticated synchronization schemes, such as mbarriers, thus blocking
the use of Tensor Memory Accelerator.
The cuBLASDx Pipelining API exposes:
same interface for performing optimized GEMM on all architectures,
arbitrary depth scaffolding for performing pipelined Generalized Matrix Multiplication (GEMM) with multiple load and compute stages in flight,
autovectorizing copy mechanism choosing between
TMA/LDGSTS/LDG+STSbased on provided layouts and architecture,synchronization dependent asynchronous MMA instructions, available only in pipelined mode (
WGMMA/1SM UTCMMA, plus2SM UTCMMAenabled by BlocksPerCluster on supported architectures), andmbarrierbased synchronization mechanism, internally choosing between warp-specialized and unified execution paths.
Quick Start Guide#
Pipeline Creation#
To use the pipelining API, two host-side setup steps are necessary:
- On host, create your block-level BLAS description for per-tile operation.
Add
BlockandBlockDimoperators for a block-level GEMM tile.Add
WithPipelineoperator to inform the library that this tile will be used in a pipelined context.(performance) If you won’t be using any per-element input preprocessing, add
EnableInputStreamingoperator. It requires the global A and B tensor value types to match the descriptor’sPrecisionandTypeexactly. Treat it as a candidate and validate the complete kernel against a non-streaming descriptor: accumulator-to-shared epilogues, multiple or persistent accumulators, and adjacent cooperative shared-memory scratch deserve particular scrutiny. Blackwell 1SM UTCMMA input streaming requires exactly 128 threads.EnableInputStreamingis also a hard gate: the suggestedWGMMA(Hopper) andUTCMMA(Blackwell) backends are selected only when it is present, so dropping it silently falls back to the register-MMA path.(performance) If you won’t use more threads than specified in
BlockDimoperator, addStaticBlockDimoperator.
#include <cublasdx.hpp>
using namespace cublasdx;
using GEMM = decltype(Size<128 /* m */, 128 /* n */, 32 /* k */>()
+ Precision<__half, __half, float>()
+ Type<type::real>()
+ Function<function::MM>()
+ Arrangement<cublasdx::row_major /* A */, cublasdx::col_major /* B */>()
+ Alignment<16, 16, 16>()
+ SM<890>()
+ Block()
+ BlockDim<256>()
+ WithPipeline()
+ EnableInputStreaming()
+ StaticBlockDim());
- On host, use the description from step 1 to create a
host pipelineobject. Create global memory tensors for global A and B matrices
Pass them as arguments to the
suggest_pipelinefunction.Verify if the
cublasdx::detail::expectedreturn value contains a valid pipeline.
- On host, use the description from step 1 to create a
#include <cublasdx.hpp>
using namespace cublasdx;
using BLAS = decltype(... + WithPipeline());
// Create global descriptors for full A and B matrices (not only tiles)
auto global_a = cublasdx::make_gmem_tensor<cublasdx::row_major>(a_device_pointer, m, k, lda);
auto global_b = cublasdx::make_gmem_tensor<cublasdx::col_major>(b_device_pointer, k, n, ldb);
constexpr int pipeline_depth = 4;
auto pipeline = cublasdx::suggest_pipeline<pipeline_depth, BLAS>(global_a, global_b);
if(not pipeline) {
auto const& error = pipeline.error();
std::cout << "Failed to create pipeline";
if (error.code != cublasdx::pipeline_error_code::none) {
std::cout << ": " << cublasdx::pipeline_error_string(error.code);
}
if (error.get_cuda_error() != cudaSuccess) {
std::cout << " (" << cudaGetErrorString(error.get_cuda_error()) << ")";
}
std::cout << std::endl;
exit(1);
}
Pipeline Creation returns a value-or-error result. A successful result provides the host pipeline object that owns
launch metadata and any host-side resources. A failed result provides cublasdx::pipeline_error for logging or
programmatic recovery.
Requirements on pipeline creation:
Correct problem definition: A and B matrices must be compatible, i.e. have the same
Kdimension and, for 3D (batched) tensors, the same number of batches. A and B must both be 2D or both be 3D; mixing ranks is a compile-time error. To use one matrix for every batch, give that operand the full batch count and a batch stride of 0 (broadcast) instead of a single batch. The batch coordinates passed toget_tileare not checked on the device and must be smaller than the batch count: out-of-range batches are zero-filled by TMA copies and read out of bounds otherwise.Global problem divisibility: global problem size must be non-zero and divisible by descriptor specified tile size (this limitation might be removed in the future). For 3D tensors the batch count must also be non-zero.
Flexible-precision emulation K limit: when
RequiredMantissaBitsselects emulation, pipelines requireglobal_k <= floor((2^31 - 1) / ((2^8 - 1) * (2^8 - 1))), orglobal_k <= 33025. The emulation implementation slices inputs toint8and accumulates products of sliced 8-bit values in an internalint32GEMM path. Use split-K for larger emulation problems.Declared alignment is met: the base pointer and any dynamic leading dimension of the A and B global tensors must be a multiple of the effective alignment derived from the Alignment operator, i.e.
leading_dimension * sizeof(element)must be a multiple of it. Static leading dimensions instead constrain the effective copy alignment at compile time and may be smaller-aligned than the declared value.Complete clusters: when clustered execution is active (see BlocksPerCluster), the global tile grid must contain only complete clusters; otherwise creation returns
pipeline_error_code::cluster_grid_divisibility.
The pipeline depth may exceed global_k / tile_k. In that case, the pipeline prefills only the available K tiles.
If any of these requirements fails, suggest_pipeline and make_pipeline return cublasdx::pipeline_error in the
error() branch. Host code can inspect error.code for the pipeline error code and call
error.get_cuda_error() for CUDA runtime status. If CUDA emulation preprocessing fails after cuBLASDx validation succeeds, error.code is
pipeline_error_code::cuda and error.get_cuda_error() reports the CUDA status.
Flexible-precision emulation pipelines allocate temporary device memory (stream-ordered, via cudaMallocAsync or a memory pool) on the CUDA stream
passed to suggest_pipeline / make_pipeline; a failure of that allocation is reported as pipeline_error_code::allocation_failed.
For the complete list of error codes and the host pipeline accessors returned on success, see Pipeline Creation Result.
Attention
Why are pipelines created on host?
Some elements of the CUDA SDK require driver calls; one example is Tensor Memory Accelerator descriptor creation.
To expose TMA as one of the automatically chosen options underneath, cuBLASDx must adhere to this limitation.
Passing Pipeline To Kernels#
- After a host pipeline has been successfully created, it exposes the properties needed to configure a kernel launch efficiently.
buffer_alignment()describes alignment of single shared memory buffer owned by the pipeline.buffer_size()describes size of single shared memory buffer owned by the pipeline.get_block_dim()describes the required block configuration to run this pipeline.get_device_handle()returns the device pipeline object that should be passed by value to a kernel.
Example of a kernel execution configuration:
// ... pipeline creation
auto shared_memory_size = cublasdx::make_shared_storage_calculator()
.add(pipeline->buffer_alignment(), pipeline->buffer_size())
.get();
auto block_dim = pipeline->get_block_dim();
kernel<<<grid_dim, block_dim, shared_memory_size>>>(pipeline->get_device_handle(), ...);
Taking Pipeline As Argument#
The device handle returned by pipeline->get_device_handle() can be passed only to __global__ functions
(not __device__) when annotated with __grid_constant__.
Additionally, a ::max_threads_per_block trait can be used for getting new launch bounds limiting value.
template<class BLAS, class Alpha, class Beta, class CTensor, class DevicePipeline>
__launch_bounds__(DevicePipeline::max_threads_per_block, 1) __global__
void gemm_kernel(Alpha const alpha,
Beta const beta,
CTensor global_c,
__grid_constant__ DevicePipeline const device_pipeline) {
Initializing Tile Pipeline In Kernels#
The device_pipeline object should remain a kernel argument and should not be copied to on-chip memory.
Convert it to a per-block tile_pipeline object by indexing:
extern __shared__ __align__(device_pipeline.buffer_alignment()) cublasdx::byte smem[];
auto tile_pipeline = device_pipeline.get_tile(smem, blockIdx.x, blockIdx.y);
Alternatively, smem can be sliced to fit something other than pipeline itself:
extern __shared__ __align__(device_pipeline.buffer_alignment()) cublasdx::byte smem[];
auto [smem_pipeline, smem_c_tensor] =
cublasdx::shared_memory::slice<char, CType>(
smem,
device_pipeline.buffer_alignment(),
device_pipeline.buffer_size(),
cublasdx::alignment_of_v_c<BLAS>,
BLAS::suggest_layout_smem_c());
auto tile_pipeline = device_pipeline.get_tile(smem_pipeline, blockIdx.x, blockIdx.y);
Note
get_tile() requires the buffer to start at a multiple of buffer_alignment(). This
applies to every pipeline buffer, not only to the start of dynamic shared memory:
buffer_size()is in general not a multiple ofbuffer_alignment()(for example 32932 bytes for a64x64x64half/floatUTCMMAtile with depth 2). When several pipeline buffers, or a pipeline buffer and other data, are carved from one shared memory allocation, round the offset of every pipeline buffer up to itsbuffer_alignment(), for example withcublasdx::make_shared_storage_calculator()andcublasdx::shared_memory::sliceas shown above.__align__on theextern __shared__declaration is not always sufficient when the kernel also declares static__shared__variables, because dynamic shared memory then starts after them. Allextern __shared__declarations with the same name in one translation unit are a single declaration, and the alignment of the first one applies (nvccwarns with#1886-D). A same-named declaration with a smaller alignment in another kernel, or a hand-computed offset into dynamic shared memory, can therefore leave the buffer misaligned. In that case round the pointer up, and addbuffer_alignment() - 1bytes to the dynamic shared memory size passed at launch:extern __shared__ cublasdx::byte smem_raw[]; unsigned const alignment = device_pipeline.buffer_alignment(); unsigned const address = static_cast<unsigned>(__cvta_generic_to_shared(smem_raw)); cublasdx::byte* smem = smem_raw + (alignment - address % alignment) % alignment; auto tile_pipeline = device_pipeline.get_tile(smem, blockIdx.x, blockIdx.y);
How to partition global tensor into tiles?
The general device GEMM looks like the following, where total global GEMM dimensions are MxNxK and
per-block tile-dimensions are tile_m x tile_n x tile_k:
The C matrix determines problem partitioning because it is the reduction output tensor.
Its size (M x N) is divided into tile_m x tile_n independent sub-problems, each of which
is solved by an independent CUDA block. These sub-problems could be addressed in the following manner:
The simplest way to perform this partitioning is to launch a CUDA grid of size:
dim3 grid_dim = dim3 {m / tile_m, n / tile_n, 1};
With this convention, blockIdx.x is the output tile row and blockIdx.y is the output tile
column:
Grid dimension |
Block index |
Tile coordinate |
|---|---|---|
|
|
|
|
|
|
This way there will be exactly 1 block for each 1 sub-problem, and the block index will be the same as the sub-problem tile index. There are other, more advanced, software ways of scheduling work, such as threadblock swizzling or persistent stream-k scheduling.
Resetting A Pipeline#
If a persistent execution is chosen, the pipeline needs to reset its state and start executing with
a new output tile. This is possible with a call to device_pipeline.reset_tile().
device_pipeline.reset_tile(tile_pipeline,
new_tile_idx_row,
new_tile_idx_col);
The 22_persistent_cluster_gemm example in
example/cublasdx/22_persistent_cluster_gemm/persistent_cluster_gemm.cu
uses a bounded grid of clusters. Each cluster processes multiple output-tile
groups in a loop, calling reset_tile() to reuse its pipeline. This reuse
across multiple work items is why the example is called persistent.
Pipeline and accumulator lifetime#
This section concerns tile_pipeline objects and their pipeline-associated
accumulators obtained from tile_pipeline.get_accumulator(). Regular,
non-pipelined accumulators obtained from BLAS::get_accumulator() are not subject
to this single-lifetime restriction: they may be created and destroyed repeatedly,
including inside loops.
Warning
Each participating block uses a single lifetime for its owning tile_pipeline
during a kernel invocation. The corresponding per-thread objects must be
constructed and destroyed only once. In pipeline reusable-accumulator mode,
each participating thread must also obtain its accumulator from
tile_pipeline.get_accumulator() only once and retain that same object for
the pipeline’s accumulation cycles. Do not destroy and recreate either object
inside a loop, or repeatedly call tile_pipeline.get_accumulator() to replace
the accumulator of a live tile pipeline.
These pipeline-bound objects own cleanup and must not be copied or moved, including
through by-value function or lambda arguments. Pass them by reference. Direct
initialization from get_tile() and tile_pipeline.get_accumulator() constructs
the owning objects in place; it does not create additional cleanup owners.
For persistent execution, use device_pipeline.reset_tile() to select new input
tiles and reuse the same accumulator. A reusable accumulator is owned by the user and
keeps its running sum across executes, so successive execute(accumulator) calls can
intentionally accumulate into one result. The result is kept and can be reused only
until finish_accumulation() (or tile_pipeline.epilogue(), which calls it) is
called: each finish_accumulation() requires a subsequent accumulator.clear()
before the next accumulation. Read or discard a finished result before calling clear(),
because clear() discards the contents of a register accumulator. These operations
preserve the synchronization state
needed to reuse the pipeline safely. Reconstructing a pipeline-associated
accumulator can cause incorrect results or deadlock.
An internal accumulator (owned by the pipeline and visible only inside the epilogue functor passed to
tile_pipeline.execute(epilogue_functor)) is cleared by the library for every execution, so each epilogue sees only
the result of its own tile and no clear() is needed.
A UTCMMA pipeline owns the SM’s entire TMEM (all 512 columns) while it lives, so at most one such pipeline can
be alive per CTA at a time; a second one deadlocks in tcgen05.alloc.
Declare the reusable accumulator after its tile pipeline, at the same participating thread scope, so reverse destruction order cleans up the accumulator first:
auto tile_pipeline = device_pipeline.get_tile(smem, blockIdx.x, blockIdx.y);
auto accumulator = tile_pipeline.get_accumulator();
for (unsigned tile_row = blockIdx.x; tile_row < tile_rows; tile_row += gridDim.x) {
if (tile_row != blockIdx.x) {
device_pipeline.reset_tile(tile_pipeline, tile_row, blockIdx.y);
}
auto output = cublasdx::get_tile(global_c, BLAS::c_shape, tile_row, blockIdx.y);
accumulator.clear();
tile_pipeline.execute(accumulator);
tile_pipeline.epilogue(accumulator, [&](auto& ready) {
ready.axpby(1.0f, 0.0f, output);
});
}
// On scope exit: accumulator cleanup, then tile_pipeline cleanup.
The loop’s scheduling must remain valid for the selected cluster: blocks in a cluster must participate in corresponding executions and epilogues. The reusable accumulator must be destroyed before its tile pipeline. Keep the pipeline’s shared storage alive until both objects have been destroyed. Their destructors complete the library’s pending resource cleanup.
Every submitted accumulation must still be finalized and consumed by a valid
epilogue or discard() handshake. Use tile_pipeline.epilogue() or call
finish_accumulation() with the same thread scope as execute(accumulator)
before the participating epilogue threads read or discard the result. Do not guard
the finish step with accumulator.is_thread_active(). Destruction only drains
completed releases; it does not implicitly finish accumulation, read results or
discard an unfinished result. Construction followed by destruction without any
execute does not require a fabricated epilogue or discard.
Destruction is not a general-purpose collective barrier for user epilogue work. Keep user-issued asynchronous operations’ completion waits, including asynchronous output store waits, on their issuing threads. If shared storage is repurposed after the pipeline’s lifetime, explicitly synchronize all threads that can still access it; peer shared-memory accesses require the corresponding cluster-wide synchronization.
Executing Pipelined GEMM#
Depending on the chosen accumulation mode, there are two ways of executing a pipelined GEMM:
In both modes, tile_pipeline.execute drives the staged GEMM pipeline: asynchronous load stages prepare K tiles while
asynchronous compute stages issue MMA work for previously prepared tiles. More than one load stage and more than one
compute stage can be in flight. After those K-stage accumulations are complete, exactly one epilogue stage consumes the
final accumulator for the output tile.
Execute with internal accumulation (pipeline owns the accumulator).
auto tile_pipeline = device_pipeline.get_tile(smem, blockIdx.x, blockIdx.y);
auto tile_gmem_c = cublasdx::get_tile(global_c, BLAS::c_shape, blockIdx.x, blockIdx.y);
// Prepare epilogue functor
auto epilogue_functor = [&](auto& accumulator) {
auto d_fragment = accumulator.make_partition_and_copy(tile_gmem_c);
cublasdx::axpby(alpha, accumulator.get_results(), beta, d_fragment);
accumulator.partition_and_copy(d_fragment, tile_gmem_c);
};
tile_pipeline.execute(epilogue_functor);
In this interface the results of the GEMM are accessed via epilogue functor, getting accumulator as its only argument.
What is available in the epilogue functor?
Epilogue functor always:
operates on the number of threads used as the value in
BlockDimoperatorcan be synchronized using
tile_pipeline.epilogue_sync()
This is also true when heuristic_blocksize mode of pipeline execution is used. This synchronization
operation has the same memory guarantees as a regular syncthreads (bar.sync).
Execute with reusable accumulation (user owns the accumulator).
auto tile_pipeline = device_pipeline.get_tile(smem, blockIdx.x, blockIdx.y);
auto tile_gmem_c = cublasdx::get_tile(global_c, BLAS::c_shape, blockIdx.x, blockIdx.y);
auto accumulator = tile_pipeline.get_accumulator();
tile_pipeline.execute(accumulator);
// Use the same thread scope as execute(accumulator); do not guard this with if(accumulator.is_thread_active()).
accumulator.finish_accumulation();
// Inlined epilogue
auto d_fragment = accumulator.make_partition_and_copy(tile_gmem_c);
cublasdx::axpby(alpha, accumulator.get_results(), beta, d_fragment);
accumulator.partition_and_copy(d_fragment, tile_gmem_c);
In this manual interface the accumulator is owned by user code. finish_accumulation() is required after
execute(accumulator) and before reading accumulator contents, including get_results(), axpby(),
partition_and_store(), or reduce_and_store() style epilogues. The convenience
tile_pipeline.epilogue(accumulator, functor) path calls finish_accumulation() internally.
Call finish_accumulation() with the same thread scope as tile_pipeline.execute(accumulator). Do not guard it with
if(accumulator.is_thread_active()); internal warp-specialization may require threads that do not own accumulator
elements to participate in the finish step.
Advanced Pipeline Configuration Options#
Heuristic Based Pipeline Depth Choice#
Pipeline depth is an argument that can be either carefully chosen and specified, or left to the library, which will try to fill as much of shared memory as possible, by choosing the architecturally maximal depth. Depth controls how many K tiles can be staged by the pipeline. On architectures that support it, this can mean multiple asynchronous load stages and multiple asynchronous compute stages in flight at the same time; it does not create multiple epilogue stages for one output tile.
// Explicit choice version
constexpr int pipeline_depth = 4;
auto pipeline = cublasdx::suggest_pipeline<pipeline_depth, BLAS>(global_a, global_b);
// Heuristic version
auto pipeline = cublasdx::suggest_pipeline<BLAS>(global_a, global_b);
Result Storage Options#
The public result-storage mode names are cublasdx::internal_accumulator and cublasdx::reusable_accumulator.
They replace the older internal_accumulation / external_accumulation terminology.
This advanced option controls ownership of pipeline results. The accumulator can be owned by the pipeline and exposed only as an epilogue lambda argument:
Mode |
Owner |
Required flow |
|---|---|---|
|
Pipeline |
Call |
|
User code |
Call |
// Pipeline owns the accumulator
auto pipeline = cublasdx::suggest_pipeline<BLAS, cublasdx::internal_accumulator>(global_a, global_b);
// ... later in device code
auto epilogue_functor = [](auto const& accumulator) {
// some epilogue
};
tile_pipeline.execute(epilogue_functor);
Alternatively, if more flexibility is required, the accumulator can be reusable and owned by user code. This may lead to performance regressions on some architectures.
// User owns the reusable accumulator
// host code
auto pipeline = cublasdx::suggest_pipeline<BLAS, cublasdx::reusable_accumulator>(global_a, global_b);
// ... later in device code
auto accumulator = tile_pipeline.get_accumulator();
tile_pipeline.execute(accumulator);
// Use the same thread scope as execute(accumulator); do not guard this with if(accumulator.is_thread_active()).
accumulator.finish_accumulation();
some_epilogue(accumulator.get_results());
// tile_pipeline.epilogue(accumulator, functor) remains compatible, calls finish_accumulation() internally,
// and preserves the required thread scope.
When using tile_pipeline.epilogue(accumulator, functor), the functor must consume the accumulator through
get_results(), axpby(), partition_and_store(), or reduce_and_store().
The reduce_and_store(output, lambda) epilogue calls the lambda as lambda(result_element, output_element, coord),
where coord is the element’s (m, n) coordinate within the output tile; for memory-backed outputs, padded
out-of-bounds elements of a predicated GEMM are skipped.
Use finish_accumulation() directly for custom flows that do not read the accumulator inside the epilogue functor.
Call it with the same thread scope as tile_pipeline.execute(accumulator); it is unsafe to call it under
if(accumulator.is_thread_active()) because internal warp-specialization may require broader participation.
Warning
Why reusable accumulation can cause performance regressions?
cuBLASDx internally applies register trading in warp-specialized kernels, allowing some threads to own more registers than others. This
optimization is disabled when the user wants to own the results.
Blocksize Strategy Options#
By default, a cuBLASDx pipeline expects the kernel launch to use pipeline->get_block_dim() as the block dimension. This allows the internal implementation to use optimizations such as warp-specialization, adding extra threads to only act as producers.
Alternatively, as an advanced option, the user can specify cublasdx::fixed_blocksize which forces the pipeline to use the same block-dimension rules as the
BlockDim operator. This choice provides more control and flexibility, but disables warp-specialized kernel paths.
// Default - heuristic
auto pipeline = cublasdx::suggest_pipeline<BLAS, cublasdx::internal_accumulator, cublasdx::heuristic_blocksize>(global_a, global_b);
// Fixed - explicit user choice
auto pipeline = cublasdx::suggest_pipeline<BLAS, cublasdx::internal_accumulator, cublasdx::fixed_blocksize>(global_a, global_b);