2. CuPy数组创建与基础操作
CuPy 的数组对象 cupy.ndarray 与 NumPy 的 numpy.ndarray 在接口设计上高度对齐,这让熟悉 NumPy 的开发者几乎可以零成本迁移。但两者有一个根本差异:NumPy 数组的数据位于主机内存(RAM),而 CuPy 数组的数据位于 GPU 显存(VRAM)。理解这一点,是掌握 CuPy 数组创建与操作的关键——每一次创建、切片、运算,本质上都是在决定"数据放在哪里、以什么形状和类型存放、如何被 GPU 访问"。
从 Python 列表与 NumPy 数组创建
最直接的创建方式是 cupy.array(),它接受 Python 列表、嵌套列表或 NumPy 数组作为输入,并在 GPU 上分配显存、拷贝数据:
import cupy as cp
import numpy as np
# 从 Python 列表创建
a = cp.array([1, 2, 3, 4])
# 从嵌套列表创建二维数组
b = cp.array([[1.0, 2.0], [3.0, 4.0]])
# 从 NumPy 数组创建(会触发一次 H2D 拷贝)
host = np.arange(6, dtype=np.float32).reshape(2, 3)
c = cp.asarray(host)
这里要区分 cp.array() 与 cp.asarray():前者默认总是复制数据,后者在输入已经是 CuPy 数组时直接返回原对象、不复制,行为类似 NumPy 的 asarray。当输入是 NumPy 数组时,两者都会执行一次主机到设备(H2D)的传输,这个传输走 PCIe 总线,是迁移过程中最容易被忽视的性能开销。对于大数组,应尽量在 GPU 上直接生成,而不是先在 CPU 上建好再拷贝。
反向操作同样重要:cp.asnumpy(cupy_array) 或 cupy_array.get() 会把显存数据拷回主机内存,得到普通 NumPy 数组。只有拷回之后,才能用 Matplotlib 绘图、写入磁盘或交给不支持 GPU 的库处理。
常用创建函数
CuPy 提供了一组与 NumPy 同名的创建函数,它们直接在显存中分配并初始化数据,避免了"先建后拷"的额外开销:
| 函数 | 作用 | 典型示例 |
|---|---|---|
cp.arange | 生成等差序列 | cp.arange(0, 10, 2) → [0,2,4,6,8] |
cp.zeros | 全零数组 | cp.zeros((3, 4), dtype=cp.float32) |
cp.ones | 全一数组 | cp.ones(5, dtype=cp.int32) |
cp.empty | 未初始化数组 | cp.empty((2, 2)) |
cp.full | 填充指定值 | cp.full((2, 3), 7.5) |
需要特别提醒 cp.empty:它只分配显存、不写入任何值,内容是该显存块此前残留的任意数据。它的优势是快,适合"马上会被完整覆盖"的场景,例如作为输出缓冲区;如果直接读取,会得到不可预期的数值。这一点与 NumPy 的 empty 一致,但在 GPU 上残留数据的来源更复杂,调试时更应警惕。
此外,cp.zeros_like(x)、cp.ones_like(x)、cp.full_like(x, v) 会参照已有数组 x 的形状和 dtype 创建新数组,在实现迭代算法(如初始化累加器、梯度缓冲区)时非常常用,能避免手写 shape 和 dtype 出错。
数组属性:shape、dtype、device
创建完成后,三个属性最值得关注。shape 是各维度长度组成的元组,决定索引的合法范围;dtype 决定每个元素的字节数和精度,直接影响显存占用与计算速度;device 则标识数据所在的 GPU,返回一个 cupy.cuda.Device 对象。
x = cp.zeros((1024, 1024), dtype=cp.float32)
print(x.shape) # (1024, 1024)
print(x.dtype) # float32
print(x.device) # <CUDA Device 0>
print(x.nbytes) # 4194304,即 4 MiB
print(x.size) # 1048576
多卡环境下,device 属性尤其关键。可以用 with cp.cuda.Device(1): 上下文管理器切换当前设备,在其中创建的数组会自动落在 1 号卡上。跨设备直接运算会报错,必须先通过 cp.asarray(x, device=...) 或拷贝显式迁移。养成"创建前确认当前设备"的习惯,能避免大量隐蔽的显存归属问题。
基础索引与切片
CuPy 的索引语法与 NumPy 完全一致:支持整数索引、负索引、切片、步长、布尔掩码和整数数组索引。区别在于,切片返回的通常是原数组的视图(共享底层显存),而布尔索引和整数数组索引返回的是新数组(需要重新分配显存并拷贝数据)。
a = cp.arange(12).reshape(3, 4)
print(a[1, 2]) # 标量索引,返回 0 维数组
print(a[:, 1:3]) # 切片,视图
print(a[a > 5]) # 布尔索引,新数组
print(a[[0, 2]]) # 整数数组索引,新数组
a[0, 0] = 99 # 原地赋值,直接写显存
有一个容易踩坑的地方:用标量索引取出的单个元素仍然是 0 维 CuPy 数组,而不是 Python 标量。若要在主机端使用它的值,需要调用 .item() 或 int(),这会触发一次设备到主机的同步拷贝。在循环中频繁对单个元素取值,会因反复同步而严重拖慢程序——这正是 GPU 编程中"避免逐元素回读"原则的由来。正确的做法是把逐元素判断改写成向量化的掩码运算,让整个数组在 GPU 上一次性处理完。
一个可复现的小练习
把上述知识点串起来,可以做一个简单的验证:在 GPU 上创建一个 4×4 的 float32 数组,用切片修改其中一块,再检查视图是否共享数据。
import cupy as cp
x = cp.arange(16, dtype=cp.float32).reshape(4, 4)
view = x[1:3, 1:3] # 视图
view[:] = -1 # 原地写入
print(x) # 中间 2×2 区域变为 -1,证明共享显存
mask = x > 0
print(mask.sum()) # 布尔索引 + 归约,全程在 GPU 完成
print(x.device, x.dtype, x.shape)
预期结果是:x 的第 2、3 行第 2、3 列全部变成 -1,其余元素保持原值;mask.sum() 返回剩余正数元素的个数。如果切片返回的是副本,x 就不会改变——这个对比能帮助建立"视图 vs 副本"的直觉。最后一行打印出的 device、dtype、shape 则确认了数据确实落在 GPU 上、类型符合预期、形状未被破坏。
import numpy as np 换成 import cupy as cp,并把 np. 替换为 cp.。真正需要人工介入的地方集中在三处:显式的主机/设备拷贝、依赖 CPU 的第三方库调用、以及逐元素回读导致的同步开销。掌握创建与基础操作后,后续的向量化运算、归约和自定义核函数才有稳固的起点。建议在动手写复杂算法前,先用小数组验证 shape、dtype 和 device 是否符合预期,再逐步放大规模——这比在大数组上报错后再回头排查要高效得多。