了解 SSE 的内在函数如何使用内存

Posted

技术标签:

【中文标题】了解 SSE 的内在函数如何使用内存【英文标题】:Understanding how the instrinsic functions for SSE use memory 【发布时间】:2015-03-19 14:57:46 【问题描述】:

在我问我的问题之前,先简单介绍一下背景信息。

在 C 语言中,当您分配一个变量时,您可以从概念上假设您只是修改了 RAM 中的一小块内存。

int a = rand(); //conceptually, you created and assigned variable A in ram

在汇编语言中,要做同样的事情,你基本上需要将 rand() 的结果存储在寄存器中,以及指向“a”的指针。然后,您将执行存储指令以将寄存器内容放入 ram。

例如,当您使用 C++ 编程时,当您分配和操作值类型对象时,您通常甚至不必考虑它们的地址或它们将如何或何时存储在寄存器中。

使用 SSE 内在函数很奇怪,因为就概念记忆模型而言,它们似乎介于 C 和汇编编码之间。

您可以调用加载/存储函数,它们会返回对象。像 _mm_add 这样的数学运算将返回一个对象,但我不清楚结果是否会实际存储在对象中,除非您调用 _mm_store。

考虑以下示例:

inline void block(float* y, const float* x) const 
// load 4 data elements at a time
__m128 X = _mm_loadu_ps(x);
__m128 Y = _mm_loadu_ps(y);
// do the computations
__m128 result = _mm_add_ps(Y, _mm_mul_ps(X, _mm_set1_ps(a)));
// store the results
_mm_storeu_ps(y, result);

这里有很多临时对象。临时对象实际上不存在吗?是否只是用于以类似 C 的方式调用程序集指令的语法糖?如果你没有在最后执行 store 命令,而是保留了结果,结果会不会不仅仅是语法糖,而且实际上会保存数据?

TL:DR 在使用 SSE 内在函数时我应该如何考虑内存?

【问题讨论】:

【参考方案1】:

__m128 变量可能在寄存器和/或内存中。这与简单的floatint 变量非常相似——编译器将决定哪些变量属于寄存器,哪些必须存储在内存中。一般来说,编译器会尝试将“最热”的变量保存在寄存器中,其余的保存在内存中。它还将分析变量的生命周期,以便一个寄存器可以用于一个块中的多个变量。作为一名程序员,您无需过多担心这一点,但您应该知道您拥有多少个寄存器,即 32 位模式下的 8 个 XMM 寄存器和 64 位模式下的 16 个。将变量使用保持在这些数字以下将有助于尽可能地将所有内容保存在寄存器中。话虽如此,访问 L1 缓存中的操作数的代价并不比访问寄存器操作数大得多,所以如果事实证明很难将所有内容保存在寄存器中,您不应该过于执着于这样做。

脚注:在使用内部函数时,关于 SSE 变量是在寄存器中还是在内存中的模糊性实际上非常有帮助,并且使得编写优化代码比使用原始汇编器更容易 - 编译器完成了跟踪的繁重工作寄存器分配和其他优化,让您专注于使代码正常工作。

【讨论】:

如果我不担心它,那为什么会有加载和存储功能呢?特别是加载,似乎编译器应该自动执行。 好吧,您实际上不必这样做 - 如果您愿意,您可以使用指针或数组语法,但您仍然会在后台生成 _mm_load_xxx/_mm_store_xxx 指令。但是内在函数的要点是,在大多数情况下,它们将 1:1 映射到实际的机器指令,因此在与其他 SSE 内在函数混合时使用 _mm_load_xxx/_mm_store_xxx 会更清晰。它还使您可以明确控制您实际使用的指令(例如对齐与未对齐,以及在 C 中没有等效的各种其他特殊情况)。 我很难将我的头脑围绕在它被优化为像常规 C 一样的指令中,同时你可以控制加载/存储。加载只是优化的提示吗? 并非如此 - 它只是让您更接近机器级别,因为您明确加载和存储数据,而不是让编译器管理它。它使您可以编写比 C 更接近汇编程序的代码,同时仍为您提供编译器的一些省力优势(寄存器分配、指令调度、窥孔优化等)。这确实意味着您在编程时需要跨越 HLL/汇编程序的栅栏——您需要同时像汇编程序程序员和 C 程序员一样思考。 是的,足够接近。您可能会发现使用内部函数编写一些简单的 SIMD 代码很有帮助/有启发性,然后查看使用 gcc -O3 -S ... 生成的代码。【参考方案2】:

向量变量并不特殊。如果编译器在优化循环时(或在对函数的函数调用中编译器无法“看到”以知道它没有触及),它们将溢出到内存并在以后需要时重新加载向量寄存器)。

gcc -O0 实际上确实倾向于在您设置它们时将它们存储到 RAM,而不是将 __m128i 变量仅保存在寄存器中,IIRC。

可以编写所有使用内在函数的代码,而无需使用任何加载或存储内在函数,但是您将受编译器的支配来决定如何以及何时移动数据。 (实际上,在某种程度上,由于编译器擅长优化内在函数,实际上你现在仍然如此,而不仅仅是在你使用负载内在函数的任何地方都直接吐出负载。)

如果不需要该值作为其他内容的输入,编译器会将负载折叠到内存操作数中以供后续指令使用。但是,只有当数据位于已知对齐的地址或使用了对齐的加载内在函数时,这才是安全的。

我目前考虑加载内在函数的方式是作为向编译器传达对齐保证(或缺乏对齐保证)的一种方式。“常规”SSE(非 AVX/非 VEX 编码) ) 如果与未对齐的 128b 内存操作数一起使用,则向量指令的版本会出错。 (即使在支持 AVX、FWIW 的 CPU 上。)例如,请注意,即使 punpckl* 将其内存操作数列为 m128,因此有对齐要求,即使它实际上只读取低 64b。 pmovzx 将其操作数列为m128

无论如何,使用load 而不是loadu 告诉编译器它可以将负载折叠成另一条指令的内存操作数,即使它无法证明它来自对齐的地址。

为 AVX 目标机器编译将允许编译器将甚至未对齐的负载折叠到其他操作中,以利用 uop 微融合。

这在 How to specify alignment with _mm_mul_ps 的 cmets 中出现。

store 内在函数显然有两个目的:

    告诉编译器它应该使用对齐还是未对齐的 asm 指令。 不再需要从__m128d 转换为double *(不适用于整数大小写)。

为了混淆视听,AVX2 引入了诸如_mm256_storeu2_m128i (__m128i* hiaddr, __m128i* loaddr, __m256i a) 之类的东西,它将高/低一半存储到不同的地址。它可能编译为vmovdqu / vextracti128 ..., 1 序列。顺便说一句,我猜他们在制作 vextracti128 时考虑了 AVX512,因为使用 0 作为立即数与 vmovdqu 相同,但速度较慢且编码时间更长。

【讨论】:

以上是关于了解 SSE 的内在函数如何使用内存的主要内容,如果未能解决你的问题,请参考以下文章

如何将此代码重写为 sse 内在函数

如何将参数传递给英特尔 SSE 内在函数中的 const 值?

数组乘法与 sse 内在函数乘法的时序?

SSE 比较内在 - 如何从比较中获得 1 或 0?

在 Visual C++ 中删除 SSE2 内在函数

VC++ 2K8 中 SSE 编码的内在函数与内联 ASM