重新排列内存中的字节,然后写入文件。

3

我在内存中有一块从 memory_ptr 开始的数据块。这个程序的功能是对每个 qword 进行字节反转,然后将其写入文件。例如,18171615141312112827262524232221 将被写成 11121314151617182122232425262728,以此类推。

我希望这个程序能尽可能快地运行,所以我已经将翻转后的字节存储在内存中,并在达到 8KB 时将其写入文件,以减少调用 fwrite() 的次数。但是,我觉得有一种更简单、更快速的方法来做这件事,而不是增加 malloc 的大小以进一步减少对 fwrite 的调用次数。你有什么想法可以加速吗?

#define htonll(x) (((uint64_t)htonl((x) & 0xFFFFFFFF) << 32) | htonl((x) >> 32))

uint64_t data;
int total_chunks = 1000;
int chunk_size = 8192;
char *tmp = malloc(8192);
FILE *fp = fopen("data.bin","wb");

for (int i = 0; i < total_chunks; i++) {
    for (int j = 0; j < chunk_size; j+=8) {
        memcpy(&data, memory_ptr, 8);
        data = htonll(data);
        memcpy(tmp+j, &data, 8);
        memory_ptr+=8;
    }
    fwrite(tmp, 8192, 1, fp);
    memset(tmp,0,8192);
}

4
stdio使用输出缓冲,因此您不需要尽量减少对fwrite的调用次数。当其内部缓冲区填满时,它会将数据写入文件。 - Barmar
2
我希望这个程序能够尽可能地快速运行——你正在做过多的不必要的memcpy和memset操作。 - Employed Russian
1
@EmployedRussian 注意,编译器会对使用小固定大小的memcpymemset函数调用进行优化。它们通常会删除函数调用,可以删除无用的复制并生成快速指令,如SIMD移动。我更担心的是htonll宏,因为并非所有编译器都能对其进行优化(但好的编译器应该可以)。 - Jérôme Richard
1
你的宏将ABCD更改为DCBA,而不是你描述中的BADC。 - M.M
1
你能否在不分配额外缓冲区的情况下进行原地交换? - Lev M.
显示剩余6条评论
1个回答

4
TL;DR:调整编译器标志(或者如果你的编译器不够聪明,使用SIMD内嵌库),并使用内存映射文件。如果这还不够,可以使用OpenMP和较低级别的API来使用多个线程,以避免许多开销,但代价是代码不太可移植且更加复杂。

使用SIMD指令进行向量化

假设您使用的编译器已经调整好以生成快速代码,并且其启发式算法足够好以生成优化后的代码,则您当前的代码已经相当不错了。请注意,对于小固定大小的memcpymemset函数调用,编译器已经进行了优化(至少对于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的类似接口)。该接口旨在避免复制(即零复制)和系统调用(热循环中没有系统调用),从而产生几乎没有开销的非常快速的代码。但是,直接使用它将使您的代码不太可移植且更加复杂。以上方法对您来说可能已足够。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接