CMake中CUDAToolkit替代FindCUDA的实践指南 如果你现在翻到一份旧教程里面还写着find_package(CUDA)加include_directories(${CUDA_INCLUDE_DIRS})那基本可以判断它写于 CUDA 11 之前。这套老配方在当年是标配但如今官方已经把它标记为弃用对新的 CUDA 版本也停止了适配。我这两年把所有新工程的 CMake 接 CUDA 部分都换成了find_package(CUDAToolkit REQUIRED)整个配置干净了不止一个档次。这篇不是翻译官方文档而是把我实际跑通的完整范例、关键变量和 target 整理出来也包括从“找不到包”到“架构报错”的一系列排查过程。适合刚想把 CUDA 接进 CMake 工程的人也适合想把老工程从FindCUDA迁移过来的人。1. 为什么新工程要换成 CUDAToolkit 这套查找逻辑1.1 老方案 FindCUDA 的软肋find_package(CUDA)对应的是一套很老的 CMake 模块名字叫 FindCUDA。它在很多年前的 CMake 版本里是唯一选择问题也积累了不少。最让人头疼的是它把各种路径全部平铺成普通变量用的时候必须自己拼装。比如要在目标里加上 CUDA 头文件目录和库目录通常得写include_directories(${CUDA_INCLUDE_DIRS}) target_link_libraries(my_app ${CUDA_LIBRARIES})看起来还可以但实际工程一复杂就出问题头文件目录是全局的不是跟着某个 target 走的CUDA_LIBRARIES只是一个大杂烩变量你根本不知道里面具体拼了哪些库如果想只链接 CUDA runtime 而不要其它库还得去翻CUDA_cudart_LIBRARY这种更细的变量。更麻烦的是不同版本 CUDA 的动态库名字还会变化手动拼路径很容易踩雷。而且这个模块从 CMake 3.10 左右开始就被官方标记为弃用对 CUDA 11 之后的版本基本停止更新。你如果还在用它遇到诡异问题只能自己扛。1.2 新方案的核心设计find_package(CUDAToolkit)不是对老模块的修补而是 CMake 团队重新写的一套查找逻辑设计思路完全换了。它不再把结果散成一堆路径变量而是用 imported target 来表达一切。你要链接 CUDA runtime就写target_link_libraries(my_app PRIVATE CUDA::cudart)要链接驱动 API就写CUDA::cuda_driver。include 目录、库依赖、甚至部分系统库都会被 target 自动带过去。另外一个让我很欣赏的设计是它把“查找 CUDA toolkit”和“启用 CUDA 语言”解耦了。老方案找完 CUDA 后你还得让 CMake 认识.cu文件新方案即使你的project()里没写 CUDA 语言也能先find_package(CUDAToolkit)拿到 toolkit 信息之后需要编译.cu文件时再启用 CUDA 语言也不迟。简单对比一下对比项find_package(CUDA) 老模块find_package(CUDAToolkit)官方维护状态已弃用基本停止更新正常维护使用方式平铺变量手动拼 include 和库imported target一行链接版本变量CUDA_VERSION_STRING 等CUDAToolkit_VERSION 等include 传递需要手动 include_directoriestarget 自动传递多组件扩展变量命名不统一COMPONENTS 显式声明最低 CMake 版本任意旧版3.17 起正式可用新工程我建议无脑用新方案。老工程迁移也不难后面我会专门写迁移时要注意的坑。2. find_package 之后真正到手的东西变量、目标与隐藏的坑2.1 版本信息变量怎么用调用一次find_package(CUDAToolkit REQUIRED)之后CMake 会给你注入一组版本变量其中最常用的是CUDAToolkit_VERSION完整版本号字符串类似12.3.107CUDAToolkit_VERSION_MAJOR主版本号比如 12CUDAToolkit_VERSION_MINOR次版本号CUDAToolkit_VERSION_PATCH补丁号CUDAToolkit_NVCC_EXECUTABLEnvcc 的完整路径实际项目里我经常在 configure 阶段直接打印出来确认机器上找到的是不是自己预期的那套 CUDAfind_package(CUDAToolkit REQUIRED) message(STATUS Found CUDA ${CUDAToolkit_VERSION}) message(STATUS nvcc path: ${CUDAToolkit_NVCC_EXECUTABLE})也可以在 CMake 里做版本判断比如某些代码只在特定版本之后才需要不同的架构参数if(CUDAToolkit_VERSION VERSION_GREATER_EQUAL 11.8) set(MY_EXTRA_CUDA_FLAGS -lineinfo) endif()2.2 最常用的几个 CUDA:: 目标这是新方案里最值得掌握的部分。常用的 imported target 可以列成一张表Target 名称作用常见使用场景CUDA::cudartCUDA runtime 动态库几乎所有编写 kernel 的程序CUDA::cudart_staticCUDA runtime 静态库部署环境不可控想少带动态库CUDA::cuda_driver驱动 APIlibcuda直接调 cuInit、cuCtxCreate 等底层接口CUDA::nvccnvcc 可执行文件自定义编译命令、工具脚本CUDA::curand随机数库蒙特卡洛、随机采样CUDA::cufftFFT 库信号处理、频谱分析CUDA::cublas线性代数库矩阵乘、BLAS 操作CUDA::cusolver求解器库矩阵分解、线性方程组求解CUDA::cusparse稀疏矩阵库大规模稀疏计算需要注意CUDA::cudart_static和CUDA::cudart不是同一个目标。想把运行时改成静态链接可以直接链接静态目标也可以设置CUDAToolkit_USE_STATIC_CUDA_RUNTIME变量让默认的CUDA::cudart指向静态版本。不要再用${CUDA_LIBRARIES}这种变量去链接。target 的价值在于它把 include 路径、依赖关系、甚至部分系统库都封装好了什么时候该带什么CMake 会自动处理。2.3 目录变量与运行时库的 PATH 问题除了上面这些 targetCUDAToolkit 模块也提供几个目录变量用来解决“编译过了但运行时找不到库”的问题CUDAToolkit_INCLUDE_DIRSCUDA 头文件目录CUDAToolkit_LIBRARY_DIRCUDA 库文件目录CUDAToolkit_BIN_DIRCUDA 可执行文件和动态库所在目录Windows 上尤其关键CUDAToolkit_TARGET_DIR设备相关库所在的平台子目录这些变量平时不一定要用但排查运行时问题时会很有用。比如 Windows 下编译好的程序一运行就报cudart64_12.dll not found就是因为没把CUDAToolkit_BIN_DIR加进PATH。Linux 下则是要确保LD_LIBRARY_PATH里包含CUDAToolkit_LIBRARY_DIR。这个坑我在后面专门用一节来讲。3. 可以直接抄作业的完整 CMakeLists 范例3.1 最小工程结构先看一个最基础的 CUDA 工程结构demo/ CMakeLists.txt kernels.cuh kernels.cu main.cpp其中kernels.cu放一个极简的 vector add kernelmain.cpp负责申请显存、调用 kernel、把结果拷回来。这样的结构能让你把注意力集中在 CMake 配置上而不被 CUDA 代码本身干扰。完整 CMakeLists.txt 如下cmake_minimum_required(VERSION 3.22) project(cudatoolkit_demo LANGUAGES CXX CUDA) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CUDA_STANDARD 17) set(CMAKE_CUDA_STANDARD_REQUIRED ON) set(CMAKE_CUDA_ARCHITECTURES native) find_package(CUDAToolkit REQUIRED) message(STATUS CUDA version: ${CUDAToolkit_VERSION}) add_library(gpu_kernels STATIC kernels.cu ) target_link_libraries(gpu_kernels PUBLIC CUDA::cudart ) add_executable(demo main.cpp ) target_link_libraries(demo PRIVATE gpu_kernels )逐行解释为什么这样写。project()里的LANGUAGES CXX CUDA是让 CMake 知道这个工程既用 C 又用 CUDA它才会去探测 nvcc 并注册.cu文件的编译规则。如果你漏了CUDA后面add_library里放.cu文件时会直接报错说无法识别源文件类型。CMAKE_CUDA_ARCHITECTURES这一行很关键。它指定要为哪些 GPU 架构编译 cubin。native是让 CMake 自动探测当前机器的 GPU开发机上用很方便但发布时要改成固定的架构列表后面我会专门说。find_package(CUDAToolkit REQUIRED)中的REQUIRED表示如果找不到就直接报错不要等到编译阶段才暴露。实际调试时想临时看看路径能不能找到可以临时改成QUIET。gpu_kernels是一个静态库里面是所有.cu文件。把 CUDA 代码单独拆成库而不是直接塞进可执行文件是管理大型工程的好习惯。这样main.cpp所在的 target 完全不需要关心 CUDA 是怎么编译的只需要链接gpu_kernels即可CUDA 相关的头文件路径和 library 依赖都被传递过去了。3.2 配套的三个代码文件kernels.cuh内容#pragma once void launch_add(const float* a, const float* b, float* c, int n);kernels.cu内容#include kernels.cuh __global__ void add_kernel(const float* a, const float* b, float* c, int n) { int idx blockIdx.x * blockDim.x threadIdx.x; if (idx n) { c[idx] a[idx] b[idx]; } } void launch_add(const float* a, const float* b, float* c, int n) { int block 256; int grid (n block - 1) / block; add_kernelgrid, block(a, b, c, n); }main.cpp内容#include kernels.cuh #include cuda_runtime.h #include cstdio #include cstdlib #include vector #define CHECK_CUDA(call) \ do { \ cudaError_t err (call); \ if (err ! cudaSuccess) { \ std::fprintf(stderr, CUDA error %d: %s\n, err, \ cudaGetErrorString(err)); \ std::exit(1); \ } \ } while (0) int main() { const int n 1 20; std::vectorfloat h_a(n, 1.0f); std::vectorfloat h_b(n, 2.0f); std::vectorfloat h_c(n, 0.0f); float* d_a nullptr; float* d_b nullptr; float* d_c nullptr; CHECK_CUDA(cudaMalloc(d_a, n * sizeof(float))); CHECK_CUDA(cudaMalloc(d_b, n * sizeof(float))); CHECK_CUDA(cudaMalloc(d_c, n * sizeof(float))); CHECK_CUDA(cudaMemcpy(d_a, h_a.data(), n * sizeof(float), cudaMemcpyHostToDevice)); CHECK_CUDA(cudaMemcpy(d_b, h_b.data(), n * sizeof(float), cudaMemcpyHostToDevice)); launch_add(d_a, d_b, d_c, n); CHECK_CUDA(cudaMemcpy(h_c.data(), d_c, n * sizeof(float), cudaMemcpyDeviceToHost)); float max_err 0.0f; for (int i 0; i n; i) { float expected h_a[i] h_b[i]; float err h_c[i] - expected; if (err 0.0f) err -err; if (err max_err) max_err err; } std::printf(max error: %g\n, max_err); CHECK_CUDA(cudaFree(d_a)); CHECK_CUDA(cudaFree(d_b)); CHECK_CUDA(cudaFree(d_c)); return 0; }这个例子很简单但足够验证整套 CMake 配置是否生效。如果你把max error编译运行后看到接近 0 的数字说明find_package、target 链接、.cu编译、runtime 加载全链路都是通的。3.3 构建与首次运行命令Linux 或 macOS 环境cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build --parallel ./build/demoWindows 环境默认 Visual Studio 生成器cmake -S . -B build cmake --build build --config Release .\build\Release\demo.exe如果一切顺利你应该能看到类似这样的输出-- Found CUDA 12.3.107 max error: 0如果这一步就报错了直接跳到后面第 5 章看排查链路。4. 多个 CUDA 组件一起用的正确姿势4.1 用 COMPONENTS 显式声明需要的库基础工程只依赖 CUDA runtimefind_package(CUDAToolkit REQUIRED)不写组件也能工作。但一旦需要 cuFFT、cuRAND、cuBLAS 这些库就要显式告诉模块去搜索对应组件。写法是find_package(CUDAToolkit REQUIRED COMPONENTS CUDART curand cufft)CUDART表示 CUDA runtimecurand是随机数库cufft是 FFT 库。组件名如果写错configure 阶段会报错而且 CMake 通常会提示可用的组件名按提示修正即可。拉取组件之后链接方式和基础库一模一样target_link_libraries(gpu_kernels PUBLIC CUDA::cudart CUDA::curand CUDA::cufft )有人可能会问为什么不手动指定${CUDA_cufft_LIBRARY}之类的变量。答案是没必要而且容易错。不同版本 CUDA 库名不同不同平台下静态库和动态库的后缀也不同target 会帮你屏蔽这些差异。比如在 Linux 上你可能需要-lcufft在 Windows 上可能是cufft.lib这些细节 imported target 都处理好了。4.2 组件越多越要把依赖集中在一个库上工程组件一多很容易出现每个可执行文件都链接一大堆CUDA::*的情况。我的习惯是把所有 CUDA 依赖收敛到一个中间层库上比如上面范例里的gpu_kernels业务代码只链接这一个。原因很简单如果gpu_kernels的target_link_libraries用了PUBLIC下游 target 链接gpu_kernels时CUDA include 路径和所有组件依赖会自动传递。以后加新组件只需要改gpu_kernels这一个地方。如果每个 target 都自己链一遍升级 CUDA 或者切换静态/动态运行时的时候改动量会爆炸。4.3 静态链接时的额外系统依赖当你决定用CUDA::cudart_static时事情会稍微复杂一点。静态 CUDA runtime 在最终可执行文件里还会引入一些系统库依赖尤其是pthread和rt。用 imported target 的好处是 CMake 通常会自动把这些系统库作为接口依赖加上去但你仍然需要留意如果最终可执行文件不是通过 CMake 的target_link_libraries链接你的库而是手动用 g 命令去拼那么-lpthread -ldl -lrt这些参数得自己补。我在实际工程里就见过有人把静态库拷给别人结果对方链接时缺了 pthread报了一堆诡异错误。5. 高频坑位从找不到包到架构报错5.1 CMake 说找不到 CUDAToolkit 的完整排查链路遇到 CMake 找不到 CUDA 的情况我的排查顺序是这样的。先在终端里直接执行nvcc --version如果这一步就提示找不到命令说明 toolkit 根本不在 PATH 里。那就先确认 toolkit 装在哪然后显式告诉 CMakecmake -S . -B build -DCUDAToolkit_ROOT/usr/local/cudaWindows 上通常用cmake -S . -B build -DCUDAToolkit_ROOTC:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.3如果nvcc --version正常但 CMake 还是找不到可以检查一下CUDAToolkit_ROOT这个 CMake 变量是否设置成了别的路径或者机器上装了多个版本的 CUDACMake 探测到了旧的那套。这里有个特别容易踩的坑find_package 的结果会被缓存。你改了系统环境变量或者CUDAToolkit_ROOT如果不删CMakeCache.txt里的相关缓存项重新 configure 时 CMake 可能仍用旧的探测结果。最省事的办法是删掉整个 build 目录重新来rm -rf build cmake -S . -B build我确实遇到过一台开发机上/usr/local/cuda和发行版自带的 CUDA 路径同时存在CMake 探测到了系统路径里的旧版本导致CUDAToolkit_VERSION和 nvcc 版本对不上最后就是显式指定CUDAToolkit_ROOT加删缓存解决的。5.2 CUDA 架构设置导致的报错先说最典型的症状。如果你完全不设置CMAKE_CUDA_ARCHITECTURES较新版本的 CMake 会触发一个 policy 行为让 nvcc 按默认架构列表编译。问题是 nvcc 的默认列表往往包含很多老架构编译会非常慢生成的可执行文件体积也大得离谱。另一种症状是编译时报错nvcc fatal : Unsupported gpu architecture compute_90这通常是 CUDA 版本太旧不支持你写的架构数字。比如拿 CUDA 11 去为 90 架构编译就会出这个错。还有一种情况是你把架构数字写错了格式比如写成9.0而不是90。还有一种更隐蔽的坑开发机上用native编译通过程序放到另一台显卡较旧的机器上跑结果 kernel 无法加载报类似no kernel image is available。这是因为native只针对当前机器生成了最高效的 cubin没包含目标机器的架构。所以我对发布版本的建议是不要用native而是固定一组兼容架构set(CMAKE_CUDA_ARCHITECTURES 80;86;89;90)这样生成的可执行文件能覆盖这几代主流 GPU。开发阶段图方便用native没问题但要清楚它的局限性。5.3 编译通过但运行时找不到库这是另一个非常常见的坑而且特别容易让人困惑明明配置正常编译也成功了结果一运行就报错。Linux 上常见的是error while loading shared libraries: libcudart.so.12: cannot open shared object file解决方式是让动态链接器能找到 CUDA 的库目录export LD_LIBRARY_PATH/usr/local/cuda/lib64:${LD_LIBRARY_PATH}Windows 上类似报错一般是没有cudart64_12.dll。解决方式是把CUDAToolkit_BIN_DIR加进环境变量 PATH开发阶段也可以用 CMake 的 post-build 步骤把 DLL 拷贝到可执行文件旁边。我自己的经验是编译阶段完全不涉及运行时库的查找所以这类问题通常要等程序启动才暴露。排查时先用ldd看一下依赖很简单ldd ./build/demo | grep cudart看到libcudart.so.12 not found就知道是路径问题不是 CMake 配置问题。5.4 host 编译器版本导致的诡异报错nvcc 在编译主机侧代码时依赖一个 host 编译器。不同 CUDA 版本对 host 编译器版本有明确的上限要求比如新版 CUDA 可能要求 GCC 不超过 13。我遇到过在很新的 Linux 发行版上系统自带 GCC 版本太高nvcc 直接报unsupported GNU version。这种情况下有两条路一是指定一个兼容的 host 编译器cmake -S . -B build -DCMAKE_CUDA_HOST_COMPILER/usr/bin/gcc-12二是在 CMakeLists 里加一行set(CMAKE_CUDA_HOST_COMPILER /usr/bin/gcc-12)注意CMAKE_CUDA_HOST_COMPILER和普通的CMAKE_CXX_COMPILER是不同的概念。前者是 nvcc 用来编译主机侧代码用的工具链后者是 CMake 编译.cpp文件用的工具链。理想情况下应该保持一致否则可能出现 ABI 不一致之类的隐患。6. 进阶配置静态运行时、自定义路径与工程拆分6.1 静态运行时与单文件分发如果你希望最终交付的可执行文件不依赖目标机器上的 CUDA runtime可以考虑静态链接 CUDA runtime。在find_package之前设置一个变量即可set(CUDAToolkit_USE_STATIC_CUDA_RUNTIME ON) find_package(CUDAToolkit REQUIRED)设了这个变量之后链接CUDA::cudart实际会指向静态运行时。优点是部署简单只要目标机有显卡驱动就行。缺点是最终可执行文件体积变大而且静态链接可能引入额外的系统依赖。对于需要在不同机器上跑的工具型程序我会偏向静态运行时对于大型应用一般还是用动态库方便单独升级 CUDA 组件。6.2 自定义安装路径时的最佳实践非标准路径安装 CUDA 时不要只改 PATH 环境变量最好显式传给 CMakecmake -S . -B build -DCUDAToolkit_ROOT/opt/cuda-12.3Windows 上如果是从官方安装包装的 CUDA一般会自动设置环境变量CMake 能探测到。但如果你的开发环境有多个 CUDA 版本来回切换建议在 CMakePresets.json 里固定好CUDAToolkit_ROOT省得每次都要敲命令行。这里有一个我自己常用的检查方法configure 完成后用cmake -LA查看缓存变量确认 CUDAToolkit 相关的路径是否指向你预期的那套。6.3 迁移老工程时的三个建议如果你的老工程还在用find_package(CUDA)迁移到新方案时建议按顺序做这三件事。第一把所有${CUDA_INCLUDE_DIRS}和${CUDA_LIBRARIES}相关的写法治掉全部换成 target 链接。这一步做完很多全局 include 造成的隐藏冲突会自动消失。第二确认project()或enable_language()里正确启用了 CUDA 语言。老模块有时即使没有显式启用 CUDA 语言也能编译.cu文件新方案更规范要求 CMake 先知道自己要处理 CUDA 源文件。第三设置CMAKE_CUDA_ARCHITECTURES。这是迁移时最容易忽略的一步因为老模块可能一直在用 nvcc 默认架构迁移后突然发现编译慢、体积大多半就是没主动设置架构列表。我的习惯是在工程根目录的 CMakePresets.json 里统一管理这些配置{ version: 6, configurePresets: [ { name: linux-release, displayName: Linux Release, generator: Unix Makefiles, binaryDir: ${sourceDir}/build, cacheVariables: { CMAKE_BUILD_TYPE: Release, CMAKE_CUDA_ARCHITECTURES: 80;86;89;90, CMAKE_CUDA_HOST_COMPILER: /usr/bin/gcc-12 } } ] }把架构和编译器版本写进 preset 后团队里每个人第一次配置都会用同一套参数很多环境差异问题直接消失。我迁移过的一个老工程改完这三个地方之后configure 错误直接少了一大半编译产物体积也明显降下来了。