07 > 存储层次

GPU 并不是只有一种内存,而是有一整架 “梯子”。靠近核心的几级很小、很快,离得远的那一级很大、很慢。这一课在 L40S 上沿着这架梯子往下走,看每一级谁能访问、有多大、有多快,并算一算哪里能放下多少数据。

一架梯子,三个问题

第 00 课介绍过寄存器、共享内存和 L2 缓存,第 06 课讲了显存带宽为什么会限制 GPU。这一课把它们按顺序排好,这个顺序就叫存储层次。

对每一级,都问三个问题。

  • 它在哪里?在 SM 内部,在 GPU 芯片的其他位置,还是在旁边单独的显存芯片上。
  • 谁能访问它?一个线程,同一个线程块里的所有线程,还是整个 GPU 上的所有线程。
  • 它有多大、多快?小的层级快,大的层级慢,没有哪一级又大又快。

“快” 有两种含义。带宽是一级存储每秒能送出多少字节。延迟是一次加载要等多久数据才到,用时钟周期来计。

寄存器

寄存器就在每个 SM 内部,紧挨着核心。每个线程都有自己的寄存器,用来放局部变量,别的线程读不到。用寄存器不需要额外等待,因为核心在执行指令时直接读取它。

L40S 一个 SM 的寄存器文件有 65,536 个 32 位寄存器,也就是 256 KB。一个线程最多能用 255 个。H100 每个 SM 也是 256 KB。

[!NOTE] 把整块芯片加起来,寄存器其实不少。L40S 有 142 个 SM × 256 KB = 36,352 KB 寄存器,约 35.5 MB,比它的 142 × 100 KB = 14,200 KB 共享内存还多。

共享内存和 L1

每个 SM 还有一块高速的片上存储,分给两种用途。

  • 共享内存,由核函数自己管理。同一个线程块里的所有线程都能读写它,所以它们用它来共享数据。其他线程块的线程看不到它。
  • L1 缓存,由硬件管理。它把最近从全局内存加载的数据留在 SM 附近,同样的数据再读一次就很快。

L40S 是 CC 8.9 的 GPU,在它上面,这块存储每个 SM 有 128 KB。核函数可以选择怎么分,这叫 carveout。共享内存取 0、8、16、32、64 或 100 KB,剩下的给 L1。CUDA 会给每个线程块预留 1 KB,所以一个线程块最多能用 99 KB。H100 上这块存储每个 SM 有 256 KB,其中最多 228 KB 可作共享内存。

已发表的微基准测试在 RTX 4090 上做了测量,它和 L40S 用的是同一种 AD102 芯片。结果是共享内存加载大约 30 个周期,L1 命中大约 43 个周期。

L2 缓存

L2 缓存在 GPU 芯片上,但在 SM 之外。所有 SM 共用它,所以它服务整个 GPU。对全局内存的每一次读写都要经过它。

L40S 有 96 MB 的 L2,H100 有 50 MB。同一组微基准测试在 RTX 4090 上测得 L2 命中大约 273 个周期,约为共享内存加载的 9 倍。

全局内存

全局内存是 GPU 的主内存,也就是 L40S 上 48 GB 的 VRAM。它在单独的显存芯片上,所有线程块的所有线程都能访问它,CPU 也会把数据拷进拷出。全局内存里的数据在多次核函数启动之间一直保留。

L40S 用的是 GDDR6,带宽 864 GB/s。H100 SXM 有 80 GB 的 HBM3,带宽 3.35 TB/s。全局内存是最慢的一级,在 RTX 4090 上每次加载大约 541 个周期,是 L2 命中的 2 倍,约为共享内存加载的 18 倍。

常量内存和纹理内存

全局内存还有两种特殊的访问方式,各自带一个小缓存。

常量内存是 64 KB 的只读数据,每个 SM 缓存其中 8 KB。当一个线程束的所有线程读同一个地址时,读一次就广播给全部 32 个线程。如果它们读不同的地址,这些读取就只能一个接一个地完成。

纹理内存是一条经过 L1 缓存的只读通路,为图形而设计,因为图形里相邻的线程往往读相邻的像素。在 Ada 和 Hopper 上,纹理缓存和 L1 是同一个单元,所以大多数 CUDA 代码直接读全局内存,让 L1 去缓存就行。

本地内存和寄存器溢出

本地内存和寄存器一样只属于一个线程,但它其实在全局内存里。它会经过 L1 和 L2 缓存,可一旦未命中,代价就和任何一次全局内存加载一样。每个线程最多能用 512 KB。

寄存器不够用时,编译器就把数据放到本地内存,这叫寄存器溢出。如果线程用运行时才知道的值去索引一个数组,也会这样,因为寄存器不能这样索引。

[!WARNING] “本地” 的意思是私有,不是离得近。本地内存在芯片外,和全局内存一样慢。编译时加上 -Xptxas -v,编译器会报告每个核函数用了多少寄存器,以及溢出写入和溢出读取各多少字节。

一览表

层级 位置 谁能访问 L40S H100 SXM 加载等待(周期)
寄存器 每个 SM 内部 一个线程 每个 SM 256 KB 每个 SM 256 KB 无
共享内存 每个 SM 内部 一个线程块 每个 SM 最多 100 KB 每个 SM 最多 228 KB 约 30
L1 缓存 每个 SM 内部 一个 SM 128 KB 中剩下的部分 256 KB 中剩下的部分 约 43
L2 缓存 芯片上 整个 GPU 96 MB 50 MB 约 273
全局内存 显存芯片 整个 GPU 48 GB GDDR6 80 GB HBM3 约 541

这些周期数是在 RTX 4090 上测得的。H800 是和 H100 一样的 Hopper GPU,在它上面测得的结果几乎一样,分别是 29、41、263 和 479 个周期。

算一算哪里能放下多少

一个 float 占 4 个字节。在 CUDA 的表格里,1 KB 是 1,024 字节,1 MB 是 1,024 KB。

L40S 的一个 SM 最多有 100 KB 共享内存。

  • 100 × 1,024 = 102,400 字节。
  • 102,400 / 4 = 25,600 个 float。
  • 一个 32 × 32 的 float 分块占 32 × 32 × 4 = 4,096 字节 = 4 KB,所以一个 SM 能放 100 / 4 = 25 个这样的分块。
  • 一个线程块最多用 99 KB,也就是 99 × 1,024 / 4 = 25,344 个 float。

H100 每个 SM 有 228 KB,能放 228 × 1,024 / 4 = 58,368 个 float,是 L40S 的两倍多。

L40S 的 L2 缓存有 96 MB。

  • 96 × 1,024 × 1,024 = 100,663,296 字节。
  • 100,663,296 / 4 = 25,165,824 个 float,约 2,520 万个。
  • 一个 5,000 × 5,000 的 float 矩阵是 25,000,000 个 float,刚好放得下。6,000 × 6,000 的矩阵(36,000,000 个 float)就放不下了。

[!TIP] 一个 SM 上的所有线程共用寄存器。要让一个 L40S SM 跑满 1,536 个线程,每个线程能分到 65,536 / 1,536 ≈ 42.7 个寄存器。硬件按每个线程束 256 个寄存器为一块来分配,所以实际上限是每个线程 40 个,因为 40 × 32 = 1,280 = 5 块,48 个线程束 × 1,280 = 61,440,不超过 65,536。

这对 CUDA 意味着什么

写核函数时,你要为每一份数据选好它放在哪一级。

  • 普通的局部变量放在寄存器里。数量要少,否则会溢出到本地内存,跑得和全局内存一样慢。
  • 线程块里的线程要反复读取的数据放进共享内存,用 __shared__ 声明。从全局内存只加载一次,之后每次复用只要约 30 个周期,而不是约 541 个。
  • 大数组放在全局内存里。读取时让一个线程束的 32 个线程访问相邻的地址,这样 L1 和 L2 就能用几次宽的传输把它们都送到。

一次全局内存加载要几百个周期。GPU 靠切换到其他线程束来掩盖这段等待,这是 第 08 课 的主题。第 09 课 接着讲怎么判断一个核函数是受计算限制还是受内存限制。

术语表

  • GPU(Graphics Processing Unit,图形处理器):这些课讲的处理器,由许多并行运行线程的 SM 组成。
  • CPU(Central Processing Unit,中央处理器):计算机的主处理器,负责把数据拷进、拷出全局内存。
  • 存储层次(memory hierarchy):GPU 各级存储组成的梯子,从又小又快的寄存器到又大又慢的全局内存。
  • SM(Streaming Multiprocessor,流式多处理器):GPU 里的处理单元,有自己的核心、寄存器、共享内存和 L1 缓存。L40S 有 142 个。
  • 线程(thread):一条指令流。每个线程有自己的寄存器和本地内存。
  • 线程块(block):在一个 SM 上运行的一组线程,可以通过共享内存共享数据。
  • 线程束(warp):SM 一起运行的 32 个线程。
  • 核函数(kernel):在 GPU 上运行的函数,由许多线程一起执行。
  • 带宽(bandwidth):一级存储每秒能送出的字节数。
  • 延迟(latency):一次加载要等多久数据才到。
  • 时钟周期(clock cycle):GPU 时钟的一次跳动。本页的延迟都按周期计。
  • 寄存器(register):最快的存储,只属于一个线程。L40S 的一个 SM 有 65,536 个 32 位寄存器。
  • 寄存器文件(register file):一个 SM 的全部寄存器,L40S 和 H100 上都是 256 KB。
  • KB(kilobyte,千字节)/ MB(megabyte,兆字节):在 CUDA 的表格里分别是 1,024 字节和 1,024 KB。
  • 片上(on-chip):直接做在 GPU 芯片里,比如寄存器、共享内存、L1 和 L2。
  • 共享内存(shared memory):每个 SM 里的高速片上存储,同一个线程块的所有线程都能读写。
  • L1 缓存(Level 1 cache,一级缓存):每个 SM 片上存储中由硬件管理的那一部分,把最近用过的数据留在近处。
  • carveout(划分比例):片上存储在共享内存和 L1 之间怎么分,按核函数来选。
  • CC(compute capability,计算能力):GPU 功能的版本号。L40S 是 CC 8.9。
  • CUDA(Compute Unified Device Architecture,统一计算设备架构):NVIDIA 用来编写在其 GPU 上运行的程序的平台。
  • 微基准测试(microbenchmark):只测一件事的小测试程序,比如某一级存储的延迟。
  • L2 缓存(Level 2 cache,二级缓存):所有 SM 共用的片上缓存,L40S 上 96 MB,H100 上 50 MB。
  • 全局内存(global memory):GPU 的主内存,所有线程都能访问,CPU 也往里拷数据,是最慢的一级。
  • VRAM(显存):GPU 卡上存放全局内存的显存芯片。
  • GDDR6(Graphics Double Data Rate 6):L40S 用的显存类型,48 GB,864 GB/s。
  • HBM3(High Bandwidth Memory 3,第三代高带宽内存):和 GPU 芯片封装在一起的堆叠式显存。H100 SXM 有 80 GB,3.35 TB/s。
  • GB/s(gigabytes per second,吉字节每秒)/ TB/s(terabytes per second,太字节每秒):带宽的单位。
  • 常量内存(constant memory):64 KB 只读数据,每个 SM 有 8 KB 缓存,整个线程束读同一个地址时很快。
  • 广播(broadcast):读一次,结果同时送给线程束的全部 32 个线程。
  • 纹理内存(texture memory):一条经过 L1 缓存的只读通路,为图形而设计。
  • 本地内存(local memory):每个线程私有、但放在全局内存里的存储,每个线程最多 512 KB。
  • 寄存器溢出(register spill):寄存器不够用时,把数据从寄存器挪到本地内存。
  • float:32 位浮点数,占 4 个字节。
  • 分块(tile):大数组中的一小块方形区域,加载到共享内存里反复使用。