TL;DR:调整编译器标志(或者如果你的编译器不够聪明,使用SIMD内嵌库),并使用内存映射文件。如果这还不够,可以使用OpenMP和较低级别的API来使用多个线程,以避免许多开销,但代价是代码不太可移植且更加复杂。
使用SIMD指令进行向量化
假设您使用的编译器已经调整好以生成快速代码,并且其启发式算法足够好以生成优化后的代码,则您当前的代码已经相当不错了。请注意,对于小固定大小的memcpy
和memset
函数调用,编译器已经进行了优化(至少对于Clang、GCC和ICC)。Clang使用标志-O3 -mavx2
为现代x86-64处理器生成几乎最优代码,它假定运行程序的目标机器具有AVX2指令集(大多数x86处理器都具有它,但并非全部,特别是旧的处理器)。以下是热循环的生成汇编代码:
.LBB0_7:
vmovdqu ymm0, ymmword ptr [r14 + 8*rcx]
vmovdqu ymm1, ymmword ptr [r14 + 8*rcx + 32]
vmovdqu ymm2, ymmword ptr [r14 + 8*rcx + 64]
vmovdqu ymm3, ymmword ptr [r14 + 8*rcx + 96]
vpshufb ymm0, ymm0, ymm4
vpshufb ymm1, ymm1, ymm4
vpshufb ymm2, ymm2, ymm4
vpshufb ymm3, ymm3, ymm4
vmovdqu ymmword ptr [rbx + 8*rcx], ymm0
vmovdqu ymmword ptr [rbx + 8*rcx + 32], ymm1
vmovdqu ymmword ptr [rbx + 8*rcx + 64], ymm2
vmovdqu ymmword ptr [rbx + 8*rcx + 96], ymm3
add rcx, 16
cmp rcx, 1024
jne .LBB0_7
这段代码一次可以在很少的周期内(在我的Intel Skylake处理器上仅需4个周期)对128个字节进行洗牌!
如果您不确定是否使用AVX2指令集,不用担心,因为像GCC和Clang这样的编译器已经使用AVX指令集生成了相当好的代码(几乎所有x86-64现代处理器都支持)。您可以在godbold上看到。使用标志-mavx
就足以生成快速的代码(对于Clang/GCC/ICC)。对于MSVC,您需要分别使用标志/arch:AVX
和/arch:AVX2
。如果您想支持几乎所有x86-64处理器,但要以更慢的代码为代价,可以使用-mssse3
标志来使用SSE而不是AVX。
关于SIMD指令集的支持说明:
Steam调查报告称,87%的用户支持AVX2,95%的用户支持AVX,99.3%的用户支持SSSE3。
请注意,对于其他硬件架构也是同样的情况:您主要只需要启用编译器的正确标志。
其他优化
memset(tmp,0,8192);
调用不必要,因为tmp
只被写入。可以将其删除。
fwrite
通常很快,因为libc在内部使用自己的(相当大的)缓冲区,并且应该相对适应您的硬件。但是,操作系统(OS)请求的数据缓冲区需要被复制,而大多数内核实现(例如Linux,由于使用SIMD指令而涉及到复杂的技术原因)并没有高效地完成这个复制。在硬盘驱动器(HDD)上,这个复制通常不会影响性能。然而,在快速的固态硬盘(SSD)上,特别是高吞吐量的NVMe硬盘上,它可能会引入开销,这些硬盘可以写入几个GiB/s。加快此过程的一种方法是使用映射内存文件(有关更多信息,请参见此处,此处和那里)。
如果您有非常快的SSD,并且使用内存映射文件不足够,那么您可以尝试使用OpenMP并行化循环。在这种情况下,这应该非常简单,因为循环是令人尴尬的并行:只需添加
#pragma omp parallel for
即可。请注意,此解决方案可能会更慢,并且某些较慢的SDD上将肯定会更慢,并且在HDD上(不喜欢非顺序访问或并行访问)也将更慢。
如果这仍然不够,您可以尝试使用像liburing这样的实验性解决方案,该解决方案使用仅在Linux上提供的新
IO_uring内核接口(Windows似乎有一个名为
IoRing的类似接口)。该接口旨在避免复制(即零复制)和系统调用(热循环中没有系统调用),从而产生几乎没有开销的非常快速的代码。但是,直接使用它将使您的代码不太可移植且更加复杂。以上方法对您来说可能已足够。
memcpy
和memset
函数调用进行优化。它们通常会删除函数调用,可以删除无用的复制并生成快速指令,如SIMD移动。我更担心的是htonll
宏,因为并非所有编译器都能对其进行优化(但好的编译器应该可以)。 - Jérôme Richard