算子在做什么,先看一个算例
逐点运算(elementwise)是最简单的一类算子:输出数组的第 i 个元素只由输入数组的第 i 个元素算出。元素个数不变、形状不变、下标不动,线程之间没有任何数据交换。
加法、乘法、ReLU、GELU、加法融合的 bias——只要满足“下标对下标”,都是同一个模板的实例。也正因为如此,它的性能完全由访存决定:算一个乘法只要 1 条指令,而读两个数写一个数要 3 次访存。这是典型的带宽受限算子。
一个可以手工核对的算例
取 n = 10、类型为 half(2 字节):
两种写法要做的事完全一样,差别只在每个线程一次搬几个元素。
朴素写法:一个线程算一个元素。开 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)”:
这个例子把整套模板的动机说完了:在不改变计算结果的前提下,把访存指令的条数压下来。而要做到这一点,模板必须同时处理好四件麻烦事——元素数除不尽(尾巴)、指针不对齐、元素类型不同(包宽不同)、运算不同(有的运算能成对算,有的不能)。下面逐段看它是怎么把这些都塞进编译期的。
代码逐段解析
代码分三层:类型层(定义“一个包”是什么)、调度层(决定一个包怎么算)、启动层(决定开多少 block、要不要退回标量)。下面按这三层走。
① 类型层:PackType / Pack —— 只在编译期算尺寸用
// 【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 里真正用的那个包
// 【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 的聚合体(可以按值传参、进寄存器)。
③ 类型层:包宽是编译期算出来的
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
}
kMaxPackBytes = 128/8 就是“一次访存最多搬 16 字节”,kMaxPackSize = 8 是为了别让包太宽。第二个变参版本 PackSize<T, U, Args...>() 递归取输出和所有输入里最小的那个包宽——只要有一个操作数是 double,整体就降到 2 个/包,保证类型对齐不出问题。
注意 Min 是自己写的 constexpr 版本,而不是 std::min——因为 std::min 在 C++11 的 CUDA 里不是 constexpr 的,没法用在模板实参里。这类“为了能进编译期”而手写的小函数在这个文件里到处出现。
④ 调度层:HasApply2 —— 问一句“这个 functor 能不能成对算”
// 【编译期问一句:"你这个 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 的两个重载
// 【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 —— 网格步长走包 + 标量补尾巴
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 侧构造
// 【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”和“对齐不了怎么办”,少了它这套模板并不完整:
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>
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);
}
两点值得说明。对齐检查是运行期做的: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)。
#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 | 朴素 mul | 671.3 | 599.8 | 89.3% | 1.00× |
| float | OneFlow 模板 | 676.5 | 595.2 | 88.6% | 0.99× |
| half | 朴素 mul | 356.3 | 565.1 | 84.1% | 1.00× |
| half | OneFlow 模板 | 335.9 | 599.4 | 89.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 | 朴素 elementwise | 298.46 | 85.88% | 1.00× |
| float | OneFlow elementwise | 284 | 89.42% | 1.05× |
| half | 朴素 elementwise | 237.28 | 52.55% | 1.00× |
| half | OneFlow elementwise | 140.74 | 87.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。