Rust 中的安全 SIMD,即使内部也安全
摘要
Rust 的 SIMD 抽象现在允许在不使用 unsafe 代码的情况下安全使用,这得益于 Rust 1.87 引入的 CPU 特性令牌,从而实现了简洁且可移植的向量操作。
<p><a href="https://lobste.rs/s/jmhfck/safe_simd_rust_even_on_inside">评论</a></p>
查看缓存全文
缓存时间: 2026/06/20 14:32
# 即使在内部也能实现安全的 Rust SIMD 编程
来源:https://shnatsel.medium.com/safe-simd-in-rust-even-on-the-inside-c6f1ff381828
Sergey "Shnatsel" Davidoff (https://shnatsel.medium.com/?source=post_page---byline--c6f1ff381828---------------------------------------)
*Rust 的 SIMD 抽象 (https://shnatsel.medium.com/the-state-of-simd-in-rust-in-2025-32c263e5f53d)并不像我希望的那样安全——直到现在。*
众所周知,直接使用原始 SIMD 内联函数(intrinsics)非常令人不快。你希望写 `a + b`,而不是下面这种怪物:
```rust
unsafe {
#[cfg(all(any(target_arch = "x86", target_arch = "x86_64"), target_feature = "avx2"))]
_mm256_add_ps(a, b)
#[cfg(all(any(target_arch = "x86", target_arch = "x86_64"), target_feature = "sse", not(target_feature = "avx2")))]
_mm_add_ps(a, b)
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
vaddq_f32(a, b)
}
```
看看它,多么丑陋!而且整个东西还包裹在 `unsafe` 中!这还算是一个简化的例子。它仍然没有处理:
- 其他常见平台:AVX-512、32 位 ARM、WebAssembly
- 没有 SIMD 的平台或像 RISC-V 这样的冷门平台
- 实际将数据(如 `&[f32]`)加载到每个内联函数接受的格式中
- 为正在运行的 CPU 选择最佳实现
幸运的是,Rust 提供了许多 SIMD 抽象 (https://shnatsel.medium.com/the-state-of-simd-in-rust-in-2025-32c263e5f53d),它们为你处理了所有这些,让你只需写 `a + b` 即可。但有一个小问题:它们内部仍然充满了 `unsafe`。这并没有消失,只是被隐藏了。大量 `unsafe` 代码潜伏在表面之下,偶尔会搞砸事情 (https://github.com/servo/pathfinder/issues/588)(比如这里 (https://github.com/wingertge/macerator/issues/31)和这里 (https://github.com/sarah-quinones/pulp/issues/28))。或者说,它们曾经是这样的——直到现在。
## 为什么我们需要 `unsafe`?
在很长一段时间里,你无法绕开将每个内联函数(如 `_mm256_add_ps`)的调用包裹在 `unsafe` 中的做法,因为当 CPU 不支持某个指令时调用它是非法的。所以你*必须*有某种机制来跟踪每个内联函数需要哪些指令,以及你能访问哪些指令,并交叉引用它们来决定调用给定函数是否安全。手工做的话很繁琐,用代码生成器做则很复杂,而且总是容易出错,需要在每个内联函数周围放上 `unsafe`。
这种情况在 Rust 1.87 版本中发生了变化,编译器开始自行跟踪所需的指令集,因此你可以这样写:
```rust
#[target_feature(enable = "avx2")]
fn add_avx2(a: __m256, b: __m256) -> __m256 {
_mm256_add_ps(a, b) // 这是一个 avx2 内联函数
}
```
看,没有 `unsafe` 了!……但还没完。你仍然不能直接用 `a + b` 来写。你能做到的最好程度是:
```rust
unsafe { add_avx2(a, b) }
```
这只是将 `unsafe` 向上移了一层。现在你可以在标注了正确 `#[target_feature]` 的函数内部调用内联函数,但链中仍然必须在某处有一个 `unsafe`。
另一个问题更根本。你不能在类型 `+` 操作的实现上放置 `#[target_feature]`,因为 `+` 必须始终可用。所以通过这种机制,我们仍然无法实现 `a + b`。
## 引理:CPU 特性令牌
要理解最终的解决方案,你首先需要了解 CPU 特性检测是如何工作的。通常,运行时检查 AVX2 这样的 CPU 特性是通过 `is_x86_feature_detected!("avx2")` 完成的。但我们肯定不希望每次将两个数相加时都运行这个检查——那会完全拖垮性能。我们希望*只检查一次*,然后向编译器证明从那时起使用 AVX2 指令是安全的。
我们可以通过一个*不可伪造的令牌*将此证明编码到类型系统中:一个具有私有内部字段的零大小类型。获得这个令牌的*唯一*方式是调用一个执行 CPU 特性检查的函数。如果检查通过,该函数就会给你这个令牌:
```rust
pub struct Avx2(());
fn detect_avx2() -> Option<Avx2> {
if is_x86_feature_detected!("avx2") {
Some(Avx2(()))
} else {
None
}
}
```
由于它是零大小类型,传递这个令牌没有运行时开销。它纯粹作为编译时的证明存在。要点是:只要你拥有一个 `Avx2` 结构体的实例,你就可以确信系统上 AVX2 指令是可用的。
## 关键洞见
编译器不知道,但这个函数可以安全调用:
```rust
#[target_feature(enable = "avx2")]
fn add_avx2(token: Avx2, a: __m256, b: __m256) -> __m256 {
_mm256_add_ps(a, b)
}
```
你*只有*在拥有 Avx2 令牌时才能调用这个函数,而只有系统上 AVX2 指令可用时你才能获得该令牌。如果我们能向编译器解释这是有效的(使用 `unsafe`),那么我们可以只写一次 `unsafe` 并在任何地方重用它。
我们需要的是一个可以安全调用的宏:
```rust
with_avx2!(
fn add_avx2(token: Avx2, a: __m256, b: __m256) -> __m256 {
_mm256_add_ps(a, b)
}
)
```
但在幕后展开成这样:
```rust
fn add_avx2(token: Avx2, a: __m256, b: __m256) -> __m256 {
// 安全性:根据令牌,Avx2 可用,
// 并且我们验证了内部函数不是 `unsafe fn`
unsafe { inner(token, a, b) }
#[target_feature(enable = "avx2")]
fn inner(token: Avx2, a: __m256, b: __m256) -> __m256 {
_mm256_add_ps(a, b)
}
}
```
现在如果你使用一个不在 AVX2 中的内联函数,**编译器会拒绝它!** 我们刚刚成功地为 SIMD 内联函数提供了一个安全的编程接口,而无需任何特制的目标特性跟踪!即使内部仍然有一个 `unsafe` 块,它也被封装在一个健全的 API 中,所以你无法滥用它导致内存安全错误。从这个意义上讲,它就像 `println!`,安全地抽象了不安全的代码。
这样,你只需要审查和审计*这一个宏*,而不是成百上千个特制的 `unsafe` 块。而且我们在实现中*可能*搞砸的地方只有两处:
1. 将令牌映射到错误的 `#[target_feature]`
2. 允许从安全上下文中调用一个 `unsafe fn`
这两种失败模式都很容易检查。
所以现在我们可以不用 `unsafe` 就调用 `add_avx2(token, a, b)`,但这仍然没有让我们达到 `a + b`。那我们要*如何*解决呢?
## 泛型来援救
我们不能用 `#[target_feature]` 注释 `a + b` 的实现,因为它必须可以从任何地方安全调用。而且我们不能向函数传递令牌,因为它只接受 `a` 和 `b`,而不接受 `token`。但即使我们能做到,也会使 API 非常丑陋。我们希望 `a + b` 始终有效,并自动使用最佳的 SIMD 指令,用户永远不用与令牌打交道。
我们可以一次性用泛型解决这两个问题:通过定义一个在可用指令集上泛型的 `f32x8` 类型,我们可以在其上实现加法,既在内部走私一个令牌,*又*为每个 SIMD 指令集创建独立的实现!看起来像这样:
```rust
pub trait Level {}
#[derive(Clone, Copy)]
pub struct Avx2(());
impl Level for Avx2 {}
pub struct f32x8<L: Level> {
// 为简单起见,本例中用数组作为后备。
// 生产代码中,我们使用针对该等级的原生 SIMD 类型。
data: [f32; 8],
// 走私的令牌!
token: L,
}
/// `a + b` 对 Avx2 的实现
impl std::ops::Add for f32x8<Avx2> {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
// (类型转换省略)
// 使用 Avx2 令牌调用我们的安全包装器
let result = add_avx2(self.token, self, rhs);
Self { data: store_m256(result), token: self.token }
}
}
```
然后我们可以轻松地让它适用于任何其他指令集,或者当根本没有 SIMD 时:
```rust
#[derive(Clone, Copy)]
pub struct NoSimd(());
impl Level for NoSimd {}
/// `a + b` 在没有 SIMD 时的实现
impl std::ops::Add for f32x8<NoSimd> {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
let result = std::array::from_fn(|i| self.data[i] + rhs.data[i]);
Self { data: result, token: self.token }
}
}
```
我们一次性解决了安全*和*运行时指令选择的问题!再添加一个便利函数,它给出系统上最好的 `Level`,你就得到了几乎完美的 SIMD API!
## ABI 有话要说
不幸的是,在编写 `a + b` 并将其降级为 SIMD 指令方面存在一个根本问题:**函数调用开销**。调用一个函数不是免费的,但相当便宜——只是少数几条 CPU 指令。但*少数*指令远比我们刚用于实现加法的*一条*指令要多!所以如果中间有一个函数调用,加法性能就会暴跌。而性能正是使用 SIMD 的全部意义所在!
编译器通常通过内联 (https://matklad.github.io/2021/07/09/inline-in-rust.html) 很好地消除这种开销。它基本上将你调用的函数的实现复制粘贴到调用它的函数中,因此不再有函数,也不再有过开销。但是 `#[target_feature]` 注释会打乱这个机制。编译器不能内联一个具有 `#[target_feature]` 注释的函数到一个没有它的函数中,因为所需的特性在其中不可用!猜猜什么不能有 `#[target_feature]` 注释?是的。
那么*我们*要如何让 `a + b` 与 SIMD 一起工作呢?
## 内联它,用火焰内联它!
我们不能将 `#[target_feature]` 放在实现 `a + b` 的函数上,但*可以*将它放在调用 `a + b` 的函数上!然后我们可以利用内联将 `a + b` 的实现复制到调用它的函数中,这样它最终就处于一个 `#[target_feature]` 的上下文中。
所以调用链看起来像这样:
```rust
#[target_feature(enable = "avx2")]
fn do_stuff() {
// TODO: 一些计算
c = a + b;
// TODO: 更多计算
}
// 它调用...
#[inline(always)] // 函数体将被复制到调用者中
fn add(self, rhs: Self) -> Self::Output {
// 使用 Avx2 令牌调用我们的安全包装器
add_avx2(self.token, self, rhs);
// return 语句省略
}
// 它又调用...
#[inline] // 如果可行,函数体将被复制到调用者中
#[target_feature(enable = "avx2")]
fn add_avx2(token: Avx2, a: __m256, b: __m256) -> __m256 {
_mm256_add_ps(a, b)
}
```
这可行。你可以在正确的地方添加这些注释,并在 SIMD 层级上进行抽象,而且你甚至不需要宏 (https://docs.rs/fearless_simd/latest/fearless_simd/trait.Simd.html#tymethod.vectorize)。问题在于,每当你对 SIMD 类型调用 `a + b` 时,你都必须在一个带有 `#[inline(always)]` 或 `#[target_feature]` 的函数中进行,否则代码仍然可以编译,但性能会暴跌。想亲自试试?打开这个示例 (https://rust.godbolt.org/z/xo1fEfqnT),移除 `#[target_feature]`,看看生成的汇编变成多么恐怖的东西。我不确定对此能做什么。对于任何用 SIMD 实现 `a + b` 的方法,这个限制似乎相当根本。结构体目标特性 RFC (https://shnatsel.medium.com/github.com/rust-lang/rfcs/pull/3525) 解决了 `add_avx2(token, a, b)` 和 `add(token, a, b)` 的问题,但我还没看到实现 `a + b` 的路径。
## 那我们处于什么位置?
尽管所有 SIMD 代码都固有的内联问题,我们仍然设法以一种惊人的低水平 `unsafe` 代码提供了非常令人愉快的 SIMD 抽象,这是前所未有的。你可以找到这些想法的生产版本,就在 `fearless_simd` (https://crates.io/crates/fearless_simd) v0.5 中,**现已可用**,就在你附近的包注册表中!下面是一个小例子 (https://github.com/linebender/fearless_simd/blob/d2411665f0726cd6c09bd6fd98af4af18e3c1778/fearless_simd/examples/sigmoid.rs),看看它们在生产中如何组合在一起。用于实现它的宏也是公开暴露的,所以你可以轻松地混合和匹配 (https://github.com/linebender/fearless_simd/blob/e76b820154dc09f41bca8302670424d8ca079219/fearless_simd/examples/srgb.rs) 高级操作(如 `a + b`)和平台特定的内联函数,以充分利用硬件。
`fearless_simd` 中有不止一个 `unsafe` 块,因为它还提供了 `safe_unaligned_simd` (https://crates.io/crates/safe_unaligned_simd) 包的功能,但这也是以比原始代码**显著更低** (https://github.com/okaneco/safe_unaligned_simd/issues/51) 的 `unsafe` 代码量完成的。
对我来说,使用高级 SIMD 抽象的障碍一直是它们带来的大量 `unsafe`。这很可怕,也很难证明其合理性。但现在 Rust 中的 SIMD 可以真正地无所畏惧了。
## 致谢
我很震惊我是第一个将其投入生产的人(据我所知),因为我肯定不是第一个想到这一点的人。CPU 特性令牌是一个古老而常见的想法。`pulp` (https://crates.io/crates/pulp) 包已经使用它们多年,但它们依赖于手写的围绕内联函数的 `unsafe` 包装器,并且偶尔会把它们弄错 (https://github.com/sarah-quinones/pulp/issues/28)。使用泛型生成多个实现也是一个古老的想法。它是 8 年前最初的 `fearless_simd` 概念 (https://linebender.org/blog/towards-fearless-simd/) 的一部分。更早的 `simdeez` (https://crates.io/crates/simdeez) 包似乎也使用了类似的东西 (https://docs.rs/simdeez/1.0.8/simdeez/)。
将令牌与单个委托给 rustc 的安全包装器结合的关键洞见也不是我独有的。就在 `fearless_simd` 包的上下文中,Raph Levien 已经尝试过它 (https://github.com/linebender/fearless_simd/commit/81b6ab4c8dd6f25064f539c49818c45c2f686815),而 Daniel McNab (https://github.com/DJMcNab) 在几个月前创建了一个比我更精细的实现 (https://github.com/linebender/fearless_simd/pull/108)。Daniel 的方法允许对每个单独的 CPU 特性进行细粒度跟踪,而不是像 `fearless_simd` 那样只使用少数固定的 CPU 特性级别。它更具表现力,但代价是复杂性,而且他的方法从未被合并,因为没有其他维护者站出来审查它。我仍然希望有一天它能作为独立的包发布。
感谢 Daniel 和 Laurenz Stampfl (https://github.com/LaurenzV) 审查我对 `fearless_simd` 的所有 PR,它们很大,快速的审查真的非常感谢!
相似文章
借助 Rust 新 API 实现更快的浮点运算
Rust 1.98 引入了一个新 API,通过允许更激进的编译器优化来加速浮点运算,同时仍让开发者控制舍入误差。
Show HN: SIMD Viterbi Decoder in Rust
A Rust crate implementing Viterbi and Reed-Solomon forward error correction with SIMD acceleration, achieving faster throughput than the C library libfec on supported codecs.
6倍更快的二分查找:从编译代码到机械共鸣
本文详细介绍了Rust中二分查找的一系列底层优化,通过利用分支预测和SIMD等CPU架构特性,实现了6倍的加速,并将其应用于scikit-learn梯度提升用例中。
安全变得简单 第1部分:单一所有权(并非)可选
本文介绍了一种基于线性类型和抽象解释的内存安全新方法,旨在比Rust更符合人机工程学原理地消除诸如释放后使用和内存泄漏等常见错误。
GPU上的无畏并发:在Rust中进行安全的GPU推理,与vLLM/SGLang竞争 [R]
cuTile Rust 引入了一种基于块(tile)的编程模型,利用 Rust 的所有权机制来保证 GPU 内核的内存安全和无数据竞争,基于该模型构建的 Grout 推理引擎在 Qwen3 模型上实现了与 vLLM/SGLang 相当的吞吐量。