快速复制每个字节的第二个字节到新的内存区域

4

我需要一种快速的方法将每个第二个字节复制到一个新的malloc'd内存区域中。我有一个原始图像,其中包含RGB数据和每个通道16位(48位),并且希望创建一个每个通道8位(24位)的RGB图像。

是否有比逐字节复制更快的方法?我对SSE2不是很了解,但我认为使用SSE / SSE2可能是可能的。


1
@tilz0R别提那个了。我有两台Amiga :) - Jean-François Fabre
1
@PaulR 听起来你有能力回答这个问题。在 SSE/SSE2 代码中说“几乎重复”就像是说“制造原子弹和制造氢弹几乎相同” :) - Jean-François Fabre
1
@PaulR: pshufb 对于一个寄存器来说是很好的,但在循环整个图像时,洗牌端口吞吐量会成为瓶颈。因此,您应该将高半部分与低半部分进行 AND 运算或向下移位以丢弃低半部分,然后 _mm_packus_epi16 每对输入向量合并为一个输出向量。这里可能有重复的内容... - Peter Cordes
2
@Someprogrammerdude: 这里的“second”不是一个时间单位,而是“2nd”,描述了OP想要的转换/过滤类型。我也曾经误读了半秒钟。 - Peter Cordes
1
@AKW:你想保留RGB16数据的高字节还是低字节?即_mm_and_si128(v,_mm_set1_epi16(0x00ff))_mm_srli_epi16(v,8) - Peter Cordes
显示剩余7条评论
1个回答

7
你的RGB数据是打包的,因此我们实际上不需要关心像素边界。问题只在于如何打包数组中的每个其他字节。(至少在图像的每一行内; 如果使用16或32B的行跨度,则填充可能不是整数个像素。)
可以使用SSE2、AVX或AVX2 shuffle高效地完成此操作。(还有AVX512BW,甚至可能使用AVX512VBMI等更多的操作,但第一个AVX512VBMI CPU可能不会有非常有效的vpermt2b,这是一个2输入lane-crossing byte shuffle。)
你可以使用SSSE3的指令来获取所需的字节,但它只是一个1输入的洗牌操作,将给出8字节的输出。每次存储8个字节需要更多的总存储指令,而不是一次存储16个字节。(自Haswell以来,英特尔CPU的洗牌吞吐量已经瓶颈了,因为它只有一个洗牌端口,因此每个时钟周期只能进行一次洗牌操作)。(你还可以考虑2x+来提供16B存储,并且在Ryzen上可能会很好。使用2个不同的洗牌控制向量,一个将结果放入低64b,另一个将结果放入高64b。请参见将8个16位SSE寄存器转换为8位数据。)
相反,使用_mm_packus_epi16 (packuswb)可能是一个胜利。但由于它饱和而不是丢弃您不想要的字节,因此您必须提供具有每个16位元素低字节中要保留数据的输入。

在您的情况下,这可能是每个RGB16组件的高字节,从每个颜色分量中舍弃8个最低有效位。即_mm_srli_epi16(v, 8)要将每个16位元素中的高字节清零,请改用_mm_and_si128(v, _mm_set1_epi16(0x00ff))(在这种情况下,不要再考虑使用非对齐加载来替换其中一个移位;这是容易的情况,您应该只使用两个AND来提供PACKUS。)

这大致是gcc和clang在-O3下的自动向量化方式,除了它们都会出现问题并浪费很多指令(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82356https://bugs.llvm.org/show_bug.cgi?id=34773)。不过,让它们使用SSE2(x86-64的基准线)或者ARM的NEON等进行自动向量化,是一种安全的获取性能的方法,而不用担心手动向量化可能引入的错误。除了编译器的问题,它们生成的任何内容都将正确实现此代码的C语义,适用于任何大小和对齐方式:
// gcc and clang both auto-vectorize this sub-optimally with SSE2.
// clang is *really* sub-optimal with AVX2, gcc no worse
void pack_high8_baseline(uint8_t *__restrict__ dst, const uint16_t *__restrict__ src, size_t bytes) {
  uint8_t *end_dst = dst + bytes;
  do{
     *dst++ = *src++ >> 8;
  } while(dst < end_dst);
}

查看此版本以及后续版本的代码和汇编在Godbolt上

// Compilers auto-vectorize sort of like this, but with different
// silly missed optimizations.
// This is a sort of reasonable SSE2 baseline with no manual unrolling.
void pack_high8(uint8_t *restrict dst, const uint16_t *restrict src, size_t bytes) {
  // TODO: handle non-multiple-of-16 sizes
  uint8_t *end_dst = dst + bytes;
  do{
     __m128i v0 = _mm_loadu_si128((__m128i*)src);
     __m128i v1 = _mm_loadu_si128(((__m128i*)src)+1);
     v0 = _mm_srli_epi16(v0, 8);
     v1 = _mm_srli_epi16(v1, 8);
     __m128i pack = _mm_packus_epi16(v0, v1);
     _mm_storeu_si128((__m128i*)dst, pack);
     dst += 16;
     src += 16;  // 32 bytes, unsigned short
  } while(dst < end_dst);
}

但在许多微架构中(Skylake之前的Intel,AMD Bulldozer / Ryzen),向量移位吞吐量限制为每个时钟1次。此外,在AVX512之前没有加载+移位汇编指令,因此很难通过流水线获得所有这些操作。(即我们很容易在前端瓶颈。) 我们可以从偏移一个字节的地址加载,以便所需字节位于正确位置,而不是进行移位。使用AND屏蔽掉我们想要的字节具有良好的吞吐量,特别是在AVX下,编译器可以将加载+AND折叠为一条指令。如果输入是32字节对齐的,并且我们只对奇向量使用这种偏移加载技巧,则我们的加载永远不会跨越缓存行边界。通过循环展开,这可能是SSE2或AVX(没有AVX2)在许多CPU上的最佳选择。
// take both args as uint8_t* so we can offset by 1 byte to replace a shift with an AND
// if src is 32B-aligned, we never have cache-line splits
void pack_high8_alignhack(uint8_t *restrict dst, const uint8_t *restrict src, size_t bytes) {
  uint8_t *end_dst = dst + bytes;
  do{
     __m128i v0 = _mm_loadu_si128((__m128i*)src);
     __m128i v1_offset = _mm_loadu_si128(1+(__m128i*)(src-1));
     v0 = _mm_srli_epi16(v0, 8);
     __m128i v1 = _mm_and_si128(v1_offset, _mm_set1_epi16(0x00FF));
     __m128i pack = _mm_packus_epi16(v0, v1);
     _mm_store_si128((__m128i*)dst, pack);
     dst += 16;
     src += 32;  // 32 bytes
  } while(dst < end_dst);
}

没有AVX,内部循环每16B向量的结果需要6条指令(6 uops)。 (使用AVX只需要5个,因为加载折叠到and中)。由于这完全瓶颈在前端,循环展开可以大有裨益。gcc -O3 -funroll-loops对于此手动矢量化版本看起来非常好,特别是使用gcc -O3 -funroll-loops -march=sandybridge启用AVX。

有了AVX,可能值得同时使用v0v1进行and,以减少前端瓶颈,代价是具有缓存行拆分(和偶尔的页面拆分)。但可能取决于uarch以及您的数据是否已经错位。 (在这方面进行分支可能是值得的,因为如果数据在L1D中很热,则需要最大化缓存带宽)。

使用AVX2,256b版本的256b加载应该在Haswell/Skylake上运行良好。如果src按64B对齐,则偏移加载仍不会分裂缓存行。(它将始终加载缓存行的字节[62:31],而v0加载将始终加载字节[31:0])。但是,在128b通道内进行打包工作,因此在打包后,必须使用vpermq进行洗牌,以将64位块放入正确的顺序中。查看gcc如何使用vpackuswb ymm7, ymm5, ymm6/vpermq ymm8, ymm7, 0xD8自动矢量化标量基准版本。

使用AVX512F,这个技巧不再起作用,因为64B的加载必须对齐以保持在单个64B缓存行中。但是,使用AVX512,可用不同的混洗方式,并且ALU uop吞吐量更加宝贵(在Skylake-AVX512上,当512b uops正在运行时,port1会关闭)。因此,v = load+shift -> __m256i packed = _mm512_cvtepi16_epi8(v) 可能效果很好,即使它仅进行256b存储。
正确的选择可能取决于您的源和目标是否通常对齐为64B。KNL没有AVX512BW,因此这可能仅适用于Skylake-AVX512。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接