简介:《CUDA BY EXAMPLE》(GPU高性能编程CUDA实战)一书的配套示例源码全部收录,面向正在啃书本、想动手实践CUDA并行编程的开发者,也适合需要快速查阅经典例程的GPU计算工程师。这份压缩包共57个文件,以36个.cu源文件为核心,另有9个.h头文件、4个.lib与4个.dll运行库、2个txt说明及docx/rtf文档,包体仅443KB,十分轻量。代码按章节组织,资源目录从chapter03到chapter11清晰划分,从入门到进阶覆盖了CUDA线程块与网格建模、全局/共享/常量/纹理内存的分配访问、原子操作与__syncthreads同步、错误处理、双流异步复制、多设备协作等关键主题;具体实例包括Julia集渲染、光线追踪、热传导模拟、直方图统计、CPU/GPU计时对比等,并涉及共享内存bank冲突和性能分析等优化细节。所有例程均为可编译的CUDA C/C++源码,配合书中讲解能直观理解GPU并行执行过程。已有415人学习下载,适合希望结合源码深入理解CUDA执行模型、内存层级及性能优化思路的入门与进阶读者。
1. 这本书的示例源码,解决的是“看得懂但跑不起来”这一关
很多想入门 GPU 编程的人卡在同一个地方:概念书读得下去,一到键盘前就不知道怎么整。实际上读 CUDA 入门书最绕不开的就是这本《CUDA BY EXAMPLE》,中文版叫《GPU高性能编程CUDA实战》。它的价值不全在文字,而在于那些能让你直接改、直接撞、直接翻车的例子源码——把线程块、共享内存、原子操作这些抽象名词变成一行行看得见结果的代码。这套源码值得每一位打算做 GPU 开发的人从头到尾跑一遍,而不是只翻目录。
那它到底适合谁?适合两类人:零基础想做 GPU 并行计算的同学,能把 C 写得顺但没碰过 CUDA;以及在公司里做计算加速验证、想快速找一个覆盖完整、结构清晰的代码样本库做参照的工程师。书里的例子从最朴素的向量相加到纹理内存、流并发都覆盖了,跑完一遍等于把 CUDA C 的主要 API 都摸了一遍。接下来这几章,我把这套源码的读法、跑法和改法从头拆开讲。
2. 示例源码的三种固定骨架:从内存分配、核函数启动到错误检查
2.1 主机端与设备端的分工:为什么每个示例都在重复 cudaMalloc 和 cudaMemcpy
打开源码包里的任何一个示例,最先看到的固定段落一定是三件套:用cudaMalloc给设备显存分配空间,用普通malloc给主机端分配空间,再用cudaMemcpy把数据搬到显存。这个结构几乎贯穿所有示例,原因很简单:GPU 无法直接访问主机内存,CPU 也无法直接读写显存,二者之间只有一条总线,所有数据流动都必须显式发起。
float *h_in, *d_in; int size = N * sizeof(float); // 主机端分配,数据先住进 CPU 内存 h_in = (float *)malloc(size); // 设备端分配,这才是 GPU 能读写的显存 cudaMalloc((void **)&d_in, size); // 把数据从主机搬到设备,方向不能写反 cudaMemcpy(d_in, h_in, size, cudaMemcpyHostToDevice); // 计算结束后再搬回来 cudaMemcpy(h_out, d_out, size, cudaMemcpyDeviceToHost); cudaFree(d_in); free(h_in);这段代码里最容易犯的错是把两个指针搞混:cudaMalloc传入的是void **,必须对d_in取地址;第三个参数是字节数不是元素个数,写成N而不是N * sizeof(float)是最常见的翻车点。搬回来那个方向尤其容易写反,写反后程序不一定报错,结果是全零或旧数据,属于最难排查的一类问题。
我在实际项目里验证内核正确性的标准动作是:先故意不搬回结果,看一眼输出里有没有随机垃圾,再搬回来对比,两轮对比能同时证明“内核执行了”和“拷贝方向正确”。这个习惯在看这份源码时也会用到——理解每个示例的输入输出方向,比记住函数名重要得多。
2.2 核函数启动:不是函数调用,而是一条异步命令
很多新手第一次看到vecAdd<<<gridDim, blockDim>>>(d_a, d_b, d_c, N)会以为这是带语法糖的函数调用。实际上这行代码干了两件事:把内核函数打包成一条命令投入 GPU 执行队列,然后函数立即返回,CPU 接着跑后面的代码。这个“异步”属性是后面许多奇怪行为的根源,也是示例里到处出现同步点和显式拷贝的原因。
// 一个最简单的向量加法内核,注意 __global__ 关键字 __global__ void vecAdd(float *a, float *b, float *c, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) { // 边界检查,N 不是 blockDim 整数倍时靠它兜底 c[i] = a[i] + b[i]; } }启动命令里<<<128, 256>>>前面是网格维度、后面是线程块维度,这两个数直接决定 GPU 上并发线程数量。函数体里的blockIdx.x和threadIdx.x是硬件自动填充的内建变量,GPU 为每个线程生成一组。这段代码值得注意的地方是边界检查:线程总数往往大于数据长度,不写这个if,超出的线程会访问越界内存,轻则读到垃圾值,重则触发invalid argument或直接崩溃。
想直观看到异步特征很简单:把内核启动后紧接着的cudaMemcpy前的同步去掉,再观察结果,大概率读回的是未定义数据。书里很多示例都在拷贝前做了隐式同步——cudaMemcpy本身会等 GPU 执行完,这是 CUDA 系统里最值得记住的一条“隐式时序规则”。
2.3 每个示例都有的错误检查宏:别删,但也别全信
翻看这套源码时,你会发现几乎每个.cu文件里都有同一个怪东西:一个用宏包裹的错误检查代码。它的经典形态长这样:
#define CHECK_CUDA_ERROR(call) { \ cudaError_t err = (call); \ if (err != cudaSuccess) { \ fprintf(stderr, "CUDA error at %s:%d: %s\n", \ __FILE__, __LINE__, \ cudaGetErrorString(err)); \ exit(EXIT_FAILURE); \ } \ }这段宏是入门阶段被删除率最高的代码——很多人觉得每行都检查错误会让程序显得冗长,剪掉后“看着干净”。但实际上 GPU 的错误是异步的:一个内核在启动时出问题,错误往往要等下一个同步点才会反馈到 CPU。没有这个宏,程序可能在几百行之后突然崩溃,报错位置和真正出错的地方隔了十万八千里。
但也不要盲目迷信它。我踩过的坑是:这个宏对同步调用有效,但在内核启动后立刻查错误函数返回的值,拿到的基本是cudaSuccess——因为执行还没推进到出错点。真正可靠的检查点是cudaDeviceSynchronize()或执行完cudaMemcpy之后。读示例源码时,留意它在哪个位置做检查,比照抄宏本身更有价值。
3. 把全部示例跑通:从目录整理、编译参数到输出判读
3.1 先把源码整理成一个能批量编译的工程骨架
不同渠道拿到的源码包结构差异很大,有的按章节分目录,有的把所有.cu文件平铺一层。我建议动手前先统一成“一章一个子目录”的形态,并写一个总控 Makefile。原因很实际:逐条敲nvcc命令容易漏掉头文件路径,统一工程后每次编译只是make一个动作,排查报错也集中。
# Makefile:把 examples 目录下所有子目录里的 .cu 逐个编成可执行文件 CUDA_ROOT := /usr/local/cuda NVCC := $(CUDA_ROOT)/bin/nvcc NVCCFLAGS := -O2 -arch=sm_75 SRCS := $(shell find . -name '*.cu') OBJS := $(SRCS:.cu=) all: $(OBJS) %: %.cu $(NVCC) $(NVCCFLAGS) -o $@ $< clean: find . -type f -perm -111 -delete这个 Makefile 的思路是让所有示例共享同一套编译参数,省去在几十个目录间来回配置。其中的-arch=sm_75是本机显卡的算力,不是随便拍的——它可以通过后面给的设备查询命令得到。需要注意:$(shell find ...)要求文件路径里没有空格,下载的源码包如果解压后带着中文路径名或空格,建议先改成纯英文路径再编译。Windows 下可以用 make 的 Windows 移植版配合 cmd,步骤不变;路径里有空格时记得给 nvcc 传参用引号。
3.2 单文件编译命令:把架构参数选对是第一步
如果暂时不想搭工程,只想先把书里某个例子跑起来,最稳妥的是直接在示例目录里用nvcc编译单个文件。命令模式如下:
cd chapter03 nvcc -O2 -arch=sm_75 -o vecAdd vecAdd.cu ./vecAdd-arch=sm_75决定了编译器为哪种 GPU 架构生成机器码,指的是图灵架构的算力 7.5。如果显卡是 Ampere 架构(如 30 系列),换成-arch=sm_86;如果是更新的卡,需要查对应算力表。不知道显卡算力时,最直接的办法是用下面的代码去查询:
#include <cstdio> #include <cuda_runtime.h> int main() { cudaDeviceProp prop; cudaGetDeviceProperties(&prop, 0); // 0 表示第一块卡 printf("Compute capability: %d.%d\n", prop.major, prop.minor); return 0; }把这段存成device_query.cu,然后nvcc -o device_query device_query.cu && ./device_query,输出数字直接拼成-arch=sm_XY就行。这个过程看似多此一举,但值得做:很多老示例默认带的架构参数早就失效了,直接跑会报 “invalid device function” 或找不到合适的二进制,给排查增加无谓负担。
如果 nvcc 没被加入 PATH,Linux 下通常在/usr/local/cuda/bin/nvcc,Windows 下在类似 CUDA 安装目录的 bin 子目录里。命令行找不到 nvcc 时先检查这个路径,而不是急着重装。装完 CUDA 后第一件事跑一遍nvcc --version,输出正常再谈编译示例。
3.3 输出判读:什么结果才算真的“跑通了”
书里的示例大多会打印一行执行时间或结果摘要,但“能打印”不等于“结果正确”。我见过最典型的现象是:程序正常退出,输出里却是 0.00ms 和一堆无意义数值。问题多半出在数据没搬回主机端,或核函数里线程坐标算错了。你可以用一份非常小的输入来验证——比如 N=4 时手算预期结果,然后打印前四个值。
./vecAdd # 期望输出类似: # Sum of 4 elements: 10.000000 # 如果打印出 0.000000,先检查 cudaMemcpy 方向判断跑通的底线是三条:可执行文件正常生成;运行过程没有报 “CUDA error”;打印结果和你手算的答案一致。至于时间指标(带宽、GFLOPS),那是优化阶段的事,不在这步验证范围内。先跑对一个点,再去对比书页里的参考输出,这是对齐环境和验证正确性的标准顺序。
4. 避坑:架构不匹配、显存不足、GLUT 缺失这些常见翻车点
这几条基本上是我把这套源码在不同机器上跑了几轮之后攒下来的血泪经验。每条按“现象、原因、解决”写,你在跑通示例的过程中遇到类似报错,直接对号入座。
4.1 报错一:运行时提示 invalid device function,或某个符号找不到
现象:编译通过,运行时一启动内核就报invalid device function,或初始化时报类似驱动函数找不到的错误。原因:你用了老的架构参数,比如-arch=sm_30,而当前驱动的运行时库只支持新架构;或者反过来,指定了比驱动版本更新的架构。解决:先用第 3 章那个设备查询程序拿实际算力,再重新编译。如果换参数后仍然报错,检查驱动和 CUDA toolkit 的主版本是否一致——常见做法是看nvidia-smi输出的 CUDA Version,toolkit 主版本不高于驱动支持版本即可。这类问题最容易出现在“别人给的源码直接拿来跑”的场合,因为那份 Makefile 里经常写死了一个老架构参数。
4.2 报错二:cudaMalloc 返回 out of memory,但显存明明是够的
现象:只分配几百 MB 显存的示例在小显存卡上跑得好好的,换到大显存卡反而报内存不足。原因:多数不是显存真不够,而是设备端分配后忘了cudaFree,连续运行几个示例把显存黑洞越积越厚;也可能是多卡机器上cudaSetDevice选了错误的卡。解决:程序结束前逐个cudaFree;如果还不行,用cudaMemGetInfo打印实际剩余显存:
size_t free_bytes, total_bytes; cudaMemGetInfo(&free_bytes, &total_bytes); printf("Free: %.2f MB, Total: %.2f MB\n", free_bytes / 1024.0 / 1024.0, total_bytes / 1024.0 / 1024.0);如果 free 数值远小于总显存,说明之前几个进程还占着卡;Linux 下用nvidia-smi看进程列表,把遗留的僵尸进程杀掉再跑。这个排查顺序能解决九成以上“显存莫名不够”的问题。
4.3 报错三:较新的 CUDA 版本与旧源码不兼容
现象:编译出一堆废弃警告,比如某个 API 被标成 deprecated,甚至直接报 “has been removed” 之类的错误。原因:书里的示例相对较早,部分 API 在后续版本中被标记废弃或移除。解决:常见做法是先看废弃警告指向哪个函数,再按错误提示用替代 API 改写;也可以用-Wno-deprecated-declarations先把告警压住,确认逻辑没变后再回来处理。我的建议是“先跑通、后清理”——不加功能,只加编译开关让示例先执行起来,再逐个替换废弃函数,这样能把环境问题和代码问题分开处理,不会被一堆告警干扰判断。
4.4 报错四:OpenGL 互操作示例找不到 gl.h / glut.h
现象:源码包里凡是涉及 OpenGL 互操作的示例,编译时总是报找不到头文件,或链接时缺库。原因:这些示例依赖 GLUT 和 OpenGL 头文件,而 GPU 工具链默认不带它们。解决:在 Debian/Ubuntu 系执行sudo apt install freeglut3-dev,在 CentOS/RHEL 系执行sudo yum install freeglut-devel,安装后重新编译。如果还报头文件缺失,检查示例里的 include 路径是否写成了相对路径,必要时把-I/usr/include/GL补进 nvcc 参数。这类示例能不能跑通,和你做不做图形开发没有关系,纯粹是依赖没补齐。
4.5 报错五:程序正常结束,但每次输出都差一点
现象:结果看起来差不多,但总有几个元素和预期不一致,且每次运行都稳定地错同一个位置。原因:不是随机错,而是线程坐标计算有边界问题。比如数据长度 1000,你开 256 线程一个块、4 个块,总共 1024 个线程,索引 1000 到 1023 就会越界。解决:确认核函数里有边界检查if (i < n),没有就补上。这是示例里最容易让人忽略的一行代码,却是在真实场景中最致命的一处漏写。记住:GPU 不会替你检查越界,它只会给你一个莫名其妙的结果。
5. 源码学完还能怎么用:加计时、改参数、做并发实验
5.1 给向量相加示例加事件计时,让它变成带宽探针
书里的向量相加例子只打印结果,看不出性能信息。给它加上 CUDA 事件计时器,它就能变成一个粗略的带宽探针:用数据量除以运行时间,估算 GPU 在“搬运数据加执行内核”这条流水线上的实际吞吐。改动只在主函数里加几行:
cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventRecord(start); vecAdd<<<blocks, threads>>>(d_a, d_b, d_c, N); cudaEventRecord(stop); cudaEventSynchronize(stop); // 必须等 GPU 跑完才能读数 float ms = 0.0f; cudaEventElapsedTime(&ms, start, stop); printf("Kernel time: %.3f ms\n", ms); cudaEventDestroy(start); cudaEventDestroy(stop);注意这里的cudaEventRecord记录的是 GPU 流水线上的时间点,不是 CPU 侧的时间戳,所以用它包内核比用clock()准得多。把 N 放大到千万量级,跑出来的毫秒数再按数据量换算,就是你这张卡在实际场景里能看到的带宽级别;多跑几次取中位数,比单次读数有意义。
5.2 用 Julia 集示例做参数扫描
Julia 集示例生成图像时用到的复常数 c,实部和虚部各换一点点,输出图像就完全变样。常见做法是把 c 改成命令行可传的变量,然后循环跑:
./julia -c -0.8 0.156 -o fractal_01.ppm ./julia -c 0.285 0.010 -o fractal_02.ppm用不同参数批量生成多幅 PPM 图像,能直观看出“改一个标量参数如何改变并行程序的输出”,也能顺带感受浮点边界在迭代计算里的影响。如果想让实验更有数据味,可以把每次迭代的逃逸次数写进像素灰度,再写个小脚本统计平均迭代次数随 c 的变化。
把示例从“照着敲”提升到“能改、能出实验数据”的程度,这套源码的价值才算真正落地。按我带人跑这份源码的经验,最该记住的教训就一条:CUDA 里的怪现象九成不是语法问题,而是内存和同步问题;改完代码见到奇怪结果,先查cudaMemcpy方向、边界检查和原子操作这三样,能省掉大半的折腾时间。希望这些能帮到你。
本文还有配套的精品资源,点击获取