将数组简化为char的最快方法

时间:2016-03-31 21:30:00

标签: c++ performance

我必须每秒处理大约2000个,100个元素数组。数组作为短路来到我身边,带有高位数据,需要移位并转换为字符。这是否有效,或者是否有更快的方法来执行此操作? (我必须跳过2个值)

for(int i = 0; i < 48; i++)
{
    a[i] = (char)(b[i] >> 8);
    a[i+48] = (char)(b[i+50] >> 8);
}

2 个答案:

答案 0 :(得分:2)

即使移位和按位操作很快,您也可以尝试将short数组作为char指针处理,而在注释中建议使用。它是按标准允许的,并且对于常见的体系结构可以实现预期的效果 - 留下字节序问题。

所以你可以先尝试确定你的字节序:

bool isBigEndian() {
    short i = 1;   // sets only lowest order bit
    char *ix = reinterpret_cast<char *>(&i);
    return (*ix == 0);   // will be 1 if little endian
}

您的循环现在变为:

int shft = isBigEndian()? 0 : 1;
char * pb = reinterpret_cast<char *>(b);
for(int i = 0; i < 48; i++)
{
    a[i] = pt[2 * i + shft];
    a[i+48] = pt[2 * i + 50 + shft];
}

但是,与低级别优化一样,这必须使用将在生产代码中使用的编译器和编译器选项进行基准测试。

答案 1 :(得分:0)

您可以在这些数组周围放置一个包装类,因此以实际的顺序访问包装器元素的代码将访问底层内存的每个其他字节。

但这可能会打败自动矢量化。除此之外,让所有读取a的代码实际读取b并将其指针增加2而不是1,不应该改变成本。

但是,跳过的两个元素是一个问题。让operator[]if (i>=48) i+=2可能会扼杀这个想法。 memmove通常 比一次存储一个字节更快,因此您可以考虑使用memmove创建一个连续的短路数组,即使看起来似乎也可以索引傻傻地复制而不以更好的格式存储。

诀窍是编写一个完全优化的包装器,在数组的循环中没有额外的指令。这在x86上是可行的,其中缩放索引在asm指令中的正常有效地址中可用,因此如果编译器理解正在进行的操作,它可以使代码具有同样高效的效果。

拥有short的数组确实占用了两倍的内存,因此缓存效果可能很重要。

这完全取决于你需要对字节数组做什么。

如果您确实需要转换,请使用SIMD

对于x86目标,您可以使用SIMD向量获得大幅加速,而不是一次循环一个char。对于您关心的其他编译目标,您可以编写类似的特殊版本。例如,我认为ARM NEON具有类似的改组功能。

在编写特定于平台的版本时,您还可以在该平台上进行所有endian和unaligned-access假设。

#ifdef __SSE2__  // will be true for all x86-64 builds and most i386 builds
#include <immintrin.h>
static __m128i pack2(const short *p) {
    __m128i lo = _mm_loadu_si128((__m128i*)p);
    __m128i hi = _mm_loadu_si128((__m128i*)(p + 8));
    lo = _mm_srli_epi16(lo, 8);         // logical shift, not arithmetic, because we need the high byte to be zero
    hi = _mm_srli_epi16(hi, 8);
    return _mm_packus_epi16(lo, hi);    // treats input as signed, saturates to unsigned 0x0 .. 0xff range
}
#endif // SSE2

void conv(char *a, const short *b) {
#ifdef __SSE2__
    for(int i = 0; i < 48; i+=16) {
        __m128i low  = pack2(b+i);
        _mm_storeu_si128((__m128i *)(a+i), low);
        __m128i high = pack2(b+i + 50);
        _mm_storeu_si128((__m128i *)(a+i + 48), high);
    }
#else
    /*******   Fallback C version  *******/
    for(int i = 0; i < 48; i++) {
        a[i] = (char)(b[i] >> 8);
        a[i+48] = (char)(b[i+50] >> 8);
    }
#endif
}

As you can see on the Godbolt Compiler Explorer,gcc完全展开循环,因为一次存储16B时只需几次迭代。

这应该可以执行,但是在预售之前,Skylake会在商店之前转移short s的两个向量。 Haswell每个时钟只能维持一个psrli。 (当班次计数立即生效时,Skylake可以维持每0.5c一个。参见Agner Fog的指南和insn表,标签wiki上的链接。)

(__m128i*)(1 + (char*)p)加载可能会得到更好的结果,因此我们想要的字节已经在每个16位元素的低半部分。我们仍然需要使用_mm_and_si128而不是移位来屏蔽每个元素的高半部分,但是PAND可以在任何向量执行端口上运行,因此每个时钟吞吐量有三个。

更重要的是,使用AVX,它可以与未对齐的负载结合使用。例如vpand xmm0, xmm5, [rsi],其中xmm5是_mm_set1_epi16(0x00ff)的掩码,[rsi]拥有2*i + 1 + (char*)b。融合域uop吞吐量可能会成为一个问题,就像具有大量加载/存储以及计算的代码一样。

未对齐访问比对齐访问稍慢,但至少有一半的向量访问将是未对齐的(因为跳过两个短路意味着跳过4B)。在英特尔SnB系列CPU上,我认为在12:4拆分中,以15:1拆分在高速缓存行边界上拆分的负载速度较慢。 (不分裂的情况肯定更快。)如果b是16B对齐的,那么它就值得根据移位版本测试掩码版本。

我没有为此版本编写完整的代码,因为除非采取特殊预防措施,否则最终会在b结束后读取一个字节。如果您确保b具有某种填充,那么这样就可以了,因此它不会直接到达内存页的末尾。

AVX2

使用AVX2,vpackuswb ymm在两个独立的通道中运行。 IDK,如果在256b向量上进行加载和掩码(或移位),然后在256b向量的两半上使用vextracti128和128b包,可以获得任何东西。

或者也许在两个向量之间进行256b打包然后用vpermq_mm256_permute4x64_epi64)来解决问题:

lo = _mm256_loadu(b..);  // { b[15..8]  | b[7..0] }
hi =                     // { b[31..24] | b[23..16] }

// mask or shift
__m256i packed = _mm256_packus_epi16(lo, hi);    // [ a31..24  a15..8 | a23..16  a7..0 ]
packed = _mm256_permute4x64_epi64(packed, _MM_SHUFFLE(3, 1, 2, 0));

当然,请使用C版本中的任何可移植优化。例如Serge Ballesta建议在从机器的字节序中找出它们的位置之后复制所需的字节。 (最好在编译时检查GNU C's __BYTE_ORDER__ macro