协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
pipeline-gpu-kernel【开发与工程】Apply software pipelining (double-buffering) to a tiled GPU kernel to overlap global memory loads with Tensor Core computation. Covers prologue/loop/epilogue restructuring, LDG-register vs cp.async (LDGSTS) variant selection based on compute/load ratio, shared memory budget verification against architecture-specific occupancy cliffs, and SASS-level v…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Apply software pipelining (double-buffer) to tiled GPU kernel. Global loads for tile N+1 overlap Tensor Core compute on tile N. Convert sequential load-sync-compute-sync K-loop into prologue/loop/epilogue. Pick LDG-register or cp.async (LDGSTS) by compute/load ratio. Verify shared mem stays below occupancy cliff. Confirm overlap in SASS.
analyze-kernel-bottleneck flagged memory-bound kernel, low compute/load ratio per tile.cu) with tiled K-loop, separate load and compute phasesanalyze-kernel-bottleneck; estimate if missing)Confirm tiled K-loop has distinct load and compute phases split by __syncthreads(). Compute doubled smem cost. Verify under architecture cliff.
__syncthreads(), compute (HMMA/IMMA/FFMA) on shared tiles, __syncthreads().smem_a_size = BM * BK * sizeof(T) and smem_b_size = BK * BN * sizeof(T).smem_doubled = smem_a_size * 2 + smem_b_size * 2.Single buffer: smem_a[BM*BK] + smem_b[BK*BN] = 2 KB + 2 KB = 4 KB
Double buffer: smem_a[2][BM*BK] + smem_b[2][BK*BN] = 4 KB + 4 KB = 8 KB
8 KB << 50 KB cliff -> 2 blocks/SM -> 8 warps
num_tiles = K / BK. Pipelining needs num_tiles >= 2 (one prologue + one main iteration min).Got: Smem budget table — single-buffer and double-buffer costs. Doubled allocation under architecture cliff. At least 2 blocks/SM occupancy.
If fail: Doubled smem above cliff? Shrink tile (halve BK or BM) until smem_doubled <= 50 KB for GA104. Or use register-only prefetch (LDG variant) — no double smem, stage in registers, write same single buffer after __syncthreads().
Choose LDG-register or cp.async (LDGSTS) by compute/load ratio per tile.
ratio = (2 * BM * BN * BK) / ((BM * BK + BK * BN) * sizeof(T)) for GEMM-like kernels (2 FLOPs per multiply-add, bytes loaded per tile).LDG-register variant (ratio >= 5 or CUDA < 11.0):
buf[N % 2] (overlaps outstanding LDGs).__syncthreads(), then STS registers to buf[(N+1) % 2], __syncthreads().(BM * BK + BK * BN) / BLOCK_SIZE registers per thread for staging.cp.async (LDGSTS) variant (ratio < 5, CUDA >= 11.0):
__pipeline_memcpy_async tile N+1 directly to buf[(N+1) % 2] (async, bypass register file).__pipeline_commit() before compute.buf[N % 2].__pipeline_wait_prior(0) + __syncthreads() after compute.#include <cuda_pipeline.h>.Got: Picked variant with reason — based on compute/load ratio and target arch.
If fail: Ratio ambiguous (5-20:1)? Implement both, benchmark. cp.async safer default when CUDA version supports.
Convert sequential load-sync-compute-sync loop into pipelined prologue/loop/epilogue.
Identify three sections: Original loop body splits into three:
buf[0], sync, enter main loop.num_tiles - 1, overlap loading tile N+1 with computing tile N.LDG-register variant structure:
// === LDG-register variant ===
// Prologue: load tile 0 into buf[0]
cooperative_load_tile(smem_a[0], smem_b[0], global_a, global_b, /*k_offset=*/0);
__syncthreads();
for (int tile = 0; tile < num_tiles - 1; tile++) {
int cur_buf = tile & 1;
int next_buf = 1 - cur_buf;
// Phase 1: LDG next tile into registers (non-blocking)
float reg_a[ELEMS_PER_THREAD_A], reg_b[ELEMS_PER_THREAD_B];
prefetch_tile_to_registers(reg_a, reg_b, global_a, global_b,
(tile + 1) * BK);
// Phase 2: Compute on current buffer (overlaps with LDG flight)
tensor_core_mma(smem_a[cur_buf], smem_b[cur_buf], acc);
// Phase 3: Drain registers into next buffer
__syncthreads();
store_registers_to_smem(smem_a[next_buf], smem_b[next_buf],
reg_a, reg_b);
__syncthreads();
}
// Epilogue: compute last tile
tensor_core_mma(smem_a[(num_tiles - 1) & 1], smem_b[(num_tiles - 1) & 1], acc);
// === cp.async variant ===
#include <cuda_pipeline.h>
// Prologue: async load tile 0 into buf[0]
cpasync_load_tile(smem_a[0], smem_b[0], global_a, global_b, /*k_offset=*/0);
__pipeline_commit();
__pipeline_wait_prior(0);
__syncthreads();
for (int tile = 0; tile < num_tiles - 1; tile++) {
int cur_buf = tile & 1;
int next_buf = 1 - cur_buf;
// Phase 1: cp.async next tile into next buffer (async, direct to smem)
cpasync_load_tile(smem_a[next_buf], smem_b[next_buf],
global_a, global_b, (tile + 1) * BK);
__pipeline_commit();
// Phase 2: Compute on current buffer (overlaps with LDGSTS in flight)
tensor_core_mma(smem_a[cur_buf], smem_b[cur_buf], acc);
// Phase 3: Wait for async copies to complete
__pipeline_wait_prior(0);
__syncthreads();
}
// Epilogue: compute last tile
tensor_core_mma(smem_a[(num_tiles - 1) & 1], smem_b[(num_tiles - 1) & 1], acc);
num_tiles - 1 iterations (tiles 0 through num_tiles - 2 indexing compute, loading tiles 1 through num_tiles - 1). Epilogue computes tile loaded last iteration.Got: Restructured K-loop source with clear prologue, main loop, epilogue for picked variant.
If fail: Most common bug: off-by-one in buffer indexing or forget epilogue compute pass. Verify: prologue loads buf[0], first main iteration computes buf[0] and loads buf[1], second iteration computes buf[1] and loads buf[0]. Epilogue computes buf[(num_tiles - 1) & 1].
Declare double-buffered smem. Implement load functions.
// Before (single buffer)
__shared__ half smem_a[BM * BK];
__shared__ half smem_b[BK * BN];
// After (double buffer)
__shared__ half smem_a[2][BM * BK];
__shared__ half smem_b[2][BK * BN];
__device__ void cpasync_load_tile(half* dst_a, half* dst_b,
const half* src_a, const half* src_b,
int k_offset) {
// Each thread copies its portion (16 bytes = 8 half values per cp.async)
int tid = threadIdx.x;
int bytes_per_thread = 16; // cp.async.cg supports 4, 8, or 16 bytes
// A tile: BM * BK elements, distributed across BLOCK_SIZE threads
int elems_a = BM * BK / BLOCK_SIZE;
for (int i = 0; i < elems_a; i += 8) {
int idx = tid * elems_a + i;
__pipeline_memcpy_async(dst_a + idx,
src_a + k_offset * BM + idx,
bytes_per_thread);
}
// B tile: BK * BN elements, distributed similarly
int elems_b = BK * BN / BLOCK_SIZE;
for (int i = 0; i < elems_b; i += 8) {
int idx = tid * elems_b + i;
__pipeline_memcpy_async(dst_b + idx,
src_b + k_offset * BN + idx,
bytes_per_thread);
}
}
// Declare register staging (size = elements per thread)
half reg_a[BM * BK / BLOCK_SIZE];
half reg_b[BK * BN / BLOCK_SIZE];
// Prefetch: LDG from global to registers (non-blocking, issued early)
for (int i = 0; i < BM * BK / BLOCK_SIZE; i++) {
int idx = threadIdx.x * (BM * BK / BLOCK_SIZE) + i;
reg_a[i] = global_a[k_offset * BM + idx];
}
// ... similarly for reg_b
// Store: STS from registers to shared memory (after __syncthreads)
for (int i = 0; i < BM * BK / BLOCK_SIZE; i++) {
int idx = threadIdx.x * (BM * BK / BLOCK_SIZE) + i;
smem_a[next_buf][idx] = reg_a[i];
}
__launch_bounds__(BLOCK_SIZE) on kernel — gives compiler accurate occupancy info.nvcc --cubin -arch=sm_86 -O2 -o kernel.sm_86.cubin kernel.cu.Got: Compilable kernel with double-buffered smem and picked load mechanism. Cubin builds, no errors.
If fail: Compile fails on pipeline API? Verify #include <cuda_pipeline.h> present, CUDA toolkit >= 11.0. Register spills (check nvcc --resource-usage)? Shrink register staging array sizes — bigger BLOCK_SIZE or smaller BK.
Run pipelined kernel against CPU reference. Confirm same numerical output.
nvcc -arch=sm_86 -O2 -o bench bench.cu -lcuda -I../../phase2/common.abs=0.5, rel=0.1abs=1e-2, rel=1e-2abs=1e-3, rel=1e-3Got: PASS at small and target sizes with same error bounds as non-pipelined baseline.
If fail: Buffer indexing bug most likely. Verify: compute reads buf[tile & 1], loads write buf[1 - (tile & 1)]. Epilogue uses (num_tiles - 1) & 1, not num_tiles & 1. For cp.async: __pipeline_wait_prior(0) must finish before __syncthreads() — else compute reads partial data.
Measure pipelined vs non-pipelined baseline at target size.
speedup = pipelined_metric / baseline_metric.| Variant | GFLOPS | Speedup vs Baseline |
|------------------|--------|---------------------|
| Baseline | XXX | 1.00x |
| LDG-register | XXX | X.XXx |
| cp.async (LDGSTS)| XXX | X.XXx |
Got: Perf comparison table showing improvement. Picked variant shows measurable speedup matching compute/load ratio prediction.
If fail: Perf regresses? Check three things: (1) SASS for unexpected instruction overhead (extra BAR.SYNC, register spills). (2) Smem stayed below occupancy cliff — verify with nvcc --resource-usage or cuobjdump -res-usage. (3) Problem size produces enough tiles (K / BK >= 4) to amortize prologue/epilogue overhead.
Inspect compiled SASS. Confirm global loads and Tensor Core instructions overlap in main loop body.
cuobjdump -sass kernel.sm_86.cubin | grep -E 'IMMA|HMMA|LDGSTS|LDG|BAR'.LDGSTS or LDG instructions appear before HMMA or IMMA.BAR.SYNC between load and compute (must overlap freely in warp scheduler).BAR.SYNC appears after compute block — gates next iteration's use of loaded data.# Full SASS pipeline verification
cuobjdump -sass kernel.sm_86.cubin | grep -E 'IMMA|HMMA|LDGSTS|LDG|BAR'
# Count compute instructions per loop
cuobjdump -sass kernel.sm_86.cubin | grep -c 'HMMA\|IMMA'
# Check for register spills
nvcc --resource-usage --cubin -arch=sm_86 -O2 kernel.cu 2>&1 | grep -i spill
Got: Annotated SASS showing load-before-compute pattern. No intervening barriers. Zero register spills.
If fail: Compiler reordered loads after compute (defeats overlap)? Try: (1) #pragma unroll 1 on main loop — prevents over-aggressive unroll. (2) Split load and compute into distinct inline functions — sequencing hint. (3) Use asm volatile("" ::: "memory") as compiler fence between load and compute (last resort — may inhibit other opts).
buf[tile & 1] pattern)buf[0]buf[(num_tiles - 1) & 1]BAR.SYNC between LDGSTS/LDG and IMMA/HMMA)nvcc --resource-usage)smem_doubled before implementing. Kernel using 28 KB single-buffered jumps to 56 KB doubled, crosses cliff, halves occupancy. Turns +20% pipelining gain into -50% occupancy regression.buf[tile & 1] for current compute buffer, buf[1 - (tile & 1)] for next load buffer. Common mistake: buf[(tile + 1) & 1] for next buffer — equivalent to buf[1 - (tile & 1)] only when buffer count is 2 — but reads wrong if accidentally applied to compute index.__pipeline_commit() BEFORE compute (seals batch of async copies). __pipeline_wait_prior(0) AFTER compute (blocks until committed copies complete). Swap them → async copies become synchronous → all overlap benefit gone.__syncthreads() between compute and STS drain (compute finishes reading current buffer before overwrite). Another __syncthreads() after STS drain (all threads finish writing before next iteration reads). In cp.async variant, __syncthreads() after __pipeline_wait_prior(0) ensures all threads see completed async copies.__pipeline_memcpy_async needs source address valid and aligned. At matrix edges where K not multiple of BK, last tile may read out of bounds. Fall back to scalar loads with bounds checking for final tile, or pad input matrices to multiple of BK.analyze-kernel-bottleneck — identify if kernel is memory-bound. Compute the compute/load ratio that drives variant selection.