将大型 char8 c 数组转换为 short16 的最快方法是什么?
What is the fastest way to convert a large c-array of char8 to short16?
我的原始数据是一堆长度 > 1000000 的(无符号)字符(8 位)c 数组。
我想按照下面代码中的规则将它们加在一起(向量加法)。
结果:
(无符号)短(16 位)的 c 数组。
我已经阅读了所有 SSE 和 AVX/AVX2 但只有一个类似的调用
那多个256位的寄存器。前 4 个 32bit 将相乘,每对 32bit 的结果是一个 64bit 将放入 256 寄存器。( _mm256_mul_epi32, _mm256_mul_epu32)
Firgure
https://www.codeproject.com/Articles/874396/Crunching-Numbers-with-AVX-and-AVX
示例代码:
static inline void adder(uint16_t *canvas, uint8_t *addon, uint64_t count)
{
for (uint64_t i=0; i<count; i++)
canvas[i] += static_cast<uint16_t>(addon[i]);
}
谢谢
确实评论是对的:编译器可以为你做矢量化。
我稍微修改了您的代码以改进自动矢量化。
使用 gcc -O3 -march=haswell -std=c++14
(gcc 版本 8.2),以下代码:
#include <cstdint>
#include <immintrin.h>
void cvt_uint8_int16(uint16_t * __restrict__ canvas, uint8_t * __restrict__ addon, int64_t count) {
int64_t i;
/* If you know that n is always a multiple of 32 then insert */
/* n = n & 0xFFFFFFFFFFFFFFE0u; */
/* This leads to cleaner code. Now assume n is a multiple of 32: */
count = count & 0xFFFFFFFFFFFFFFE0u;
for (i = 0; i < count; i++){
canvas[i] += static_cast<uint16_t>(addon[i]);
}
}
编译为:
cvt_uint8_int16(unsigned short*, unsigned char*, long):
and rdx, -32
jle .L5
add rdx, rsi
.L3:
vmovdqu ymm2, YMMWORD PTR [rsi]
add rsi, 32
add rdi, 64
vextracti128 xmm1, ymm2, 0x1
vpmovzxbw ymm0, xmm2
vpaddw ymm0, ymm0, YMMWORD PTR [rdi-64]
vpmovzxbw ymm1, xmm1
vpaddw ymm1, ymm1, YMMWORD PTR [rdi-32]
vmovdqu YMMWORD PTR [rdi-64], ymm0
vmovdqu YMMWORD PTR [rdi-32], ymm1
cmp rdx, rsi
jne .L3
vzeroupper
.L5:
编译器 Clang 生成的 code 有点不同:它加载 128 位(字符)向量并用 vpmovzxbw
转换它们。
编译器gcc加载256位(char)向量并转换上下128位
分开,这可能效率稍低。
尽管如此,您的问题可能还是带宽受限(因为长度 > 1000000)。
您还可以使用内部函数对代码进行矢量化(未测试):
void cvt_uint8_int16_with_intrinsics(uint16_t * __restrict__ canvas, uint8_t * __restrict__ addon, int64_t count) {
int64_t i;
/* Assume n is a multiple of 16 */
for (i = 0; i < count; i=i+16){
__m128i x = _mm_loadu_si128((__m128i*)&addon[i]);
__m256i y = _mm256_loadu_si256((__m256i*)&canvas[i]);
__m256i x_u16 = _mm256_cvtepu8_epi16(x);
__m256i sum = _mm256_add_epi16(y, x_u16);
_mm256_storeu_si256((__m256i*)&canvas[i], sum);
}
}
这导致与自动矢量化代码类似 results。
添加到@wim 答案(这是一个好的答案)并考虑@Bathsheba 评论,值得信任编译器但是 还检查您的编译器输出的内容,以了解如何执行此操作并检查它是否按照您的要求进行操作。 运行 通过 godbolt(对于 msvc、gcc 和 clang)对您的代码稍加修改的版本给出了一些不完美的答案。
如果您将自己限制在 SSE2 及低于此答案假定的值(以及我测试的内容),则尤其如此
所有编译器都对代码进行矢量化和展开,并使用 punpcklbw
将 'unpack' uint8_t
转换为 uint16_t
,然后 运行 a SIMD 添加和保存。这很好。但是,MSVC 往往会在内部循环中出现不必要的溢出,而 clang 仅使用 punpcklbw
而不是 punpckhbw
,这意味着它会加载源数据两次。 GCC 正确处理了 SIMD 部分,但循环约束的开销更高。
所以理论上,如果您想改进这些版本,您可以使用类似于以下内容的内在函数来推出自己的版本:
static inline void adder2(uint16_t *canvas, uint8_t *addon, uint64_t count)
{
uint64_t count32 = (count / 32) * 32;
__m128i zero = _mm_set_epi32(0, 0, 0, 0);
uint64_t i = 0;
for (; i < count32; i+= 32)
{
uint8_t* addonAddress = (addon + i);
// Load data 32 bytes at a time and widen the input
// to `uint16_t`'sinto 4 temp xmm reigsters.
__m128i input = _mm_loadu_si128((__m128i*)(addonAddress + 0));
__m128i temp1 = _mm_unpacklo_epi8(input, zero);
__m128i temp2 = _mm_unpackhi_epi8(input, zero);
__m128i input2 = _mm_loadu_si128((__m128i*)(addonAddress + 16));
__m128i temp3 = _mm_unpacklo_epi8(input2, zero);
__m128i temp4 = _mm_unpackhi_epi8(input2, zero);
// Load data we need to update
uint16_t* canvasAddress = (canvas + i);
__m128i canvas1 = _mm_loadu_si128((__m128i*)(canvasAddress + 0));
__m128i canvas2 = _mm_loadu_si128((__m128i*)(canvasAddress + 8));
__m128i canvas3 = _mm_loadu_si128((__m128i*)(canvasAddress + 16));
__m128i canvas4 = _mm_loadu_si128((__m128i*)(canvasAddress + 24));
// Update the values
__m128i output1 = _mm_add_epi16(canvas1, temp1);
__m128i output2 = _mm_add_epi16(canvas2, temp2);
__m128i output3 = _mm_add_epi16(canvas3, temp3);
__m128i output4 = _mm_add_epi16(canvas4, temp4);
// Store the values
_mm_storeu_si128((__m128i*)(canvasAddress + 0), output1);
_mm_storeu_si128((__m128i*)(canvasAddress + 8), output2);
_mm_storeu_si128((__m128i*)(canvasAddress + 16), output3);
_mm_storeu_si128((__m128i*)(canvasAddress + 24), output4);
}
// Mop up
for (; i<count; i++)
canvas[i] += static_cast<uint16_t>(addon[i]);
}
为此检查输出,它比 gcc/clang/msvc 中的任何一个都要好。所以如果你想获得绝对的最后一滴性能(并且有一个固定的架构)那么像上面这样的东西是可能的。 但是这是一个非常小的改进,因为编译器已经几乎完美地处理了这个问题,所以我实际上建议不要这样做而只信任编译器。
如果您确实认为可以改进编译器,请记住始终进行测试和分析以确保您确实可以。
与 wim 和 Mike 的出色答案中提供的手动优化方法相比,我们还可以快速了解一下完全普通的 C++ 实现会给我们带来什么:
std::transform(addon, addon + count, canvas, canvas, std::plus<void>());
Try it out here。您会发现,即使您没有付出任何实际努力,编译器也已经能够生成相当不错的矢量化代码,因为它无法对缓冲区的对齐方式和大小做出任何假设,并且还存在一些潜在的别名问题(由于 uint8_t
的使用,不幸的是,它迫使编译器假定指针可能别名指向任何其他对象)。另外,请注意,代码基本上与您从 C 风格实现中获得的代码相同(取决于编译器,C++ 版本多了一些指令或少了一些指令)
void f(uint16_t* canvas, const uint8_t* addon, size_t count)
{
for (size_t i = 0; i < count; ++i)
canvas[i] += addon[i];
}
但是,通用 C++ 解决方案适用于不同种类的容器和元素类型的任意组合,只要可以添加元素类型即可。因此——正如其他答案中也指出的那样——虽然通过手动优化肯定可以获得稍微更有效的实现,但仅通过编写纯 C++ 代码(如果做得正确)就可以走很长一段路。在求助于手动编写 SSE 内在函数之前,请考虑通用 C++ 解决方案更灵活、更易于维护,尤其是更具可移植性。通过简单地切换目标架构开关,您可以让它不仅为 SSE 生成质量相似的代码,还为 AVX,甚至 ARM 和 NEON 以及您可能碰巧想要 运行 的任何其他指令集生成类似质量的代码。如果您需要您的代码在一个特定 CPU 上的一个特定用例的最后一条指令都是完美的,那么是的,内在函数甚至内联汇编可能是可行的方法。但总的来说,我还建议将重点放在编写 C++ 代码上,使编译器能够并引导编译器生成所需的程序集,而不是自己生成程序集。例如,通过使用(非标准但通常可用的)限制限定符并借用让编译器知道您的 count
始终是 32
的倍数的技巧
void f(std::uint16_t* __restrict__ canvas, const std::uint8_t* __restrict__ addon, std::size_t count)
{
assert(count % 32 == 0);
count = count & -32;
std::transform(addon, addon + count, canvas, canvas, std::plus<void>());
}
you get (-std=c++17 -DNDEBUG -O3 -mavx
)
f(unsigned short*, unsigned char const*, unsigned long):
and rdx, -32
je .LBB0_3
xor eax, eax
.LBB0_2: # =>This Inner Loop Header: Depth=1
vpmovzxbw xmm0, qword ptr [rsi + rax] # xmm0 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpmovzxbw xmm1, qword ptr [rsi + rax + 8] # xmm1 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpmovzxbw xmm2, qword ptr [rsi + rax + 16] # xmm2 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpmovzxbw xmm3, qword ptr [rsi + rax + 24] # xmm3 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpaddw xmm0, xmm0, xmmword ptr [rdi + 2*rax]
vpaddw xmm1, xmm1, xmmword ptr [rdi + 2*rax + 16]
vpaddw xmm2, xmm2, xmmword ptr [rdi + 2*rax + 32]
vpaddw xmm3, xmm3, xmmword ptr [rdi + 2*rax + 48]
vmovdqu xmmword ptr [rdi + 2*rax], xmm0
vmovdqu xmmword ptr [rdi + 2*rax + 16], xmm1
vmovdqu xmmword ptr [rdi + 2*rax + 32], xmm2
vmovdqu xmmword ptr [rdi + 2*rax + 48], xmm3
add rax, 32
cmp rdx, rax
jne .LBB0_2
.LBB0_3:
ret
真的不错…
我的原始数据是一堆长度 > 1000000 的(无符号)字符(8 位)c 数组。 我想按照下面代码中的规则将它们加在一起(向量加法)。 结果: (无符号)短(16 位)的 c 数组。
我已经阅读了所有 SSE 和 AVX/AVX2 但只有一个类似的调用 那多个256位的寄存器。前 4 个 32bit 将相乘,每对 32bit 的结果是一个 64bit 将放入 256 寄存器。( _mm256_mul_epi32, _mm256_mul_epu32)
Firgure
https://www.codeproject.com/Articles/874396/Crunching-Numbers-with-AVX-and-AVX
示例代码:
static inline void adder(uint16_t *canvas, uint8_t *addon, uint64_t count)
{
for (uint64_t i=0; i<count; i++)
canvas[i] += static_cast<uint16_t>(addon[i]);
}
谢谢
确实评论是对的:编译器可以为你做矢量化。
我稍微修改了您的代码以改进自动矢量化。
使用 gcc -O3 -march=haswell -std=c++14
(gcc 版本 8.2),以下代码:
#include <cstdint>
#include <immintrin.h>
void cvt_uint8_int16(uint16_t * __restrict__ canvas, uint8_t * __restrict__ addon, int64_t count) {
int64_t i;
/* If you know that n is always a multiple of 32 then insert */
/* n = n & 0xFFFFFFFFFFFFFFE0u; */
/* This leads to cleaner code. Now assume n is a multiple of 32: */
count = count & 0xFFFFFFFFFFFFFFE0u;
for (i = 0; i < count; i++){
canvas[i] += static_cast<uint16_t>(addon[i]);
}
}
编译为:
cvt_uint8_int16(unsigned short*, unsigned char*, long):
and rdx, -32
jle .L5
add rdx, rsi
.L3:
vmovdqu ymm2, YMMWORD PTR [rsi]
add rsi, 32
add rdi, 64
vextracti128 xmm1, ymm2, 0x1
vpmovzxbw ymm0, xmm2
vpaddw ymm0, ymm0, YMMWORD PTR [rdi-64]
vpmovzxbw ymm1, xmm1
vpaddw ymm1, ymm1, YMMWORD PTR [rdi-32]
vmovdqu YMMWORD PTR [rdi-64], ymm0
vmovdqu YMMWORD PTR [rdi-32], ymm1
cmp rdx, rsi
jne .L3
vzeroupper
.L5:
编译器 Clang 生成的 code 有点不同:它加载 128 位(字符)向量并用 vpmovzxbw
转换它们。
编译器gcc加载256位(char)向量并转换上下128位
分开,这可能效率稍低。
尽管如此,您的问题可能还是带宽受限(因为长度 > 1000000)。
您还可以使用内部函数对代码进行矢量化(未测试):
void cvt_uint8_int16_with_intrinsics(uint16_t * __restrict__ canvas, uint8_t * __restrict__ addon, int64_t count) {
int64_t i;
/* Assume n is a multiple of 16 */
for (i = 0; i < count; i=i+16){
__m128i x = _mm_loadu_si128((__m128i*)&addon[i]);
__m256i y = _mm256_loadu_si256((__m256i*)&canvas[i]);
__m256i x_u16 = _mm256_cvtepu8_epi16(x);
__m256i sum = _mm256_add_epi16(y, x_u16);
_mm256_storeu_si256((__m256i*)&canvas[i], sum);
}
}
这导致与自动矢量化代码类似 results。
添加到@wim 答案(这是一个好的答案)并考虑@Bathsheba 评论,值得信任编译器但是 还检查您的编译器输出的内容,以了解如何执行此操作并检查它是否按照您的要求进行操作。 运行 通过 godbolt(对于 msvc、gcc 和 clang)对您的代码稍加修改的版本给出了一些不完美的答案。
如果您将自己限制在 SSE2 及低于此答案假定的值(以及我测试的内容),则尤其如此
所有编译器都对代码进行矢量化和展开,并使用 punpcklbw
将 'unpack' uint8_t
转换为 uint16_t
,然后 运行 a SIMD 添加和保存。这很好。但是,MSVC 往往会在内部循环中出现不必要的溢出,而 clang 仅使用 punpcklbw
而不是 punpckhbw
,这意味着它会加载源数据两次。 GCC 正确处理了 SIMD 部分,但循环约束的开销更高。
所以理论上,如果您想改进这些版本,您可以使用类似于以下内容的内在函数来推出自己的版本:
static inline void adder2(uint16_t *canvas, uint8_t *addon, uint64_t count)
{
uint64_t count32 = (count / 32) * 32;
__m128i zero = _mm_set_epi32(0, 0, 0, 0);
uint64_t i = 0;
for (; i < count32; i+= 32)
{
uint8_t* addonAddress = (addon + i);
// Load data 32 bytes at a time and widen the input
// to `uint16_t`'sinto 4 temp xmm reigsters.
__m128i input = _mm_loadu_si128((__m128i*)(addonAddress + 0));
__m128i temp1 = _mm_unpacklo_epi8(input, zero);
__m128i temp2 = _mm_unpackhi_epi8(input, zero);
__m128i input2 = _mm_loadu_si128((__m128i*)(addonAddress + 16));
__m128i temp3 = _mm_unpacklo_epi8(input2, zero);
__m128i temp4 = _mm_unpackhi_epi8(input2, zero);
// Load data we need to update
uint16_t* canvasAddress = (canvas + i);
__m128i canvas1 = _mm_loadu_si128((__m128i*)(canvasAddress + 0));
__m128i canvas2 = _mm_loadu_si128((__m128i*)(canvasAddress + 8));
__m128i canvas3 = _mm_loadu_si128((__m128i*)(canvasAddress + 16));
__m128i canvas4 = _mm_loadu_si128((__m128i*)(canvasAddress + 24));
// Update the values
__m128i output1 = _mm_add_epi16(canvas1, temp1);
__m128i output2 = _mm_add_epi16(canvas2, temp2);
__m128i output3 = _mm_add_epi16(canvas3, temp3);
__m128i output4 = _mm_add_epi16(canvas4, temp4);
// Store the values
_mm_storeu_si128((__m128i*)(canvasAddress + 0), output1);
_mm_storeu_si128((__m128i*)(canvasAddress + 8), output2);
_mm_storeu_si128((__m128i*)(canvasAddress + 16), output3);
_mm_storeu_si128((__m128i*)(canvasAddress + 24), output4);
}
// Mop up
for (; i<count; i++)
canvas[i] += static_cast<uint16_t>(addon[i]);
}
为此检查输出,它比 gcc/clang/msvc 中的任何一个都要好。所以如果你想获得绝对的最后一滴性能(并且有一个固定的架构)那么像上面这样的东西是可能的。 但是这是一个非常小的改进,因为编译器已经几乎完美地处理了这个问题,所以我实际上建议不要这样做而只信任编译器。
如果您确实认为可以改进编译器,请记住始终进行测试和分析以确保您确实可以。
与 wim 和 Mike 的出色答案中提供的手动优化方法相比,我们还可以快速了解一下完全普通的 C++ 实现会给我们带来什么:
std::transform(addon, addon + count, canvas, canvas, std::plus<void>());
Try it out here。您会发现,即使您没有付出任何实际努力,编译器也已经能够生成相当不错的矢量化代码,因为它无法对缓冲区的对齐方式和大小做出任何假设,并且还存在一些潜在的别名问题(由于 uint8_t
的使用,不幸的是,它迫使编译器假定指针可能别名指向任何其他对象)。另外,请注意,代码基本上与您从 C 风格实现中获得的代码相同(取决于编译器,C++ 版本多了一些指令或少了一些指令)
void f(uint16_t* canvas, const uint8_t* addon, size_t count)
{
for (size_t i = 0; i < count; ++i)
canvas[i] += addon[i];
}
但是,通用 C++ 解决方案适用于不同种类的容器和元素类型的任意组合,只要可以添加元素类型即可。因此——正如其他答案中也指出的那样——虽然通过手动优化肯定可以获得稍微更有效的实现,但仅通过编写纯 C++ 代码(如果做得正确)就可以走很长一段路。在求助于手动编写 SSE 内在函数之前,请考虑通用 C++ 解决方案更灵活、更易于维护,尤其是更具可移植性。通过简单地切换目标架构开关,您可以让它不仅为 SSE 生成质量相似的代码,还为 AVX,甚至 ARM 和 NEON 以及您可能碰巧想要 运行 的任何其他指令集生成类似质量的代码。如果您需要您的代码在一个特定 CPU 上的一个特定用例的最后一条指令都是完美的,那么是的,内在函数甚至内联汇编可能是可行的方法。但总的来说,我还建议将重点放在编写 C++ 代码上,使编译器能够并引导编译器生成所需的程序集,而不是自己生成程序集。例如,通过使用(非标准但通常可用的)限制限定符并借用让编译器知道您的 count
始终是 32
void f(std::uint16_t* __restrict__ canvas, const std::uint8_t* __restrict__ addon, std::size_t count)
{
assert(count % 32 == 0);
count = count & -32;
std::transform(addon, addon + count, canvas, canvas, std::plus<void>());
}
you get (-std=c++17 -DNDEBUG -O3 -mavx
)
f(unsigned short*, unsigned char const*, unsigned long):
and rdx, -32
je .LBB0_3
xor eax, eax
.LBB0_2: # =>This Inner Loop Header: Depth=1
vpmovzxbw xmm0, qword ptr [rsi + rax] # xmm0 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpmovzxbw xmm1, qword ptr [rsi + rax + 8] # xmm1 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpmovzxbw xmm2, qword ptr [rsi + rax + 16] # xmm2 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpmovzxbw xmm3, qword ptr [rsi + rax + 24] # xmm3 = mem[0],zero,mem[1],zero,mem[2],zero,mem[3],zero,mem[4],zero,mem[5],zero,mem[6],zero,mem[7],zero
vpaddw xmm0, xmm0, xmmword ptr [rdi + 2*rax]
vpaddw xmm1, xmm1, xmmword ptr [rdi + 2*rax + 16]
vpaddw xmm2, xmm2, xmmword ptr [rdi + 2*rax + 32]
vpaddw xmm3, xmm3, xmmword ptr [rdi + 2*rax + 48]
vmovdqu xmmword ptr [rdi + 2*rax], xmm0
vmovdqu xmmword ptr [rdi + 2*rax + 16], xmm1
vmovdqu xmmword ptr [rdi + 2*rax + 32], xmm2
vmovdqu xmmword ptr [rdi + 2*rax + 48], xmm3
add rax, 32
cmp rdx, rax
jne .LBB0_2
.LBB0_3:
ret
真的不错…