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 社区推崇的方向。

相关推荐
丁小未10 小时前
基于MVVM框架的XUUI HelloWorld 新手教程
unity·性能优化·c#·游戏引擎
飞天狗10 小时前
长列表滚动卡顿怎么破:虚拟列表选型 + react-virtuoso 实战调优指南
react.js·性能优化
zhangjw3413 小时前
第29篇:Java伪共享与对象分配:并发性能优化的关键
java·开发语言·性能优化
SelectDB1 天前
美团数十 PB 规模 Apache Doris 实践:从统一 OLAP 到 AI-Native 数据基座
大数据·数据库·性能优化
Patrick_Wilson1 天前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化
侑虎科技1 天前
解决URP角色渲染短板,自研IllusionRP管线落地移动端
性能优化
紫霖ty2 天前
Session处理性能过慢的问题排查与优化
性能优化
大家的林语冰2 天前
Rust 再次打败 Go,Astro 7 首发,什么叫“万物皆可锈化“!
前端·javascript·性能优化
UWA2 天前
资源回收“两头堵”:RUUA调了会卡,不调内存又涨
性能优化·memory·游戏开发
本旺2 天前
Elasticsearch 前缀匹配性能优化实战,附4种方式即执行计划判别
大数据·elasticsearch·性能优化