每隔一个字节快速复制到新的内存区域

Posted

技术标签:

【中文标题】每隔一个字节快速复制到新的内存区域【英文标题】:Fast copy every second byte to new memory area 【发布时间】:2017-09-28 11:28:40 【问题描述】:

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

有没有比按字节复制更快的方法? 我对 SSE2 了解不多,但我认为 SSE/SSE2 是可能的。

【问题讨论】:

@tilz0R 不要让我开始。我有 2 个 Amigas :) @PaulR 听起来你也有能力回答这个问题。 SSE/SSE2 代码上的“几乎重复”就像是在说“制造原子弹与制造氢弹几乎一样”:) @PaulR: pshufb 适合一个寄存器,但 shuffle-port 吞吐量将成为循环遍历整个图像的瓶颈。因此,您应该 AND 输出高半部分或将其向下移动以丢弃低半部分,然后将每对输入向量 _mm_packus_epi16 转换为一个输出向量。在某处可能有一个副本...... @Someprogrammerdude:“秒”在这里不是一个时间单位,它是“第二个”,描述了 OP 想要什么样的转换/过滤>。 @AKW:你想保留RGB16数据的高字节还是低字节?即_mm_and_si128(v, _mm_set1_epi16(0x00ff))_mm_srli_epi16(v, 8)? 【参考方案1】:

您的 RGB 数据已打包,因此我们实际上不必关心像素边界。问题只是打包数组的每个其他字节。 (至少在图像的每一行内;如果您使用 16 或 32B 的行跨度,则填充可能不是整数像素。)

这可以使用 SSE2、AVX 或 AVX2 shuffle 高效地完成。 (还有 AVX512BW,可能更多的是 AVX512VBMI,但第一批 AVX512VBMI CPU 可能不会有非常高效的vpermt2b, a 2-input lane-crossing byte shuffle.)


你可以使用 SSSE3 pshufb 来获取你想要的字节,但它只是一个 1-input shuffle 会给你 8 个字节的输出。一次存储 8 个字节比一次存储 16 个字节需要更多的存储指令。 (自 Haswell 以来,英特尔 CPU 上的 shuffle 吞吐量也会成为瓶颈,Haswell 只有一个 shuffle 端口,因此每个时钟一个 shuffle 吞吐量)。 (您也可以考虑使用 2xpshufb + por 为 16B 存储提供数据,这在 Ryzen 上可能会很好。使用 2 个不同的随机播放控制向量,一个将结果放入低 64b,一个将结果放入高 64b。见Convert 8 16 bit SSE register to 8bit data)。

相反,使用_mm_packus_epi16 (packuswb) 可能是一种胜利。但由于它会饱和而不是丢弃您不想要的字节,因此您必须向其输入要保留在每个 16 位元素的低字节中的数据。

在您的情况下,这可能是每个 RGB16 组件的高字节,从每个颜色组件中丢弃 8 个最低有效位。即_mm_srli_epi16(v, 8)要将每个 16 位元素中的高字节归零,请改用 _mm_and_si128(v, _mm_set1_epi16(0x00ff))。 (在这种情况下,不要介意所有关于使用未对齐负载来替换其中一个班次的东西;这是最简单的情况,您应该只使用两个 AND 来喂一个 PACKUS。)

-O3 上,gcc 和 clang 或多或少是如何自动矢量化的。除了他们都搞砸了并浪费了大量的指令(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82356,https://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);

查看此版本及更高版本的代码 + asm on 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 之前没有加载+移位 asm 指令,因此很难通过管道获得所有这些操作。 (即我们很容易在前端遇到瓶颈。)

我们可以从偏移一个字节的地址加载而不是移位,这样我们想要的字节就在正确的位置。 AND 屏蔽我们想要的字节具有良好的吞吐量,尤其是在 AVX 中,编译器可以将 load+and 折叠到一条指令中。如果输入是 32 字节对齐的,并且我们只对奇数向量执行这种偏移加载技巧,我们的加载将永远不会跨越缓存线边界。使用循环展开,这可能是跨多个 CPU 的 SSE2 或 AVX(没有 AVX2)的最佳选择。

// 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 微指令)。 (对于 AVX,它只有 5 个,因为负载折叠成 and)。由于这完全是前端的瓶颈,循环展开有很大帮助。 gcc -O3 -funroll-loops 看起来非常适合这个手动矢量化版本,尤其是使用 gcc -O3 -funroll-loops -march=sandybridge 来启用 AVX。

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

对于 AVX2,具有 256b 负载的 256b 版本应该在 Haswell/Skylake 上运行良好。使用src 64B-aligned,偏移负载仍然永远不会分割缓存行。 (它将始终加载缓存行的字节[62:31],而v0 加载将始终加载字节[31:0])。但是打包工作在 128b 通道内,所以打包后你必须洗牌(使用vpermq)将 64 位块放入正确的顺序。看看 gcc 如何使用 vpackuswb ymm7, ymm5, ymm6 / vpermq ymm8, ymm7, 0xD8 自动矢量化标量基线版本。

对于 AVX512F,此技巧不再有效,因为必须对齐 64B 负载才能保持在单个 64B 高速缓存行内。但是对于 AVX512,可以使用不同的 shuffle,并且 ALU uop 吞吐量更宝贵(在 Skylake-AVX512 上,端口 1 关闭,而 512b uop 正在运行)。所以v = load+shift -> __m256i packed = _mm512_cvtepi16_epi8(v) 可能工作得很好,即使它只做 256b 存储。

正确的选择可能取决于您的 src 和 dst 是否通常是 64B 对齐的。 KNL 没有 AVX512BW,所以这可能只适用于 Skylake-AVX512。

【讨论】:

以上是关于每隔一个字节快速复制到新的内存区域的主要内容,如果未能解决你的问题,请参考以下文章

memcpy不能复制内存重叠区域

MFC-memcpy内存区域复制

安全清除内存并重新分配

memcpy memmove 函数

QVector的内存分配策略

Java内存区域