【问题标题】:Efficient C/C++ multi-threaded program to partition and process data高效的 C/C++ 多线程程序来分区和处理数据
【发布时间】:2015-11-08 23:00:35
【问题描述】:

我正在尝试解决这个任务,但我发现我的多线程解决方案的性能比我的单线程解决方案差。

作业:

创建一个实用程序,它将对给定的数据集执行简单的XOR cryptographic 转换 - 一个 XOR 流加密器。加密密钥将由任意大小的外部文件中的二进制数据提供。以字节为单位的密钥大小将决定block size。明文数据将在stdin 上给出,然后实用程序会将其分成块大小的部分,与密钥进行异或,并将cipher 文本写入stdout。处理完每个块后,应将密钥向左旋转一位以生成新密钥。这意味着密钥将每 N 个块重复一次,其中 N 是密钥中的位数。明文数据的长度不必是块大小的倍数,也不应假定为 ASCII 或 Unicode 文本。明文非常大,远超过系统可用内存+交换空间也是有效的。

除了正确执行转换之外,该实用程序还应该能够通过实现并行处理多个明文块的多线程方案来利用多核或多处理器机器。线程数应指定为命令行参数,无论使用多少,输出密文都应保持不变。所有错误和/或状态信息都应打印在 stderr 上。

该实用程序应使用 C 或 C++ 编写,并且应构建在 UNIX 平台上。请确保您的提交可以使用各种不同的输入正确编译和运行。

必需的命令行选项:

encryptUtil [-n #] [-k 密钥文件] -n #

要创建的线程数 -k keyfile 包含的文件路径 键

输入/输出示例:

$ hexdump -C keyfile 
00000000  f0 f0         

|??|

$ hexdump -C plaintext 
00000000  01 02 03 04 11 12 13 14   

   |........|

$ cat plaintext | encryptUtil -n 1 -k keyfile >

密文

$ hexdump -C ciphertext 
00000000  f1 f2 e2 e5 d2 d1 94 93   

   |??????..|

最有效的方法是什么?

我的做法:

  1. 我正在提前生成所有密钥
  2. 提前生成所有密钥:鉴于输入大小可能非常大,每次 XOR 块操作旋转密钥的成本可能很高,因此决定提前生成所有密钥组合(密钥链) , 并为每个 XOR 块操作索引到密钥链中。 批量读取数据 thread_count x block_size :考虑到输入数据可能非常大,最好批量读取数据并进行处理。如果需要将解决方案修改为 OpenCL 等技术以使用多个处理单元,则对数据进行分区也非常有用。

  3. 流水线:假设该程序有两个 IO 操作,1> 输入,2> 输出。流水线似乎是一个很好的解决方案。

我们在主线程中读取大小为 (thread_count x block_size) 的批量数据,并将其发送到调度程序,该调度程序获取它可以处理的输入数据的本地副本。主线程被释放以读取更多数据,而调度程序处理来自本地副本的输入纯文本。

调度程序产生 N 个线程,这些线程实际上执行 XOR 操作,它们索引到钥匙串并生成它们的部分输出。当所有工作人员都生成了他们的部分输出后,他们向调度程序发出信号,调度程序将数据写入标准输出。

所有这些都发生在主线程准备下一批要处理的数据时。

该方法看起来合乎逻辑,但从性能的角度来看(至少对于这个特定问题)来说,这种方法看起来很合乎逻辑,但结果却是:管道。看起来数据移动和同步开销导致程序运行速度比单线程程序慢,这也包括在我提交的解决方案中(也使用预生成的密钥)。

我的解决方案是在

repo:包含问题描述和两个解决方案 https://github.com/vjtron/encryptUtil

多线程解决方案 https://github.com/vjtron/encryptUtil/tree/master/source/encryptUtil_pthread

【问题讨论】:

  • 如果你的代码是这样运行的,你可能更擅长代码审查。但请在发布前阅读他们的常见问题解答。
  • 嗨@Olaf,这里是代码仓库:github.com/vjtron/encryptUtil

标签: c multithreading performance pthreads processing-efficiency


【解决方案1】:

这里的问题是最小化内存副本。现代 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,那么您完全是在自找麻烦。为了有任何希望,您需要每个线程从文件描述符中执行自己的readread 系统调用使内核 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 的缓存中。

数据会移动:

  1. 磁盘-> DRAM(DMA,可能根本不进入缓存)。 (如果文件在磁盘缓存中,请跳过此步骤。)
  2. 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。

  3. L2(IO-CPU) -> L3,当工作线程的 CPU 需要这些高速缓存行,并且一致性流量发现另一个内核使它们处于 E 或 M 状态时。

  4. L3 -> L2/L1 和工作线程 CPU 的寄存器
  5. 在工作线程 CPU 的 L1/L2 中修改 (M),当它写入修改后的缓冲区时。
  6. 当 I/O 线程的 write 系统调用读取它时,被一致性流量刷新到 L3
  7. L3 -> I/O 线程的 L2/L1。 (write系统调用)
  8. L2/L1 -> 寄存器 -> L1/L2 I/O 线程 CPU。 (write 系统调用的写操作,将我们的缓冲区复制到页面缓存。)
  9. L2 -> L3 和 DRAM(页面缓存)在缓存压力最终驱逐保存已写入输出数据的缓存行时被刷新。
  10. 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 个键的缓冲区,您应该能够从缓冲区中的某个位置获得所需的任何键的旋转。

【讨论】:

  • 我建议不要缓存所有密钥轮换,因为这会占用key_bits*key_bits 的内存。我宁愿缓存 8 个副本并根据需要进行字节洗牌。
  • 这是一个优秀的建议。我并没有真正考虑过非常大键的问题,但这对于适合寄存器的键以及非常大键的情况可能很有用。 (palignr / shrd 可以在寄存器之间移动字节。)在具有快速非对齐内存访问的系统(如 x86)上,您可以拥有 9 个副本,并以您需要的任何字节偏移量顺序访问 8 * key_bits。或者,将大小的奇数部分对齐到 16B,然后在键和缓冲区之间进行宽 XOR。
【解决方案2】:

应用于每个块的算法非常简单,顺序 I/O 的成本将占主导地位;在多个线程中运行 XOR 循环不会使程序更快。如果您正在从文件中读取输入,那么使用线程同时读取、加密和写入不同的块可能会有所帮助。

【讨论】:

  • 如果您正在从文件中读取输入,使用线程同时读取、加密和写入不同的块可能是有益的。 告诉(单个)驱动器头!
  • 并非所有文件都存储在旋转磁盘上。
  • 正是我的想法。从标准输入读取它的 memcpy 部分是主要成本,即使它不是来自磁盘。将数据放入另一个 CPU 内核的 L1 缓存只会减慢速度。如果你能以某种方式记忆映射你的输入,线程只会有帮助(或者至少不会减慢速度)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-02-02
  • 1970-01-01
  • 2013-04-11
  • 1970-01-01
相关资源
最近更新 更多