CUDA全局内存

全局内存

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));

新分配的设备内存中的值是不确定的。

如果需要初始化,可以:

  1. 从主机拷贝数据到设备。
  2. 使用 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 也一起带入缓存,浪费带宽。

性能调整

优化设备内存带宽利用率有两个目标:

  1. 对齐合并内存访问,以减少带宽的浪费
  2. 足够的并发内存操作,以隐藏内存延迟
    减少无用数据传输,靠的是对齐访问、合并访问,以及合适的数据布局。增加并发内存操作,可以通过循环展开和调整 block 大小实现。
相关推荐
ysa0510301 小时前
【板子】树上启发式合并
数据结构·c++·笔记·算法
Simple_core1 小时前
cmake模板和问题总结
c++
劉宇雁3 小时前
C++ ASCII 3D无尽跑酷游戏
c++·游戏·3d
杜子不疼.3 小时前
【C++ 在线五子棋对战】- 会话管理模块实现
开发语言·c++
有点。3 小时前
C++深度优先搜索(DFS)的概念(一)
开发语言·c++·深度优先
旖-旎3 小时前
《LeetCode 978 最长湍流子数组 || LeetCode 139 单词拆分》
c++·算法·leetcode·动态规划
aaPIXa6223 小时前
C++大型项目模块化拆分实战记录
开发语言·c++
石山代码4 小时前
C++23 新特性在 CLion 中的实战体验
开发语言·c++·c++23
在线OJ的阿川4 小时前
仿muduo库实现高并发服务器
linux·服务器·c++