首页 > 解决方案 > AVX指令中寄存器和指针的客观区别

问题描述

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

为了使算法适合可用的寄存器,可以“内联”常量。例如:

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 增加 2 个额外延迟”这样的概念问题吗?或者它是不确定的,因此它会因具体情况而异,只能通过反复试验+分析来完全优化?

标签: c++performanceavxopcode

解决方案


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

帮助编译器的最佳选择是尽可能少使用不同的常量。例如,不要_mm_and_si128同时使用set1_epi16(0x00FF)and 0xFF00,而是使用_mm_andn_si128另一种方式来掩盖。你通常不能做任何事情来影响它选择在寄存器中保留哪些东西而不是,但幸运的是编译器在这方面做得很好,因为它对于标量代码也是必不可少的。


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

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


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

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

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

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

是像“ptr 增加 2 个额外延迟”这样的概念问题吗?

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

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


推荐阅读