CUDA学习之路[5]——逐元素操作算子
目录
逐元素操作
1. 常见逐元素算子
| 类别 | 算子名称 | 数学表达式 / 描述 | 典型应用场景 |
|---|---|---|---|
| 基础算术 | 1. 加法Add | 特征融合、残差连接 | |
| 2. 减法Sub | 误差计算、差分运算 | ||
| 3. 乘法Mul | 注意力掩码、逐通道缩放 | ||
| 4. 除法Div | 归一化中间步骤、比值计算 | ||
| 5. 绝对值Abs | $ y[i] = [x[i]] | $ | |
| 6. 取反Neg | 梯度反转层 (GRL)、相位翻转 | ||
| 7. 平方 Square | 均方误差 (MSE) 计算 | ||
| 8. 平方根Sqrt | 标准差计算、RMSNorm | ||
| 9. 幂运算Pow | 对比度调整 (Gamma 校正) | ||
| 激活函数 | 10. ReLU | 深度神经网络标准激活函数 | |
| 11. Leaky ReLU | 解决神经元死亡问题 (GAN 常用) | ||
| 12. Sigmoid | 二分类概率输出、门控机制 | ||
| 13. Tanh | RNN/LSTM 内部状态激活 | ||
| 14. Swish / SiLU | EfficientNet、LLaMA 模型 | ||
| 15. GELU | (近似公式: ) | Transformer (BERT, GPT) 标准激活 | |
| 16. Hard Swish | MobileNetV3 移动端高效激活 | ||
| 17. ELU | 加速收敛且输出均值接近零 | ||
| 18. Softplus | ReLU 的平滑近似 | ||
| 裁剪与归一化 | 19. Clip / Clamp | 梯度裁剪、像素值范围约束 | |
| 20. 取最大值Max | 最大池化辅助操作、ReLU 变体 | ||
| 21. 取最小值Min | 对偶操作、距离场计算 | ||
| 22. 舍入 (Round / Ceil / Floor) | 量化感知训练 (QAT) 中的伪量化 | ||
| 特殊函数与图像 | 23. 指数运算Exp | Softmax 内部计算、对数似然 | |
| 24. 对数运算Log | 交叉熵损失内部计算 | ||
| 25. 取倒数Reciprocal | 除法优化、调和平均数 | ||
| 26. 取符号Sign | 二值化神经网络 (BNN) | ||
| 27. 数值比较 (Eq / Ne / Gt / Lt) | 生成布尔掩码、准确率统计 | ||
| 28. RGB 转灰度 | 计算机视觉预处理、单通道特征提取 | ||
| 29. 颜色反转 | 图像负片效果、数据增强 | ||
| 30. Dropout | 神经网络正则化 (掩码为逐元素伯努利采样) | ||
| 31. 缩放与偏置Scale & Bias | BatchNorm 的仿射变换部分 |
2. 核心特性
2.1 数据并行
以上所有算子的共同核心在于:输出位置 i 的值仅取决于输入数据在同一位置 i 的值(单目运算)或对应位置 i 的值(双目运算),计算过程不涉及相邻位置数据的求和、滑动窗口或矩阵乘法。
也就是说,在一个百万元素的张量上做 ReLU,第0个元素和第100个元素无任何关系。这种无跨线程数据依赖的特性,意味着它们具备优秀的数据并行性,是 GPU 优化中最容易达到理论峰值带宽的一类算子。
对GPU而言,这就意味着:
线程之间无需交换数据,也就不需要同步指令。
除了计算当前线程对应的全局偏移之外,没有额外的寻址开销。
2.2 极低的计算访存比
这是 Element-wise 算子最重要的性能特征,也决定了它所有的优化策略。
在 GPU 上执行一条算术指令(如浮点加法、乘法)的耗时,往往只有几个时钟周期;而访问一次全局显存的耗时,则是几百个时钟周期。
我们来看一个典型的逐元素加法操作:C[i] = A[i] + B[i]。
- 计算量:1 次加法。
- 访存量:读取 A、读取 B、写入 C。共计 3 次显存操作(假设单精度浮点,每次 4 字节)。 此时,计算访存比 = 1 FLOP / 12 Bytes ≈ 0.08 FLOP/Byte。 而主流 GPU 的理论峰值计算访存比通常在 10~20 FLOP/Byte 以上。
[!WARNING] 逐元素算子的瓶颈不在计算能力上,而在显存带宽上。 因此,衡量一个 Element-wise 核函数好坏的指标不是 TFLOPs,而是它是否跑满了显存带宽。
3. 优化方向
基于上述两个核心特性,针对 Element-wise 算子的 CUDA 优化方向非常明确,且万变不离其宗。
我们先来看一下最基础的模板代码:
// naive:一个线程只负责一个元素__global__ void add_kernel(const float* A, const float* B, float* C, int N) { // 1. 计算全局线程索引 int idx = blockIdx.x * blockDim.x + threadIdx.x;
// 2. 边界保护 if (idx < N) { // 3. 逐元素计算 C[idx] = A[idx] + B[idx]; }}在这个模板代码中,索引计算,边界检查、算子实现是我们通用不变的框架。如果要实现其他方案,例如ReLU、Sigmoid、Scale等,只需要替换序号3,其余代码完全不用动。
启动配置通常为:
int threadsPerBlock = 256;int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;add_kernel<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);那接下来我们逐步对其进行优化。
3.1. 访存合并与对齐
GPU 的显存系统并非以单个字节或者单个浮点数为单位与核函数交互,而是以内存事务(Memory Transaction) 为单位。
以常见的 NVIDIA GPU 架构为例,L2 缓存与显存控制器之间的最小传输单元通常为 32 字节。当 SM发出一次全局内存访问请求时,硬件会尝试将同一 Warp 的所有请求合并成尽可能少的事务。
- 理想情况:一个Warp的32个线程访问连续且对齐的32个float,也就是128字节,那么只需要发送4次32字节的事务,或者1次128字节的事务。
- 最坏情况:32个线程访问的内存地址在显存各处,并不连续,那么一次Warp的访存操作最差为32次独立的事务。
由于单次事务的延迟高达数百个时钟周期,拆分事务意味着带宽利用率断崖式下跌,核函数则需要等待数据。

3.2. 隐藏访存延迟
既然运算单元在等数据,那就让每一个线程多负责几个元素的处理。
这也就是非常著名的网格跨步循环(Grid-Stride Loop)
__global__ void add_kernel_v2(const float* A, const float* B, float* C, int N) { // 起始索引 int idx = blockIdx.x * blockDim.x + threadIdx.x; // 总线程数(网格跨度) int stride = gridDim.x * blockDim.x;
// 跨步循环处理多个元素 for (int i = idx; i < N; i += stride) { C[i] = A[i] + B[i]; }}启动配置并不需要覆盖整个N,而是可以让gridSize更小,这样每个线程就可以处理更多的数据。
int threadsPerBlock = 256;int blocksPerGrid = 128 * 2; // 故意只启动 SM 数量 × 若干倍,而非 N/256add_kernel_v2<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);这种方案实际上已经成为了Element Wise核函数的事实标准,这样无论数据量多大,核函数都能够自适应,无需调整启动参数。
3.3. 向量化加载与存储
即使使用了 Grid-Stride Loop,每个线程每次循环依然只处理 1 个 float。而 GPU 的显存总线位宽通常是 32 字节(或更高),这意味着硬件有能力在一次事务中搬运更多数据。
因此我们可以在一次事务中批量进行加载数据。 CUDA 提供了内置的向量类型:float2、float4、double2 等。一个 float4 变量包含 4 个连续的 float,共 16 字节。使用它可以将 4 次 32-bit 访存合并为 1 次 128-bit 访存。
那我们要注意的就是原本idx的计算方式,要进行换算。
__global__ void add_kernel_v3(const float* A, const float* B, float* C, int N) { // 向量化:一次处理 4 个元素 const float4* A4 = reinterpret_cast<const float4*>(A); const float4* B4 = reinterpret_cast<const float4*>(B); float4* C4 = reinterpret_cast<float4*>(C);
int idx = blockIdx.x * blockDim.x + threadIdx.x; int stride = gridDim.x * blockDim.x;
// 以 float4 为单位循环 for (int i = idx; i < N / 4; i += stride) { float4 a = A4[i]; float4 b = B4[i]; float4 c; c.x = a.x + b.x; c.y = a.y + b.y; c.z = a.z + b.z; c.w = a.w + b.w; C4[i] = c; }
// 处理尾部不足 4 的剩余元素(略)}那通过Grid-Stride Loop与向量化加载结合,这样就能够使得单个线程的计算密度足够高。
模板代码
template<typename T, typename Op>__global__ void elementwise_kernel(const T* input, T* output, int N, Op op) { using VecT = typename VecType<T, 4>::Type; // 假设 VecType 特化了 float->float4 const VecT* in_vec = reinterpret_cast<const VecT*>(input); VecT* out_vec = reinterpret_cast<VecT*>(output);
int idx = blockIdx.x * blockDim.x + threadIdx.x; int stride = gridDim.x * blockDim.x; int vec_N = N / 4;
for (int i = idx; i < vec_N; i += stride) { VecT in = in_vec[i]; VecT out; // 对向量中每个元素应用算子 out.x = op(in.x); out.y = op(in.y); out.z = op(in.z); out.w = op(in.w); out_vec[i] = out; }
// 尾部标量处理(保证 N 非 4 倍数时的正确性) for (int i = vec_N * 4 + idx; i < N; i += stride) { output[i] = op(input[i]); }}支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!