删除多个 _mm256_blend_ps 会降低性能而不是提高性能

Removing multiple _mm256_blend_ps decreases performance instead of increasing it

我正在编写一个小型模板库来使用 AVX 内在函数转置任意矩阵。由于我大量使用 if constexpr 和模板,因此我想确保编译器正在应用我期望的所有优化并对我的代码进行基准测试。我遇到了一个我不太明白的结果。

这些函数有一个模板参数,用于控制应如何处理未使用的寄存器值。一种选择是在执行的操作过程中采取任何结束的方式。另一种是只写入存储结果所必需的条目。我删除了所有模板内容并为 7x4 矩阵编写了一个简短示例:

编辑:这段代码是错误的---参见更新

void Transpose7x4(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2,
                    __m256& out3, __m256& out4, __m256& out5, __m256& out6)
{
    __m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;


    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp6, tmp4, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp7, tmp5, 0xee);

    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x44);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x44);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x44);


    // Don't care what is written to unused values
    out0 = tout0;
    out1 = tout1;
    out2 = tout2;
    out3 = tout3;
    out4 = tout4;
    out5 = tout5;
    out6 = tout6;

    // Only write to values necessary to store the result
    //out0 = _mm256_blend_ps(out0, tout0, 0xfe);
    //out1 = _mm256_blend_ps(out1, tout1, 0xfe);
    //out2 = _mm256_blend_ps(out2, tout2, 0xfe);
    //out3 = _mm256_blend_ps(out3, tout3, 0xfe);
    //out4 = _mm256_blend_ps(out4, tout4, 0xfe);
    //out5 = _mm256_blend_ps(out5, tout5, 0xfe);
    //out6 = _mm256_blend_ps(out6, tout6, 0xfe);
}

如您所见,不覆盖未使用值的版本需要额外的混合,所以我预计它会稍微慢一些。然而,基准测试的结果(英特尔 skylake 处理器上的 Clang 8.0.0 和 GCC 8.3.0)告诉我事实并非如此。对于混合版本,100 次换位给了我大约 430 纳秒,而另一个版本花了大约 670 纳秒。我检查了程序集是否发生了奇怪的事情,但我什么也看不到:godbolt

程序集大致相同,只有一个版本 vmovaps 与其他 vblendps(和一个 vperm2f128)交错。

我计算了预期的时钟周期,并考虑了 _mm256_permute2f128_ps 的指令流水线。对于代码,在没有混合的情况下,我得出了 17 个周期。乘以 100 再除以我的处理器频率得到 425ns,这几乎是我在混合版本中得到的结果。我能看到,为什么没有混合的版本需要更多时间的唯一原因是,_mm256_permute2f128_ps 的指令流水线由于某种原因无法使用。如果我计算假设下的预期时序,即每个 _mm256_permute2f128_ps 需要 3 个时钟周期,我得到 725ns,这更接近我得到的结果。

所以问题是,为什么带有混合的版本比“更简单”的版本更快(利用指令流水线),我该如何解决这个问题。

找到解决方案。 Peter Cordes 的评论将我推向了正确的方向。我的基准测试有问题。我正在使用 google 基准测试,这是我使用的原始基准测试代码:

#include <benchmark/benchmark.h>

#include <x86intrin.h>

#include <array>



class FixtureBenchmark_m256 : public benchmark::Fixture
{
public:
    std::array<std::array<__m256, 8>, 10000> in;
    std::array<std::array<__m256, 8>, 10000> out;

    FixtureBenchmark_m256()
    {
        __m256 tmp0 = _mm256_setr_ps(1, 2, 3, 4, 5, 6, 7, 8);
        for (std::size_t i = 0; i < 1000; ++i)
            for (std::size_t j = 0; j < 8; ++j)
            {
                __m256 tmp1 = _mm256_set1_ps(i * 8 + j);
                in[i][j] = _mm256_mul_ps(tmp0, tmp1);
            }
    }
};



void T7x4_assign(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2, __m256& out3,
                 __m256& out4, __m256& out5, __m256& out6)
{
    __m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;


    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp6, tmp4, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp7, tmp5, 0xee);

    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x44);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x44);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x44);

    out0 = tout0;
    out1 = tout1;
    out2 = tout2;
    out3 = tout3;
    out4 = tout4;
    out5 = tout5;
    out6 = tout6;
}


void T7x4_blend(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2, __m256& out3,
                __m256& out4, __m256& out5, __m256& out6)
{
    __m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;

    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp6, tmp4, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp7, tmp5, 0xee);

    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x44);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x44);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x44);

    out0 = _mm256_blend_ps(out0, tout0, 0xfe);
    out1 = _mm256_blend_ps(out1, tout1, 0xfe);
    out2 = _mm256_blend_ps(out2, tout2, 0xfe);
    out3 = _mm256_blend_ps(out3, tout3, 0xfe);
    out4 = _mm256_blend_ps(out4, tout4, 0xfe);
    out5 = _mm256_blend_ps(out5, tout5, 0xfe);
    out6 = _mm256_blend_ps(out6, tout6, 0xfe);
}



BENCHMARK_F(FixtureBenchmark_m256, 7x4_assign)(benchmark::State& state)
{
    for (auto _ : state)
    {
        for (std::size_t i = 0; i < 100; ++i)
        {
            T7x4_assign(in[i][0], in[i][1], in[i][2], in[i][3], out[i][0], out[i][1], out[i][2], out[i][3], out[i][4],
                        out[i][5], out[i][6]);
            benchmark::ClobberMemory();
        }
    }
}

BENCHMARK_F(FixtureBenchmark_m256, 7x4_blend)(benchmark::State& state)
{
    for (auto _ : state)
    {
        for (std::size_t i = 0; i < 100; ++i)
        {
            T7x4_blend(in[i][0], in[i][1], in[i][2], in[i][3], out[i][0], out[i][1], out[i][2], out[i][3], out[i][4],
                       out[i][5], out[i][6]);
            benchmark::ClobberMemory();
        }
    }
}

BENCHMARK_MAIN();

这给出了输出:

---------------------------------------------------------------------------
Benchmark                                 Time             CPU   Iterations
---------------------------------------------------------------------------
FixtureBenchmark_m256/7x4_assign        646 ns          646 ns      1081509
FixtureBenchmark_m256/7x4_blend         380 ns          380 ns      1847485

这里的问题是循环。我真的不能说到底发生了什么,可能是缓存未命中或一些奇怪的循环优化,但删除循环给出了预期的时间:

---------------------------------------------------------------------------
Benchmark                                 Time             CPU   Iterations
---------------------------------------------------------------------------
FixtureBenchmark_m256/7x4_assign       3.27 ns         3.27 ns    214698649
FixtureBenchmark_m256/7x4_blend        4.15 ns         4.14 ns    168642478

那么为什么首先是循环?这是因为使用 sudo apt-get install libbenchmark-dev 在 ubuntu 中安装了 google 基准测试。问题是,这是一个调试版本,纳秒计时在这个版本中是四舍五入的。所以我看不出单次执行和带循环的定时多个函数调用有什么区别。 然而,在手动构建和安装发布版本后,我得到了更准确的时间并且可以删除循环,这对基准产生了负面影响。

补充说明: 我还错误地计算了预期的 CPU 周期。我没有使用优化的程序集,而是使用了内在函数。所以我想出了 8 次正常洗牌和 7 次车道间洗牌,得到 15。加上最后一个车道间排列不可避免的延迟(2 个额外的周期)得到 17。但是,编译器优化了 3 _mm256_permute2f128_ps 得到 14 (12 次洗牌 - 正如 Peter Cordes 所说 - 加上 2 个周期的延迟)。 除以我的 cpu 频率 4.2 得出 3.33,这非常接近基准测试结果。

更新

我想知道,为什么编译器优化掉了 3 个 _mm256_permute2f128_ps 调用。在我的库中,内在函数被泛化以轻松交换寄存器类型。此外,所有掩码都是自动计算的。所以我在替换所有库调用时犯了一些错误。这是正确的代码:

void Transpose7x4(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2,
                    __m256& out3, __m256& out4, __m256& out5, __m256& out6)
{
__m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;


    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp4, tmp6, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp5, tmp7, 0xee);


    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x33);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x33);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x33);


    out0 = tout0;
    out1 = tout1;
    out2 = tout2;
    out3 = tout3;
    out4 = tout4;
    out5 = tout5;
    out6 = tout6;

    //out0 = _mm256_blend_ps(out0, tout0, 0xfe);
    //out1 = _mm256_blend_ps(out1, tout1, 0xfe);
    //out2 = _mm256_blend_ps(out2, tout2, 0xfe);
    //out3 = _mm256_blend_ps(out3, tout3, 0xfe);
    //out4 = _mm256_blend_ps(out4, tout4, 0xfe);
    //out5 = _mm256_blend_ps(out5, tout5, 0xfe);
    //out6 = _mm256_blend_ps(out6, tout6, 0xfe);
}

现在所有指令(8 次洗牌和 7 次车道间洗牌)都按预期出现在程序集中:

godbolt