一、显存基础与瓶颈分析

做GPU优化这么多年,我最大的感触就是——显存管理是性能的命门。很多同学一上来就盯着计算量,觉得算得越快越好。但实际项目中,我见过太多计算效率很高、却被显存带宽卡死的案例。说白了,GPU是个"搬运工",数据搬得快,算得才有意义。

1.1 GPU显存层次结构

先聊聊显存的"家族成员"。GPU里的存储系统,其实是个金字塔结构。我习惯把它分成四层:

  • Register(寄存器):离计算单元最近,延迟几乎为0。但数量极其有限,每个线程通常只有几十到几百个。我在优化一个矩阵乘法时,就因为寄存器溢出,性能直接腰斩。
  • Shared Memory(共享内存):同一个Block内的线程共享,延迟约20-30个周期。容量小,通常几十KB。但用好它,性能能翻倍。
  • Local Memory(局部内存):寄存器不够用时,数据会被"挤"到这里。其实它物理上在Global Memory里,延迟很高。嗯,这是个坑,后面会细说。
  • Global Memory(全局内存):容量最大(几GB到几十GB),但延迟最高(400-800个周期)。带宽虽然大,但用不好就是瓶颈。

核心观点:优化的本质,就是让数据尽量往金字塔上层走。能放寄存器的,别放共享内存;能放共享内存的,别放全局内存。

我画了一张图,帮你直观理解这个层次结构:

Register 延迟: ~1 cycle 容量: 几十~几百B/线程 Shared Memory 延迟: ~20-30 cycles 容量: 几十KB/Block Local Memory 延迟: 400-800 cycles (物理在Global) Global Memory 延迟: 400-800 cycles | 容量: GB级别 速度递减 → 容量递增

1.2 显存带宽与延迟的量化分析

你想想看,为什么有时候计算单元在"空转"?因为数据没到。这就是带宽和延迟的问题。

带宽:单位时间内能搬运多少数据。比如NVIDIA A100,带宽约1.6TB/s。听起来很大对吧?但一个简单的向量加法,如果数据在Global Memory里,带宽利用率可能只有20%。

延迟:从发出请求到拿到数据的时间。Global Memory的延迟是400-800个周期。这段时间里,SM(流多处理器)能执行上千条指令。所以,隐藏延迟是优化的关键。

存储层次 典型延迟 典型带宽 容量 作用域
Register ~1 cycle 极高 255个/线程 (Max) 单个线程
Shared Memory ~20-30 cycles ~1-2 TB/s 48-164 KB/Block Block内线程
Local Memory 400-800 cycles 同Global 受限于显存 单个线程
Global Memory 400-800 cycles ~0.9-2 TB/s GB级别 所有线程

我的经验:判断一个kernel是否带宽受限,有个简单方法——算一下"计算量/访存量"(即算术强度)。如果这个值小于某个阈值(比如10 FLOPs/Byte),那基本就是带宽瓶颈。我在优化BERT推理时,就发现很多层算术强度只有2-3,果断转向访存优化。

1.3 典型显存瓶颈的Profiling方法

光靠猜可不行。我习惯用工具说话。这里分享几个我常用的方法:

方法一:ncu(NVIDIA Nsight Compute)

这是我最常用的profiler。它能告诉你:

  • Memory Throughput:实际带宽利用率
  • L1/TEX Cache Throughput:缓存命中情况
  • Shared Memory Bank Conflicts:共享内存冲突
  • Register Spill:寄存器溢出情况

举个例子,跑一个简单的kernel:

// 一个典型的带宽受限kernel
__global__ void vector_add(float *a, float *b, float *c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

用ncu跑一下:

ncu --set full -o vector_add ./vector_add

看报告里的Memory Throughput。如果远低于理论带宽(比如只有30%),那说明有优化空间。我曾经在一个项目中,通过合并内存访问,把带宽利用率从25%提升到了85%。

方法二:检查Register Spill

这个坑我踩过。当你给每个线程分配太多局部变量时,寄存器不够用,数据会被"挤"到Local Memory。你想想看,本来1个周期能拿到的数据,现在要等400个周期,性能不崩才怪。

怎么查?用--ptxas-options=-v编译选项:

nvcc -o vector_add vector_add.cu --ptxas-options=-v

输出里会显示regcount和lmem使用量。如果lmem大于0,就要警惕了。

我曾经踩过的坑:有一次优化一个图像处理kernel,为了减少Global Memory访问,我把所有中间结果都存成局部变量。结果寄存器溢出严重,性能反而下降了30%。后来我改用Shared Memory做缓存,才把性能提上来。记住:不是所有局部变量都适合放寄存器。

方法三:分析内存访问模式

Global Memory的带宽利用率,很大程度上取决于合并访问(Coalesced Access)。简单说,就是让同一个warp里的线程,访问连续的内存地址。

用ncu的Memory Workload Analysis页面,可以看:

  • Global Load/Store Efficiency:理想值是100%
  • Sector Utilization:越高越好

如果效率低,通常是因为:

  • 线程访问地址不连续(比如按列访问矩阵)
  • 数据类型对齐有问题
  • 存在Bank Conflict(共享内存)

一句话总结:显存优化的核心就三件事——减少访存量、提高带宽利用率、隐藏访存延迟。后面的章节,我会逐一展开这些技巧。

好了,这一章先到这里。记住,没有profile就没有优化。拿到一个新kernel,第一件事就是跑ncu,看看瓶颈在哪。别像我早期那样,闷头优化半天,结果发现瓶颈根本不在我以为的地方。

可可精品资料库