最近邻 2 倍上采样

算子在做什么,先看一个算例

最近邻 2 倍上采样(UpsampleNearest2D 2X)把一张特征图放大一倍:输出的每个像素直接取输入里对应的那个像素,不插值、不平均、不做任何加权——最近邻就是“复制”的意思。

它在网络里通常出现在需要“把特征图放大回原尺寸”的位置:FPN 自顶向下路径要把小特征图放大后与浅层特征相加、分割网络的解码端要逐步恢复到输入分辨率、VAE 解码器也要把隐变量逐级放大。这些场景里它往往不是性能瓶颈,但因为输入输出数据量差 4 倍,实际上是一个不小的带宽消耗点。

形状(NCHW 布局): in : N × C × H × W out : N × C × 2H × 2W 元素数是输入的 4 倍 数值映射(每个输入像素原样铺满输出的一个 2×2 块): out[n, c, 2h, 2w ] = in[n, c, h, w] out[n, c, 2h, 2w+1] = in[n, c, h, w] out[n, c, 2h+1, 2w ] = in[n, c, h, w] out[n, c, 2h+1, 2w+1] = in[n, c, h, w]

一个可以手工核对的算例

取一个 2×2 的输入(单通道):

输入 in(2×2): 输出 out(4×4): 1 2 1 1 2 2 3 4 1 1 2 2 3 3 4 4 3 3 4 4 核对:in(0,0)=1 → out 的 (0,0)(0,1)(1,0)(1,1) 四个位置 in(1,0)=3 → out 的 (2,0)(2,1)(3,0)(3,1) 四个位置

仓库里 demo 跑的是一个更容易肉眼检查的形状——2 个平面、每平面 2 行 3 列,填入 1~12:

in : 1 2 3 | 7 8 9 4 5 6 | 10 11 12 out: 1 1 2 2 3 3 | 7 7 8 8 9 9 1 1 2 2 3 3 | 7 7 8 8 9 9 4 4 5 5 6 6 | 10 10 11 11 12 12 4 4 5 5 6 6 | 10 10 11 11 12 12 核对:in 里第 5 个数(nc=0, h=1, w=1)的值是 5, 它出现在 out 的 (2,2)(2,3)(3,2)(3,3) 四个位置上。

反向:梯度怎么回去

反向传播时,正向的“1 个输入复制成 4 个输出”对应到梯度上就是“4 个输出的梯度加起来还给 1 个输入”。这是链式法则的直接结果:正向一个输入值被用在了 4 个地方,所以这 4 个地方各自算出的梯度都要累加到它头上。

dx[n, c, h, w] = dy[n, c, 2h, 2w ] + dy[n, c, 2h, 2w+1] + dy[n, c, 2h+1, 2w ] + dy[n, c, 2h+1, 2w+1] 用 dy 全填 1 来验证:每个 dx 都应该等于 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 可以合并成一个概念:“第几个平面”。

in 的线性下标(输入是 H×W 的平面,共若干平面): index = ((n*C + c) * H + h) * W + w = nc_idx * (H*W) + h*W + w └─ 第几个平面 ─┘ └ 平面内偏移 ┘ 还原: nc_idx = index / (H*W) 第几个平面 hw_off = index - nc_idx*(H*W) 平面内偏移 h = hw_off / W 行号 w = hw_off - h*W 列号

注意后两步用的是“算出商再反推余数”而不是 %:hw_off - h*W 和 hw_off % W 结果相同,但少了一次取模运算。这是整数除法代价高时的常规手法(这份代码里除法本身没省掉,见第 ⑤ 段的说明)。

② Pack2X:把水平相邻的两个元素绑在一起

upsample_nearest_2d.cpp : 21-25CUDA C++
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] 永远存的是同一个值。既然两个位置的值必然相同,那就一次写进去,省掉一条指令。

③ 网格步长宏

upsample_nearest_2d.cpp : 16-18CUDA C++
#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 条宽写

upsample_nearest_2d.cpp : 28-48CUDA C++
// 【正向】线程按**输入**下标划分:每线程读 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:

in_value = in_dptr[index] ← 1 条标量读 out_value = {in_value, in_value} ← 造一个“两个相同值”的包 out_pack[nc_idx*in_hw_size*2 + h*2*in_width + w] ← 写输出的第 2h 行 out_pack[nc_idx*in_hw_size*2 + (h*2+1)*in_width + w] ← 写输出的第 2h+1 行 (各覆盖 2 个元素)

输出那两个下标值得拆开看。输出每个平面的 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 条标量写,没有原子操作

upsample_nearest_2d.cpp : 52-78CUDA C++
// 【反向】梯度回传,线程按**输入(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 条标量写。

dy_pack_value1 = dy_pack[nc_idx*dx_hw_size*2 + dx_h*2*dx_width + dx_w] ← 含 (2h,2w) 和 (2h,2w+1) dy_pack_value2 = dy_pack[nc_idx*dx_hw_size*2 + (dx_h*2+1)*dx_width + dx_w] ← 含 (2h+1,2w) 和 (2h+1,2w+1) dx_value = v1.x + v1.y + v2.x + v2.y ← 4 项普通浮点加法 dx_dptr[index] = dx_value ← 1 条标量写

这 4 项加法不是 atomicAdd。原因在第 ① 段已经说过:这 4 个 dy 位置对应的那个输入像素只有一个,而负责它的线程也只有一个,别的线程不会碰同一个 dx 槽位——不需要任何同步。这正是“按输入划分”换来的东西。

顺带一提,反向 kernel 在仓库的 main 里从未被调用,所以它不会被实例化进二进制。本文的性能数据是另写一个 main 显式调用它才测到的。

⑦ main:一个只验证“跑得通”的测例

upsample_nearest_2d.cpp : 81-107CUDA C++
// 【测例】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.cpp · 全文 107 行CUDA C++
// ============================================================================
// 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.5432.01.00×
float正向OneFlow Pack2X1219.5550.31.27×
float反向朴素(含 atomicAdd)1466.3457.71.00×
float反向OneFlow Pack2X1092.3614.41.34×
half正向朴素(1 线程 1 输出)1379.2243.31.00×
half正向OneFlow Pack2X609.1550.92.26×
half反向朴素(含 atomicAdd)4203.179.81.00×
half反向OneFlow Pack2X543.7617.17.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)
PyTorchFloat32正向28.30%111.42
PyTorchFloat32反向60.16%65.12
OneFlowFloat32正向52.18%61.44
OneFlowFloat32反向77.66%50.56
PyTorchFloat16正向16.99%100.38
PyTorchFloat16反向31.56%57.38
OneFlowFloat16正向43.26%35.36
OneFlowFloat16反向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 倍——比任何指令级优化都值钱。

结论二:Pack2X 是“刚好够用”的宽度

它只把两个元素绑在一起,没有追求一次搬满 16 字节。原因很实际:正向的 4 个输出位置本来就是“上下两行各两个”,水平方向相邻的两个必须写同一个值——打包 2 个是数据结构决定的,不是硬凑的。

这和 elementwise 里那个“包宽由 16 字节除以元素大小算出来”的思路不同:那边是让编译器算最优宽度,这边是根据语义事实选一个刚好贴合的宽度。两种都成立,但后者通常更简单、也更好理解。

下一篇:GEMV 矩阵向量乘——换一类问题:这次瓶颈不再是写宽度,而是“一个 warp 怎么协作算出一行点积”。