如何有效地并行设置位向量的位?

时间:2017-08-07 21:38:12

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

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

如何在多个线程中有效地分割这项工作?

首选语言是C ++(MSVC ++ 2017工具集v141),汇编也很棒。首选CPU是x86_64(内在函数没问题)。如果有任何益处,AVX2是理想的。

2 个答案:

答案 0 :(得分:2)

我们假设你想在T个线程中划分这个工作。这是一个非常有趣的问题,因为它不能通过分区轻松并行化,并且各种解决方案可能适用于NM的不同大小。

完全并发基线

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

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

私人N

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

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

M

的分区

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

如果M 顺利分发,那么效果很好的简单算法就是首先将M的值划分为M个桶,其中的桶具有范围T中的值。也就是说,将[0, N/T), [N/T, 2N/T], ..., [(T-1)N/T, N)划分为N个不相交的区域,然后找到落入每个区域的T的值。您可以通过为每个线程分配相同大小的M块,并让它们各自创建T分区然后逻辑合并M个线程传播该工作最后> 1 ,因此您拥有T的{​​{1}}分区。

第二步是实际设置所有位:为每个线程T分配一个分区,它可以设置"单线程"中的位。方式,即不担心并发更新,因为每个线程正在处理M 2 的不相交分区。

步骤T和第二步与单线程情况相同,因此并行化的开销是第一步。我怀疑第一个的速度大约和第二个相同,大约是2-4倍,这取决于实现和硬件,所以你可以期望在拥有多个内核的机器上加速,但只有2或4个没有更好的。

如果N的分布不是平滑,那么在第一步中创建的分区具有非常不同的大小,它将无法正常工作,因为某些线程将获得更多的工作。一个简单的策略是创建say O(M)分区,而不是仅M,并让第二遍中的线程全部从同一个分区队列中消耗,直到完成为止。通过这种方式,您可以更均匀地分散工作,除非数组10 * T非常紧密。在这种情况下,您可以考虑对第一步进行细化,首先基本上创建元素的分块直方图,然后是一个减少阶段,它会查看组合直方图以创建良好的分区。

基本上,我们正在逐步将第一阶段细化为一种并行排序/分区算法,其中已有大量文献。您甚至可能会发现完整(并行)排序是最快的,因为它将极大地帮助进行位设置阶段,因为访问将按顺序进行并具有最佳空间局部性(分别有助于预取和高速缓存)。 p>

0 ...以及"分配一个长度为N"的私有数组。一步,虽然这可能很快。

1 概念上最简单的合并形式是简单地复制M的每个线程的分区,使得你有一个T的所有连续分区,但在实践中如果分区很大,您可以将分区保留在原来的位置并将它们链接在一起,从而为使用代码增加了一些复杂性,但避免了压缩步骤。

2 为了使它真正脱离线程的观点,你要确保M的分区落在"字节边界",甚至可能缓存 - 线路边界,以避免错误共享(虽然后者可能不是一个大问题,因为它只发生在每个分区的边缘,处理的顺序意味着你不可能得到争用)。

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

答案 1 :(得分:1)

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

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

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

E.g。线程1试图存储0x1并踩到线程2的0x2商店。线程2必须注意并重试读取 - 修改 - 写入(可能使用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,而不是实际的mfence指令。这对于AMD和Intel的吞吐量都更好。例如,根据Agner Fog's tableslock add在Haswell / Skylake上每33c吞吐量有一个,其中or(性能与mfence相同)的吞吐量为18c或19c。或Ryzen,~70c(lock add)vs.~17c(m[i]/8)。

如果我们保持每个fence的操作量非常低,则可以将数组索引(1<<(m[i] & 7))+ mask(or)保存在寄存器中以进行所有操作。这可能不值得;每隔6 bts次操作,栅栏太昂贵了。使用btor位串指令意味着我们可以在寄存器中保留更多的索引(因为不需要移位结果),但可能不值得,因为它们会变慢。 / p>

使用向量寄存器来保存索引可能是一个好主意,以避免在屏障后从内存重新加载它们。我们希望只要回读加载uops可以执行就会准备好加载地址(因为它们在阻塞之前等待最后一个商店提交到L1D并变得全局可见)。

使用单字节读取 - 修改 - 写入使得实际冲突尽可能不可能。每次写入一个字节仅在7个相邻字节上执行非原子RMW。当两个线程在同一个64B缓存行中修改字节时,性能仍会受到错误共享的影响,但至少我们不必实际重做xor eax,eax次操作。 32位元素大小会使某些事情变得更有效(例如使用bts eax, reg / 1<<(m[i] & 31)生成shlx eax, r10d, reg只有2 uops,或者1为BMI2 r10d=1(其中{{} 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中一起攻击这个以获取我想要的asm这个独立功能,而不需要内联到调用者或任何东西。 __atomic_fetch_or asm("":::"memory")的方式是stdatomic。 (至少C11 atomic_uint8_t版本不是。)我应该使用not a proper compiler barrier for non-atomic variables 是所有内存操作的完全障碍。

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

我使用{{1}}作为内存屏障,因为我知道它将在x86上。使用seq_cst,它可能也会出现在其他ISA上,但这都是一个大问题。