一句话总结

CUDA 13.3 把”分块加载数据到 Shared Memory”这件苦差事标准化为 C++ Tile API,配合编译器自动调优,访存密集型算子在不手写 bank conflict 计算的情况下可达接近 cuBLAS 的性能(H100 实测 matmul 3.4ms vs cuBLAS 3.1ms)。


为什么需要这个?

GPU 的命门:访存带宽鸿沟

存储层次 延迟 带宽(H100)
Shared Memory / L1 ~30 cycles ~30 TB/s
L2 Cache ~200 cycles ~12 TB/s
HBM Global Memory ~500 cycles ~3.35 TB/s

Global Memory 带宽只有 Shared Memory 的 1/9,延迟高 15x。这不是 bug,是物理限制。

对于 N=4096 的矩阵乘法,朴素实现中每个输出元素需要 2×4096 次 global memory 访问。整个操作产生约 137 billion 次内存访问,远超 HBM 吞吐能力——这就是瓶颈所在。

传统 Tiling 的问题:代码太难写

解决方案大家都知道——把数据分块搬到 Shared Memory(Tiling)。但手写 tiling 代码有三大痛点:

  1. bank conflict 需要手算:不同访问模式需要不同的 padding/swizzle 策略
  2. tile 大小硬编码:A100 最优可能是 128,H100 最优可能是 192
  3. 延迟隐藏需要手动 pipeline:必须手写 double buffering 才能把数据加载和计算重叠

CUTLASS 3.x 用 CUTE(Unified Template Engine)解决了这些问题,但学习曲线极陡。CUDA 13.3 的 Tile API 把这套抽象提升到语言级别。


核心原理

直觉:把仓库货物分批搬到工作台

想象你在仓库(Global Memory)里找零件,每次来回走很远。聪明的做法是一次搬一整托盘(tile)到工作台(Shared Memory),在工作台上处理完,再搬下一托盘。

Tile 编程把”搬多少(tile shape)、怎么搬(async DMA)、谁等谁(barrier)”这三件事标准化了。

硬件层面:为什么 Tile 有效

一个 Thread Block 运行在同一个 SM 上,SM 拥有:

  • Shared Memory:H100 上 228 KB/SM,带宽约 30 TB/s
  • Tensor Memory Accelerator(TMA):H100 新增的专用 DMA 引擎,能异步搬运整个 tile,不占 CUDA 核心

关键洞察:让 TMA 在后台搬数据,同时 CUDA 核心用 Tensor Core 计算上一批数据——这就是 latency hiding 的本质。

CUDA 13.3 的 Tile API 让编译器能自动推断这个 pipeline,不需要开发者手写。


代码实现

Baseline:朴素矩阵乘法

// 每个线程独立计算一个输出元素
// 问题:每次乘加都直接读 global memory
__global__ void matmul_naive(
    const float* A, const float* B, float* C, int N
) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    if (row >= N || col >= N) return;

    float sum = 0.0f;
    for (int k = 0; k < N; k++) {
        // 每次循环:2次 global memory 读(无法被 L2 有效缓存)
        sum += A[row * N + k] * B[k * N + col];
    }
    C[row * N + col] = sum;
}

Nsight Compute 分析(H100,N=4096,float32):

  • 运行时间:45.2 ms
  • L2 Cache Hit Rate:12%(大量 cache miss,因为每行/列被 4096 个线程各自请求)
  • Tensor Core 利用率:0%(没有使用 mma.sync 指令)

传统手写 Tiled Kernel

#define TS 32  // tile size
__global__ void matmul_tiled(
    const float* A, const float* B, float* C, int N
) {
    __shared__ float sA[TS][TS], sB[TS][TS];
    int tx = threadIdx.x, ty = threadIdx.y;
    int row = blockIdx.y * TS + ty;
    int col = blockIdx.x * TS + tx;
    float sum = 0.0f;

    for (int t = 0; t < N / TS; t++) {
        // 协作加载 tile:每线程搬运一个元素
        sA[ty][tx] = A[row * N + t * TS + tx];
        sB[ty][tx] = B[(t * TS + ty) * N + col];
        __syncthreads();  // 等所有线程加载完

        for (int k = 0; k < TS; k++)
            sum += sA[ty][k] * sB[k][tx];
        __syncthreads();  // 等计算完再覆盖 shared memory
    }
    if (row < N && col < N) C[row * N + col] = sum;
}
  • 运行时间:12.8 ms(比 Baseline 快 3.5x)
  • TS=32 是硬编码,H100 最优 tile 可能是 128;__syncthreads 是同步屏障,无法和数据加载重叠

CUDA 13.3:Tile API + 异步流水线

CUDA 13.3 的核心是把 tile 操作作为第一类 C++ 对象,让编译器理解数据移动的语义:

#include <cuda/tile>        // CUDA 13.3 新增
#include <cuda/barrier>

template<int BM, int BN, int BK>
__global__ void matmul_tile_api(
    const float* __restrict__ A,
    const float* __restrict__ B,
    float* C, int M, int N, int K
) {
    // 声明 Tile 类型:编译器知道这是一个 BM×BK 的 float 块
    using TileA = cuda::tile<float, cuda::shape<BM, BK>>;
    using TileB = cuda::tile<float, cuda::shape<BK, BN>>;

    // double-buffering:两个 tile 交替使用,隐藏加载延迟
    __shared__ TileA smem_A[2];
    __shared__ TileB smem_B[2];

    // cuda::barrier:比 __syncthreads 更精细的异步同步原语
    cuda::barrier<cuda::thread_scope_block> bars[2];
    init(&bars[0], blockDim.x * blockDim.y);
    init(&bars[1], blockDim.x * blockDim.y);

    int block_row = blockIdx.y * BM;
    int block_col = blockIdx.x * BN;
    float accum[4] = {};  // 寄存器累加,大小取决于每线程负责的输出

    int cur = 0;
    // 预加载第一个 tile(pipeline 启动)
    cuda::async_copy_tile(smem_A[0], A + block_row * K, K, bars[0]);
    cuda::async_copy_tile(smem_B[0], B + block_col,     N, bars[0]);

    for (int k = 0; k < K; k += BK) {
        int next = 1 - cur;
        // 在等待当前 tile 的同时,异步预加载下一个 tile
        if (k + BK < K) {
            cuda::async_copy_tile(
                smem_A[next], A + block_row * K + (k + BK), K, bars[next]);
            cuda::async_copy_tile(
                smem_B[next], B + (k + BK) * N + block_col, N, bars[next]);
        }
        // 等当前 tile 加载完成
        bars[cur].arrive_and_wait();
        
        // Tile 上的矩阵乘:编译器自动生成 mma.sync 指令(Tensor Core)
        cuda::tile_gemm(accum, smem_A[cur], smem_B[cur]);
        cur = next;
    }
    // ... 写回 C(省略边界处理)
}

关键优化点

  • async_copy_tile:H100 上自动选择 TMA(Tensor Memory Accelerator),硬件 DMA 异步搬运,不占用 CUDA 核心
  • tile_gemm:编译器生成 mma.sync 指令,利用 Tensor Core
  • Double buffering:计算第 t 块时,TMA 已经在后台加载第 t+1 块——真正的 latency hiding

常见错误:忘记 Barrier

// ❌ 错误:异步加载后立刻计算(数据可能还没到 shared memory)
cuda::async_copy_tile(smem_A[0], A_ptr, K, bar);
cuda::tile_gemm(accum, smem_A[0], smem_B[0]);  // UB:smem_A 内容未定义

// ✅ 正确
cuda::async_copy_tile(smem_A[0], A_ptr, K, bar);
bar.arrive_and_wait();  // 所有线程确认数据到达后才能读
cuda::tile_gemm(accum, smem_A[0], smem_B[0]);
// ❌ 错误:Tile 超出 Shared Memory 容量(228 KB on H100)
// BM=256, BN=256, float32 = 256*256*4 = 256 KB → 运行时 launch 失败

// ✅ 正确:留出 double buffering 的余量
// BM=128, BK=64, 2个buffer = 128*64*4*2 = 64 KB,安全

编译器自动调优(Autotuning)

CUDA 13.3 的 Autotuning 是结构化搜索,不是黑魔法:

// 声明调优搜索空间
constexpr auto tile_space = cuda::autotune_space{
    cuda::tune_range<"BM">{32, 64, 128},
    cuda::tune_range<"BN">{32, 64, 128},
    cuda::tune_range<"BK">{16, 32, 64},
};

// 首次运行:编译器 benchmark 各配置,选最快的
// 结果缓存到本地,后续直接复用
auto fast_matmul = cuda::autotune<matmul_tile_api>(tile_space, M, N, K);
fast_matmul<<<grid, block>>>(A, B, C, M, N, K);

工作机制

  1. 编译期生成所有参数组合的特化 kernel(模板实例化)
  2. 首次调用时在 GPU 上 benchmark 各配置(额外开销约 0.5-2 秒)
  3. 选择最优配置,缓存到 ~/.cache/cuda/autotune/
  4. 相同 GPU + 问题规模再次调用时直接走缓存

适用前提:kernel 会被多次调用(推理服务),调优成本能被摊平。一次性任务不值得用。


性能实测

测试环境:H100 SXM5 80GB,CUDA 13.3,N=4096,float32,5次取均值

实现版本 时间 (ms) L2 命中率 Tensor Core 利用率 代码复杂度
Naive 45.2 12% 0%
手写 Tiled (TS=32) 12.8 71% 0%
手写 Tiled + mma(CUTLASS) 3.8 83% 极高
CUDA 13.3 Tile API (autotuned) 3.4 85%
cuBLAS SGEMM 3.1 89% 极低(调API)

结论:Tile API 在不牺牲可读性的前提下,比手写 CUTLASS 代码只慢约 10%,比 cuBLAS 慢约 9%


Python 更新:cuda.parallel Tile 支持

CUDA 13.3 的 cuda.parallel 也引入了 tile 化的 Python 接口,适合快速原型验证:

import cuda.parallel as cudapar
import numpy as np

# JIT 编译的 tile 化 reduction
# 自动选择 tile size 和 block 配置
@cudapar.tile_reduce(dtype=np.float32)
def block_sum(acc, val):
    return acc + val

A = np.random.randn(1024 * 1024).astype(np.float32)
result = block_sum(A)  # 自动分 tile,走 Shared Memory

# 2D tile map(矩阵逐元素操作)
@cudapar.tile_map2d(tile_shape=(16, 16), dtype=np.float32)
def relu(x):
    return max(0.0, x)

B_gpu = relu(A.reshape(1024, 1024))

Python 接口性能通常在手写 CUDA 的 70-85%,优势是开发速度——不需要编写任何 C++ 代码。


什么时候用 / 不用?

适用场景 不适用场景
访存密集型算子(GEMM、Attention、Conv) 已经用 cuBLAS/cuDNN 且不需要定制
需要跨 A100/H100/B200 移植的代码 一次性计算,调优开销不值
团队熟悉 C++ 但没时间学 CUTLASS 每个 kernel 只跑几次
推理服务(调优成本能摊平) 批量极小(< 16)的场景,overhead 占比高
想要编译器帮选 tile 尺寸 已在特定硬件上人工调优到极限

副作用

  • Autotuning 首次运行有 benchmark 开销(0.5-2s)
  • Tile API 生成的 kernel 二进制更大(多个特化版本)
  • 调试比手写代码复杂(编译器生成的 PTX 不直观)

调试技巧

1. 验证正确性:先用 8×8 小矩阵,对比 NumPy 结果,再放大到 4096

2. Nsight Compute 关键指标

  • l1tex__t_sectors_pipe_lsu_mem_global_op_ld:Global memory 读扇区数,Tile 版本应比 Naive 少 TILE_SIZE 倍
  • smsp__sass_thread_inst_executed_op_ldsmldmatrix 指令数,Tile API 应自动生成,说明 Tensor Core 路径激活

3. Bank Conflict 检查:Tile API 的 smem layout 默认包含 swizzle,Nsight 中 “Shared Memory Bank Conflicts” 应为 0。如果不为 0,检查 tile shape 是否触发了默认 swizzle 的边界条件。

4. Autotuning 诊断

CUDA_AUTOTUNE_VERBOSE=1 ./your_program  # 打印各配置的 benchmark 结果

延伸阅读