使用内在指令的欧几里德距离

时间:2017-08-17 12:53:21

标签: c++ sse simd euclidean-distance

对于一个研究项目,我需要计算很多欧几里德距离,其中必须选择某些维度而忽略其他维度。在程序的当前状态中,所选维度的数组具有100个元素,并且我计算大约2-3百万个距离。我目前的代码如下:

float compute_distance(const float* p1, const float* p2) const
{
    __m256 euclidean = _mm256_setzero_ps();

    const uint16_t n = nbr_dimensions;
    const uint16_t aligend_n = n - n % 16;
    const float* local_selected = selected_dimensions;

    for (uint16_t i = 0; i < aligend_n; i += 16)
    {
        const __m256 r1 = _mm256_sub_ps(_mm256_load_ps(&p1[i]), _mm256_load_ps(&p2[i]));
        euclidean = _mm256_fmadd_ps(_mm256_mul_ps(r1, r1), _mm256_load_ps(&local_selected[i]), euclidean);
        const __m256 r2 = _mm256_sub_ps(_mm256_load_ps(&p1[i + 8]), _mm256_load_ps(&p2[i + 8]));
        euclidean = _mm256_fmadd_ps(_mm256_mul_ps(r2, r2), _mm256_load_ps(&local_selected[i + 8]), euclidean);
    }
    float distance = hsum256_ps_avx(euclidean);

    for (uint16_t i = aligend_n; i < n; ++i)
    {
        const float num = p1[i] - p2[i];
        distance += num * num * local_selected[i];
    }

    return distance;
}

所选尺寸是预先确定的。因此,我可以预先计算__m256的数组,以传递给_mm256_blendv_ps,而不是在行euclidean = _mm256_fmadd_ps(_mm256_mul_ps(r1, r1), _mm256_load_ps(&local_selected[i]), euclidean);中乘以0或1。但我不是内在指令的新手,我还没有找到一个有效的解决方案。

我想知道你们是否可以提供一些建议,甚至是代码建议,以提高这个功能的运行速度。作为旁注,我无法访问AVX-512指令。

更新: 使用上面提到的第一个解决方案,它来了:

float compute_distance(const float* p1, const float* p2) const
{
    const size_t n = nbr_dimensions;
    const size_t aligend_n = n - n % 16;
    const unsigned int* local_selected = selected_dimensions;
    const __m256* local_masks = masks;

    __m256 euc1 = _mm256_setzero_ps(), euc2 = _mm256_setzero_ps(),
        euc3 = _mm256_setzero_ps(), euc4 = _mm256_setzero_ps();

    const size_t n_max = aligend_n/8;
    for (size_t i = 0; i < n_max; i += 4)
    {       
        const __m256 r1 = _mm256_sub_ps(_mm256_load_ps(&p1[i * 8 + 0]), _mm256_load_ps(&p2[i * 8 + 0]));
        const __m256 r1_1 = _mm256_and_ps(r1, local_masks[i + 0]);
        euc1 = _mm256_fmadd_ps(r1_1, r1_1, euc1);

        const __m256 r2 = _mm256_sub_ps(_mm256_load_ps(&p1[i * 8 + 8]), _mm256_load_ps(&p2[i * 8 + 8]));
        const __m256 r2_1 = _mm256_and_ps(r2, local_masks[i + 1]);
        euc2 = _mm256_fmadd_ps(r2_1, r2_1, euc2);

        const __m256 r3 = _mm256_sub_ps(_mm256_load_ps(&p1[i * 8 + 16]), _mm256_load_ps(&p2[i * 8 + 16]));
        const __m256 r3_1 = _mm256_and_ps(r3, local_masks[i + 2]);
        euc3 = _mm256_fmadd_ps(r3_1, r3_1, euc3);

        const __m256 r4 = _mm256_sub_ps(_mm256_load_ps(&p1[i * 8 + 24]), _mm256_load_ps(&p2[i * 8 + 24]));
        const __m256 r4_1 = _mm256_and_ps(r4, local_masks[i + 3]);
        euc4 = _mm256_fmadd_ps(r4_1, r4_1, euc4);
    }

    float distance = hsum256_ps_avx(_mm256_add_ps(_mm256_add_ps(euc1, euc2), _mm256_add_ps(euc3, euc4)));

    for (size_t i = aligend_n; i < n; ++i)
    {
        const float num = p1[i] - p2[i];
        distance += num * num * local_selected[i];      
    }

    return distance;
}

1 个答案:

答案 0 :(得分:3)

基本建议:

不要将uint16_t用于循环计数器,除非你真的想强制编译器每次都截断为16位。至少使用unsigned,或者有时候使用uintptr_t(或更常规的size_t)会更好。从32位到指针宽度的零扩​​展仅在使用32位操作数大小的asm指令时在x86-64上免费发生,但有时编译器仍然不能做得很好。

使用五个或更多个单独的累加器而不是一个euclidean,因此可以在飞行中使用多个子/ FMA指令,而不会出现将FMA转换为一个累加器的循环携带依赖链的延迟瓶颈。

FMA的延迟为5个周期,但英特尔Haswell的吞吐量为每0.5个周期一个。另请参阅latency vs throughput in intel intrinsics,以及Why does mulss take only 3 cycles on Haswell, different from Agner's instruction tables?上有关更高级版本的答案。

避免将args传递给全局变量。显然你的n是编译时常量(这很好),但selected_dimensions不是,是吗?如果是,那么你只在整个程序中使用一套面具,所以不要在下面提到有关压缩面具的内容。

使用全局变量可以在将函数内联到调用者之前阻止编译器优化,调用者在调用它之前设置全局变量。 (通常只有在设置全局和使用它之间有非内联函数调用时才会调用,但这并不罕见。)

更新:您的阵列很小,只有~100个元素,所以只展开2个可能是好的,以减少启动/清理开销。乱序执行可以在这么短的迭代次数上隐藏FMA延迟,特别是如果不需要这个函数调用的最终结果来决定下一次调用的输入参数。

总函数调用开销很重要,而不仅仅是大型数组的矢量化效率。

As discussed in comments,剥离循环的第一次迭代可以通过初始化euc1 = stuff(p1[0], p2[0]);而不是_mm256_setzero_ps()来避免第一次FMA。

使用零将数组填充到完整的向量(或者甚至是2个向量的完整展开的循环体),可以完全避免标量清理循环,并使整个函数非常紧凑。

如果你不能只是填充,你仍然可以通过加载一个直到输入末尾的未对齐向量来避免标量清理,并屏蔽它以避免重复计算。 (有关基于未对齐计数生成掩码的方法,请参阅this Q&A)。在您编写输出数组的其他类型的问题中,重做重叠元素是可以的。

您没有显示您的hsum256_ps_avx代码,但这只是总延迟和功能吞吐量的一小部分。确保对吞吐量进行优化:例如避免haddps / _mm_hadd_ps。请参阅Fastest way to do horizontal float vector sum on x86上的答案。

您的具体案例

  

因此,我可以预先计算一个__m256数组,以传递给_mm256_blendv_ps,而不是在FMA中乘以0或1。

是的,那会更好,特别是如果它允许你将其他东西折叠到FMAdd / FMSub中。但更好的是,使用全0或全1的布尔_mm256_and_ps。这会使值保持不变(1 & x == x)或归零(0 & x == 0,并且float 0.0的二进制表示为全零。)

如果缓存中没有丢失掩码,则将它们完全解压缩存储,以便可以加载它们。

如果您使用具有相同p1p2的不同面具,则可以预先计算p1-p2平方,然后执行屏蔽add_ps减少。 (但请注意,FMA在英特尔前Skylake上的吞吐量比ADD更高.Haswell / Broadwell有2个FMA单元,但在专用设备上运行ADDPS,延迟更低(3c对5c)。只有一个矢量 - FP添加单元.Skylake只运行FMA单元上的所有内容,周期延迟为4周。)无论如何,这意味着使用FMA作为1.0 * x + y实际上可以获得吞吐量。但是你可能很好,因为你仍然需要单独加载掩码和square(p1-p2),以便每个FP加载2个加载,因此每个循环一个跟上负载吞吐量。除非您(或编译器)在前面剥离几次迭代,并将寄存器中的那些迭代的浮点数据保存在多个不同的local_selected掩码中。

更新:我写这个假设数组大小为2-3百万,而不是~100。 L1D缓存未命中的配置文件决定是否花费更多CPU指令来减少缓存占用是值得的。如果你总是对所有300万个电话使用相同的掩码,那么压缩它可能是不值得的。

你可以将你的面具压缩到每个元素8位,并用pmovsx (_mm256_cvtepi8_epi32)加载它们(符号扩展一个全1值会产生更广泛的全部,因为这是2&#39;补充-1有效。不幸的是,将它用作负载很烦人;编译器有时无法将_mm256_cvtepi8_epi32(_mm_cvtsi64x_si128(foo))优化为vpmovsxbd ymm0, [mem],而是实际使用单独的vmovq指令。

const uint64_t *local_selected = something;  // packed to 1B per element

__m256 euc1 = _mm256_setzero_ps(), euc2 = _mm256_setzero_ps(),
euc3 =  _mm256_setzero_ps(), euc4 =  _mm256_setzero_ps();

for (i = 0 ; i < n ; i += 8*4) {  // 8 floats * an unroll of 4

    __m256 mask = _mm256_castsi256_ps( _mm256_cvtepi8_epi32(_mm_cvtsi64x_si128(local_selected[i*1 + 0])) );
    // __m256 mask = _mm256_load_ps(local_selected[i*8 + 0]); //  without packing

    const __m256 r1 = _mm256_sub_ps(_mm256_load_ps(&p1[i*8 + 0]), _mm256_load_ps(&p2[i*8 + 0]));
    r1 = _mm256_and_ps(r1, mask);             // zero r1 or leave it untouched.
    euc1 = _mm256_fmadd_ps(r1, r1, euc1);    // euc1 += r1*r1
    // ... same for r2 with local_selected[i + 1]
    // and p1/p2[i*8 + 8]
    // euc2 += (r2*r2) & mask2

    // and again for euc3 (local_selected[i + 2], p1/p2[i*8 + 16]
    // and again for euc3 (local_selected[i + 3], p1/p2[i*8 + 24]
}
euclidean = hsum (euc1+euc2+euc3+euc4);

我猜你在没有pmovsx的情况下在负载吞吐量方面略有瓶颈,因为你有三个负载用于三个向量ALU操作。 (并且通过微融合,它在Intel CPU上只有4个融合域uop,所以它在前端没有瓶颈)。并且三个ALU微操作可以在不同的端口上运行(vandps对于英特尔前Skylake上的端口5是1 uop。在SKL上它可以在任何端口上运行。)

在port5上添加一个shuffle(pmovsx)潜在的瓶颈(在Haswell / Broadwell上)。您可能希望使用vpand进行屏蔽,以便它可以在任何端口上运行,如果您正在调整HSW / BDW,即使它们在整数AND和FP数学指令之间有额外的旁路延迟。有了足够的累加器,您就不会受到延迟限制。 (Skylake对VANDPS有额外的旁路延迟,具体取决于它运行的端口。)

blendv比AND慢:总是至少2 uops。

为大型数组压缩掩码

如果您的阵列大于L2缓存,并且您的掩码阵列具有与浮点数组一样多的元素,则很可能会在负载带宽上出现瓶颈(至少在您使用多个向量累加器展开时)。这意味着花费更多指令来解压缩掩码数据是值得的,以减少带宽需求的那部分。

我认为你的掩码数据的理想格式是32个交错的掩码矢量,这使得它非常便宜#34; unpack&#34;在飞行中。使用移位将右掩码放入每个32位元素的高位,并将其与vblendvps一起使用,通过与零混合来有条件地将元素置零。 (或者使用算术右移+布尔AND)

__m256i masks = _mm256_load_si256(...);

                          // this actually needs a cast to __m256, omitted for readability
r0 = _mm256_blendv_ps(_mm256_setzero_ps(), r0, masks);
...

__m256i mask1 = _mm256_slli_epi32(masks, 1);
r1 = _mm256_blendv_ps(_mm256_setzero_ps(), r1, mask1);
...

__m256i mask2 = _mm256_slli_epi32(masks, 2);
r2 = _mm256_blendv_ps(_mm256_setzero_ps(), r2, mask2);
...

// fully unrolling is overkill; you can set up for a loop back to r0 with
masks = _mm256_slli_epi32(masks, 4);

你也可以在每一步做masks = _mm256_slli_epi32(masks, 1);,这可能会更好,因为它少用1个寄存器。但它可能对资源冲突更敏感,导致掩码dep链上的延迟,因为每个掩码都取决于前一个掩码。

Intel Haswell仅在port5上运行vblendvps个uops,因此您可以考虑使用_mm256_srai_epi32 + _mm256_and_ps。但是Skylake可以在任何p015上运行2个uop,因此混合很好(尽管它确实占用了一个保持全零向量的向量寄存器)。

以交错格式生成具有打包比较的掩码,然后_mm256_srli_epi32(cmp_result, 31)和OR进入您正在构建的矢量中。然后左移它1.重复32次。

如果数组中的数据总量少于32个,则仍可以使用此格式。较低的位将被闲置。或者你可以为每个向量设置2个或更多selected_dimensions的掩码。例如每个元素的前16位用于一个selected_dimensions,而后16位用于另一个。你可以做点什么

__m256i masks =  _mm256_load_si256(dimensions[selector/2]);
masks = _mm256_sll_epi32(masks, 16 * (selector % 2));

// or maybe
if (selector % 2) {
    masks = _mm256_slli_epi32(masks, 16);
}

AVX512:

AVX512可以直接使用位图掩码,因此效率更高一些。只需使用const __mmask16 *local_selected = whatever;声明一个16位掩码数组(用于16个浮点数的512b向量),并使用r0 = _mm512_maskz_sub_ps(p1,p2, local_selected[i]);对减法进行零掩码。

如果您确实在加载端口uop吞吐量(每个时钟2个负载)上遇到瓶颈,您可能会尝试一次加载64位掩码数据,并使用掩码移位来获得不同的低16值。除非您的数据在L1D缓存中很热,否则这可能不会成为问题。

首先使用比较掩码生成掩码数据非常容易,不需要交错。

理想情况下,您可以缓存阻止调用此代码的代码,以便在缓存中很热时可以重用数据。例如从p1和p2的第一个64kiB获得你想要的所有组合,然后转到后面的元素,并在它们在缓存中热时执行它们。