每隔一个字节快速复制到新的内存区域
Fast copy every second byte to new memory area
我需要一种快速的方法来将每隔一个字节复制到一个新的 malloc 内存区域。
我有一个带有 RGB 数据和每通道 16 位(48 位)的原始图像,我想创建一个每通道 8 位(24 位)的 RGB 图像。
有没有比按字节复制更快的方法?
我对 SSE2 了解不多,但我想 SSE/SSE2.
是可能的
您的 RGB 数据已打包,因此我们实际上不必关心像素边界。问题只是打包数组的每隔一个字节。 (至少在图像的每一行内;如果您使用 16 或 32B 的行步幅,则填充可能不是整数像素。)
这可以使用 SSE2、AVX 或 AVX2 随机播放高效地完成。 (还有 AVX512BW,AVX512VBMI 可能更多,但第一个 AVX512VBMI CPU 可能不会非常高效 vpermt2b
, a 2-input lane-crossing byte shuffle.)
您可以使用 SSSE3 pshufb
来抓取您想要的字节,但它只是一个 1 输入随机播放,会给您 8 个字节的输出。一次存储 8 个字节比一次存储 16 个字节需要更多的存储指令。
(自 Haswell 以来,您还会遇到 Intel CPU 洗牌吞吐量的瓶颈,它只有一个洗牌端口,因此每个时钟一个洗牌吞吐量)。 (您还可以考虑 2xpshufb
+ por
来提供 16B 存储,这在 Ryzen 上可能很好。使用 2 个不同的洗牌控制向量,一个将结果放在低 64b 中,另一个将结果放入高位 64b。参见 ).
相反,使用 _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=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);
}
但是在许多微体系结构中,向量移位吞吐量被限制为每个时钟 1 个(Intel 在 Skylake 之前,AMD Bulldozer/Ryzen)。此外,直到 AVX512 才出现 load+shift 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,可能值得同时使用 v0
和 v1
以及 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,可以使用不同的 shuffle,并且 ALU uop 吞吐量更为宝贵(在 Skylake-AVX512 上,端口 1 关闭而 512b uops 正在运行)。所以 v
= load+shift -> __m256i packed = _mm512_cvtepi16_epi8(v)
可能工作得很好,即使它只做 256b 存储。
正确的选择可能取决于您的 src 和 dst 通常是否是 64B 对齐的。 KNL 没有 AVX512BW,所以这可能只适用于 Skylake-AVX512。
我需要一种快速的方法来将每隔一个字节复制到一个新的 malloc 内存区域。 我有一个带有 RGB 数据和每通道 16 位(48 位)的原始图像,我想创建一个每通道 8 位(24 位)的 RGB 图像。
有没有比按字节复制更快的方法? 我对 SSE2 了解不多,但我想 SSE/SSE2.
是可能的您的 RGB 数据已打包,因此我们实际上不必关心像素边界。问题只是打包数组的每隔一个字节。 (至少在图像的每一行内;如果您使用 16 或 32B 的行步幅,则填充可能不是整数像素。)
这可以使用 SSE2、AVX 或 AVX2 随机播放高效地完成。 (还有 AVX512BW,AVX512VBMI 可能更多,但第一个 AVX512VBMI CPU 可能不会非常高效 vpermt2b
, a 2-input lane-crossing byte shuffle.)
您可以使用 SSSE3 pshufb
来抓取您想要的字节,但它只是一个 1 输入随机播放,会给您 8 个字节的输出。一次存储 8 个字节比一次存储 16 个字节需要更多的存储指令。
(自 Haswell 以来,您还会遇到 Intel CPU 洗牌吞吐量的瓶颈,它只有一个洗牌端口,因此每个时钟一个洗牌吞吐量)。 (您还可以考虑 2xpshufb
+ por
来提供 16B 存储,这在 Ryzen 上可能很好。使用 2 个不同的洗牌控制向量,一个将结果放在低 64b 中,另一个将结果放入高位 64b。参见
相反,使用 _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=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);
}
但是在许多微体系结构中,向量移位吞吐量被限制为每个时钟 1 个(Intel 在 Skylake 之前,AMD Bulldozer/Ryzen)。此外,直到 AVX512 才出现 load+shift 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,可能值得同时使用 v0
和 v1
以及 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,可以使用不同的 shuffle,并且 ALU uop 吞吐量更为宝贵(在 Skylake-AVX512 上,端口 1 关闭而 512b uops 正在运行)。所以 v
= load+shift -> __m256i packed = _mm512_cvtepi16_epi8(v)
可能工作得很好,即使它只做 256b 存储。
正确的选择可能取决于您的 src 和 dst 通常是否是 64B 对齐的。 KNL 没有 AVX512BW,所以这可能只适用于 Skylake-AVX512。