第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能直接省一半显存。但要注意:

我曾经在一个KMeans项目中把所有数据转成float16,结果质心收敛变慢,甚至不收敛。后来发现是半精度的精度丢失导致梯度计算不稳定。所以只有在精度容忍度高的场景(比如图像像素归一化后)才用float16,否则老老实实用float32。
# 检查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(),省一次拷贝。

一个小窍门:如果你的NumPy数组很大(比如几百MB),转成CuPy时可能会短暂占用双倍显存。我一般先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数组从创建到操作再到互转的完整链路。它是我自己梳理的,你对照着看,思路会更清楚。

CuPy数组基础:知识结构 创建 CuPy 数组 从Python列表 工厂函数 从NumPy转 数据类型设定/转换 形状操作(reshape/T/…) GPU ↔ CPU 互转(性能注意) 核心原则:减少CPU-GPU传输,尽量在GPU上完成全部操作
个人建议:初学阶段先别急着优化。你直接把NumPy代码里的np换成cp,大部分都能跑通。等跑通了再去考虑dtype、预分配、减少传输这些细节。毕竟先跑起来,再跑快,是工程里最实在的套路。