CUDA框架
好的,我们来详细探讨一下 CUDA 的基本框架、Runtime API 的实现原理,并通过与 Driver API 的对比示例来加深理解。
一、CUDA 基本框架
CUDA 的核心思想是提供一个异构计算模型,允许程序在 CPU(主机)和 GPU(设备)上协同执行。其基本框架包含以下几个关键部分:
主机 (Host): 运行在 CPU 上的程序部分。负责控制流程、数据准备、启动 GPU 计算任务(内核)以及获取结果。
设备 (Device): 指 NVIDIA GPU。包含大量轻量级核心(CUDA Cores),擅长并行计算。
内核 (Kernel): 在 GPU 上并行执行的函数。由主机代码启动,每个内核实例(称为一个线程)处理数据的不同部分。
线程层次结构 (Thread Hierarchy):
线程 (Thread): 最基本的执行单元。
线程块 (Thread Block): 一组线程的集合。块内的线程可以:
通过共享内存 (
__shared__
) 高效协作。使用
__syncthreads()
进行同步。被调度到同一个流式多处理器 (SM) 上执行。
网格 (Grid): 所有线程块的集合,执行同一个内核。网格中的线程块可以独立执行(通常在不同的 SM 上)。
内存模型 (Memory Model):
主机内存 (Host Memory): CPU 可访问的内存(RAM)。
设备内存 (Device Memory): GPU 板载内存(Global Memory)。访问延迟较高。
全局内存 (Global Memory): 所有线程(所有块)都可读写的设备内存。主存与显存数据传输的主要区域。
常量内存 (Constant Memory): 只读、缓存的设备内存。适合存储所有线程读取的常量。
纹理内存 (Texture Memory): 优化的只读内存,支持特定寻址模式和滤波。
共享内存 (Shared Memory): 位于 SM 上的高速、低延迟内存。由同一个线程块内的所有线程共享。
寄存器 (Registers): 每个线程私有的最快内存。用于存储局部变量。
本地内存 (Local Memory): 寄存器溢出或无法放入寄存器的变量使用的设备内存(实际在 Global Memory 上)。速度慢。
执行模型 (Execution Model):
GPU 由多个 流式多处理器 (SM) 组成。
当一个内核网格启动时,其线程块被分配到可用的 SM 上。
一个 SM 可以同时执行多个线程块。
每个 SM 将分配给它的线程块划分为更小的执行单元 线程束 (Warp)(通常是 32 个线程)。Warp 是 SM 调度和执行的基本单位。同一个 Warp 中的线程执行相同的指令(SIMT - Single Instruction, Multiple Thread)。
二、CUDA Runtime API 实现原理
CUDA Runtime API (如 cudaMalloc
, cudaMemcpy
, cudaLaunchKernel
, cudaDeviceSynchronize
) 是开发者最常用的接口。它的设计目标是提供更简洁、更高级别的抽象,隐藏底层 Driver API 的复杂性。其实现原理可以概括为:
封装 Driver API:
Runtime API 本质上是对底层 Driver API 的封装。当你调用
cudaMalloc
时,Runtime 内部最终会调用 Driver API 的cuMemAlloc
或类似函数。这种封装提供了更简洁的语法(例如,不需要显式管理上下文)。
隐式状态管理:
上下文 (Context): Runtime API 为每个主机线程自动管理一个 CUDA 上下文(称为 Primary Context)。当该线程第一次调用 Runtime API 时,Runtime 会隐式地为该线程初始化一个上下文(如果尚未存在),并将其与该线程关联。后续该线程的所有 Runtime API 调用都在这个上下文中执行。这大大简化了上下文管理。
模块 (Module): 当使用
nvcc
编译.cu
文件时,生成的代码(PTX 或 Cubin)通常被嵌入到最终的可执行文件中。Runtime 在需要时(例如第一次启动某个内核)会自动加载这些模块到当前上下文中。开发者通常不需要显式处理模块加载(除非使用动态加载)。
错误处理:
Runtime API 函数通常返回
cudaError_t
枚举值表示成功或错误类型。它维护一个线程本地的错误状态。大多数 Runtime API 调用会覆盖这个状态。
cudaGetLastError()
用于检索该状态,cudaPeekAtLastError()
则只查看不重置状态。内部实现会将 Driver API 的错误代码 (
CUresult
) 转换为对应的cudaError_t
。
流管理:
Runtime API 提供
cudaStream_t
来表示异步操作流。cudaStreamCreate
内部调用cuStreamCreate
。它管理流的生命周期和与上下文的关联。
内存管理:
cudaMalloc
,cudaFree
,cudaMemcpy
等函数封装了 Driver API 的内存分配、释放和传输操作,并处理了必要的上下文绑定。
内核启动:
<<<>>>
语法是 Runtime API 特有的语法糖。nvcc
编译器会将kernel<<<grid, block, sharedMem, stream>>>(args);
这样的调用编译成一系列 Runtime API 调用(主要是cudaLaunchKernel
)。cudaLaunchKernel
内部会:确保当前线程有有效的上下文。
确保包含目标内核函数的模块已加载到该上下文中。
通过 Driver API (
cuLaunchKernel
) 最终启动内核,传递网格/块维度、共享内存大小、流句柄以及参数列表(可能需要处理参数打包)。
同步:
cudaDeviceSynchronize()
会等待当前设备(更准确地说,是当前线程关联的上下文)上所有未完成的操作完成。内部调用cuCtxSynchronize()
。cudaStreamSynchronize(stream)
等待特定流上的操作完成。内部调用cuStreamSynchronize(stream)
。
总结 Runtime API 原理: Runtime API 通过封装 Driver API、自动管理上下文和模块加载、提供简化的错误处理以及引入 <<<>>>
语法糖,极大地降低了 CUDA 编程的入门门槛和日常开发的复杂性。它隐藏了 Driver API 的许多细节,使开发者能更专注于算法和并行逻辑本身。
三、结合 Driver API 示例理解
Driver API (cu
前缀的函数,如 cuMemAlloc
, cuMemcpyHtoD
, cuLaunchKernel
) 提供了对 CUDA 硬件更低层次、更直接的控制。它暴露了 Runtime API 所隐藏的细节(如显式的上下文、模块管理)。使用 Driver API 通常需要更多代码,但也提供了更大的灵活性(例如,精细控制多个上下文、动态加载模块)。
下面我们通过一个简单的 向量加法 示例,分别用 Runtime API 和 Driver API 实现,来对比理解:
示例:向量加法 (a + b = c)
1. CUDA Runtime API 实现 (VectorAdd_Runtime.cu)
#include <iostream>
#include <cuda_runtime.h> // 包含 Runtime API 头文件// 内核函数定义 (在设备上执行)
__global__ void vectorAdd(const float *A, const float *B, float *C, int numElements) {int i = blockDim.x * blockIdx.x + threadIdx.x;if (i < numElements) {C[i] = A[i] + B[i];}
}int main() {const int numElements = 50000;size_t size = numElements * sizeof(float);// 主机端分配内存float *h_A = new float[numElements];float *h_B = new float[numElements];float *h_C = new float[numElements];// 初始化主机数据for (int i = 0; i < numElements; ++i) {h_A[i] = rand() / (float)RAND_MAX;h_B[i] = rand() / (float)RAND_MAX;}// 设备端分配内存 (Runtime API)float *d_A = nullptr;float *d_B = nullptr;float *d_C = nullptr;cudaMalloc((void**)&d_A, size); // 1. 分配设备内存 (封装了 cuMemAlloc)cudaMalloc((void**)&d_B, size);cudaMalloc((void**)&d_C, size);// 主机到设备数据传输 (Runtime API)cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); // 2. 数据传输 (封装了 cuMemcpyHtoD)cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);// 启动内核 (Runtime API 特有的 <<<>>> 语法)int threadsPerBlock = 256;int blocksPerGrid = (numElements + threadsPerBlock - 1) / threadsPerBlock;vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, numElements); // 3. 内核启动 (编译时转换为 cudaLaunchKernel)// 同步设备 (等待内核完成)cudaDeviceSynchronize(); // 4. 同步 (封装了 cuCtxSynchronize)// 设备到主机数据传输 (Runtime API)cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost); // 5. 数据传输回主机// 验证结果 (略)// ...// 释放设备内存 (Runtime API)cudaFree(d_A); // 6. 释放设备内存 (封装了 cuMemFree)cudaFree(d_B);cudaFree(d_C);// 释放主机内存delete[] h_A;delete[] h_B;delete[] h_C;return 0;
}
2. CUDA Driver API 实现 (VectorAdd_Driver.cu)
#include <iostream>
#include <cuda.h> // 包含 Driver API 头文件// 内核代码 (通常编译成 PTX 或 Cubin 字符串或文件,这里假设已编译好)
// 实际中需要通过 nvcc 编译 kernel.cu 得到 .ptx 或 .cubin 文件,然后加载
// 这里为了简化,省略了编译和加载内核代码的具体步骤,假设我们有一个代表内核函数的指针或名称。
// 实际代码需要调用 cuModuleLoad 和 cuModuleGetFunctionint main() {const int numElements = 50000;size_t size = numElements * sizeof(float);// 1. 初始化 Driver APIcuInit(0); // 初始化 CUDA 驱动,必须在其他 Driver API 调用之前// 2. 获取设备句柄 (Device)CUdevice cuDevice;cuDeviceGet(&cuDevice, 0); // 获取设备 0// 3. 创建上下文 (Context) - Runtime API 隐式做的关键一步!CUcontext cuContext;cuCtxCreate(&cuContext, 0, cuDevice); // 显式创建上下文并与当前主机线程关联// 4. 加载模块 (Module) - 包含内核函数 - Runtime API 隐式做的关键一步!CUmodule cuModule;// 实际中需要加载编译好的 .ptx 或 .cubin 文件// cuModuleLoad(&cuModule, "vectorAdd.ptx"); // 或 cuModuleLoadData, cuModuleLoadFatBinary// 假设 cuModule 已成功加载// 5. 获取内核函数句柄 (Function)CUfunction vecAddKernel;cuModuleGetFunction(&vecAddKernel, cuModule, "vectorAdd"); // 按名称查找内核函数// 主机端分配内存 (同 Runtime)float *h_A = new float[numElements];float *h_B = new float[numElements];float *h_C = new float[numElements];// ... 初始化 h_A, h_B ...// 6. 设备端分配内存 (Driver API)CUdeviceptr d_A, d_B, d_C; // Driver API 使用 CUdeviceptr 表示设备内存地址cuMemAlloc(&d_A, size); // 直接分配设备内存cuMemAlloc(&d_B, size);cuMemAlloc(&d_C, size);// 7. 主机到设备数据传输 (Driver API)cuMemcpyHtoD(d_A, h_A, size); // 主机到设备拷贝cuMemcpyHtoD(d_B, h_B, size);// 8. 设置内核参数// Driver API 需要手动打包参数到连续的缓冲区void *kernelParams[] = { &d_A, &d_B, &d_C, &numElements };// 或者使用更推荐的新方式 (CUDA 4.0+)// CUDA_KERNEL_PARAMS 结构体 (实际使用 cuLaunchKernel 的参数)// 9. 启动内核 (Driver API)int threadsPerBlock = 256;int blocksPerGrid = (numElements + threadsPerBlock - 1) / threadsPerBlock;cuLaunchKernel(vecAddKernel, // 内核函数句柄blocksPerGrid, 1, 1, // 网格维度 (gridDim.x, gridDim.y, gridDim.z)threadsPerBlock, 1, 1, // 块维度 (blockDim.x, blockDim.y, blockDim.z)0, // 共享内存大小 (字节)NULL, // 流 (NULL 表示默认流)kernelParams, // 内核参数指针数组NULL); // 额外参数 (通常 NULL)// 或者使用 CUDA_KERNEL_PARAMS 结构体// 10. 同步上下文 (等待内核完成) - Runtime cudaDeviceSynchronize() 的底层cuCtxSynchronize(); // 等待当前上下文所有任务完成// 11. 设备到主机数据传输 (Driver API)cuMemcpyDtoH(h_C, d_C, size); // 设备到主机拷贝// 验证结果 (略)// 12. 释放设备内存 (Driver API)cuMemFree(d_A);cuMemFree(d_B);cuMemFree(d_C);// 13. 卸载模块 (Driver API)cuModuleUnload(cuModule);// 14. 销毁上下文 (Driver API) - Runtime API 在进程退出/设备重置时隐式清理cuCtxDestroy(cuContext);// 释放主机内存delete[] h_A;delete[] h_B;delete[] h_C;return 0;
}
四、关键对比与理解
初始化和上下文管理:
Runtime: 完全隐式。第一次调用 Runtime API (如
cudaMalloc
) 时自动初始化并创建 Primary Context。Driver: 必须显式调用
cuInit(0)
初始化驱动,然后显式获取设备 (cuDeviceGet
) 并创建上下文 (cuCtxCreate
)。结束时需显式销毁上下文 (cuCtxDestroy
)。这提供了对多个设备和上下文的精细控制。
模块加载:
Runtime: 隐式。
nvcc
将内核代码嵌入可执行文件,Runtime 在第一次启动内核时自动加载所需模块。Driver: 必须显式加载包含内核代码的模块 (
cuModuleLoad
/cuModuleLoadData
/cuModuleLoadFatBinary
),然后显式获取内核函数句柄 (cuModuleGetFunction
)。这允许运行时动态加载内核代码(例如,从文件或网络)。
内存管理:
Runtime: 使用
cudaMalloc
,cudaFree
,cudaMemcpy
(指定方向cudaMemcpyHostToDevice
等)。接口更简洁。Driver: 使用
cuMemAlloc
,cuMemFree
,cuMemcpyHtoD
,cuMemcpyDtoH
,cuMemcpyDtoD
等。直接操作CUdeviceptr
。功能相同,但接口更底层。
内核启动:
Runtime: 使用
<<<grid, block, smem, stream>>>(args)
语法糖。编译器将其转换为cudaLaunchKernel
调用。Driver: 显式调用
cuLaunchKernel
。需要手动设置网格/块维度、共享内存大小、流句柄,并手动打包参数(将参数指针按顺序放入一个数组或使用CUDA_KERNEL_PARAMS
结构体)。这是最显著的复杂性差异。
同步:
Runtime:
cudaDeviceSynchronize()
(同步设备/上下文) 或cudaStreamSynchronize(stream)
(同步流)。Driver:
cuCtxSynchronize()
(同步当前上下文) 或cuStreamSynchronize(hStream)
(同步指定流)。概念一致,函数名不同。
错误处理:
Runtime: 函数返回
cudaError_t
。使用cudaGetLastError()
获取最后一个错误。Driver: 函数返回
CUresult
。使用cuGetErrorName
和cuGetErrorString
获取错误名称和描述信息。错误检查通常更冗长。
五、总结
CUDA Runtime API 提供了更高级、更简洁的接口,通过封装 Driver API 并自动管理上下文、模块加载等复杂细节,极大地简化了 CUDA 编程。它是大多数 CUDA 应用程序的首选。
CUDA Driver API 提供了更低层次、更灵活的控制。它暴露了 Runtime 隐藏的细节(如显式上下文、模块管理、手动参数打包),适用于需要精细控制 CUDA 资源(如多 GPU、多上下文、动态内核加载)或开发底层库/工具的场景。使用 Driver API 通常需要编写更多的代码。
理解 Runtime API 的实现原理(即它是对 Driver API 的封装和状态管理) 有助于深入理解 CUDA 的工作机制,特别是在调试复杂问题或需要超越 Runtime 提供的功能时。Driver API 的示例清晰地展示了 Runtime API 在幕后所完成的那些“魔法”步骤(初始化、创建设备上下文、加载模块、参数打包)。