CUDA 内存模型 Q&A
CUDA 内存模型 Q&A
Q1:CUDA 中有哪几种内存类型?各自的特点、带宽、延迟是什么?
GPU 的存储层次和 CPU 类似,都是"越快越小、越慢越大",但 GPU 拥有更丰富的片上可编程内存,这是高性能 CUDA 编程的核心所在。
CUDA 设备上的内存类型汇总如下:
| 内存类型 | 物理位置 | 访问范围 | 生命周期 | 速度 | 大小 |
|---|---|---|---|---|---|
| 寄存器(Register) | SM 片上 | 单线程私有 | Kernel 执行期间 | 最快 | 极小(每线程约 255 个 32 位寄存器) |
| 共享内存(Shared Memory) | SM 片上 | 线程块内所有线程 | Kernel 执行期间 | 极快(低延迟) | 每 SM 最大约 100~228 KB(因架构而异) |
| L1 Cache | SM 片上(与共享内存共用物理空间) | 自动缓存全局内存访问 | 自动管理 | 极快 | 与共享内存共享配额 |
| L2 Cache | 设备片上,全局共享 | 所有 SM 共享 | 自动管理 | 较快 | 数 MB 至数十 MB |
| 全局内存(Global Memory) | 设备 DRAM(显存) | Grid 内所有线程 | 应用程序生命周期 | 高带宽但高延迟 | 数 GB(即整块显存) |
| 常量内存(Constant Memory) | 设备 DRAM + 专用缓存 | Grid 内所有线程(只读) | 应用程序生命周期 | 广播命中时快,否则慢 | 固定 64 KB |
| 本地内存(Local Memory) | 设备 DRAM(逻辑私有) | 单线程私有 | Kernel 执行期间 | 与全局内存相同(慢) | 每线程最大 512 KB |
| 纹理内存(Texture Memory) | 设备 DRAM + 专用缓存 | Grid 内所有线程(只读) | 应用程序生命周期 | 空间局部性好时有缓存加速 | — |
性能感知: 寄存器和共享内存是 GPU 最快的存储,访问延迟只有几个时钟周期;全局内存延迟高达数百个周期,但带宽极高(如 H100 的 HBM3 带宽超过 3 TB/s),合理利用这一带宽是高性能 kernel 设计的关键。
Q2:各内存类型的用途详解
全局内存(Global Memory)
全局内存是 GPU 的主存储区域,相当于 CPU 系统中的 RAM。所有 kernel 线程都可以读写,容量等同于整块显存(数 GB)。
- 持久性强:
cudaMalloc分配的全局内存在调用cudaFree或程序结束前一直存在。 - 是 CPU 和 GPU 之间数据传输的中转区。
- 所有线程可访问,但访问延迟高,必须通过合并访问(Coalescing)优化。
- Kernel 的计算结果唯一的返回途径:kernel 没有返回值,计算结果只能写入全局内存,再由 CPU 读取。
1 | // 在全局内存上分配和使用 |
共享内存(Shared Memory)
共享内存是 SM 上的片上可编程缓存,速度远高于全局内存,是同一线程块内线程协作的核心机制。
- 物理上与 L1 缓存共享同一块硅片资源(统一数据缓存),两者的配比可以通过 API 调节。
- 作用域仅限于单个线程块,线程块结束后数据消失。
- 用途:线程间数据共享、缓存频繁访问的全局内存数据、用作用户管理的 scratchpad(便签本)。
- 访问需要用
__syncthreads()防止数据竞争。
1 | __global__ void kernel(float* input, float* output) { |
动态分配方式:
1 | // 启动时指定字节数 |
寄存器(Register)
寄存器是最快的存储,每个线程独占,由编译器自动分配管理。
- 存储 kernel 中的局部变量、循环计数器、临时计算中间值。
- 无需程序员显式声明,编译器自动决定哪些变量放入寄存器。
- 每个 SM 的寄存器总数固定(通常 64K 个 32 位寄存器),所有线程共享,用量直接影响 Occupancy。
- 超出可用寄存器数量时会发生"寄存器溢出"(Register Spilling),详见 Q5。
常量内存(Constant Memory)
常量内存是只读的全局内存区域,配有专用的 SM 级常量缓存。
- 容量固定为 64 KB,在 host 代码中初始化,kernel 中只能读取。
- 当 Warp 内所有线程访问同一地址时,硬件以**广播(Broadcast)**机制一次性分发,效率极高。
- 适合存储所有线程都会用到的只读参数、系数、查找表等。
- 若不同线程访问不同地址(非广播),则串行化,性能退化为与全局内存相当。
1 | // host 端声明并初始化 |
纹理内存(Texture Memory)
纹理内存在老旧 GPU 上因其空间局部性缓存机制而有性能优势。但在所有当前支持的 NVIDIA GPU 上,直接的 load/store 指令已经能达到同等效果,纹理内存不再具有性能优势。
如果你是新项目开发者,可以直接忽略纹理内存 API。如果维护老代码时遇到,只需知道它提供二维空间局部性缓存即可。
本地内存(Local Memory)
本地内存名字中有"local",但物理上位于全局 DRAM,访问延迟与全局内存完全相同。"本地"只是指其逻辑作用域是线程私有的。
它是寄存器的溢出区,详见 Q5。
分布式共享内存(Distributed Shared Memory,CC ≥ 9.0)
从 Hopper 架构(CC 9.0)起,同一 Thread Block Cluster 内的所有线程块可以互相访问对方的共享内存,这个跨块可访问的共享内存空间称为分布式共享内存。其总容量等于 Cluster 内各线程块共享内存之和,为需要比单个 SM 更大协作范围的算法(如大规模直方图)提供了新的优化手段。
Q3:什么是内存合并访问(Memory Coalescing)?为什么重要?如何实现?
为什么重要
全局内存的访问以 32 字节为最小事务单位进行。当一个 Warp(32 个线程)同时请求全局内存时,硬件会将这 32 个线程的请求合并(coalesce)成尽量少的内存事务来完成。合并访问与非合并访问的性能差距可以高达 8 倍。
合并 vs 非合并对比
理想情况(完全合并): 32 个连续线程访问连续的 4 字节数据,共需 128 字节,通过 4 次 32 字节事务完成,内存带宽利用率 100%。

最坏情况(完全不合并): 32 个线程访问的地址彼此间隔 32 字节以上,每个线程触发一次独立的 32 字节事务,共需传输 1024 字节,但实际只用到 128 字节,带宽利用率仅 12.5%。

如何实现合并访问
核心原则:让连续线程 ID 的线程访问连续的内存地址。
1 | // 合并访问:thread i 访问 data[i] |
矩阵转置的典型案例: 朴素的转置 kernel 读操作合并但写操作不合并(列方向写入)。解决方案是用共享内存做"中转":先按行(合并)读入共享内存,经 __syncthreads() 后,再按行(合并)写出到转置后的位置:
1 | __global__ void smem_transpose(int m, float* a, float* c) { |
合并访问的本质衡量标准: 最大化"实际使用的字节数 / 实际传输的字节数"之比。只要 Warp 内所有线程访问的地址落在尽量少的 32 字节段内,就能得到高利用率。
Q4:什么是 Shared Memory 的 Bank 冲突?如何避免?
共享内存的 Bank 结构
共享内存被划分为 32 个 Bank,连续的 32 位字依次映射到 Bank 0、Bank 1、…、Bank 31,然后循环。每个 Bank 每个时钟周期可以独立服务一次 32 位访问。
什么是 Bank 冲突
当同一个 Warp 内的多个线程在同一个时钟周期访问同一个 Bank 中的不同地址时,就发生了 Bank 冲突(Bank Conflict)。发生冲突时,这些访问必须被串行化执行,几路冲突就需要几倍的时间。
不会发生冲突的两种情况:
- 多个线程访问不同的 Bank(无论地址是否相邻)
- 多个线程访问同一 Bank 的同一地址——硬件以广播方式一次性分发
图解三种访问模式
- 步长为 1(左图):连续线程访问连续地址,每个线程命中不同 Bank,无冲突。
- 步长为 2(中图):每隔一个 Bank 访问,32 个线程中有 16 对线程命中相同 Bank,发生 2 路冲突,性能减半。
- 步长为 3(右图):3 与 32 互质,所有线程仍然命中不同 Bank,无冲突。
- 左图:随机排列但每个线程命中不同 Bank,无冲突。
- 中图:多个线程访问 Bank 5 的同一地址,触发广播,无冲突。
- 右图:所有线程访问同一地址,广播,无冲突。
矩阵转置中的 Bank 冲突与规避
在上面 Q3 的共享内存转置代码中,声明 __shared__ float tile[32][32],写操作 tile[threadIdx.x][threadIdx.y] 中,同一 Warp 内连续线程对应相同的 threadIdx.x,不同的 threadIdx.y,访问同一列——这 32 个元素在内存中每隔 32 个字,全部落在同一个 Bank,造成 32 路 Bank 冲突。
经典解决方法:在共享内存数组中加一列 padding(填充):
1 | __shared__ float tile[32][33]; // 加 1 列 padding,打破 Bank 对齐 |
加了一列 padding 后,同一列的元素不再对齐到相同 Bank,Bank 冲突消除,吞吐量大幅提升。
实践建议
- 声明共享内存数组时,如果第一维大小是 2 的幂次(16、32、64 等),优先考虑在行末加 1 个 padding 元素。
- 用 CUDA profiler(Nsight Compute)可以直接测量 shared memory bank conflict 次数,是优化的有力工具。
Q5:寄存器溢出(Register Spilling)是什么?有何影响?
什么是寄存器溢出
每个 SM 的寄存器文件大小有限(通常 64K 个 32 位寄存器),由当前驻留的所有线程共享。如果一个 kernel 中单个线程需要的寄存器数量超过了分配给它的上限,编译器会把多余的变量"溢出"到**本地内存(Local Memory)**中存放,这就是寄存器溢出(Register Spilling)。
本地内存在逻辑上是线程私有的,但物理上位于设备 DRAM(全局内存区域)。因此,一旦发生溢出,原本只需寄存器访问(接近零延迟)的操作,变成了访问全局内存(数百周期延迟),性能严重下降。
哪些情况会导致溢出
- kernel 中声明了大量局部变量,超出寄存器预算
- 使用了不能在编译期确定下标的局部数组(编译器无法将其放入寄存器)
- 大型结构体或数组占用了过多寄存器空间
- 使用
--maxrregcount强制限制寄存器数量后,超出部分自动溢出
溢出的影响
- 直接影响: 访问溢出变量的延迟从接近 0 变为数百个时钟周期(等同于全局内存访问),严重拖慢 kernel 性能。
- 间接影响: 由于溢出到本地内存,额外占用显存带宽,可能影响其他访问的带宽。
- 正面效果(有时): 限制寄存器用量可以让更多线程块同时驻留在 SM 上,提高 Occupancy。若 kernel 本身是延迟密集型(而非计算密集型),更高的 Occupancy 可能带来净性能提升,即使有溢出开销也值得。
如何诊断和优化
1 | # 编译时查看寄存器用量 |
编译器输出示例:
1 | ptxas info: Used 48 registers, 0 bytes smem, 0 bytes lmem |
优化方法:
- 拆分大 kernel,减少单个 kernel 内的变量数量
- 减少不必要的局部数组,改用全局内存或共享内存
- 使用
__launch_bounds__(maxThreadsPerBlock, minBlocksPerSM)给编译器提供提示,让其优化寄存器分配 - 慎用
--maxrregcount,确保限制后的性能确实提升(通过 profiler 验证)
本地内存的访问模式有一个好消息:连续线程 ID 对本地内存中相同偏移量的访问是天然合并的,因此溢出的性能损失比随机全局内存访问要小,但仍远不如寄存器。
Q6:L1/L2 Cache 在 GPU 中如何工作?
整体结构
GPU 采用两级缓存结构:
- L1 Cache(每 SM 独立):物理上与共享内存共用同一块片上资源(称为统一数据缓存,Unified Data Cache)。两者的配比可以由程序员配置。
- L2 Cache(全局共享):位于设备上,所有 SM 共享,是 L1 和全局 DRAM 之间的缓冲层。容量通常为数 MB 至数十 MB,可通过
cudaDeviceProp.l2CacheSize查询。
每个 SM 还有独立的常量缓存(Constant Cache),专用于缓存常量内存中的只读数据。
L1/共享内存比例配置
由于 L1 和共享内存物理共用,可以按需调整两者的比例:
1 | // 推荐方式(hint,驱动可忽略) |
- 如果 kernel 不使用共享内存,整个统一数据缓存都作为 L1 使用,自动获得最大 L1。
- 如果 kernel 大量使用共享内存,L1 配额相应减少。
最佳实践: 优先使用 cudaFuncSetAttribute 设置 carveout 百分比,而不是 cudaFuncSetCacheConfig,前者是 hint,驱动可灵活处理,不会导致 kernel 串行;后者是硬性要求,相邻不同配置的 kernel 交替时会触发重配置开销。
L2 缓存的持久化控制(CC ≥ 8.0)
从 Ampere 架构(CC 8.0)起,可以将 L2 缓存的一部分划出来,专门用于持久化(persisting)访问——即某段数据在 L2 中享有高优先级驻留权,不会轻易被其他访问驱逐:
1 | // 划出 75% 的 L2 用于持久化访问 |
对于反复访问同一数据集(如大型权重矩阵、查找表)的 kernel,持久化 L2 可以显著降低全局内存带宽压力,提升性能。
GPU Cache 与 CPU Cache 的关键区别
GPU L1 缓存对于全局内存写操作**默认不是写回(write-back)**的——全局内存写通常直接穿透到 L2 或 DRAM。而 CPU 的 L1 通常是写回缓存。这是 GPU 内存一致性模型的一个重要差异,在多 kernel 访问共享数据时需要注意内存一致性。
Q7:什么是 Unified Memory?有何优缺点?
Unified Memory 的定义
统一内存(Unified Memory)是 CUDA 的一项内存管理特性,允许创建一块 CPU 和 GPU 都能直接访问的内存区域,程序员无需手动在两端之间拷贝数据——CUDA 运行时或底层硬件会自动在需要时将数据迁移到正确的位置。
1 | float* data; |
三种统一内存范式
根据系统硬件和操作系统不同,统一内存的能力分三个级别:
1. 受限统一内存(Limited Unified Memory)
主要出现在 Windows、WSL 和部分 Tegra 设备上:
- 托管内存首先分配在 CPU 物理内存
- GPU 开始执行时,整块托管内存迁移到 GPU
- GPU 执行期间 CPU 不能访问托管内存
- GPU 同步后数据迁移回 CPU
- 不支持 GPU 显存超额分配
2. 完全统一内存(Full Unified Memory,软件一致性)
大多数 Linux 系统(PCIe 连接 GPU + HMM 支持):
- 支持 CPU 和 GPU 并发访问
- 按页(page)粒度按需迁移
- 支持 GPU 显存超额分配(oversubscription)
- 可以使用
cudaMemAdvise和cudaMemPrefetchAsync优化迁移行为
3. 完全统一内存(硬件一致性)
Grace Hopper、Grace Blackwell 等 NVLink C2C 连接架构(ATS 支持):
- 硬件级别维护一致性,无需软件页面迁移开销
- GPU 可直接访问所有 CPU 内存,CPU 也可直接访问 GPU 内存
- 性能最优,是真正的"零拷贝"架构
优缺点
优点:
- 代码简洁,无需手写
cudaMemcpy,快速原型开发 - 自动处理数据位置,降低 CPU/GPU 编程复杂度
- 支持显存超额分配(Full 模式),可运行超过 GPU 内存大小的数据集
- 可以通过
cudaMemAdvise/cudaMemPrefetchAsync进一步优化
缺点:
- 按需迁移存在运行时开销(缺页中断、数据迁移),首次访问延迟高
- 性能上通常不如手动显式内存管理(optimal 时两者可以接近)
- Windows/WSL 上限制较多,CPU 不能在 GPU 执行期间访问数据
- 对迁移行为的控制粒度不如显式管理精细
性能建议: 即使使用统一内存,也应尽量使用 cudaMemPrefetchAsync 提前将数据迁移到目标设备,避免 kernel 执行时触发大量缺页中断:
1 | // 将数据预取到 GPU,在 kernel 启动前完成迁移 |
Q8:cudaMalloc、cudaMallocManaged、cudaMallocHost 的区别?
这三个 API 是 CUDA 内存分配的核心,分别在不同的内存空间分配,适用于不同场景:
| API | 分配位置 | 谁能访问 | 用途 |
|---|---|---|---|
cudaMalloc |
GPU 显存(全局内存) | 仅 GPU kernel | 标准 GPU 计算缓冲区 |
cudaMallocManaged |
统一内存(自动迁移) | CPU + GPU 均可 | 快速开发、超额分配 |
cudaMallocHost |
CPU 物理内存(页锁定) | 仅 CPU(可用于 DMA 传输) | 高速 CPU↔GPU 数据传输缓冲区 |
cudaMalloc
在 GPU 显存上分配内存,是最基础的 GPU 内存分配方式。
1 | float* devPtr; |
特点:性能最可预期,完全在显存中,无迁移开销,是生产代码的首选。
cudaMallocManaged
分配统一内存,CPU 和 GPU 均可直接访问,CUDA 运行时自动管理数据迁移。
1 | float* ptr; |
特点:开发最简便,适合算法原型验证;生产环境需配合 prefetch/advise 优化性能。
cudaMallocHost
在 CPU 上分配页锁定内存(Pinned Memory),是 CPU↔GPU 高速数据传输的标准方式。
1 | float* hostPtr; |
特点:是所有需要频繁 CPU↔GPU 数据传输的场景的最佳实践,详见 Q9。
选择建议
1 | 需要频繁 CPU↔GPU 数据传输? |
Q9:Pinned Memory(页锁定内存)是什么?为何能加速数据传输?
什么是 Pinned Memory
普通 CPU 内存(通过 malloc/new/mmap 分配)是可分页内存(Pageable Memory)——操作系统可以随时将其换出到磁盘(swap),也可以在物理内存中重新定位(内存整理)。
Pinned Memory(页锁定内存,也称固定内存)是用 cudaMallocHost 分配的 CPU 内存,操作系统保证永远不会将其换出到磁盘,也不会改变其物理地址。
为什么能加速数据传输
原因一:DMA 直接传输
GPU 通过 DMA(Direct Memory Access)控制器在 PCIe/NVLink 上传输数据,DMA 控制器只能访问物理地址固定的内存。
- 普通可分页内存:在传输之前,CUDA 运行时必须先把数据临时拷贝到一块内部的 Pinned 缓冲区,再由 DMA 传输到 GPU。这是两段传输,有额外开销。
- Pinned 内存:物理地址固定,DMA 可以直接访问,省去了中间拷贝,传输速度更快。
原因二:支持异步传输(cudaMemcpyAsync)
异步内存传输要求 CPU 端内存在传输期间保持有效且地址固定——这是 Pinned Memory 的天然属性。
1 | // 异步传输 + kernel 重叠执行(流水线) |
普通可分页内存无法用于 cudaMemcpyAsync,因为操作系统随时可能移动数据的物理位置,DMA 传输中途就会读到错误数据。
原因三:可以被映射供 GPU 直接访问
Pinned Memory 可以通过 cudaHostAlloc 的 cudaHostAllocMapped 标志被映射到 GPU 的地址空间,kernel 可以直接通过指针读写 CPU 端的 Pinned Memory(数据走 PCIe/NVLink)。虽然带宽低于显存访问,但在数据量小、只需访问一次时可以省去显式传输步骤。
API 汇总
1 | // 分配页锁定内存(最常用) |
注意事项
- 不要过量分配 Pinned Memory。页锁定的内存不能被操作系统换出,会长期占用物理内存,分配过多会导致系统整体内存压力增大,影响其他进程。
- 最佳实践:只对用于 CPU↔GPU 传输的缓冲区使用 Pinned Memory,其他 CPU 端数据使用普通
malloc即可。 - 在配备 HMM 或 ATS 的系统上,所有 CPU 内存对 GPU 直接可见,Pinned Memory 的传输优势会有所改变,具体以 NVIDIA 官方文档为准。
