先说个很常见的现象。很多人拿到 CUDA Samples 之后,跑一个 vectorAdd 看到输出数字就关掉了,甚至有人连装都没装过。我以前也这样,直到有一次为了调矩阵乘法的性能,把官方样例从头到尾翻了一遍,才发现这个仓库的价值被大大低估了。它表面上是一堆 GPU 例子,实际上是一套 NVIDIA 工程师反复打磨过的开发骨架,里面包含了 CUDA 架构的理解、源码分层的示范,以及大量可以直接搬到真实项目里的工程写法。13.3 是随 CUDA Toolkit 13.3 一同发布的版本,GitHub 上的 cuda-samples 仓库还在持续维护。这篇文章不打算教你安装 CUDA,而是带你把这套源码的架构和分层逻辑过一遍——读完你至少能把它当成自己的 GPU 开发脚手架用起来。
如果你是刚接触 CUDA、正被各种教程绕晕的人,这套代码是你绕开弯路的最佳参考;如果你已经在用 PyTorch,但想进一步理解底层 kernel 是怎么写的,Samples 里的 0_Simple 目录非常适合你;如果你马上要面 GPU 相关职位,把 deviceQuery、bandwidthTest、simpleCUBLAS 这几个样例吃透,比背面试题管用得多。下文从目录结构讲起,再到源码分层、工程能力、实战操作,一层层拆。
1. 为什么值得把官方Samples当成源码啃一遍
1.1 这套代码解决的到底是什么问题
CUDA 开发最大的学习障碍不是语法,而是缺一个标准答案。网上能找到的博客教程经常把 kernel 简化到只剩一个数组相加,但实际项目里要考虑内存布局、错误检查、多设备选择、计时调优,这些工程上下文极少有人讲清楚。官方 Samples 恰恰解决了这个问题:每个样例都从 main 函数开始,把设备初始化、内存分配、数据传输、kernel 执行、结果校验、资源清理这六步写全了。你照着读一遍,等于看 NVIDIA 内部约定的最佳实践。
更关键的是,Samples 不是孤立的。它们和 CUDA Toolkit 版本一一对应,能够验证当前驱动、Toolkit、编译器的兼容性。很多人遇到 torch.cuda.is_available() 返回 False 或者 cudaGetDevice 失败,第一个该跑的就是 deviceQuery 样例,而不是去改什么环境变量。业内有个说法是「环境没体检之前,别急着跑模型」,这个体检工具就藏在 Samples 里。
1.2 13.3的定位:新特性与老经典的组合
CUDA 13.x 与 12.x 相比,除了正常的驱动迭代,还把一些之前分散的高级能力做了整合,CUDA Graphs、虚拟内存管理、统一寻址在样例里的使用方式都更完整了。不过 13.3 里真正值得读的仍然是那些老经典:vectorAdd 教你最小 kernel,bandwidthTest 教你测带宽,simpleCUBLAS 教你用官方库替代手写实现。新特性样例让你知道天花板在哪,老经典则保证地基扎实。
我系统看下来的感受是:这版 Samples 的取舍很克制,没有为了展示新功能而把样例写复杂,每个目录还是围绕一个主题讲一件事。这种风格其实比很多自媒体的长教程更适合学习,因为你能很清楚地看到「什么功能对应什么写法」,不存在上下文污染。
1.3 谁适合花这个时间
如果你属于下面三类人,这篇分析和后面的实操步骤会特别有用。第一类,刚入门 CUDA 的开发者,拿 Samples 当教材比直接看论文或大会视频高效得多;第二类,做 GPU 相关面试准备的人,Samples 里的错误处理、设备选择、性能测量都是面试常客;第三类,想把手写的 GPU 代码工程化的人,这里的 helper 层和构建脚本可以直接抄作业。
2. 目录全景:13.3的Samples到底长什么样
2.1 分组目录与核心样例分布
拿到源码仓库之后,第一件事不是急着编译,而是看目录结构。cuda-samples 根目录下按功能分了几个大目录,最早的命名习惯一直沿用到 13.x 系列。0_Simple 放最简单的单功能样例,例如 vectorAdd、matrixMul、simpleStreams,主要演示 kernel 怎么写、stream 怎么用;1_Utilities 放环境诊断类工具,deviceQuery、bandwidthTest、p2pBandwidthLatencyTest 都在这里,这些工具在跑任何重型 GPU 任务之前都建议先执行一遍;2_Graphics 和 3_Imaging 主要放 OpenGL/Vulkan 互操作和图像处理样例,做渲染和视觉方向会经常用到;4_Finance 是量化计算相关的随机数样例;5_Simulations 是粒子、烟雾等物理模拟;6_Advanced 放的是 CUDA Graphs、Cooperative Groups、VMM 这类进阶功能;7_CUDALibraries 则是围绕 cuBLAS、cuFFT、cuSPARSE、NPP 这些官方库写的调用示例。
注意,具体到 13.3,目录编号有可能和旧版稍有不同,仓库里的 README 会写每个目录的用途。我建议你按「我要解决什么问题」去找样例,而不是按目录编号背下来。比如想学会异步并发,去看 simpleStreams;想搞懂多 GPU 通信,去看 p2pBandwidthLatencyTest;想验证某个新 GPU 特性,再去翻 6_Advanced。
2.2 Make和CMake两套构建系统并存的秘密
每个样例目录下同时存在 Makefile 和 CMakeLists.txt,这是很多人会忽略的设计。NVIDIA 一方面保留了传统 Makefile,让你在 Linux 下敲 make vectorAdd 就能单独编译一个样例;另一方面大力推 CMake,因为 CMake 对依赖、跨平台编译和 IDE 集成更友好。从 CUDA 11 前后的版本开始,NVIDIA 越来越强调 CMake 作为首选构建方式,13.3 也不例外。
实际使用中,我的建议是:临时跑单个样例用 Make,正经搭工程用 CMake。Make 的坑在于它对 CUDA 版本和 GCC 版本极其敏感,nvcc 不支持太新的 GCC;CMake 虽然配置麻烦一点,但能更好地管理编译选项。如果你在同一台机器上装了多个 CUDA 版本,建议用环境变量或者 update-alternatives 做切换,千万别在 PATH 里同时放两个 nvcc。
2.3 common目录:最容易被忽略的辅助框架
几乎每个样例都 include 了 common/inc 下的头文件,例如 helper_cuda.h、helper_string.h、helper_functions.h。这些头文件构成了 Samples 的辅助层,里面封装了错误检查宏、命令行解析、图片读写、计时器等功能。很多人读样例的时候眼睛盯着 kernel,完全不看 helper,其实真正该学的是这一层。
举个例子,helper_string.h 里有一个 getCmdLineArgumentInt 函数,可以从命令行解析 --n=1024 这样的参数。这样一来,同一个样例可以用不同规模去跑,做性能回归测试就非常方便。这种设计思路完全可以迁移到你自己的项目里,我后面在实战部分还会具体演示怎么用。
3. 源码分层:从kernel调用到官方库的本质区别
3.1 直接写kernel:vectorAdd的全流程解读
我们来看最经典的 vectorAdd。虽然它简单,但它的结构覆盖了 GPU 开发的完整生命周期。host 端先分配内存和显存,把输入数据拷贝到显存,然后以 grid/block 的方式启动 kernel,再把结果拷回来做 CPU 校验。kernel 本身只有三行核心逻辑:i = blockIdx.x * blockDim.x + threadIdx.x; 然后执行加法,边界判断用 if (i < N) 挡掉越界线程。这套「网格覆盖数据」的写法是所有 CUDA kernel 的起点。
__global__ void vectorAdd(const float *A, const float *B, float *C, int N) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < N) { C[i] = A[i] + B[i]; } }为什么 block 大小常用 256 或 512?因为 GPU 调度以 warp 为单位,一个 warp 是 32 个线程,block 大小取 warp 的整数倍可以避免调度碎片。block 太小,不足以掩盖访存延迟;block 太大,又可能超出单 SM 的最大线程数。vectorAdd 选 256 不是随便写的,你从 occupancy 样例里能看到更系统的推导方法。
3.2 Runtime API和内存管理这一层
继续往下挖,vectorAdd 背后的分层其实更清楚。最底层是驱动 API,再往上是 Runtime API(cudaMalloc 这种 cuda 开头的接口)。Samples 绝大多数都基于 Runtime API,因为它封装了驱动 API 的初始化流程,对新手更友好。
内存管理是这一层的核心。cudaMalloc 分配显存、cudaMemcpy 做 Host 和 Device 之间的拷贝、cudaFree 做释放,三件套缺一个都不行。实际项目里比 vectorAdd 复杂得多,因为要处理统一内存、stream、event。建议你顺着 simpleStreams 这个样例去读,它演示了怎么让多个 kernel 和内存拷贝重叠执行。理解了 stream,你才算真正入门 GPU 并发编程。
3.3 官方库层:调库和手写kernel怎么选
Samples 里有一类特别适合做对比阅读的组合:matrixMul 和 simpleCUBLAS。前者手写了一个分块矩阵乘法,代码量大,逻辑复杂;后者直接调 cublasSgemm,几十行代码就能完成同样的事,性能还更优。
选择依据其实看两点:一是性能需求,如果官方库的 kernel 已经接近硬件峰值,没必要自己重复造轮子;二是定制需求,如果你的算法是特殊的稀疏结构或者需要和自定义 kernel 融合,那调库反而会很别扭。NVIDIA 自己的建议也是先查库、再写 kernel,这是 Samples 在 7_CUDALibraries 目录里反复传达的思路。
3.4 一层更比一层懒:三种开发范式对照
把上面的内容汇总一下,CUDA 开发实际上有三个选择:直接写 kernel、基于 Runtime 做内存和任务管理、调用官方库或运行时提供的专用 kernel。它们是层层封装的关系,也是项目复杂度逐步提高时的应对方式。
| 开发层级 | 典型接口 | Samples代表 | 适合场景 |
|---|---|---|---|
| 直接写 kernel | global | vectorAdd, matrixMul | 自定义算法、融合算子 |
| Runtime API | cudaMalloc/cudaMemcpy/stream | simpleStreams | 管理资源与并发 |
| 官方库 | cublasSgemm/cuFFT/NPP | simpleCUBLAS | 矩阵乘法、FFT、图像处理 |
我个人的原则是:能用 cuBLAS/cuFFT/NPP 解决,就不自己写 kernel;必须自己写 kernel 时,先在 Samples 里找一个最接近的样例改;等性能瓶颈确定了,再用 profiler 定位到具体 kernel 做手工优化。这套流程和 Samples 在 13.3 里展示的编写思路是一致的。
4. 样例里藏的工程能力:错误处理、计时器与多设备管理
4.1 checkCudaErrors:把异步错误变成同步崩溃
很多网上代码不检查 CUDA API 的返回值,出了问题输出一串乱糟糟的错误。Samples 里的 helper_cuda.h 提供了一个 checkCudaErrors 宏,几乎所有 CUDA 调用都会包一层。它做的事情很简单:如果调用返回值不是 cudaSuccess,就把文件名、行号、错误字符串打出来,然后退出进程。
#define checkCudaErrors(call) \ do { \ cudaError_t err = (call); \ if (cudaSuccess != err) { \ fprintf(stderr, "CUDA error at %s:%d: %s\n", \ __FILE__, __LINE__, cudaGetErrorString(err)); \ exit(1); \ } \ } while (0)这里有两个细节值得注意。第一,CUDA 的 kernel 启动是异步的,很多错误要等到同步点才暴露,所以样例里会在关键位置调用 cudaDeviceSynchronize(),让错误尽早显现。第二,do-while(0) 的写法是为了让宏在 if/else 里用起来不出问题,这是 C 语言写多行宏的常见技巧。生产项目如果用了 C++,我建议把宏换成异常或者返回值包装,但 Samples 里的思路是完全合理的:错误必须尽早暴露。
4.2 findCudaDevice:多卡环境的设备选择
Samples 在需要 GPU 时几乎都会调用 findCudaDevice(argc, argv) 这个函数。它的作用是从命令行参数里读 --device=N,然后调用 cudaGetDeviceProperties 检查设备是否满足算力要求,再设置当前设备。没有这个函数,程序会默认用 0 号卡。在多卡机器上,这很可能不是你预期的那张卡。
结合 PyTorch 用户经常遇到的情况:PyTorch 通过 CUDA_VISIBLE_DEVICES 控制可见设备,本质上也是选择设备的一种方式。理解 Samples 里这套设备选择逻辑,再看框架的 API 设计会通透很多。设备属性检查很重要,因为有些 kernel 要求特定的计算能力,也就是 SM 版本,老卡跑不了新特性。
4.3 CUDA Event计时与性能基线
调 GPU 程序,第一件事是先建立性能基线。Samples 里的做法非常统一:用 CUDA Event 来计时,而不是用 CPU 的 clock 函数。原因很简单,kernel 是异步提交的,CPU 侧计时器测到的可能只是启动命令的时间,不是 kernel 真正执行的时间。
cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventRecord(start); myKernel<<<blocks, threads>>>(...); cudaEventRecord(stop); cudaEventSynchronize(stop); float ms = 0.0f; cudaEventElapsedTime(&ms, start, stop);如果需要精确测量一个 kernel 的 GPU 执行时间,这段代码是标准答案。跑性能测试时,我会用 bandwidthTest 先测一下当前机器能达到的带宽水平,再挂上 Nsight Compute 分析 kernel 占用了多少资源。很多性能问题不是 kernel 逻辑复杂,而是数据搬运太多,这时候测带宽比抠指令管用得多。
4.4 哪些写法值得抄,哪些要改
Samples 的工程写法值得抄的地方很多,但我并不会全盘照搬。比如它的很多程序喜欢在 main 函数里写几千行,逻辑全堆在一起,这是示例代码为了好读做的妥协,不是生产代码应该有的样子。自己做项目时,可以把 helper_cuda.h 里的宏、findCudaDevice 的思路、StopWatch 的计时方案抽出来封装成自己的工具模块,然后该怎么分层就怎么分层。
另外,Samples 对现代 C++ 的利用非常保守,基本停留在偏 C 的风格。这可以理解,因为要兼容各种编译器和老代码。你写新项目时,完全可以用更现代的容器、RAII 管理 CUDA 资源,不必刻意模仿它的 C 风格。评价一套示例代码好不好,不是看它能不能直接拿到生产环境,而是看它有没有把关键的模式讲清楚——从这个角度讲,Samples 的表现是优秀的。
5. 实战操作:把Samples变成GPU开发脚手架
5.1 获取源码、构建13.3的完整命令
第一步是拿到正确版本的源码。如果你安装了 CUDA Toolkit 13.3,样例默认在类似 /usr/local/cuda-13.3/samples 的目录里,但那个目录通常是只读的,而且没有源码仓库的 git 历史。我更推荐直接克隆 GitHub 仓库,切到 13.3 的 tag,这样以后更新版本还能对比 diff。
git clone https://github.com/NVIDIA/cuda-samples.git cd cuda-samples git checkout v13.3 mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc)CMake 配置时如果报了编译工具不兼容,第一反应不是换工具链,而是去 CUDA Toolkit 的 release notes 里查支持的主机编译器版本。GCC 太新或者太旧都会让 nvcc 罢工,这是我在 Linux 上踩过最多的坑,没有之一。
5.2 deviceQuery和bandwidthTest:环境体检两件套
构建完成之后,可执行文件会分布在 bin 目录里。我拿到一台新 GPU 机器时,习惯先跑两个样例:deviceQuery 和 bandwidthTest。
deviceQuery 会输出 GPU 名称、驱动版本、CUDA 版本、计算能力、显存大小、可以使用的 block 最大数量等信息。它既是诊断工具,也是了解硬件参数的最快入口。比如你发现某个 kernel 启动失败,可以先看 deviceQuery 里显示的计算能力,再对比 kernel 编译时指定的 arch 是否匹配。像 Jetson 这类嵌入式平台,拿到手第一件事也是跑 deviceQuery,确认驱动和工具链状态。
bandwidthTest 则负责测量 Host 到 Device、Device 到 Host、Device 到 Device 三向的拷贝带宽。我建议记录下这台机器的基线数字:PCIe Gen3 大概能达到 10~13GB/s,Gen4 能到 20~28GB/s,A100/H100 这类 HBM 设备内部带宽已经超过 1.5TB/s。有了这些基线,后面优化任何 kernel 都知道天花板在哪。
5.3 从修改vectorAdd到写出自己的测量代码
环境没问题之后,我通常拿 vectorAdd 当模板,改造成自己的性能测量脚手架。做法很简单:把 N 从 1<<20 改到 1<<26,循环跑几次,统计时间,导出 txt 或者 csv。虽然逻辑简单,但它验证了从数据初始化、拷贝、kernel 执行到结果回传的完整链路,再往里面加自己的算法 kernel,就非常顺了。
还有一个进阶技巧:把 blocks 和 threads 换成通过参数输入,甚至用 cudaOccupancyMaxPotentialBlockSize 来根据当前 kernel 的寄存器占用自动计算最优块大小。这是从 deviceQuery 到真实调优的中间过渡,Samples 里有一个专门的 occupancy 样例演示了怎么从 kernel 函数指针推导出合适的 block 尺寸。你把这个逻辑吃透,基本就具备了自己做 kernel 调优的基础。
5.4 常见环境坑:驱动、WSL、PyTorch共存时的排查顺序
最后列几个我实际遇到的坑,大家可以直接对照排查。这些坑看起来分散,核心其实是一条排查顺序:先驱动,再 Toolkit,再应用层。Samples 里的 deviceQuery 就是这条链路中间的验证关口,它通过了,PyTorch 大概率也能通。
| 症状 | 原因 | 处理方式 |
|---|---|---|
| nvcc: command not found | CUDA Toolkit 未安装或 PATH 没配 | 检查 /usr/local/cuda/bin,添加 export PATH |
| deviceQuery 报错 cudaGetDeviceProperties failed | 驱动和 Toolkit 版本不匹配 | 先卸载旧驱动,再重装对应版本驱动 |
| WSL 里 CUDA 不可用 | WSL 需要 Windows 侧驱动和 Linux 侧 Toolkit | 在 Windows 装 NVIDIA 驱动,在 WSL 装 Toolkit |
| PyTorch 报 CUDA 不可用 | 驱动、CUDA、PyTorch 兼容矩阵不满足 | 先跑 deviceQuery 确认环境,再装匹配的 PyTorch |
| 编译报错 GCC 版本不支持 | nvcc 对 GCC 版本敏感 | 查看 release notes,换用受支持的 GCC |
| 显存不足 | 没先查可用显存 | 用 cudaGetMemInfo 或 nvidia-smi 检查显存占用 |
我自己在几个实际项目里试下来,最顺手的组合就是 deviceQuery 确认硬件、bandwidthTest 测基线、vectorAdd 改造成测量脚手架,再用 Nsight Compute 去看 kernel 的占用和带宽。这套流程比起闭门造车式的学习高效太多。建议你也把这三个样例放到自己的常用工具里,先让环境说话,再让代码跑起来。