CUDA Mode - Lecture 8

1. 性能优化

GPU 优化的核心:我们为 GPU 付费的唯一原因是性能,如果无法获得性能提升,使用 GPU 就没有意义。

理念:Profile First ------ 先有假设,再用 profiling 工具验证。

Code: https://github.com/lvy010/cuda-training

运行环境: 推荐 Lightning AI Studio(已预装 NCU 工具)


2. 内存层级与延迟

2.1 SRAM vs DRAM

内存类型 位置 大小 延迟
SRAM (Shared Memory) 每个 SM 内 ~100+ KB (可配置) ~25 周期
L1 Cache 每 SM ~128 KB ~25 周期
L2 Cache GPU 全局 ~几 MB ~200 周期
Global Memory (DRAM) GPU 外部 ~40-80 GB ~290 周期

发现 :L1 Cache 和 Shared Memory 相比 Global Memory 有 ~10倍 的速度优势。

2.2 为什么延迟难以减小

文章《It's Latency, Stupid》观点:

  • 吞吐量可以通过并行化轻松提升(例如 80 条电话线并行处理)
  • 延迟无法通过增加并行度来降低,必须从根本上改变架构

GPU 的策略是:**隐藏延迟(hide latency)**而非减少延迟,通过大量线程并发执行来掩盖内存访问延迟


3. 合并全局内存访问 (Coalescing Global Memory Accesses)

3.1 为什么重要

GPU 是吞吐量导向架构,理想情况下读取 50 个连续元素不比读取 1 个元素慢多少。但前提是内存访问必须是合并的。

3.2 合并 vs 非合并

cuda 复制代码
// 合并版本(Coalesced)
__global__ void copy_data_coalesced(float* out, float* in, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        out[idx] = in[idx];  // 连续访问 in[idx]
    }
}

// 非合并版本(Non-Coalesced)
__global__ void copy_data_non_coalesced(float* out, float* in, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        out[idx] = in[idx * 2 % n];  // 跳跃访问,间隔访问
    }
}

3.3 性能对比( NCU profiling)

指标 非合并版本 合并版本
DRAM Throughput ~90% ~82%
L1 Cache Throughput ~30% ~37%
Kernel Duration ~764 μs ~5582 μs

非合并版本慢了约 4倍,L1 Cache 命中率从 37% 降到 30%。

3.4 在 PyTorch 中的表现

非合并访问在 PyTorch 中通常以 stride 形式出现:

python 复制代码
# 物理内存连续,但按 stride=2 读取
x = torch.randn(1024)
y = x[::2]  # stride=2,非连续访问模式

4. 最大化占用率 (Maximizing Occupancy)

4.1 占用率

占用率 = 实际运行的 warp 数量 / GPU SM 能支持的最大 warp 数量。

目标:让 SM 始终有 warp 可调度,这样当某个 warp 等待内存时,另一个 warp 可以执行。

4.2 占用率不足的问题:量化效应

Tile Quantization(平铺量化)

当矩阵维度无法被线程块大小整除时,最后一行的 tile 会产生"不完整的 tile",导致资源利用不足。

Wave Quantization(波次量化)

当总 tile 数无法被 SM 数量整除时,最后一批 tiles 会不均衡地分配到 SM 上。

python 复制代码
# 示例:M=1024, N=1024, 改变 K 值
# K=1012: 耗时 4x(差)
# K=1016: 耗时 1x(优)

4.3 最佳矩阵维度(Tensor Core)

对于 A100 上的 Tensor Core,矩阵维度最好是 16 的倍数:

数据类型 最小维度倍数 原因
FP64 8 8 bytes per element
TF32 16 4 bytes
FP16/BF16 32 2 bytes
INT8 64 1 byte

这解释了为什么 PyTorch 社区广泛使用 padding(如 vocab=32000 是 64 的倍数)。

4.4 CUDA Occupancy Calculator

CUDA 提供了便捷的工具来计算最佳 launch 参数:

cpp 复制代码
#include <cuda_occupancy.h>

int main() {
    int block_size;
    int min_grid_size;
    
    // 传入 kernel 函数引用,自动计算最优配置
    cudaOccupancyMaxPotentialBlockSize(
        &min_grid_size,
        &block_size,
        copy_data_coalesced,
        0,  // 共享内存大小(无额外需求)
        0   // 最大 block size(0 表示无限制)
    );
    
    printf("Recommended block size: %d\n", block_size);
    printf("Minimum grid size: %d\n", min_grid_size);
}

运行结果示例:

复制代码
Recommended block size: 1024
Minimum grid size: 40

不同 GPU 上的结果不同(如 A100 推荐 block_size=768, grid_size=160,是 T4 的 4 倍)。

4.5 如何诊断占用率问题

查看 NCU 报告中的 Theoretical Occupancy vs Measured Occupancy

复制代码
Theoretical occupancy: 100%
Measured occupancy: 77%

差距可能来源:

  • Warp 调度开销
  • Block 间/Block 内负载不均衡

5. 计算密集 vs 内存密集分析 (Roofline Model)

5.1 Roofline Model 概述

复制代码
Performance (GFLOPS)
    ^                          /
    |                         /
    |            Compute     /
    |            Bound      /
    |           Region     /
    |          (平坦)      /
    |         /           /
    |        /           /
    |       / Memory    /
    |      / Bound     /
    |     / Region    /
    |    /(斜线)      /
    |   /           /
    +-------------------------> Operational Intensity (FLOPs/Byte)
  • X 轴:运算强度 = 总 FLOPs / 总内存访问字节数
  • Y 轴:实际性能 (GFLOPS)

两个区域

  1. Memory Bound Region(左侧斜线部分):性能受内存带宽限制
  2. Compute Bound Region(右侧平坦部分):性能受 GPU 算力限制

5.2 运算强度计算

示例 1:ReLU 激活函数
python 复制代码
# ReLU: y[i] = max(x[i], 0)

# 场景 A: x[i] < 0 需要写入 0
# 读取 1 个 float32 (4 bytes)
# 写入 1 个 float32 (4 bytes)
# 执行 1 个比较操作

运算强度 = 1 FLOP / 8 bytes = 0.125

# 场景 B: x[i] >= 0 无需写入(最乐观情况)
# 读取 1 个 float32 (4 bytes)
# 执行 1 个比较操作

运算强度 = 1 FLOP / 4 bytes = 0.25

结论:运算强度 < 1 的操作(如 ReLU)是内存带宽密集型。

示例 2:矩阵乘法 (GEMM)
python 复制代码
# C[M×K] = A[M×N] @ B[N×K]
# M=1024, N=1024, K=1024

# 总 FLOPs = M × K × 2N = 1024 × 1024 × 2048 ≈ 2.1B
# 总内存访问 = A(M×N) + B(N×K) + C(M×K) 
#           = 1024² + 1024² + 1024² = 3 × 1024² ≈ 3MB

运算强度 = 2.1B / 3MB ≈ 700 FLOPs/Byte

结论 :大规模矩阵乘法是计算密集型,但小矩阵(如 1×1, 2×2)会变成内存密集型。

作业:尝试推导矩阵向量乘法的运算强度,理解为什么它通常是内存密集型。

5.3 优化方向对照表

瓶颈类型 优化策略 具体手段
Memory Bound 提高运算强度 Kernel Fusion(融合多个小操作) Quantization(减少数据传输量) Thread Coarsening(单线程做更多工作)
Compute Bound 改进算法 更好的矩阵乘法算法 更高效的数值计算

建议 :使用 torch.compile 可以自动完成 Kernel Fusion 和编译优化。


6. 最小化控制流分歧 (Minimizing Control Divergence)

6.1 问题

CUDA 按 warp(32 个线程)为单位调度指令。一个 warp 内的所有线程必须同时执行同一条指令。

cuda 复制代码
// 分歧示例
if (data[idx] % 2 == 0) {
    out[idx] = data[idx] * 2;  // 偶数:乘以2
} else {
    out[idx] = data[idx] + 1;  // 奇数:加1
}

问题:

  • 某些线程执行 *2,其他执行 +1
  • 快的线程必须等待慢的线程
  • 效果是乘法的而非加法的性能损失

6.2 重写消除分歧

将条件分支重写为代数运算:

cuda 复制代码
// 重写版本:无条件分支
bool is_even = (data[idx] % 2 == 1) ^ (data[idx] % 2 == 0);
out[idx] = data[idx] * is_even + (data[idx] + 1) * (1 - is_even);

// 更简洁的写法:
// 如果 is_even=1: out = data[idx] * 2
// 如果 is_even=0: out = data[idx] + 1

6.3 性能对比

版本 分歧开销 Duration
原版(有分支) 98,000 branch instructions 0.74 ms
重写(无分支) 65,000 branch instructions 0.24 ms

约 3 倍加速,这是最 impactful 的优化之一。

6.4 注意

  • 循环导致的分歧不严重(只有 warp 边界处可能分歧)
  • 嵌套 if 语句很危险(每层分支指数级增加分歧概率)
  • NVCC 编译器会尝试自动消除某些分歧,但无法完全消除

7. 线程粗化 (Thread Coarsening / Vectorization)

传统观点:每个线程做尽可能少的工作。

新观点:在内存带宽密集型场景下 ,让单个线程做更多工作可以大幅提升性能。

7.2 示例:向量加法

cuda 复制代码
// 细粒度版本
__global__ void vector_add(float* c, float* a, float* b, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        c[i] = a[i] + b[i];
    }
}

// 粗化版本(Coarsening Factor = 2)
__global__ void vector_add_coarsened(float* c, float* a, float* b, int n) {
    int i = (blockIdx.x * blockDim.x + threadIdx.x) * 2;
    if (i + 1 < n) {
        c[i] = a[i] + b[i];
        c[i + 1] = a[i + 1] + b[i + 1];
    } else if (i < n) {
        c[i] = a[i] + b[i];
    }
}

7.3 性能对比

版本 Duration
细粒度 ~23 μs
粗化 (factor=2) ~0.2 μs

约 100 倍加速(演讲现场实测约 30 倍),这是本次课程中性能提升最大的优化。

7.4 原理分析

粗化后:

  • DRAM Throughput: 从 ~80% 降到 ~1%(数据可能完全 fit 在 L2/L1 cache)
  • 单线程读取更多连续数据,提高内存访问效率

tips:实测结果受数据大小和缓存状态影响,有用户测试了 factor=4 和 factor=8,未见显著额外提升。


8. 私有化 (Privatization)

将部分更新的数据存储在私有副本(寄存器或 shared memory)中,最后再写回全局内存,避免频繁访问 global memory。

示例

cuda 复制代码
// 非私有化(每次操作都访问 global memory)
for (int i = 0; i < n; i++) {
    sum += input[i];
    output[i] = sum;  // 频繁写回
}

// 私有化(在 shared memory 中累积,最后写回)
__shared__ float private_sum[SUBTILE_SIZE];
// ... 累积操作 ...
// 最后一次性写回 output

应用:Sliding Window

Sliding Window Attention 是私有化的典型应用:

  • 不计算完整的 N×N 注意力矩阵
  • 只计算局部窗口(如 window_size=8)内的注意力
  • 大幅减少内存访问

这是 Mistral 和 Mixtral 等模型使用的重要技巧。


9. 数学重写:在线 Softmax 与 Flash Attention

9.1 标准 Softmax 的问题

python 复制代码
# 标准 Softmax 算法
# 第一遍:计算分母(所有 exp 的和)
total = sum(exp(x[i]) for i in range(n))

# 第二遍:计算输出
y[i] = exp(x[i]) / total

问题:

  1. 需要读取数据两次(一遍算 total,一遍算 y)
  2. 数值溢出风险:exp(1000) 会溢出
  3. 在低精度(FP16)下尤其严重

9.2 安全版 Softmax(防溢出)

python 复制代码
m = max(x)  # 减去最大值防止溢出
y[i] = exp(x[i] - m) / sum(exp(x[j] - m))

问题:现在需要读取三次(max、total、exp)

9.3 在线 Softmax(Online Normalizer)

核心思想:维持一个"假的"归一化因子,随着新数据的到来渐进修正

python 复制代码
# 论文:Online Normalizer Calculation for Softmax
# https://arxiv.org/abs/2002.09018

# 变量定义
M_prev = previous max
M_curr = current max
L_prev = previous sum of exp
L_curr = current sum of exp

# 关键公式
L_curr = (L_prev * exp(M_prev - M_curr)) + exp(x_curr - M_curr)

核心洞察

  • 如果 max 没变:新数据直接加到 sum 上
  • 如果 max 变了:用 exp(M_old - M_new) 来 rescale 旧的 sum

9.4 效果

版本 内存访问次数
标准 Softmax 2 reads + 1 write
安全版 Softmax 3 reads + 1 write
在线 Softmax 2 reads + 1 write(无溢出风险)

9.5 与 Flash Attention 的关系

Flash Attention 使用了在线 Softmax 的思想:

  1. 将 Q、K、V 分 tile 加载到 shared memory
  2. 使用在线归一化器逐步计算 softmax
  3. 无需完整保存 N×N 注意力矩阵(节省 O(N²) 显存)

10. Tiling

Jeremy 在 Lecture 2 中已详细讲解,本文仅做简要回顾

在矩阵乘法中,某些元素会被多次复用

  • 矩阵 A 的元素:在计算 C 的一整行时会被用到 K 次
  • 矩阵 B 的元素:在计算 C 的一整列时会被用到 M 次

优化策略 :将热点数据加载到 shared memory,减少 global memory 访问

与私有化的关系

Tiling 是私有化的一种特殊形式,因为其重要性而独立讨论。

实现要点

Tiling 算法本质是 4 层嵌套循环(外层两个矩阵维度 × 内层两个 tile 维度),实现并不复杂。


11. 综合

优化技巧 受益类型 说明
Coalescing Memory Access Memory Bound 最基础、最常见
Maximizing Occupancy 两者皆可 使用 occupancy calculator
Thread Coarsening Memory Bound 本课程最大加速来源
Minimizing Divergence 两者皆可 消除分支,分歧是乘法效应
Tiling / Data Reuse Memory Bound 减少全局内存带宽
Privatization Memory Bound 减少 global memory 访问
Math Rewrite 两者皆可 利用数值恒等式
Quantization Memory Bound 降低内存带宽需求

12. 工具与资源

12.1 Profiling 工具

工具 用途
NCU (NVIDIA Nsight Compute) Kernel-level profiling
NVTX 代码中添加性能注释标记
PyTorch Profiler 上层应用分析

12.2 论文

  1. Citadel GPU Benchmark Paper - 理解 GPU 微架构
  2. Demystifying Nvidia Ampere Architecture - 微基准测试方法
  3. Online Normalizer Calculation for Softmax - 数学重写示例
  4. Flash Attention Paper - 在线归一化在注意力机制中的应用
  5. Programming Massively Parallel Processors (Ch. 6+) - 所有优化技巧的来源

12.3 讲座

  • Bill Dally (NVIDIA Chief Scientist) 的所有 YouTube 讲座 - 理解 GPU 硬件设计哲学

13. 总结

CUDA 优化的三个核心问题:

  1. 瓶颈在哪里? 使用 NCU 分析,确定是 Compute Bound 还是 Memory Bound
  2. 为什么在这里? 使用 Roofline Model 理解运算强度
  3. 我能做什么? 对照优化清单选择合适的技巧

优秀的工程师兼具数学直觉系统能力,这也是 CUDA Mode 社区推崇的方向。

相关推荐
We་ct18 小时前
React 性能优化精讲
前端·javascript·react.js·性能优化·前端框架·html·浏览器
动恰客流管家1 天前
动恰3DV3丨2026年实体商业数字化转型:客流数据是第一生产力——全场景智慧客流解决方案
大数据·人工智能·3d·性能优化
计算机安禾1 天前
【Linux从入门到精通】第43篇:I/O调度算法与磁盘性能优化
linux·算法·性能优化
AI木马人1 天前
10.人工智能实战:大模型系统如何做全链路性能优化?从请求进入到 GPU 推理的端到端瓶颈分析与落地方案
人工智能·性能优化
武藤一雄1 天前
WPF进阶:万字详解WPF如何性能优化
windows·性能优化·c#·.net·wpf·.netcore·鲁棒性
yqcoder1 天前
前端性能优化:如何减少重绘与重排?
前端·性能优化
wltx16881 天前
外贸独立站+GEO优化需要多久维护一次?
性能优化
前端百草阁2 天前
【前端性能优化全链路指南】从开发编写到构建运行的多维度实践
前端·性能优化
Wect2 天前
React 性能优化精讲
前端·react.js·性能优化