编程 Rust core::simd 跑进 GPU warp:VectorWare 把 SIMT 还原成 SIMD 的那一刀——从 lane 映射到类型化 Warp IR 的全链路拆解

2026-08-11 05:20:02 +0800 CST views 6

Rust core::simd 跑进 GPU warp:VectorWare 把 SIMT 还原成 SIMD 的那一刀——从 lane 映射到类型化 Warp IR 的全链路拆解

2026 年 8 月 10 日,VectorWare 发了一篇不长的博客,标题很平淡:《Rust SIMD on the GPU》。内容一句话概括:Rust 的 portable SIMD(core::simd)现在可以在 GPU 上跑了,源码一个字不改。

如果你只把它当成"又一个 GPU 后端支持了新特性",那就错过了这件事真正的分量。这不是新增了一个 intrinsic,也不是又发明了一门 GPU DSL。这是有人把一个存在了近二十年的、被所有 GPU 教材当成"两个东西"的概念——SIMD 和 SIMT——按回原位,然后顺手证明:一个 Simd<f32, 32> 值,在 x86 上是一条 AVX-512 指令,在 GPU 上就是一整个 warp。

这篇文章我会把这件事拆到底:为什么 GPU 编程模型从一开始就是错位的、warp 到底能不能当向量单元用、四类 SIMD 算子分别下降成什么 PTX 指令、通道宽度不匹配时该怎么写、零成本抽象的边界在哪里、以及作为一个不写 CUDA 的普通后端/系统程序员,这件事和你有什么关系。

代码全部可读可抄,PTX 和 SASS 层面的对应关系我会尽量给到指令级。文中凡是我自己的推断而非 VectorWare 原文事实,我会明确标注。


一、先看结论:这不是"又一个 GPU DSL"

先把结论摆在前面,方便你判断要不要读完。

VectorWare 这一年做了四件事,按时间线排:

时间里程碑映射关系
2026-01-20Rust std 跑在 GPU 上用 hostcall 把 syscall 转发给主机
(更早)async/await 跑在 GPU 上Future 状态机在设备侧驱动
2026-03-24std::thread 跑在 GPU 上1 个 std::thread = 1 个 warp
2026-08-10core::simd 跑在 GPU 上1 个 SIMD lane = 1 个 warp lane

这四件事拼在一起,形成了一个完整的、闭合的映射:

CPU 侧的心智模型              GPU 侧的硬件实体
─────────────────────────────────────────────
进程 / fn main()       →     kernel(单 warp 启动)
std::thread            →     warp(32 lane 一组)
SIMD lane              →     warp lane
async task             →     warp 内的协作式调度单元
std::fs / std::io      →     hostcall(RPC 到主机)

它的野心不是"让 Rust 能写 GPU",而是让 GPU 变成 Rust 的一个普通 target。这两句话听起来差不多,实际差着一整个生态:前者你要学一套新 API,后者你的 rayon、你的 #![no_std] crate、你写了三年的向量化数值库,理论上都能直接跑。

VectorWare 原文最后一句话说得很直白:

"We are bringing the GPU to Rust instead of just bringing Rust to the GPU."

这句话值得单独琢磨十分钟。


二、背景:GPU 编程模型和执行模型的错位

要理解 SIMD-on-GPU 为什么是个"手术"而不是"补丁",得先理解 GPU 编程从第一天起就带着的那个原罪。

2.1 kernel 是一个"说谎的函数"

看这段谁都写过的 CUDA C:

__global__ void scale(float* data) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    data[i] *= 2.0f;
}

它长得像一个函数。它有参数、有函数体、有返回。但它不是一个函数——它是一个会被硬件复制几千份、每份带着不同 threadIdx 同时执行的东西。

这就是 VectorWare 那篇 threads 文章里点破的核心矛盾:编程模型(function)和执行模型(massively parallel instances)不匹配

后果是什么?后果是所有的不变量都得靠人脑维护:

  • 索引算对了吗?越界了吗?
  • 两个 instance 会不会写同一个地址?
  • 这个分支会不会导致同 warp 内 lane 走不同路径(divergence)?
  • 共享内存的 __syncthreads() 放的位置,所有 lane 都能到达吗?

编译器帮不了你,因为在编译器眼里这就是一个普通函数。你写的 data[i] *= 2.0f 和单线程 CPU 代码在 IR 层面几乎没区别,并行语义完全在函数体之外、在 launch 配置里。

2.2 Rust 在 GPU 上的历史包袱

Rust 走 GPU 这条路,之前的形态是这样的(nvptx64-nvidia-cuda target):

#![no_std]
#![feature(abi_ptx, stdsimd)]
use core::arch::nvptx::*;

#[no_mangle]
pub unsafe extern "ptx-kernel" fn scale(data: *mut f32) {
    let i = (_block_idx_x() * _block_dim_x() + _thread_idx_x()) as usize;
    *data.add(i) *= 2.0;
}

注意这几个词:#![no_std]unsafe*mut f32

为什么必须是裸指针? 因为几千个 instance 拿到的是同一个指针。Rust 的 &mut T 语义是独占可变借用——你没法用 &mut [f32] 表达"这几千个执行体各自动其中一段"。所以整个 kernel 边界被当成 FFI 边界处理:借用检查器在这里停摆,生命周期在这里失效,所有权模型在这里让位。

为什么必须 no_std 因为 GPU 上没有操作系统。没有文件系统、没有网络栈、没有 SystemTime、没有线程调度器。std 里凡是碰 syscall 的东西全部不可用。

结果就是:你用 Rust 写 GPU,得到的是"带泛型和 trait 的 C"。安全性收益基本归零。VectorWare 那篇 std 文章里对此的评价很克制但很扎心——no_std crate 能直接复用是好事,"这比其他 GPU 生态好得多",但**"opting out of std has a cost"**。

2.3 VectorWare 的破局思路:不改语言,改映射

摆在面前有三条路:

  1. 发明新类型和新注解,让语言能表达 GPU 语义。代价:又一套要学的 GPU 专用编程模型,生态割裂。
  2. 改编译器让 Rust 理解 GPU 执行模型。代价:长期工程,设计空间巨大,几年起步。
  3. 让 GPU 去适配 Rust 的执行模型

VectorWare 选了第三条,而且执行得非常激进。他们的做法本质上是一个"降维打击":

GPU 的执行模型之所以和 CPU 不兼容,是因为大家默认 kernel 一启动所有 warp 就得干活。但没有任何硬件规定 warp 必须立刻干活

于是:kernel 启动时只让 Warp 0 活着跑 main(),其余 warp 全部睡眠;thread::spawn() 唤醒一个睡眠的 warp 去跑闭包;join() 阻塞父 warp 直到子 warp 结束。

这一下就把 CPU 的执行模型完整恢复了:一个控制流起点,并发由程序员显式引入。借用检查器、生命周期、Send/Sync 全部原地复活。

而今天要讲的 SIMD,是这个映射的第二层


三、核心概念:SIMT 到底是不是 SIMD

3.1 三层并行的坐标系

先把术语钉死,因为这块是重灾区。

CPU 侧:

进程
 └─ 线程(OS 调度单位,独立 PC、独立栈)
     └─ SIMD lane(一条指令内的数据并行,无独立控制流)

GPU 侧(NVIDIA 术语):

grid
 └─ block(CTA,共享 shared memory,可 __syncthreads)
     └─ warp(32 个 lane,一个 PC,一个调度单位)
         └─ lane / "thread"(NVIDIA 管它叫 thread,这是命名灾难的源头)

NVIDIA 把 lane 叫 thread,这是过去十五年 GPU 编程最大的术语污染。

一个 CPU 线程有:独立的栈、独立的程序计数器、可被独立调度。一个 GPU "thread"(lane):没有独立 PC(Volta 之后有 per-thread PC,但调度粒度仍是 warp)、没有独立栈(栈是 per-thread 分配的,但控制流仍绑在 warp 上)、不能被独立调度

它更像什么?它更像 CPU 上的一条 SIMD 通道

VectorWare 的原话:

"A GPU thread is a single lane within a warp, more analogous to a SIMD lane on a CPU than an independent execution context."

所以对齐关系其实一直摆在那儿,只是被命名遮住了:

CPUGPU共同点
线程warp独立 PC、独立寄存器组、可被调度器换出
SIMD lanewarp lane无独立控制流,随指令一起前进

3.2 SIMT 只是 SIMD 加了个"每通道寻址"

SIMT = Single Instruction, Multiple Thread。这个术语是 NVIDIA 造的市场词汇,用来强调"我们比 SIMD 灵活"。

灵活在哪?主要两点:

  1. 每个 lane 可以访问不同的内存地址(gather/scatter 是原生的,不需要专门指令)。
  2. 每个 lane 可以被谓词屏蔽(分支时通过 active mask 关掉一部分 lane)。

但你去看 x86 的 AVX-512:vgatherdps 是每通道寻址,k 掩码寄存器是每通道谓词。ARM SVE 更是把谓词做成一等公民。

SIMT 提供的能力,现代 SIMD ISA 全都有。 差别只在于 GPU 把它们做成了默认路径,而 CPU 上是可选路径。

VectorWare 的判断因此非常干脆:

"A warp issues one instruction, and each of its 32 lanes runs that instruction on its own data. One instruction operating on many data elements is exactly what SIMD means."

一个 warp 就是一个 32 通道的向量单元。这不是类比,这是恒等式。

3.3 portable SIMD 与 core::arch 的分野

Rust 里写 SIMD 历史上有两条路。

路线 A:core::arch 厂商 intrinsic。

#[cfg(target_arch = "x86_64")]
use core::arch::x86_64::*;

#[target_feature(enable = "avx")]
unsafe fn add8(a: __m256, b: __m256) -> __m256 {
    _mm256_add_ps(a, b)
}

#[cfg(target_arch = "aarch64")]
use core::arch::aarch64::*;

unsafe fn add4(a: float32x4_t, b: float32x4_t) -> float32x4_t {
    vaddq_f32(a, b)
}

问题很明显:unsafe、每个架构一套、宽度写死、跨平台就得写 N 份 + runtime dispatch。

路线 B:core::simd portable SIMD。

#![feature(portable_simd)]
use core::simd::Simd;

fn add<const N: usize>(a: Simd<f32, N>, b: Simd<f32, N>) -> Simd<f32, N>
where
    core::simd::LaneCount<N>: core::simd::SupportedLaneCount,
{
    a + b   // safe,无 unsafe,无 target_feature
}

Simd<T, N> 是一个泛型的"N 个 T 的向量"。你写一次算术、比较、归约、shuffle,编译器负责下降到目标平台有的向量指令;没有的话就标量展开。

关键细节一:core::simdcore 里,不在 std 里。

这点在 GPU 场景下价值巨大——它意味着 SIMD 支持不依赖 VectorWare 那套 hostcall/std 基建。哪怕你在一个纯 #![no_std] 的 GPU kernel 里,portable SIMD 也能用。VectorWare 原文专门点了这一句:"it does not even need the std support we brought to the GPU."

关键细节二:通道数有约束。

VectorWare 原文说 CPU 上 N 可取 1 到 64。更精确地说,当前 Rust 的 LaneCount<N>: SupportedLaneCount 约束只对 1、2、4、8、16、32、64 这些 2 的幂成立。这个约束和 GPU 的 32 lane 恰好能对上,是运气也是必然。

关键细节三:它还没稳定。

#![feature(portable_simd)],nightly only,API 还在变。这是本次方案最大的现实风险,后面会展开。

3.4 uniform vs varying:ISPC 二十年前就给过答案

这里有一个我认为是整篇博客里最漂亮的洞察。

在 ISPC(Intel SPMD Program Compiler)里,变量分两种:

// ISPC
uniform int n = 100;        // 所有通道共享同一个值
varying float x = ...;      // 每个通道各有一份

这个区分是 ISPC 手动引入的语言关键字,因为 C 本身表达不了。

在 CUDA 里,这个区分根本不存在于语言层int i = threadIdx.x; 是 varying,int n = 100; 是 uniform,编译器靠数据流分析去推。推不出来就都当 varying 处理,寄存器全量分配。

而在 VectorWare 的映射下:

let n: usize = 100;              // 普通标量 → uniform,warp 内每个 lane 都算一遍同样的值
let x: Simd<f32, 32> = ...;      // SIMD 向量 → varying,每个 lane 一个元素

uniform/varying 的区分从 Rust 的类型系统里自然掉出来了。 不需要新关键字,不需要新注解,f32 就是 uniform,Simd<f32, 32> 就是 varying。

VectorWare 原话:

"This is the same uniform-versus-varying distinction that data-parallel languages like ISPC make explicit, except here it falls out of Rust's own types."

这就是"用现有抽象而不是发明新抽象"的力量。一个二十年历史的编译器设计难题,被一个泛型类型顺手解决了。


四、架构分析:两层映射如何拼成完整的并行层级

4.1 第一层:warp-as-thread(回顾)

std::thread → warp 的映射,实现要点:

  • kernel 启动时只有 Warp 0 活跃,跑 main()
  • 其余 warp 进入睡眠(PTX 的 nanosleep 指令,Volta / sm_70 之后可用)
  • thread::spawn() 唤醒一个睡眠 warp
  • thread::join() 阻塞父 warp
  • thread::current() 返回 warp 标识
  • thread::yield_now() 让出给 warp scheduler

有个副作用被 VectorWare 拿来当卖点,我觉得确实成立:这个模型从构造上消除了 divergence

因为 thread::spawn(closure) 是把一整个闭包一整个 warp,warp 内 32 个 lane 跑的必然是同一份代码。你在 Rust 里根本没有语法去表达"warp 内 lane 之间走不同分支"。

最坏情况是什么?是一个 warp 只用了 1 个 lane,另外 31 个 lane 空转。

空转 vs 发散,哪个更糟? 空转浪费吞吐(1/32 利用率),发散是串行化(两条路径依次执行,延迟翻倍且吞吐同样受损)。空转严格优于发散——你至少还有确定的 1/32,而发散的最坏情况是 1/32 的吞吐 + 32 倍的延迟。

但注意,这个"优点"同时也是第一层映射的天花板:如果你只有 warp 级并行,你永远只能用到 1/32 的硬件通道。

4.2 第二层:lane-as-simd-lane(本次)

所以第二层来了。

let a = Simd::<f32, 32>::splat(2.0);

这一个值,占满一个 warp 的 32 个 lane。a * b 编译成一条 warp 指令,32 个乘法同时发生。

至此,层级闭合:

kernel
 └─ std::thread  = warp        ← 第一层,任务并行
     └─ Simd lane = warp lane  ← 第二层,数据并行

这和 CPU 上的层级完全同构

process
 └─ std::thread  = OS thread
     └─ Simd lane = AVX/NEON lane

同一份源码,在 CPU 上是"多线程 + SIMD",在 GPU 上是"多 warp + warp lane"。 这就是这套设计真正的杀伤力。

4.3 四类算子的下降路径

VectorWare 把 core::simd 的算子分成四类,每类都有直接的 warp 级对应物。这部分是全文技术密度最高的地方,我逐个展开并补上 PTX 层面的对应。

(1)Elementwise:最简单的一类

let c = a + b;          // Add trait
let d = a * b;          // Mul trait
let m = a.simd_gt(b);   // SimdPartialOrd → Mask

每个 lane 独立算自己那份,硬件原生支持,零额外开销。

PTX 层面就是普通的算术指令:

add.f32   %f3, %f1, %f2;
mul.f32   %f6, %f4, %f5;
setp.gt.f32 %p1, %f7, %f8;

注意 PTX 是"单 lane 视角"的汇编——你写的 add.f32 天然就是 32 个 lane 同时执行。PTX 本身就是一门 SIMD 语言,只是它伪装成标量。 这也是为什么 elementwise 是最容易映射的一类:根本不需要做任何事。

(2)Reduction:横向归约

let s: f32 = v.reduce_sum();
let m: f32 = v.reduce_max();

跨 lane 把 32 个值合成 1 个。GPU 上用 warp shuffle 实现,标准做法是 butterfly(蝶形)归约:

// CUDA C 的经典写法
__device__ __forceinline__ float warp_reduce_sum(float v) {
    #pragma unroll
    for (int offset = 16; offset > 0; offset >>= 1) {
        v += __shfl_xor_sync(0xffffffff, v, offset);
    }
    return v;   // 每个 lane 都持有完整的和
}

对应 PTX:

shfl.sync.bfly.b32  %r2, %r1, 16, 0x1f, 0xffffffff;
add.f32             %f2, %f1, %f2;
shfl.sync.bfly.b32  %r3, %r2, 8,  0x1f, 0xffffffff;
add.f32             %f3, %f2, %f3;
shfl.sync.bfly.b32  %r4, %r3, 4,  0x1f, 0xffffffff;
add.f32             %f4, %f3, %f4;
shfl.sync.bfly.b32  %r5, %r4, 2,  0x1f, 0xffffffff;
add.f32             %f5, %f4, %f5;
shfl.sync.bfly.b32  %r6, %r5, 1,  0x1f, 0xffffffff;
add.f32             %f6, %f5, %f6;

5 轮 shuffle + 5 次加法,log₂(32) = 5。

注意 VectorWare 原文的一个精确表述:"producing the same scalar result in every lane"——蝶形归约的结果是每个 lane 都拿到完整的和,而不是只有 lane 0 拿到。这一点很重要:reduce_sum() 返回一个 f32(uniform 语义),所以所有 lane 必须持有相同的值,蝶形正好满足;如果用 shfl.down 做归约,只有 lane 0 正确,还得再广播一次。选 bfly 是对的。

Ampere(sm_80)之后还有更快的路redux.sync 指令能一条指令完成整数归约:

redux.sync.add.s32  %r2, %r1, 0xffffffff;

对应 CUDA 的 __reduce_add_sync()。但注意它在 sm_80 上只覆盖整数(add/min/max 的 u32/s32,and/or/xor 的 b32),浮点的 redux.sync.fmin/fmax 要到更新的架构才补上,而浮点加法归约因为不满足结合律,硬件从来没做过单指令版本。

这里有个隐含的语义坑(我的补充)reduce_sum() 在 CPU 上,编译器可能用 pairwise 或者线性顺序累加;在 GPU 上是蝶形。浮点加法不满足结合律,所以两边的结果可能有 ULP 级别的差异。 如果你在做差分测试(CPU 跑一遍 GPU 跑一遍对比),别用 == 比浮点。

(3)Cross-lane shuffle:跨通道搬运

use core::simd::simd_swizzle;

let rotated = simd_swizzle!(v, [1,2,3,4,5,6,7,0, /* ... */]);
let reversed = simd_swizzle!(v, [31,30,29, /* ... */ 2,1,0]);

因为 SIMD lane 就是 warp lane,这直接落到 shfl.sync

shfl.sync.idx.b32   %r2, %r1, %r_srclane, 0x1f, 0xffffffff;
shfl.sync.up.b32    %r3, %r1, 1, 0x0,  0xffffffff;
shfl.sync.down.b32  %r4, %r1, 1, 0x1f, 0xffffffff;

VectorWare 特别提了一句:"these map onto the same warp shuffle primitives that make GPU lanes so good at exchanging data."

这话反过来读更有意思:GPU 的 lane 间数据交换能力,本来就比大多数 CPU SIMD ISA 强。 x86 上跨 128-bit lane 的 permute 是有额外延迟的(vperm2f128 之类),而 GPU 的 shfl 在整个 32 lane 范围内是均匀代价的。所以 shuffle-heavy 的算法(FFT 蝶形、bitonic sort、转置)在 GPU 上映射得比在 CPU 上更自然

不过后面 6.3 节会讲,"均匀代价"只对硬件支持的模式成立。

(4)Mask:谓词

let m: Mask<i32, 32> = a.simd_gt(b);
let picked = m.select(a, b);
let any = m.any();
let all = m.all();
  • select → 每个 lane 独立的条件选择,PTX 里就是 selp
    selp.f32  %f3, %f1, %f2, %p1;
    
  • any() / all()warp vote 指令
    vote.sync.any.pred   %p2, %p1, 0xffffffff;
    vote.sync.all.pred   %p3, %p1, 0xffffffff;
    vote.sync.ballot.b32 %r1, %p1, 0xffffffff;
    

ballot 把 32 个 lane 的谓词打包成一个 32-bit 整数,是实现 Mask::to_bitmask() 的天然对应物。

这里有一个非常值得注意的语义对齐:CPU 上 Mask::any() 通常是 movmskps + test,GPU 上是 vote.sync。两者都是"横向"操作,都需要跨 lane 通信。它们的成本模型是同构的——在两个平台上,any()/all() 都比 elementwise 贵。这意味着你在 CPU 上优化 mask 使用的直觉,可以直接迁移到 GPU。

这种"成本模型同构"是 portable SIMD 能真正 portable 的前提。如果 GPU 上 any() 比 CPU 上贵 100 倍,那这个抽象就是个陷阱而不是工具。

4.4 类型化 Warp IR:把不变量搬进类型系统

前面四类算子都是"宽度刚好等于 32"的理想情况。现实中不可能总是这样。

VectorWare 的解法是给 warp 这台"小机器"定义一个 IR,但这个 IR 不是独立的数据结构,而是编码在 Rust 类型系统里的——用 type、generic、const generic 和 trait bound。

原文:

"Rather than a standalone data structure, we encode it in Rust's type system using types, generics, const generics, and trait bounds. A program is composed of typed operations: ballots, shuffles, reductions, scans, gathers, scatters, atomics, and strip mining for vectors wider than the warp. Operands, execution shape, and capacity are typed too. Because the operations carry their shape in the types, many invalid programs cannot be constructed at all."

这个设计我觉得是整套方案里最有工程价值的部分。以下是我根据描述重构的一个简化示意(非官方源码,仅用于说明思路):

// ---- 执行形状:warp 宽度作为类型 ----
pub trait WarpShape {
    const LANES: usize;
}

pub struct Nvidia32;
impl WarpShape for Nvidia32 { const LANES: usize = 32; }

pub struct Amd64;
impl WarpShape for Amd64 { const LANES: usize = 64; }

// ---- 值的"变化性"编码进类型 ----
/// warp 内每个 lane 各持一份,且每 lane 持有 CHUNK 个元素
pub struct Varying<T, W: WarpShape, const CHUNK: usize> {
    data: [T; CHUNK],
    _shape: core::marker::PhantomData<W>,
}

/// warp 内所有 lane 值相同
#[derive(Clone, Copy)]
pub struct Uniform<T>(pub T);

// ---- 算子携带形状约束 ----
impl<T, W, const CHUNK: usize> Varying<T, W, CHUNK>
where
    T: Copy + core::ops::Add<Output = T>,
    W: WarpShape,
{
    /// 归约结果必然是 uniform:类型强制了这一点
    pub fn reduce_sum(self) -> Uniform<T> {
        // 1) lane 内先把 CHUNK 个元素折叠成 1 个(无跨 lane 通信)
        let mut acc = self.data[0];
        let mut i = 1;
        while i < CHUNK {
            acc = acc + self.data[i];
            i += 1;
        }
        // 2) 再做 log2(W::LANES) 轮蝶形 shuffle
        Uniform(warp_butterfly_add::<W, T>(acc))
    }
}

// ---- 编译期就能拒绝的错误 ----
// let x: Varying<f32, Nvidia32, 1> = ...;
// let y: Varying<f32, Amd64, 1>    = ...;
// let z = x + y;   // ❌ 不同 WarpShape 无法相加,编译期报错
//
// fn need_uniform(idx: Uniform<usize>) { ... }
// need_uniform(some_varying);   // ❌ varying 不能当 uniform 用

这套设计解决了 CUDA 编程里最要命的一类 bug:你在一个只有部分 lane 活跃的上下文里,调用了一个要求全 warp 参与的原语。

在 CUDA 里这是运行时 UB(__shfl_sync 的 membermask 和实际 active mask 不匹配,Volta 之后行为未定义,实践中表现为挂死或静默错值)。在类型化 IR 里,这类程序根本构造不出来

VectorWare 还提到一个配套设施,我认为价值不亚于 IR 本身:

"We built a reference interpreter that executes the IR deterministically, a kind of Miri for warp-lane programming. We use it to simulate GPU code and for differential testing."

一个 warp 级的 Miri。 在 CPU 上确定性地模拟 warp 语义,然后和真实 GPU 跑差分测试。GPU 程序最难的就是不确定性调试——没有断点、没有 printf(有但会改变时序)、race 只在特定 occupancy 下复现。有一个确定性的参考解释器,调试体验是数量级的差异。

4.5 "不需要 std"这个细节被严重低估了

回到 3.3 提过的一句话:core::simdcore 里。

这意味着 SIMD-on-GPU 这条路径不依赖 hostcall 框架

为什么重要?因为 hostcall 是有代价的。它本质上是 GPU→CPU 的 RPC:双缓冲、原子操作、CUDA stream 异步、避免 torn read。VectorWare 自己承认要"take care to avoid data tearing and ensure memory consistency"。这套东西再优化,一次 hostcall 的延迟也在微秒量级——对于计算密集的内层循环,这是不可接受的。

core::simd 完全在设备侧闭环。它是这四个里程碑里,唯一一个理论上零主机参与、零额外抽象开销的。 这也解释了为什么 VectorWare 敢说"zero cost over hand-written PTX"——这句话对 SIMD 成立,对 std::fs::File::open 显然不成立。


五、代码实战

5.1 官方示例逐行拆解

先把 VectorWare 给的例子完整贴出来:

#![feature(portable_simd)]

use core::simd::cmp::SimdPartialOrd;
use core::simd::num::SimdFloat;
use core::simd::{Select, Simd};

// 这个函数在 CPU 上同样编译运行,
// 会根据 target 下降到 x86-64、Arm 或标量代码。
fn relu_dot(a: Simd<f32, 32>, b: Simd<f32, 32>) -> f32 {
    // 逐元素乘法:32 个乘积一次算完
    let products = a * b;

    // 逐 lane 比较,产生 mask,每 lane 一个 bool
    let positive = products.simd_gt(Simd::splat(0.0));

    // 保留正的乘积,其余置零
    let clamped = positive.select(products, Simd::splat(0.0));

    // 横向求和,压成一个标量
    clamped.reduce_sum()
}

fn main() {
    let a = Simd::<f32, 32>::splat(2.0);
    let b = Simd::<f32, 32>::from_array(std::array::from_fn(|i| i as f32 - 16.0));

    let result = relu_dot(a, b);

    // 从 GPU 上打印,依赖前面做的 std 支持
    println!("relu_dot = {result}");
}

几个必须注意的点:

第一,入口是 fn main(),没有任何 GPU 注解。 对比 std 那篇文章里还要写 #[unsafe(no_mangle)] pub extern "gpu-kernel" fn kernel_main(),这里已经干净到看不出是 GPU 代码。VectorWare 说他们的工具链负责把它编译成 kernel。

第二,println! 走的是 hostcall。 这是唯一需要 std 的地方——纯计算部分不需要。

第三,use core::simd::Select 注意这个 Select trait。当前 nightly 上主流写法是 Mask::select(mask, a, b) 或者 mask.select(a, b)Mask 的固有方法)。原文导入了一个 Select trait,说明 portable SIMD 的 API 在这个时间点上又动过。这正好印证了 API 不稳定这个风险——你今天抄的代码,半年后可能编译不过。

第四,std::array::from_fn:注意它用的是 std::array 而不是 core::array,在 GPU 上下文里两者等价(std re-export 了 core)。

这段代码在 GPU 上的实际执行是什么样的? 我逐行标注(推断,基于前述映射规则):

fn main()                                       ← Warp 0 执行
  Simd::<f32,32>::splat(2.0)                   ← 一条 mov.f32,32 个 lane 各得 2.0
  Simd::from_array(...)                        ← lane i 得到 (i - 16.0)
  relu_dot(a, b)
    a * b                                      ← 一条 mul.f32(warp 级)
    products.simd_gt(splat(0.0))               ← 一条 setp.gt.f32
    positive.select(products, splat(0.0))      ← 一条 selp.f32
    clamped.reduce_sum()                       ← 5× shfl.sync.bfly + 5× add.f32
  println!("...")                              ← hostcall,RPC 到主机

整个计算部分:大约 4 条向量指令 + 10 条归约指令。这就是所谓的 zero cost。

5.2 同一份逻辑的四种写法对照

为了让你直观感受抽象层级的差异,我把"ReLU 点积"用四种方式写一遍。

写法一:CUDA C(手写 intrinsic)

__device__ __forceinline__ float relu_dot_cuda(float a, float b) {
    float p = a * b;
    float c = (p > 0.0f) ? p : 0.0f;
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1)
        c += __shfl_xor_sync(0xffffffff, c, off);
    return c;
}

问题:归约逻辑硬编码 warp 宽度 32;0xffffffff 这个 membermask 是个定时炸弹(如果调用点有 lane 不活跃就是 UB);abfloat 但实际是 varying,看代码完全分不出来。

写法二:Rust + rust-cuda 的 warp intrinsic

#![no_std]
use cuda_std::warp;

#[inline(always)]
unsafe fn relu_dot_rustcuda(a: f32, b: f32) -> f32 {
    let p = a * b;
    let mut c = if p > 0.0 { p } else { 0.0 };
    let mut off = 16u32;
    while off > 0 {
        c += warp::warp_shuffle_xor(c, off);
        off >>= 1;
    }
    c
}

比 CUDA C 好一点(有类型、有模块),但本质一样:unsafe、宽度硬编码、uniform/varying 不可见。

写法三:ISPC

export uniform float relu_dot_ispc(uniform float a[], uniform float b[],
                                   uniform int n) {
    varying float acc = 0.0f;
    foreach (i = 0 ... n) {
        varying float p = a[i] * b[i];
        acc += (p > 0.0f) ? p : 0.0f;
    }
    return reduce_add(acc);
}

好很多:uniform/varying 显式,foreach 自动做 strip mining,reduce_add 是语言原语。但这是一门专用语言,你的 Rust crate 生态一个都用不上。

写法四:Rust portable SIMD(VectorWare 方案)

fn relu_dot(a: Simd<f32, 32>, b: Simd<f32, 32>) -> f32 {
    let p = a * b;
    p.simd_gt(Simd::splat(0.0)).select(p, Simd::splat(0.0)).reduce_sum()
}
  • safe(无 unsafe
  • uniform/varying 从类型可见(f32 vs Simd<f32,32>
  • 宽度是泛型参数,不是魔数
  • 同一份代码在 x86、ARM、GPU 上都能跑
  • 借用检查器、生命周期全部有效
  • 可以放进普通 crate,被 cargo test 在 CPU 上测试

差距不是一点点。

5.3 lane 宽度不匹配:strip mining 实战

现实中你的数据长度不会正好是 32 的倍数。这是 VectorWare 自己承认的唯一一个抽象与硬件不对齐的地方

"On the CPU a Simd<T, N> allows any N from 1 through 64, but GPU hardware has a fixed width: 32 lanes on NVIDIA and 32 or 64 on AMD. The mapping is one to one only when N matches that width."

三种情况:

关系后果
N == 32完美 1:1,零开销
N < 32部分 lane 闲置,浪费吞吐
N > 32每个操作展开成多条指令(strip mining)

基础版:chunks_exact + 标量尾巴

#![feature(portable_simd)]
use core::simd::{Simd, num::SimdFloat};

const W: usize = 32;

pub fn dot_v1(a: &[f32], b: &[f32]) -> f32 {
    assert_eq!(a.len(), b.len());

    let mut acc = Simd::<f32, W>::splat(0.0);
    let mut ia = a.chunks_exact(W);
    let mut ib = b.chunks_exact(W);

    for (ca, cb) in (&mut ia).zip(&mut ib) {
        let va = Simd::<f32, W>::from_slice(ca);
        let vb = Simd::<f32, W>::from_slice(cb);
        acc += va * vb;      // 后端通常合成 FMA
    }

    // 尾巴:标量处理
    let mut tail = 0.0f32;
    for (x, y) in ia.remainder().iter().zip(ib.remainder()) {
        tail += x * y;
    }

    acc.reduce_sum() + tail
}

这个版本在 CPU 上没问题,在 GPU 上有个隐患(我的分析):那个标量尾巴循环是 uniform 的——warp 里 32 个 lane 会各自完整地跑一遍同样的尾巴循环,算出同样的结果。也就是说,尾巴部分的有效并行度是 1/32

如果你的数组长度是 33,那 1 个元素的尾巴要占掉和 32 个元素主体差不多的时间。

改进版:掩码加载消灭尾巴

#![feature(portable_simd)]
use core::simd::{Simd, Mask, cmp::SimdPartialOrd, num::SimdFloat};

const W: usize = 32;

pub fn dot_v2(a: &[f32], b: &[f32]) -> f32 {
    assert_eq!(a.len(), b.len());
    let n = a.len();

    let mut acc = Simd::<f32, W>::splat(0.0);
    let mut base = 0usize;

    while base < n {
        // 构造本轮的 lane 索引:base, base+1, ..., base+31
        let idx = Simd::<usize, W>::splat(base)
            + Simd::<usize, W>::from_array(core::array::from_fn(|i| i));

        // 越界的 lane 屏蔽掉
        let m: Mask<isize, W> = idx.simd_lt(Simd::splat(n));

        // 掩码加载:越界 lane 取 0.0
        // 注意:nightly 上 API 名在 load_or_default / load_select 之间动过
        let va = Simd::<f32, W>::load_select(&a[base..], m, Simd::splat(0.0));
        let vb = Simd::<f32, W>::load_select(&b[base..], m, Simd::splat(0.0));

        acc += va * vb;
        base += W;
    }

    acc.reduce_sum()
}

关键差异:没有标量尾巴了。最后一轮虽然有部分 lane 被屏蔽,但它仍然是一条 warp 指令,不是 32 次串行标量运算。

注意load_select / load_or_default 这组 API 在 nightly 上名字变过好几次。写这类代码时务必 pin 住 toolchain 版本。

进阶版:显式 strip mining,每 lane 处理多个元素

当你的向量远宽于 warp 时(比如 Simd<f32, 256>),VectorWare 的 IR 会自动做 strip mining。但你也可以手动控制,收益是更好的 ILP(指令级并行):

const W: usize = 32;
const UNROLL: usize = 4;   // 每 lane 处理 4 个元素

pub fn dot_v3(a: &[f32], b: &[f32]) -> f32 {
    assert_eq!(a.len(), b.len());

    // 4 路独立累加器:打断依赖链,让 FMA 流水线填满
    let mut accs = [Simd::<f32, W>::splat(0.0); UNROLL];

    let step = W * UNROLL;   // 每轮吃掉 128 个元素
    let mut base = 0usize;

    while base + step <= a.len() {
        for u in 0..UNROLL {
            let off = base + u * W;
            let va = Simd::<f32, W>::from_slice(&a[off..off + W]);
            let vb = Simd::<f32, W>::from_slice(&b[off..off + W]);
            accs[u] += va * vb;
        }
        base += step;
    }

    // 合并 4 路累加器(此时才产生跨累加器依赖)
    let mut acc = accs[0];
    for u in 1..UNROLL {
        acc += accs[u];
    }

    // 处理剩余(略,可复用 dot_v2 的掩码路径)
    acc.reduce_sum()
}

为什么要 4 路累加器? 因为 acc += va * vb 形成了一条串行依赖链——下一次 FMA 必须等上一次的结果。FMA 的延迟通常是 4 个周期左右(CPU)或者若干周期(GPU),如果只有一条依赖链,流水线大部分时间在等。4 路独立累加器把依赖链拆成 4 条,可以并发。

这个优化在 CPU 和 GPU 上都成立——又一个"成本模型同构"的例子。你在 CPU 上调优 SIMD 的经验,绝大部分可以直接搬到 GPU。这是这套抽象最实用的价值。

5.4 两层并行组合:warp 分工 + lane 分工

这是 VectorWare 说的 "future work" 里提到的方向,但基于已有的 std::threadcore::simd 支持,组合起来应该是这样(我的推断示例):

#![feature(portable_simd)]
use std::thread;
use core::simd::{Simd, num::SimdFloat};

const W: usize = 32;

/// 4 个 warp 各处理一段,每 warp 内 32 lane 做数据并行
pub fn dot_parallel(a: &'static [f32], b: &'static [f32]) -> f32 {
    const NWARPS: usize = 4;
    let n = a.len();
    let seg = n / NWARPS;

    let handles: Vec<_> = (0..NWARPS)
        .map(|w| {
            let lo = w * seg;
            let hi = if w == NWARPS - 1 { n } else { lo + seg };
            // 每个 spawn 唤醒一个 warp
            thread::spawn(move || {
                let mut acc = Simd::<f32, W>::splat(0.0);
                let mut i = lo;
                while i + W <= hi {
                    let va = Simd::<f32, W>::from_slice(&a[i..i + W]);
                    let vb = Simd::<f32, W>::from_slice(&b[i..i + W]);
                    acc += va * vb;
                    i += W;
                }
                let mut t = 0.0f32;
                while i < hi { t += a[i] * b[i]; i += 1; }
                acc.reduce_sum() + t
            })
        })
        .collect();

    handles.into_iter().map(|h| h.join().unwrap()).sum()
}

这段代码的美妙之处在于:它就是普通的 Rust 多线程 + SIMD 代码。 你在 CPU 上写过一模一样的东西。区别只是编译目标不同:

  • CPU 上:4 个 OS 线程,每线程用 AVX-512(16 lane,Simd<f32,32> 会被拆成两条指令)
  • GPU 上:4 个 warp,每 warp 32 lane,Simd<f32,32> 正好一条指令

同一份源码,cargo test 在 CPU 上跑正确性,cargo build --target nvptx64-* 出 GPU kernel。

这就是"GPU as a normal Rust platform"的实际含义。

5.5 一个可以今天就实践的心智训练

VectorWare 的工具链还没开源,你现在跑不了 GPU 版。但你可以今天就开始用 core::simd 写 CPU 代码,并且按 GPU 的约束来写

rustup toolchain install nightly
cargo +nightly build

自我约束清单(我总结的):

  1. 宽度只用 32(NVIDIA warp 宽度)。CPU 上会被拆成多条指令,但语义一致。
  2. 避免标量尾巴,全部用掩码加载。
  3. 避免任意 permute,只用 shfl 硬件支持的模式(xor/up/down/idx)。
  4. 横向算子(reduce_*anyall)尽量往外提,别放内层循环。
  5. Simd<T,N> 别嵌套进复杂结构体,保持它是一个平坦的值。
  6. 别依赖浮点归约的精确顺序,测试用相对误差。

这样写出来的代码,等 GPU 后端可用了,理论上直接能跑。这个"提前适配"的成本几乎为零,因为这些约束在 CPU 上本来也是好实践。


六、性能优化:零成本抽象的边界在哪

VectorWare 说 "zero cost over hand-written PTX"。这句话有前提,我们把前提逐条拆开。

6.1 宽度对齐是第一原则

Simd<f32, 32>   // ✅ 1 lane = 1 warp lane,完美
Simd<f32, 8>    // ⚠️ 24 个 lane 闲置,75% 吞吐浪费
Simd<f32, 64>   // ⚠️ 每个操作展开成 2 条指令
Simd<f32, 128>  // ⚠️ 每个操作展开成 4 条指令

N < 32 的情况最糟——你付了完整 warp 的调度成本,只用了一部分通道。

N > 32 反而没那么糟:strip mining 出来的多条指令是独立的,能填满流水线。某种意义上 Simd<f32, 128> 相当于自动做了 4 路展开,5.3 节的 dot_v3 手动做的事情,编译器帮你做了。

实践建议:把 warp 宽度做成 const generic 参数,别写死:

pub fn dot<const W: usize>(a: &[f32], b: &[f32]) -> f32
where
    core::simd::LaneCount<W>: core::simd::SupportedLaneCount,
{
    // ...
}

// 用的时候:
#[cfg(target_arch = "nvptx64")]
const NATIVE_W: usize = 32;
#[cfg(target_arch = "x86_64")]
const NATIVE_W: usize = 16;   // AVX-512
#[cfg(target_arch = "aarch64")]
const NATIVE_W: usize = 4;    // NEON

AMD 更麻烦:wavefront 可能是 32 也可能是 64(RDNA 是 32,CDNA 是 64)。这个只能运行时或者编译期 feature 区分。

6.2 横向算子是同步点

VectorWare 明确警告:

"Horizontal operations like reductions and all/any also act as synchronization points within the warp, which constrains how freely the scheduler can overlap work."

shfl.syncvote.sync 里那个 .sync 不是装饰——它是 Volta 之后引入的显式 warp 同步语义。指令要求 membermask 里的所有 lane 都到达这条指令,才能继续。

这在两个层面产生开销:

  1. 硬件层:warp scheduler 不能自由地把这条指令和后续指令重排、重叠。
  2. 编译器层.sync 是一个内存/执行屏障,会阻止跨越它的优化。

优化手法:横向算子提到循环外。

// ❌ 每轮迭代都归约一次,32 次 sync
let mut total = 0.0f32;
for chunk in data.chunks_exact(32) {
    let v = Simd::<f32,32>::from_slice(chunk);
    total += v.reduce_sum();          // 每轮 5 次 shfl.sync
}

// ✅ 累加在向量域,最后归约一次
let mut acc = Simd::<f32,32>::splat(0.0);
for chunk in data.chunks_exact(32) {
    acc += Simd::<f32,32>::from_slice(chunk);   // 纯 elementwise
}
let total = acc.reduce_sum();         // 总共 5 次 shfl.sync

假设 1000 轮迭代:前者 5000 次 shuffle,后者 5 次。三个数量级的差距。

这个优化在 CPU 上也成立(_mm256_hadd_ps 同样比 _mm256_add_ps 贵),但在 GPU 上因为 .sync 的调度约束,惩罚更重。

6.3 shuffle 模式决定代价

VectorWare 说:

"Shuffles that match the hardware's supported patterns are cheap, but arbitrary permutations may need several instructions or a trip through shared memory."

硬件原生支持的四种模式:

PTX 模式语义CUDA 对应典型用途
shfl.sync.bflylane XOR offset__shfl_xor_sync蝶形归约、FFT
shfl.sync.uplane - delta__shfl_up_sync前缀和(scan)
shfl.sync.downlane + delta__shfl_down_sync归约、滑窗
shfl.sync.idx任意源 lane(广播式__shfl_sync广播、gather

注意 shfl.sync.idx 看起来很万能——它允许每个 lane 指定任意源 lane。这确实是一条指令能完成任意置换

但"一条指令"不等于"零代价":shfl.idx 的源 lane 索引如果是 varying 的(每个 lane 不同),硬件内部的 crossbar 仍然是全连接的,但指令延迟高于 elementwise 算术(典型在 20-30 周期量级,取决于架构,而 add.f32 是个位数周期)。

而且 simd_swizzle! 如果目标是编译期常量置换,编译器有机会选择最优的 shfl 模式;如果是运行时索引,就只能走 shfl.idx

优化建议

// ✅ 编译期常量置换,编译器可选最优模式
let rot = simd_swizzle!(v, [1,2,3,4,5,6,7,0, /* ... */]);

// ⚠️ 运行时索引,只能走 shfl.idx
let g = v.gather_or_default(runtime_indices);

超出 32 lane 的置换(比如 Simd<f32,64> 的跨半区搬运)就得走 shared memory 了,那是几十到上百周期。能不跨就不跨。

6.4 寄存器压力与 occupancy

这是原文没提但我认为最容易翻车的地方。

一个 Simd<f32, 32> 在 GPU 上占用每 lane 1 个 32-bit 寄存器。听起来很少。但如果你写了:

let a: Simd<f32, 32> = ...;
let b: Simd<f32, 32> = ...;
// ... 20 个中间变量
let accs = [Simd::<f32, 32>::splat(0.0); 8];   // 8 个累加器

这就是 30+ 个寄存器。而 NVIDIA GPU 每个 SM 的寄存器文件是有限的(比如 Ampere 是 64K 个 32-bit 寄存器/SM),每线程用得越多,能同时驻留的 warp 越少,occupancy 越低,延迟隐藏能力越差

更危险的是 Simd<f32, 128>:strip mining 之后每 lane 要存 4 个元素,就是 4 个寄存器。你写 8 个这样的累加器,就是 32 个寄存器,只为了累加器。

寄存器溢出(register spilling)到 local memory 是 GPU 性能的头号杀手——local memory 物理上在 device memory 里,延迟几百周期。

诊断方法:

# 编译时看每线程寄存器数
nvcc -Xptxas -v ...
# 或者对 PTX:
ptxas -v -arch=sm_80 kernel.ptx

# 输出类似:
# ptxas info : Used 40 registers, 0 bytes spill stores, 0 bytes spill loads
#                                  ^^^^^^^^^^^^^^^^^^ 这两个必须是 0

Rust 侧目前没有直接的 __launch_bounds__ 等价物。这是 VectorWare 方案的一个现实缺口(我的判断)——高层抽象让你更容易无意中制造寄存器压力,因为你看不到寄存器

6.5 divergence 的新形态

前面说 warp-as-thread 模型"从构造上消除了 divergence"。加入 SIMD 之后,这个结论要打个折扣。

// 这段代码没有 divergence:mask select 是无分支的
let r = m.select(a, b);

// 这段呢?
if m.any() {
    expensive_path();
}

m.any() 返回一个 bool,这个 bool 是 uniform 的(所有 lane 相同,因为 vote.sync.any 的结果全 warp 一致)。所以 if 分支是 uniform 分支——整个 warp 一起走同一条路,没有 divergence

这是好事,但也是陷阱:

// ⚠️ 只要有 1 个 lane 满足条件,整个 warp 都要跑 expensive_path
if m.any() {
    expensive_path();     // 可能只有 1/32 的 lane 真正需要
}

这是经典的"warp 内工作量不均衡"。解法通常是:

// 用 popcount 判断值不值得走慢路径
let active = m.to_bitmask().count_ones();
if active > THRESHOLD {
    expensive_path();      // 够多 lane 需要,走批量路径
} else if active > 0 {
    // 走一个便宜的、可能有冗余计算的路径
    let r = m.select(cheap_approx(), fallback);
}

to_bitmask() 对应 vote.sync.ballotcount_ones() 对应 popc,都是单指令。这个模式在 CUDA 里叫 "ballot + popc 剪枝",是老手的标准手法。

6.6 怎么量:别信"感觉快"

给一套可操作的测量流程。

第一步:看生成的 PTX/SASS。

# Rust → PTX
cargo +nightly rustc --target nvptx64-nvidia-cuda --release -- --emit=asm

# PTX → SASS(看真实指令)
ptxas -arch=sm_80 -o kernel.cubin kernel.ptx
cuobjdump -sass kernel.cubin | less

# 或者
nvdisasm -c kernel.cubin

重点看三件事:

  1. 有没有意外的 ld.local / st.local(寄存器溢出)
  2. shfl.sync 的数量是不是符合预期(每次 reduce_sum 应该是 5 条)
  3. 有没有意外的分支(@!%p bra

第二步:Nsight Compute 采集关键指标。

ncu --set full -o report ./my_gpu_app

# 或者只抓关键指标
ncu --metrics \
  sm__throughput.avg.pct_of_peak_sustained_elapsed,\
  sm__warps_active.avg.pct_of_peak_sustained_active,\
  launch__registers_per_thread,\
  smsp__sass_average_branch_targets_threads_uniform.pct \
  ./my_gpu_app

含义:

指标看什么
sm__throughput...pct_of_peak计算单元利用率,低说明卡在访存或同步
sm__warps_active...pct_of_peakachieved occupancy,低说明寄存器/shared mem 用太多
launch__registers_per_thread每线程寄存器,>64 就要警惕
...branch_targets_threads_uniform.pct分支一致性,100% 表示无 divergence

第三步:CPU/GPU 差分测试。

这是 VectorWare 自己在用的方法(他们有 warp 级参考解释器)。你没有解释器,但你有 CPU:

#[cfg(test)]
mod tests {
    use super::*;

    fn approx_eq(a: f32, b: f32) -> bool {
        let d = (a - b).abs();
        // 浮点归约顺序不同,用相对误差
        d <= 1e-5 * a.abs().max(b.abs()).max(1.0)
    }

    #[test]
    fn dot_matches_scalar() {
        let a: Vec<f32> = (0..1000).map(|i| (i as f32) * 0.001).collect();
        let b: Vec<f32> = (0..1000).map(|i| (i as f32) * -0.002).collect();

        let simd = dot_v2(&a, &b);
        let scalar: f32 = a.iter().zip(&b).map(|(x, y)| x * y).sum();

        assert!(approx_eq(simd, scalar), "simd={simd} scalar={scalar}");
    }

    // 长度覆盖 32 的边界:0, 1, 31, 32, 33, 63, 64, 65
    #[test]
    fn dot_all_lengths() {
        for n in [0usize, 1, 31, 32, 33, 63, 64, 65, 127, 128, 129] {
            let a: Vec<f32> = (0..n).map(|i| i as f32).collect();
            let b: Vec<f32> = (0..n).map(|i| (n - i) as f32).collect();
            let simd = dot_v2(&a, &b);
            let scalar: f32 = a.iter().zip(&b).map(|(x, y)| x * y).sum();
            assert!(approx_eq(simd, scalar), "n={n}");
        }
    }
}

长度边界一定要覆盖 32 的邻域,这是 strip mining 和掩码逻辑最容易出错的地方。


七、十四条踩坑清单

按我的判断,从最可能坑到人的排起:

1. portable_simd 是 nightly feature,API 会变。
VectorWare 自己列在 Downsides 第一条。原文示例里的 use core::simd::Select 就说明 API 在变。生产项目务必 pin toolchainrust-toolchain.toml 写死 nightly 日期。

2. 通道数只能是 2 的幂(1/2/4/8/16/32/64)。
Simd<f32, 24> 编译不过——LaneCount<24>: SupportedLaneCount 不成立。别指望用非 2 的幂来"刚好装下"你的数据。

3. N != 32 时零成本不成立。
N=8 浪费 75% 通道,N=64 每个操作双倍指令。这不是 bug,是物理约束。

4. 浮点归约的结果 CPU/GPU 可能不一致。
蝶形归约 vs 线性累加,浮点加法不结合。差分测试用相对误差,别用 ==。金融/科学计算场景要特别小心。

5. 标量尾巴在 GPU 上是 1/32 并行度。
CPU 上尾巴循环无所谓,GPU 上是 warp 内 32 个 lane 各跑一遍。用掩码加载消灭它。

6. 横向算子(reduce_*/any/all)是同步点。
放进内层循环会被 .sync 语义反复卡住调度。提到循环外。

7. 任意 permute 可能退化到 shared memory。
只有 xor/up/down/idx 四种模式是单指令。跨越 warp 边界的置换代价高一个量级。

8. 寄存器压力看不见但会杀死 occupancy。
高层抽象让你意识不到自己开了多少寄存器。定期 ptxas -v 检查 spill。

9. m.any() 的分支是 uniform 的,但工作量可能极度不均衡。
1 个 lane 触发,32 个 lane 陪跑。用 ballot + popc 做阈值剪枝。

10. AMD 的 wavefront 可能是 32 也可能是 64。
RDNA 是 32,CDNA 是 64。硬编码 32 的代码在 CDNA 上会浪费一半通道。把宽度做成 const generic。

11. VectorWare 的工具链目前只针对 NVIDIA + Linux 主机。
原文说 IR 本身是架构无关的、AMD wavefront 和 Vulkan subgroup 语义类似,但**"targets NVIDIA today"**。别按可用产品规划。

12. 编译器被改过,soundness 未完全验证。
VectorWare 自己的原话:"We had to change the compiler to make the abstraction sound when interacting with other Rust features. As this is uncharted territory, we are not yet confident we have covered every case." 这是很诚实的表态,但也意味着现在不适合上生产。

13. std 相关调用(println!、文件 IO)走 hostcall,微秒级延迟。
纯 SIMD 计算不碰 hostcall,但你一旦在内层循环里 println! 调试,性能数据就全废了。

14. 这套东西还没开源。
std、threads、SIMD 三篇文章都说"preparing to open source / upstream"。截至发文,你还拿不到代码。这是一篇技术预告,不是一个可安装的包。


八、横向对比:它在 GPU 编程谱系里的位置

把当前主流的 GPU 编程路径摆在一起看:

方案抽象层级安全性生态复用uniform/varying现状
CUDA C/C++低(kernel = 函数)C++ 生态(device 子集)编译器推断事实标准
ISPC中(SPMD)无(专用语言)语言关键字小众但成熟
Triton高(block 级张量)Python 动态Python隐式(tile 抽象)AI 领域主流
Mojo高(含 SIMD 类型)部分新生态类型区分新语言
SYCL/oneAPI中(C++ 单源)C++编译器推断跨厂商标准
rust-cuda / rust-gpu低(no_std + intrinsic)unsafe 边界no_std crate开源可用
VectorWare高(std + thread + simd)完整借用检查完整 Rust 生态类型自然区分未开源

我的判断:

Triton 和 VectorWare 不是竞品,是不同象限的东西。 Triton 的抽象单位是"tile"(一块张量),它假设你写的是矩阵/张量运算,然后帮你做 tiling、调度、内存搬运。这对 AI kernel 是完美的,对"我要在 GPU 上跑一个复杂的图算法/解析器/状态机"是不适用的。

VectorWare 的抽象单位是"Rust 的执行模型"。它不假设你的工作负载形状。这意味着它能覆盖 Triton 覆盖不了的场景——GPU 上的通用计算,而不只是数值内核。

ISPC 是它最直接的思想前辈。 uniform/varying 的区分、strip mining、foreach 循环,这些概念 ISPC 二十年前就有了。VectorWare 的贡献是把这套模型嫁接到一门有完整生态的通用语言上,而不是再造一门 DSL。

和 Mojo 比。 Mojo 也有 SIMD[DType.float32, 32] 这样的类型,也在做 CPU/GPU 统一。区别是 Mojo 是新语言、新生态;VectorWare 是把已有的 Rust 生态搬过去。这是"从零建城"和"给现有城市修地铁"的区别。谁赢取决于生态成熟速度。


九、总结与展望

这件事的真正意义

我认为可以概括成三句话:

第一,它证明了 SIMT 和 SIMD 是同一件事。
这个认知不是新的(学术界早就这么说),但把它做成一个能编译能跑的工程产物,让 Simd<f32,32> 在 x86 上是 AVX、在 GPU 上是 warp——这是第一次。

第二,它证明了 GPU 可以是一个"普通 target"。
过去二十年 GPU 编程的默认假设是"你必须换一套模型"。VectorWare 用四个里程碑(std / async / thread / simd)证明了:不换模型也能干,只要你愿意在编译器和运行时层面做足工作。

第三,它把 uniform/varying 这个编译器难题降级成了类型问题。
f32 是 uniform,Simd<f32,32> 是 varying。不需要新关键字,不需要注解,不需要数据流分析。这是"选对抽象"的教科书案例。

VectorWare 说的 future work

原文列了三个方向:

  1. 三层组合:thread(跨 warp)+ simd(warp 内 lane)+ async(warp 间并发)三者组合使用。
  2. Tensor core 映射:把"矩阵形状的 SIMD"下降到 tensor core(mma.sync / wgmma)。这个我特别期待——如果 Simd 能扩展出一个矩阵形状的兄弟类型(比如 SimdMatrix<f16, 16, 16>),直接映射到 mma.sync.aligned.m16n8k16,那就把 AI kernel 的最后一块拼图补上了。
  3. 自动向量化:把普通标量 Rust 循环自动向量化成 Simd 操作,让你连 core::simd 都不用写。

第三点是终局形态。VectorWare 团队里有 Rust 编译器团队成员,他们明确说了想在编译器里做这件事。

他们自己也承认的开放问题

原文最后有一段挺关键的自我批评:

"A vector representation shared across the CPU and the GPU is valuable, though it is not clear that today's portable SIMD types are the right basis for one. For one thing, they largely sit in a world of their own within the core and std APIs."

翻译:core::simd 现在是个孤岛。 它和 Iterator、和 slice 的 API、和整个 std 生态是割裂的。你没法 .iter().simd_map(...),没法让 Vec<f32> 自然地产出 Simd 块。

这个问题比"nightly 不稳定"更根本。如果 core::simd 不能和 Rust 的核心抽象(迭代器、切片、trait)深度整合,那"用 portable SIMD 写 GPU"就永远是一个小众技巧,而不是默认路径。

给不同角色的行动建议

如果你是做 AI 推理/训练的:
短期继续用 Triton/CUDA,这套东西还没开源。但值得关注 tensor core 映射的进展——如果 Simd 系列能覆盖 mma,那"用安全 Rust 写高性能 kernel"就从口号变成现实了。

如果你是做数值计算/科学计算的:
今天就可以开始用 core::simd 写 CPU 代码,并按 5.5 节的约束来写。这个投入几乎零风险(这些约束在 CPU 上本来也是好实践),未来可能白捡一个 GPU 后端。

如果你是做通用后端/系统的:
这件事目前和你关系不大,但值得记住一个判断:CPU 和 GPU 正在架构上收敛(AMD APU、Apple M 系列统一内存、NPU/TPU 集成进 CPU)。VectorWare 在 std 那篇文章里也是这么论证的。五年后"异构计算"可能不再是个专门技能,而是编译器和标准库的默认能力。提前建立"warp = 线程,lane = SIMD 通道"的心智模型,成本很低。

如果你是编译器/语言方向的:
这篇东西值得精读三遍。特别是"类型化 warp IR"那部分——用 const generic + trait bound 把执行形状编码进类型,让非法程序无法构造,这个手法可以迁移到很多领域(分布式一致性协议、状态机、协议解析器)。

最后

我最喜欢 VectorWare 这系列文章的一点是:他们每一步都在减少概念,而不是增加概念。

  • 要在 GPU 上用 std?不发明 GPU 专用 API,做 hostcall 把 syscall 转发过去。
  • 要在 GPU 上用线程?不发明 gpu::spawn,把 std::thread 映射到 warp。
  • 要在 GPU 上用 SIMD?不发明 GpuVec<f32>,把 core::simd 映射到 lane。

在一个人人都在造新 DSL、新框架、新抽象的时代,有人在做减法,而且做成了。

这才是真正的工程审美。


参考来源

  • VectorWare, Rust SIMD on the GPU, 2026-08-10
  • VectorWare, Rust threads on the GPU, 2026-03-24
  • VectorWare, Rust's standard library on the GPU, 2026-01-20
  • NVIDIA, Parallel Thread Execution ISAshfl.sync / vote.sync / redux.sync / nanosleep 语义)
  • Rust, core::simd 文档与 portable_simd tracking issue
  • ISPC 文档(uniform/varying 模型)

免责说明:文中标注"我的分析/推断/示例"的部分为作者基于公开信息的技术推演,非 VectorWare 官方表述。类型化 Warp IR 的代码为根据文字描述重构的示意实现,非官方源码。PTX 指令对应关系基于 NVIDIA 公开 ISA 文档,具体编译产物以实际 ptxas 输出为准。

推荐文章

服务器购买推荐
2024-11-18 23:48:02 +0800 CST
防止 macOS 生成 .DS_Store 文件
2024-11-19 07:39:27 +0800 CST
PyMySQL - Python中非常有用的库
2024-11-18 14:43:28 +0800 CST
如何在 Vue 3 中使用 TypeScript?
2024-11-18 22:30:18 +0800 CST
程序员茄子在线接单