在现有浮点数组上使用 SIMD 内在函数的安全高效方法

问题描述 投票:0回答:1

我正在学习 SSE 和 AVX,以进一步提高代码中某些计算的性能。

但是,我遇到了在现有浮点数组上使用 SSE 指令的多种不同方法。我想知道其中哪些是安全的(没有UB)和高效的。我用 <-- arrow 注释标记了跨版本不同的代码行。还给出了 godbolt 示例的链接。

版本 1:- 使用 _mm_load

#include <immintrin.h>
#include <iostream>

int main()
{
    __m128 simd = _mm_set1_ps(10.0f) ;
    alignas(16) float float_arr[4] = {0, 1, 2, 3} ;
    __m128 load_simd = _mm_load_ps(float_arr) ; // <-------
    __m128 sum = _mm_add_ps(simd, load_simd) ;
    alignas(16) float float_arr_sum[4] ;
    _mm_store_ps(float_arr_sum, sum) ;
    std::cout << float_arr_sum[0] << ", " << float_arr_sum[1] << ", " << float_arr_sum[2] << ", " << float_arr_sum[3] << std::endl ;
}

版本 2:- 使用 __m128&(参考)

#include <immintrin.h>
#include <iostream>

int main()
{
    __m128 simd = _mm_set1_ps(10.0f) ;
    alignas(16) float float_arr[4] = {0, 1, 2, 3} ;
    __m128& cast_ref_simd = reinterpret_cast<__m128&>(float_arr[0]) ; // <-----
    __m128 sum = _mm_add_ps(simd, cast_ref_simd) ;
    alignas(16) float float_arr_sum[4] ;
    _mm_store_ps(float_arr_sum, sum) ;
    std::cout << float_arr_sum[0] << ", " << float_arr_sum[1] << ", " << float_arr_sum[2] << ", " << float_arr_sum[3] << std::endl ;
}

版本 3:- 使用 __m128(指针)*

#include <immintrin.h>
#include <iostream>

int main()
{
    __m128 simd = _mm_set1_ps(10.0f) ;
    alignas(16) float float_arr[4] = {0, 1, 2, 3} ;
    __m128* cast_ptr_simd = reinterpret_cast<__m128*>(float_arr) ; // <-------
    __m128 sum = _mm_add_ps(simd, *cast_ptr_simd) ; // <-------
    alignas(16) float float_arr_sum[4] ;
    _mm_store_ps(float_arr_sum, sum) ;
    std::cout << float_arr_sum[0] << ", " << float_arr_sum[1] << ", " << float_arr_sum[2] << ", " << float_arr_sum[3] << std::endl ;
}

我在 Compiler Explorer 中测试了所有这些,发现对于版本 23,在 xmm0 和 xmm1 寄存器之间有一条额外的移动指令(

movaps
,移动对齐打包单精度浮点)。它们还生成相同的汇编输出。 对于包括 1 及以上的优化(
-O1
-O2
-O3
...),所有三个版本都会生成相同的代码。

对于我的项目,我使用

-O3
,所以我想编译的输出不会改变,但如果可能的话,我想有一个正确的理解。

我也在查看一些流行的额外库,例如 Agner Fog 的仅标头 libVc(在某些时候将成为

std::simd
),但我想在使用它们之前学习一些低级经验。

c++ simd sse
1个回答
0
投票

所有 3 个版本都与此用例中的编译器完全相同,您只读取引用/解引用指针一次,而不在其与转换或加载之间进行任何存储。

参见 硬件 SIMD 向量指针和相应类型之间的 `reinterpret_cast` 是未定义的行为吗?(没有 UB,它是安全的。)


所以选择取决于风格和可读性

_mm_load_ps
_mm_loadu_ps
表示未对齐,是标准配置。编译器仍然可以将
_mm_load_ps
折叠到
addps xmm0, [rdi]
或其他任何内容的内存源操作数中。

如果转换为原始

__m128 *p
并使用
*p
解引用,则仅适用于对齐(相当于
_mm_load_ps
而不是
loadu
)。如果您想修改代码以允许未对齐的输入(例如,指向数组中间的指针,即使所有数组都对齐,您也可能需要它),则必须显着更改代码才能使用
 _mm_loadu_ps
。该内在函数需要
float*
,因此您实际上必须投射
_mm_loadu_ps( (float*)p )

有些人喜欢通过整数数组增加

__m128i *ptr
,其中
load[u]
/
store[u]
内在函数采用
__m128i*
而不是
void*
。但大多数代码仍然使用加载/存储内在函数而不是原始解引用,主要只是为了使其更加可见。

使用引用而不是负载似乎是一个糟糕的习惯。通常您希望编译器加载一次,而不是每次读取变量时都重新引用内存,即使在存储到其他位置之后也是如此。如果它想要将引用的

__m128&
值优化到寄存器中,则使用引用将强制它进行别名分析(找出两个数组不能重叠,或者特定存储不能与任何
__m128
引用重叠)在 asm 中只加载一次。 (这里仍然完全相同,因为您只阅读了一次参考文献,没有中间存储。)

__m128
引用不寻常,很容易造成混淆。稍后维护代码的人可能会忘记它是引用而不是加载结果,并在可能重叠的存储之后再次读取它而引入错误。或者,如果编译器再次加载,至少会使代码效率降低,因为它无法证明存储不可能指向引用的浮点数。

我倾向于用这两种方式来写

    __m128 v = _mm_load_ps( ptr );    // when doing pointer increments
// or
    __m128 v = _mm_load_ps( &arr[i] );  // when using integer indices

// then do stuff to the load result, maybe declaring other __m128 temporaries

由于在做一些不平凡的事情时,您确实经常想要声明其他

__m128
临时变量,因此最好让加载结果是另一个像这样的
__m128
,因此多次使用它会提示编译器朝以下方向提示:加载一次到寄存器中。它可能仍然会将多个
*ptr
解引用优化为一个负载,但将源代码编写得尽可能接近我想要的高效汇编似乎是一个好主意,有时会有所帮助。
__m128 &v
引用会更糟糕,隐藏了读取希望在寄存器中的本地变量与重新访问内存之间的差异。

对于琐碎的事情,编译器通常会很好地自动向量化,因此您通常根本不需要内在函数。


查看未优化的 asm 是没有用的,尤其是从内部函数来看,因为

_mm_load_ps
是一个围绕取消引用的实际函数。如果启用优化,它将优化掉,但如果不启用优化,则会有一个额外的返回值对象,这可能会使反优化的调试构建汇编变得更糟。一般来说,禁用优化的内在函数对于 asm 效率来说是一场彻底的灾难。

© www.soinside.com 2019 - 2024. All rights reserved.