分析 _mm_setzero_ps 和 {0.0f,0.0f,0.0f,0.0f}

Profiling _mm_setzero_ps and {0.0f,0.0f,0.0f,0.0f}

编辑:正如 Cody Gray 在他的评论中指出的那样,禁用优化的分析完全是浪费时间。那么我应该如何应对这个测试?


Microsoft 在其 XMVectorZero 中,如果已定义 _XM_SSE_INTRINSICS_,则使用 _mm_setzero_ps,如果未定义,则使用 {0.0f,0.0f,0.0f,0.0f}。我决定检查胜利有多大。所以我在 Release x86 中使用了以下程序并将 Configuration Properties>C/C++>Optimization>Optimization 设置为 Disabled (/Od).

constexpr __int64 loops = 1e9;
inline void fooSSE() {
    for (__int64 i = 0; i < loops; ++i) {
        XMVECTOR zero1 = _mm_setzero_ps();
        //XMVECTOR zero2 = _mm_setzero_ps();
        //XMVECTOR zero3 = _mm_setzero_ps();
        //XMVECTOR zero4 = _mm_setzero_ps();
    }
}
inline void fooNoIntrinsic() {
    for (__int64 i = 0; i < loops; ++i) {
        XMVECTOR zero1 = { 0.f,0.f,0.f,0.f };
        //XMVECTOR zero2 = { 0.f,0.f,0.f,0.f };
        //XMVECTOR zero3 = { 0.f,0.f,0.f,0.f };
        //XMVECTOR zero4 = { 0.f,0.f,0.f,0.f };
    }
}
int main() {
    fooNoIntrinsic();
    fooSSE();
}

I 运行 程序两次,第一次只有 zero1,第二次所有行都没有注释。在第一种情况下,内在输了,在第二种情况下,内在显然是赢家。所以,我的问题是:

在禁用优化的情况下进行性能分析会给您带来毫无意义的结果,而且完全是在浪费时间。如果您禁用优化,因为否则优化器会注意到您的基准测试实际上没有任何用处并且正在完全删除它,那么欢迎来到微基准测试的困难!

通常很难编造一个测试用例,该用例实际上完成了足够多的实际工作,不会被足够聪明的优化器删除,但该工作的成本不会压倒并使您的结果变得毫无意义。例如,很多人的第一直觉是使用 printf 之类的东西打印出增量结果,但这是行不通的,因为 printf 非常慢,绝对会毁了你的基准测试。将收集中间值的变量设置为 volatile 有时会起作用,因为它有效地禁用了对该特定变量的 load/store 优化。尽管这依赖于定义不明确的语义,但这对于基准测试并不重要。另一种选择是对中间结果执行一些无意义但相对便宜的操作,比如将它们相加。这依赖于优化器不会比您聪明,并且为了验证您的基准测试结果是否有意义,您必须检查编译器发出的目标代码并确保代码确实在 做事。不幸的是,没有制作微基准的灵丹妙药。

best 技巧通常是隔离函数内代码的相关部分,根据一个或多个不可预测的输入值对其进行参数化,将结果安排为 returned,然后将这个函数放在一个外部模块中,这样优化器就无法对其进行处理。

由于无论如何您都需要查看反汇编代码以确认您的微基准案例是否合适,因此这通常是一个不错的起点。如果您有足够的能力阅读汇编语言,并且您已经充分提炼了有问题的代码,这甚至可能足以让您对代码的效率做出判断。如果您不能弄清楚代码的正面或反面,那么它可能足够复杂,您可以继续对其进行基准测试。

这是一个很好的例子,说明粗略检查生成的目标代码就足以回答问题,甚至不需要制定基准。

按照我上面的建议,让我们编写一个简单的函数来测试内部函数。在这种情况下,我们没有任何输入可以参数化,因为代码实际上只是将寄存器设置为 0。所以让我们只 return 函数中的归零结构:

DirectX::XMVECTOR ZeroTest_Intrinsic()
{
    return _mm_setzero_ps();
}

这是另一个以看似天真的方式执行初始化的候选人:

DirectX::XMVECTOR ZeroTest_Naive()
{
    return { 0.0f, 0.0f, 0.0f, 0.0f };
}

这是编译器为这两个函数生成的目标代码(无论是哪个版本,无论你是为 x86-32 还是 x86-64 编译,或者你是否优化大小或速度;结果相同):

ZeroTest_Intrinsic
    xorps  xmm0, xmm0
    ret
ZeroTest_Naive
    xorps  xmm0, xmm0
    ret

(如果支持 AVX 或 AVX2 指令,则它们都是 vxorps xmm0, xmm0, xmm0。)

这很明显,即使对于看不懂汇编代码的人来说也是如此。他们俩一模一样!我想说这非常明确地回答了哪个更快的问题:它们将是相同的,因为优化器识别出看似简单的初始化程序并将其转换为单个优化的汇编语言指令以清除寄存器。

现在,肯定可能存在这样的情况,即它被深深地嵌入到各种复杂的代码结构中,阻止优化器识别它并发挥它的魔力。换句话说,"your test function is too simple!" 异议。这很可能就是为什么库的实现者选择在可用时显式使用内部函数的原因。它的使用保证代码生成器将发出所需的指令,因此代码将尽可能优化。

显式使用内部函数的另一个可能的好处是确保您获得所需的指令,即使代码是在没有 SSE/SSE2 支持的情况下编译的。正如我想象的那样,这不是一个特别引人注目的用例,因为如果可以接受使用这些指令,则没有 SSE/SSE2 支持就不会进行编译。如果您明确尝试禁用 SSE/SSE2 指令的生成,以便您可以在遗留系统上 运行,那么内在函数会毁了你的一天,因为它会强制发出 xorps 指令,遗留系统会在命中这条指令时立即抛出无效操作异常。

不过,我确实看到了一个有趣的案例。 xorps 是该指令的单精度版本,仅需要 SSE 支持。但是,如果我仅在 SSE 支持(无 SSE2)的情况下编译上面显示的函数,我将得到以下结果:

ZeroTest_Intrinsic
    xorps  xmm0, xmm0
    ret
ZeroTest_Naive
    push   ebp
    mov    ebp, esp
    and    esp, -16
    sub    esp, 16

    mov    DWORD PTR [esp],    0
    mov    DWORD PTR [esp+4],  0
    mov    DWORD PTR [esp+8],  0
    mov    DWORD PTR [esp+12], 0
    movaps xmm0, XMMWORD PTR [esp]

    mov    esp, ebp
    pop    ebp
    ret

显然,由于某些原因,优化器无法在 SSE2 指令支持不可用时将优化应用于初始化器的使用,即使 xorps 它将使用的指令不需要 SSE2 指令支持!这可以说是优化器中的一个错误,但显式使用内部函数可以解决它。