"Safe" 奇数大小对齐向量的 SIMD 算法?

"Safe" SIMD arithmetic on aligned vectors of odd size?

假设我有一些 16 字节对齐的结构,它只包装了 3xFloat32 数组:

#[repr(C, align(16))]
pub struct Vector(pub [f32; 3]);

现在我想把它分成两个实例,像这样:

use core::arch::x86_64;

let a = Vector([1f32, 2f32, 3f32]);
let b = Vector([4f32, 5f32, 6f32]);
let mut q = Vector([0f32, 0f32, 0ff32]);

unsafe {
    let a1 = x86_64::_mm_load_ps(a.0.as_ptr());
    let b1 = x86_64::_mm_load_ps(b.0.as_ptr());
    let q1 = x86_64::_mm_div_ps(a1, b1);
    x86_64::_mm_store_ps(q.0.as_mut_ptr(), q1);
}

它确实除法,但有一个问题:第 4 个元素包含垃圾,除其他外,它可能会发出 NaN 信号。如果某些异常标志未被屏蔽,则将触发 SIGFPE。我想以某种方式避免这种情况,而不是完全消除信号。 IE。我要么只想在第 4 对元素上将其静音,要么将一些理智的值放在那里。最好、最快的方法是什么?或者一般来说可能有更好的方法?

通常没有人会揭露 FP 异常,否则您需要洗牌,例如复制其中一个元素,以便顶部元素与其他元素之一进行相同的划分。或者有其他已知安全的东西。

如果您可以假设被除数在那个元素中是非 NaN 的,也许您只需要改组除数就可以逃脱。

使用 AVX512,您可以使用零掩码抑制元素的异常,但在那之前没有这样的功能。此外,AVX512 还允许您覆盖舍入模式 + 抑制所有异常 (SAE) 而无需屏蔽,因此您可以使最接近甚至显式获得 SAE。但这抑制了 all 元素的异常。


说真的,不要启用 FP 异常。如果异常数量是可见的副作用,编译器几乎/不知道如何以安全的方式进行优化。例如GCC 的 -ftrapping-math 默认打开,但它已损坏。

我不认为 LLVM 会更好;默认的严格 FP 可能仍然会进行优化,可以在源会引发 2 或 4 的地方给出一个 SIGFPE。甚至可能在源会引发 1 的地方引发 0 的优化,反之亦然,例如 GCC 的损坏和几乎无用的默认值。

启用 FP 异常可能对调试有用,但是,如果您希望永远不会有任何某种异常。但是您可以通过忽略具有该源地址的指令来处理 SIMD 指令偶尔出现的误报。


如果在性能和异常正确性之间进行权衡,库的大多数用户宁愿它最大化性能。

甚至用 fenv 东西清除然后检查粘性 FP 屏蔽标志也很少有人这样做,并且需要在受控环境下使用。我对库函数调用没有任何期望,尤其是使用任何 SIMD 的库函数调用。


避免垃圾元素中的次正规

如果 MXCSR 没有设置 FTZ 和 DAZ,您可能会因次正规(又名非正规)而减速。 (即正常情况,除非你用(相当于 Rust 的)-ffast-math 编译。)

对于具有 SSE/AVX 指令的典型 x86 硬件,生成 NaN 或 +-Inf 不需要额外的时间。 (有趣的事实:NaN 也很慢,即使在现代硬件上也有 x87 数学的遗留问题)。因此,例如,在数学运算之前,_mm_or_pscmpps 结果是安全的,可以在向量的某些元素中创建 NAN。或者 _mm_and_ps 在除法之前在除数中创建一些零。

但要注意填充中的垃圾,因为它可能导致虚假的次正规。 0.0 和 NaN(所有的)通常总是安全的。


通常避免使用 SIMD 的水平内容。 SIMD vec != 几何 vec.

仅使用 SIMD 向量的 4 个元素中的 3 个通常不是一个好主意,因为这通常意味着您使用单个 SIMD 向量来保存单个几何向量,而不是 4 x 坐标、4 y 坐标和 4 z 坐标的三个向量。

随机播放/水平播放大多需要额外的指令(除了已经在内存中的标量的广播负载),但如果您以这种方式使用 SIMD,您通常需要大量的随机播放。在某些情况下,您无法对一系列事物进行矢量化,但您仍然可以使用 SIMD 获得加速。

如果您只是将此部分向量内容用于奇数操作的剩余元素,那么很好,一个部分向量比 3 次标量迭代要好得多。但是大多数人询问只使用 4 个矢量元素中的 3 个是因为他们错误地使用了 SIMD,例如添加几何向量作为 SIMD 向量仍然很便宜,但点积需要洗牌。有关如何正确使用 SIMD(SoA 与 AoS 等)的一些好东西,请参阅 https://deplinenoise.wordpress.com/2015/03/06/slides-simd-at-insomniac-games-gdc-2015/。如果您已经知道这一点并且只是将 3 元素向量用于奇特的极端情况,而不是用于大部分工作,那很好。

填充到向量宽度的倍数通常适用于奇数大小,但某些算法的另一种选择是在数据末尾结束的最终未对齐向量。部分重叠的存储很好,除非它是就地算法并且您不得不担心不会对一个元素执行两次。 (或者关于存储转发停顿,甚至对于像 AND 屏蔽或钳位这样的幂等操作)。


免费获得零

如果您只剩下 2 float 个元素,movsd 加载将加载 + 零扩展到 XMM 寄存器中。您不妨让编译器执行此操作,而不是 movaps.

否则,如果将 3 个标量洗牌在一起,insertps 可以使元素归零。或者您可能从内存中的 movss 加载中知道 xmm regs 的零高部分。因此,编译器可以免费使用 0.0 作为标量向量初始值设定项(如 C++ _mm_set_ps())的一部分。

使用 AVX,如果您担心填充导致次正规,可以考虑使用屏蔽负载。 https://www.felixcloutier.com/x86/vmaskmov。但这比 vmovaps 慢一些。并且屏蔽 商店 在 AMD 上要贵得多,即使是 Ryzen。

在 Rust 中,就像在 C 中一样,sizeof 始终是 alignof 的倍数:这是必要的,因为 sizeof 用作 步长 在数组中,数组元素需要正确对齐。

因此,即使您只为 struct 使用 12 个字节,它的 sizeof 无论如何也是 16 个字节,"padding".

有 4 个字节

因此,我会提出一个非常务实的解决方案:拥有填充。不要让 struct 的内部可见,而是给它一个构造函数和一个访问器...并用 1.0 值将它填充到 16 个字节。

#[repr(C, align(16))]
pub struct Vector([f32; 4]);

impl Vector {
    pub fn new(v: [f32; 3]) -> Vector {
        Vector([v[0], v[1], v[2], 1.0])
    }

    pub fn pad(&mut self, pad: f32) { self.0[3] = pad; }

    pub fn as_ptr(&self) -> *const f32 { self.0.as_ptr() }
}

那么您就可以放心地执行操作,不会使用垃圾字节。