SIMD 内部函数和持久性 Variables/State

SIMD Intrinsics and Persistent Variables/State

我希望这不会变成一个非常愚蠢的问题,我以后会感到尴尬,但我一直对 SIMD 内在函数感到困惑,以至于我发现合理化汇编代码比内在函数。

所以我的主要问题是关于使用像 __m256 这样的 SIMD 内部数据类型。直奔主题,我的问题是关于做这样的事情:

class PersistentObject
{
     ...
private:
     std::vector<__m256, AlignedAlloc<__m256, 32>> data;
};

在生成最高效的代码时,这是否可以接受,是否会阻碍编译器?这就是现在让我困惑的部分。我处于缺乏经验的水平,当我有一个热点并且用尽了所有其他直接选项时,我给 SIMD 内在函数一个机会,并且如果它们不能提高性能,我总是希望取消我的更改(我已经退出了这么多与 SIMD 相关的更改)。

但是我对持久存储 SIMD 内部类型的这个问题和困惑也让我意识到我并不真正理解这些内部函数在基本编译器级别上是如何工作的。我想把 __m256 想成一个抽象的 YMM 寄存器(还不一定分配)。当我看到加载和存储说明时,我开始点击它。我认为它们是编译器执行其寄存器分配的提示。

而且我不需要比以前考虑更多,因为我总是临时使用 SIMD 类型:_mm256_load_ps__m256,做一些操作,存储结果回到 32 位 SPFP 256 位对齐数组 float[8]。我把 __m256 想成了一个 YMM 寄存器。

摘要 YMM 注册?

但最近我正在实现一个数据结构,它试图围绕 SIMD 处理(一个简单的代表 SoA 风格的一堆向量),如果我主要使用 __m256 无需不断地从浮点数数组中加载并将结果存储回来。在一些快速测试中,MSVC 至少似乎发出了将我的内在函数映射到程序集的适当指令(以及当我从向量中访问数据时正确对齐的加载和存储)。但这打破了我将 __m256 视为抽象 YMM 寄存器的概念模型,因为持久存储这些东西意味着更像是一个常规变量,但在这一点上 loads/movs 是怎么回事和商店?

所以我在脑海中建立的关于如何思考所有这些东西的概念模型有点绊倒了,我希望也许有经验的人可以立即认识到我的思维方式有什么问题关于这些东西并给我那个调试我大脑的灵光一现的答案。我希望这个问题不会太愚蠢(我有一种不安的感觉,但我试图在别处找到答案,结果仍然感到困惑)。所以最终,直接持久存储这些数据类型是否可以接受(这意味着我们会在内存已经从 YMM 寄存器溢出后的某个时刻重新加载内存而不使用 _mm_load*),如果是这样,有什么问题与我的概念模型?

如果这是一个愚蠢的问题,我们深表歉意!我真的被这些东西弄湿了。

更多细节

非常感谢到目前为止提供的有用评论!我想我应该分享更多细节,让我的问题不那么模糊。基本上我正在尝试创建一个数据结构,它只不过是一个以 SoA 形式存储的向量集合:

xxxxxxxx....
yyyyyyyy....
zzzzzzzz....

... 主要用于关键循环具有顺序访问模式的热点。但与此同时,非关键执行路径可能想要随机访问 AoS 形式的第 5 个 3 向量 (x/y/z),此时我们不可避免地要进行标量访问(如果不是这样就很好如此高效,因为它们不是关键路径)。

在这种特殊情况下,我发现从实现的角度来看,持久存储和使用 __m256 而不是 float* 更方便。它会阻止我用 _mm_loads*_mm_stores* 散布大量垂直循环代码,因为这种情况下的常见情况(在关键执行和大部分代码方面)都是用 SIMD 内在函数实现的.但是我不确定这是否是一种合理的做法,而不是仅保留 __m256 用于某些函数的局部临时数据,将一些浮点数加载到 __m256,进行一些操作,然后像我过去通常做的那样存储结果。这会更方便一些,但我有点担心这种方便的实现类型可能会扼杀一些优化器(尽管我还没有发现这种情况)。如果它们没有让优化器出错,那么我一直以来对这些数据类型的思考方式就有点偏离了。

所以在这种情况下,如果做这些事情非常好并且我们的优化器一直出色地处理这件事,那么我很困惑,因为我思考这些事情的方式并认为我们需要那些明确的_mm_load_mm_store 在短暂的上下文中(局部于函数,即)来帮助我们的优化器是错误的!这让我有点不安,因为它工作得很好,因为我认为它不应该工作得很好! :-D

答案

Mysticial 的几条评论对我来说真的很中肯,帮助我修复了大脑,并让我确信我想做的事情是正确的。它是以评论而不是答案的形式给出的,所以我会在这里引用它,以防有人碰巧遇到与我类似的困惑。

If it helps, I have about 200k LOC written exactly like this. IOW, I treat the SIMD type as a first-class citizen. It's fine. The compiler handles them no differently than any other primitive type. So there are no issues with it.

The optimizers aren't that flimsy. They do maintain correctness within reasonable interpretations of the C/C++ standards. The load/store intrinsics aren't really needed unless you need the special ones (unaligned, non-temporal, masked, etc...)

即便如此,也请随时写下您自己的答案。更多信息越多越好!我真的希望提高对如何更有信心地编写 SIMD 代码的基本理解,因为我正处于对所有事情都犹豫不决并且仍然经常猜测自己的阶段。

反射回来

再次感谢大家!我现在对设计围绕 SIMD 构建的代码更加清晰和自信。出于某种原因,我非常怀疑仅针对 SIMD 内在函数的优化器,认为我必须以尽可能低级别的方式编写代码,并在有限的函数范围内尽可能将这些加载和存储本地化。我认为我的一些迷信源于大约几十年前最初针对旧编译器编写 SIMD 内在函数,也许那时优化器可能需要更多帮助,或者我一直都在非理性地迷信。我看它有点像人们在 80 年代看 C 编译器的方式,到处放 register 提示之类的东西。

使用 SIMD 时,我总是得到非常复杂的结果,并且有一种倾向,尽管偶尔偶尔会用到它,但我总是觉得自己像个初学者,也许只是因为混合的成功有让我不愿意使用它,这大大延迟了我的学习过程。最近我正在努力纠正这个问题,非常感谢所有的帮助!

是的,__m256作为常规类型工作;它不必是仅注册的。您可以制作 __m256 的数组,通过引用将它们传递给非内联函数,以及其他任何东西。

主要警告是它是一个 "over-aligned" 类型:编译器假定内存中的 __m256 是 32 字节对齐的,但 std::max_align_t 通常只有 8 或 16 字节与主流 C++ 实现保持一致。因此,您需要用于 std::vector 或其他动态分配的自定义分配器,因为 std::vector<__m256> 将分配未充分对齐以存储 __m256 的内存。谢谢,C++(尽管 C++17 显然最终会解决这个问题)。


But that breaks my conceptual model of thinking of __m256 as an abstract YMM register, because storing these things persistently implies something more like a regular variable, but at that point what's up with the loads/movs and stores?

__m128 _mm_loadu_ps(float*) / _mm_load_ps 内在函数的存在主要是为了将对齐信息传递给编译器,以及(对于 FP 内在函数)进行类型转换。对于整数你他们甚至不这样做,你必须将指针指向 __m128i*.

(AVX512 内在函数最终使用 void* 而不是 __m512i*。)

_mm256_load_ps(fp) 基本上等同于 *(__m256*)fp:对齐加载 8 个浮点数。 __m256* 允许别名其他类型,但(据我所知)反过来是 not true:不能保证获得 [=26 的第三个元素是安全的=] 代码如 ((float*)my_vec)[3]。那将是严格的别名违规。尽管它在大多数编译器上至少大部分时间在实践中确实有效。

(参见 Get member of __m128 by index?, and also print a __m128i variable for a portable way: storing to a tmp array often optimizes away. But if you want a horizontal sum or something, it's usually best to use vector shuffle and add intrinsics,而不是希望编译器自动向量化存储 + 标量加循环。)


也许在过去的某个时刻,当内在函数是新的时,每次您的 C 源代码包含 _mm_load_ps 时,您确实确实得到了 movaps 负载,但在这一点上它与* float* 运算符;编译器可以并且将会优化掉相同数据的冗余加载,或者优化向量存储/标量重新加载到随机播放中。


But at the same time the non-critical execution paths might want to randomly access a 5th 3-vector in AoS form (x/y/z), at which point we're inevitably doing scalar access.

这里最大的警告是从 __m256 对象中获取标量的代码会很丑陋,并且可能无法高效编译。您可以使用包装函数隐藏丑陋之处,但效率问题可能不会轻易消失,具体取决于您的编译器。

如果您编写不使用 gcc 风格 my_vec[3] 或 MSVC my_vec.m256_f32[3] 的可移植代码,将 __m256 存储到像 alignas(32) float tmp [8] 这样的数组可能不会优化离开,你可能会加载到 YMM 寄存器和存储中。 (然后是 vzeroupper)。