【问题标题】:"Safe" SIMD arithmetic on aligned vectors of odd size?奇数大小对齐向量的“安全”SIMD算法?
【发布时间】:2020-02-05 10:34:45
【问题描述】:

假设我有一些 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 对元素上使其静音,要么放一些合理的值。最好、最快的方法是什么?或者也许有更好的方法?

【问题讨论】:

    标签: rust floating-point sse simd floating-point-exceptions


    【解决方案1】:

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

    如果您可以假设除数在该元素中是非 NaN,那么您可能只需要改组除数即可。

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


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

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

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


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

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


    避免在垃圾元素中出现subnormals

    如果 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 != 几何向量。

    只使用 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。

    【讨论】:

    • 我不想自己启用 FP 异常,但我希望我的代码(这是一个库)在编译器或用户启用时能够正常运行。如果通常没有人揭露 FP 异常,我可以使用条件编译让用户启用/禁用它们。只是想确定一个默认情况下不能正确处理它们的代码通常是可以接受的。
    • @L117:是的,很少有人会对任何库函数的实现有严格的 FP 异常行为的期望。没有人知道你在内部做什么。鉴于性能和异常语义之间的选择,大多数人更喜欢性能。捕获 SIGFPE 后,除了记录它之外,您几乎无能为力,所以我认为很少关心它。 GCC 的异常语义保留选项从未起作用,所以它告诉你一些事情!但是你确实要小心部分向量以避免次正规;在我的答案中添加了另一个部分。
    • 我很清楚几何向量和SIMD向量的区别。但是你是什么意思使用 SIMD 向量来保存单个几何向量是不好的?是关于使用 256 位宽寄存器的 8 个可用“单元”中的 3 个吗?它肯定不如使用 8 of 8 有效,但仍比使用标量运算快得多。
    • 我的代码实际上将任意大小的向量切成块,然后执行操作。例如。如果我每个有两个向量 f32x37,它们将变成五个块:[f32x8, f32x8, f32x8, f32x8, f32x5] 完全填充 SIMD 寄存器(除了最后一个,f32x5。这就是我的问题所在。)。
    • @L117:啊,是的,使用部分向量作为清理奇数长度的一部分是完全可以的。我保留了该部分,以使未来的读者受益,他们会因错误的原因发现此问题并且正在犯了我警告过的错误。填充很棒,但是当它不是一个选项时,另一个清理技巧是做一个未对齐的最终向量,该向量在数据的末尾结束。最容易用于纯垂直非就地的东西,例如复制和分割,以便您可以安全地重新加载和重做重叠中的一些元素。例如memcpy 可以以可能重叠的未对齐加载+存储结束。
    【解决方案2】:

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

    因此,即使您的 struct 仅使用 12 个字节,它的 sizeof 仍然是 16 个字节,并带有 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() }
    }
    

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

    【讨论】:

    • 使用0.0 填充有时可以免费完成,例如如果将从内存中加载的标量混在一起,则 XMM regs 的上部元素将被称为零。 0.0/0.0divps 没有性能损失,但会引发异常。但是由于sub 可以从1.0 填充创建0.0,如果a / (b-c) 以1.0 填充开始,它们就会这样做。
    • 仍然同意拥有填充。但是仅使用 SIMD 向量的 4 个元素中的 3 个通常是一个坏主意,因为这通常意味着您使用单个 SIMD 向量来保存单个几何向量,而不是 4 个 x 坐标的三个向量,4 @987654337 @ 坐标和 4 z 坐标。洗牌/水平的东西很烂。我的意思是,在某些情况下,您无法对一系列事物进行矢量化处理,但仔细使用 SIMD,您仍然可以获得加速。
    • 这是有道理的。但是我的代码大量使用了实验性的、不完整的、夜间功能(const_generics、specialization)。正因为如此,拥有第 4 个元素并不容易。好吧,实际上它甚至不总是第 4 个元素,向量结构定义为 rust pub struct Vector<T, L, const SIZE: usize> where L: ReprWrapper<Wrapped = [T; { SIZE }]>, { repr: L, }
    猜你喜欢
    • 1970-01-01
    • 2020-02-15
    • 1970-01-01
    • 2011-01-03
    • 2018-09-07
    • 2016-01-27
    • 1970-01-01
    • 2021-04-04
    • 1970-01-01
    相关资源
    最近更新 更多