这里的问题是最小化内存副本。现代 Intel(或 AMD)CPU 的一个内核可以使主存带宽饱和。例如Haswell:~26GB/s 最大理论主存带宽。 26GB/s / 8B = 3.25GHz 使 DRAM 饱和的最低时钟速度,在代码中,每个时钟平均触及 8B 的新内存(重复访问 L1 缓存中相同的 8B 命中)。仅使用标量代码就很容易实现,尤其是。如果你在写作,而不仅仅是阅读。 (相比之下,每个 Haswell 内核每个周期可以支持两次(最多)32B 读取和一次(最多)32B 写入 L1 D$。每个内核都有自己的 32k L1 和 256k L2$。 )
如果您的多线程解决方案涉及额外的memcpy,那么您完全是在自找麻烦。为了有任何希望,您需要每个线程从文件描述符中执行自己的read。 read 系统调用使内核 memcpy 文件数据进入您的缓冲区。这发生在运行系统调用的 CPU 上,因此数据应该在之后缓存中。
对于读取来说,大约 16k 的缓冲区大小可能会很好,因为这是 L1D$ 的一半(在 Intel 设计中。AMD 使用 16k L1 D$,所以可能更小的缓冲区会很好,因为 L2 很大但比英特尔的 L2)。 (将“$”发音为“缓存”)。但是,我们仍然可以通过多核的 L2 带宽跟上 DRAM,更大的缓冲区将减少同步/系统调用开销。所以也许 128k 会是更好的选择。
这不是您通常在多线程程序中进行 I/O 的方式。我认为通常你会有一个 I/O 线程,工作线程将在 I/O 线程读入的缓冲区上工作。
(请参阅下面的另一种设计,我认为它仍会使用 I/O 线程,但比read + memcpy 更有效地将数据发送到工作线程。)
我们试图通过让每个线程成为访问其内存块的唯一线程来最大化缓存优势。可以想象这样一种情况,您想要多线程处理计算密集度稍微一点的东西;在单个内核上不太受内存限制,但在多个内核上很容易受内存限制。这使得它比多线程更有趣,目标是不比简单的单线程实现慢!
由于您显然必须从标准输入读取输入,而不是通过内存映射文件,您需要让每个线程在自己的缓冲区中执行自己的 reads,所以数据将位于运行该线程的内核的 L1/L2$ 中。您必须按顺序写入输出块,为此您需要知道哪个线程具有哪个数据块。
我认为让每个线程读取stdin,然后找出哪个线程得到哪个块来订购它们以进行输出可能是理想的。问题是,read(2) 不返回文件位置或任何东西。使用lseek(2) 查询位置,然后read(2) 会引入竞争条件,原子计数器也会引入竞争条件(您可以在其中获得旧值或新值,作为原子添加8k 的一部分)。从不可搜索的流(没有文件位置)中读取,无论如何您都需要一个计数器。
无论如何,您都会受到内存带宽的限制,因此线程在某种程度上相互阻塞不是问题。我有一个想法,只需要锁定输入,由于原子计数器的输出排序:
- 输入锁和位置计数器(都在同一个缓存行中)
- 输出位置计数器(原子)。 (在与输入锁不同的缓存行中,以避免错误共享。尽管线程确实需要在增加输出 pos 计数器后立即获取输入锁。)
每个线程:
while (42) {
acquire(input.lock);
local blockpos = input.pos;
ssize_t count = read(stdin, buf, blksz);
if (count != blsz) {
// special case, maybe try to fill the rest of the buffer if it wasn't EOF?
}
input.pos += count;
release(input.lock);
process(buf, blockpos); // needs the position to know what key position to start with
spin_until(output.pos == blockpos);
// acquire(output.lock); // no other thread will have the same blockpos
write(stdout, buf, count);
store_fence(); // not sure this is needed; the kernel probably makes sure that write ordering is globally visible.
// So the output.pos increment couldn't be globally visible before write's change to the file position.
output.pos += count; // make sure this is an atomic increment
// release(output.lock);
}
我不确定如何最好地实现spin_until(output.pos == blockpos);,让线程等待原子计数器的输出位置与它拥有的输入块匹配。 (我知道多线程的理论,但没有太多的实现经验。)
如果您正在从可查找的文件中读取(但无法对其进行映射),则可以使用 pread(2) 系统调用。类似于read(2),但带有off_t offset 参数。每个线程都可以获得Nth 块,其中 N 是线程数。您需要锁定以控制写入时的排序。除非您只使用pwrite,否则可能每 16 个块,确保线程之间没有失步,因为顺序 I/O 比随机更快,即使在 SSD 上也是如此。您必须对此进行基准测试:您可能正在为内核 I/O 调度程序做额外的工作。特别是。如果您没有就地重写文件,则当您写入超出 EOF 而不是 EOF 时,该文件会暂时出现漏洞。实际的 I/O 调度程序可能要过一段时间才会参与,那时内核最终会刷新您前一段时间编写的脏页。 (这将是连续的,因为你不会让你的线程分开太远)。
替代方案:仍然是 I/O 线程,但复制较少
这仍然涉及数据在不同 CPU 内核的缓存之间反弹,但通常这不是 CPU 密集型问题的瓶颈,值得并行化。
您仍然可以使用一个 I/O 线程来实现它,但通过让 I/O 线程直接读入工作线程的缓冲区来减少复制。因此每个工作线程将有两个缓冲区,每个缓冲区可能为 64k。
-
工作线程:当工作线程看到将两个缓冲区之一标记为准备处理的标志时,它会执行加载围栏,处理它,执行存储围栏,然后清除标志。即使用load(std::memory_order_acquire) 加载标志,并使用store(std::memory_order_release 写入标志)。 (StoreStore、LoadLoad 和 LoadStore 屏障在 x86 上是隐式的(零指令),但您仍然需要将其放入代码中以停止编译器重新排序。更不用说,因此您的代码适用于其他架构。)
I/O 线程:read 进入工作线程的空缓冲区,存储流位置(以便工作人员知道从哪个键轮换开始),然后设置一个带有store(std::memory_order_release) 的标志。查找空缓冲区时,请务必使用 std::memory_order_acquire 检查标志。
我认为您无需在任何地方使用 StoreLoad 屏障就可以逃脱,因为我们不会先锁定然后再读取一些受保护的数据。相反,我们读取一个标志,然后读取/重写数据。所以StoreLoad case 没有进入它。即使我们不重写,而只是编写,我们也只需要一个 LoadStore 屏障和我们的 LoadLoad 屏障。
以相同的顺序读取缓冲区,write 当它们的标志被清除时。理想情况下,write 是一个缓冲区,然后立即重新填充它,因此它仍在 I/O 线程 CPU 的缓存中。
数据会移动:
- 磁盘-> DRAM(DMA,可能根本不进入缓存)。 (如果文件在磁盘缓存中,请跳过此步骤。)
-
DRAM(pagecache) -> 运行 I/O 线程的任何内核的 L3 和 L2/L1。
2.1 L1->registers : read 系统调用从 I/O 线程中的 pagecache 读取
2.2 寄存器 -> L1/L2 的另一部分 (modified (M state))。 read 在 I/O 线程中写入我们的缓冲区。
2.x. 这是您的代码在 I/O 线程中进行额外复制的地方,L2->registers->L2。
L2(IO-CPU) -> L3,当工作线程的 CPU 需要这些高速缓存行,并且一致性流量发现另一个内核使它们处于 E 或 M 状态时。
- L3 -> L2/L1 和工作线程 CPU 的寄存器
- 在工作线程 CPU 的 L1/L2 中修改 (M),当它写入修改后的缓冲区时。
- 当 I/O 线程的
write 系统调用读取它时,被一致性流量刷新到 L3
- L3 -> I/O 线程的 L2/L1。 (
write系统调用)
- L2/L1 -> 寄存器 -> L1/L2 I/O 线程 CPU。 (
write 系统调用的写操作,将我们的缓冲区复制到页面缓存。)
- L2 -> L3 和 DRAM(页面缓存)在缓存压力最终驱逐保存已写入输出数据的缓存行时被刷新。
- DRAM(pagecache) -> 内核刷新脏输出文件页面时的磁盘
如果我们幸运的话,并且保持我们的缓冲区很小,大多数时候数据的额外副本(即我们的工作线程缓冲区)不会进入 DRAM,只是 L3。数据通过缓存一致性流量在内核之间传输。这是最新英特尔设计中大型末级缓存的主要优势之一。它是一个缓存一致性缓冲区,可防止往返主内存。这可能在 Ulrich Drepper 的 What Every Programmer Should Know About Memory 中没有提到,因为它是从 2007 年开始的。
我忘记了内核是否可以直接从另一个内核的 L2 传输缓存行,而不是先写入 L3,然后再从 L3 读取。我想也许可以,但是在内核之间弹跳缓存线仍然很慢。
你额外的 memcpy 只是我列表中的额外一行,但它会占用缓存空间,并且可能会将数据驱逐回主内存。它也可能使用额外的 L3 共享带宽来进行复制。我认为这里的主要问题是你的缓冲区可能太大了,最终到达了主内存。 (我没有检查你的 git repo。)
按键轮换
如果密钥是 8、16、32 或 64 位,则旋转它非常便宜(在 x86 上)。将寄存器循环 1 是一条单周期指令。请参阅https://stackoverflow.com/a/31488147/224132,了解如何在 C 中编写循环代码,该循环实际上编译为机器指令,而没有任何 C 未定义行为(或从避免 UB 的代码生成的额外指令)。这仅适用于 64b 键或 32 个键。其他键太短,因为您需要为 64b 值的每个部分使用不同的小键旋转。
如果钥匙不是这些尺寸之一,那么它会更贵。您将密钥旋转展开直到重复的解决方案是一个很好的方法,否则您将受编译器的支配来生成最佳旋转和旋转进位指令。
即使是偶数 128b,旋转也可能需要测试低 64 的高位,然后旋转携带高 64,然后旋转携带低 64。RCL r,1 相当有效,只有 ROL r, 1 成本的 3 倍左右(类似于单次添加)。如果它是一些不规则的大小,例如 11 个字节,则一对 64b 和 32b 旋转进位指令将无法解决问题。
如果您完全展开它们的键重复,则向量寄存器中最多可以容纳 15* 16B。 (或 15 * 32B 带 AVX)。您需要一个免费的注册作为划痕。希望一个好的编译器能够实现这一切,并将一个键数组矢量化到你的缓冲区上。尽管只要密钥那么小,它仍然可以放入 L1 缓存中,并且如果您是多线程并且受主内存限制,那么那些额外的 L1 读取(以及执行它们的指令)不会成为问题。
更新:EOF 有一个很好的建议:只需展开密钥轮换,直到您需要担心整个字节数。如果您有一个连续有 8 个键的缓冲区,您应该能够从缓冲区中的某个位置获得所需的任何键的旋转。