如何在块复制期间矢量化范围检查?

Bli*_*ndy 6 c++ sse vectorization avx

我有以下功能:

void CopyImageBitsWithAlphaRGBA(unsigned char *dest, const unsigned char *src, int w, int stride, int h,
    unsigned char minredmask, unsigned char mingreenmask, unsigned char minbluemask, unsigned char maxredmask, unsigned char maxgreenmask, unsigned char maxbluemask)
{
    auto pend = src + w * h * 4;
    for (auto p = src; p < pend; p += 4, dest += 4)
    {
        dest[0] = p[0]; dest[1] = p[1]; dest[2] = p[2];
        if ((p[0] >= minredmask && p[0] <= maxredmask) || (p[1] >= mingreenmask && p[1] <= maxgreenmask) || (p[2] >= minbluemask && p[2] <= maxbluemask))
            dest[3] = 255;
        else
            dest[3] = 0;
    }
}
Run Code Online (Sandbox Code Playgroud)

它的作用是将32位位图从一个存储块复制到另一个存储块,当像素颜色落在某个颜色范围内时,将alpha通道设置为完全透明.

如何在VC++ 2017中使用SSE/AVX?现在它没有生成矢量化代码.如果没有自动执行此操作,我可以使用哪些功能来执行此操作?

因为实际上,我想象一下测试字节是否在一个范围内将是最明显有用的操作之一,但我看不到任何内置函数来处理它.

Pet*_*des 6

我不认为你会得到一个自动矢量化的编译器,你可以用英特尔的内在函数手工完成.(错误,也可以手工做:P).

可能一旦我们手动向量化它,我们就可以看到如何用标量代码手动保存编译器,但我们真的需要打包比较到带有字节元素的0/0xFF,并且很难用C语言编写一些东西.编译器将自动矢量化.默认的整数提升意味着大多数C表达式实际上产生32位结果,即使你使用它uint8_t,并且经常欺骗编译器解压缩8位到32位元素,在自动因子4的基础上花费大量的混乱吞吐量损失(每个寄存器的元素数量减少),例如@ harold对您的源代码的小调整.


SSE/AVX(在AVX512之前)已经签署了SIMD整数的比较,而不是无符号.但是你可以通过减去128来将范围转换为带符号-128..127.在某些CPU上,XOR(无 - 无进位)稍微提高效率,所以你实际上只需要XOR 0x80来翻转高位.但是在数学上你从0..255无符号值中减去128,得到-128..127有符号值.

甚至还可以实现"无符号比较技巧" (x-min) < (max-min).(例如,检测字母ASCII字符).作为奖励,我们可以将范围转换为减法.如果x<min,它包裹并变成大于的大值max-min.这显然适用于无符号,但它确实可以max-min使用SSE/AVX2符号比较指令工作(使用范围移位).(这个答案的先前版本声称这个技巧只有在max-min < 128这种情况下才有效,但事实并非如此. 如果从上面开始,x-min就无法完全包裹并变得低于max-min或者进入该范围max).

此答案的早期版本具有使范围独占的代码,即不包括末尾,因此即使redmin = 0/redmax = 255也会排除红色= 0或红色= 255的像素.但我通过比较另一种方式解决了这个问题(感谢来自@ Nejc和@chtz答案的想法).

@ chtz使用饱和的add/sub 而不是比较的想法非常酷.如果你安排的东西让饱和意味着在范围内,那么它适用于包容范围.(并且您可以通过选择使得所有256个可能输入在范围内的最小值/最大值来将Alpha分量设置为已知值. 这使我们可以避免范围转换为有符号转换,因为无符号饱和可用

我们可以将sub/cmp范围检查和饱和技巧结合起来sub(包裹越界subs越低)/ (如果第一次sub没有换行,则只能达到零).然后我们不需要andnotor对每个组件组合两个单独的检查; 我们已经0在一个向量中得到/非零结果.

因此,只需要两次操作就可以为我们检查的整个像素提供32位值.Iff所有3个RGB组件都在范围内,该元素将具有特定值.(因为我们已经安排Alpha组件已经给出了已知值).如果3个组件中的任何一个超出范围,它将具有其他一些值.

如果你以另一种方式执行此操作,那么饱和度意味着超出范围,那么你在该方向上有一个独占范围,因为你不能选择一个限制,使得没有值达到0或达到255.你总是可以使无论RGB组件的含义如何,alpha组件都可以在那里给出一个已知值.通过选择无像素可匹配的范围,独占范围可让您滥用此功能始终为false.(或者如果有第三个条件,除了每个组件的最小/最大值,那么也许你想要一个覆盖).


显而易见的是使用具有32位元素大小(_mm256_cmpeq_epi32/ vpcmpeqd)的打包比较指令来生成一个0xFF0x00(我们可以应用/混合到原始RGB像素值中)进入/超出范围.

// AVX2 core idea: wrapping-compare trick with saturation to achieve unsigned compare
__m256i tmp = _mm256_sub_epi8(src, min_values);       // wraps to high unsigned if below min
__m256i RGB_inrange = _mm256_subs_epu8(tmp, max_minus_min);  // unsigned saturation to 0 means in-range
__m256i new_alpha = _mm256_cmpeq_epi32(RGB_inrange, _mm256_setzero_si256());

// then blend the high byte of each element with RGB from the src vector
__m256i alpha_replaced = _mm256_blendv_epi8(new_alpha, src, _mm256_set1_epi32(0x00FFFFFF));  // alpha from new_alpha, RGB from src
Run Code Online (Sandbox Code Playgroud)

请注意,SSE2版本只需要一个MOVDQA指令即可复制src; 相同的寄存器是每条指令的目的地.

另请注意,您可以使另一个方向饱和:add然后adds(使用(256-max)(256-(min-max)),我认为)在范围内饱和到0xFF.如果您使用固定掩码(例如对于alpha)或可变掩码(对于某些其他条件)使用零掩码来基于某些其他条件排除组件,这对AVX512BW非常有用.对于sub/subs版本的AVX512BW零屏蔽将考虑组件的范围,即使它们不是,这也可能是有用的.


但是将其扩展到AVX512需要采用不同的方法:AVX512比较产生位掩码(在掩码寄存器中),而不是向量,因此我们无法转向并分别使用每个32位比较结果的高字节.

而不是cmpeq_epi32,我们可以使用从左到右传播的减法中的进位/借位,在每个像素的高字节中产生我们想要的值.

0x00000000 - 1 = 0xFFFFFFFF     # high byte = 0xFF = new alpha
0x00?????? - 1 = 0x00??????     # high byte = 0x00 = new alpha
Where ?????? has at least one non-zero bit, so it's a 32-bit number >=0 and <=0x00FFFFFFFF
Remember we choose an alpha range that makes the high byte always zero
Run Code Online (Sandbox Code Playgroud)

_mm256_sub_epi32(RGB_inrange, _mm_set1_epi32(1)).我们只需要每个32位元素的高字节来获得我们想要的alpha值,因为我们使用字节混合将其与源RGB值合并.对于AVX512,这避免了VPMOVM2D zmm1, k1将比较结果转换回0/-1矢量的指令,或者(更昂贵)将每个掩码位与3个零交错以将其用于字节混合的指令.

sub,而不是cmp有一个小的优势,甚至对AVX2:sub_epi32在多个端口上SKYLAKE微架构(P0/P1/P5与用于pcmpgt/pcmpeq P0/P1)运行.在所有其他CPU上,向量整数add/sub在与向量整数比较相同的端口上运行.(Agner Fog的指令表).

此外,如果您编译_mm256_cmpeq_epi32()-march=native用AVX512在CPU上,或以其他方式使AVX512然后编译正常AVX2内部函数,一些编译器会愣神使用AVX512比较-成面罩,然后展开回矢量,而不是仅仅使用VEX编码vpcmpeqd.因此,我们用sub,而不是cmp连供_mm256内部函数的版本,因为我已经花的时间来弄明白,并表明它在编制定期AVX2正常情况下是至少一样有效.(虽然_mm256_setzero_si256()比它便宜set1(1); vpxor可以廉价地将寄存器归零而不是加载常量,但是这种设置发生在循环之外.)

#include <immintrin.h>

#ifdef __AVX2__
// inclusive min and max
__m256i  setAlphaFromRangeCheck_AVX2(__m256i src, __m256i mins, __m256i max_minus_min)
{
    __m256i tmp = _mm256_sub_epi8(src, mins);   // out-of-range wraps to a high signed value

    // (x-min) <= (max-min)  equivalent to:
    // (x-min) - (max-min) saturates to zero
    __m256i RGB_inrange = _mm256_subs_epu8(tmp, max_minus_min);
    // 0x00000000 for in-range pixels, 0x00?????? (some higher value) otherwise

    // this has minor advantages over compare against zero, see full comments on Godbolt    
    __m256i new_alpha = _mm256_sub_epi32(RGB_inrange, _mm256_set1_epi32(1));
    // 0x00000000 - 1  = 0xFFFFFFFF
    // 0x00?????? - 1  = 0x00??????    high byte = new alpha value

    const __m256i RGB_mask = _mm256_set1_epi32(0x00FFFFFF);  // blend mask
    // without AVX512, the only byte-granularity blend is a 2-uop variable-blend with a control register
    // On Ryzen, it's only 1c latency, so probably 1 uop that can only run on one port.  (1c throughput).
    // For 256-bit, that's 2 uops of course.
    __m256i alpha_replaced = _mm256_blendv_epi8(new_alpha, src, RGB_mask);  // RGB from src, 0/FF from new_alpha

    return alpha_replaced;
}
#endif  // __AVX2__
Run Code Online (Sandbox Code Playgroud)

为此函数设置向量args并使用_mm256_load_si256/ 循环遍历数组_mm256_store_si256.(或者如果你不能保证对齐,则为loadu/storeu.)

这与gcc,clang和MSVC 非常有效地编译(Godbolt Compiler explorer).(Godbolt上的AVX2版本很好,AVX512和SSE版本仍然很乱,并不是所有的技巧都适用于它们.)

;; MSVC's inner loop from a caller that loops over an array with it:
;; see the Godbolt link
$LL4@:
    vmovdqu ymm3, YMMWORD PTR [rdx+rax*4]
    vpsubb   ymm0, ymm3, ymm7
    vpsubusb ymm1, ymm0, ymm6
    vpsubd   ymm2, ymm1, ymm5
    vpblendvb ymm3, ymm2, ymm3, ymm4
    vmovdqu YMMWORD PTR [rcx+rax*4], ymm3
    add      eax, 8
    cmp      eax, r8d
    jb       SHORT $LL4@
Run Code Online (Sandbox Code Playgroud)

所以MSVC在内联后设法提升了常量设置.我们从gcc/clang得到类似的循环.

该循环有4个向量ALU指令,其中一个指令占用2个uop.共有5个向量ALU uops.但是Haswell/Skylake上的总融合域uops = 9没有展开,所以幸运的是,每2.25个时钟周期可以运行32个字节(1个向量).它可能接近实际实现L1d或L2缓存中的数据热,但L3或内存将成为瓶颈.通过展开,它可能是L2缓存带宽的瓶颈.

AVX512版本(也包含在Godbolt链接中),只需要1个uop进行混合,并且每个周期可以在向量中运行得更快,因此使用512字节向量的速度快两倍.