【问题标题】:How to set bits of a bit vector efficiently in parallel?如何有效地并行设置位向量的位?
【发布时间】:2018-01-15 07:14:06
【问题描述】:

考虑其中包含N 位的位向量(N 很大)和M 数字数组(M 中等,通常远小于N),每个都在@987654327 范围内@ 表示向量的哪一位必须设置为1。后一个数组未排序。位向量只是一个整数数组,特别是 __m256i,其中 256 位被打包到每个 __m256i 结构中。

如何将这项工作高效地拆分到多个线程?

首选语言是 C++(MSVC++2017 工具集 v141),汇编也很棒。首选 CPU 是 x86_64 (内在是好的)。需要 AVX2,如果有任何好处的话。

【问题讨论】:

  • 嗯...似乎主要是内存带宽的问题。我不确定是否真的有比仅仅以明显的方式做更好的方法。一种方法可能是先对数组进行排序,以便您可以按顺序设置位,从而提高缓存效率。
  • M 已经排序了吗?如果没有,您几乎肯定会希望针对单个线程进行优化。
  • 用一些算法用典型数据测量性能...向我们展示你的代码。通过位向量,您是指std::bitset 还是std::vector&lt;bool&gt; 或其他东西。另请参阅:How can std::bitset be faster than std::vector<bool>?。如果您的数据尚未排序且非常大,则很难优化。 还要避免过早优化。只有当你能证明显而易见的方法是不够的。对于小数据量,线程或复杂算法的开销会使代码变慢。
  • 在 x86 上,锁定或将要锁定整个缓存行,因此使用字节而不是 qwords 不会有任何好处。
  • 如果数组没有排序,考虑使用bts。然后你就不必做任何内存地址算术或位移;直接使用位号即可。

标签: c++ algorithm parallel-processing x86 bit-manipulation


【解决方案1】:

集合中涉及到几个操作(A,B = 集合,X = 集合中的元素):

Set operation           Instruction
---------------------------------------------
Intersection of A,B     A and B
Union of A,B            A or B
Difference of A,B       A xor B
A is subset of B        A and B = B     
A is superset of B      A and B = A       
A <> B                  A xor B <> 0
A = B                   A xor B = 0
X in A                  BT [A],X
Add X to A              BTS [A],X
Subtract X from A       BTC [A],X

鉴于您可以使用布尔运算符来替换集合操作,您可以使用VPXORVPAND 等。
要设置、重置或测试单个位,您只需使用

mov eax,BitPosition
BT [rcx],rax

您可以使用以下代码设置集合是否(等于)空(或其他)

vpxor      ymm0,ymm0,ymm0       //ymm0 = 0
//replace the previous instruction with something else if you don't want
//to compare to zero.
vpcmpeqqq  ymm1,ymm0,[mem]      //compare mem qwords to 0 per qword
vpslldq    ymm2,ymm1,8          //line up qw0 and 1 + qw2 + 3
vpand      ymm2,ymm1,ymm2       //combine qw0/1 and qw2/3
vpsrldq    ymm1,ymm2,16         //line up qw0/1 and qw2/3
vpand      ymm1,ymm1,ymm2       //combine qw0123, all in the lower 64 bits.
//if the set is empty, all bits in ymm1 will be 1.
//if its not, all bits in ymm1 will be 0.     

(我确信可以使用 blend/gather 等指令改进此代码) 从这里您可以扩展到更大的集合或其他操作。

请注意,btbtcbts 的内存操作数不限于 64 位。
以下将正常工作。

mov eax,1023
bts [rcx],rax   //set 1024st element (first element is 0).

【讨论】:

  • 问题是在并行(多线程)中有效地将位设置为1,给定一组位索引以设置为1(并保持其他位不变)。跨度>
  • and's 和 or's 是你的朋友,如上所述
【解决方案2】:

假设您想在T 线程之间分配这项工作。这是一个非常有趣的问题,因为它不能通过分区实现简单的并行化,并且各种解决方案可能适用于不同大小的 NM

完全并发基线

您可以简单地将数组M 划分为T 分区,并让每个线程在其自己的M 分区上工作,并使用共享N。主要问题是由于M 未排序,所有线程都可以访问N 的任何元素,因此相互踩踏。为避免这种情况,您必须对共享的N 数组的每次修改使用原子操作,例如std::atomic::fetch_or,或者提出一些锁定方案。这两种方法都可能会降低性能(即,使用原子操作设置位可能比等效的单线程代码慢一个数量级)。

让我们看看可能更快的想法。

私人 N

避免“共享 N”问题(需要对 N 的所有突变进行原子操作)的一个相对明显的想法是简单地给每个 T 一个 N 的私有副本,并在最后通过 or 合并它们。

不幸的是,这个解决方案是O(N) + O(M/T),而原来的单线程解决方案是O(M),而上面的“原子”解决方案类似于O(M/T)4。因为我们知道N &gt;&gt; M 在这种情况下这可能是一个糟糕的权衡。不过,值得注意的是,每个术语中的隐藏常量非常不同:O(N) 术语,来自合并步骤0 可以使用 256 位宽的 vpor 指令,这意味着吞吐量接近 200-500 位/周期(如果缓存),而位设置步骤 O(M/T) 我估计接近 1 位/周期。因此,即使N 的大小是M 的大小的 10 或 100 倍,这种方法肯定是中等 T 的最佳方法。

M 的分区

这里的基本思想是对M 中的索引进行分区,这样每个工作线程就可以在N 数组的不相交部分上工作。如果M 已排序,那将是微不足道的,但事实并非如此,所以...

如果M平滑分布,一个简单的算法会很好地工作,首先将M 的值划分为T 桶,桶的值在@987654349 范围内@。也就是说,将N 划分为T 不相交的区域,然后找到属于每个区域的M 的值。您可以通过为每个线程分配相同大小的M 块,并让它们各自创建T 分区然后逻辑合并1 他们在最后,所以你有MT 分区。

第二步是实际设置所有位:为每个线程分配一个分区T,它可以以“单线程”方式设置位,即不用担心并发更新,因为每个线程都在工作在N2 的不相交分区上。

O(M) 和第二步都与单线程情况相同,因此并行化这一步的开销是第一步。我怀疑第一个的速度从与第二个大致相同的速度到可能慢 2-4 倍,具体取决于实现和硬件,因此您可以期望在具有许多内核的机器上加速,但只有 2 或 4 个它可能也好不到哪里去。

如果M 的分布不是平滑,以至于第一步创建的分区大小有很大不同,它会工作很差,因为一​​些线程会得到更多的工作。一个简单的策略是创建10 * T 分区,而不仅仅是T,并让第二轮中的线程全部从同一个分区队列中消耗直到完成。通过这种方式,您可以更均匀地分布工作,除非数组M 非常聚集。在这种情况下,您可能会考虑对第一步进行改进,首先创建元素的分桶直方图,然后是减少阶段,查看组合直方图以创建良好的分区。

本质上,我们只是将第一阶段逐步细化为一种并行排序/分区算法,对此已有大量文献。您甚至可能会发现完全(并行)排序是最快的,因为它在位设置阶段有很大帮助,因为访问将是有序的并且具有最佳的空间局部性(分别有助于预取和缓存)。


0 ...以及“分配长度为 N 的私有数组”步骤,尽管这可能非常快。

1 合并的概念上最简单的形式是简单地复制 M 的每个线程的分区,这样你就有一个所有 M 的连续分区,但实际上如果分区很大,你可以将分区保留在原处并将它们链接在一起,这给消费代码增加了一些复杂性,但避免了压缩步骤。

2 为了使其真正脱离线程的观点,您需要确保N 的分区落在“字节边界”上,甚至可能是缓存行边界以避免错误共享(虽然后者可能不是什么大问题,因为它只发生在每个分区的边缘,而且处理顺序意味着你不太可能发生争用)。

4 在实践中,使用共享N 的基线并发解决方案的确切“顺序”很难定义,因为会存在争用,因此O(M/T) 缩放将分解到足够大T。如果我们假设N 非常大,而T 仅限于最多十几个核心的典型硬件并发,那么它可能是一个可以的近似值。

【讨论】:

  • 或者shlx可以替换xorbts,如果你有一个在循环外初始化为1的寄存器。
  • 可以解释为存储转发。如果读/写现在是 8 字节,则下一次迭代的读取会从上一次迭代中命中存储。尽管在我的思维模式中实际上没有任何存储转发,因为锁定操作的隐含围栏不应该允许以后的加载继续进行,直到 SB 为空,但谁知道这一切在实践中是如何实现的。无论如何,一堆背靠背的原子操作并不常见。
  • 我尝试使用times 10 imul ecx,ecx 并注释掉(或不注释掉)lock or 块。差异(如果有的话)低于测量噪声水平,对于 25M 迭代约为 750.4Mc。
  • 哈!整洁地发现读取最小锁定延迟。所以我们可以说锁可以完全免费,这取决于。实际上,当它们用于互斥体获取时,这通常无济于事,因为您可能在互斥体中执行的第一件事是从内存中读取(毕竟,您正在保护内存),因此您通常最终会支付全部罚款那个案子。一个原子计数器的火灾和遗忘增量,然后是足够的 reg,reg 工作可能是一个可以免费的地方。有趣的优化机会...
  • 是的,Intel 明确声明 HT静态 对存储缓冲区进行分区,因此每个逻辑线程都有自己的。 (stackoverflow.com/questions/27797424/…)
【解决方案3】:

@IraBaxter 发布了an interesting but flawed idea,它可以工作(成本很高)。我怀疑@BeeOnRope 对 M 数组进行部分排序/分区的想法会表现得更好(尤其是对于具有大型私有缓存的 CPU,它可以保持 N 的部分热)。我将总结 Ira 的想法的修改版本,我在他删除的答案中描述了 in comments。 (这个答案有一些关于 N 在值得多线程之前必须有多大的建议。)


每个编写器线程都会获得一块没有排序/分区的 M。

这个想法是,冲突非常罕见,因为与可以同时运行的商店数量相比,N 很大。由于设置一个位是幂等的,所以我们可以通过检查内存中的值来处理冲突(两个线程想要在同一个字节中设置 不同 位),以确保它确实设置了那个位我们希望在像 or [N + rdi], al 这样的 RMW 操作之后(没有 lock 前缀)。

例如线程 1 尝试存储 0x1 并踩到线程 2 的 0x2 存储。线程 2 必须注意并重试 read-modify-write(可能使用 lock or 以保持简单,并且无法多次重试)以在冲突字节中以 0x3 结束。

在回读之前我们需要一个mfence 指令。否则存储转发将给我们我们刚刚写的值before other threads see our store。换句话说,线程可以在它们出现在全局顺序中之前先观察它自己的存储。 x86 确实有存储的总订单,但没有负载。因此,we need mfence to prevent StoreLoad reordering。 (英特尔的“加载不会与旧存储重新排序到同一位置”保证并不像听起来那么有用:存储/重新加载不是内存障碍;他们只是在谈论保持程序顺序的乱序执行语义。)

mfence 很昂贵,但比仅使用lock or [N+rdi], al 更好的技巧是我们可以批处理操作。例如执行 32 条or 指令,然后进行 32 条回读。这是mfence 每次操作的开销与增加的错误共享机会(读回已被另一个 CPU 声明无效的缓存行)之间的权衡。

代替实际的mfence 指令,我们可以将组的最后一个or 执行为lock or。这对 AMD 和 Intel 的吞吐量都有好处。例如,根据Agner Fog's tablesmfence 在 Haswell/Skylake 上有一个每 33c 的吞吐量,其中lock add(与or 的性能相同)具有 18c 或 19c 的吞吐量。或者对于 Ryzen,~70c (mfence) 与 ~17c (lock add)。

如果我们将每个栅栏的操作量保持在非常低的水平,则数组索引 (m[i]/8) + 掩码 (1&lt;&lt;(m[i] &amp; 7)) 可以保存在所有操作的寄存器中。这可能不值得;每 6 次 or 操作一次,围栏太昂贵了。使用btsbt 位串指令意味着我们可以在寄存器中保留更多索引(因为不需要移位结果),但可能不值得,因为它们很慢。

使用向量寄存器来保存索引可能是一个好主意,以避免在屏障之后从内存中重新加载它们。我们希望加载地址在读回加载 uop 可以执行后立即准备就绪(因为它们正在等待屏障提交到 L1D 并成为全局可见之前的最后一个存储)。

使用单字节读取-修改-写入可以尽可能地减少实际冲突。每个字节的写入仅对 7 个相邻字节执行非原子 RMW。当两个线程修改同一个 64B 缓存行中的字节时,性能仍然会受到错误共享的影响,但至少我们避免了实际重做尽可能多的or 操作。 32 位元素大小会使某些事情变得更高效(例如使用 xor eax,eax / bts eax, reg 生成 1&lt;&lt;(m[i] &amp; 31) 只需 2 微指令,或 1 用于 BMI2 shlx eax, r10d, reg(其中 r10d=1)。)

避免像bts [N], eax 这样的位串指令:它的吞吐量比为or [N + rax], dl 进行索引和掩码计算要差。这是它的完美用例(除了我们不关心内存中位的旧值,我们只想设置它),但它的 CISC 包袱仍然太多.

在 C 中,函数可能看起来像

/// UGLY HACKS AHEAD, for testing only.

//    #include <immintrin.h>
#include <stddef.h>
#include <stdint.h>
void set_bits( volatile uint8_t * restrict N, const unsigned *restrict M, size_t len)
{
    const int batchsize = 32;

    // FIXME: loop bounds should be len-batchsize or something.
    for (int i = 0 ; i < len ; i+=batchsize ) {
        for (int j = 0 ; j<batchsize-1 ; j++ ) {
           unsigned idx = M[i+j];
           unsigned mask = 1U << (idx&7);
           idx >>= 3;
           N[idx] |= mask;
        }

        // do the last operation of the batch with a lock prefix as a memory barrier.
        // seq_cst RMW is probably a full barrier on non-x86 architectures, too.
        unsigned idx = M[i+batchsize-1];
        unsigned mask = 1U << (idx&7);
        idx >>= 3;
        __atomic_fetch_or(&N[idx], mask, __ATOMIC_SEQ_CST);
        // _mm_mfence();

        // TODO: cache `M[]` in vector registers
        for (int j = 0 ; j<batchsize ; j++ ) {
           unsigned idx = M[i+j];
           unsigned mask = 1U << (idx&7);
           idx >>= 3;
           if (! (N[idx] & mask)) {
               __atomic_fetch_or(&N[idx], mask, __ATOMIC_RELAXED);
           }
        }
    }
}

这编译成我们想要的 gcc 和 clang。 asm (Godbolt) 在几个方面可能更有效,但尝试一下可能会很有趣。 这是不安全的:我只是在 C 中将其组合在一起以获得我想要的独立函数的汇编,而没有内联到调用者或任何东西。 __atomic_fetch_ornot a proper compiler barrier for non-atomic variables 的方式 asm("":::"memory") 是。 (至少 C11 stdatomic 版本不是。)我可能应该使用 legacy __sync_fetch_and_or,它是所有内存操作的完整屏障。

它使用 GNU C atomic builtins 对不是 atomic_uint8_t 的变量执行原子 RMW 操作。一次从多个线程运行此函数将是 C11 UB,但我们只需要它在 x86 上工作。 我使用volatile 来获得atomic 的允许异步修改部分,而不强制N[idx] |= mask; 是原子的。 这个想法是确保回读检查不会优化离开。

我使用__atomic_fetch_or 作为内存屏障,因为我知道它将在 x86 上。使用 seq_cst,它可能也会在其他 ISA 上,但这都是一个大黑客。

【讨论】:

    猜你喜欢
    • 2011-05-05
    • 2010-10-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-01-10
    • 2011-04-24
    相关资源
    最近更新 更多