@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 tables,mfence 在 Haswell/Skylake 上有一个每 33c 的吞吐量,其中lock add(与or 的性能相同)具有 18c 或 19c 的吞吐量。或者对于 Ryzen,~70c (mfence) 与 ~17c (lock add)。
如果我们将每个栅栏的操作量保持在非常低的水平,则数组索引 (m[i]/8) + 掩码 (1<<(m[i] & 7)) 可以保存在所有操作的寄存器中。这可能不值得;每 6 次 or 操作一次,围栏太昂贵了。使用bts 和bt 位串指令意味着我们可以在寄存器中保留更多索引(因为不需要移位结果),但可能不值得,因为它们很慢。
使用向量寄存器来保存索引可能是一个好主意,以避免在屏障之后从内存中重新加载它们。我们希望加载地址在读回加载 uop 可以执行后立即准备就绪(因为它们正在等待屏障提交到 L1D 并成为全局可见之前的最后一个存储)。
使用单字节读取-修改-写入可以尽可能地减少实际冲突。每个字节的写入仅对 7 个相邻字节执行非原子 RMW。当两个线程修改同一个 64B 缓存行中的字节时,性能仍然会受到错误共享的影响,但至少我们避免了实际重做尽可能多的or 操作。 32 位元素大小会使某些事情变得更高效(例如使用 xor eax,eax / bts eax, reg 生成 1<<(m[i] & 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_or 是 not 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 上,但这都是一个大黑客。