算子在做什么,先看一个算例
最近邻 2 倍上采样(UpsampleNearest2D 2X)把一张特征图放大一倍:输出的每个像素直接取输入里对应的那个像素,不插值、不平均、不做任何加权——最近邻就是“复制”的意思。
它在网络里通常出现在需要“把特征图放大回原尺寸”的位置:FPN 自顶向下路径要把小特征图放大后与浅层特征相加、分割网络的解码端要逐步恢复到输入分辨率、VAE 解码器也要把隐变量逐级放大。这些场景里它往往不是性能瓶颈,但因为输入输出数据量差 4 倍,实际上是一个不小的带宽消耗点。
一个可以手工核对的算例
取一个 2×2 的输入(单通道):
仓库里 demo 跑的是一个更容易肉眼检查的形状——2 个平面、每平面 2 行 3 列,填入 1~12:
反向:梯度怎么回去
反向传播时,正向的“1 个输入复制成 4 个输出”对应到梯度上就是“4 个输出的梯度加起来还给 1 个输入”。这是链式法则的直接结果:正向一个输入值被用在了 4 个地方,所以这 4 个地方各自算出的梯度都要累加到它头上。
这里有一个结构性的设计选择,也是本文性能对比里差距最大的地方:如果按“每个输出像素一个线程”来划分反向,那么上式的 4 个 dy 会分散在 4 个不同线程手里,它们要写同一个 dx,就必须用 atomicAdd 来避免写冲突。而 OneFlow 这份 kernel 换了个划分方式——按输入划分:一个输入像素对应的那个 2×2 输出块,永远由同一个线程负责。这样 4 个梯度天然落在同一个线程的寄存器里,普通加法就够了,一个原子操作都不需要。
“按谁划分”在 GPU 上是个反复出现的基本决策。按输出划分看起来更自然(输出大、并行度看上去更高),但它把本该在同一个线程内完成的事情拆给了 4 个线程,代价是一条原子指令和随之而来的竞争。按输入划分并行度少 4 倍,但每个线程手里的活是自洽的——本文后面的实测会看到,这个选择在 half 上值 7.7 倍。
代码逐段解析
代码不长,核心只有两个 kernel。要理解它,先要跨过一道门槛:一维下标怎么还原成 (n, c, h, w)。
① 先把二维坐标问题压成一维
NCHW 布局下内存是连续的一维数组。kernel 的形参里只有 in_height、in_width 和元素总数,没有 N、也没有 C——因为上采样不动 n 和 c,只改 h、w。所以 n 和 c 可以合并成一个概念:“第几个平面”。
注意后两步用的是“算出商再反推余数”而不是 %:hw_off - h*W 和 hw_off % W 结果相同,但少了一次取模运算。这是整数除法代价高时的常规手法(这份代码里除法本身没省掉,见第 ⑤ 段的说明)。
② Pack2X:把水平相邻的两个元素绑在一起
template<typename T>
struct alignas(2 * sizeof(T)) Pack2X {
T x;
T y;
};
只有 4 行,但它是整个性能提升的来源。alignas(2 * sizeof(T)) 让 Pack2X<float> 的对齐从 4 字节抬到 8 字节,编译器才敢把对这个结构体的读写编译成 64 位的访存指令。没有这句 alignas,编译器无法证明对象落在 8 字节边界上,只能退回两次 4 字节访问。
为什么是“水平相邻的两个”而不是别的组合——因为正向的映射里,out[2w] 和 out[2w+1] 永远存的是同一个值。既然两个位置的值必然相同,那就一次写进去,省掉一条指令。
③ 网格步长宏
#define CUDA_1D_KERNEL_LOOP(i, n) \
for (int32_t i = blockIdx.x * blockDim.x + threadIdx.x, step = blockDim.x * gridDim.x; i < (n); \
i += step) // 【循环体末尾的 i += step】就是"跳到下一个归本线程的元素"
一个写死的 for 循环:从本线程的全局编号出发,每次跳“整个网格的线程数”。好处是 kernel 不挑 grid 大小——但注意 step 声明成了 int32_t,元素总数超过 2³¹ 就会溢出。本例是 2²⁵,离上限还远。
④ 正向 kernel:1 条标量读 + 2 条宽写
// 【正向】线程按**输入**下标划分:每线程读 1 个 in 元素,把它复制成输出上的一个 2x2 块。
// 4 个输出位置 = 2 个 Pack2X(上下两行各一个),所以是 2 条宽存储。
// 【形参】in_elem_cnt:输入元素总数(= N);in_height/in_width:输入的 H、W(输出是 2 倍);
// in_dptr:输入显存地址(只读);out_dptr:输出显存地址(4 倍元素数,写)。
template<typename T>
__global__ void UpsampleNearest2D2XForward(const int32_t in_elem_cnt, const T* in_dptr,
const int32_t in_height, const int32_t in_width,
T* out_dptr){
const int32_t in_hw_size = in_width * in_height; // 一个 (n,c) 平面的输入元素数;输出平面是它的 2 倍(pack 数)
CUDA_1D_KERNEL_LOOP(index, in_elem_cnt) { // index 是**输入**的线性下标:index = ((n*C + c)*H + h)*W + w
const T in_value = in_dptr[index]; // 1 条标量读(float 时 PTX 是 ld.global.b32)
const int32_t nc_idx = index / in_hw_size; // 第几个 (n,c) 平面。除数是运行期形参 → 走倒数近似,见文件头"整数除法的代价"
const int32_t hw_off = index - nc_idx * in_hw_size; // 平面内偏移;用商反推余数,省掉一次 `%`
const int32_t h = hw_off / in_width; // 输入行号(第二次除法)
const int32_t w = hw_off - h * in_width; // 输入列号(同样用商反推余数)
Pack2X<T> out_value{in_value, in_value}; // 造出一个"两个相同值"的 pack:{x,y} = {v,v}
Pack2X<T>* out_pack_dptr = reinterpret_cast<Pack2X<T>*>(out_dptr); // 换成 pack 视图(输出的一行有 2*in_width 个元素 = in_width 个 pack,(n,c) 平面的 pack 数是 2*in_hw_size);要求 out_dptr 8B(float)/4B(half) 对齐,见文件头"main 的测例…"第 3 条
out_pack_dptr[nc_idx * in_hw_size * 2 + h * 2 * in_width + w] = out_value; // 写输出的第 2h 行:覆盖 (2h,2w) 和 (2h,2w+1)。一个 warp 内 lane 只改变 w,32 个地址连续(float 时 256B 完整事务)
out_pack_dptr[nc_idx * in_hw_size * 2 + (h * 2 + 1) * in_width + w] = out_value; // 写输出的第 2h+1 行:覆盖 (2h+1,2w) 和 (2h+1,2w+1)。与上一条相隔 2*in_width 个元素(in_width=1024 时 8KB),也是独立的完整事务
}
}
线程按输入下标划分,所以每个线程只读 1 个输入元素,但要写出 4 个输出位置。这 4 个位置构成一个 2×2 块,正好是上下两行各一个 Pack2X:
输出那两个下标值得拆开看。输出每个平面的 pack 数是 2 * in_hw_size(输出元素是 4 倍,但 pack 里装了 2 个,所以 pack 数是 2 倍);行内偏移是 h*2*in_width——输出一行有 2*in_width 个元素,也就是 in_width 个 pack,所以“第 2h 行的行首”正好是 2h * in_width 个 pack。最后 +w 是关键:pack 的组号恰好等于输入列号 w,因为第 w 个 pack 覆盖的就是输出第 2w、2w+1 两列。
访存效果:一个 warp 的 32 个 lane 只在下标 w 上不同,32 个连续的 8 字节 pack = 256 字节完整事务。如果不用 pack,同样这 256 字节范围会被拆成 4 条各自只填一半字节的指令。
还有一处细节:h*2*in_width 和 (h*2+1)*in_width 这两个地址之间隔了 in_width 个 pack = 8 KB(in_width=1024 时),所以它们是两次独立的完整事务,不会互相抵消。
⑤ 整数除法的代价
第 ① 段那两次除法,除数是运行期形参(in_hw_size、in_width),编译期无法把它变成移位。SASS 里会走浮点倒数近似(I2F.RP + MUFU.RCP),整个正向 kernel 编译出来 96 条 SASS 指令——对这样一个“搬运”kernel 来说相当可观。
还好这个 kernel 是纯带宽受限的,计算被访存盖住了,所以实测中它没成为瓶颈。真要消掉它,可以换成三维 grid(用 blockIdx.z 当平面号)或者把 h、w 在 host 侧预计算好传进来。
⑥ 反向 kernel:2 条宽读 + 1 条标量写,没有原子操作
// 【反向】梯度回传,线程按**输入(dx,小图)**下标划分:每线程收自己那 2x2 块的 4 个 dy。
// 4 个 dy = 2 个 Pack2X(上下两行各一个),所以是 2 条宽读;dx 只有 1 个值,标量写。
// 【形参】命名沿用正向的套路但含义换了:in_elem_cnt 这里指 dx 的元素数(= 正向的输入数),
// dy_dptr 是上游梯度(大图,4 倍),dx_dptr 是要算的输入梯度(小图)。
template<typename T>
__global__ void UpsampleNearest2D2XBackward(const int32_t in_elem_cnt, const T* dy_dptr,
const int32_t dx_height, const int32_t dx_width,
T* dx_dptr) {
const int32_t dx_hw_size = dx_height * dx_width;
CUDA_1D_KERNEL_LOOP(index, in_elem_cnt) {
T dx_value = 0.0;
const int32_t nc_idx = index / dx_hw_size; // 与正向同样的两次除法
const int32_t dx_hw_off = index - nc_idx * dx_hw_size;
const int32_t dx_h = dx_hw_off / dx_width;
const int32_t dx_w = dx_hw_off - dx_h * dx_width;
const Pack2X<T>* dy_pack_dptr = reinterpret_cast<const Pack2X<T>*>(dy_dptr); // dy 用同一个 pack 视图,因为正向就是这么写下去的
const Pack2X<T> dy_pack_value1 =
dy_pack_dptr[nc_idx * dx_hw_size * 2 + dx_h * 2 * dx_width + dx_w]; // dy 第 2*dx_h 行的那一个 pack:含 (2h,2w) 与 (2h,2w+1) 两个梯度
const Pack2X<T> dy_pack_value2 =
dy_pack_dptr[nc_idx * dx_hw_size * 2 + (dx_h * 2 + 1) * dx_width + dx_w]; // dy 第 2*dx_h+1 行:含 (2h+1,2w) 与 (2h+1,2w+1)
dx_value += dy_pack_value1.x; // 4 项相加:正向里同一个 in_value 复制到 4 个位置,
dx_value += dy_pack_value1.y; // 反向按链式法则就是把这 4 个位置的梯度加起来。这里是普通
dx_value += dy_pack_value2.x; // 浮点加法(不是 atomicAdd):4 项都在本线程手里,
dx_value += dy_pack_value2.y; // 别的线程不会碰同一个 dx 槽位
dx_dptr[index] = dx_value; // 标量写(float 时 PTX 是 st.global.b32)
}
}
反向把方向倒过来:还是按输入(也就是 dx,小图)划分,但这次每个线程要收自己那 2×2 块的 4 个 dy。4 个 dy 正好是上下两行各一个 pack,所以是 2 条 64 位宽读;而 dx 每个位置只有一个值,所以是 1 条标量写。
这 4 项加法不是 atomicAdd。原因在第 ① 段已经说过:这 4 个 dy 位置对应的那个输入像素只有一个,而负责它的线程也只有一个,别的线程不会碰同一个 dx 槽位——不需要任何同步。这正是“按输入划分”换来的东西。
顺带一提,反向 kernel 在仓库的 main 里从未被调用,所以它不会被实例化进二进制。本文的性能数据是另写一个 main 显式调用它才测到的。
⑦ main:一个只验证“跑得通”的测例
// 【测例】1024x1024 的输入、32 个 (n,c) 平面、倍率 2,只跑正向并打印前 50 个输出值。
int main(){
float *input_host = (float*)malloc(N*sizeof(float)); // 主机输入缓冲 128MB
float *input_device;
cudaMalloc((void **)&input_device, N*sizeof(float)); // 设备输入缓冲 128MB;cudaMalloc 的地址 256B 对齐,满足 Pack2X<float> 的 8B 要求
for (int i = 0; i < N; i++) input_host[i] = 1.0; // 【全 1 数据】只能验证不崩/不越界,验证不了下标映射(见文件头"main 的测例…"第 2 条)
cudaMemcpy(input_device, input_host, N*sizeof(float), cudaMemcpyHostToDevice);
float *output_host = (float*)malloc(N * 4 * sizeof(float)); // 输出 4 倍元素数 = 512MB
float *output_device;
cudaMalloc((void **)&output_device, N * 4 * sizeof(float));
// 【启动配置】grid = 33,554,432/256 = 131,072 个 block,线程总数恰好 = N,
// 于是 grid-stride 循环每线程只走一圈。本次 kernel 的访存量:读 128MB + 写 512MB = 640MB,
// 写读比 4:1 —— 属于纯带宽受限,所以"每条指令搬几字节"直接决定性能。
dim3 grid(N / kBlockSize, 1);
dim3 block(kBlockSize, 1);
UpsampleNearest2D2XForward<<<grid, block>>>(N, input_device, 1024, 1024, output_device); // 显式传 1024x1024;N/1024/1024 = 32 个平面
cudaMemcpy(output_host, output_device, N * 4 * sizeof(float), cudaMemcpyDeviceToHost);
for(int i = 0; i < 50; i++) {
printf("%.5f\n", output_host[i]); // 本机(RTX 5070 / sm_120)实测打印 50 行 1.00000,与原版一致
}
// 【原样保留】host 的 malloc 与 device 的 cudaMalloc 都没有释放,进程退出后由 OS 回收。
// 反向 kernel UpsampleNearest2D2XBackward 这里没调用,也就不会被实例化进二进制。
return 0;
}
输入全填 1.0,输出打印前 50 个值——自然全是 1.00000。这个测例只能证明“没崩、没越界”,验证不了下标映射算得对不对(全 1 的输入下,任何映射都输出全 1)。本文的性能测试里,我换成了递增数据并与 CPU 参考实现逐元素对拍,正反向都确认输出一致。
另外这段 main 里没有计时代码——注释里也写明了“耗时是 README 里用 ncu 外部测的”。本文的耗时是另加 cudaEvent 测的。
完整代码
下面是 upsample_nearest_2d.cpp 全文(107 行),一字未改。它是同一份 kernel 的轻注释版本(仓库里另有注释更密的 _annotated.cu 与零注释的原版 .cu,三者去掉注释后代码完全一致)。
// ============================================================================
// upsample_nearest_2d —— OneFlow 的 2 倍最近邻上采样前后向 kernel:
// 用 Pack2X<T> 把"一次搬 1 个元素"改成"一次搬水平相邻的 2 个元素"
// ============================================================================#
// include <cuda.h> // driver API,本文件没用到(kernel 启动走的是 runtime)
#include <cuda_runtime.h> // 真正需要的:__global__、cudaMalloc/cudaMemcpy、dim3
#include <time.h> // 【未使用】main 里没有计时代码,耗时是 README 里用 ncu 外部测的
#include <algorithm> // 【未使用】
#include <iostream> // 【未使用】打印用的是 printf(随 cuda_runtime 间接引入)
#include <cuda_fp16.h> // 本文件 main 只实例化 float,这个 include 是给 T=__half 准备的
using namespace std; // 【本文件其实没用到 std 里的东西】reinterpret_cast 是关键字,不是 std 成员
#define N 32*1024*1024 // 输入元素个数 33,554,432(= 32 个 1024x1024 平面);输出是 4N
#define kBlockSize 256 // 每 block 线程数
#define CUDA_1D_KERNEL_LOOP(i, n) \
for (int32_t i = blockIdx.x * blockDim.x + threadIdx.x, step = blockDim.x * gridDim.x; i < (n); \
i += step) // 【循环体末尾的 i += step】就是"跳到下一个归本线程的元素"
template<typename T>
struct alignas(2 * sizeof(T)) Pack2X {
T x;
T y;
};
// 【正向】线程按**输入**下标划分:每线程读 1 个 in 元素,把它复制成输出上的一个 2x2 块。
// 4 个输出位置 = 2 个 Pack2X(上下两行各一个),所以是 2 条宽存储。
// 【形参】in_elem_cnt:输入元素总数(= N);in_height/in_width:输入的 H、W(输出是 2 倍);
// in_dptr:输入显存地址(只读);out_dptr:输出显存地址(4 倍元素数,写)。
template<typename T>
__global__ void UpsampleNearest2D2XForward(const int32_t in_elem_cnt, const T* in_dptr,
const int32_t in_height, const int32_t in_width,
T* out_dptr){
const int32_t in_hw_size = in_width * in_height; // 一个 (n,c) 平面的输入元素数;输出平面是它的 2 倍(pack 数)
CUDA_1D_KERNEL_LOOP(index, in_elem_cnt) { // index 是**输入**的线性下标:index = ((n*C + c)*H + h)*W + w
const T in_value = in_dptr[index]; // 1 条标量读(float 时 PTX 是 ld.global.b32)
const int32_t nc_idx = index / in_hw_size; // 第几个 (n,c) 平面。除数是运行期形参 → 走倒数近似,见文件头"整数除法的代价"
const int32_t hw_off = index - nc_idx * in_hw_size; // 平面内偏移;用商反推余数,省掉一次 `%`
const int32_t h = hw_off / in_width; // 输入行号(第二次除法)
const int32_t w = hw_off - h * in_width; // 输入列号(同样用商反推余数)
Pack2X<T> out_value{in_value, in_value}; // 造出一个"两个相同值"的 pack:{x,y} = {v,v}
Pack2X<T>* out_pack_dptr = reinterpret_cast<Pack2X<T>*>(out_dptr); // 换成 pack 视图(输出的一行有 2*in_width 个元素 = in_width 个 pack,(n,c) 平面的 pack 数是 2*in_hw_size);要求 out_dptr 8B(float)/4B(half) 对齐,见文件头"main 的测例…"第 3 条
out_pack_dptr[nc_idx * in_hw_size * 2 + h * 2 * in_width + w] = out_value; // 写输出的第 2h 行:覆盖 (2h,2w) 和 (2h,2w+1)。一个 warp 内 lane 只改变 w,32 个地址连续(float 时 256B 完整事务)
out_pack_dptr[nc_idx * in_hw_size * 2 + (h * 2 + 1) * in_width + w] = out_value; // 写输出的第 2h+1 行:覆盖 (2h+1,2w) 和 (2h+1,2w+1)。与上一条相隔 2*in_width 个元素(in_width=1024 时 8KB),也是独立的完整事务
}
}
// 【反向】梯度回传,线程按**输入(dx,小图)**下标划分:每线程收自己那 2x2 块的 4 个 dy。
// 4 个 dy = 2 个 Pack2X(上下两行各一个),所以是 2 条宽读;dx 只有 1 个值,标量写。
// 【形参】命名沿用正向的套路但含义换了:in_elem_cnt 这里指 dx 的元素数(= 正向的输入数),
// dy_dptr 是上游梯度(大图,4 倍),dx_dptr 是要算的输入梯度(小图)。
template<typename T>
__global__ void UpsampleNearest2D2XBackward(const int32_t in_elem_cnt, const T* dy_dptr,
const int32_t dx_height, const int32_t dx_width,
T* dx_dptr) {
const int32_t dx_hw_size = dx_height * dx_width;
CUDA_1D_KERNEL_LOOP(index, in_elem_cnt) {
T dx_value = 0.0;
const int32_t nc_idx = index / dx_hw_size; // 与正向同样的两次除法
const int32_t dx_hw_off = index - nc_idx * dx_hw_size;
const int32_t dx_h = dx_hw_off / dx_width;
const int32_t dx_w = dx_hw_off - dx_h * dx_width;
const Pack2X<T>* dy_pack_dptr = reinterpret_cast<const Pack2X<T>*>(dy_dptr); // dy 用同一个 pack 视图,因为正向就是这么写下去的
const Pack2X<T> dy_pack_value1 =
dy_pack_dptr[nc_idx * dx_hw_size * 2 + dx_h * 2 * dx_width + dx_w]; // dy 第 2*dx_h 行的那一个 pack:含 (2h,2w) 与 (2h,2w+1) 两个梯度
const Pack2X<T> dy_pack_value2 =
dy_pack_dptr[nc_idx * dx_hw_size * 2 + (dx_h * 2 + 1) * dx_width + dx_w]; // dy 第 2*dx_h+1 行:含 (2h+1,2w) 与 (2h+1,2w+1)
dx_value += dy_pack_value1.x; // 4 项相加:正向里同一个 in_value 复制到 4 个位置,
dx_value += dy_pack_value1.y; // 反向按链式法则就是把这 4 个位置的梯度加起来。这里是普通
dx_value += dy_pack_value2.x; // 浮点加法(不是 atomicAdd):4 项都在本线程手里,
dx_value += dy_pack_value2.y; // 别的线程不会碰同一个 dx 槽位
dx_dptr[index] = dx_value; // 标量写(float 时 PTX 是 st.global.b32)
}
}
// 【测例】1024x1024 的输入、32 个 (n,c) 平面、倍率 2,只跑正向并打印前 50 个输出值。
int main(){
float *input_host = (float*)malloc(N*sizeof(float)); // 主机输入缓冲 128MB
float *input_device;
cudaMalloc((void **)&input_device, N*sizeof(float)); // 设备输入缓冲 128MB;cudaMalloc 的地址 256B 对齐,满足 Pack2X<float> 的 8B 要求
for (int i = 0; i < N; i++) input_host[i] = 1.0; // 【全 1 数据】只能验证不崩/不越界,验证不了下标映射(见文件头"main 的测例…"第 2 条)
cudaMemcpy(input_device, input_host, N*sizeof(float), cudaMemcpyHostToDevice);
float *output_host = (float*)malloc(N * 4 * sizeof(float)); // 输出 4 倍元素数 = 512MB
float *output_device;
cudaMalloc((void **)&output_device, N * 4 * sizeof(float));
// 【启动配置】grid = 33,554,432/256 = 131,072 个 block,线程总数恰好 = N,
// 于是 grid-stride 循环每线程只走一圈。本次 kernel 的访存量:读 128MB + 写 512MB = 640MB,
// 写读比 4:1 —— 属于纯带宽受限,所以"每条指令搬几字节"直接决定性能。
dim3 grid(N / kBlockSize, 1);
dim3 block(kBlockSize, 1);
UpsampleNearest2D2XForward<<<grid, block>>>(N, input_device, 1024, 1024, output_device); // 显式传 1024x1024;N/1024/1024 = 32 个平面
cudaMemcpy(output_host, output_device, N * 4 * sizeof(float), cudaMemcpyDeviceToHost);
for(int i = 0; i < 50; i++) {
printf("%.5f\n", output_host[i]); // 本机(RTX 5070 / sm_120)实测打印 50 行 1.00000,与原版一致
}
// 【原样保留】host 的 malloc 与 device 的 cudaMalloc 都没有释放,进程退出后由 OS 回收。
// 反向 kernel UpsampleNearest2D2XBackward 这里没调用,也就不会被实例化进二进制。
return 0;
}
输出指针必须对齐:Pack2X<float> 要求 8 字节、Pack2X<half> 要求 4 字节。cudaMalloc 返回的地址是 256 字节对齐,常规用法没问题;但如果你从一个更大的缓冲区里按元素切一段传进来,就可能踩到非对齐地址。
下标全程 int32:in_elem_cnt 必须小于 2³⁰,否则中间的乘法会溢出。本文的 2²⁵ 在安全范围内。
实测性能对比分析
数据规模 N = 32×1024×1024 = 33,554,432 个输入元素,即 32 个 1024×1024 的平面(输入 float 128 MiB、输出 512 MiB)。一次正向或反向的访存量都是 (1+4) × N × sizeof(T) = float 640 MB / half 320 MB,读写比 1:4,是典型的写多读少。
对照的基线是我另写的朴素版本:每个输出元素一个线程、纯标量访存。要特别说明的是——朴素反向版本因为按输出划分,4 个梯度分散在不同线程手里,必须用 atomicAdd。所以下面这组对比不只是“打包 vs 不打包”,也是“原子加 vs 无原子加”。
本机实测(RTX 5070)
| 数据类型 | 方向 | 实现 | 耗时 (µs) | 等效带宽 (GB/s) | 相对基线 |
|---|---|---|---|---|---|
| float | 正向 | 朴素(1 线程 1 输出) | 1553.5 | 432.0 | 1.00× |
| float | 正向 | OneFlow Pack2X | 1219.5 | 550.3 | 1.27× |
| float | 反向 | 朴素(含 atomicAdd) | 1466.3 | 457.7 | 1.00× |
| float | 反向 | OneFlow Pack2X | 1092.3 | 614.4 | 1.34× |
| half | 正向 | 朴素(1 线程 1 输出) | 1379.2 | 243.3 | 1.00× |
| half | 正向 | OneFlow Pack2X | 609.1 | 550.9 | 2.26× |
| half | 反向 | 朴素(含 atomicAdd) | 4203.1 | 79.8 | 1.00× |
| half | 反向 | OneFlow Pack2X | 543.7 | 617.1 | 7.73× |
四组数据里,half 反向那 7.73 倍是全部文章里最悬殊的一个数字,而且它的原因不在打包访存上——朴素版只跑到 79.8 GB/s,是峰值带宽的 12%,这个速度根本不是“访存慢”,而是被原子操作拖住了。
逐条看为什么会这样:
half + 反向 = 最坏组合。朴素版要对同一个 dx 槽位做 4 次原子加,而 half 的原子加只有 2 字节宽,占不满硬件的原子通路;33.5M 个半精度原子操作在 L2 上排队,直接把整条流水线堵死。OneFlow 版本把 4 个梯度收进同一个线程做普通加法,一个原子操作都没有,于是它跑回了正常的访存速度(617 GB/s,峰值占用 92%)。
float + 反向(1.34×)比 half 温和得多,因为 4 字节的原子加比 2 字节的效率高,朴素版还能跑到 457.7 GB/s,没有到堵死的程度。
正向没有原子操作,差距只来自访存宽度。float 正向朴素版 432 GB/s(因为每个输出位置都要独立算一遍下标、独立发一条 4 字节写,warp 内的写地址还因为 2×2 复制而不连续),打包后升到 550 GB/s;half 正向从 243 GB/s 升到 551 GB/s——朴素版的 2 字节写只用到通路的一小截,打包成 4 字节的 Pack2X<half> 后正好填满,翻了一倍多。
和 A100 上的数据对照
仓库 README 里有一组 A100-PCIE-40GB 上的 profile 数据,对比的是 PyTorch 与 OneFlow 各自的上采样实现,注意数据规模不同:那组用的是 16×32×80×80 的输入(OneFlow 官方脚本),不是本文的 32×1024×1024,所以绝对耗时不可直接比:
| 框架 | 数据类型 | 方向 | 带宽利用率 | 耗时 (µs) |
|---|---|---|---|---|
| PyTorch | Float32 | 正向 | 28.30% | 111.42 |
| PyTorch | Float32 | 反向 | 60.16% | 65.12 |
| OneFlow | Float32 | 正向 | 52.18% | 61.44 |
| OneFlow | Float32 | 反向 | 77.66% | 50.56 |
| PyTorch | Float16 | 正向 | 16.99% | 100.38 |
| PyTorch | Float16 | 反向 | 31.56% | 57.38 |
| OneFlow | Float16 | 正向 | 43.26% | 35.36 |
| OneFlow | Float16 | 反向 | 44.82% | 40.26 |
两份数据的结论方向是一致的:float 上提升有限,half 上提升显著。A100 上 OneFlow 相对 PyTorch:float 正向 1.81×、float 反向 1.29×、half 正向 2.84×、half 反向 1.43×。本文的对照反过来(float 提升小、half 提升大)也符合这个规律——窄类型在窄访问下的损失更大,补上宽度后收益也更大。
差别在于反向的幅度:A100 上 half 反向只快 1.43×,本机快 7.73×。原因是两份工作的基线不同——A100 那组比的是 PyTorch 的 kernel,它虽然也有原子操作,但实现方式和我写的朴素版不一样,没有落到“2 字节原子加堵死 L2”这个最坏情况里。
小结
这个算子给我们两个可以带走的结论。
正向“一个输入变成 4 个输出”、反向“4 个梯度并成 1 个输入”——这两种多对一、一对多的关系,如果按输出划分线程,就必须靠 atomicAdd 解决写冲突;按输入划分,一个线程负责完整的 2×2 块,冲突自然消失。
在动手写 atomicAdd 之前,先想想能不能改划分方式。本文的实测里,这一条在 half 反向值 7.73 倍——比任何指令级优化都值钱。
它只把两个元素绑在一起,没有追求一次搬满 16 字节。原因很实际:正向的 4 个输出位置本来就是“上下两行各两个”,水平方向相邻的两个必须写同一个值——打包 2 个是数据结构决定的,不是硬凑的。
这和 elementwise 里那个“包宽由 16 字节除以元素大小算出来”的思路不同:那边是让编译器算最优宽度,这边是根据语义事实选一个刚好贴合的宽度。两种都成立,但后者通常更简单、也更好理解。
下一篇:GEMV 矩阵向量乘——换一类问题:这次瓶颈不再是写宽度,而是“一个 warp 怎么协作算出一行点积”。