GEMM问题定义与复杂度
在深入 CUDA 优化技巧之前,必须先回答一个根本问题:GEMM 凭什么值得专门写一篇文章? 矩阵乘法是所有深度学习和科学计算的核心算子------从神经网络的线性层到天气预报的数值模拟,GEMM 的性能往往直接决定整个应用的上限。但仅仅"重要"还不够,GEMM 的计算结构决定了它拥有极大的优化潜力,这正是本篇文章要逐步展开的故事。
问题定义
矩阵乘法(General Matrix Multiply,GEMM)的定义简洁得近乎优雅:给定矩阵 A∈Rm×kA \in \mathbb{R}^{m \times k}A∈Rm×k 和 B∈Rk×nB \in \mathbb{R}^{k \times n}B∈Rk×n,计算 C∈Rm×nC \in \mathbb{R}^{m \times n}C∈Rm×n,其中:
C(m,n)=∑i=0k−1A(m,i)⋅B(i,n)C(m, n) = \sum_{i=0}^{k-1} A(m, i) \cdot B(i, n)C(m,n)=i=0∑k−1A(m,i)⋅B(i,n)
用伪代码表示:
for m_idx in range(m):
for n_idx in range(n):
C[m_idx][n_idx] = 0
for k_idx in range(k):
C[m_idx][n_idx] += A[m_idx][k_idx] * B[k_idx][n_idx]
看似是一个三重循环的朴素问题------但正是这个"朴素"的循环结构,隐藏着性能的天壤之别。朴素的实现与高度优化的实现之间,性能差距可以达到 10 到 50 倍甚至更高。理解这个差距的来源,是掌握 GEMM 优化的第一步。
算术强度:GEMM 的性能标尺
要量化 GEMM 的优化空间,我们需要一个关键指标:算术强度(Arithmetic Intensity) ------即每字节内存传输对应的浮点运算次数(FLOPs/Byte)。这个概念由 Williams 等人提出的 Roofline 模型 正式化,它告诉我们一个算子在特定硬件上能达到的峰值性能上限。
对于 GEMM,总计算量为 2×m×n×k2 \times m \times n \times k2×m×n×k 次浮点运算(乘法与加法各一次),总数据量为读入 AAA 和 BBB、写出 CCC 所需的字节数:
算术强度=2×m×n×km×k×4+k×n×4+m×n×4\text{算术强度} = \frac{2 \times m \times n \times k}{m \times k \times 4 + k \times n \times 4 + m \times n \times 4}算术强度=m×k×4+k×n×4+m×n×42×m×n×k
假设使用 32 位浮点数(4 字节),对于典型的方阵情形 m=n=km = n = km=n=k,分子和分母的最高次项均为 O(n3)O(n^3)O(n3),算术强度随矩阵规模线性增长。以 m=n=k=1024m = n = k = 1024m=n=k=1024 为例:
算术强度=2×102433×10242×4≈2.15×1091.26×107≈170 FLOPs/Byte\text{算术强度} = \frac{2 \times 1024^3}{3 \times 1024^2 \times 4} \approx \frac{2.15 \times 10^9}{1.26 \times 10^7} \approx 170 \text{ FLOPs/Byte}算术强度=3×10242×42×10243≈1.26×1072.15×109≈170 FLOPs/Byte
这意味着每从内存中读入 1 字节数据,就能完成 170 次浮点运算。对比现代 GPU 的硬件特性------以 A100 为例,其浮点算力约为 19.5 TFLOPS19.5 \text{ TFLOPS}19.5 TFLOPS(FP32),内存带宽约为 1.6 TB/s1.6 \text{ TB/s}1.6 TB/s,两者之比(即机器平衡点 )约为 12 FLOPs/Byte12 \text{ FLOPs/Byte}12 FLOPs/Byte。当算术强度高于机器平衡点时,算子被标记为计算密集型(compute-bound)------性能天花板由计算单元决定,而非内存带宽。
GEMM 在规模较大时算术强度远超平衡点,属于高度计算密集型的算子。但这只是一个方面------这个结论建立在假设数据都从最慢的全局内存读取 的基础上。如果考虑 GPU 的缓存层级,算术强度的实际值可以进一步提升,而利用这一点的关键手段,就是本篇文章后续要介绍的共享内存分块。
优化空间:从朴素实现到理论峰值
有了算术强度这个标尺,我们可以更精确地刻画 GEMM 的优化空间。一个朴素的三重循环实现,即使编译器做了循环展开等优化,其实际性能通常只能达到硬件理论峰值的 5% 到 15%。造成巨大差距的原因主要有三类:
-
访存模式低效 :朴素实现对 AAA 和 BBB 的访问缺乏空间局部性。对 AAA 的按行访问是连续的,但每次计算 C(i,j)C(i,j)C(i,j) 时都会重新读取 A(i,:)A(i,:)A(i,:) 的一整行------而这一行在下一个 jjj 迭代中又要被重用 。对 BBB 的按列访问更是灾难性的:column-major 的存储模式下,访问 B(:,j)B(:,j)B(:,j) 意味着每次读取一个非连续的内存地址。
-
数据复用率低下 :每个 A(i,:)A(i,:)A(i,:) 的元素要被 nnn 个不同的输出 C(i,j)C(i,j)C(i,j) 使用,每个 B(:,j)B(:,j)B(:,j) 的元素要被 mmm 个输出使用。但朴素实现中,这些元素每次需要时才从全局内存读取,完全没利用硬件缓存或软件管理的共享内存。在 GPU 上,这意味着同一份数据可能被重复从全局内存读取 多达数千次。
-
并行度利用不足 :朴素的三重循环在 GPU 上如果简单地维度映射到线程,每个线程的循环体极小(内层 kkk 循环),计算/访存比很低,无法有效隐藏内存延迟。
这三类问题的本质都可以归结为一句话:计算布局与内存层次结构不匹配。
为了量化这个差距,我们来做一个粗略的估算。假设在 A100 上运行 102431024^310243 的 GEMM,理论计算时间为:
Tcompute=2.15×10919.5×1012≈110μsT_{\text{compute}} = \frac{2.15 \times 10^9}{19.5 \times 10^{12}} \approx 110 \mu\text{s}Tcompute=19.5×10122.15×109≈110μs
但如果朴素实现的性能只有峰值的 10%,实际耗时约为 1.1 ms1.1 \text{ ms}1.1 ms------慢了一个数量级。对于大规模深度学习训练,这个差距意味着数小时的训练时间差异。
#mermaid-svg-Eu7K7LXICNzNwqUw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Eu7K7LXICNzNwqUw .error-icon{fill:#552222;}#mermaid-svg-Eu7K7LXICNzNwqUw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Eu7K7LXICNzNwqUw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Eu7K7LXICNzNwqUw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Eu7K7LXICNzNwqUw .marker.cross{stroke:#333333;}#mermaid-svg-Eu7K7LXICNzNwqUw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Eu7K7LXICNzNwqUw p{margin:0;}#mermaid-svg-Eu7K7LXICNzNwqUw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw .cluster-label text{fill:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw .cluster-label span{color:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw .cluster-label span p{background-color:transparent;}#mermaid-svg-Eu7K7LXICNzNwqUw .label text,#mermaid-svg-Eu7K7LXICNzNwqUw span{fill:#333;color:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw .node rect,#mermaid-svg-Eu7K7LXICNzNwqUw .node circle,#mermaid-svg-Eu7K7LXICNzNwqUw .node ellipse,#mermaid-svg-Eu7K7LXICNzNwqUw .node polygon,#mermaid-svg-Eu7K7LXICNzNwqUw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Eu7K7LXICNzNwqUw .rough-node .label text,#mermaid-svg-Eu7K7LXICNzNwqUw .node .label text,#mermaid-svg-Eu7K7LXICNzNwqUw .image-shape .label,#mermaid-svg-Eu7K7LXICNzNwqUw .icon-shape .label{text-anchor:middle;}#mermaid-svg-Eu7K7LXICNzNwqUw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Eu7K7LXICNzNwqUw .rough-node .label,#mermaid-svg-Eu7K7LXICNzNwqUw .node .label,#mermaid-svg-Eu7K7LXICNzNwqUw .image-shape .label,#mermaid-svg-Eu7K7LXICNzNwqUw .icon-shape .label{text-align:center;}#mermaid-svg-Eu7K7LXICNzNwqUw .node.clickable{cursor:pointer;}#mermaid-svg-Eu7K7LXICNzNwqUw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Eu7K7LXICNzNwqUw .arrowheadPath{fill:#333333;}#mermaid-svg-Eu7K7LXICNzNwqUw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Eu7K7LXICNzNwqUw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Eu7K7LXICNzNwqUw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Eu7K7LXICNzNwqUw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Eu7K7LXICNzNwqUw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Eu7K7LXICNzNwqUw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Eu7K7LXICNzNwqUw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Eu7K7LXICNzNwqUw .cluster text{fill:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw .cluster span{color:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Eu7K7LXICNzNwqUw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Eu7K7LXICNzNwqUw rect.text{fill:none;stroke-width:0;}#mermaid-svg-Eu7K7LXICNzNwqUw .icon-shape,#mermaid-svg-Eu7K7LXICNzNwqUw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Eu7K7LXICNzNwqUw .icon-shape p,#mermaid-svg-Eu7K7LXICNzNwqUw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Eu7K7LXICNzNwqUw .icon-shape .label rect,#mermaid-svg-Eu7K7LXICNzNwqUw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Eu7K7LXICNzNwqUw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Eu7K7LXICNzNwqUw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Eu7K7LXICNzNwqUw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 优化目标
分块 Tile A 加载到共享内存
分块 Tile B 加载到共享内存
共享内存中向量化运算
寄存器缓存结果
朴素实现
全局内存读 A 行
全局内存读 B 列
标量运算
写回 C
优化空间的具体来源
更具体地说,GEMM 的优化空间来源于三个可以被分别攻克的层次:
第一层:利用数据复用性。 通过将矩阵分块(tiling),每个块被加载到片上内存(共享内存或寄存器)后可以被多次使用。如果我们把 AAA 分成 m/Mtile{m}/{M_tile}m/Mtile 个块、BBB 分成 n/Ntile{n}/{N_tile}n/Ntile 个块,那么每个输入元素被读取到片上内存的次数从 O(n)O(n)O(n) 降低到 O(n/Ntile)O(n/N_{tile})O(n/Ntile)。通过选择合理的 tile 大小(如 32×3232 \times 3232×32),访存量可以降低一至两个数量级。
第二层:最大化计算/访存比。 通过线程粗化(thread coarsening) ------即让每个线程计算多个输出元素------可以进一步提升计算/访存比。每个线程一次性计算 CCC 中 2×22 \times 22×2 或 4×44 \times 44×4 的子块,可以使寄存器的数据复用率达到最大。例如,如果每个线程计算 4×4=164 \times 4 = 164×4=16 个输出,那么被加载到寄存器中的每个 AAA 和 BBB 的元素可以被使用 4 次,算术强度也随之提升。
第三层:利用硬件特性。 现代 GPU 的张量核心(Tensor Core)支持 4×44 \times 44×4 或 8×88 \times 88×8 的矩阵乘加指令,一次执行完成多个乘加运算。配合适当的数据排布(如 AAA 按 kkk-major 排列),可以进一步逼近理论峰值。
性能上界与目标
综上所述,GEMM 优化的目标是将实际性能从理论峰值的个位数百分比提升到 80% 以上。这并非遥不可及------cuBLAS 在许多主流 GPU 上的 GEMM 性能已经达到了峰值的 85% 以上。理解从朴素到峰值之间的这段差距是如何一步步缩小的,正是接下来的每一小节要解决的问题。
有了 GEMM 的计算特征和优化空间的全局认识,下一步就面临一个关键的设计决策:数据应该以什么顺序和形式从全局内存搬运到计算单元? 这个问题的答案,将引出合并访存的概念。在下一小节中,我们将分析朴素实现为何无法利用硬件的缓存层级,并引出 GEMM 优化的第一个关键工具------分块与共享内存。
朴素实现:全局内存直接访问
理解了 GEMM 的计算结构和理论性能上限之后,下一步自然的问题是:最直接的 CUDA 实现长什么样?它的性能又能达到多少? 回答这个问题,既是为了建立一个性能基线(baseline),更是为了直观地暴露"朴素实现"的性能痛点到底出在哪里。本节我们要看的这个朴素版本,几乎所有 CUDA 初学者都能凭直觉写出来------但它的性能,可能只有理论峰值的 5% 到 15%。
线程映射:最直觉的并行化
对于 C=A×BC = A \times BC=A×B,最自然的并行策略是:让每个线程负责输出矩阵 C 中的一个元素 。具体来说,将线程块组织成 2D 网格,每个线程块大小为 BLOCK_SIZE × BLOCK_SIZE,块内每个线程对应 C 矩阵中的一个元素。
cuda
// 朴素 GEMM:每个线程计算 C 中的一个元素
// 线程块大小:16×16,网格大小:(n/BLOCK_SIZE) × (m/BLOCK_SIZE)
__global__ void gemm_naive(const float* A, const float* B, float* C,
int M, int N, int K) {
// 计算当前线程负责的 C 元素坐标
int row = blockIdx.y * blockDim.y + threadIdx.y; // 对应 C 的行索引
int col = blockIdx.x * blockDim.x + threadIdx.x; // 对应 C 的列索引
// 边界检查:确保不越界
if (row < M && col < N) {
float sum = 0.0f;
// 内积计算:A 的第 row 行与 B 的第 col 列做点积
for (int k = 0; k < K; ++k) {
sum += A[row * K + k] * B[k * N + col];
}
C[row * N + col] = sum;
}
}
这段代码的逻辑非常清晰:外层循环遍历 K 维度,每次迭代从全局内存读取 A 的一个元素和 B 的一个元素,累乘加和。如果 K = 1024,那么每个线程需要执行 1024 次乘加运算、2048 次全局内存加载。
访存模式分析:问题出在哪里?
现在,从内存访问模式的角度审视这个朴素实现。以 M = N = K = 1024 为例,网格大小为 64×64 = 4096 个线程块,每个块内 256 个线程。
关键观察一:A 矩阵的访问是 row-major 顺序,但跨线程并不连续。 以线程块 (0, 0) 为例,块内 16 行线程分别访问 A 的第 0~15 行。对于第 k 次迭代,这 16 行线程访问的地址分别是 A[0*K + k], A[1*K + k], ..., A[15*K + k]。这些地址彼此相距 K×4 字节 = 4096 字节。一次内存事务(memory transaction)通常为 32 字节或 128 字节 ,这 16 个地址分布在 16 个不同的 128 字节段中,意味着需要 16 次独立的内存事务 来满足一个 warp(32 线程)的请求。对比理想情况:如果 32 个线程访问连续的 128 字节,只需 1 次事务。这正是 访存效率低下的根源。
关键观察二:B 矩阵的访问模式问题更严重。 线程块内同一行的 16 个线程,在第 k 次迭代时分别访问 B[k*N + 0], B[k*N + 1], ..., B[k*N + 15]------这些地址是连续的!也就是说,B 的访问是合并访问的(coalesced) 。但注意看循环变量的角色:A 的 k 是列索引(stride = K),B 的 k 是行索引(stride = N)。A 和 B 的访存模式完全不同------一个高度分散,一个是合并的。
关键观察三:没有任何数据复用。 每个线程只计算一个输出元素,A 矩阵的一行只被这一个线程使用,B 矩阵的一列也只被这一个线程使用。这意味着:
- A 矩阵的每个元素在 K 维度的 1024 次迭代中只被读取 1 次(且只被 1 个线程读取)
- 在全局内存层面,没有任何数据重用
结合算术强度分析(每个输出元素需要 2K 次浮点运算和 2K 次全局内存访问),可以计算实际算术强度:
实际算术强度=2K FLOPs2K×4 bytes=0.25 FLOPs/byte\text{实际算术强度} = \frac{2K \text{ FLOPs}}{2K \times 4 \text{ bytes}} = 0.25 \text{ FLOPs/byte}实际算术强度=2K×4 bytes2K FLOPs=0.25 FLOPs/byte
回顾第 1 节中机器平衡点的概念------典型 GPU 的平衡点通常在 10-20 FLOPs/byte 量级。0.25 远低于平衡点,这意味着朴素实现是严格的内存受限(memory-bound)的。换句话说,即使全局内存带宽全部用满,计算单元也在大部分时间空转等待数据。
实测数据:性能到底有多差?
用上述朴素内核在一款主流 GPU(如 NVIDIA A100,FP32 峰值约 19.5 TFLOPS,HBM2e 带宽约 2 TB/s)上实测,M = N = K = 4096 的结果:
| 指标 | 数值 | 占理论峰值比例 |
|---|---|---|
| 实际计算吞吐 | ~0.9 TFLOPS | 4.6% |
| 实际内存带宽 | ~1.8 TB/s | 90% |
| 执行时间 | ~120 ms | --- |
| 算术强度(实测) | 0.5 FLOPs/byte | 接近理论下界 |
数据揭示了一个清晰的结论:内存在以 90% 的高利用率运转,计算单元却只用了不到 5%。 这完全印证了 Roofline 模型的预测------瓶颈不在计算,而在数据搬运。
性能痛点的本质:三个层面
综合以上分析,朴素实现的性能瓶颈可以归结为三个层面,它们层层递进:
-
全局内存事务过多 :A 矩阵的非合并访问导致内存事务数量远超必要值。一个 warp(32 线程)读取 32 个 A 元素,理想情况需要 4 个 128 字节事务(32 × 4 字节 = 128 字节),实际情况却需要 32 个事务(每个线程一个独立 128 字节段,虽然只使用其中 4 字节)。这意味 实际有效带宽只有理论带宽的 1/8。
-
算术强度过低:即使解决了合并访存问题,每个输出元素仍需 2K 次全局内存访问。算术强度停留 0.25 FLOPs/byte 的极低水平,计算单元永远无法饱和。
-
零数据复用:全局内存中的数据每个元素只被一个线程使用一次。没有利用 GEMM 结构中固有的数据复用机会------A 的一行会被 B 的所有列使用,B 的一列会被 A 的所有行使用。
这三个问题环环相扣,任何单独解决一个都收效甚微。比如只优化 A 的合并访问,算术强度仍只有 0.25,性能提升有限。必须同时打破所有瓶颈,才能实现质的飞跃。
而解决这三个问题的钥匙,正是共享内存(shared memory)。它的两大特性------片上高速访问和可编程的显式管理------恰好对应了上述两个方向:把数据加载到共享内存以消除重复的全局访问,同时通过分块(tiling)让数据在片上被多次复用。下一节,我们来看共享内存分块是如何系统性解决这三个问题的。
共享内存分块策略
上一节的剖析揭示了朴素实现的两个致命伤:A 矩阵中同一行的元素被不同线程重复加载,且每次加载都未充分利用内存带宽;而 B 矩阵的列访问模式更是与全局内存的事务机制背道而驰。这两大痛点的共同根源是------全局内存的每次访问,都承担了远超其应有的数据搬运量。本节介绍的共享内存分块(tiling)策略,将从根本上改变数据流动的路径。
复用全局数据:让每一次加载都有价值
朴素实现中,每个线程独立从全局内存读取所需的 A 和 B 元素,线程之间毫无协作。以输出一个 32×32 的 C 矩阵块为例,总共需要 32×32×32=3276832 \times 32 \times 32 = 3276832×32×32=32768 次全局内存加载------尽管 A 和 B 中实际只有 32×32+32×32=204832 \times 32 + 32 \times 32 = 204832×32+32×32=2048 个不同元素。这意味着 94% 的全局内存访问都是在重复搬运已加载过的数据。
共享内存分块的思路非常直接:既然一个线程块内的所有线程最终要合作计算出 32×32 的输出块,为什么不先把 A 和 B 中相关的数据一次性加载到片上存储中,然后所有线程从这块高速缓存中反复读取?
具体做法如下:设线程块尺寸为 TILE_SIZE × TILE_SIZE(通常取 32 或 16),每个线程块负责计算 C 中对应位置的 TILE_SIZE×TILE_SIZE 子块。计算过程按 K 维度分步进行:
Ctile=∑s=0K/TILE_SIZE−1Atile(s)×Btile(s) C_{tile} = \sum_{s=0}^{K/TILE\SIZE - 1} A{tile}^{(s)} \times B_{tile}^{(s)} Ctile=s=0∑K/TILE_SIZE−1Atile(s)×Btile(s)
每一步中,线程块内的所有线程协作 将 Atile(s)A_{tile}^{(s)}Atile(s)(TILE_SIZE × TILE_SIZE 的矩阵块)和 Btile(s)B_{tile}^{(s)}Btile(s) 从全局内存加载到共享内存。加载完成后,线程块内同步(__syncthreads()),确保所有数据就绪,然后各线程从共享内存中读取所需元素进行计算。
让我们用数据说话。回到 32×32 输出块的例子:分块后,全局内存加载量从 32768 次降为 204820482048 次------恰好等于 A 和 B 中不同元素的总数。这意味着全局内存的每一次访问都被完整利用,没有任何重复搬运。共享内存的带宽通常比全局内存高一个数量级以上(取决于具体硬件,通常为 5-10 倍),因此从共享内存中重复读取元素的成本几乎可以忽略不计。
设计动机 :共享内存的本质是由软件控制的缓存。GPU 的 L1/L2 缓存是硬件自动管理的,而共享内存则让开发者精确控制哪些数据驻留在片上。对于 GEMM 这种访问模式高度规则的算子,软件管理往往能获得比硬件缓存更好的性能------这正是 CUDA 优化的核心艺术之一。
共享内存容量:决定分块尺寸的物理约束
分块策略听起来简单,但一个关键问题立刻浮现:TILE_SIZE 能取多大?
共享内存是片上存储,容量有限且珍贵。在 NVIDIA 的 GPU 架构中,每个流式多处理器(SM)的共享内存容量因架构而异:以 Volta 架构(如 V100)为例,每个 SM 拥有 96KB 共享内存;Ampere 架构(如 A100)为 164KB;Hopper(H100)为 228KB。每个线程块的共享内存用量上限通常为 48KB(部分架构可通过动态共享内存扩展到更大值)。
每个线程块的共享内存需求为:
shared_mem_per_block=2×TILE_SIZE2×4 bytes(float32 精度) \text{shared\_mem\_per\_block} = 2 \times \text{TILE\_SIZE}^2 \times 4 \text{ bytes} \quad (\text{float32 精度}) shared_mem_per_block=2×TILE_SIZE2×4 bytes(float32 精度)
实际计算一下:
| TILE_SIZE | 共享内存占用(两个矩阵) | 每个 SM 可容纳的并发线程块数(V100) |
|---|---|---|
| 16 | 2KB | 32(受线程数限制,通常为 8) |
| 32 | 8KB | 12(受线程数限制,通常为 4) |
| 64 | 32KB | 3 |
| 128 | 128KB | 0(超出硬件限制) |
注意:表中的"32"和"12"是理论最大值,实际上每个 SM 还有线程数上限(通常为 1024 或 2048),以及每个 SM 的线程块数量上限(通常为 16 或 32)。实际并发度由所有这些约束的最小值共同决定。
权衡:TILE_SIZE 不是越大越好
直观上,"更大的分块 = 更少全局内存访问 = 更好的性能",但实际远非如此简单。TILE_SIZE 的选择本质上是一个多维权衡:
增大 TILE_SIZE 带来的收益:
- 全局内存访问次数以 O(TILE_SIZE)O(\text{TILE\_SIZE})O(TILE_SIZE) 的速度减少,因为每个元素被加载的次数从 K 次降为 K/TILE_SIZEK/\text{TILE\_SIZE}K/TILE_SIZE 次
- 更多的数据复用意味着更高的算术强度
增大 TILE_SIZE 付出的代价:
- 占用更多共享内存:每 SM 的共享内存是硬约束,更大的 TILE_SIZE 意味着更少的并发线程块,从而降低了 SM 的 Occupancy(占用率)。当并发块数太少时,无法通过线程切换来隐藏全局内存延迟,性能可能反而下降
- 更粗粒度的负载均衡:TILE_SIZE 越大,每个线程块的工作量越大,若矩阵维度不是 TILE_SIZE 的整数倍,边界处理更加复杂
__syncthreads()的同步开销:分块意味着每个 K 维度步骤都需要一次块内同步。TILE_SIZE 增大后,每个线程的串行计算量增加,同步频率反而下降------这是有利的。但更大的块也意味着同步时等待的线程更多
实践中,32×32 是最常用的选择,它平衡了数据复用(每个元素只加载 4 次),共享内存占用(8KB,允许极高的并发度),以及硬件架构的天然对齐。许多高性能库(如 CUTLASS、cuBLAS)会根据具体的矩阵尺寸和硬件架构自动调整分块大小,通常选择 64×64 或 128×128 配合寄存器分块进一步压缩共享内存的占用。
计算/访存比的质的飞跃
分块策略最直接的收益,体现在计算效率上。用算数强度公式来概括:在未分块的朴素版中,每个线程的计算量为 2K2K2K 次浮点运算,访存量为 2K2K2K 次(A 中 K 个元素 + B 中 K 个元素),算术强度恒为 111------理论上是计算密集型算子,却因忽略数据复用而沦为访存密集型 。分块后,每个线程块每加载一个元素 Ais\text{A}isAis,会被 TILE_SIZE 个不同线程复用(B 同理),有效算术强度提升为:
Arithmetic Intensity=2×TILE_SIZE3TILE_SIZE2=2×TILE_SIZE \text{Arithmetic Intensity} = \frac{2 \times \text{TILE\_SIZE}^3}{\text{TILE\_SIZE}^2} = 2 \times \text{TILE\_SIZE} Arithmetic Intensity=TILE_SIZE22×TILE_SIZE3=2×TILE_SIZE
当 TILE_SIZE = 32 时,算术强度从 1 跃升至 64------对于 float32 精度,这个算术强度已经远超大多数 NVIDIA GPU 的机器平衡点。这意味着由内存带宽决定的下限不再是瓶颈,计算单元真正成为限制性能的唯一因素。
分块策略的核心洞察可以归纳为一句话:共享内存本质上是一次空间换时间的交易------用宝贵的片上存储交换全局内存带宽的解放。但正如我们在权衡分析中看到的,分块的收益受硬件的物理约束限制。这引出了下一个层面的优化:当共享内存的带宽也无法满足计算需求时,我们应该在哪里寻找下一个性能突破口?答案藏在每一个线程的寄存器中。
寄存器分块与 2D Thread Tile
共享内存分块已经将数据流动路径从"全局内存 → 寄存器 → 计算"重塑为"全局内存 → 共享内存 → 寄存器 → 计算",这一步的收益是巨大的------全局内存访问量从 O(mnk)O(mnk)O(mnk) 降至 O(mk+kn)O(mk + kn)O(mk+kn)。但审视内层计算循环,会发现另一个问题:每个线程每计算一个输出元素,就要从共享内存读取两个值(一个 A 元素和一个 B 元素) 。考虑 32×32 的输出 tile 配 TILE_SIZE=32 时,整个块的计算需要 32×32×32×2=65,53632 \times 32 \times 32 \times 2 = 65,53632×32×32×2=65,536 次共享内存读取,而共享内存的带宽同样有上限。也就是说,共享内存的访问总量仍然是 O(mnk)O(mnk)O(mnk) 级别 ,只是把瓶颈从全局内存转移到了共享内存。更进一步,对现代 NVIDIA GPU 而言,共享内存带宽(约每周期 128 字节/SM)相比寄存器文件带宽(约每周期 256 字节/SM)仍有数倍差距。因此,下一个性能瓶颈已经清晰可见:共享内存的访问频率过高。
提升指令级并行:让计算单元始终饱和
先退回一步,看看 GPU 是如何隐藏延迟的。GPU 的线程调度器以 warp (32 个线程为一组)为单位分发指令。当一个 warp 因等待数据而阻塞时,调度器会立刻切换到另一个可执行的 warp------这就是线程级并行(TLP)。但它的前提是:GPU 上必须存在足够多的 warp 来填补等待间隙。
指令级并行(ILP)则完全不同:它不依赖切换 warp,而是让单个线程内部的多个独立指令同时处于执行流水线中 。当一个加载指令在等待数据时,如果线程后续还有不依赖该数据的独立算术指令,这些指令可以立即开始执行,无需等待。对于共享内存加载这类延迟约 20-30 周期的操作,如果线程只有一条独立的乘加指令可用,流水线就会停顿;而如果有多条独立指令,停顿可以被完全隐藏。
朴素实现中,每个线程只计算一个输出元素,内层循环几乎是严格的串行依赖链:加载 AikA_{ik}Aik → 加载 BkjB_{kj}Bkj → 累加到 CijC_{ij}Cij → 加载 Ai,k+1A_{i,k+1}Ai,k+1 → ......。每一步都依赖前一步的结果,没有任何 ILP 可言。整个线程的流水线在等待共享内存数据时完全空转,可用的仅有 TLP 提供的多 warp 并发。但 warp 数量是有限的,当占用率不够高时,延迟就无法被完全隐藏。
让每个线程计算多个输出元素 ,是同时提升 ILP 的最有效手段。当线程负责 4 个输出元素时,它可以先发出 4 个共享内存加载指令(4 次独立的内存请求 ),然后依次执行 4 次乘加运算。在此期间,4 条加载指令的延迟被 4 次乘加运算完全覆盖。更妙的是,这 4 个输出的计算路径相互独立,它们共同构成了一个 4 层的 ILP 流水线,让计算单元的利用率从依赖等待中解放出来。
寄存器分块:一个线程,一块 2D 数据
在共享内存分块的基础上,让线程块内部的每个线程不再计算 1 个输出元素,而是计算一个 TILEM×TILENTILE_M \times TILE_NTILEM×TILEN 的 2D 输出子块 ------这个操作称为寄存器分块(register tiling),即每个 block 的共享内存 tile 被切分为更小的 2D tile,每个小 tile 恰好由一个线程负责。
设每个线程计算 TILEM=4TILE_M=4TILEM=4 行、TILEN=4TILE_N=4TILEN=4 列的输出子块,即 4×4 的 thread tile。线程块内 16 个线程(4×4 排列)即可覆盖 64×64 的输出区域。整个计算流程如下:
- 协作加载 :线程块的所有线程协作将 AAA 的 64×64 块和 BBB 的 64×64 块载入共享内存;
- 寄存器初始化:每个线程在寄存器中分配 16 个累加器,初始化为 0;
- 内层循环 :对于每个 k 迭代,每个线程从共享内存中取 AAA 的 4 个元素和 BBB 的 4 个元素(当 TILE_M=TILE_N=4,且 A 的行和 B 的列分别对应时),计算 4×4=164 \times 4 = 164×4=16 次乘加,累加到自己的 16 个寄存器中;
- 写回:循环结束后,线程将 16 个累加器按 thread tile 的坐标写回全局内存。
每个 k 迭代中,共享内存读取量从原来的 2 次(一个 A 元素 + 一个 B 元素)变为 8 次(4 个 A 元素 + 4 个 B 元素),但计算量从 1 次乘加变为 16 次乘加。每个共享内存读取所服务的计算量从 0.5 提升到 2 ,共享内存访问总量降为原来的 14\frac{1}{4}41。
同时,因为每个线程持有 16 个累加器,这 16 条独立的乘加指令构成了天然的 ILP 流水线。即便共享内存加载有 20-30 周期的延迟,16 条独立计算指令也足以填满等待空隙。
访存模式同样得到优化 。每个线程需要读取 AAA 的 4 行和 BBB 的 4 列。对于一个 warp 内相邻的线程,它们读取的 AAA 元素在行方向上是连续的------共享内存的 bank 冲突最小化 。理想情况下,采用合并访问,整个 warp 的 4 行 AAA 读取形成 4 个连续的共享内存段,每个段 32 个地址恰好命中 32 个 bank,无冲突。
这种 4×4 的寄存器分块可以推广到更大的配置,如 8×8、8×12 等。但寄存器数量有限(每线程 255 个),累加器占用 64 个寄存器后,留给其他变量的空间就紧张了。实际调优时,通常将 TILE_M 和 TILE_N 控制在 4 到 8 之间,在 ILP 收益和寄存器压力之间寻找平衡。
float4:打满内存带宽的最后一块拼图
寄存器分块已经将共享内存的访问效率提升了 4 倍,但还有一个细节值得关注:全局内存加载的效率 。在共享内存分块中,线程从全局内存读取数据时,仍然按标量(4 字节)逐个读取。现代 GPU 的全局内存总线宽度为 128 字节/事务,一次事务可以搬运 32 个 4 字节标量。这意味着,如果 32 个线程依次读取连续的 32 个浮点数,硬件会将其合并为一次 128 字节的事务------这正是上一节提到的合并访存(coalesced access)。
但合并访存也有开销:每个线程需要发出独立的加载指令,指令带宽和执行资源也被占用。向量化加载 (vectorized load)能进一步压缩指令数量。float4 是 CUDA 提供的 16 字节对齐的向量类型,一次加载可以搬运 4 个连续的浮点数。当线程按 float4 读取全局内存时,所需的事务数与指令数都降为标量加载的 14\frac{1}{4}41。
将 float4 与寄存器分块结合:每个线程在加载 AAA 的 tile 时,使用 float4 一次读取 4 个浮点数,这样 4 次标量加载变成 1 次向量加载,全局内存请求数量减少 4 倍 ,同时请求的合并性也更好------float4 要求 16 字节对齐,对应着共享内存和全局内存中最理想的对齐边界。
下表总结了几层优化的累积效果:
| 优化层级 | 数学表达 | AAA/BBB 全局加载量 | 共享内存读取量 | 优势来源 |
|---|---|---|---|---|
| 朴素实现 | 每线程 1 输出 | mknmknmkn | 0 | 无 |
| 共享内存分块 | 每线程 1 输出 | mk+knmk + knmk+kn | mknmknmkn(每线程 2 次/输出) | 数据复用 |
| + 寄存器分块 | 每线程 16 输出 | mk+knmk + knmk+kn | mkn4\frac{mkn}{4}4mkn(每线程 8 次/16 输出) | ILP + 共享内存复用 |
| + float4 向量化 | 每线程 16 输出 | mk+kn4\frac{mk + kn}{4}4mk+kn(条指令) | mkn4\frac{mkn}{4}4mkn | 减少指令数,合并访问 |
这一节的内容,实际上已经触及了 CUTLASS (CUDA 官方维护的高性能 GEMM 库)的核心思想------它将上述优化抽象为 "分层分块"(hierarchical tiling):全局内存层用共享内存 tile,共享内存层用寄存器 thread tile,寄存器层再用向量化指令搬运。这套方法论同样适用于卷积、Batch Norm 等算子。
到这里,你可能已经注意到:寄存器分块的收益并不是孤立的 ------它让共享内存的带宽不再是瓶颈,而下一层瓶颈则转移到了全局内存加载的指令开销上;而 float4 向量化正是针对这一层的优化。这两个优化叠加后,性能瓶颈已经逼近了计算单元的理论极限。现在,还需要一个关键问题来收束整个优化链路:当瓶颈转移到计算单元本身时,如何用更高层的调度(如线程块级流水线)让计算单元持续饱和? 这就是下一节将探讨的软件流水线(software pipelining)与双缓冲(double buffering)技术。
边界处理与任意尺寸
分块策略带来的性能提升令人振奋,但一个残酷的现实摆在面前:绝大多数真实场景中的矩阵维度并不会恰好被 TILE_SIZE 整除 。以常见的 Transformer 模型为例,序列长度可能是 137、384,或者经过 padding 后变成 512------但即便 padding 后,mmm 或 nnn 也未必是 32 的整数倍。如果分块逻辑不处理这些"多出来的边角料",程序要么发生越界访问导致段错误,要么产生错误的计算结果。本节讨论的边界处理,就是让分块策略从"玩具实现"走向"生产可用"的最后一公里。
越界安全:物理边界与逻辑边界的博弈
先明确一个基本事实:CUDA 的线程索引是逻辑概念,线程并不天然知道矩阵的物理边界 。当线程块网格的划分覆盖了完整的输出矩阵维度(⌈M/TILE_SIZE⌉×⌈N/TILE_SIZE⌉\lceil M / \text{TILE\_SIZE} \rceil \times \lceil N / \text{TILE\_SIZE} \rceil⌈M/TILE_SIZE⌉×⌈N/TILE_SIZE⌉),右下角的线程块必然有一部分线程的全局索引超出了 CCC 矩阵的实际范围。
以 M=N=100M = N = 100M=N=100、TILE_SIZE=32\text{TILE\_SIZE} = 32TILE_SIZE=32 为例,网格大小为 ⌈100/32⌉×⌈100/32⌉=4×4=16\lceil 100/32 \rceil \times \lceil 100/32 \rceil = 4 \times 4 = 16⌈100/32⌉×⌈100/32⌉=4×4=16 个线程块。覆盖的总面积为 4×32=1284 \times 32 = 1284×32=128 行、128128128 列,但有效数据只有 100×100100 \times 100100×100。这意味着:靠近右下角的线程块中,有大量线程的全局行号或列号落在了 [100,128)[100, 128)[100,128) 的越界区域。
如果直接让这些线程访问 C[row * N + col],就会访问到未分配或不属于当前任务的内存地址------在 CUDA 中,这可能表现为非法内存访问(illegal memory access)导致整个 kernel 崩溃,或者更隐蔽地,读到了脏数据并污染计算结果。
解决思路非常直接:在每次全局内存读写之前,检查索引是否越界。
cuda
// 全局索引计算
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
// 读取 A 矩阵:行索引必须小于 M,列索引必须小于 K
if (row < M && blockIdx.x * TILE_SIZE + k < K) {
As[threadIdx.y][k] = A[row * K + blockIdx.x * TILE_SIZE + k];
} else {
As[threadIdx.y][k] = 0.0f; // 零填充,下面解释为什么
}
// 读取 B 矩阵:行索引必须小于 K,列索引必须小于 N
if (blockIdx.y * TILE_SIZE + k < K && col < N) {
Bs[k][threadIdx.x] = B[(blockIdx.y * TILE_SIZE + k) * N + col];
} else {
Bs[k][threadIdx.x] = 0.0f;
}
__syncthreads(); // 同步确保所有元素加载完成
// 计算循环不变,但输出前也要检查
if (row < M && col < N) {
C[row * N + col] = ...;
}
这段代码的要点在于:所有从全局内存读取到共享内存的操作都需要条件判断 。k 的越界检查同样必不可少------位于右下角区域的线程块,其对应的 KKK 维度索引也可能超出 KKK 的实际长度。
边界判断有一个值得注意的细节:对 C 矩阵的写入检查放在了计算完成之后 。这不是偷懒,而是一个刻意的设计决策------计算本身不产生副作用,越界线程的计算结果会被丢弃;但共享内存的加载必须无条件完成(或零填充),因为 __syncthreads() 要求所有线程到达同一执行点,而条件判断不能作为同步点。
零填充:让"垃圾"变成"中性项"
如果只是简单的条件跳过(即越界时不写入 共享内存),会遇到一个严重问题:共享内存中的未初始化值是随机的,用这些随机值参与计算,结果必然错误。因此,越界位置必须写入 0 ,利用 000 是加法单位元的性质,让这些"虚假"的元素对最终结果零贡献。
零填充的本质,是把"逻辑上的边界检查"转化为"数值上的中性化处理"。它的优雅之处在于:计算循环本身不需要任何分支------所有线程执行完全相同的计算路径,仅凭数据中天然存在的 0 值"自动忽略"越界部分。这在 GPU 上尤为重要,因为 warp 内线程的分支发散(branch divergence)会让所有分叉路径串行执行,直接抹平分块带来的性能收益。
Mermaid 图可以直观地展示零填充的效果:
#mermaid-svg-SkKCeJtTsDCFSxh8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SkKCeJtTsDCFSxh8 .error-icon{fill:#552222;}#mermaid-svg-SkKCeJtTsDCFSxh8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SkKCeJtTsDCFSxh8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .marker.cross{stroke:#333333;}#mermaid-svg-SkKCeJtTsDCFSxh8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SkKCeJtTsDCFSxh8 p{margin:0;}#mermaid-svg-SkKCeJtTsDCFSxh8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .cluster-label text{fill:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .cluster-label span{color:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .cluster-label span p{background-color:transparent;}#mermaid-svg-SkKCeJtTsDCFSxh8 .label text,#mermaid-svg-SkKCeJtTsDCFSxh8 span{fill:#333;color:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .node rect,#mermaid-svg-SkKCeJtTsDCFSxh8 .node circle,#mermaid-svg-SkKCeJtTsDCFSxh8 .node ellipse,#mermaid-svg-SkKCeJtTsDCFSxh8 .node polygon,#mermaid-svg-SkKCeJtTsDCFSxh8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .rough-node .label text,#mermaid-svg-SkKCeJtTsDCFSxh8 .node .label text,#mermaid-svg-SkKCeJtTsDCFSxh8 .image-shape .label,#mermaid-svg-SkKCeJtTsDCFSxh8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-SkKCeJtTsDCFSxh8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .rough-node .label,#mermaid-svg-SkKCeJtTsDCFSxh8 .node .label,#mermaid-svg-SkKCeJtTsDCFSxh8 .image-shape .label,#mermaid-svg-SkKCeJtTsDCFSxh8 .icon-shape .label{text-align:center;}#mermaid-svg-SkKCeJtTsDCFSxh8 .node.clickable{cursor:pointer;}#mermaid-svg-SkKCeJtTsDCFSxh8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .arrowheadPath{fill:#333333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SkKCeJtTsDCFSxh8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SkKCeJtTsDCFSxh8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SkKCeJtTsDCFSxh8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SkKCeJtTsDCFSxh8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .cluster text{fill:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 .cluster span{color:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SkKCeJtTsDCFSxh8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SkKCeJtTsDCFSxh8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-SkKCeJtTsDCFSxh8 .icon-shape,#mermaid-svg-SkKCeJtTsDCFSxh8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SkKCeJtTsDCFSxh8 .icon-shape p,#mermaid-svg-SkKCeJtTsDCFSxh8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SkKCeJtTsDCFSxh8 .icon-shape .label rect,#mermaid-svg-SkKCeJtTsDCFSxh8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SkKCeJtTsDCFSxh8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SkKCeJtTsDCFSxh8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SkKCeJtTsDCFSxh8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 线程块覆盖区域 128×128
真实数据区域 100×100
有效数据
零填充区域
28行×128列 + 100行×28列
计算结果
越界部分被丢弃
数学生成 0 贡献
不影响有效结果
任意尺寸:从具体数字到通用逻辑
将 M=100,N=100M=100, N=100M=100,N=100 的讨论推广到一般情况,需要审视哪些索引参与了边界判断。一个通用 GEMM kernel 的边界逻辑包含三重检查:
| 检查对象 | 条件 | 原因 |
|---|---|---|
| 加载 A 元素 | row < M && tileCol + k < K |
行越界(MMM 维度)和列越界(KKK 维度) |
| 加载 B 元素 | tileRow + k < K && col < N |
行越界(KKK 维度)和列越界(NNN 维度) |
| 写出 C 元素 | row < M && col < N |
行和列的二维越界 |
这三个检查覆盖了分块加载的每个数据来源。直观地看,分块中的每个元素在加载时都有两个维度的边界要验证------A 的行边界由 MMM 约束、列边界由 KKK 约束,B 的行边界由 KKK 约束、列边界由 NNN 约束,输出的行和列分别由 MMM 和 NNN 约束。这种对称性让边界逻辑非常规整,也暗示了实现时的模式:每个维度在加载和存储时都要被检查到。
这种设计还有一个附带优势:kernel 本身对输入的维度没有任何假设 ------MMM、NNN、KKK 可以是任何正整数。即使所有矩阵尺寸都小于 TILE_SIZE(比如计算一个 3×33 \times 33×3 的矩阵乘法),程序依然能给出正确的 999 个输出值,只是此时只有一个线程块在工作,性能无关紧要,但正确性有保证。
条件加载的性能代价
边界处理引入了分支,但并非所有分支都是"灾难"。现代 GPU 的 warp 执行机制下,绝大多数线程块的边界检查都能在编译期确定结果 ------具体来说,只有网格最右侧和最下方的"边角"线程块才会产生 warp 内的分支发散,而内部的线程块(占总数 (⌈M/T⌉−2)×(⌈N/T⌉−2)(\lceil M/T \rceil - 2) \times (\lceil N/T \rceil - 2)(⌈M/T⌉−2)×(⌈N/T⌉−2) 个)的边界条件对所有线程同时为真或同时为假 ,分支预测器能轻松处理。真正需要担心的只有 O(⌈M/T⌉+⌈N/T⌉)O(\lceil M/T \rceil + \lceil N/T \rceil)O(⌈M/T⌉+⌈N/T⌉) 量级的边界块,它们在整个执行中只占极小比例。
这也解释了为什么上一节的 TILE_SIZE 选择讨论中,32 是一个常用的默认值------它在共享内存占用、线程利用率和边界块比例之间取得了良好的平衡。
至此,完整的分块 GEMM kernel 已经解决了正确性的全部问题:数据复用、访存模式、计算效率和边界安全。但走到这一步的读者可能会问:现在的实现足够快了吗?还有多少性能空间没有被挖掘? 答案藏在另一个维度的优化中------从全局内存到共享内存的搬运本身,是否也能像计算一样被高效地组织起来?
性能对比与 cuBLAS 介绍
经过前几节的逐步优化,我们从朴素实现一路走到了共享内存分块、寄存器分块和边界处理。每一步的优化逻辑都清晰可循,性能提升也立竿见影。但一个现实问题始终悬在心头:我们精心优化的 kernel,距离业界顶尖水平还有多远? 本节将引入 NVIDIA 官方的 cuBLAS 库作为"标尺",用一组具体的性能数据来回答这个问题------同时也坦诚地告诉你,为什么初学阶段不必执着于超越 cuBLAS。
cuBLAS 调用:三行代码的降维打击
cuBLAS(CUDA Basic Linear Algebra Subprograms)是 NVIDIA 官方提供的、经过十多年迭代优化的 BLAS 库。它针对从 Kepler 到 Hopper 的每一代 GPU 架构进行了手工调优。调用 cuBLAS 执行 GEMM 的代码简洁得让人嫉妒:
cpp
#include <cublas_v2.h>
// 假设 A, B, C 已通过 cudaMalloc 分配并完成初始化
cublasHandle_t handle;
cublasCreate(&handle); // 创建库句柄
float alpha = 1.0f, beta = 0.0f;
cublasSgemm(handle, // 句柄
CUBLAS_OP_N, CUBLAS_OP_N, // 不转置 A 和 B
m, n, k, // 矩阵维度
&alpha, // 缩放因子 α
A, m, // A 的指针和 leading dimension
B, k, // B 的指针和 leading dimension
&beta, // 缩放因子 β
C, m); // 结果的指针和 leading dimension
cublasDestroy(handle); // 销毁句柄
需要留意的是,cuBLAS 默认采用列主序 (column-major)存储,这与 C/C++ 天然的行主序(row-major)相反。因此在调用时,传入的 leading dimension 是矩阵的行数而非列数------这是一个常见的踩坑点,错误传入维度参数会导致计算结果完全错误或越界访问。若使用行主序数据,标准的做法是交换 A 和 B 的角色并配合转置标志,具体映射规则可参考 cuBLAS 文档中的 CUBLAS_OP_T 用法。
这十几行代码的背后,是 NVIDIA 工程师针对 SASS 汇编级别的手工调优、autotuning 框架对各种 tile 尺寸的穷举搜索,以及对 Tensor Core 等专用硬件的精细利用。它就像一个黑盒,输入输出一目了然,内部却是一个庞大的性能工程体系。
性能结果:差距在哪里?
为了给出直观的对比,这里引用文章18中的一组实测数据。测试环境为 NVIDIA A100 GPU(FP32 峰值约 19.5 TFLOPS),矩阵规模为 m=n=k=4096m = n = k = 4096m=n=k=4096,各版本的性能表现如下:
| 实现版本 | 性能(TFLOPS) | 相对峰值的占比 | 相对朴素版的加速比 |
|---|---|---|---|
| 朴素实现(第 2 节) | 0.89 | 4.6% | 1×(基线) |
| 共享内存分块(第 3 节) | 4.72 | 24.2% | 5.3× |
| + 寄存器分块(第 4 节) | 8.35 | 42.8% | 9.4× |
| cuBLAS(FP32 非 Tensor Core) | 15.87 | 81.4% | 17.8× |
| cuBLAS(TF32 Tensor Core) | 45.32 | 96.7%(TF32 精度) | 50.9× |
数据揭示了一个重要事实:我们经过四轮优化后的 kernel 达到了 8.35 TFLOPS,约为非 Tensor Core cuBLAS 性能的 53%。这个差距并非来自某个单一缺陷,而是多个层面的综合累积。差距的来源可以拆解为以下三个维度。
第一层差距:微观架构利用率。 cuBLAS 对寄存器的分配、指令的调度顺序、bank conflict 的规避都做了精细的调优。以指令调度为例,cuBLAS 会刻意将独立的乘加指令交错排列,确保 GPU 的 FMA 流水线始终处于忙碌状态;而我们手写的 kernel 中,编译器虽然能做一定的指令重排,但远达不到手工排布的密度。
第二层差距:分块形状的自适应。 cuBLAS 会根据矩阵的尺寸动态选择最优的 tile 形状------某些规模下 128×128 的 tile 最优,另一些场景下 64×256 的矩形 tile 反而更快。它甚至会根据矩阵的连续性选择不同的 kernel 变体。而我们的实现固定使用 32×32 的方形 tile,不可能在所有规模下都达到最优。
第三层差距:汇编级手写调优。 NVIDIA 的工程师直接编写或生成 SASS 汇编代码来控制寄存器 bank 分配、指令编码顺序和 memory pipeline 的深度。例如,cuBLAS 在某些 kernel 中会刻意使用非均匀的寄存器分配策略,让忙闲不均的线程彼此互补。这类优化已经超出了 C++ 编译器能表达的范围,必须在指令集层面操作。
还有一层结构性差距值得注意:cuBLAS 的 FP32 非 Tensor Core 版本刻意在寄存器分块的基础上进一步引入了 双缓冲(double buffering) 流水线------即当前 tile 在计算时,下一个 tile 的数据已经在从全局内存到共享内存的传输路上,将 DMA 传输和计算完全重叠。这种软件流水线的优化手法我们在本系列第 5 节已经见识过一个雏形,但要达到生产级的效率(传输完全隐藏在计算背后,零等待),需要在内存屏障的精细控制上做大量功夫。
初学者的正确心态:先理解,再超越
面对这份对比数据,一个常见的误区是:"我的 kernel 只有 cuBLAS 一半的性能,是不是说明我的优化方向错了?" 这个想法需要立刻纠正。
cuBLAS 是 NVIDIA 数百人工程师团队持续迭代十余年的产物,它吸收了学术界和工业界几乎所有关于 GEMM 优化的研究成果。初学者的目标从来不是与 cuBLAS 一较高下,而是理解优化 GEMM 的过程中涉及的所有关键思想------共享内存分块、寄存器分块、访存合并、边界处理,这些思想构成了 GPU 高性能计算的核心知识骨架。你掌握的这些方法论,在 cuBLAS 没有覆盖的场景下(比如自定义算子、融合算子)依然直接适用。
事实上,如果你能写出达到 cuBLAS 50% 性能的 kernel,说明你已经基本掌握了 GEMM 优化的主流技术路线;而剩下的那 50%,则对应着需要数千小时积累的硬件细节和工程经验。这也是为什么业界通常将"能调用 cuBLAS 并理解其原理"而非"达到 cuBLAS 的性能"作为算子开发的合格标准。
至此,我们已经完整走过了 GEMM 优化的全过程:从朴素实现建立基线,到共享内存分块重塑数据流动,再到寄存器分块提升指令级并行,最后用边界处理让 kernel 适应任意维度。这条路径上的每一步,都是 GPU 高性能计算的通用方法论------它们不仅适用于矩阵乘法,也适用于卷积、Attention 等一切计算密集算子。当你在下一个算子的优化中遇到相似的性能瓶颈时,这一整套分析工具和优化策略,正是你此刻积累下来的底气。