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 上一次性处理完。

CuPy 数组创建路径与数据位置 主机内存 (RAM) Python 列表 NumPy 数组 H2D PCIe 拷贝 GPU 显存 (VRAM) cp.array() cp.asarray() arange/zeros/… 直接分配显存,避免先建后拷 D2H:.get() / cp.asnumpy() 数组属性 shape → 维度长度,决定索引范围 dtype → 元素类型,影响显存与速度 device → 所在 GPU,多卡时必查 索引与切片 切片 a[:, 1:3] → 视图,共享显存 布尔/整数索引 → 新数组,重新分配 标量索引 → 0 维数组,.item() 回读

一个可复现的小练习

把上述知识点串起来,可以做一个简单的验证:在 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 上、类型符合预期、形状未被破坏。

迁移建议:把 NumPy 代码改成 CuPy 时,通常只需把 import numpy as np 换成 import cupy as cp,并把 np. 替换为 cp.。真正需要人工介入的地方集中在三处:显式的主机/设备拷贝、依赖 CPU 的第三方库调用、以及逐元素回读导致的同步开销。

掌握创建与基础操作后,后续的向量化运算、归约和自定义核函数才有稳固的起点。建议在动手写复杂算法前,先用小数组验证 shape、dtype 和 device 是否符合预期,再逐步放大规模——这比在大数组上报错后再回头排查要高效得多。