第2章 CuPy数组基础:创建数组、数据类型、形状操作、与NumPy数组的互转
2.1 为什么说CuPy数组就是“换了芯”的NumPy?
刚开始接触CuPy时,我最大的感受就是——这不就是NumPy吗?语法几乎一模一样。但底层完全不一样。NumPy跑在CPU上,CuPy跑在GPU上。你用cupy.array()创建出来的东西,表面看是个数组,实际上背后已经是一块显存里的数据了。
说直接点,CuPy数组就是NumPy数组的GPU亲戚。它俩共享同一套API设计哲学,但物理存放位置不同。你想想看,如果你已经会NumPy,学CuPy基本就是换个包名的事。
2.2 创建数组的几种姿势
我一般把创建数组分成三类:从Python列表直接生成、用工厂函数创建、从NumPy转过来。咱们一个个看。
2.2.1 从列表或元组创建
import cupy as cp
# 一维数组
arr1 = cp.array([1, 2, 3, 4, 5])
# 二维数组
arr2 = cp.array([[1.0, 2.0], [3.0, 4.0]])
# 指定dtype
arr3 = cp.array([1, 2, 3], dtype=cp.float32)
这里有个坑,我在项目里踩过:如果你传入的列表里混了整数和浮点数,CuPy会默认推断成float64。在深度学习场景下,float64又慢又占显存。所以我建议显式指定dtype=cp.float32或cp.float16。
cp.array(),结果显存爆了。后来一查,自动推断成了float64,占了我两倍显存。从那以后,只要数据量大,我一定手动指定dtype。
2.2.2 用工厂函数批量创建
日常开发中,我更常用下面这些函数,效率和可读性都好很多。
| 函数 | 说明 | 示例 |
|---|---|---|
cp.zeros(shape) |
全0数组 | cp.zeros((3, 4), dtype=cp.float32) |
cp.ones(shape) |
全1数组 | cp.ones((2, 2)) |
cp.empty(shape) |
未初始化(快,但值随机) | cp.empty((5,)) |
cp.full(shape, val) |
所有元素为指定值 | cp.full((2,3), 7) |
cp.eye(N) |
单位矩阵 | cp.eye(4) |
cp.arange(start, stop, step) |
等差数列 | cp.arange(0, 10, 2) |
cp.linspace(start, stop, num) |
等间隔序列 | cp.linspace(0, 1, 5) |
cp.random.rand(shape) |
均匀分布随机数 | cp.random.rand(3, 3) |
cp.random.randn(shape) |
标准正态分布随机数 | cp.random.randn(1000) |
嗯,上面这些基本覆盖了日常工作。尤其cp.zeros和cp.random.randn,我在初始化神经网络权重时天天用。
cp.empty。它不初始化数据,速度比cp.zeros快一到两个数量级。但在调试阶段慎用,因为未初始化的数据可能让你找bug找到怀疑人生。
2.3 数据类型:选对精节省一半显存
CuPy支持的数据类型和NumPy几乎一一对应,只是在名字前面加了个cp.。常用的有:
- 整数型:
cp.int8,cp.int16,cp.int32,cp.int64,以及无符号的cp.uint8等 - 浮点型:
cp.float16(半精度),cp.float32(单精度),cp.float64(双精度) - 布尔型:
cp.bool_ - 复数型:
cp.complex64,cp.complex128
这里我想重点说说float16和float32。我做聚类算法加速时,经常遇到显存瓶颈。如果数据精度要求不高,用float16能直接省一半显存。但要注意:
# 检查dtype
print(arr2.dtype) # float64
# 转换dtype
arr_f32 = arr2.astype(cp.float32)
print(arr_f32.dtype) # float32
# 创建时指定
arr_f16 = cp.array([1.5, 2.5], dtype=cp.float16)
2.4 形状操作:reshape、resize、transpose、squeeze、expand_dims
形状操作是数组的灵魂。CuPy提供了和NumPy一模一样的函数,性能却跑在GPU上。我整理了一张常用操作表:
| 操作 | 方法/函数 | 示例 | 说明 |
|---|---|---|---|
| 重设形状 | arr.reshape(shape) |
arr.reshape(3, -1) |
-1表示自动推断 |
| 展平 | arr.flatten() |
arr.flatten() |
返回新数组(拷贝) |
| 展平(不拷贝) | arr.ravel() |
arr.ravel() |
可能返回视图,更快 |
| 转置 | arr.T 或 cp.transpose(arr, axes) |
arr.T |
二维矩阵常用.T |
| 交换轴 | arr.swapaxes(axis1, axis2) |
arr.swapaxes(0, 1) |
不改变数据布局 |
| 压缩维度 | cp.squeeze(arr, axis) |
cp.squeeze(arr) |
删除长度为1的轴 |
| 扩展维度 | cp.expand_dims(arr, axis) |
cp.expand_dims(arr, 0) |
在指定位置插入新轴 |
| 拼接 | cp.concatenate([a, b], axis=0) |
cp.concatenate((a, b), axis=1) |
沿已有轴拼接 |
| 堆叠 | cp.stack([a, b], axis=0) |
cp.stack((a, b), axis=-1) |
创建新轴后堆叠 |
# 实际演练
a = cp.arange(12).reshape(3, 4)
print(a.shape) # (3, 4)
b = a.reshape(2, 2, 3)
print(b.shape) # (2, 2, 3)
c = a.flatten()
print(c) # [0 1 2 ... 11]
d = a.T
print(d.shape) # (4, 3)
你想想看,如果一个形状是(1, 28, 28, 1)的四维数组,你想变成(28, 28),两步搞定:先squeeze去掉长度为1的轴,再reshape。不过要注意,squeeze默认删掉所有长度为1的轴,如果你只想删某个轴,要指定axis参数。
2.5 与NumPy数组的互转:显存与内存之间的桥梁
这里是最容易出问题的地方。CuPy数组不能直接当NumPy数组用,反之亦然。互转就是数据在GPU显存和CPU内存之间的搬运。
2.5.1 GPU → CPU
# 假设 gpu_arr 是一个CuPy数组
cpu_arr = cp.asnumpy(gpu_arr)
# 或者用 .get() 方法
cpu_arr2 = gpu_arr.get()
我个人更推荐cp.asnumpy(),因为语义更清晰——就是“把CuPy变成NumPy”。注意,这两种方式都会触发显存到内存的拷贝,开销可不小。所以我在项目中会尽量减少来回传输,能在GPU上算完的就不往回传。
2.5.2 CPU → GPU
# 普通方式
gpu_arr = cp.array(cpu_arr)
# 更高效的方式:用 cp.asarray() 避免不必要的拷贝
gpu_arr2 = cp.asarray(cpu_arr)
# 如果原数组是Fortran连续,用 cp.asfortranarray()
gpu_f = cp.asfortranarray(cpu_arr)
cp.asarray()和cp.array()的区别是什么?简单来说,如果传入的已经是CuPy数组,cp.asarray()不会创建新副本,而cp.array()会。所以从NumPy转过来时,建议用cp.asarray(),省一次拷贝。
del掉原来的NumPy数组释放内存,再转CuPy。不过Python的垃圾回收不是实时的,必要时可以调gc.collect()。
2.5.3 互转的陷阱
- 数据类型自动转换:NumPy默认
float64,CuPy默认也是float64。但很多GPU操作默认用float32,来回转可能会隐式改变精度。我建议在转换时明确指定dtype。 - 连续内存布局:转过去后,CuPy默认按C连续(行优先)存储。如果你的NumPy数组是Fortran连续(列优先),用
cp.asfortranarray()可以保持布局,但这在深度学习中不常见。 - 内存泄漏:在循环中频繁
cp.array()再cp.asnumpy(),会产生大量临时对象,有可能撑爆显存或内存。我曾在一次批量处理中吃了这个亏,后来改成在循环外预分配CuPy数组,原地更新。
# 正确示范:避免循环内频繁转
import numpy as np
batch_size = 1000
n_features = 784
# 预分配GPU数组
gpu_batch = cp.empty((batch_size, n_features), dtype=cp.float32)
for i in range(100):
# 模拟从磁盘读取一批数据
cpu_chunk = np.random.randn(batch_size, n_features).astype(np.float32)
# 直接拷贝到预分配显存
gpu_batch[:] = cp.asarray(cpu_chunk)
# 在GPU上处理...
# 不把结果转回CPU,直到最后
2.6 本章知识体系一览
下面这张图总结了CuPy数组从创建到操作再到互转的完整链路。它是我自己梳理的,你对照着看,思路会更清楚。
np换成cp,大部分都能跑通。等跑通了再去考虑dtype、预分配、减少传输这些细节。毕竟先跑起来,再跑快,是工程里最实在的套路。