HyperAI 算力平台已经正式上线,通过开箱即用的环境、高性价比的 GPU 定价以及丰富的站内资源,为开发者提供高稳定性的算力服务,加速创意落地。


以下为 HyperAI 用户基于平台进行 Elementwise 算子优化的经验分享 ⬇️


 插播一则活动!

目前,HyperAI 内测活动仍在招募中,最高可得 $200 激励,点击了解活动详情:最高可得 $200!HyperAI 内测招募正式开启!




核心目标:将一个简单的逐元素加法算子(C = A + B )从基础实现优化到逼近 PyTorch 原生性能(即逼近硬件的显存带宽极限)。


核心难点:Elementwise 是典型的 Memory Bound(显存受限)算子。

  • 算力不是瓶颈(GPU 算加法快得离谱)。

  • 瓶颈在于「指令发射端」 和 「显存搬运端」的供需平衡。

  • 优化的本质:用最少的指令(Instruction),搬运最多的数据(Bytes)。


实验环境与算力准备


Elementwise 算子的优化本质是在挑战显存带宽的物理极限。为了获得最准确的 Benchmark 数据,本次实战是在 HyperAI (hyper.ai) 的云端算力平台上完成的。我特意选用了一台配置豪华的实例来压榨算子性能:

  • GPU: NVIDIA RTX 5090 (32GB VRAM)

  • RAM: 40 GB

  • Environment: PyTorch 2.8 / CUDA 12.8


 福利时间:如果你也想体验 RTX 5090 复现本文代码,注册 app.hyper.ai 时可以使用我的专属兑换码「EARLY_dnbyl」,即可领取 1 小时 5090 免费算力(1 个月内有效)。


快速开启 RTX 5090 实例


1. 注册与登录:访问 app.hyper.ai 注册账号后,点击右上方「财务中心」,进入后点击「兑换码兑换」,输入「EARLY_dnbyl」领取免费算力。



2. 创建容器:点击左侧边栏「模型训练」->「选择算力:5090」->「选择镜像:PyTorch 2.8」->「接入方式:Jupyter」->「容器名称:任意填写,例如cuda_kernels」->「执行」。



3. 打开Jupyter:实例启动后(状态变为「运行中」),直接点击「打开工作空间」一键即用



平台支持使用 Jupyter 或 VS Code SSH Remote 进行连接。我使用的是 Jupyter,在第一个 Cell 中运行以下命令:

import osimport torchfrom torch.utils.cpp_extension import load


第一阶段:FP32 优化系列


1.Version 1: FP32 Baseline (标量版)


这是最符合直觉的写法,但在 GPU 看来效率平平。


原理深度解析:

  • 指令层:Scheduler 发射 1 条 LD.E (32-bit Load) 指令。

  • 执行层(Warp):依据 SIMT 原则,Warp 内的 32 个线程同时执行这条指令。

  • 数据量:每个线程搬运 4 字节。总数据量 =

    32 threads × 4 Bytes = 128 Bytes

  • 显存事务:LSU(Load Store Unit)将这 128 字节合并为 1 个显存事务。

  • 瓶颈分析:虽然利用了显存合并,但指令效率低。为了搬运 128 字节数据,SM(流多处理器)必须消耗 1 个指令发射周期。对于海量数据,指令发射单元会忙死,成为瓶颈。


代码 (v1_f32.cu):

  %%writefile v1_f32.cu  #include <torch/extension.h>  #include <cuda_runtime.h>
  __global__ void elementwise_add_f32_kernel(float *a, float *b, float *c, int N) {    int idx = blockIdx.x * blockDim.x + threadIdx.x;    if (idx < N) {      c[idx] = a[idx] + b[idx];    }}
void elementwise_add_f32(torch::Tensor a, torch::Tensor b, torch::Tensor c) {      int N = a.numel();      int threads_per_block = 256;      int blocks_per_grid = (N + threads_per_block - 1) / threads_per_block;        elementwise_add_f32_kernel<<<blocks_per_grid, threads_per_block>>>(          a.data_ptr<float>(), b.data_ptr<float>(), c.data_ptr<float>(), N      );}
  PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {    m.def("add", &elementwise_add_f32, "FP32 Add");}


2.Version 2: FP32x4 Vectorized (向量化版)


优化手段:使用 float4 类型,强制生成 128-bit 加载指令。


原理深度解析(核心优化点):

  • 指令层:Scheduler 发射 1 条 LD.E.128 (128-bit Load) 指令。

  • 执行层(Warp):Warp 内 32 个线程同时执行,但这次每人搬运 16 字节(float4)。

  • 数据量总数据量 = 32 threads x 16 Bytes = 512 Bytes 。

  • 显存事务:LSU 看到 512 字节的连续请求,会连续发起 4 个 128B 显存事务。

  • 效率对比:

    Baseline:1 指令 = 128 字节。

    Vectorized:1 指令 = 512 字节。

  • 结论:指令效率提升 4 倍。SM 只需要原来 1/4 的指令数,就能吃满同样的显存带宽。这彻底解放了指令发射单元,让瓶颈真正转移到了显存带宽上。


代码 (v2_f32x4.cu):

%%writefile v2_f32x4.cu#include <torch/extension.h>#include <cuda_runtime.h>
#define FLOAT4(value) (reinterpret_cast<float4 *>(&(value))[0])
__global__ void elementwise_add_f32x4_kernel(float *a, float *b, float *c, int N) {      int tid = blockIdx.x * blockDim.x + threadIdx.x;      int idx = 4 * tid;     
  if (idx + 3 < N) {            float4 reg_a = FLOAT4(a[idx]);            float4 reg_b = FLOAT4(b[idx]);            float4 reg_c;            reg_c.x = reg_a.x + reg_b.x;            reg_c.y = reg_a.y + reg_b.y;            reg_c.z = reg_a.z + reg_b.z;            reg_c.w = reg_a.w + reg_b.w;            FLOAT4(c[idx]) = reg_c;      }      else if (idx < N){            for (int i = 0; i < 4; i++){                  if (idx + i < N) {                        c[idx + i] = a[idx + i] + b[idx + i];                  }            }      }}
void elementwise_add_f32x4(torch::Tensor a, torch::Tensor b, torch::Tensor c) {      int N = a.numel();      int threads_per_block = 256 / 4;       int blocks_per_grid = (N + 256 - 1) / 256;         elementwise_add_f32x4_kernel<<<blocks_per_grid, threads_per_block>>>(            a.data_ptr<float>(), b.data_ptr<float>(), c.data_ptr<float>(), N      );}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {    m.def("add", &elementwise_add_f32x4, "FP32x4 Add");}




第二阶段:FP16 优化系列


3.Version 3: FP16 Baseline (半精度标量)


使用 half (FP16) 节省显存。


原理深度解析(为何它很慢?):


  • 访存模式:代码中 idx 连续,因此 32 个线程的访问是完全合并的。

  • 数据量:32 个线程 × 2 Bytes = 64 Bytes(一个 Warp 的总请求量)。

  • 硬件行为:显存控制器(LSU)会生成 2 个 32-byte 的显存扇区事务。

    注意:这里没有浪费带宽,传输的全是有效数据。


真正的瓶颈:


1. 指令发射瓶颈(Instruction Bound):


这是核心原因。为了填满显存带宽,我们需要源源不断地搬运数据。此版本中,1 条指令只能搬运 64 字节。对比 float4 版本(1 条指令搬运 512 字节),本版本的指令效率仅为 1/8。

后果:SM 的指令发射单元(Scheduler)即使全速运转,发出的指令所携带的数据量也喂不饱巨大的显存带宽。包工头喊破喉咙(发射指令),工人搬的砖(数据量)也不够多。


2.显存事务粒度过小:


* 物理层:显存最小传输单位是 32B 扇区;缓存层:通常以 128B 缓存行为单位管理。


* 现状:Warp 请求的 64B 数据虽然填满了 2 个扇区,但对于 128B 的缓存行来说,只用了 一半。


* 后果:这种「零售式」的小包数据传输,相比于 float4 那种一次性搬运 4 个完整缓存行(512B)的「批发式」传输,在此吞吐量下效率极低,且无法掩盖显存的高延迟。为了填满显存带宽,我们需要源源不断地搬运数据。


代码 (v3_f16.cu):

%%writefile v3_f16.cu#include <torch/extension.h>#include <cuda_fp16.h> 
__global__ void elementwise_add_f16_kernel(half *a, half *b, half *c, int N) {    int idx = blockIdx.x * blockDim.x + threadIdx.x;    if (idx < N) {        c[idx] = __hadd(a[idx], b[idx]);    }}
void elementwise_add_f16(torch::Tensor a, torch::Tensor b, torch::Tensor c) {      int N = a.numel();      int threads_per_block = 256;      int blocks_per_grid = (N + threads_per_block - 1) / threads_per_block;        elementwise_add_f16_kernel<<<blocks_per_grid, threads_per_block>>>(             reinterpret_cast<half*>(a.data_ptr<at::Half>()),            reinterpret_cast<half*>(b.data_ptr<at::Half>()),            reinterpret_cast<half*>(c.data_ptr<at::Half>()),            N      );}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {    m.def("add", &elementwise_add_f16, "FP16 Add");}


4.Version 4: FP16 Vectorized (Half2)

引入 half2


原理深度解析:

  • 数据:half2 (4 字节)。

  • 指令层发射 32-bit Load 指令。

  • 算力层:使用 __hadd2 (SIMD),一条指令同时算 2 对加法。

  • 现状:

    访存效率等同于 FP32 Baseline(1 指令 = 128 字节)。虽然比 V3 快了,但依然没有达到 float4 那种 512 字节/指令 的巅峰状态。


代码 (v4_f16x2.cu):

%%writefile v4_f16x2.cu#include <torch/extension.h>#include <cuda_fp16.h>
#define HALF2(value) (reinterpret_cast<half2 *>(&(value))[0])
__global__ void elementwise_add_f16x2_kernel(half *a, half *b, half *c, int N) {    int idx = 2 * (blockIdx.x * blockDim.x + threadIdx.x);      if (idx + 1 < N) {        half2 reg_a = HALF2(a[idx]);        half2 reg_b = HALF2(b[idx]);        half2 reg_c = __hadd2(reg_a, reg_b);        HALF2(c[idx]) = reg_c;    }    else if (idx < N){        c[idx] = __hadd(a[idx], b[idx]);    }}
void elementwise_add_f16x2(torch::Tensor a, torch::Tensor b, torch::Tensor c) {      int N = a.numel();      int threads_per_block = 256 / 2;      int blocks_per_grid = (N + 256 - 1) / 256;        elementwise_add_f16x2_kernel<<<blocks_per_grid, threads_per_block>>>(            reinterpret_cast<half*>(a.data_ptr<at::Half>()),            reinterpret_cast<half*>(b.data_ptr<at::Half>()),            reinterpret_cast<half*>(c.data_ptr<at::Half>()),            N      );}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {    m.def("add", &elementwise_add_f16x2, "FP16x2 Add");}


hyper Jupyter 运行示例见附录


5.Version 5: FP16x8 Unroll (手动循环展开)

为了进一步挖掘性能,我们尝试让 1 个线程处理 8 个 half(即 4 个 half2)。


原理深度解析(对比 V4 的提升在哪里?):

  • 做法:在代码中手动连续写 4 行 half2 读取操作。

  • 效果:Scheduler 会连续发射 4 条 32-bit Load 指令。

  • 收益:ILP (指令级并行) 与 延迟掩盖。

    V4 (FP16x2) 的问题:发射 1 条指令 -> 傻等数据返回 (Stall) -> 计算。在等待期间,GPU 没事干。

    V5 的改进:连发 4 条指令。当 GPU 还在等待第 1 条数据从显存返回时,它已经把第 2、3、4 条指令发出去了。这充分利用了指令流水线的空隙,掩盖了昂贵的显存延迟。

  • 局限:指令密度依然很高。

    虽然利用了 ILP,但本质上还是发起了 4 次 32-bit 的「小车运输」。为了搬运 128 bit 的数据,SM 依然消耗了 4 个指令发射周期。指令发射端依然非常忙碌,没有达到「一条指令搬一座山」的效果。


代码 (v5_f16x8.cu):

%%writefile v5_f16x8.cu#include <torch/extension.h>#include <cuda_fp16.h>
#define HALF2(value) (reinterpret_cast<half2 *>(&(value))[0])
__global__ void elementwise_add_f16x8_kernel(half *a, half *b, half *c, int N) {    int idx = 8 * (blockIdx.x * blockDim.x + threadIdx.x);      if (idx + 7 < N) {        half2 ra0 = HALF2(a[idx + 0]);         half2 ra1 = HALF2(a[idx + 2]);         half2 ra2 = HALF2(a[idx + 4]);         half2 ra3 = HALF2(a[idx + 6]);            half2 rb0 = HALF2(b[idx + 0]);        half2 rb1 = HALF2(b[idx + 2]);        half2 rb2 = HALF2(b[idx + 4]);        half2 rb3 = HALF2(b[idx + 6]);            HALF2(c[idx + 0]) = __hadd2(ra0, rb0);        HALF2(c[idx + 2]) = __hadd2(ra1, rb1);        HALF2(c[idx + 4]) = __hadd2(ra2, rb2);        HALF2(c[idx + 6]) = __hadd2(ra3, rb3);    }    else if (idx < N) {        for(int i = 0; i < 8; i++){              if (idx + i < N) {                    c[idx + i] = __hadd(a[idx + i], b[idx + i]);              }        }    }}
void elementwise_add_f16x8(torch::Tensor a, torch::Tensor b, torch::Tensor c) {      int N = a.numel();      int threads_per_block = 256 / 8;      int blocks_per_grid = (N + 256 - 1) / 256;        elementwise_add_f16x8_kernel<<<blocks_per_grid, threads_per_block>>>(            reinterpret_cast<half*>(a.data_ptr<at::Half>()),            reinterpret_cast<half*>(b.data_ptr<at::Half>()),            reinterpret_cast<half*>(c.data_ptr<at::Half>()),            N      );}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {    m.def("add", &elementwise_add_f16x8, "FP16x8 Add");}


hyper Jupyter 运行示例见附录


6.Version 6: FP16x8 Pack (终极优化)

这是 Elementwise 算子优化的天花板。我们结合了 V2 的「大宽带搬运」和 V5 的「指令级并行」,并引入了寄存器缓存技术。


核心魔法深度解析:


1.地址伪装:


* 问题:我们的数据是  half  类型,GPU 并没有原生的  load_8_halfs  这种指令。


* 对策: float4 类型恰好占用 128 位(16字节),而 8 个  half  也占用 128 位。


* 操作:我们把  half  数组的地址强行转换( reinterpret_cast )为  float4* 


* 效果:编译器看到  float4* ,就会生成 1 条  LD.E.128  指令。显存控制器才不管你搬的是什么,它只管一次搬运 128 bit 的二进制流。


2. 寄存器数组 (Register Array):


 half pack_a[8] :这个数组虽然定义在 Kernel 里,但因为是固定大小且很小,编译器会把它直接映射到 GPU 的寄存器文件 (Register File) 中,而不是慢速的 Local Memory。这就相当于在「手边」开辟了一块极速缓存区。


3. 内存再解释 (Reinterpretation):


宏定义 LDST128BITS这是本代码的灵魂。它把任何变量的地址强转为  float4* 并取值。


LDST128BITS(pack_a[0])=LDST128BITS(a[idx]);

* 右边:去 Global Memory a[idx] 处,抓取 128 bit 数据。

* 左边:把这 128 bit 数据直接覆盖写入到 pack_a 数组(从第 0 个元素开始填,瞬间填满 8 个)。

* 结果:1 条指令,瞬间完成 8 个数据的搬运。


代码 (v6_f16x8_pack.cu):

%%writefile v6_f16x8_pack.cu#include <torch/extension.h>#include <cuda_fp16.h>
#define LDST128BITS(value) (reinterpret_cast<float4 *>(&(value))[0])#define HALF2(value) (reinterpret_cast<half2 *>(&(value))[0])
__global__ void elementwise_add_f16x8_pack_kernel(half *a, half *b, half *c, int N)   int idx = 8 * (blockIdx.x * blockDim.x + threadIdx.x);      half pack_a[8], pack_b[8], pack_c[8];       if ((idx + 7) < N) {        LDST128BITS(pack_a[0]) = LDST128BITS(a[idx]);         LDST128BITS(pack_b[0]) = LDST128BITS(b[idx]);             #pragma unroll        for (int i = 0; i < 8; i += 2) {            HALF2(pack_c[i]) = __hadd2(HALF2(pack_a[i]), HALF2(pack_b[i]));        }                LDST128BITS(c[idx]) = LDST128BITS(pack_c[0]);    }     else if (idx < N) {          for (int i = 0; i < 8; i++) {              if (idx + i < N) {                    c[idx + i] = __hadd(a[idx + i], b[idx + i]);              }          }    }   }  void elementwise_add_f16x8_pack(torch::Tensor a, torch::Tensor b, torch::Tensor c) {       int N = a.numel();       int threads_per_block = 256 / 8;        int blocks_per_grid = (N + 256 - 1) / 256;          elementwise_add_f16x8_pack_kernel<<<blocks_per_grid, threads_per_block>>>(             reinterpret_cast<half*>(a.data_ptr<at::Half>()),             reinterpret_cast<half*>(b.data_ptr<at::Half>()),             reinterpret_cast<half*>(c.data_ptr<at::Half>()),             N       ); }  PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {     m.def("add", &elementwise_add_f16x8_pack, "FP16x8 Pack Add"); }




第三阶段:综合 Benchmark 与可视化分析


为了全方位评估优化效果,我们设计了一个覆盖延迟敏感型(小数据)到带宽敏感型(大数据)的全场景测试方案。


1.测试策略设计


我们选取了三组具有代表性的数据规模,分别对应 GPU 内存层级的不同瓶颈:


  1. Cache Latency (1M 元素):数据量极小(4MB),完全命中 L2 Cache。测试核心在于 Kernel 启动延迟 (Launch Overhead) 和指令发射效率。

  2. L2 Throughput (16M 元素):数据量中等(64MB),接近 L2 Cache 容量上限。测试核心在于 L2 Cache 的读写吞吐能力。

  3. VRAM Bandwidth (256M 元素):数据量巨大(1GB),远超 L2 Cache。数据必须从显存(VRAM)搬运。这是大模型算子的真实战场,测试核心在于是否跑满了物理显存带宽


2.Benchmark 脚本 (Python)


该脚本直接加载上述定义的 .cu 文件,并自动计算带宽(GB/s)与耗时(ms)。

import torchfrom torch.utils.cpp_extension import loadimport timeimport os
# ==========================================# 0. 准备工作# ==========================================# 确保你的文件路径和笔记里写的一致kernel_dir = "."flags = ["-O3""--use_fast_math""-U__CUDA_NO_HALF_OPERATORS__"]
print(f"Loading kernels from {kernel_dir}...")
# ==========================================# 1. 分别加载 6 个模块# ==========================================# 我们分别编译加载,确保每个模块有独立的命名空间,避免符号冲突try:      mod_v1 = load(name="v1_lib", sources=[os.path.join(kernel_dir, "v1_f32.cu")], extra_cuda_cflags=flags, verbose=False)      mod_v2 = load(name="v2_lib", sources=[os.path.join(kernel_dir, "v2_f32x4.cu")], extra_cuda_cflags=flags, verbose=False)      mod_v3 = load(name="v3_lib", sources=[os.path.join(kernel_dir, "v3_f16.cu")], extra_cuda_cflags=flags, verbose=False)      mod_v4 = load(name="v4_lib", sources=[os.path.join(kernel_dir, "v4_f16x2.cu")], extra_cuda_cflags=flags, verbose=False)      mod_v5 = load(name="v5_lib", sources=[os.path.join(kernel_dir, "v5_f16x8.cu")], extra_cuda_cflags=flags, verbose=False)      mod_v6 = load(name="v6_lib", sources=[os.path.join(kernel_dir, "v6_f16x8_pack.cu")], extra_cuda_cflags=flags, verbose=False)      print("All Kernels Loaded Successfully!\n")except Exception as e:      print("\n[Error] 加载失败!请检查目录下是否有这6个 .cu 文件,且代码已修正语法错误。")      print(f"详细报错: {e}")      raise e  # ==========================================# 2. Benchmark 工具函数# ==========================================def run_benchmark(func, a, b, tag, out, warmup=10, iters=1000):      # 重置输出      out.fill_(0)        
  # Warmup (预热,让 GPU 进入高性能状态)      for _ in range(warmup):            func(a, b, out)      torch.cuda.synchronize()              # Timing (计时)      start = time.time()      for _ in range(iters):            func(a, b, out)      torch.cuda.synchronize()      end = time.time()            # Metrics (指标计算)      avg_time_ms = (end - start) * 1000 / iters            # Bandwidth Calculation: (Read A + Read B + Write C)      element_size = a.element_size() # float=4, half=2      total_bytes = 3 * a.numel() * element_size      bandwidth_gbs = total_bytes / (avg_time_ms / 1000) / 1e9            # Check Result (打印前2个元素用于验证正确性)      # 取数据回 CPU 检查      out_val = out.flatten()[:2].cpu().float().tolist()      out_val = [round(v, 4for v in out_val]            print(f"{tag:<20} | Time: {avg_time_ms:.4f} ms | BW: {bandwidth_gbs:>7.1f} GB/s | Check: {out_val}")    # ==========================================  # 3. 运行测试 (从小到大)  # ==========================================  # 1M = 2^20  shapes = [        (10241024),         # 1M elems  (Cache Latency)        (40964096),         # 16M elems (L2 Cache 吞吐)        (1638416384),       # 256M elems (显存带宽压测)  ]    print(f"{'='*90}")  print(f"Running Benchmark on {torch.cuda.get_device_name(0)}")  print(f"{'='*90}\n")    for S, K in shapes:        N = S * K        print(f"--- Data Size: {N/1e6:.1f} M Elements ({N*4/1024/1024:.0f} MB FP32) ---")                # --- FP32 测试 ---        a_f32 = torch.randn((S, K), device="cuda", dtype=torch.float32)        b_f32 = torch.randn((S, K), device="cuda", dtype=torch.float32)        c_f32 = torch.empty_like(a_f32)                # 注意:这里调用的是 .add 方法,因为你在 PYBIND11 里面定义的名字是 "add"        run_benchmark(mod_v1.add, a_f32, b_f32, "V1 (FP32 Base)", c_f32)        run_benchmark(mod_v2.add, a_f32, b_f32, "V2 (FP32 Vec)", c_f32)        # PyTorch 原生对照        run_benchmark(lambda a,b,c: torch.add(a,b,out=c), a_f32, b_f32, "PyTorch (FP32)", c_f32)                # --- FP16 测试 ---        print("-" * 60)        a_f16 = a_f32.half()        b_f16 = b_f32.half()        c_f16 = c_f32.half()                run_benchmark(mod_v3.add, a_f16, b_f16, "V3 (FP16 Base)", c_f16)        run_benchmark(mod_v4.add, a_f16, b_f16, "V4 (FP16 Half2)", c_f16)        run_benchmark(mod_v5.add, a_f16, b_f16, "V5 (FP16 Unroll)", c_f16)        run_benchmark(mod_v6.add, a_f16, b_f16, "V6 (FP16 Pack)", c_f16)        # PyTorch 原生对照        run_benchmark(lambda a,b,c: torch.add(a,b,out=c), a_f16, b_f16, "PyTorch (FP16)", c_f16)        print("\n")    


3.实战数据:RTX 5090 真实表现


以下是在 NVIDIA GeForce RTX 5090 上运行上述代码获得的真实数据:

==========================================================================================Running Benchmark on NVIDIA GeForce RTX 5090==========================================================================================--- 
Data Size: 1.0 M Elements (4 MB FP32) ---V1 (FP32 Base)       | Time: 0.0041 ms | BW:  3063.1 GB/s | Check: [0.8656, 1.9516]V2 (FP32 Vec)        | Time: 0.0041 ms | BW:  3066.1 GB/s | Check: [0.8656, 1.9516]PyTorch (FP32)       | Time: 0.0044 ms | BW:  2868.9 GB/s | Check: [0.8656, 1.9516]------------------------------------------------------------V3 (FP16 Base)       | Time: 0.0041 ms | BW:  1531.9 GB/s | Check: [0.8657, 1.9512]V4 (FP16 Half2)      | Time: 0.0041 ms | BW:  1531.9 GB/s | Check: [0.8657, 1.9512]V5 (FP16 Unroll)     | Time: 0.0041 ms | BW:  1533.5 GB/s | Check: [0.8657, 1.9512]V6 (FP16 Pack)       | Time: 0.0041 ms | BW:  1533.6 GB/s | Check: [0.8657, 1.9512]PyTorch (FP16)       | Time: 0.0044 ms | BW:  1431.6 GB/s | Check: [0.8657, 1.9512]

--- Data Size: 16.8 M Elements (64 MB FP32) ---V1 (FP32 Base)       | Time: 0.1183 ms | BW:  1702.2 GB/s | Check: [-3.2359, -0.1663]V2 (FP32 Vec)        | Time: 0.1186 ms | BW:  1698.1 GB/s | Check: [-3.2359, -0.1663]PyTorch (FP32)       | Time: 0.1176 ms | BW:  1711.8 GB/s | Check: [-3.2359, -0.1663]------------------------------------------------------------V3 (FP16 Base)       | Time: 0.0348 ms | BW:  2891.3 GB/s | Check: [-3.2363, -0.1664]V4 (FP16 Half2)      | Time: 0.0348 ms | BW:  2891.3 GB/s | Check: [-3.2363, -0.1664]V5 (FP16 Unroll)     | Time: 0.0348 ms | BW:  2892.8 GB/s | Check: [-3.2363, -0.1664]V6 (FP16 Pack)       | Time: 0.0348 ms | BW:  2892.6 GB/s | Check: [-3.2363, -0.1664]PyTorch (FP16)       | Time: 0.0148 ms | BW:  6815.7 GB/s | Check: [-3.2363, -0.1664]

--- Data Size: 268.4 M Elements (1024 MB FP32) ---V1 (FP32 Base)       | Time: 2.0432 ms | BW:  1576.5 GB/s | Check: [0.4839, -2.6795]V2 (FP32 Vec)        | Time: 2.0450 ms | BW:  1575.2 GB/s | Check: [0.4839, -2.6795]PyTorch (FP32)       | Time: 2.0462 ms | BW:  1574.3 GB/s | Check: [0.4839, -2.6795]------------------------------------------------------------V3 (FP16 Base)       | Time: 1.0173 ms | BW:  1583.2 GB/s | Check: [0.4839, -2.6797]V4 (FP16 Half2)      | Time: 1.0249 ms | BW:  1571.5 GB/s | Check: [0.4839, -2.6797]V5 (FP16 Unroll)     | Time: 1.0235 ms | BW:  1573.6 GB/s | Check: [0.4839, -2.6797]V6 (FP16 Pack)       | Time: 1.0236 ms | BW:  1573.4 GB/s | Check: [0.4839, -2.6797]PyTorch (FP16)       | Time: 1.0251 ms | BW:  1571.2 GB/s | Check: [0.4839, -2.6797] 


4.数据解读


这份数据清晰地展示了 RTX 5090 在不同负载下的物理特性:


阶段一:极小规模 (1M Elements / 4MB)

  • 现象:所有版本的耗时惊人一致,均为 0.0041 ms。

  • 真相:此时是 Latency Bound (延迟受限)。

    不管数据多小,GPU 启动一个 Kernel 的固定开销(Launch Overhead)就是约 4微秒。

    因为耗时锁死,FP16 数据量只有 FP32 的一半,所以算出来的带宽自然只有一半。这里测的不是传输速度,而是「点火速度」。


阶段二:中等规模 (16M Elements / 64MB vs 32MB)

这是最能体现 L2 Cache 作用的区域:

  • FP32 (64MB):总数据量 A+B+C≈192MB。这超过了 RTX 5090 的 L2 Cache 容量(约 128MB)。数据溢出,被迫去读写 VRAM,所以带宽掉到了 1700 GB/s(接近显存物理带宽)。

  • FP16 (32MB):总数据量 。刚好能塞进 L2 Cache! 数据在高速缓存内循环,所以带宽飙升至 2890 GB/s。

  • PyTorch 的黑魔法:注意 PyTorch 在 FP16 下跑出了 6815 GB/s。这说明在纯 Cache 场景下,JIT 编译器的指令流水线优化依然强于简单的手写 Kernel。


阶段三:大规模 (268M Elements / 1024MB)


这是大模型训练/推理的真实场景(Memory Bound):


  • 众生平等:无论是 FP32 还是 FP16,无论是 Baseline 还是 Optimized,带宽全部死死锁定在 1570 - 1580 GB/s。

  • 物理墙:我们成功触碰到了 RTX 5090 的 GDDR7 显存带宽物理极限。水管就这么粗,谁也别想运得更快。

  • 优化的价值:

    虽然带宽没变,但发现 FP16 的耗时 (1.02ms) 只有 FP32 (2.04ms) 的一半。在跑满带宽的前提下,将数据量减半,有 2x 的端到端加速。

    V6 vs V3:虽然看起来 V3 也跑满了,这是因为 NVCC 编译器自动优化 和 GPU 硬件延迟掩盖 帮了忙。但在更复杂的算子(如 FlashAttention)中,V6 的写法才是性能的保障。




核心 FAQ:参数设计的硬核推演


在本次实验的所有 Kernel 中,我们不约而同地设置了一个参数:threads_per_block = 256。这个数字并非随手一填,而是在硬件限制与调度效率之间做出的数学最优解。


Q: 为什么 threads_per_block 总是设为 128 或 256?

A: 这是一个通过四层筛选得出的「黄金区间」。


我们将 block_size 的选值过程看作一个漏斗,层层过滤:


1.Warp 对齐 (Warp Alignment) -> 必须是 32 的倍数

GPU 的最小执行单元是 Warp (线程束),由 32 个连续线程组成(SIMT 架构,单指令多线程)。


  • 硬限制:如果你申请 31 个线程,硬件依然会调度 1 个完整的 Warp,剩下的 1 个线程位置虽然闲置,但依然占用相同的硬件资源。

  • 结论: block_size 最好是 32 的整数倍,以避免算力浪费。


2.Occupancy 下限 (Occupancy Floor) -> 必须 >= 96

Occupancy (占用率) = 当前 SM 上并发执行的线程数 / SM 支持的最大线程数。


  • 背景:为了掩盖显存延迟,我们需要足够多的 Active Warps。如果 Block 太小,SM 的“Max Blocks”限制会先于“Max Threads”限制被触顶。

  • 推算:

    主流架构(如 Turing/Ampere/Ada)通常要求: block_size > (SM最大线程数 / SM最大Block数)。

    常见的比值有 64 或 96。

  • 结论:为了理论上能达到 100% Occupancy, block_size 不应小于 96。


3.调度原子性 (Scheduling Atomicity) -> 锁定 128, 256, 512

Block 是调度给 SM 的最小原子单位。SM 必须能完整吃下整数个 Block。


  • 整除性:为了不浪费 SM 的容量, block_size 最好能被 SM 的最大线程容量整除。

  • 筛选:

    主流架构 SM 最大容量通常为 1024, 1536, 2048 等。

    它们的公约数通常是 512。

    结合前两步(>=96 且是 32 倍数),我们的候选名单缩小为:128, 192, 256, 384, 512。


4.寄存器压力 (Register Pressure) -> 排除 512+

这是最后的「天花板」。


  • 硬限制:每个 Block 能用的寄存器总数有限(SM 总寄存器通常为 64K 个 32bit)。

  • 风险:如果 block_size 很大(如 512),而 Kernel 又稍微复杂一点(每个线程多用几个寄存器),就会出现 512 * Regs/Thread > Max_Regs_Per_Block 的情况。

  • 后果:

    启动失败:直接报错。

    Register Spill:寄存器溢出到慢速的 Local Memory,性能雪崩。

  • 结论:为了安全(Safe Bet),我们通常避免使用 512 或 1024。128 和 256 是最安全的「甜点区」。




总结

经过四层漏斗筛选,只剩下两位选手:


  • 128:通用性最强。即使 Kernel 很复杂(寄存器用得多),也能保证成功启动且 Occupancy 不错。

  • 256:elementwise算子首选。对于逐元素这类逻辑简单的算子,寄存器压力极小。256 能提供比 128 更好的访存合并潜力(Coalescing),且减少了 Block 的调度开销。


这也解释了为什么在朴素实现中,我们一旦确定了 threads_per_block = 256grid_size 也就随之确定了(只要保证总量覆盖 N 即可)。




附录: Jupyter 运行示例



内容中包含的图片若涉及版权问题,请及时与我们联系删除