全局内存
CUDA内存模型概述
内存层次结构的优点
应用程序通常不会在某个时刻随机访问所有数据,而是倾向于访问一小部分相关数据。这种现象叫 局部性原则 locality principle。内存层次结构正是利用这个规律,把常用数据放在更快、更小的存储器中,把不常用或长期保存的数据放在更慢、更大的存储器中,从而在成本、容量和速度之间取得平衡。

内存层次结构的基本思想
现代计算机不会只使用一种内存,而是使用多级内存:
cpp
寄存器 / 缓存
↓
主存 DRAM
↓
磁盘 / 闪存
越靠近处理器,速度越快,但容量越小、成本越高;越远离处理器,容量越大、成本越低,但延迟越高。

SRAM 和 DRAM

CPU和GPU的主存通常都是DRAM,而缓存等低延迟存储通常使用 SRAM。
CUDA内存模型
CUDA 内存模型把 GPU 上的多种内存空间显式暴露给程序员。和 CPU 编程中很多缓存由硬件自动管理不同,CUDA 程序员可以更主动地决定数据放在哪里,从而优化访问延迟、带宽和线程协作方式。
CUDA 中的存储器大体分为两类:

CUDA 主要内存空间


寄存器
寄存器是 GPU 上速度最快的内存空间。kernel 中没有特殊修饰符的自动变量,通常会放在寄存器中。
cpp
__global__ void kernel(float *a) {
int tid = threadIdx.x; // 通常放在寄存器
float x = a[tid]; // x 通常放在寄存器
}
寄存器是线程私有的,生命周期与 kernel 相同。寄存器虽然快,但数量有限。如果一个 kernel 每个线程使用太多寄存器,会降低 SM 上可驻留线程块/线程束数量,从而影响占用率。
可以用编译选项查看 kernel 资源使用:
cpp
nvcc -Xptxas -v kernel.cu
也可以用 -maxrregcount 限制寄存器数量:
cpp
nvcc -maxrregcount=32 kernel.cu
本地内存
本地内存 local memory 是一个容易误解的名字。它虽然是线程私有的,但实际通常位于设备内存中,因此访问延迟高、带宽低。
编译器可能把这些变量放到本地内存:

cpp
__global__ void kernel(int i) {
int arr[100];
arr[i] = 1; // i 编译期未知,arr 可能进入本地内存
}
本地内存访问本质上接近全局内存访问,所以应尽量避免大量寄存器溢出。
共享内存
共享内存使用 shared 声明:
cpp
__global__ void kernel(float *a) {
__shared__ float tile[256];
int tid = threadIdx.x;
tile[tid] = a[tid];
__syncthreads();
float x = tile[(tid + 1) % blockDim.x];
}
共享内存位于片上,延迟低、带宽高,适合同一 block 内线程协作。它的生命周期是线程块生命周期,block 执行结束后共享内存被释放。
使用共享内存时通常需要同步:
cpp
__syncthreads();
因为一个线程写入共享内存后,其他线程可能要读取它。如果没有同步,就可能出现数据竞争。
但共享内存也是有限资源。每个 block 使用过多共享内存,会降低 SM 上可同时驻留的 block 数量。
常量内存
常量内存使用__constant__声明,必须在全局作用域声明:
cpp
__constant__ float c_coef[64];
主机端通过 cudaMemcpyToSymbol 初始化:
cpp
cudaMemcpyToSymbol(c_coef, h_coef, 64 * sizeof(float));
kernel 只能读取常量内存,不能写。常量内存适合这种场景:
cpp
一个 warp 中所有线程读取同一个地址
例如公式系数、固定参数表等。
如果 warp 中每个线程读取不同常量地址,常量内存优势会下降。
纹理内存
纹理内存是通过只读缓存访问的全局内存,适合二维空间局部性较强的数据访问。它还支持硬件滤波等功能,所以在图像、二维网格、空间采样类应用中可能有优势。
但纹理内存不是万能优化。有些应用使用纹理内存会比普通全局内存更慢,因此是否使用要结合访问模式测试。
全局内存
全局内存是 GPU 中容量最大、最常用、延迟最高的内存。它可以被所有线程访问,生命周期贯穿整个应用程序。
全局内存可以静态声明:
cpp
__device__ float devData;
也可以动态分配:
cpp
float *d_a;
cudaMalloc(&d_a, n * sizeof(float));
cudaFree(d_a);
全局内存访问性能非常依赖访问模式。一个 warp 执行 load/store 时,实际内存事务数量取决于:

满足同一个 warp 中线程连续、对齐访问时,全局内存吞吐量更高。
GPU缓存
GPU 上主要有四类缓存:

CUDA 变量声明总结

主机与设备变量访问
主机代码不能直接访问设备变量,即使变量在同一个 .cu 文件中声明。设备代码也不能直接访问主机变量。
例如:
cpp
__device__ float devData;
主机端不能直接这样访问:
cpp
devData = 1.0f; // 错误理解
需要通过 CUDA API:
cpp
float value = 1.0f;
cudaMemcpyToSymbol(devData, &value, sizeof(float));
从设备取回:
cpp
cudaMemcpyFromSymbol(&value, devData, sizeof(float));
如果需要获取设备符号地址,可以使用:
cpp
cudaGetSymbolAddress((void **)&dptr, devData);
内存管理
内存分配和释放
CUDA 程序运行在异构系统上:CPU 有主机内存,GPU 有设备内存。kernel 在 GPU 上执行,因此需要先在设备端分配内存,再把数据传过去,计算结束后释放设备内存。
设备内存分配:cudaMalloc
在主机端使用 cudaMalloc 为 GPU 分配全局内存:
cpp
cudaError_t cudaMalloc(void **devPtr, size_t count);

cpp
float *d_a = NULL;
int n = 1024;
cudaMalloc((void **)&d_a, n * sizeof(float));
新分配的设备内存中的值是不确定的。
如果需要初始化,可以:
- 从主机拷贝数据到设备。
- 使用 cudaMemset 初始化。
设备内存初始化:cudaMemset
cpp
cudaError_t cudaMemset(void *devPtr, int value, size_t count);
作用:从设备内存地址 devPtr 开始,把 count 个字节设置为 value。
设备内存释放:cudaFree
不再使用设备内存时,需要释放:
cpp
cudaError_t cudaFree(void *devPtr);
如果 devPtr 不是由 CUDA 分配函数分配的,或者已经被释放,再调用 cudaFree 会返回错误。
设备内存的分配和释放操作成本较高,所以应用程序应重利用设备内存,以减少对整体性能的影响。
内存传输
GPU kernel 访问的是设备内存,但原始数据通常在主机内存中。因此 CUDA 程序需要显式地在主机 Host 和 设备 Device 之间传输数据。
cudaMemcpy 函数
cpp
cudaError_t cudaMemcpy(
void *dst,
const void *src,
size_t count,
cudaMemcpyKind kind
);


cpp
int n = 1024;
size_t bytes = n * sizeof(float);
float *h_a = (float *)malloc(bytes);
float *h_b = (float *)malloc(bytes);
float *d_a = NULL;
cudaMalloc((void **)&d_a, bytes);
// Host -> Device
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
// kernel 使用 d_a
// kernel<<<grid, block>>>(d_a);
// Device -> Host
cudaMemcpy(h_b, d_a, bytes, cudaMemcpyDeviceToHost);
cudaFree(d_a);
free(h_a);
free(h_b);
cudaMemcpy 的同步性
cudaMemcpy 在大多数情况下是同步的。也就是说:cudaMemcpy 返回时,复制通常已经完成。
例如:
cpp
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
kernel<<<grid, block>>>(d_a);
这通常可以保证 kernel 启动时,d_a 中的数据已经准备好。
为什么内存传输很重要


因此尽可能减少主机和设备之间的数据传输。
固定内存
主机端普通分配的内存默认是 可分页内存 pageable memory,操作系统可能会移动它的物理位置。GPU 不能安全地直接访问这种内存,所以 CUDA 在从 pageable memory 传输到设备时,会先把数据复制到一块临时的固定内存 pinned memory / page-locked memory,再传给 GPU。
固定内存是页面锁定的主机内存,物理位置不会被操作系统随意移动,因此 GPU 可以更高效地访问它。相比可分页内存,固定内存在主机和设备之间传输数据时带宽更高。
可分页内存 vs 固定内存

分配固定内存:cudaMallocHost
CUDA 运行时提供 cudaMallocHost 直接分配固定主机内存:
cpp
cudaError_t cudaMallocHost(void **ptr, size_t count);
cpp
float *h_a = NULL;
size_t bytes = n * sizeof(float);
cudaMallocHost((void **)&h_a, bytes);
这里 h_a 是主机指针,但它指向的是固定主机内存。
释放固定内存:cudaFreeHost
cpp
cudaFreeHost(h_a);
cpp
int n = 1024;
size_t bytes = n * sizeof(float);
float *h_a = NULL;
float *d_a = NULL;
cudaMallocHost((void **)&h_a, bytes);
cudaMalloc((void **)&d_a, bytes);
// 初始化主机固定内存
for (int i = 0; i < n; i++) {
h_a[i] = i;
}
// HtoD 传输
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
cudaFree(d_a);
cudaFreeHost(h_a);

零拷贝内存
零拷贝内存 zero-copy memory 是一种特殊的固定主机内存,它被映射到设备地址空间中。这样 GPU kernel 可以直接访问主机内存,不需要显式调用 cudaMemcpy 把数据复制到设备内存。
普通情况:
cpp
Host memory -> cudaMemcpy -> Device global memory -> kernel 访问
零拷贝:
cpp
Host pinned memory -> 映射到设备地址空间 -> kernel 直接访问
零拷贝内存的优点

使用零拷贝内存的条件
零拷贝内存本质上是:
cpp
固定内存 + 映射到设备地址空间
所以需要使用:
cpp
cudaHostAlloc(..., cudaHostAllocMapped)
并且设备必须支持映射固定内存。通常需要先查询设备属性:
cpp
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, 0);
if (!prop.canMapHostMemory) {
printf("Device does not support mapping host memory\n");
return;
}
分配零拷贝内存:cudaHostAlloc
cpp
cudaError_t cudaHostAlloc(
void **pHost,
size_t count,
unsigned int flags
);

获取设备端指针:cudaHostGetDevicePointer
分配映射固定内存后,还需要获取 GPU kernel 可用的设备指针:
cpp
cudaError_t cudaHostGetDevicePointer(
void **pDevice,
void *pHost,
unsigned int flags
);
cpp
float *h_a = NULL;
float *d_a = NULL;
cudaHostAlloc((void **)&h_a, bytes, cudaHostAllocMapped);
//h_a主机端访问
//d_a设备端 kernel 访问
//它们指向同一块映射主机内存,只是地址空间中的指针不同。
cudaHostGetDevicePointer((void **)&d_a, (void *)h_a, 0);
使用零拷贝内存的风险
使用零拷贝共享主机和设备数据时,必须同步主机和设备访问。
如果主机和设备同时修改同一块零拷贝内存:结果不可预测
常用同步:cudaDeviceSynchronize();
统一虚拟寻址
统一虚拟寻址 UVA,Unified Virtual Addressing 是 CUDA 4.0 引入的一种寻址机制。对于计算能力 2.0 及以上的设备,UVA 允许主机内存和设备内存共享同一个虚拟地址空间。
在 UVA 之前,程序员需要明确区分:
cpp
这个指针指向主机内存
那个指针指向设备内存
有了 UVA 后,指针所属的内存空间对应用程序更透明,CUDA 可以根据指针判断它对应的是主机内存还是设备内存。

没有 UVA 时,零拷贝内存通常需要两个指针:

使用UVA前:
cpp
float *h_A = NULL;
float *d_A = NULL;
cudaHostAlloc((void **)&h_A, bytes, cudaHostAllocMapped);
cudaHostGetDevicePointer((void **)&d_A, h_A, 0);
kernel<<<grid, block>>>(d_A);
使用 UVA 后:
cpp
float *h_A = NULL;
cudaHostAlloc((void **)&h_A, bytes, cudaHostAllocMapped);
kernel<<<grid, block>>>(h_A);
统一内存寻址
统一内存寻址 Unified Memory 是 CUDA 6.0 引入的功能,用来简化主机和设备之间的内存管理。它创建了一种托管内存 managed memory:同一块内存可以用同一个指针同时被 CPU 和 GPU 访问,底层系统会自动在主机和设备之间迁移数据。
传统 CUDA:
cpp
cudaMalloc 分配设备内存
cudaMemcpy Host -> Device
kernel 执行
cudaMemcpy Device -> Host
统一内存:
cpp
cudaMallocManaged 分配托管内存
CPU 和 GPU 使用同一个指针
系统自动迁移数据
UVA 和统一内存的区别

和零拷贝内存的区别
统一内存和零拷贝都像是"一个指针访问数据",但底层行为不同。

托管内存 Managed Memory
统一内存中分配出来的内存叫 托管内存。
cpp
同一个指针在 CPU 和 GPU 上都有效
底层系统自动处理数据迁移
可以和普通 cudaMalloc 分配的设备内存一起使用
静态托管内存
可以使用__managed__ 在全局作用域声明托管变量:
cpp
__managed__ int value;
主机和设备都可以直接访问:
cpp
__global__ void kernel() {
value += 1;
}
int main() {
value = 10;
kernel<<<1, 1>>>();
cudaDeviceSynchronize();
printf("%d\n", value);
}
动态托管内存:cudaMallocManaged
cpp
cudaError_t cudaMallocManaged(void **devPtr, size_t size);
cpp
float *a = NULL;
int n = 1024;
size_t bytes = n * sizeof(float);
cudaMallocManaged((void **)&a, bytes);
使用统一内存的优点

使用统一内存的注意点

内存访问模式
CUDA 中,全局内存访问是以线程束为单位发出的。一个 warp 有 32 个线程,每个线程给出一个地址,硬件会根据这些地址分布,把请求合并成一个或多个内存事务。访问是否高效,主要看这些地址是否连续、是否对齐、是否能被合并。
上图展示了全局内存加载/存储经过缓存的基本路径。全局内存本身在设备 DRAM 中,而 SM 访问数据时,会通过一级缓存、二级缓存等层次结构完成数据传输。
对齐访问与合并访问
对齐访问指的是一次内存事务的起始地址正好落在缓存粒度边界上,比如 32B 或 128B。合并访问指的是一个 warp 中的线程访问连续或紧凑的内存区域。最理想的情况是 32 个线程访问连续的 32 个 float,总共 128B,并且起始地址 128B 对齐。

图 4-7 描述的是对齐且合并的加载操作。线程束中的线程访问一段连续内存,而且这段内存正好落在对齐边界内,因此硬件可以用很少的内存事务满足整个 warp 的请求。这是全局内存访问最希望达到的模式。
如果访问没有对齐,或者地址分布不连续,就会造成额外事务。程序需要的数据量可能仍然是 128B,但硬件实际搬运的数据会更多。
图 4-8 描述了非对齐和未合并访问。图中可以看到,请求的数据跨过了原本的内存段边界,或者线程访问的地址分散,导致硬件必须发起更多事务。这个图适合用来说明"带宽浪费"的本质:不是计算变多了,而是搬运了更多无用字节。
全局内存读取
全局内存读取可以经过不同路径,最常见的是一级缓存和二级缓存。是否使用一级缓存,和 GPU 架构、编译选项有关。
这里有一个关键差别:

启用一级缓存时,加载粒度较大。理想情况下这很好,因为一个 warp 正好可以用掉一个 128B 缓存行。但如果访问非对齐或分散,就可能把很多用不到的数据也加载进来。
图 4-9 展示的是理想的全局内存读取:一个 warp 的 32 个线程访问连续的 128B 范围,并且起始地址对齐。这个模式下,请求的数据和实际加载的数据高度匹配,加载效率最好。
图 4-10 展示的是访问地址仍然落在一个较小范围内,但线程访问顺序不连续的情况。它说明即使内存请求不完全按照线程 ID 顺序排列,只要地址仍然集中在同一个缓存行或较少内存段内,访问效率仍可能比较好。

图 4-11 展示的是连续但非对齐的读取。线程确实访问连续的 32 个 4 字节元素,但起始地址没有落在 128B 对齐边界上,因此一次 warp 请求会跨越两个缓存行或多个内存段。这就是 readSegment.cu 中 offset = 11 这类情况的图形化解释。
图 4-12 展示的是 warp 中所有线程读取同一个地址的情况。这种模式和连续访问不同,它更像广播读取:多个线程请求同一个数据。这个图适合放在解释"并不是所有线程都访问不同地址才是高效"的地方。
图 4-13 展示的是较差的情况:一个 warp 中的线程读取分散在全局内存中的多个地址。由于地址分布离散,硬件需要发起更多内存事务,加载很多无法被其他线程复用的数据,因此带宽效率很低。
验证非对齐读取:
cpp
unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int k = i + offset;
if (k < n) {
C[i] = A[k] + B[k];
}
这里 Ak 和 Bk 的读取会受到 offset 影响。offset = 0 时通常对齐;offset = 11 时起始地址错开,容易形成图 4-11 那样的非对齐读取。
禁用一级缓存后,加载可以按 32B 段处理。这样在某些非对齐访问中,虽然整体运行时间不一定更快,但无用数据搬运可能减少。




这几张图对应没有缓存加载时的几种访问模式。它们和图 4-9 到图 4-13 是一组对照:前一组强调 128B 缓存行粒度下的访问行为,后一组强调 32B 段粒度下的访问行为。通过对比可以看出,非缓存加载在非对齐或分散访问时,有时能减少被额外加载的无用字节。
全局内存写入
写入操作通常不使用一级缓存,而是通过二级缓存,以 32B 段为粒度执行。写入也希望 warp 的线程写入连续、对齐的地址。
图 4-19 展示的是理想写入模式:一个 warp 写入连续且对齐的 128B 范围。硬件可以用较少事务完成存储请求,存储效率最高。

图 4-20 展示的是写入地址分散在较大范围内的情况。虽然每个线程都只写少量数据,但由于地址分布不集中,硬件需要多个事务才能完成写入。

图 4-21 展示的是写入地址集中在较小范围内,例如连续的 64B 区域。这种情况比完全分散要好,但仍然没有充分利用完整的 128B 访问范围。
AoS 与 SoA
AoS 是数组结构体,内存布局类似:
cpp
x0 y0 x1 y1 x2 y2 ...
cpp
x0 x1 x2 x3 ...
y0 y1 y2 y3 ...

图 4-22 展示了 AoS 和 SoA 两种数据布局。AoS 中,一个对象的 x 和 y 相邻;SoA 中,所有对象的 x 连续存放,所有对象的 y 也连续存放。对于 CUDA 来说,如果一个 warp 中的线程都只访问 x 字段,SoA 更容易形成合并访问;AoS 则会把不需要的 y 也一起带入缓存,浪费带宽。
性能调整
优化设备内存带宽利用率有两个目标:
- 对齐合并内存访问,以减少带宽的浪费
- 足够的并发内存操作,以隐藏内存延迟
减少无用数据传输,靠的是对齐访问、合并访问,以及合适的数据布局。增加并发内存操作,可以通过循环展开和调整 block 大小实现。