CUDA学习之路[5]——逐元素操作算子

2409 字
12 分钟
CUDA学习之路[5]——逐元素操作算子

目录#

逐元素操作#

1. 常见逐元素算子#

类别算子名称数学表达式 / 描述典型应用场景
基础算术1. 加法AddC[i]=A[i]+B[i]C[i] = A[i] + B[i]特征融合、残差连接
2. 减法SubC[i]=A[i]B[i]C[i] = A[i] - B[i]误差计算、差分运算
3. 乘法MulC[i]=A[i]×B[i]C[i] = A[i] \times B[i]注意力掩码、逐通道缩放
4. 除法DivC[i]=A[i]/B[i]C[i] = A[i] / B[i]归一化中间步骤、比值计算
5. 绝对值Abs$ y[i] = [x[i]]$
6. 取反Negy[i]=x[i]y[i] = -x[i]梯度反转层 (GRL)、相位翻转
7. 平方 Squarey[i]=x[i]2y[i] = x[i]^2均方误差 (MSE) 计算
8. 平方根Sqrty[i]=x[i]y[i] = \sqrt{x[i]}标准差计算、RMSNorm
9. 幂运算Powy[i]=x[i]py[i] = x[i]^p对比度调整 (Gamma 校正)
激活函数10. ReLUy=max(0,x)y = \max(0, x)深度神经网络标准激活函数
11. Leaky ReLUy=max(αx,x)y = \max(\alpha x, x)解决神经元死亡问题 (GAN 常用)
12. Sigmoidy=11+exy = \frac{1}{1 + e^{-x}}二分类概率输出、门控机制
13. Tanhy=exexex+exy = \frac{e^x - e^{-x}}{e^x + e^{-x}}RNN/LSTM 内部状态激活
14. Swish / SiLUy=xσ(x)y = x \cdot \sigma(x)EfficientNet、LLaMA 模型
15. GELUy=xΦ(x)y = x \cdot \Phi(x)
(近似公式: 0.5x(1+tanh())0.5x(1+\tanh(\dots)))
Transformer (BERT, GPT) 标准激活
16. Hard Swishy=xReLU6(x+3)6y = x \cdot \frac{\text{ReLU6}(x+3)}{6}MobileNetV3 移动端高效激活
17. ELUy={xx>0α(ex1)x0y = \begin{cases} x & x>0 \\ \alpha(e^x - 1) & x \le 0 \end{cases}加速收敛且输出均值接近零
18. Softplusy=ln(1+ex)y = \ln(1 + e^x)ReLU 的平滑近似
裁剪与归一化19. Clip / Clampy=min(max(x,min),max)y = \min(\max(x, \text{min}), \text{max})梯度裁剪、像素值范围约束
20. 取最大值MaxC[i]=max(A[i],B[i])C[i] = \max(A[i], B[i])最大池化辅助操作、ReLU 变体
21. 取最小值MinC[i]=min(A[i],B[i])C[i] = \min(A[i], B[i])对偶操作、距离场计算
22. 舍入 (Round / Ceil / Floor)y=xy = \lfloor x \rceil量化感知训练 (QAT) 中的伪量化
特殊函数与图像23. 指数运算Expy[i]=ex[i]y[i] = e^{x[i]}Softmax 内部计算、对数似然
24. 对数运算Logy[i]=ln(x[i])y[i] = \ln(x[i])交叉熵损失内部计算
25. 取倒数Reciprocaly[i]=1/x[i]y[i] = 1 / x[i]除法优化、调和平均数
26. 取符号Signy[i]=sgn(x[i])y[i] = \text{sgn}(x[i])二值化神经网络 (BNN)
27. 数值比较 (Eq / Ne / Gt / Lt)y[i]=(A[i]==B[i])y[i] = (A[i] == B[i])生成布尔掩码、准确率统计
28. RGB 转灰度Y=0.299R+0.587G+0.114BY = 0.299R + 0.587G + 0.114B计算机视觉预处理、单通道特征提取
29. 颜色反转y=255xy = 255 - x图像负片效果、数据增强
30. Dropouty=xmask1py = \frac{x \cdot \text{mask}}{1-p}神经网络正则化 (掩码为逐元素伯努利采样)
31. 缩放与偏置Scale & Biasy=γx+βy = \gamma x + \betaBatchNorm 的仿射变换部分

2. 核心特性#

2.1 数据并行#

为什么这些都是Element wise?

以上所有算子的共同核心在于:输出位置 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次独立的事务。

由于单次事务的延迟高达数百个时钟周期,拆分事务意味着带宽利用率断崖式下跌,核函数则需要等待数据。

访存合并可视化展示
访存合并可视化展示
如这个图所示,我们左边是连续访问,右边是隔一个访问,可以看到左侧的事务为4次,而右侧的事务为8次,很明显访存次数翻倍,导致性能下降。

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/256
add_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]);
}
}

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
CUDA学习之路[5]——逐元素操作算子
https://dlog.com.cn/posts/cuda05/element_wise/
作者
杜子源
发布于
2026-04-13
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
杜子源
都是风景,幸会
公告
如果需要源码,可以B站私信我哦!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
12
分类
6
标签
7
总字数
19,878
运行时长
0
最后活动
0 天前

目录