4. CuPy与NumPy的互操作
CuPy 并不是要取代 NumPy,而是把 NumPy 的数组语义搬到 GPU 上。因此在实际工程中,最常见的场景不是“全用 CuPy”,而是“CPU 端用 NumPy 做 I/O 和预处理,GPU 端用 CuPy 做重计算”。这就引出了本节的核心问题:两种数组如何互相转换、转换时数据到底有没有被复制、以及跨设备搬运的代价如何控制。
一、两个方向的转换函数
CuPy 提供了与 NumPy 高度对称的接口。把 NumPy 数组搬到 GPU 用 cp.asarray,把 CuPy 数组搬回主机用 cp.asnumpy(等价写法还有 cp.asnumpy(a) 与 a.get())。
import numpy as np
import cupy as cp
# 主机端准备数据(NumPy)
host = np.arange(6, dtype=np.float32).reshape(2, 3)
# NumPy -> CuPy:数据被复制到当前 GPU 显存
gpu = cp.asarray(host)
# 在 GPU 上计算
gpu2 = gpu * 2 + 1
# CuPy -> NumPy:结果被复制回主机内存
result = cp.asnumpy(gpu2)
print(result.dtype, result.shape) # float32 (2, 3)
需要强调的是,cp.asarray 的语义是“尽可能转换”,而不是“总是复制”。如果传入的已经是 CuPy 数组,它直接返回原对象(或同设备视图),不会产生新拷贝;如果传入的是 Python 列表,它会先在主机端构造数组再上传。理解这一点,是判断开销的前提。
二、零拷贝共享内存的条件
很多资料会说“CuPy 和 NumPy 可以零拷贝共享内存”,这句话有严格前提,不能泛化。真正成立的情形主要有两类:
- 统一内存(Unified Memory)场景:在支持并启用统一内存的平台上,主机与设备共享同一地址空间,
cp.asarray可能不显式复制,而是建立可被 GPU 访问的映射。但这依赖驱动、平台与 CuPy 配置,不能默认成立。 - 固定内存(pinned memory)配合异步传输:主机数组若分配在页锁定内存中,DMA 传输可以绕过中间缓冲、与计算重叠,显著降低有效开销。这仍是一次复制,但代价更低、可被隐藏。
反过来,普通 np.ndarray 位于可分页内存,GPU 无法直接高效访问,cp.asarray 必然触发一次 H2D 拷贝。因此工程上应把“零拷贝”理解为一种需要满足条件的优化,而不是默认行为。判断方法很直接:转换后检查 gpu.base is None、对比 gpu.data.ptr 与主机地址、或用 cp.cuda.runtime 相关接口观察内存类型;更实用的是用计时对比转换耗时是否接近纯拷贝量级。
三、传输开销从哪里来
一次 cp.asarray 或 cp.asnumpy 的开销由三部分组成:启动开销(固定,约几微秒到几十微秒)、带宽开销(数据量 ÷ 有效带宽)、以及同步开销(cp.asnumpy 会等待 GPU 上所有相关任务完成)。在 PCIe 4.0 x16 上,H2D 与 D2H 的有效带宽通常在十几 GB/s 量级,远低于显存内部数百 GB/s 的带宽。这意味着:
- 数据量越大,单次传输越“划算”,因为固定开销被摊薄;
- 传输次数越多,累计开销越显著,哪怕每次只传几 KB;
- D2H 往往比 H2D 更“贵”,因为它通常伴随同步,打断流水线。
举个可复现的估算例子(示例参数,非实测):若单次转换固定开销约 20 μs,传输 4 MB 数据在 12 GB/s 下约需 0.33 ms,则单次总开销约 0.35 ms。若把同样 4 MB 拆成 1000 次、每次 4 KB 传输,固定开销累计约 20 ms,是单次传输的数十倍。这就是“批量转换”原则的量化依据。
四、优化策略与实操要点
1. 减少转换次数,合并传输。把多次小数组拼接成一个大数组一次性上传,或把多个结果打包后一次 cp.asnumpy 回传。例如把 1000 张 32×32 的小图堆叠成 (1000, 32, 32) 再上传,而不是循环 1000 次。
2. 让数据尽量长时间留在 GPU 上。把“上传—计算—下载”的边界推到整个任务的最外层,中间所有步骤都用 CuPy 完成。避免在循环体内反复 cp.asnumpy 取中间结果做判断。
3. 用页锁定内存与异步流隐藏传输。对需要频繁往返的数据,可先分配到 pinned 内存,再配合 cp.cuda.Stream 做异步拷贝,使 H2D 与核函数执行重叠:
# 示例:用固定内存 + 异步流重叠传输与计算
host_buf = cp.cuda.alloc_pinned_memory(4 * 1024 * 1024) # 4 MB 页锁定缓冲
stream = cp.cuda.Stream(non_blocking=True)
with stream:
gpu_buf = cp.asarray(np.frombuffer(host_buf, dtype=np.float32))
out = gpu_buf * 2.0
host_out = cp.asnumpy(out) # 仍在流内,可与其它流重叠
stream.synchronize()
4. 注意 dtype 与内存布局。转换不会自动帮你做类型转换的“优化”,float64 在消费级 GPU 上吞吐远低于 float32。上传前统一为 float32 可同时减少传输量和计算时间。非连续数组(如转置视图)上传时可能触发隐式整理,建议先 np.ascontiguousarray。
5. 用显存池避免反复分配。CuPy 默认启用内存池,反复创建/释放同尺寸数组不会真正向驱动申请显存,但跨进程或大起大落时仍应关注 cp.get_default_memory_pool().used_bytes(),必要时手动 free_all_blocks()。
cp.asarray 对 NumPy 数组是零拷贝。对普通可分页 np.ndarray 而言它一定发生复制;只有在统一内存或页锁定等特定条件下才可能避免显式拷贝。写性能敏感代码前,务必用计时确认实际行为,而不是依赖假设。把以上要点串起来,一个稳健的互操作模式是:主机端用 NumPy 完成 I/O 与清洗,一次性批量上传为 CuPy 数组,在 GPU 上完成全部重计算,最后只把最终结果(而非中间量)一次性回传。这样既保留了 NumPy 生态的便利,又把跨设备传输控制在最少次数,让 PCIe 带宽不再成为整条流水线的瓶颈。