考虑其中N
位的位向量(N
很大)和M
个数字M
的数组是中等的,通常远小于{{ 1}}),每个范围N
,指示向量的哪个位必须设置为0..N-1
。后一个数组未排序。位向量只是一个整数数组,特别是1
,其中256位被打包到每个__m256i
结构中。
如何在多个线程中有效地分割这项工作?
首选语言是C ++(MSVC ++ 2017工具集v141),汇编也很棒。首选CPU是x86_64(内在函数没问题)。如果有任何益处,AVX2是理想的。
答案 0 :(得分:2)
我们假设你想在T
个线程中划分这个工作。这是一个非常有趣的问题,因为它不能通过分区轻松并行化,并且各种解决方案可能适用于N
和M
的不同大小。
您可以简单地将数组M
划分为T
个分区,并使每个线程在其M
的分区上工作,共享N
。主要问题是,由于M
没有排序,所有线程都可以访问N
的任何元素,因此踩踏彼此的工作。为避免这种情况,您必须对共享std::atomic::fetch_or
数组的每次修改使用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
数组的不相交部分上工作。如果对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 tables,lock 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
次操作,栅栏太昂贵了。使用bt
和or
位串指令意味着我们可以在寄存器中保留更多的索引(因为不需要移位结果),但可能不值得,因为它们会变慢。 / 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上,但这都是一个大问题。