ObjectiveAVX指令中寄存器和指针的区别

Objective difference between register and pointer in AVX instructions

场景:您正在使用 SIMD 编写一个复杂的算法。使用了一些常量 and/or 不经常变化的值。最终,该算法最终使用了超过 16 个 ymm,导致使用堆栈指针(例如操作码包含 vaddps ymm0,ymm1,ymmword ptr [...] 而不是 vaddps ymm0,ymm1,ymm7)。

为了使算法适合可用的寄存器,常量可以是"inlined"。例如:

const auto pi256{ _mm256_set1_ps(PI) };
for (outer condition)
{
    ...
    const auto radius_squared{ _mm256_mul_ps(radius, radius) };
    ...
    for (inner condition)
    {
        ...
        const auto area{ _mm256_mul_ps(radius_squared, pi256) };
        ...
    }
}

...变成...

for (outer condition)
{
    ...
    for (inner condition)
    {
        ...
        const auto area{ _mm256_mul_ps(_mm256_mul_ps(radius, radius), _mm256_set1_ps(PI)) };
        ...
    }
}

无论所讨论的一次性变量是常数,还是不经常计算(计算外循环),如何确定哪种方法可实现最佳吞吐量?是像"ptr adds 2 extra latency"这样的概念的问题吗?还是它是不确定的,因此它因具体情况而异,只能通过反复试验 + 分析才能完全优化?

一个好的优化编译器应该为两个版本生成相同的机器码。只需将向量常量定义为局部变量,或匿名使用它们以获得最大的可读性;让编译器担心寄存器分配,并在发生这种情况时选择最便宜的方法来处理 运行 超出寄存器的问题。

帮助编译器的最好办法是尽可能少使用不同的常量。例如而不是同时使用 set1_epi16(0x00FF)0xFF00_mm_and_si128,使用 _mm_andn_si128 来掩盖另一种方式。您通常不能做任何事情来影响它选择将哪些内容保留在寄存器中,但幸运的是编译器非常擅长这一点,因为它对于标量代码也是必不可少的。


编译器会将常量提升到循环之外(甚至内联包含常量的辅助函数),或者如果仅在分支的一侧使用,则将设置带到分支的那一侧。

源代码计算完全相同的东西,在可见的副作用上没有区别,因此 as-if 规则允许编译器自由地执行此操作。


我认为编译器通常会在执行 CSE(公共子表达式消除)并识别可以提升的循环不变量和常量之后进行寄存器分配并选择 spill/reload(或只使用只读向量常量) .

当它发现它没有足够的寄存器来将所有变量和常量保存在循环内的 regs 中时, 保存在寄存器中的东西的首选通常是是循环不变向量,可以是编译时常量或在循环之前计算的值。

在 L1d 缓存中命中的额外加载比在循环内存储(也称为溢出)/重新加载变量便宜。因此,无论您将定义放在源代码中的什么位置,编译器都会选择从内存中加载常量。

用 C++ 编写的部分要点是您有一个编译器可以为您做出这个决定。由于允许对两个源执行相同的操作,因此对至少一种情况而言,执行不同的操作将是优化失误。 (在任何特定情况下最好的做法取决于周围的代码,但当编译器在 regs 上运行不足时,通常使用向量常量作为内存源操作数很好。)

Is it a matter of some concept like "ptr adds 2 extra latency"?

内存源操作数的微融合不会延长从非常量输入到输出的关键路径。一旦地址准备好,加载 uop 就可以开始,对于向量常量,它通常是 RIP 相对或 [rsp+constant] 寻址模式。所以通常负载一旦被发送到核心的无序部分就可以立即执行。假设 L1d 缓存命中(因为如果每次循环迭代都加载它会在缓存中保持热),这只有大约 5 个周期,所以如果向量寄存器输入上存在依赖链瓶颈,它很容易及时准备好。

它甚至不会影响前端吞吐量。除非您在加载端口吞吐量方面遇到瓶颈(在现代 x86 CPU 上每个时钟 2 个加载),否则它通常没有什么区别。 (即使使用高度准确的测量技术。)