Element-wise 逐点运算

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

逐点运算(elementwise)是最简单的一类算子:输出数组的第 i 个元素只由输入数组的第 i 个元素算出。元素个数不变、形状不变、下标不动,线程之间没有任何数据交换。

r[i] = f(a[i], b[i], ...) i ∈ [0, n) 具体到本文的例子(逐点乘法,两输入一输出): z[i] = x[i] * y[i]

加法、乘法、ReLU、GELU、加法融合的 bias——只要满足“下标对下标”,都是同一个模板的实例。也正因为如此,它的性能完全由访存决定:算一个乘法只要 1 条指令,而读两个数写一个数要 3 次访存。这是典型的带宽受限算子。

一个可以手工核对的算例

取 n = 10、类型为 half(2 字节):

x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (half) y = [2, 2, 2, 2, 2, 2, 2, 2, 2, 2] (half) z = [2, 4, 6, 8,10,12,14,16,18, 20] (期望输出)

两种写法要做的事完全一样,差别只在每个线程一次搬几个元素。

朴素写法:一个线程算一个元素。开 10 个线程,线程 i 发 2 条 2 字节读 + 1 条 2 字节写,一共 30 条访存指令。每条指令只搬 2 字节,而 GPU 的访存通路是按 32 位、64 位、128 位来组织的——搬 2 字节等于只用了一小截。

模板写法:先问一句“一条 128 位访存指令能装几个 half?”——16 ÷ 2 = 8 个。于是把 10 个元素切成“1 个完整的包(下标 0~7)+ 2 个尾巴(下标 8~9)”:

n_pack = 10 / 8 = 1 完整包个数 tail_offset= 8 尾巴从哪里开始 n_tail = 10 - 8 = 2 尾巴元素个数 0 号线程处理第 0 包: 读 x[0..7] 1 条 128 位读 读 y[0..7] 1 条 128 位读 算 8 个积 4 条 __hmul2(一次成对算 2 个) 写 z[0..7] 1 条 128 位写 尾巴: 0 号线程补 z[8]、1 号线程补 z[9],走标量路径 访存指令合计:宽 3 条 + 窄 6 条 = 9 条(朴素版是 30 条) 搬的字节数完全一样,指令条数少了 3 倍多。

这个例子把整套模板的动机说完了:在不改变计算结果的前提下,把访存指令的条数压下来。而要做到这一点,模板必须同时处理好四件麻烦事——元素数除不尽(尾巴)、指针不对齐、元素类型不同(包宽不同)、运算不同(有的运算能成对算,有的不能)。下面逐段看它是怎么把这些都塞进编译期的。

代码逐段解析

代码分三层:类型层(定义“一个包”是什么)、调度层(决定一个包怎么算)、启动层(决定开多少 block、要不要退回标量)。下面按这三层走。

① 类型层:PackType / Pack —— 只在编译期算尺寸用

elementwise.cpp : 15-37CUDA C++
// 【pack 的存储类型】aligned_storage 给出"大小 = pack_size*sizeof(T)、对齐 = 同样大"的裸内存块。
//   注意它只是 POD 字节,没有成员可以访问,所以下面还要 union 一个 T elem[] 视图。
template<typename T, int pack_size>
struct GetPackType {
  using type = typename std::aligned_storage<pack_size * sizeof(T), pack_size * sizeof(T)>::type;
};

template<typename T, int pack_size>
using PackType = typename GetPackType<T, pack_size>::type;  


// 【Pack:只在 host 侧用来算 sizeof】union 让 storage 和 elem[] 共用同一块内存。
//   本文件里 Pack<T,pack_size> 唯一的用处是 IsAlignedForPack(:143) 里的 sizeof —— 拿它当
//   "一个包占几字节"的标尺。Packed(下面那个)才是 kernel 里真正用的类型。
template<typename T, int pack_size>
union Pack {
  static_assert(sizeof(PackType<T, pack_size>) == sizeof(T) * pack_size, "");   // 编译期自证:aligned_storage 没被额外 padding
  __device__ Pack() {
    // do nothing   ← 什么都不做的默认构造:既避免隐式构造产生清零代码,也让这个类型不能在 host 侧默认构造
  }
  PackType<T, pack_size> storage;   // 裸字节视图(决定对齐)
  T elem[pack_size];                // 元素视图
};

std::aligned_storage<N, N>::type 给出“大小 = N 字节、对齐 = N 字节”的一块裸内存。它的用途是当一把尺子:sizeof(Pack<T, pack_size>) 就是“一个包占几字节”。第 31 行的 static_assert 是编译期自证——确认 aligned_storage 没有被额外填充,否则“一个包 = 16 字节”这个前提就不成立了。

Pack 里那个什么都不做的默认构造 __device__ Pack() {} 也有讲究:如果写成 = default 或者干脆不写,编译器可能生成清零代码;显式写一个空实现,就明确告诉它“别生成任何东西”。

② 类型层:Packed —— kernel 里真正用的那个包

elementwise.cpp : 40-55CUDA C++
// 【Packed:kernel 里真正用的"一包"】alignas 是关键:没有它,Packed<half,8> 的 alignof 只有 2,
//   编译器就发不出 128 位访存指令(本机实测 PTX 是 ld.global.v4.b32,SASS 是 LDG.E.128)。
//   匿名 union 只有一个成员,作用是让 Packed<T,N> 能直接当数组用(.elem[j]),
//   同时保持是 trivially-copyable 的聚合体。
template<typename T, int pack_size>
struct alignas(sizeof(T) * pack_size) Packed {
  __device__ Packed() {
    // do nothing
  }
  union {
    T elem[pack_size];
  };
};

constexpr int kMaxPackBytes = 128 / 8;   // 16 字节 = 一条向量访存指令搬的量(128 bit)
constexpr int kMaxPackSize = 8;          // 上限 8 个元素(half 正好 16 字节;float 会先被 16/4=4 卡住)

alignas(sizeof(T) * pack_size) 是这里最关键的一句。没有它,Packed<half, 8> 的对齐只有成员的最大对齐(half 是 2 字节),编译器无法证明这个对象落在 16 字节边界上,就不敢发出 128 位的访存指令——打包白做。有了它,编译器才敢把 pack_r[i] 编译成一条 LDG.E.128。

匿名 union 里只有一个成员,作用只是让 packed.elem[j] 这种写法能直接用,同时保持整体是个 trivially-copyable 的聚合体(可以按值传参、进寄存器)。

③ 类型层:包宽是编译期算出来的

elementwise.cpp : 54-68CUDA C++
constexpr int kMaxPackBytes = 128 / 8;   // 16 字节 = 一条向量访存指令搬的量(128 bit)
constexpr int kMaxPackSize = 8;          // 上限 8 个元素(half 正好 16 字节;float 会先被 16/4=4 卡住)

constexpr int Min(int a, int b) { return a < b ? a : b; }   // constexpr 版 min(没用 std::min,是为了在模板实参里能用)

template<typename T>
constexpr int PackSize() {
  return Min(kMaxPackBytes / sizeof(T), kMaxPackSize);   // half→8、float→4、double→2(本机打印验证)
}


template<typename T, typename U, typename... Args>
constexpr int PackSize() {
  return Min(PackSize<T>(), PackSize<U, Args...>());   // 递归取"输出和所有输入"里的最小包宽:任一操作数是 double 就整体降到 2
}
PackSize<T>() = min( 16 字节 / sizeof(T) , 8 ) half (2B) → min(8, 8) = 8 个/包 float (4B) → min(4, 8) = 4 个/包 double (8B) → min(2, 8) = 2 个/包

kMaxPackBytes = 128/8 就是“一次访存最多搬 16 字节”,kMaxPackSize = 8 是为了别让包太宽。第二个变参版本 PackSize<T, U, Args...>() 递归取输出和所有输入里最小的那个包宽——只要有一个操作数是 double,整体就降到 2 个/包,保证类型对齐不出问题。

这里用 constexpr 而不是模板元编程

注意 Min 是自己写的 constexpr 版本,而不是 std::min——因为 std::min 在 C++11 的 CUDA 里不是 constexpr 的,没法用在模板实参里。这类“为了能进编译期”而手写的小函数在这个文件里到处出现。

④ 调度层:HasApply2 —— 问一句“这个 functor 能不能成对算”

elementwise.cpp : 71-89CUDA C++
// 【编译期问一句:"你这个 functor 有没有 Apply2 成员?"】
//   test(decltype(&C::Apply2)) 能成立就匹配第一个重载(返回 1 字节的 one),
//   否则靠 ... 匹配第二个(返回 2 字节的 two)。sizeof 一比即得。
//   为什么要问:有 Apply2 就能一次算 2 个元素(half2 成对乘),没有就只能逐个算。
template<typename T>
class HasApply2 {
  typedef char one;
  struct two {
    char x[2];
  };

  template<typename C>
  static one test(decltype(&C::Apply2));   // 只有 C::Apply2 存在时这个声明才合法(SFINAE)
  template<typename C>
  static two test(...);                    // 否则退到这里

 public:
  enum { value = sizeof(test<T>(0)) == sizeof(char) };   // 1 = 有,0 = 没有
};

这是一个经典的 SFINAE 探测。核心是 decltype(&C::Apply2):只有当 C 真的有 Apply2 成员时,第一个 test 重载的声明才合法;否则模板替换失败(SFINAE),编译器退到第二个 test(...)。两个重载的返回值分别是 1 字节的 one 和 2 字节的 two,sizeof 一比就知道走了哪个分支。

为什么要问这一句:half 的乘法有一条 __hmul2 指令能一次算 2 个 half,float 没有对应的双元素乘。有 Apply2 的 functor 走快路径,没有的走逐元素路径——而这个选择在编译期就定死了,没有运行期分支。

⑤ 调度层:ApplyPack 的两个重载

elementwise.cpp : 93-116CUDA C++
// 【ApplyPack 版本 A:functor 有 Apply2 且包宽是偶数】一次给 2 个元素的地址,让 functor 成对算。
//   形参 `const Packed<IN, pack_size>... in` 是变参模板的展开:每个输入一个包,按值传(16 字节,进寄存器)。
//   调用点 (:122) 传的是 (pack_in[i])...,即第 i 包的所有输入。
template<int pack_size, typename FunctorT, typename R, typename... IN>
__device__ typename std::enable_if<HasApply2<FunctorT>::value == true && pack_size % 2 == 0,
                                   Packed<R, pack_size>>::type
ApplyPack(const FunctorT& functor, const Packed<IN, pack_size>... in) {
  Packed<R, pack_size> ret;                    // 未初始化(默认构造什么都不做),下面逐元素写满
#pragma unroll
  for (int j = 0; j < pack_size; j += 2) { functor.Apply2(ret.elem + j, (in.elem + j)...); }   // 每次推进 2;传的是元素数组里的下标 j,不是包号
  return ret;
}

// 【ApplyPack 版本 B:没有 Apply2,或包宽是奇数】逐元素调 functor 的 operator()。
//   两个版本的函数体一样长,选哪个由 enable_if 决定;条件互补(|| 与 && 取反),不会同时可选。
template<int pack_size, typename FunctorT, typename R, typename... IN>
__device__ typename std::enable_if<HasApply2<FunctorT>::value == false || pack_size % 2 != 0,
                                   Packed<R, pack_size>>::type
ApplyPack(const FunctorT& functor, const Packed<IN, pack_size>... in) {
  Packed<R, pack_size> ret;
#pragma unroll
  for (int j = 0; j < pack_size; ++j) { ret.elem[j] = functor((in.elem[j])...); }   // 每个元素调一次 operator(),参数是所有输入的第 j 个
  return ret;
}

两个函数体都很短,选哪个由 std::enable_if 决定,而两个条件是互补的(HasApply2 == true && pack_size % 2 == 0 与它的取反),不会同时可选、也不会都不可选。

版本 A 每次推进 2 个元素,把 ret.elem + j 这个元素数组内的地址交给 functor 的 Apply2,由 functor 自己决定怎么成对算(half 版就是把它 reinterpret_cast 成 half2* 再调 __hmul2)。版本 B 老老实实一个元素调一次 operator()。

形参 const Packed<IN, pack_size>... in 是变参模板展开:每个输入一个包,按值传(16 字节,直接进寄存器)。调用点传的是 (pack_in[i])...,即“第 i 包的所有输入”。所以三元运算和二元运算共用同一份 ApplyPack,这也是这套模板能叫“通用框架”的原因。

⑥ 调度层:ApplyGeneric —— 网格步长走包 + 标量补尾巴

elementwise.cpp : 124-135CUDA C++
template<int pack_size, typename FactoryT, typename R, typename... IN>
__global__ void __launch_bounds__(kBlockSize)     // 告诉 ptxas 每 block 最多 256 线程(本机实测 36 寄存器,占满 6 block/SM)
    ApplyGeneric(FactoryT factory, int64_t n_pack, Packed<R, pack_size>* pack_r,
                 const Packed<IN, pack_size>*... pack_in, int64_t n_tail, R* tail_r,
                 const IN*... tail_in) {
  auto functor = factory();                      // 在这里、在 device 侧构造 functor —— 这就是传 factory 而不直接传 functor 的原因
  const int global_tid = blockIdx.x * kBlockSize + threadIdx.x;   // 线程全局编号;注意它用的是 kBlockSize 常量,不是 blockDim.x
  for (int64_t i = global_tid; i < n_pack; i += blockDim.x * gridDim.x) {   // grid-stride:步长 = 本次启动的总线程数(本机 n=2^25 时是 2359296)
    pack_r[i] = ApplyPack<pack_size, decltype(functor), R, IN...>(functor, (pack_in[i])...);   // 读第 i 包所有输入 → 算 → 写第 i 包输出
  }
  if (global_tid < n_tail) { tail_r[global_tid] = functor((tail_in[global_tid])...); }   // 尾巴:前 n_tail 个线程各补 1 个元素,用标量路径
}

kernel 本体只有三件事。第一,在 device 侧调用 factory() 把 functor 取出来——这正是要传工厂而不是直接传 functor 的原因(见下一段)。

第二,网格步长循环遍历 pack;global_tid 用的是常量 kBlockSize 而不是 blockDim.x,这是和 __launch_bounds__(kBlockSize) 配套的写法。

第三,尾巴处理。if (global_tid < n_tail):前 n_tail 个线程每人补算 1 个元素,走标量路径。用 10 个元素、包宽 8 来验证一下:n_pack = 1、n_tail = 2,0 号线程进包循环算第 0 包,然后 0 号和 1 号线程各补一个尾巴元素——正好覆盖下标 0~9,不多不少。

指针的两套视图是这段代码最绕的地方

同一个输出缓冲区,pack_r 当 Packed<R, pack_size>* 用(下标是包号),tail_r 当 R* 用(下标是元素号)。切换点是 host 侧的 r + tail_offset:指针往后挪 tail_offset 个元素,正好落在尾巴的起点,而包视图那边只需要 n_pack 个包。两套指针指向同一块内存,只是单位不同。

⑦ 调度层:SimpleFactory —— 让 functor 在 device 侧构造

elementwise.cpp : 138-149CUDA C++
// 【functor 工厂】把 functor 装进一个可拷贝的小对象,kernel 里 factory() 再取出来。
//   直接传 functor 需要它的类型在 host 和 device 两侧都能构造;工厂把这个动作推到 device 侧。
//   这里 operator() 是 __device__ 的,所以 SimpleFactory<T> 本身在 host 侧构造(:193 那次),
//   取出模板值只在 device 侧发生。
template<typename FunctorT>
struct SimpleFactory {
  explicit SimpleFactory(FunctorT functor) : tpl(functor) {}   // host 侧调用(避免隐式转换)
  __device__ FunctorT operator()() const { return tpl; }       // device 侧:按值返回一份 functor

 private:
  FunctorT tpl;
};

如果把 functor 直接当 kernel 形参传,它的类型就必须在 host 和 device 两侧都能构造,这对带复杂成员的 functor 是个限制。工厂模式把“构造”这个动作推迟到 device 侧(kernel 里那句 auto functor = factory();),host 侧只负责把 functor 装进一个可拷贝的小对象里带过去。SimpleFactory<T> 本身在 host 侧构造,取值的 operator() 是 __device__ 的。

⑧ 启动层:block 数上限与对齐回退(在 elementwise.cu 里)

elementwise.cpp 到 SimpleFactory 就结束了——它是一个模板核心,配套的 host 侧启动器在 elementwise.cu 里。这部分决定了“开多少 block”和“对齐不了怎么办”,少了它这套模板并不完整:

elementwise.cu : 14-40 · GetNumBlocksCUDA C++
inline cudaError_t GetNumBlocks(int64_t n, int* num_blocks) {
  int dev;
  {
    cudaError_t err = cudaGetDevice(&dev);
    if (err != cudaSuccess) { return err; }
  }
  int sm_count;
  {
    cudaError_t err = cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, dev);
    if (err != cudaSuccess) { return err; }
  }
  int tpm;
  {
    cudaError_t err = cudaDeviceGetAttribute(&tpm, cudaDevAttrMaxThreadsPerMultiProcessor, dev);
    if (err != cudaSuccess) { return err; }
  }
  *num_blocks = std::max<int>(1, std::min<int64_t>((n + kBlockSize - 1) / kBlockSize,
                                                   sm_count * tpm / kBlockSize * kNumWaves));
  return cudaSuccess;
}

template<typename T, int pack_size>
struct GetPackType {
  using type = typename std::aligned_storage<pack_size * sizeof(T), pack_size * sizeof(T)>::type;
};

template<typename T, int pack_size>
elementwise.cu : 136-194 · 对齐判断 + 启动CUDA C++
template<size_t pack_size>
bool IsAlignedForPack() {
  return true;
}

template<size_t pack_size, typename T, typename... Args>
bool IsAlignedForPack(const T* ptr, const Args*... others) {
  return reinterpret_cast<uintptr_t>(ptr) % sizeof(Pack<T, pack_size>) == 0
         && IsAlignedForPack<pack_size, Args...>(others...);
}

template<size_t pack_size, typename FactoryT, typename R, typename... IN>
cudaError_t LaunchKernel(FactoryT factory, int64_t n, R* r, const IN*... in) {
  const int64_t n_pack = n / pack_size;
  const int64_t tail_offset = n_pack * pack_size;
  const int64_t n_tail = n - tail_offset;
  int num_blocks;
  {
    cudaError_t err = GetNumBlocks(n_pack, &num_blocks);
    if (err != cudaSuccess) { return err; }
  }
  ApplyGeneric<pack_size, FactoryT, R, IN...><<<num_blocks, kBlockSize, 0>>>(
      factory, n_pack, reinterpret_cast<Packed<R, pack_size>*>(r),
      (reinterpret_cast<const Packed<IN, pack_size>*>(in))..., n_tail, r + tail_offset,
      (in + tail_offset)...);
  return cudaPeekAtLastError();
}

template<typename FactoryT, typename R, typename... IN>
struct GenericLauncher {
  static cudaError_t Launch(FactoryT factory, int64_t n, R* r, const IN*... in) {
    constexpr int max_pack_size = PackSize<R, IN...>();
    if (IsAlignedForPack<max_pack_size, R, IN...>(r, in...)) {
      return LaunchKernel<max_pack_size, FactoryT, R, IN...>(factory, n, r, in...);
    } else {
      return LaunchKernel<1, FactoryT, R, IN...>(factory, n, r, in...);
    }
  }
};

template<typename FactoryT, typename R, typename A>
inline cudaError_t UnaryWithFactory(FactoryT factory, int64_t n, R* r, const A* a) {
  return GenericLauncher<FactoryT, R, A>::Launch(factory, n, r, a);
}

template<typename FunctorT, typename R, typename A>
inline cudaError_t Unary(FunctorT functor, int64_t n, R* r, const A* a) {
  return UnaryWithFactory(SimpleFactory<FunctorT>(functor), n, r, a);
}

template<typename FactoryT, typename R, typename A, typename B>
inline cudaError_t BinaryWithFactory(FactoryT factory, int64_t n, R* r, const A* a, const B* b) {
  return GenericLauncher<FactoryT, R, A, B>::Launch(factory, n, r, a, b);
}

template<typename FunctorT, typename R, typename A, typename B>
inline cudaError_t Binary(FunctorT functor, int64_t n, R* r, const A* a, const B* b) {
  return BinaryWithFactory(SimpleFactory<FunctorT>(functor), n, r, a, b);
}
IsAlignedForPack:逐个输入检查“指针能否被 sizeof(Pack) 整除” 对齐 → LaunchKernel<max_pack_size> 走宽路径(half 一次 8 个) 不对齐 → LaunchKernel<1> 整包宽度退化成 1,等于逐元素 num_blocks = min( 按数据量算出的 block 数, SM 数 × 每 SM 线程数 / blockSize × kNumWaves ) kNumWaves = 32:允许开的 block 数是最小驻留量的 32 倍

两点值得说明。对齐检查是运行期做的:cudaMalloc 返回的地址总是 256 字节对齐,所以常规路径一定走宽的那支;只有当你手动从某个大缓冲里切一段偏了位置的子数组出来时,才会退化成逐元素。这种“快路径 + 安全兜底”的写法是让模板能通用又不牺牲常路径性能的常用手段。

block 数被压了上限:kNumWaves = 32 意味着 block 数最多是“刚好填满 GPU 所需数量”的 32 倍。本机实测打印 num_blocks=9216、总线程 2359296,而 n_pack = 4194304——线程数比包数还少,多出来的包由网格步长循环吸收(每个线程摊 1~2 个包)。这和 reduce 的 v8 是同一个思路:别开那么多 block。

完整代码

下面是 elementwise.cpp 全文(149 行),一字未改。它包含了从 PackType 到 SimpleFactory 的全部模板核心,但没有 main、也没有第 ⑧ 段那些启动器函数——所以它本身不是一个可执行程序,单独编译只能得到一个目标文件。要跑起来需要配上 elementwise.cu 里的启动层,或者自己写一个 main 调用 Binary(MultiplyFunctor<half>(), n, z, x, y)。

elementwise.cpp · 全文 149 行CUDA C++
#include <cuda.h>          // driver API,本文件没用到(启动走 runtime)
#include <cuda_runtime.h>  // 真正需要的:__global__、cudaMalloc/cudaMemcpy、cudaDeviceGetAttribute、dim3
#include <time.h>          // 【未使用】main 里没有计时;本机测时用的是 cudaEvent
#include <algorithm>       // 用到的只有 std::max / std::min(GetNumBlocks :30)
#include <iostream>        // 【未使用】打印用的是 printf
#include <cuda_fp16.h>     // half / half2、__hmul2;本文件 main 实例化的就是 half
using namespace std;       // 下面用了 std::max/std::min(带 std:: 前缀),其实可以不写这行

#define N 32 * 1024 * 1024   // 元素个数 33,554,432。宏没加括号,本文件里的用法(乘、加、除)恰好都安全
// elementwise implementation copyed from https://github.com/Oneflow-Inc/oneflow/blob/master/oneflow/core/cuda/elementwise.cuh
constexpr int kBlockSize = 256;   // 每 block 线程数;kernel 上的 __launch_bounds__ 也是它(:115)
constexpr int kNumWaves = 32;     // GetNumBlocks 的"波数":允许开的 block 数 = 能同时驻留的份数 × 32


// 【pack 的存储类型】aligned_storage 给出"大小 = pack_size*sizeof(T)、对齐 = 同样大"的裸内存块。
//   注意它只是 POD 字节,没有成员可以访问,所以下面还要 union 一个 T elem[] 视图。
template<typename T, int pack_size>
struct GetPackType {
  using type = typename std::aligned_storage<pack_size * sizeof(T), pack_size * sizeof(T)>::type;
};

template<typename T, int pack_size>
using PackType = typename GetPackType<T, pack_size>::type;  


// 【Pack:只在 host 侧用来算 sizeof】union 让 storage 和 elem[] 共用同一块内存。
//   本文件里 Pack<T,pack_size> 唯一的用处是 IsAlignedForPack(:143) 里的 sizeof —— 拿它当
//   "一个包占几字节"的标尺。Packed(下面那个)才是 kernel 里真正用的类型。
template<typename T, int pack_size>
union Pack {
  static_assert(sizeof(PackType<T, pack_size>) == sizeof(T) * pack_size, "");   // 编译期自证:aligned_storage 没被额外 padding
  __device__ Pack() {
    // do nothing   ← 什么都不做的默认构造:既避免隐式构造产生清零代码,也让这个类型不能在 host 侧默认构造
  }
  PackType<T, pack_size> storage;   // 裸字节视图(决定对齐)
  T elem[pack_size];                // 元素视图
};


// 【Packed:kernel 里真正用的"一包"】alignas 是关键:没有它,Packed<half,8> 的 alignof 只有 2,
//   编译器就发不出 128 位访存指令(本机实测 PTX 是 ld.global.v4.b32,SASS 是 LDG.E.128)。
//   匿名 union 只有一个成员,作用是让 Packed<T,N> 能直接当数组用(.elem[j]),
//   同时保持是 trivially-copyable 的聚合体。
template<typename T, int pack_size>
struct alignas(sizeof(T) * pack_size) Packed {
  __device__ Packed() {
    // do nothing
  }
  union {
    T elem[pack_size];
  };
};

constexpr int kMaxPackBytes = 128 / 8;   // 16 字节 = 一条向量访存指令搬的量(128 bit)
constexpr int kMaxPackSize = 8;          // 上限 8 个元素(half 正好 16 字节;float 会先被 16/4=4 卡住)

constexpr int Min(int a, int b) { return a < b ? a : b; }   // constexpr 版 min(没用 std::min,是为了在模板实参里能用)

template<typename T>
constexpr int PackSize() {
  return Min(kMaxPackBytes / sizeof(T), kMaxPackSize);   // half→8、float→4、double→2(本机打印验证)
}


template<typename T, typename U, typename... Args>
constexpr int PackSize() {
  return Min(PackSize<T>(), PackSize<U, Args...>());   // 递归取"输出和所有输入"里的最小包宽:任一操作数是 double 就整体降到 2
}


// 【编译期问一句:"你这个 functor 有没有 Apply2 成员?"】
//   test(decltype(&C::Apply2)) 能成立就匹配第一个重载(返回 1 字节的 one),
//   否则靠 ... 匹配第二个(返回 2 字节的 two)。sizeof 一比即得。
//   为什么要问:有 Apply2 就能一次算 2 个元素(half2 成对乘),没有就只能逐个算。
template<typename T>
class HasApply2 {
  typedef char one;
  struct two {
    char x[2];
  };

  template<typename C>
  static one test(decltype(&C::Apply2));   // 只有 C::Apply2 存在时这个声明才合法(SFINAE)
  template<typename C>
  static two test(...);                    // 否则退到这里

 public:
  enum { value = sizeof(test<T>(0)) == sizeof(char) };   // 1 = 有,0 = 没有
};



// 【ApplyPack 版本 A:functor 有 Apply2 且包宽是偶数】一次给 2 个元素的地址,让 functor 成对算。
//   形参 `const Packed<IN, pack_size>... in` 是变参模板的展开:每个输入一个包,按值传(16 字节,进寄存器)。
//   调用点 (:122) 传的是 (pack_in[i])...,即第 i 包的所有输入。
template<int pack_size, typename FunctorT, typename R, typename... IN>
__device__ typename std::enable_if<HasApply2<FunctorT>::value == true && pack_size % 2 == 0,
                                   Packed<R, pack_size>>::type
ApplyPack(const FunctorT& functor, const Packed<IN, pack_size>... in) {
  Packed<R, pack_size> ret;                    // 未初始化(默认构造什么都不做),下面逐元素写满
#pragma unroll
  for (int j = 0; j < pack_size; j += 2) { functor.Apply2(ret.elem + j, (in.elem + j)...); }   // 每次推进 2;传的是元素数组里的下标 j,不是包号
  return ret;
}

// 【ApplyPack 版本 B:没有 Apply2,或包宽是奇数】逐元素调 functor 的 operator()。
//   两个版本的函数体一样长,选哪个由 enable_if 决定;条件互补(|| 与 && 取反),不会同时可选。
template<int pack_size, typename FunctorT, typename R, typename... IN>
__device__ typename std::enable_if<HasApply2<FunctorT>::value == false || pack_size % 2 != 0,
                                   Packed<R, pack_size>>::type
ApplyPack(const FunctorT& functor, const Packed<IN, pack_size>... in) {
  Packed<R, pack_size> ret;
#pragma unroll
  for (int j = 0; j < pack_size; ++j) { ret.elem[j] = functor((in.elem[j])...); }   // 每个元素调一次 operator(),参数是所有输入的第 j 个
  return ret;
}



// 【kernel 本体】grid-stride 走包 + 标量补尾巴。
//   【形参】factory:functor 工厂(见 SimpleFactory);n_pack:包数;pack_r/pack_in:包视图的指针;
//   n_tail:尾巴元素数;tail_r/tail_in:普通元素视图的指针(指向 tail_offset 处)。
//   同一个数据缓冲区用两套指针看,就是这段代码里最容易被绕晕的地方。
template<int pack_size, typename FactoryT, typename R, typename... IN>
__global__ void __launch_bounds__(kBlockSize)     // 告诉 ptxas 每 block 最多 256 线程(本机实测 36 寄存器,占满 6 block/SM)
    ApplyGeneric(FactoryT factory, int64_t n_pack, Packed<R, pack_size>* pack_r,
                 const Packed<IN, pack_size>*... pack_in, int64_t n_tail, R* tail_r,
                 const IN*... tail_in) {
  auto functor = factory();                      // 在这里、在 device 侧构造 functor —— 这就是传 factory 而不直接传 functor 的原因
  const int global_tid = blockIdx.x * kBlockSize + threadIdx.x;   // 线程全局编号;注意它用的是 kBlockSize 常量,不是 blockDim.x
  for (int64_t i = global_tid; i < n_pack; i += blockDim.x * gridDim.x) {   // grid-stride:步长 = 本次启动的总线程数(本机 n=2^25 时是 2359296)
    pack_r[i] = ApplyPack<pack_size, decltype(functor), R, IN...>(functor, (pack_in[i])...);   // 读第 i 包所有输入 → 算 → 写第 i 包输出
  }
  if (global_tid < n_tail) { tail_r[global_tid] = functor((tail_in[global_tid])...); }   // 尾巴:前 n_tail 个线程各补 1 个元素,用标量路径
}


// 【functor 工厂】把 functor 装进一个可拷贝的小对象,kernel 里 factory() 再取出来。
//   直接传 functor 需要它的类型在 host 和 device 两侧都能构造;工厂把这个动作推到 device 侧。
//   这里 operator() 是 __device__ 的,所以 SimpleFactory<T> 本身在 host 侧构造(:193 那次),
//   取出模板值只在 device 侧发生。
template<typename FunctorT>
struct SimpleFactory {
  explicit SimpleFactory(FunctorT functor) : tpl(functor) {}   // host 侧调用(避免隐式转换)
  __device__ FunctorT operator()() const { return tpl; }       // device 侧:按值返回一份 functor

 private:
  FunctorT tpl;
};
把它补成一个能跑的程序

本文的性能数据就是这么测的:以 elementwise.cu 为骨架(它同时含核心与启动层),把它的 main 换成一段计时程序,分别调用朴素 kernel mul<T> 和模板入口 Binary(...),float 与 half 各测一遍。编译命令:

nvcc -arch=sm_120 -O3 -o elementwise_bench elementwise_bench.cu

实测性能对比分析

数据规模 n = 2²⁵ = 33,554,432 个元素(每个缓冲区:float 128 MiB / half 64 MiB),两个输入一个输出,所以总访存量 = 3 × n × sizeof(T)。每个 kernel 跑 13 次、丢掉前 3 次、取后 10 次最小值。朴素版用的是 elementwise.cu 里那个原始的 mul<T> kernel(1 线程 1 元素,无边界检查)。

本机实测(RTX 5070)

数据类型实现耗时 (µs)等效带宽 (GB/s)占峰值 672 GB/s相对朴素版
float朴素 mul671.3599.889.3%1.00×
floatOneFlow 模板676.5595.288.6%0.99×
half朴素 mul356.3565.184.1%1.00×
halfOneFlow 模板335.9599.489.2%1.06×

先看 baseline:同一台机器上纯 float4 拷贝(等量读+写)实测 575 GB/s,所以 595~600 GB/s 已经贴着这台卡混合读写的实际上限了。

float 上模板没占到便宜(676.5 vs 671.3 µs,反而慢了 0.8%)。原因不复杂:4 字节的 float 本来就是访存通路的自然宽度,一个线程读 4 字节、相邻线程读相邻地址,一个 warp 的 32 个 lane 合起来正好是连续的 128 字节——合并度已经是满的。打包成 float4 只是把指令数从 3 条减到 3 条(读两条、写一条变成读两条、写一条,数量没变),却额外引入了包内 4 个分量的拆分与合并。指令数没省下来,自然看不出收益。

half 上模板快了 6%。这里才是它的用武之地:half 是 2 字节,朴素写法一个线程搬 2 字节,一个 warp 的 32 个 lane 合起来只有 64 字节——不到一条完整 128 字节事务的一半,一次访存要浪费掉一半带宽。打包成 8 个 half(16 字节)之后,一个 warp 一次覆盖 512 字节连续内存,正好是 4 条完整事务。再加上 __hmul2 一次算两个,计算侧的指令数也减半。

但要注意:6% 远不是“把浪费的一半带宽全捡回来”。原因是朴素 half 版虽然每条指令只搬 2 字节,但 GPU 靠大量线程并发把这个宽度不足掩盖掉了大部分——L1 会把同一 warp 内的请求合并,实际发往 DRAM 的仍然是完整事务。所以“2 字节访问浪费一半带宽”在孤立看指令时成立,在整机吞吐上只体现为几个百分点。

和 A100 上的数据对照

仓库 README 里原作者在 A100-PCIE-40GB(峰值 1555 GB/s)上测的一组数字,数据规模同样是 n = 2²⁵:

数据类型实现耗时 (µs)带宽利用率相对朴素版
float朴素 elementwise298.4685.88%1.00×
floatOneFlow elementwise28489.42%1.05×
half朴素 elementwise237.2852.55%1.00×
halfOneFlow elementwise140.7487.31%1.69×

把两张表并排看,最刺眼的是 half 的那一行:A100 上朴素版只跑到 52.55% 的带宽利用率,本机同样代码却有 84.1%。这不是代码变好了,是两张卡的“饱和难度”不同:A100 的带宽是 1555 GB/s,要让这条更粗的管道跑满需要更多的在飞请求;2 字节的窄访问在它上面明显喂不饱,于是模板把宽度补上后收益高达 1.69×。而 RTX 5070 的管道只有 672 GB/s,窄访问靠并发就能填到八分满,模板能补的空间自然只剩 6%。

同理,float 那一行在两台机器上都接近“没有收益”——因为 4 字节已经够宽,两台机器的朴素版都跑在 85~89% 的位置,本来就没有多少余量。

一句话总结这两张表

同一份模板代码,在 A100 上能把 half 提速 1.69×,在本机只有 1.06×,而 float 上两台机器都≈1.00×。优化的收益不取决于代码写了什么,取决于它补上的那个短板在目标硬件上到底有多短。

小结

这套模板真正的价值,与其说是“快”,不如说是把一堆本该手工处理的边界情况塞进了编译期:

它替你处理掉的四件事

包宽随类型自动定:PackSize<T>() 在编译期算出 half→8、float→4、double→2,并且取所有操作数里最窄的那个,不会出现“一个 double 混进来导致对齐崩掉”。

元素数除不尽:自动切出 n_pack 个完整的包 + n_tail 个尾巴,尾巴走标量路径。

指针不对齐:运行期检查,对齐走宽路径,不对齐整体退化成包宽 1,结果依然正确。

运算不同:用 SFINAE 探测 functor 有没有 Apply2,有就用成对指令(__hmul2),没有就逐元素算——编译期二选一,没有运行期分支。

而它的性能故事则是一堂更实际的课:打包访存不是万能药。当元素本身就是 4 字节、且相邻线程已经连续访问时,一个 warp 天然就凑满了 128 字节的事务,再打包不会有任何收益;只有当元素类型窄于通路宽度(half 的 2 字节、或者 int8 之类的 1 字节)时,打包才真正开始起作用。

下一篇:最近邻 2 倍上采样——同样是打包访存,但这次输出比输入大 4 倍,写多读少,而且反向传播里还能顺手甩掉 atomicAdd。