4. CuPy 数学运算:算术运算、广播机制、通用函数(ufunc)、聚合运算

聊到 GPU 加速,核心就是算得快。怎么个快法?

说白了,就是把循环扔给成千上万个核心并行跑。CuPy 的数学运算,就是帮你干这事的主力。

我个人习惯,上手新库先看它的运算体系是否顺手。CuPy 这块做得挺自然,跟 NumPy 几乎镜像,但底层跑在 CUDA 上。

4.1 算术运算:逐元素操作

最基本的加减乘除,在 CuPy 里就是逐元素操作。你想想看,两个同样形状的数组,对应位置算一下。

我在项目中遇到过几次,有人把 CPU 上的 for 循环直接搬到 GPU 上写,那效率……嗯,惨不忍睹。用 CuPy 的算术运算,一行顶你十行 for。

import cupy as cp

a = cp.array([1, 2, 3, 4])
b = cp.array([10, 20, 30, 40])

c_add = a + b
c_sub = a - b
c_mul = a * b
c_div = b / a

print(c_add)  # [11 22 33 44]
print(c_mul)  # [10 40 90 160]

这些运算,CuPy 内部会生成 CUDA kernel,每个元素一个线程。数据量越大,优势越明显。

运算符 对应函数 说明
+ cp.add 逐元素加法
- cp.subtract 逐元素减法
* cp.multiply 逐元素乘法
/ cp.divide 逐元素除法
** cp.power 逐元素幂运算
% cp.mod 逐元素取模
小技巧: 如果发现运算结果不符合预期,先检查两个数组的数据类型是否一致。CuPy 的隐式类型转换有时会截断小数,我习惯在创建时就指定 dtype。

4.2 广播机制:不同形状也能算

运算时形状不匹配怎么办?CuPy 沿用了 NumPy 的广播机制。规则很简单:从尾部维度开始对齐,要么相等,要么其中一个为 1。

举个例子,一个 3x4 的矩阵加上一个长度为 4 的向量。向量会在行方向自动复制 3 份。

matrix = cp.ones((3, 4))
vector = cp.array([1, 2, 3, 4])

result = matrix + vector
print(result)
# [[2. 3. 4. 5.]
#  [2. 3. 4. 5.]
#  [2. 3. 4. 5.]]

广播规则:

  • 从后往前比较各维度的尺寸。
  • 如果两个维度相等,继续比较前一个维度。
  • 如果其中一个维度为 1,它会扩展到另一个维度的大小。
  • 如果两个维度不等且都不为 1,直接报错。
小心! 我曾经在写聚类算法时,因为疏忽维度没对齐,广播机制补了个我完全没预期的形状,导致结果全错。建议正式运算前,用 cp.broadcast_shapes 先看一眼目标形状。

4.3 通用函数(ufunc):向量化的核心

通用函数,简称 ufunc。它是对数组每个元素执行同一操作的函数。CuPy 的 ufunc 直接编译为 CUDA 核函数。

我习惯把 ufunc 分为几类:数学运算、三角函数、指数对数、类型转换。写模型训练时用得最多的是 cp.exp、cp.log 和 cp.sqrt。

x = cp.array([0.0, 1.0, 2.0, 3.0])
y = cp.exp(x)       # 指数
z = cp.log(x + 1)   # 对数
w = cp.sqrt(x)      # 平方根

print(y)
print(z)
print(w)

CuPy 还支持创建自定义 ufunc,用 cp.ElementwiseKernel 或 cp.RawKernel。这块稍微进阶,但当你需要特殊的逐元素操作时,非常有用。

类别 常用 ufunc 备注
数学 abs, sign, negative, reciprocal 基础运算
指数/对数 exp, log, log10, log2 数值稳定性需注意
三角函数 sin, cos, tan, arcsin, arccos 少用但必要
双曲 sinh, cosh, tanh 神经网络激活层常见
比较 greater, less, equal, maximum 返回布尔数组
性能对比: 同样 1000 万元素,CPU 上用一次 np.exp 大约 100 毫秒,CuPy 在 RTX 3080 上只需 3 毫秒。差了 30 倍以上。模型训练里的激活函数、损失计算,一天跑下来省的时间很可观。

4.4 聚合运算:从数组到标量

聚合运算,就是沿着某个轴把数据“压”成一个更小的结果。求和、均值、最大值、最小值,是最常用的。

CuPy 的聚合运算会利用 GPU 的 reduction 操作,并行归约。我刚开始用的时候,一度怀疑结果算错了,因为太快了。

data = cp.random.randn(1000000).astype(cp.float32)

total = cp.sum(data)        # 所有元素求和
mean_val = cp.mean(data)    # 均值
max_val = cp.max(data)      # 最大值
min_val = cp.min(data)      # 最小值
std_val = cp.std(data)      # 标准差

print(f"Sum: {total:.4f}")
print(f"Mean: {mean_val:.4f}")
print(f"Max: {max_val:.4f}")
print(f"Std: {std_val:.4f}")

沿着轴聚合:

matrix = cp.random.randn(4, 5)

row_sum = cp.sum(matrix, axis=1)   # 每一行求和,结果形状 (4,)
col_sum = cp.sum(matrix, axis=0)   # 每一列求和,结果形状 (5,)

print("行求和:", row_sum)
print("列求和:", col_sum)
经验之谈: 聚合运算会触发 GPU 内存的全局同步。如果数据量极大,单次聚合没问题,但频繁调用可能成为瓶颈。我曾经有个模型,batch norm 里对每个通道做均值方差,频繁的 reduction 让 GPU 利用率反而下降了。解决方案是合并多次聚合,或者用更高效的融合 kernel。

CuPy 支持几乎所有的 NumPy 聚合函数:cp.prod、cp.cumsum、cp.argmax、cp.argmin。用法完全一致。

CuPy 数学运算核心体系 CuPy 数组 算术运算 + - * / ** % 广播机制 尾部对齐 · 补 1 通用函数 ufunc exp · log · sin · tanh 聚合运算 sum · mean · max · min 核心原则 所有运算都在 GPU 上并行执行 · 结果直接以 cupy.ndarray 返回 非就地运算 · 原数组不被修改 聚类算法 · 神经网络 · 数据预处理

最后提一句:CuPy 的数学运算,大部分是“非就地”的,即原数组不变,返回新数组。如果你要节省显存,可以用 out 参数或原地操作符(如 +=)。

a = cp.array([1, 2, 3])
b = cp.array([4, 5, 6])

# 输出到已有数组,避免重新分配内存
result = cp.empty_like(a)
cp.add(a, b, out=result)

# 原地操作
a += b   # a 被修改
print(a) # [5 7 9]
省显存技巧: 模型训练时,频繁创建临时数组会让显存碎片化。我习惯用 out 参数复用预分配的内存块,特别是在数据增强和损失计算阶段。