SIMD指令集和持久变量/状态

7

我希望这不是一个非常愚蠢的问题,让我以后感到尴尬。但是,我一直对SIMD内置函数感到困惑,以至于我发现理解汇编代码比理解内置函数更容易。

所以,我主要的问题是关于使用SIMD内置数据类型,比如__m256。为了简化问题,我的问题是关于像这样做的事情:

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

这是恶心的、可接受的,还是会在编译器生成最有效代码时出现问题?这就是我目前感到困惑的部分。当我遇到热点并耗尽所有其他立即选项时,我会尝试使用SIMD内置函数,并且始终试图撤消更改(我已经撤消了很多与SIMD相关的更改),但我处于一个不熟练的水平,因此对于如何持久地存储SIMD内置类型也存在疑问和困惑。这也让我意识到,我实际上并不理解这些内置函数在基本编译器级别上的工作原理。我的想法是将__m256视为抽象的YMM寄存器(不一定已分配)。当我看到load和store指令时,这个想法开始与我产生共鸣。我认为它们是提示编译器执行其寄存器分配的方法。但之前我不需要考虑得太多,因为我总是以临时方式使用SIMD类型:_mm256_load_ps到__m256,进行一些操作,将结果存回32位SPFP 256位对齐数组float[8]。我可以把__m256看作是一个YMM寄存器。
但最近我正在实现一种数据结构,该结构旨在围绕SIMD处理(一种简单的表示SoA方式中的一堆向量),在这种情况下,如果我可以主要使用__m256而不是不断地从float数组加载并在之后存储结果,则会变得更加方便。在一些快速测试中,至少MSVC似乎会发出适当的指令将我的内置函数映射到汇编(以及在我访问向量外的数据时进行适当对齐的加载和存储)。但这打破了我关于如何考虑所有这些东西的概念模型,因为持久存储这些东西意味着更像一个常规变量,但此时load/movs和store怎么办?
因此,我对我在头脑中构建的有关处理所有这些东西的方式的概念模型感到有些困惑,希望有经验的人能立即识别我思考这些东西的方式存在什么问题,并给我那个可以调试我的大脑的启示性答案。如果直接持久存储这些数据类型(意味着我们在使用_mm_load*之后某个时刻重新加载内存),是否可以接受?如果可以,那么我的概念模型有什么问题?
如果这个问题太愚蠢,请谅解!我对这些东西真的很陌生。

非常感谢迄今为止提供的有益评论!我想我应该分享更多细节,以使我的问题更加清晰。基本上,我正在尝试创建一个数据结构,它只是以SoA形式存储的矢量的集合而已:

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

这段内容主要是关于在hotspots(热点)中,关键循环具有连续访问模式,但同时非关键执行路径可能需要随机访问AoS格式的第5个3向量(x/y/z),此时我们不可避免地进行标量访问(如果效率不高,则完全没有问题,因为它们不是关键路径)。

在这种特殊情况下,从实现的角度来看,我认为使用__m256持久存储和处理比float*更方便。这样做可以避免在此情况下(在关键执行和大部分代码方面都是如此)频繁使用_mm_loads*_mm_stores*的情况,而是使用SIMD内置函数。但我不确定这是否是一种良好的实践,或者仅保留__m256作为短暂临时数据的预留位置,本地到某个函数,将一些浮点数加载到__m256中,执行一些操作,并将结果存回,就像我过去通常所做的那样。使用持久存储会更加方便,但我有点担心这种实现方式可能会出现一些优化器问题(尽管我还没有发现这种情况)。如果它们不会出现问题,那么我一直以来对这些数据类型的思考方式可能有点偏差。

所以,在这种情况下,如果完全没有问题,并且我们的优化器始终能够处理得很好,那么我就感到困惑了,因为我的思考方式一直都是需要在短暂的上下文中(本地函数内部)使用明确的_mm_load_mm_store来帮助优化器,但这种方式是错误的!这让我有点失望,因为我认为它不应该正常运行! :-D

回答

有一些Mysticial的评论真的帮了我很多忙,也使我更加放心我想要做的事情是正确的。他的评论如下:

如果有帮助,我写了大约20万行代码,就像这样。换句话说,我将SIMD类型视为一级公民。没问题。编译器处理它们与任何其他基本类型无异。因此,它们没有任何问题。

优化器不是那么薄弱的。它们保持符合C / C ++标准的合理解释的正确性。除非您需要特殊类型的load / store内置函数(未对齐,非暂态,掩码等...),否则您不真正需要它们。

话虽如此,请随意编写自己的答案。信息越多越好!我真的希望提高对如何更自信地编写SIMD代码的基本理解,因为我现在处于一切都犹豫不决、总是自我怀疑的阶段。

回顾过去

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

使用SIMD,我总是有着非常复杂的结果,并且倾向于始终感觉自己像个初学者,可能仅仅是因为混合的成功使我不愿使用它,这显著延迟了我的学习过程。最近我正在努力改正这一点,非常感谢大家的帮助!


2
如果有帮助的话,__m256(或任何其他SSE/AVX向量类型)的工作方式在概念上与int相同。编译器会尽可能地将其保留在寄存器中,但如果需要,它可以自由地溢出到内存中,这可能是由于寄存器压力或语言规则要求(获取地址、别名等)导致的。 - Paul R
1
我只是在SO上尝试挖掘一些相关的材料,这可能会有所帮助,例如这个答案这个答案 - Paul R
2
在向量化访问和标量访问之间切换本身没有任何问题,但理想情况下,您应该确保在切换之间在每个域中进行大量工作,以分摊从/到内存加载/存储数据的成本。 - Paul R
2
如果有帮助的话,我写了大约20万行代码,就像这样。换句话说,我把SIMD类型视为一等公民。这很好。编译器处理它们与任何其他基本类型没有区别。所以这方面没有问题。 - Mysticial
2
@TeamUpvote 可能是因为该指令本身直接作用于寄存器(最后一个操作数可以来自内存),而且他们也可能不想为每一种输入组合都提供重载版本。由于他们需要兼容 C,如果对所有这些重载版本使用不同的名称,那么就需要不同的名字。现在我们大约有 5000 个内部函数 - 足以对解析头文件产生性能影响。 - Mysticial
显示剩余12条评论
1个回答

5

是的,__m256 就像一个常规类型一样工作;它不必仅限于寄存器。你可以创建 __m256 的数组,并将它们作为引用传递给非内联函数,等等。

主要的注意点是它是一个“过度对齐”的类型:编译器假设在内存中的 __m256 是32字节对齐的,但是std::max_align_t在主流C++实现中通常只有8或16字节的对齐方式。因此,你需要使用自定义分配器来为 std::vector 或其他动态分配的内存提供支持,因为 std::vector<__m256> 会分配不能足够对齐存储 __m256 的内存。感谢 C++(尽管 C++17 显然最终会修复这个问题)。


“但是这打破了我将__m256视为抽象YMM寄存器的概念模型,因为持久存储这些东西意味着更像一个常规变量,但此时加载/移动和存储是什么情况?”
“__m128 _mm_loadu_ps(float*) / _mm_load_ps内置函数主要用于向编译器传达对齐信息,并且(对于FP内置函数)进行类型转换。对于整数,它们甚至不会这样做,您必须将指针转换为__m128i*。”
“(AVX512内置函数最终使用void*而不是__m512i*,尽管如此。)”

_mm256_load_ps(fp)基本上等同于*(__m256*)fp:加载8个浮点数的对齐数据。 __m256*可以别名其他类型,但是(据我所知)反过来不成立:不能保证使用((float*)my_vec)[3]这样的代码安全地获取__m256 my_vec的第三个元素。那将违反严格别名规则。虽然在实践中,它至少在大多数编译器上的大多数时间都能正常工作。

(请参见如何通过索引获取__m128成员?,以及如何打印__m128i变量,这是一种便携式的方法:将其存储到临时数组中通常可以进行优化。但是如果您想要水平求和或其他操作,则通常最好使用向量混洗和加入内置函数,而不是希望编译器自动向量化存储+标量加循环。)


也许在过去,当内在函数还很新时,每当您的C源代码包含_mm_load_ps时,确实会得到一个movaps加载,但是此时它与float*上的*运算符没有太大区别;编译器可以并将优化掉重复加载相同数据,或将矢量存储/标量重新加载优化为洗牌。
但与此同时,非关键执行路径可能需要以AoS形式(x / y / z)随机访问第5个3向量,此时我们不可避免地进行标量访问。
最大的注意点在于从__m256对象中获取标量的代码将会很丑陋,并且可能无法高效编译。您可以使用包装函数隐藏丑陋性,但是效率问题可能不容易消失,这取决于您的编译器。
如果您编写可移植代码,不使用gcc风格的my_vec [3]或MSVC my_vec.m256_f32 [3],将__m256存储到类似于alignas(32)float tmp [8]的数组中可能不会被优化,并且您可能会得到一个YMM寄存器和存储的负载。(然后是vzeroupper)。

1
@MarcGlisse 哦,你能给我更多信息或者指向一些资源吗?(如果这样做有点懒的话,我很抱歉,我确实在谷歌上搜索了C++17中与对齐保证相关的内容,但是没有找到)。这是怎么工作的?现在std::allocator是否使用alignof(T)并进行对齐堆分配?我对最新的编译器有点落后(不幸的是,在我的工作场所必须使用旧版本)。 - user4842163
1
说实话,真正的问题是实现同时定义了 max_align_t__m256,并且两者之间不一致。这不是 C++ 的问题,而只是实现上的缺陷。我可以理解为什么会发生这种情况(编译器团队的不同部分之间没有沟通),但这相当令人恼火。很明显,内嵌函数的开发人员具有汇编背景和一些 C 经验。 - MSalters
1
@PeterCordes 说到命名,转换和移动内在函数真是够麻烦的。 - Mysticial
1
@MSalters max_align_t是ABI的一部分。编译器可以随时添加新类型,例如__m512,只要英特尔想要,但更改max_align_t将会破坏ABI(除了已经提到的不要过度对齐的原因)。同样的原因也阻止__int128成为intmax_t。 - Marc Glisse
2
@TeamUpvote 在 [allocator.members] 中,对于 allocate 函数,它说“适当地对齐 T 类型的对象”。 - Marc Glisse
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接