brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
pipeline-gpu-kernelApply 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 verifica…
Independent GitHub snapshot · Automated classification · Original documentation governs
Aplicar software pipelining (double-buffering) a un kernel de GPU tileado para que las cargas de memoria global del tile N+1 se solapen con la computación de Tensor Core en el tile N. Transformar un K-loop secuencial load-sync-compute-sync en una estructura prologue/loop/epilogue, elegir entre variantes LDG-register y cp.async (LDGSTS) basándose en la razón cómputo/carga, verificar que la memoria compartida se mantiene bajo el acantilado de ocupación de la arquitectura y confirmar el solapamiento carga/cómputo en el SASS final.
analyze-kernel-bottleneck identifica un kernel limitado por memoria con razón cómputo/carga baja por tile.cu) con un K-loop tileado conteniendo fases de carga y cómputo separadasanalyze-kernel-bottleneck; se estimará si no se proporciona)Confirmar que el kernel tiene un K-loop tileado con fases distintas de carga y cómputo separadas por __syncthreads(). Calcular el costo doblado de memoria compartida y verificar que se mantiene bajo el acantilado de ocupación de la arquitectura.
__syncthreads(), computar (HMMA/IMMA/FFMA) en los tiles de memoria compartida, __syncthreads().smem_a_size = BM * BK * sizeof(T) y 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. El pipelining requiere num_tiles >= 2 (al menos un prologue + una iteración del main loop).Esperado: Una tabla de presupuesto de memoria compartida mostrando los costos de single-buffer y double-buffer, confirmando que la asignación doblada se mantiene bajo el acantilado de la arquitectura con al menos 2 blocks/SM de ocupación.
En caso de fallo: Si double-buffer excede el acantilado, reducir el tamaño del tile (dividir a la mitad BK o BM) hasta que smem_doubled <= 50 KB para GA104. Alternativamente, usar prefetch solo-de-registros (variante LDG) sin doblar memoria compartida — almacenar datos pre-cargados en registros y escribir al mismo buffer único después de __syncthreads().
Seleccionar entre LDG-register y cp.async (LDGSTS) basándose en la razón cómputo/carga por tile.
ratio = (2 * BM * BN * BK) / ((BM * BK + BK * BN) * sizeof(T)) para kernels tipo GEMM (2 FLOPs por multiply-add, bytes cargados por tile).Variante LDG-register (ratio >= 5 o CUDA < 11.0):
buf[N % 2] (solapa con LDGs pendientes).__syncthreads(), luego STS registros a buf[(N+1) % 2], __syncthreads().(BM * BK + BK * BN) / BLOCK_SIZE registros por thread para staging.Variante cp.async (LDGSTS) (ratio < 5, CUDA >= 11.0):
__pipeline_memcpy_async tile N+1 directamente a buf[(N+1) % 2] (async, sortea el archivo de registros).__pipeline_commit() antes del cómputo.buf[N % 2].__pipeline_wait_prior(0) + __syncthreads() después del cómputo.#include <cuda_pipeline.h>.Esperado: Variante seleccionada con justificación basada en la razón cómputo/carga y la arquitectura objetivo.
En caso de fallo: Si la razón es ambigua (rango 5-20:1), implementar ambas variantes y hacer benchmark. La variante cp.async es el predeterminado más seguro cuando la versión de CUDA la soporta.
Transformar el loop secuencial load-sync-compute-sync en una estructura pipelined prologue/loop/epilogue.
Identificar las tres secciones: El cuerpo original del loop se vuelve tres piezas:
buf[0], sincronizar, luego entrar al main loop.num_tiles - 1, solapar la carga del tile N+1 con el cómputo del tile N.Estructura de la variante LDG-register:
// === 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 iteraciones (tiles 0 a num_tiles - 2 indexando qué tiles computar, cargando tiles 1 a num_tiles - 1). El epilogue computa el tile cargado en la última iteración.Esperado: Código fuente del K-loop reestructurado con secciones claras de prologue, main loop y epilogue para la variante elegida.
En caso de fallo: El bug más común es un off-by-one en el indexado de buffer u olvidar el pase de cómputo del epilogue. Verificar: el prologue carga en buf[0], la primera iteración del main loop computa buf[0] y carga en buf[1], la segunda iteración computa buf[1] y carga en buf[0], y así sucesivamente. El epilogue computa buf[(num_tiles - 1) & 1].
Declarar la memoria compartida double-buffered e implementar las funciones de carga.
// 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) en el kernel para dar al compilador información precisa de ocupación.nvcc --cubin -arch=sm_86 -O2 -o kernel.sm_86.cubin kernel.cu.Esperado: Kernel compilable con memoria compartida double-buffered y el mecanismo de carga elegido. Generación exitosa de cubin sin errores.
En caso de fallo: Si la compilación falla en las llamadas a la API de pipeline, asegurar que #include <cuda_pipeline.h> esté presente y CUDA toolkit sea >= 11.0. Si ocurren spills de registros (verificar nvcc --resource-usage), reducir los tamaños del array de staging de registros aumentando BLOCK_SIZE o reduciendo BK.
Ejecutar el kernel pipelined contra la referencia de CPU para confirmar salida numérica idéntica.
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-3Esperado: PASS en ambos tamaños pequeño y objetivo con límites de error idénticos a la línea base no-pipelined.
En caso de fallo: Bug de indexado de buffer es la causa más probable. Verificar: el cómputo lee desde buf[tile & 1] mientras las cargas escriben a buf[1 - (tile & 1)]. Verificar que el epilogue procesa el índice de buffer (num_tiles - 1) & 1, no num_tiles & 1. Para cp.async, verificar que __pipeline_wait_prior(0) se completa antes de __syncthreads() — de otro modo el cómputo puede leer datos parcialmente escritos.
Medir el kernel pipelined contra la línea base no-pipelined al tamaño de problema objetivo.
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 |
Esperado: Tabla de comparación de rendimiento mostrando mejora. La variante elegida debe mostrar speedup medible consistente con la predicción de la razón cómputo/carga.
En caso de fallo: Si el rendimiento regresa, verificar tres cosas: (1) SASS por overhead inesperado de instrucciones (BAR.SYNC extra, spills de registros). (2) La memoria compartida no cruzó el acantilado de ocupación — verificar con nvcc --resource-usage o cuobjdump -res-usage. (3) El tamaño del problema produce suficientes tiles (K / BK >= 4) para que el pipelining amortice el overhead de prologue/epilogue.
Inspeccionar el SASS compilado para confirmar que las cargas globales y las instrucciones de Tensor Core se solapan dentro del cuerpo del main loop.
cuobjdump -sass kernel.sm_86.cubin | grep -E 'IMMA|HMMA|LDGSTS|LDG|BAR'.LDGSTS o LDG aparecen antes de las instrucciones HMMA o IMMA.BAR.SYNC entre las instrucciones de carga y las de cómputo (deben estar libres para solaparse en el warp scheduler).BAR.SYNC aparece después del bloque de cómputo, controlando el uso de la siguiente iteración de los datos cargados.# 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
Esperado: Extracto SASS anotado mostrando el patrón carga-antes-cómputo sin barreras intermedias. Cero spills de registros.
En caso de fallo: Si el compilador reordenó cargas después del cómputo (derrotando el solapamiento), probar: (1) #pragma unroll 1 en el main loop para prevenir desenrollado excesivamente agresivo. (2) Separar cargas y cómputo en funciones inline distintas para crear un hint de secuenciación. (3) Usar asm volatile("" ::: "memory") como una valla de compilador entre bloques de carga y cómputo (último recurso — puede inhibir otras optimizaciones).
buf[tile & 1])buf[0]buf[(num_tiles - 1) & 1]BAR.SYNC entre LDGSTS/LDG y IMMA/HMMA)nvcc --resource-usage)smem_doubled antes de implementar. Un kernel usando 28 KB single-buffered salta a 56 KB doblado, cruzando el acantilado y dividiendo a la mitad la ocupación. Esto puede convertir una ganancia de pipelining de +20% en una regresión de ocupación de -50%.buf[tile & 1] para el buffer de cómputo actual y buf[1 - (tile & 1)] para el buffer de siguiente carga. Un error común es usar buf[(tile + 1) & 1] para el siguiente buffer, lo cual es equivalente a buf[1 - (tile & 1)] solo cuando el conteo de buffers es 2 — pero lee mal si se aplica accidentalmente al índice de cómputo.__pipeline_commit() debe ser llamado ANTES de la fase de cómputo (sella el batch de copias async). __pipeline_wait_prior(0) debe ser llamado DESPUÉS de la fase de cómputo (bloquea hasta que todas las copias comprometidas se completen). Intercambiar estos hace las copias async síncronas, eliminando todo beneficio de solapamiento.__syncthreads() entre el cómputo y el drenaje STS (para que el cómputo termine de leer el buffer actual antes de que sea sobrescrito). Otro __syncthreads() se necesita después del drenaje STS (para que todos los threads terminen de escribir antes de que la siguiente iteración lea). En la variante cp.async, __syncthreads() después de __pipeline_wait_prior(0) asegura que todos los threads vean las copias async completadas.__pipeline_memcpy_async requiere que la dirección fuente sea válida y alineada. En los bordes de la matriz donde K no es un múltiplo de BK, el último tile puede leer fuera de los límites. Recurrir a cargas escalares con verificación de límites para el tile final, o paddear las matrices de entrada a un múltiplo de BK.analyze-kernel-bottleneck — identificar si el kernel está limitado por memoria y calcular la razón cómputo/carga que impulsa la selección de variante