将char8的大型c数组转换为short16的最快方法是什么

What is the fastest way to convert a large c-array of char8 to short16?

本文关键字:short16 方法 是什么 转换 char8 大型 数组      更新时间:2023-10-16

我的原始数据是一堆长度>100000的(无符号)字符(8位)的c数组。我想把它们加在一起(矢量加法),遵循下面代码中的规则。结果:c—(无符号)短(16位)的数组。

我已经阅读了所有的SSE和AVX/AVX2,但只有一个类似的调用多个256位的2个寄存器。前4个32位将相乘,每对32位的结果是64位将适合256寄存器。(_mm256-mul_epi32,_mm256-ul_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]);
}

感谢

添加到@wim答案(这是一个好的答案)并考虑@Bathsheba评论,值得信任编译器,但也要检查编译器的输出,以学习如何做到这一点,并检查它是否符合您的要求。通过godbolt(针对msvc、gcc和clang)运行稍微修改过的代码版本会给出一些不完美的答案。

如果您将自己限制在SSE2,并且此答案假设(以及我测试的内容)低于SSE2,则情况尤其如此

所有编译器都对代码进行矢量化和展开,并使用punpcklbwuint8_t的"解包"到uint16_t的中,然后运行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中的任何一个都要好。因此,如果你想获得绝对的最后一滴perf(并有一个固定的体系结构),那么像上面这样的东西是可能的然而这是一个非常小的改进,因为编译器已经几乎完美地处理了,所以我实际上建议不要这样做,只相信编译器。

如果您确实认为可以改进编译器,请记住始终进行测试和评测,以确保您确实做到了。

事实上,注释是正确的:编译器可以为您进行矢量化。我对您的代码进行了一些修改,以改进自动向量化。对于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生成的代码有点不同:它加载128位(char)向量,并用vpmovzxbw进行转换。编译器gcc加载256位(char)矢量,并转换高位和低位128位这可能稍微不那么有效。然而,您的问题可能是带宽受限(因为长度>100000)。

您还可以使用内部函数(未测试)对代码进行矢量化:

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);
}
}

这将导致与自动向量化代码类似的结果。

与wim和Mike的精彩答案中提出的手动优化方法相比,让我们快速了解一下一个完全普通的C++实现会给我们带来什么:

std::transform(addon, addon + count, canvas, canvas, std::plus<void>());

在这里试试。您将看到,即使您没有付出任何实际的努力,编译器也已经能够生成非常好的矢量化代码,因为它不能对缓冲区的对齐和大小做出任何假设,而且还存在一些潜在的混叠问题(由于使用了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,甚至适用于带有NEON的ARM,以及你可能想要运行的任何其他指令集。如果你需要你的代码完美到适用于一个特定CPU上的一个特定用例的最后一条指令,那么是的,intrinsic甚至内联程序集可能是最好的选择。但总的来说,我也建议将重点放在编写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>());
}

你得到(-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

这真的还不错…