如何有效地将逐位运算应用于(大)压缩位向量

How to effectively apply bitwise operation to (large) packed bit vectors?

本文关键字:向量 压缩 应用于 运算 有效地      更新时间:2023-10-16

我想实现

void bitwise_and(
char*       __restrict__  result,
const char* __restrict__  lhs,
const char* __restrict__  rhs,
size_t                    length);

或者可以是bitwise_or()bitwise_xor()或任何其它按位操作。显然,这与算法无关,只是实现细节——对齐、从内存加载尽可能大的元素、缓存感知、使用SIMD指令等。

我确信它有(不止一个)快速的现有实现,但我想大多数库实现都需要一些花哨的容器,例如std::bitsetboost::dynamic_bit_set,但我不想花时间构建其中的一个。

那么我…从现有库复制粘贴吗?找到一个可以用一个漂亮的对象"包装"内存中的原始压缩位数组的库吗?无论如何,推出我自己的实现?

注意:

  • 我最感兴趣的是C++代码,但我当然不介意简单的C方法
  • 显然,复制输入数组是不可能的——这可能会使执行时间增加一倍
  • 我有意不使用逐位运算符模板,以防对OR或AND等进行特定优化
  • 同时讨论多个向量上的运算的加分,例如V_out=V_1位和V_2位和V_3等
  • 我注意到这篇比较库实现的文章,但它是5年前的文章。我不能问使用哪个库,因为我想这会违反SO政策
  • 如果它对您有帮助,假设它是uint64_ts,而不是chars(这并不重要——如果字符数组未对齐,我们可以分别处理标题和尾部字符)

这个答案将假设您想要最快的方式,并且乐于使用特定于平台的东西。优化编译器可能能够从普通C中生成与下面类似的代码,但根据我在一些编译器中的经验,像这样特定的代码仍然是最好的手工编写。

显然,与所有优化任务一样,从不认为任何事情都是好的/坏的,并测量、测量、测量。

如果你能用至少SSE3将你的体系结构锁定到x86,你会这样做:

void bitwise_and(
char*       result,
const char* lhs,
const char* rhs,
size_t      length)
{
while(length >= 16)
{
// Load in 16byte registers
auto lhsReg = _mm_loadu_si128((__m128i*)lhs);
auto rhsReg = _mm_loadu_si128((__m128i*)rhs);
// do the op
auto res = _mm_and_si128(lhsReg, rhsReg);
// save off again
_mm_storeu_si128((__m128i*)result, res);
// book keeping
length -= 16;
result += 16;
lhs += 16;
rhs += 16;
}
// do the tail end. Assuming that the array is large the
// most that the following code can be run is 15 times so I'm not
// bothering to optimise. You could do it in 64 bit then 32 bit
// then 16 bit then char chunks if you wanted...
while (length)
{
*result = *lhs & *rhs;
length -= 1;
result += 1;
lhs += 1;
rhs += 1;
}
}

这编译为每16字节大约10asm指令(+剩余部分的更改和一点开销)。

做这样的内部处理(相对于手工编译的asm)的好处是,编译器仍然可以自由地对您所写的内容进行额外的优化(例如循环展开)。它还处理寄存器分配。

如果可以保证数据对齐,则可以保存asm指令(使用_mm_load_si128,编译器将足够聪明,可以避免第二次加载,并将其用作"pand"的直接mem操作数。

如果你能保证AVX2+,那么你可以使用256位版本,每32字节处理10asm指令。

手臂上有类似的近地天体指令。

如果要执行多个操作,只需在中间添加相关的内在操作,它将每16个字节添加1条asm指令。

我敢肯定,有了一个不错的处理器,你就不需要任何额外的缓存控制了。

不要这样做。个人操作将看起来很棒,时尚的asm,不错的性能。。但他们的组合会很糟糕。你不能把这个抽象化,尽管它看起来很好看。这些内核的算术强度几乎是最差的(唯一更差的是执行no算术,例如直接复制),并且在高级别上组合它们将保留这种糟糕的特性。在一系列操作中,每个操作都使用上一个操作的结果,结果会在很久之后(在下一个内核中)再次写入和读取,即使高级流可以被转置,这样"下一个操作"所需的结果就在寄存器中。此外,如果同一个参数在表达式树中出现两次(而不是两次都作为一个操作的操作数),它们将被流式传输两次,而不是将数据重复用于两个操作。

它没有那种"看看所有这些可爱的抽象"的温暖模糊的感觉,但你应该做的是在高水平上找出你是如何组合向量的,然后试着把它分解成从性能角度看有意义的部分。在某些情况下,这可能意味着要做一个又大又丑又乱的环,让人们在潜水前多喝一杯咖啡,这太糟糕了。如果你想要表现,你通常不得不牺牲其他东西。通常情况下,这并没有那么糟糕,这可能只是意味着你有一个循环,其中有一个由内部函数组成的表达式,而不是一个向量运算的表达式,每个向量运算都有一个环路。