Softmax

1877 字
9 分钟
Softmax

Softmax#

LeetGPU 题面#

warp 级 max 和 sum#

/* Warp-level reduction operation */
template<int WARP_SIZE = 32>
__device__ __forceinline__ float warp_max(float val) {
#pragma unroll
for (int mask = WARP_SIZE >> 1; mask >= 1; mask >>= 1) {
val = fmaxf(val, __shfl_xor_sync(0xffffffff, val, mask));
}
return val;
}

修饰词 & 内联标识#

__forceinline__#

其中,__forceinline__ 是 CUDA 函数修饰符,强制编译器内联该函数,无视启发式阈值。

  • 节省函数调用开销,包括寄存器保存、恢复和指令跳转等操作。
  • 代价:代码膨胀,寄存器压力增加,可能降低占用率。
  • 常用于小函数、device 函数、被 #pragma unroll 循环体内调用的函数。

#pragma unroll#

#pragma unroll 是 CUDA 编译器指令,告诉编译器完全展开紧跟其后的 for 循环。

像上述的代码中所示的那样, WARP_SIZE = 32, 所以 mask 的取值序列是 16-8-4-2-1,编译器会将循环展开为:

val = fmaxf(val, __shfl_xor_sync(0xffffffff, val, 16));
val = fmaxf(val, __shfl_xor_sync(0xffffffff, val, 8));
val = fmaxf(val, __shfl_xor_sync(0xffffffff, val, 4));
val = fmaxf(val, __shfl_xor_sync(0xffffffff, val, 2));
val = fmaxf(val, __shfl_xor_sync(0xffffffff, val, 1));

之所以我们需要用到它,是因为它可以:

  • 消除循环控制开销
  • 更多指令级并行(ILP)
  • 编译器优化机会

::: tip 指令级并行:处理器同时执行多条不相干的指令,而不是串行等待。 :::

__shfl_xor_sync#

warp_max 用来在一个 warp 内求所有线程的最大值,并将最大值广播到 warp 中每个线程。

fmaxf 函数是求浮点数中的最大值。

__shfl_xor_sync 函数是 CUDA warp shuffle 指令,让同一个 warp 内的线程直接交换寄存器值,不走共享内存,不经过显存。

三个参数的含义#

参数含义
0xffffffff参与掩码,32 位全 1 = warp 内 32 个线程全部参与
val当前线程要交换的寄存器值
maskXOR 偏移量,目标线程 ID = 当前线程 ID XOR mask

工作机制#

以 mask = 16 为例:

线程 0 ←→ XOR 16 → 线程 16
线程 1 ←→ XOR 16 → 线程 17
线程 2 ←→ XOR 16 → 线程 18
...
线程 15 ←→ XOR 16 → 线程 31

__shfl_xor_sync 返回配对线程的 val 值。当前线程拿到对方的 val,对方也拿到当前线程的 val

为什么用 XOR 而不是固定配对#

XOR 天然形成蝶形交换(butterfly shuffle),这是经典并行归约的通信模式:

轮次 mask=16: 线程0←→线程16, 线程1←→线程17, ... (跨半区交换)
轮次 mask=8 : 线程0←→线程8, 线程1←→线程9, ... (跨1/4区交换)
轮次 mask=4 : 线程0←→线程4, 线程1←→线程5, ... (跨1/8区交换)
轮次 mask=2 : 线程0←→线程2, 线程1←→线程3, ...
轮次 mask=1 : 线程0←→线程1, 线程2←→线程3, ...

每轮每个线程拿到配对线程的值和自己的值做 fmaxf,5 轮后所有 32 个线程都持有全 warp 的最大值。

和共享内存方案对比#

对比项__shfl_xor_sync共享内存归约
延迟~1 cycle(寄存器级)~20+ cycles(共享内存访问)
需要 __syncthreads()不需要需要
额外内存需要分配 shared memory

这就是为什么 warp-level reduction 几乎总是用 shuffle 指令而不是共享内存——寄存器交换是目前 GPU 上最快的线程间通信方式

核心机制: XOR 保证每轮交换不出重复配对,蝶形把局部最大值逐层往外扩散,log₂N 轮覆盖全 warp。没有共享内存,没有同步屏障,纯寄存器交换,一个 cycle 完成。

三个场景不可以用该方法#

  • 跨 warp 不行:shuffle 只在同一个 warp 内通信。
  • 通信模式固定:特定场景。
  • 线程分歧会出错:某些线程走了不同分支(if/else)。
指令行为典型场景
__shfl_sync从指定线程广播广播常量、配置
__shfl_up_sync拿上方线程的值前缀和、流式处理
__shfl_down_sync拿下方线程的值前缀和(反向)、数据移位
__shfl_xor_syncXOR 蝶形配对归约(求和、求最大值)

求和也是一样#

template<int WARP_SIZE = 32>
__device__ __forceinline__ float warp_sum(float val) {
#pragma unroll
for (int mask = WARP_SIZE >> 1; mask >= 1; mask >>= 1) {
// warp 内共享内存 寄存器直传 32 个线程
val += __shfl_xor_sync(0xffffffff, val, mask);
}
return val;
}

Block 级 max 和 sum#

template<int BLOCK_SIZE = 256>
__device__ __forceinline__ float block_max(float val) {
constexpr int WARP_SIZE = 32;
constexpr int NUM_WARPS = (BLOCK_SIZE + WARP_SIZE - 1) / WARP_SIZE;
const int warp_id = threadIdx.x / WARP_SIZE;
const int lane_id = threadIdx.x % WARP_SIZE;
static __shared__ float shared_mem[NUM_WARPS];
const float warp_max_val = warp_max<WARP_SIZE>(val);
if (lane_id == 0) {
shared_mem[warp_id] = warp_max_val;
}
__syncthreads();
const float thread_val = (lane_id < NUM_WARPS) ? shared_mem[lane_id] : -FLT_MAX;
const float total_max = warp_max<WARP_SIZE>(thread_val);
return total_max;
}

如上述代码所示,先求出一个 BLOCK 内的 WARP 数量,然后将线程 ID 拆分成 “哪个 warp” 和 “warp 里哪个”。

为什么只让 lane 0 写? warp 内 32 线程持有的 warp_max_val 都相同(归约结果广播了)。32 个线程写同一个值到同一个位置 → 浪费带宽,且可能写冲突。lane_id == 0 防重复写入。

接下来干什么? shared_mem 现在存的是每个 warp 的局部最大值,下一步再对这些值做一次归约,得到全局最大值。这是经典的两级归约:warp 内部归约 → 跨 warp 归约。

__syncthreads()#

屏障指令。block 内所有线程都到达这条线,才一起放行。

为什么需要它: 共享内存跨 warp 读写,写方(各个 warp 的 lane 0 写 shared_mem)和读方(warp 0 的 lane 0-7 读 shared_mem)之间没有硬件保证顺序。不加屏障 → 可能读到旧值。

warp 0: 写 shared_mem[0] ─┐
warp 1: 写 shared_mem[1] ─┤ 全到 → 同时放行
warp 2: 写 shared_mem[2] ─┤
warp 3: 写 shared_mem[3] ─┘
开始读 shared_mem,值全写完,正确。

第二级归约#

__syncthreads() 之后,shared_mem 存的是每个 warp 的局部最大值。接下来用 lane_id < NUM_WARPS 做边界检查:

  • lane_id < NUM_WARPS → 取 shared_mem[lane_id](有效 warp 最大值)
  • 其余 lane → 填 -FLT_MAX(负无穷,参与比较但永不获胜)

然后对 32 线程的 thread_val 再跑一次 warp_max,有效值留下,占位值被淘汰,得出全局最大值。

为何复用 warp_max NUM_WARPS ≤ 32(256 线程 / 32 = 8),一级归约结果正好装进一个 warp。用 -FLT_MAX 填充多余 lane,同一个函数搞定两级归约。

求和也是一样#

/* Block-level reduction operations */
template<int BLOCK_SIZE = 256>
__device__ __forceinline__ float block_sum(float val) {
constexpr int WARP_SIZE = 32;
constexpr int NUM_WARPS = (BLOCK_SIZE + WARP_SIZE - 1) / WARP_SIZE;
const int warp_id = threadIdx.x / WARP_SIZE;
const int lane_id = threadIdx.x % WARP_SIZE;
// warp 间 共享内存 同一个 block 内的所有 warp
static __shared__ float shared_mem[NUM_WARPS];
const float warp_sum_val = warp_sum<WARP_SIZE>(val);
if (lane_id == 0) {
shared_mem[warp_id] = warp_sum_val;
}
__syncthreads();
const float thread_val = (lane_id < NUM_WARPS) ? shared_mem[lane_id] : 0.0f;
const float total_sum = warp_sum<WARP_SIZE>(thread_val);
return total_sum;
}

softmax#

最后是将最大值和求和值拿来做 softmax 的运算。

/* Softmax kernel implementation */
template<int BLOCK_SIZE = 256>
__global__ void softmax_kernel(const float* input, float* output, const int N) {
const int tid = threadIdx.x;
// 找到所有线程中的最大值
float max_val = -FLT_MAX;
for (int i = tid; i < N; i += blockDim.x) {
max_val = fmaxf(max_val, input[i]);
}
max_val = block_max<BLOCK_SIZE>(max_val);
// 计算所有指数函数的和
float sum_val = 0.0f;
for (int i = tid; i < N; i += blockDim.x) {
const float exp_val = expf(input[i] - max_val);
sum_val += exp_val;
}
sum_val = block_sum<BLOCK_SIZE>(sum_val);
// 计算最终的归一化值
for (int i = tid; i < N; i += blockDim.x) {
output[i] = expf(input[i] - max_val) / sum_val;
}
}

分层接力#

线程扫数据 → 局部 max
warp 归约 → warp max(第一级)
共享内存 → 传给 warp 0
warp 归约 → block max(第二级,全局值)

::: tip 每一级往上是吞吐压缩,256 → 8 → 1,O(log₂N) 而不是 O(N)。 :::

HOST#

/* HOST */
extern "C" void solve(const float* input, float* output, const int N) {
constexpr int THREADS_PER_BLOCK = 1024;
softmax_kernel<THREADS_PER_BLOCK><<<1, THREADS_PER_BLOCK>>>(input, output, N);
cudaDeviceSynchronize();
}
Softmax
https://dongyanzhang.com/posts/leetgpu/softmax/
作者
阿东阿言
发布于
2026-07-12
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
阿东阿言
I share my notes | projects | experience here...
公告
欢迎来到阿东阿言的博客
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
25
分类
3
标签
5
总字数
11,186
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
v1.0.0
文章许可
CC BY-NC-SA 4.0