数组的逐元素操作
逐元素计算与 CuPy
在并行编程中,最常见且最直接的模式就是逐元素操作。在许多实际问题里,我们需要对大型数据集的每个元素执行相同的算术运算——加、减、乘、除,或更复杂的操作。GPU 天生擅长此类任务:数以千万计的线程可以同时工作,每个线程独立处理一个(或多个)元素,无需线程间协调,使得逐元素计算既高效又天然可扩展。这些操作广泛应用于图像处理、科学模拟、机器学习、金融建模等领域。掌握这一模式,既能加速基本算术运算,也能为更复杂的算法打下简洁可行的基础。
CuPy 为我们提供了类似 NumPy 的接口,并在底层自动调用 GPU 加速。大多数情况下,只需替换导入和数组创建方式,就能将代码从 CPU 平滑迁移到 GPU。所有 NumPy 中的广播规则和语法在 CuPy 中无缝复用,让我们瞬间拥有成千上万 CUDA 核心的算力。
运行示例:大规模逐元素算术
下面通过实战演示:
import cupy as cp
# 在 GPU 上创建两个一千多万个元素的数组
size = 10_000_000
a = cp.random.rand(size).astype(cp.float32)
b = cp.random.rand(size).astype(cp.float32)
# 逐元素运算
sum_result = a + b # 相加
diff_result = a - b # 相减
prod_result = a * b # 相乘
epsilon = 1e-8
div_result = a / (b + epsilon) # 相除(加小常数以防除零)
所有运算均在 GPU 上完成,每个线程独立处理一个元素。相比于在大数组上使用 NumPy,这些计算速度会快得多,且优势随数据规模增长而显著。
广播的威力
NumPy 和 CuPy 的一大强大功能就是广播(broadcasting),它允许对形状不同但维度兼容的数组进行逐元素运算。例如,将一个一维数组加到二维数组的每一行:
# 在 GPU 上创建一个 (size, 100) 的二维数组
array_2d = cp.random.rand(size, 100).astype(cp.float32)
# 将一维数组 a 加到二维数组的每一行
broadcasted_result = array_2d + a[:, None]
CuPy 会在 GPU 上完成所有运算,既保留了 NumPy 的便捷性,又大幅提升了速度。
- 逐元素操作 是并行编程的核心构建块,与 GPU 硬件高度契合。
- CuPy 提供了与 NumPy 几乎相同的语法和广播能力,让 GPU 加速代码编写变得轻而易举。
- 我们可以通过对比 CPU 与 GPU 的输出,快速验证正确性与性能提升。
掌握了这一模式后,我们就为更高级的并行模式做好了准备。
并行归约求和
并行归约概览
在许多场景下,我们需要将大量数据归约为一个标量值。在 CPU 上,通常用循环或内置的 sum() 就能搞定;而在 GPU 上,如果让所有线程同时向同一个全局变量累加,既会产生严重的竞争,也会造成同步瓶颈。并行归约(parallel reduction)通过分阶段“折半”累加来解决这一问题:每个线程先在局部对元素求和,然后再合并这些部分和,直到最后仅剩一个结果。这种做法最大化利用了 GPU 的共享内存(shared memory)高速缓存,显著减少全局内存访问和竞争。
编写归约 Kernel
下面示例使用 PyCUDA,在每个线程块内先对所属区间进行部分求和,最终在主机或通过多次 Kernel 调用完成全局归约。
import numpy as np
import pycuda.autoinit
import pycuda.driver as drv
import pycuda.gpuarray as gpuarray
from pycuda.compiler import SourceModule
# 数据规模
size = 1_048_576 # 1M 元素
# 在主机上生成随机数组并拷贝到设备
host_array = np.random.rand(size).astype(np.float32)
device_array = gpuarray.to_gpu(host_array)
# CUDA C 归约 kernel
kernel_code = """
extern "C"
__global__ void reduce_sum(float *g_idata, float *g_odata, int n)
{
extern __shared__ float sdata[];
unsigned int tid = threadIdx.x;
unsigned int i = blockIdx.x * blockDim.x * 2 + threadIdx.x;
float sum = 0;
// 每个线程最多读取两个元素进行累加(展开)
if (i < n)
sum += g_idata[i];
if (i + blockDim.x < n)
sum += g_idata[i + blockDim.x];
sdata[tid] = sum;
__syncthreads();
// 在共享内存中迭代折半归约
for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) {
if (tid < s) {
sdata[tid] += sdata[tid + s];
}
__syncthreads();
}
// 将每个区块的部分和写回全局内存
if (tid == 0) {
g_odata[blockIdx.x] = sdata[0];
}
}
"""
mod = SourceModule(kernel_code)
reduce_sum = mod.get_function("reduce_sum")
# 配置线程块和网格
threads_per_block = 512
blocks_per_grid = (size + threads_per_block * 2 - 1) // (threads_per_block * 2)
shared_mem_size = threads_per_block * np.dtype(np.float32).itemsize
# 为每个区块的部分和分配输出数组
partial_sums = gpuarray.empty(blocks_per_grid, dtype=np.float32)
# 启动归约 kernel
reduce_sum(
device_array, partial_sums, np.int32(size),
block=(threads_per_block, 1, 1),
grid=(blocks_per_grid, 1),
shared=shared_mem_size
)
# 如果 blocks_per_grid > 1,可重复上述过程,或直接拷回主机并用 CPU 完成最后的归约
final_sum = partial_sums.get().sum()
print("GPU 归约结果:", final_sum)
print("CPU 参考结果:", host_array.sum())
同步点与优化
- 在每次归约迭代中,我们使用
__syncthreads()确保所有线程完成对共享内存的读写,防止竞态。 - 通过“展开”(unrolling)让每个线程在第一步读取多达两个元素,减少循环次数并提高对全局内存的利用率。
- 采用共享内存折半归约,大幅降低全局内存访问次数。
若要处理更大规模数据,只需增加区块数量,并可递归地对 partial_sums 再次归约,直到只剩一个区块。与串行归约相比,这种方法在大数组上能带来显著的加速效果。
使用共享内存的分块矩阵乘法
我们之前了解到 CUDA 有多种内存空间,其中共享内存(shared memory)对优化尤为重要。它是每个流式多处理器(SM)上由用户管理的一块小型、超高速的内存区域,比全局内存快得多,并且同一线程块内的所有线程都可以访问。共享内存允许块内线程协作加载数据、重复使用中间结果,从而减少全局内存访问、提升计算密度。由于每个块可用的共享内存通常受限(现代 GPU 通常在 48 KB 或 96 KB 左右),最佳用例是那些被频繁重用的数据,例如在矩阵乘法中的分块(tiles)。
矩阵乘法是科学计算、图形学和机器学习中的基础操作。朴素的 GPU 实现会在全局内存中多次读取同一个元素,导致极高的内存流量并成为性能瓶颈。通过分块——将大矩阵按小子矩阵划分,每个子矩阵加载到共享内存——我们确保每个元素在对应子块中只需从全局内存读取一次,然后由本块内所有线程重用。该策略大幅削减全局内存访问。
分块矩阵乘法示例
下面演示如何对两个 N×N 的方阵进行分块乘法,并利用共享内存加速。
准备数据
import numpy as np
import pycuda.autoinit
import pycuda.driver as drv
import pycuda.gpuarray as gpuarray
from pycuda.compiler import SourceModule
N = 1024 # 矩阵大小
TILE_SIZE = 32 # 分块大小,最好能整除 N
# 在主机上生成随机矩阵,并拷贝到设备
A_host = np.random.rand(N, N).astype(np.float32)
B_host = np.random.rand(N, N).astype(np.float32)
A_gpu = gpuarray.to_gpu(A_host)
B_gpu = gpuarray.to_gpu(B_host)
C_gpu = gpuarray.zeros((N, N), dtype=np.float32)
CUDA Kernel(共享内存分块)
kernel_code = f"""
#define TILE_SIZE {TILE_SIZE}
__global__ void matmul_tiled(float *A, float *B, float *C, int N)
{{
__shared__ float sA[TILE_SIZE][TILE_SIZE];
__shared__ float sB[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
float sum = 0.0f;
for (int t = 0; t < N / TILE_SIZE; ++t) {{
// 协作式加载到共享内存
sA[threadIdx.y][threadIdx.x] = A[row * N + t * TILE_SIZE + threadIdx.x];
sB[threadIdx.y][threadIdx.x] = B[(t * TILE_SIZE + threadIdx.y) * N + col];
__syncthreads();
// 计算子块内的部分和
for (int k = 0; k < TILE_SIZE; ++k) {{
sum += sA[threadIdx.y][k] * sB[k][threadIdx.x];
}}
__syncthreads(); // 同步后加载下一对子块
}}
C[row * N + col] = sum;
}}
"""
mod = SourceModule(kernel_code)
matmul_tiled = mod.get_function("matmul_tiled")
启动 Kernel
block = (TILE_SIZE, TILE_SIZE, 1)
grid = (N // TILE_SIZE, N // TILE_SIZE, 1)
matmul_tiled(
A_gpu, B_gpu, C_gpu, np.int32(N),
block=block,
grid=grid
)
验证结果
C_host = C_gpu.get()
C_ref = np.dot(A_host, B_host)
print("结果一致吗?", np.allclose(C_host, C_ref, atol=1e-3))
每个线程块将对应的 A 和 B 子块从全局内存加载到共享内存,之后本块内线程可多次复用这些数据,而无需再访问全局内存。相比于每个线程在全局内存中多次读取同一行或列的元素,分块策略显著减少了慢速内存访问次数,大多数访问都命中高速的共享内存,从而大幅提升了矩阵乘法的性能。
Python 中的并行映射模式
虽然逐元素算术很强大,但很多实际应用需要更复杂的变换:非线性函数、自定义查表、条件逻辑,甚至多步链式操作。Python 内置的 map() 函数或列表推导式(如 [f(x) for x in xs])提供了一种简洁表达的方式,对集合中的每个元素应用函数。但这些模式是串行执行的,无法利用现代 GPU 的并行能力。当数据量增大或函数计算开销提升时,串行执行的性能瓶颈就会十分明显。
并行映射模式
并行映射模式(Parallel Map)为此提供了一种通用解决方案:在成千上万的 GPU 线程上,将用户自定义函数并行地应用到每个数据元素上。每个线程接受自己的输入值、计算结果并写入输出数组。它与 Python 的 map() 功能相似,但具备 GPU 级别的规模和速度,能为适合的工作负载带来巨大的性能提升。
并行映射模式的优势在于灵活性——无论是简单的数学变换、数据归一化,还是任何无需元素间交互的操作,都可以用同样的方法实现。它还让 GPU 编程更加“Python 式”,将函数式编程的表达力带入高性能计算领域。
将映射模式映射到 GPU Kernel
下面通过实战示例演示如何用 PyCUDA 将自定义函数并行应用到数组的每个元素上——就像 Python 的 map(),但在 GPU 上充分并行处理。
定义自定义函数
假设我们要对每个元素应用机器学习中常用的 Sigmoid 激活函数:
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))
我们不在 Python 中循环,而是在 CUDA Kernel 中实现这个函数。
准备数据
import numpy as np
import pycuda.autoinit
import pycuda.driver as drv
import pycuda.gpuarray as gpuarray
from pycuda.compiler import SourceModule
N = 10_000_000
input_host = np.random.randn(N).astype(np.float32)
input_gpu = gpuarray.to_gpu(input_host)
output_gpu = gpuarray.empty_like(input_gpu)
编写并行映射 Kernel
kernel_code = """
__global__ void parallel_map(const float *input, float *output, int n)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n)
{
float x = input[idx];
// 应用 sigmoid 函数
output[idx] = 1.0f / (1.0f + expf(-x));
}
}
"""
mod = SourceModule(kernel_code)
parallel_map = mod.get_function("parallel_map")
启动 Kernel
threads_per_block = 256
blocks_per_grid = (N + threads_per_block - 1) // threads_per_block
parallel_map(
input_gpu, output_gpu, np.int32(N),
block=(threads_per_block, 1, 1),
grid =(blocks_per_grid, 1 )
)
拷回主机并验证
output_host = output_gpu.get()
sigmoid_np = 1.0 / (1.0 + np.exp(-input_host))
print("结果吻合吗?", np.allclose(output_host, sigmoid_np, atol=1e-6))
这个模式可以轻松扩展到多输入、复杂表达式或带参数的函数。无论是 ReLU、tanh、阈值处理,还是任意数学函数,都可将逻辑写入 Kernel,然后并行执行千万级元素。使用并行映射,我们只需关注变换本身,而无需为循环或性能担忧。
基础直方图计算
直方图是数据科学、图像处理及其他科学应用中的重要工具,可帮助我们了解数据分布、分析图像像素强度或统计数据集中的频率。对于小规模数组,NumPy 或 CPU 方法通常足够快速。但当数据规模达到数百万或数十亿,或在需要实时性能的场景(如流媒体视频、大规模分析)中,CPU 往往难以胜任。GPU 擅长并行处理大量操作,非常适合加速直方图计算,前提是我们能妥善解决并行更新带来的竞争问题。
使用 CuPy 实现 GPU 直方图
准备数据
假设我们有一组整数值(如像素强度):
import cupy as cp
import numpy as np
size = 10_000_000
nbins = 256 # 灰度值 0–255
# 随机生成数据并在 GPU 上分配直方图数组
data = cp.random.randint(0, nbins, size, dtype=cp.int32)
histogram = cp.zeros(nbins, dtype=cp.int32)
编写直方图 Kernel
每个线程处理数据的一部分,并使用原子操作在共享内存(shared memory)中更新相应的 bin,以减少对全局内存的竞争。最后,再将每个块的部分直方图合并到全局直方图中:
kernel_code = f"""
extern "C" __global__
void histogram(const int *data, int *hist, int N, int nbins)
{{
extern __shared__ int s_hist[];
int tid = threadIdx.x;
int gid = blockIdx.x * blockDim.x + threadIdx.x;
// 初始化共享直方图
for (int i = tid; i < nbins; i += blockDim.x)
s_hist[i] = 0;
__syncthreads();
// 在共享内存中累加
if (gid < N) {{
int bin = data[gid];
if (bin >= 0 && bin < nbins)
atomicAdd(&s_hist[bin], 1);
}}
__syncthreads();
// 将共享直方图合并到全局直方图
for (int i = tid; i < nbins; i += blockDim.x)
atomicAdd(&hist[i], s_hist[i]);
}}
"""
module = cp.RawModule(code=kernel_code)
hist_kernel = module.get_function("histogram")
配置并启动 Kernel
threads_per_block = 256
blocks_per_grid = (size + threads_per_block - 1) // threads_per_block
shared_mem_size = nbins * np.dtype(np.int32).itemsize
hist_kernel(
(blocks_per_grid,), (threads_per_block,),
(data, histogram, size, nbins),
shared_mem=shared_mem_size
)
拷回主机并验证
# 参考 CPU 版本直方图
hist_cpu = np.histogram(cp.asnumpy(data), bins=nbins, range=(0, nbins))[0]
# GPU 直方图
hist_gpu = histogram.get()
print("GPU 与 CPU 直方图一致吗?", np.array_equal(hist_cpu, hist_gpu))
上述代码中,每个线程块先在共享内存中构建自己的直方图片段,再通过原子加到全局直方图。这种方法在处理海量数据和多达数百个 bins 时,既能保证正确性,又具备良好可扩展性。
直方图计算是典型的数据密集型任务,通过共享内存与原子操作的精心设计,GPU 加速可显著提升性能。借助 CuPy 的 RawKernel,我们能够高效实现这一经典并行模式,从而以高速和大规模处理数据集、图像或流式数据。
总结
总而言之,我们已经掌握了多个常用的 GPU 并行模式及其实践实现:
- 逐元素操作:利用 CuPy 的广播机制和类 NumPy 语法,在 GPU 上对大数组执行高吞吐量算术运算。
- 并行归约:使用 PyCUDA 编写高效的求和归约 kernel,通过共享内存和精心同步,实现大规模数据的快速汇总,轻松扩展到各种归约任务。
- 分块矩阵乘法:利用 CUDA 共享内存对矩阵进行分块(tiling),大幅减少全局内存访问,从而显著提升矩阵运算的吞吐量。
- 并行映射:将 Python 的函数式
map()模式推广到 GPU,编写自定义 kernel 并行应用任意元素级函数,实现复杂变换。 - 直方图计算:结合共享内存与原子操作,在每个线程块内安全并发更新直方图子段,再合并到全局直方图,轻松处理数百万线程的并发更新。
通过这些模式,我们不仅提高了对高性能并行原语的理解,还学会了如何在实际工作中,应对大规模数据处理和复杂算法的挑战。