简介:《CUDA C++编程指南》是由NVIDIA官方发布的权威技术文档,面向希望利用GPU并行计算能力的C++开发人员,系统覆盖从并行计算基础概念到高级编程技巧的完整知识体系。内容先介绍使用GPU的优势与可扩展编程模型,再讲解内核、线程层次与线程块簇、内存层次、异构编程及异步SIMT编程模型,帮助读者掌握在单GPU与多GPU环境下编写、部署和优化高性能计算程序的方法。资源包内含1个PDF文件,大小约4.3MB,便于下载后离线查阅与反复研读。已有166人学习浏览,内容适合初学者系统入门,也适合有经验的开发者补充进阶知识。文档对NVCC离线编译与即时编译机制、二进制与PTX兼容性、设备内存与L2缓存管理、64位系统兼容性等关键细节均有详细说明,是开发CUDA加速应用时不可多得的常备参考资料。
1. 把 CUDA C++ 当黑匣子用,是你性能上不去的根本原因
很多人拿到《CUDA C++ 编程指南》第一反应是“官方文档,太厚,翻不动”,于是继续靠抄别人 kernel 过日子。但这份 Release 12.8 的官方指南恰恰是解决“为什么我的程序这么慢”“为什么显存报错”“为什么换了显卡结果就不一样”这类问题的最终答案。它讲透了内核怎么组织、线程层次怎么排、内存怎么选、nvcc 怎么编译、流和事件怎么用,覆盖 CUDA C++ 编程的完整闭环。这篇文章会按“是什么 → 怎么用 → 坑在哪”的顺序,把这份指南拆成能直接落地复现的操作路径,适合刚写完第一个 kernel 的初学者,也适合已经上线过 CUDA 应用但想系统排查性能问题的开发者。
2. 编译流程与兼容性:先把 nvcc 的工作方式搞清楚,再谈优化
2.1 离线编译与即时编译:两种 PTX 生成路径的取舍
CUDA 程序的编译和普通 C++ 有一个关键区别:nvcc 不是一个简单的编译器前端,它会把代码拆成主机端(host)和设备端(device)两部分,分别走不同的编译路径。指南第 6.1.1 节详细解释了这一点。离线编译(Offline Compilation)是在开发阶段把.cu文件完整编译成 cubin 二进制,运行时直接加载;即时编译(Just-in-Time Compilation)则是把 PTX 中间代码在运行时交给驱动现场编译成设备相关的 SASS。
这两个机制的取舍直接影响你的部署策略:
| 编译方式 | 产物 | 优点 | 缺点 |
|---|---|---|---|
| 离线编译 | cubin(SASS) | 启动快,无首次运行编译延迟 | 只兼容特定计算能力,换卡需重新编译 |
| 即时编译 | PTX + cubin | 同一份 PTX 可在多代 GPU 上运行 | 首次调用会卡顿几秒到几十秒 |
我一般会在工程里同时保留两类编译产物。发布时在nvcc命令里指定-gencode arch=compute_XY,code=sm_XY和-gencode arch=compute_XY,code=compute_XY,这样既能拿到原生性能,又能给未来新卡留一条后路。
2.2 计算能力与编译参数:为什么“能跑”和“跑得快”是两回事
指南在 5.6 节和 6.1.2 节反复强调 Compute Capability(计算能力)的概念。设备端的sm_xy代表了硬件的具体架构版本,比如sm_80对应 Ampere 架构,sm_90对应 Hopper 架构。每条 SASS 指令和每个硬件单元的行为都跟这个版本绑定。
编译时arch和code的写法很容易混:
arch=compute_80指定的是 PTX 目标版本code=sm_80指定的是 SASS 目标版本
如果只写-arch=sm_80,nvcc 会同时生成对应代次的 PTX 和 SASS 的 cubin。但如果你写的是-gencode arch=compute_80,code=compute_80,那就只有 PTX,运行时靠 JIT 编译。
给一个通用的交叉编译命令模板:
nvcc -O3 -gencode arch=compute_80,code=sm_80 \ -gencode arch=compute_80,code=compute_80 \ -gencode arch=compute_90,code=sm_90 \ -o my_kernel my_kernel.cu这条命令的含义是:为 Ampere 架构生成原生 SASS,同时保留同代 PTX 兜底;再为 Hopper 架构生成原生 SASS。逻辑说明:每个-gencode组合生成一个 fatbin 段,最终打包进可执行文件,运行时由 CUDA 驱动从 fatbin 中挑选匹配的sm_xy版本。参数说明:arch值越大,PTX 新特性越多,但兼容性越差;如果拿不准用户机器分布,建议从compute_70到compute_90都打进去,代价是可执行文件体积变大。
这里有个容易踩的坑:不能对一个低于 PTX 版本的计算能力做 JIT。比如你只编译了compute_90的 PTX,那它在计算能力 8.0 的显卡上会直接加载失败,因为驱动没有能力做“降级”翻译。
2.3 64 位兼容性与 C++ 标准支持
指南 6.1.5 和 6.1.6 节专门讲了 C++ 兼容和 64 位兼容。CUDA C++ 本质上是对标准 C++ 的扩展,nvcc 支持的语法特性跟着所选 C++ 标准走。实测中__host__ __device__函数里用模板、lambda、标准库算法都没问题,但要注意std::vector这类依赖主机端实现的容器不能在设备端直接用。
64 位兼容的重点是指针和size_t的长度。在设备端代码里,unsigned int是 32 位,unsigned long long是 64 位。如果你用unsigned int存数组总长度,超过 4GB 的分配会溢出变成负数,这在指南里被明确列为未定义行为。所有涉及显存大小、数据总长度的变量,我习惯全部用size_t。
3. 线程与内存层次结构:从 grid 到共享内存,一次把模型理顺
3.1 线程层次的内外两层理解:grid、block、warp 与 cluster
指南 5.2 节给出了 CUDA 最核心的线程层次结构:grid → block → thread。但光知道这三个层级还不够,实际写代码时还得理解 warp(线程束)和 cluster(线程块簇,5.2.1 节新增概念)的定位。
warp 是硬件调度的最小单位,一个 warp 固定 32 个线程。也就是说,即使你一个 block 只开 16 线程,硬件也会按 32 个线程的粒度去占用调度资源,剩下的 16 个槽位就浪费了。因此 block 大小建议取 32 的倍数,常见配置是 128、256、512。
Thread Block Clusters 是较新计算能力引入的中间层。它允许一组 block 在运行时有共享内存的硬件连带关系,能直接访问彼此的 shared memory。这对以前只能靠全局内存通信的跨 block 协作是一个很大改善。
看一段简单的内核调用代码,把层次对应关系具象化:
#include <cuda_runtime.h> #include <stdio.h> __global__ void inspect_threads() { // threadIdx.x 是块内偏移,blockIdx.x 是块索引 // blockDim.x 是每块线程数,gridDim.x 是全局块数 int global_id = blockIdx.x * blockDim.x + threadIdx.x; printf("global thread id: %d, block %d, thread in block %d\n", global_id, blockIdx.x, threadIdx.x); } int main() { // 启动 4 个 block,每个 block 32 个线程 inspect_threads<<<4, 32>>>(); cudaDeviceSynchronize(); return 0; }逻辑说明:<<<4, 32>>>是 CUDA 的启动配置语法,第一个参数是 grid 维度,第二个是 block 维度。blockIdx.x * blockDim.x + threadIdx.x是所有 CUDA 教程都要写的全局线性索引公式,含义是“先把当前 block 之前的所有线程数算出来,再加上块内偏移”。参数说明:<<<gridDim, blockDim>>>都可以是dim3类型,用于二维和三维组织,图像处理中常见<<<gridDim, blockDim>>>同时传(width, height)各自的尺寸。
3.2 内存层次选型:全局内存、共享内存、寄存器各自的定位
指南 5.3 节把内存层次讲得很细。全局内存(Global Memory)容量大但延迟高,共享内存(Shared Memory)是 block 内的高速缓存,寄存器(Register)每个线程独占但数量有限。很多人一上手就把所有数据往全局内存里堆,性能自然上不去。
各存储空间的特征对比如下:
| 存储空间 | 位置 | 延迟量级 | 容量 | 作用域 |
|---|---|---|---|---|
| 全局内存 | 板载显存 | 数百周期 | 数 GB | 所有线程 |
| L2 缓存 | 芯片内 | 数十周期 | 数 MB | 所有线程 |
| 共享内存 | 芯片内 | 数个周期 | 几十 KB | 单个 block |
| 寄存器 | 芯片内 | 1 个周期 | 每线程数百字节 | 单个线程 |
一个典型的内存优化思路是:把全局内存中将要被反复访问的 tile 数据分阶段搬进共享内存,block 内所有线程从共享内存里读,而不是反复打全局内存。这就是 tiling 技术的基本逻辑。
3.3 共享内存的两种布局:静态与动态分配
共享内存既可以在编译期用__shared__静态声明,也可以运行期用extern __shared__动态声明。静态方式简单直白,动态方式能让同一个 kernel 适配不同大小的块。
__global__ void shared_mem_demo(const float* in, float* out, int n) { // 静态共享内存:编译期大小 __shared__ float tile[256]; int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < n) { tile[threadIdx.x] = in[idx]; } __syncthreads(); // 每个线程把共享内存里的值翻倍后写回 if (idx < n) { out[idx] = tile[threadIdx.x] * 2.0f; } }逻辑说明:__shared__ float tile[256]在编译期就把共享内存占住了,大小必须是编译期常量。__syncthreads()是 block 内屏障,保证所有线程都完成共享内存的写入之后,任何线程才能读取。如果不加这个同步,会出现部分线程还在写、部分线程已经开始读的竞争状态。参数说明:这里 tile 大小和 block 大小强相关,如果 block 开到 512 线程而 tile 只有 256,后半部分线程就会访问越界。
动态共享内存的写法是启动时在尖括号第三个参数里传大小:
__global__ void dynamic_shared_demo(const float* in, float* out) { extern __shared__ float dyn_tile[]; int idx = blockIdx.x * blockDim.x + threadIdx.x; dyn_tile[threadIdx.x] = in[idx]; __syncthreads(); out[idx] = dyn_tile[threadIdx.x] + 1.0f; } int main() { float *d_in, *d_out; // 假设已经分配并拷贝数据 int block_size = 256; int grid_size = 1024; // 第三个参数是每个 block 的动态共享内存字节数 dynamic_shared_demo<<<grid_size, block_size, block_size * sizeof(float)>>>(d_in, d_out); cudaDeviceSynchronize(); return 0; }逻辑说明:extern __shared__声明的是一个大小未知的共享内存数组,实际大小由启动配置的第三个参数决定。这里的block_size * sizeof(float)就是为每个 block 预留的字节数。参数说明:启动配置第三个参数的单位是字节,不是元素个数,写错的话轻则越界重则启动失败。一个 block 能声明的动态共享内存上限由设备的MaxDynamicSharedMemorySize属性决定,超出会直接报错。
4. 异步并发执行:用流和事件把 GPU 的每一计算单元喂饱
4.1 流(Streams):从默认流到多流并行
默认流(default stream)是每个程序隐式拥有的,特点是所有操作按顺序执行。指南 6.2.8.5 节指出,只要不用默认流,不同流里的操作就有并发执行的可能。流的使用包括三步:创建流、把操作放入流、同步。
#include <cuda_runtime.h> #include <stdio.h> int main() { cudaStream_t s1, s2; cudaStreamCreate(&s1); cudaStreamCreate(&s2); float *d_in1, *d_in2, *d_out1, *d_out2; float *h_in1, *h_in2, *h_out1, *h_out2; int n = 1 << 20; size_t bytes = n * sizeof(float); // 分配设备内存 cudaMalloc(&d_in1, bytes); cudaMalloc(&d_in2, bytes); cudaMalloc(&d_out1, bytes); cudaMalloc(&d_out2, bytes); // 分配页锁定主机内存,用 pinned memory 才能异步拷贝 cudaMallocHost(&h_in1, bytes); cudaMallocHost(&h_in2, bytes); cudaMallocHost(&h_out1, bytes); cudaMallocHost(&h_out2, bytes); // 数据初始化省略 // 流1:拷贝 -> kernel -> 拷贝回 cudaMemcpyAsync(d_in1, h_in1, bytes, cudaMemcpyHostToDevice, s1); my_kernel<<<grid_size, block_size, 0, s1>>>(d_in1, d_out1, n); cudaMemcpyAsync(h_out1, d_out1, bytes, cudaMemcpyDeviceToHost, s1); // 流2:同样三步,但与流1并行 cudaMemcpyAsync(d_in2, h_in2, bytes, cudaMemcpyHostToDevice, s2); my_kernel<<<grid_size, block_size, 0, s2>>>(d_in2, d_out2, n); cudaMemcpyAsync(h_out2, d_out2, bytes, cudaMemcpyDeviceToHost, s2); // 两个流都完成后再继续 cudaStreamSynchronize(s1); cudaStreamSynchronize(s2); cudaStreamDestroy(s1); cudaStreamDestroy(s2); cudaFree(d_in1); cudaFree(d_in2); cudaFree(d_out1); cudaFree(d_out2); cudaFreeHost(h_in1); cudaFreeHost(h_in2); cudaFreeHost(h_out1); cudaFreeHost(h_out2); return 0; }逻辑说明:这里两个流各自包含“拷贝到设备 → 执行内核 → 拷回主机”三个操作。由于它们在不同流中,如果设备资源允许,H2D 拷贝、kernel 执行、D2H 拷贝三个阶段的空闲资源会被互相填充。参数说明:cudaMemcpyAsync的最后一个参数是流 ID,传cudaMemcpyDefault则优先使用统一虚拟地址自动判断方向。pinned memory 也就是cudaMallocHost分配的主机内存,是异步拷贝的前提条件;普通malloc内存走异步拷贝会退化为同步。
4.2 事件(Events):比 cudaDeviceSynchronize 更精细的同步手段
事件(Events)用于测时和同步。cudaDeviceSynchronize()会阻塞主机直到所有设备操作完成,粒度太粗;事件可以精确标记某个流中的某个时间点。
cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); // 记录事件到默认流 cudaEventRecord(start, 0); my_kernel<<<grid_size, block_size>>>(d_out, d_in, n); cudaEventRecord(stop, 0); // 阻塞等待 stop 事件完成 cudaEventSynchronize(stop); float ms = 0.0f; cudaEventElapsedTime(&ms, start, stop); printf("kernel time: %f ms\n", ms); cudaEventDestroy(start); cudaEventDestroy(stop);逻辑说明:cudaEventRecord将事件插入指定流的命令序列,事件前所有操作完成后事件被标记完成。cudaEventElapsedTime计算两个事件之间的毫秒数。参数说明:事件必须属于同一个上下文才能计算时间差;跨设备的事件测时结果未定义。事件测时对 kernel 而言测的是 GPU 端时间,不受主机端调度抖动影响,所以比直接用clock()测出来的数据可信度高一截。
4.3 Programmatic Dependent Launch 与 CUDA Graphs:把启动开销压到最低
指南 6.2.8.6 和 6.2.8.7 节分别讲了 Programmatic Dependent Launch(PDL)和 CUDA Graphs。PDL 允许 kernel 在设备端直接触发下一个 kernel 的启动,省去主机→设备之间的启动往返。CUDA Graphs 更进一步,允许先建立依赖图,一次性重放。
cudaGraph_t graph; cudaGraphExec_t graphExec; // 用流捕获方式构建图 cudaStream_t stream; cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking); cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal); my_kernel<<<grid_size, block_size, 0, stream>>>(d_out, d_in, n); other_kernel<<<grid_size, block_size, 0, stream>>>(d_out2, d_out, n); cudaStreamEndCapture(stream, &graph); cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0); // 多次启动只走一次验证开销 cudaGraphLaunch(graphExec, 0); cudaDeviceSynchronize(); cudaGraphExecDestroy(graphExec); cudaGraphDestroy(graph);逻辑说明:cudaStreamBeginCapture之后在流里发起的所有操作都被记录为图节点,cudaStreamEndCapture生成cudaGraph_t对象。实例化后的graphExec可以直接反复 launch,省去每次 launch 的驱动解析和依赖检查开销。参数说明:流捕获模式cudaStreamCaptureModeGlobal表示在捕获期间全局范围内的操作都会被纳入图中;如果捕获期间调用cudaMemcpy这类同步操作,捕获会直接报错,这是个常见坑点。
5. CUDA 常见问题排查与避坑:五次翻车记录一次说清
5.1 问题一:cudaGetLastError()永远返回cudaSuccess,但程序就是跑不出正确结果
现象:kernel 启动后没有报错,数值结果错误或者程序崩溃。 原因:kernel launch 是异步的,主机端不会立刻收到错误。多数 kernel 错误发生在执行阶段,而不是启动阶段。很多老手贴检查代码时只查 launch 返回值,自然会漏掉。 解决:每次 kernel 启动后加cudaDeviceSynchronize(),再配合cudaGetLastError()查异步错误。
my_kernel<<<grid, block>>>(d_out, d_in, n); // 异步错误检查:同步 + 取错误 cudaError_t err = cudaDeviceSynchronize(); if (err != cudaSuccess) { fprintf(stderr, "Kernel failed: %s\n", cudaGetErrorString(err)); exit(EXIT_FAILURE); }逻辑说明:cudaDeviceSynchronize会阻塞到所有此前发出的设备操作结束,如果其中有任何错误,返回码会在这里暴露。cudaGetErrorString把错误码转成可读字符串。参数说明:在性能敏感的循环里不要每个 kernel 都调用cudaDeviceSynchronize,正确的做法是在循环末尾做一次同步检查,其他位置只查 launch 返回值。
5.2 问题二:动态共享内存明明传了大小,kernel 启动还是报invalid argument
现象:<<<grid, block, bytes>>>传了共享内存大小,但启动失败,错误为cudaErrorInvalidValue。 原因:超过设备的cudaDevAttrMaxDynamicSharedMemorySize限制。部分架构默认限制是 48KB,需要手动通过cudaFuncSetAttribute提高上限。还有一种可能:动态共享内存和静态共享内存的总和超出硬件容量。 解决:先查询设备属性,再对 kernel 设置属性。
cudaDeviceProp prop; cudaGetDeviceProperties(&prop, 0); printf("Max dynamic shared memory per block: %zu bytes\n", prop.sharedMemPerBlockOptin); // 对指定 kernel 放开动态共享内存上限 cudaFuncSetAttribute(my_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, prop.sharedMemPerBlockOptin); // 之后再用大尺寸启动 my_kernel<<<grid, block, 96 * 1024>>>(d_out, d_in, n);逻辑说明:sharedMemPerBlockOptin是设备允许单个 block 申请的最大动态共享内存字节数,需要先调用cudaFuncSetAttribute让 kernel 越过默认的 48KB 限制。参数说明:这个设置是 per-kernel 的,换了 kernel 就要重新设置;静态共享内存和动态共享内存共用同一份硬件预算,不能把两者分开算。
5.3 问题三:同一个 kernel 在旧显卡上性能正常,新显卡上反而变慢
现象:升级硬件后 kernel 执行时间不降反升。 原因:老卡上最优的 block 大小在新卡上可能不适合。不同架构对寄存器数量和共享内存容量的限制不同。如果 block 太大导致每个线程寄存器分配不足,编译器会 spill 到本地内存,变相把数据写回显存,性能直接崩。 解决:用cudaOccupancyMaxActiveBlocksPerMultiprocessor做占用率推导,或者干脆对不同 block 大小做一轮 benchmark。
int block_size = 0; // 求给定块大小时,每个 SM 能同时驻留的最大 block 数 cudaOccupancyMaxActiveBlocksPerMultiprocessor(&num_blocks, my_kernel, 256, 0); // 通过 num_blocks 推算最优 block 大小逻辑说明:占用率计算器(指南 8.2.3.1 节提到)会根据 kernel 的寄存器数、共享内存用量、block 大小和硬件限制,算出硬件能同时驻留多少 block。如果num_blocks很低,说明资源被某个维度卡住了。参数说明:第三个参数是动态共享内存大小,第四个可选参数可以传入 block 大小限制;在性能分析阶段把这两个参数都试一遍,比拍脑袋调强得多。
5.4 问题四:多流并发没效果,profiler 里显示操作还是串行的
现象:分了两个流,数据拷贝和 kernel 执行的时间完全没有重叠。 原因:最常见是用了默认流。只要有任何操作在默认流里,整个默认流和所有阻塞流会被强制串行化。另外,两个流操作同一块内存也不允许并行。 解决:所有流操作都避免使用默认流;在创建流时显式加cudaStreamNonBlocking,防止隐式同步。
// 创建非阻塞流,避免与默认流之间的隐式同步 cudaStreamCreateWithFlags(&stream1, cudaStreamNonBlocking); cudaStreamCreateWithFlags(&stream2, cudaStreamNonBlocking);参数说明:cudaStreamNonBlocking表示该流不会与默认流同步。另一个创建选项cudaStreamDefault表示该流会和默认流隐式同步,这种同步会破坏并发。
5.5 问题五:__restrict__没加导致 kernel 性能差 30%
现象:一个简单的拷贝 kernel,读写指针分别指向不同数组,性能比理论带宽低。 原因:编译器不知道两个指针是否指向同一块内存,只能做保守假设,每次写入都可能影响后续读,无法做重排和向量化。 解决:在函数参数上加__restrict__。
// 声明 in 和 out 不指向同一区域 __global__ void copy_kernel(const float* __restrict__ in, float* __restrict__ out, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) out[i] = in[i]; }逻辑说明:__restrict__是向编译器承诺“这个指针是唯一访问该内存区域的入口”,编译器得到这一信息后可以缓存和重排访问,而不必担心in写入被out的读操作影响。参数说明:滥用__restrict__有风险,如果两个指针实际指向了同一块数据,编译器会做出错误优化,数值直接错乱。这也是为什么指南 10.2.6 节反复强调要确保别名关系确不存在。
6. L2 持久化访问:从三级 cache 压榨到最后 20% 的带宽
前面几章覆盖了怎么把程序写对、怎么并发、怎么避坑,这一章聊一个容易被忽略的进阶技巧——L2 Cache Set-Aside for Persisting Accesses(指南 6.2.3 节)。常规 CUDA 优化集中在共享内存和寄存器,但当全局内存访问模式确实无法避免重复读取时,L2 持久化窗口能直接为热点数据预留缓存空间,避免被其他数据流冲掉。
L2 持久化的核心思路很简单:把访问属性分为“流式”(访问一次,不用缓存)和“持久化”(反复访问,需要长期驻留),然后告诉 CUDA 驱动在 L2 中划出固定比例给持久化数据用。
// 1. 查询设备 L2 属性 int device_id = 0; cudaDeviceProp prop; cudaGetDeviceProperties(&prop, device_id); printf("L2 cache size: %d bytes\n", prop.l2CacheSize); // 2. 设置持久化访问窗口大小(设为 L2 的 75%) size_t set_aside = (size_t)(prop.l2CacheSize * 0.75); cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, set_aside); // 3. 创建带持久化属性的流 cudaStream_t stream; cudaStreamAttribute attrs; int access_policy_window = 0; cudaStreamCreateWithAttributes(&stream, cudaStreamAttributeAccessPolicyWindow, &attrs);再看内核里怎么标记持久化访问对象的命中区域,关键在cudaStreamAttrValue的hitRatio和hitProp字段:
cudaStreamAttrValue attr; // 使用流式属性,命中窗口的比例 attr.accessPolicyWindow.base_ptr = (void*)d_data; attr.accessPolicyWindow.num_bytes = n * sizeof(float); attr.accessPolicyWindow.hitRatio = 1.0f; // 100% 命中窗口 attr.accessPolicyWindow.hitProp = cudaAccessPropertyPersisting; attr.accessPolicyWindow.missProp = cudaAccessPropertyStreaming; // 把属性绑定到流 cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &attr); // 在该流中启动 kernel,d_data 的访问会被 L2 持久化缓存 compute_kernel<<<grid, block, 0, stream>>>(d_data, result, n); // 用完把窗口重置为正常 attr.accessPolicyWindow.num_bytes = 0; cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &attr);逻辑说明:accessPolicyWindow里的base_ptr和num_bytes定义了一块 GPU 内存地址范围,hitRatio表示访问落在持久化窗口内的概率。hitProp和missProp分别决定命中窗口和落空窗口时的缓存策略。参数说明:hitRatio设 1.0 意味着所有对该地址范围的访问都按持久化处理,一般用于真正的热点数据;如果数据总量比 L2 大很多,建议降低hitRatio,让部分访问回落到流式属性,避免把整个 L2 占死。cudaLimitPersistingL2CacheSize不能超过设备允许的最大值,查询方法是cudaDeviceGetLimit,设置超限会返回cudaErrorInvalidValue。
实践中 L2 持久化适合的典型场景是:每次 kernel 只读一小块全局数据(比如权重矩阵、查找表),但被所有 block 反复访问。这种模式共享内存放不下、全局内存访问又太多,L2 持久化是投入产出比最高的解法之一。
在数据集合不同、计算强度不同的多个 kernel 交替执行时,我遇到过一个问题:开了 L2 持久化窗口之后,另一个 kernel 的流式数据访问性能反而下降了。原因是持久化窗口占掉的 L2 空间挤占了原本留给流式数据的缓存。后来我把持久化窗口从 75% 调到 50%,并给那个 kernel 单独设置了流式属性,两个 kernel 的性能才同时恢复。
从那以后,我每次使用 L2 持久化都会强制走一遍完整流程:查设备 L2 大小 → 按实际热点数据量设置窗口 → 在流上绑定访问属性 → 跑完 benchmark 对比开与关的耗时。这套验证法也推荐给你试试,做一次就清楚自己的数据模式适不适合这个机制了。希望帮到你。
本文还有配套的精品资源,点击获取