1. 课程导入与GPU计算环境搭建
欢迎进入《CuPy数组计算与GPU迁移》课程。在开始写第一行GPU代码之前,我们需要先建立三个基本认识:CuPy是什么、它和NumPy是什么关系、以及为什么值得把计算搬到GPU上。本节将围绕这三点展开,并带你完成从CUDA工具包安装到第一个CuPy数组程序跑通的完整闭环。
CuPy与NumPy:接口相似,执行引擎不同
CuPy是一个基于CUDA的数组计算库,它的API设计高度对齐NumPy。绝大多数NumPy函数在CuPy中都有同名对应,例如 np.array 对应 cp.array,np.dot 对应 cp.dot,np.linalg.solve 对应 cp.linalg.solve。这种设计让迁移成本大幅降低:很多情况下只需把 import numpy as np 换成 import cupy as cp,代码逻辑几乎不变。
但两者在底层执行机制上完全不同。NumPy数组驻留在主机内存(RAM),由CPU逐元素或调用BLAS库执行;CuPy数组驻留在GPU显存(VRAM),运算被编译为CUDA内核,在GPU的数千个流处理器上并行执行。因此,CuPy数组的创建、运算、切片都会触发主机与设备之间的数据传输或内核调度,这也是迁移时需要特别关注性能的地方。
GPU计算的优势与适用边界
GPU的优势来自大规模并行。以矩阵乘法为例,一个 4096×4096 的矩阵乘法包含约687亿次乘加运算,CPU依靠少量核心串行处理,而GPU可将计算拆分到成千上万个线程同时执行。对于元素级运算(如数组加法、激活函数)、规约运算(求和、均值)、矩阵运算和卷积等,GPU通常能带来数量级的加速。
但GPU并非万能。小规模数组(例如长度几百的向量)在GPU上运行时,内核启动和数据传输的开销可能超过计算本身,反而比NumPy慢。此外,频繁在CPU与GPU之间搬运数据会严重削弱收益。因此,合理的做法是:把大规模、计算密集、可并行的部分放在GPU上,并尽量让数据在GPU上连续处理,减少往返。
安装CUDA工具包与CuPy
CuPy依赖CUDA运行时,因此需要先确认GPU与驱动状态。在Linux或Windows命令行执行 nvidia-smi,输出中应能看到GPU型号、驱动版本以及右上角的“CUDA Version”,它表示当前驱动支持的最高CUDA版本。CuPy的安装版本必须与该版本兼容,不能高于驱动支持的上限。
推荐使用conda或pip安装预编译包,避免本地编译。以CUDA 12.x为例,常用命令如下:
# 方式一:conda(自动处理CUDA运行时依赖)
conda install -c conda-forge cupy
# 方式二:pip 指定CUDA主版本对应的包
pip install cupy-cuda12x
# 查看已安装版本
python -c "import cupy; print(cupy.__version__)"
注意pip包名中的 cuda12x 需与你的CUDA主版本匹配,例如CUDA 11.x对应 cupy-cuda11x。若版本不匹配,导入时通常会出现“CUDA driver version is insufficient”或找不到动态库的报错。
验证GPU可用性
安装完成后,不要急着写业务代码,先用几行代码确认CuPy能正确识别设备。下面是一段典型的自检脚本:
import cupy as cp
print("CuPy 版本:", cp.__version__)
print("可用 GPU 数量:", cp.cuda.runtime.getDeviceCount())
dev = cp.cuda.Device(0)
dev.use()
props = cp.cuda.runtime.getDeviceProperties(0)
print("设备名称:", props["name"].decode())
print("显存总量(GB):", round(props["totalGlobalMem"] / 1024**3, 2))
# 简单运算验证
a = cp.arange(5)
print("a =", a)
print("a 所在设备:", a.device)
若 getDeviceCount() 返回大于0,且 a.device 显示为 <CUDA Device 0>,说明环境已就绪。若返回0或抛出异常,应依次检查:驱动是否安装、nvidia-smi 是否正常、CuPy版本与CUDA版本是否匹配、当前用户是否有访问GPU设备的权限。
第一个CuPy数组程序
下面用一个可对比的小例子完成首次运行。我们分别用NumPy和CuPy计算两个较大数组的元素级运算,并观察结果一致性与耗时差异。示例规模与耗时仅用于演示观察方法,实际数值会随硬件变化。
import numpy as np
import cupy as cp
import time
n = 20_000_000
x_np = np.random.rand(n).astype(np.float32)
y_np = np.random.rand(n).astype(np.float32)
t0 = time.perf_counter()
z_np = np.sqrt(x_np**2 + y_np**2)
t_np = time.perf_counter() - t0
x_cp = cp.asarray(x_np)
y_cp = cp.asarray(y_np)
cp.cuda.Stream.null.synchronize() # 确保传输完成
t0 = time.perf_counter()
z_cp = cp.sqrt(x_cp**2 + y_cp**2)
cp.cuda.Stream.null.synchronize() # 等待GPU内核完成
t_cp = time.perf_counter() - t0
print("NumPy 耗时: %.4f s" % t_np)
print("CuPy 耗时: %.4f s" % t_cp)
print("最大误差:", float(cp.max(cp.abs(z_cp - cp.asarray(z_np)))))
这段代码有三个值得注意的细节。第一,cp.asarray 把主机数组复制到显存,这一步本身有开销,计时时应把它排除在GPU计算之外。第二,GPU内核是异步执行的,Python语句返回时计算可能尚未结束,必须调用 synchronize() 才能得到真实耗时。第三,结果比对时要把NumPy结果转回GPU或把CuPy结果转回CPU,统一设备后再比较,示例中最大误差应接近0(浮点误差范围内),以此判断迁移正确性。
np 改成 cp 就认为完成了迁移。实际上还要检查是否存在隐式的主机-设备拷贝、是否在循环中反复创建小数组、是否忘记同步导致计时失真。这些细节会在后续章节逐步展开。完成本节后,你应当能够说清CuPy与NumPy的异同、判断哪些任务适合放到GPU、独立完成CUDA与CuPy的安装、用自检脚本确认设备可用,并跑通第一个带正确性校验与耗时观察的CuPy程序。这套环境与验证习惯,是后续所有GPU数组计算实践的基础。