6

概述

我有一个受 IO 显着限制的程序,并试图加快它的速度。使用 mmap 似乎是一个好主意,但与仅使用一系列 fgets 调用相比,它实际上会降低性能。

一些演示代码

我已经将演示压缩到了最基本的部分,针对大约 350 万行的 800mb 文件进行了测试:

使用 fgets:

char buf[4096];
FILE * fp = fopen(argv[1], "r");

while(fgets(buf, 4096, fp) != 0) {
    // do stuff
}
fclose(fp);
return 0;

800mb 文件的运行时:

[juhani@xtest tests]$ time ./readfile /r/40/13479/14960 

real    0m25.614s
user    0m0.192s
sys 0m0.124s

地图版本:

struct stat finfo;
int fh, len;
char * mem;
char * row, *end;
if(stat(argv[1], &finfo) == -1) return 0;
if((fh = open(argv[1], O_RDONLY)) == -1) return 0;

mem = (char*)mmap(NULL, finfo.st_size, PROT_READ, MAP_SHARED, fh, 0);
if(mem == (char*)-1) return 0;
madvise(mem, finfo.st_size, POSIX_MADV_SEQUENTIAL);
row = mem;
while((end = strchr(row, '\n')) != 0) {
    // do stuff
    row = end + 1;
}
munmap(mem, finfo.st_size);
close(fh);

运行时变化很大,但永远不会比 fgets 快:

[juhani@xtest tests]$ time ./readfile_map /r/40/13479/14960

real    0m28.891s
user    0m0.252s
sys 0m0.732s
[juhani@xtest tests]$ time ./readfile_map /r/40/13479/14960

real    0m42.605s
user    0m0.144s
sys 0m0.472s

其他注意事项

  • 观察进程在顶部运行,memmapped 版本在此过程中产生了数千个页面错误。
  • fgets 版本的 CPU 和内存使用率都非常低。

问题

  • 为什么会这样?仅仅是因为 fopen/fgets 实现的缓冲文件访问比使用 madvise POSIX_MADV_SEQUENTIAL 积极预取该 mmap 更好吗?
  • 是否有可能使这更快的替代方法(除了动态压缩/解压缩以将 IO 负载转移到处理器)?查看同一文件上“wc -l”的运行时,我猜可能并非如此。
4

3 回答 3

8

POSIX_MADV_SEQUENTIAL只是对系统的提示,可能会被特定的 POSIX 实现完全忽略。

您的两种解决方案之间的区别在于,mmap需要将文件完全映射到虚拟地址空间,而fgetsIO 完全在内核空间中完成,只是将页面复制到不会更改的缓冲区中。

这也有更多的重叠可能性,因为 IO 是由一些内核线程完成的。

您也许可以mmap通过让一个(或多个)独立线程读取每个页面的第一个字节来提高实现的感知性能。然后,这个(或这些)线程将出现所有页面错误,并且您的应用程序线程将到达它已经加载的特定页面的时间。

于 2011-05-19T09:09:22.367 回答
5

你正在做的事情——阅读整个 mmap 空间——应该会触发一系列页面错误。使用 mmap,操作系统只会将 mmap 数据的页面延迟加载到内存中(在您访问它们时加载它们)。所以这种方法是一种优化。尽管您与 mmap 的交互就像整个事物都在 RAM 中一样,但它并非全部都在 RAM 中——它只是虚拟内存中留出的一个块。

相反,当您将文件读入缓冲区时,操作系统会将整个结构拉入 RAM(进入缓冲区)。这会施加很大的内存压力,挤出其他页面,迫使它们被写回磁盘。如果您的内存不足,它可能会导致抖动。

使用 mmap 时一种常见的优化技术是将数据分页进入内存:循环遍历 mmap 空间,将指针按页面大小递增,每页访问一个字节并触发操作系统将所有 mmap 的页面拉入内存;触发所有这些页面错误。这是一种“启动 RAM”的优化技术,将 mmap 拉入并为将来使用做好准备。使用这种方法,操作系统不需要做太多的延迟加载。您可以在单独的线程上执行此操作,以在主线程访问之前引导页面 - 只要确保您没有耗尽 RAM 或超出主线程太远,您实际上会开始降低性能。

使用 mmap 和 read() 进入大缓冲区的页面行走有什么区别?这有点复杂。

旧版本的 UNIX 和一些当前版本并不总是使用按需分页(内存被分成块并根据需要换入/换出)。相反,在某些情况下,操作系统使用传统的交换——它将内存中的数据结构视为单一的,并且根据需要将整个结构换入/换出。这在处理大文件时可能更有效,其中按需分页需要将页面复制到通用缓冲区缓存中,并且可能导致频繁交换甚至颠簸。交换可以避免使用通用缓冲区缓存 - 减少内存消耗,避免额外的复制操作并避免频繁写入。缺点是您无法从按需寻呼中受益。使用 mmap,您可以保证按需分页;使用 read() 你不是。

还要记住,在完整的 mmap 内存空间中进行页面遍历总是比完全读取慢 60% 左右(如果您使用 MADV_SEQUENTIAL 或其他优化,则不算在内)。

使用带有 MADV_SEQUENTIAL 的 mmap 时的一个注意事项 - 当您使用它时,您必须绝对确定您的数据是按顺序存储的,否则这实际上会使文件的分页速度减慢大约 10 倍。通常,您的数据不会映射到磁盘的连续部分,而是写入分布在磁盘周围的块。所以我建议你要小心,仔细研究一下。

请记住,RAM 中的数据过多会污染 RAM,从而使页面错误在其他地方更常见。关于性能的一个常见误解是 CPU 优化比内存占用更重要。不正确 - 即使使用今天的 SSD,传输到磁盘所需的时间也超过 CPU 操作时间大约 8 个数量级。因此,当关注程序执行速度时,内存占用和利用率要重要得多。

read() 的一个好处是数据可以存储在堆栈中(假设堆栈足够大),这将进一步加快处理速度。

如果适合您的用例,将 read() 与流式方法一起使用是 mmap 的一个很好的替代方案。这就是您对 fgets/fputs 所做的事情(fgets/fputs 在内部通过 read 实现)。在这里,您要做的是,在一个循环中,读入缓冲区,处理数据,然后在下一部分中读取/覆盖旧数据。像这样的流式传输可以使您的内存消耗非常低,并且可能是进行 I/O 的最有效方式。唯一的缺点是您永远不会一次将整个文件保存在内存中,并且它不会保留在内存中。所以这是一种一次性的方法。如果你可以使用它——太好了,那就去做吧。如果不是...使用 mmap。

所以 read 或 mmap 是否更快......这取决于许多因素。测试可能是您需要做的。一般来说,如果您计划长时间使用数据,mmap 会很好,您将从需求分页中受益;或者如果您无法一次处理内存中的大量数据。如果您使用流式传输方法,Read() 会更好 - 数据不必持久化,或者数据可以放入内存中,因此内存压力不是问题。此外,如果数据不会在内存中保存很长时间,read() 可能更可取。

现在,使用您当前的实现 - 这是一种流式传输方法 - 您正在使用 fgets() 并在 \n 上停止。大批量读取比重复调用 read() 一百万次(这是 fgets 所做的)更有效。您不必使用巨大的缓冲区 - 您不想要过多的内存压力(这会污染您的缓存和其他东西),并且系统也有一些它使用的内部缓冲。但是您确实想读入...的缓冲区,比如说 64k 大小。您绝对不想逐行调用 read 。

您可以多线程解析该缓冲区。只需确保线程访问不同缓存块中的数据 - 因此找到缓存块的大小,让您的线程在至少与缓存块大小相距的缓冲区的不同部分上工作。

针对您的特定问题的一些更具体的建议:您可以尝试将数据重新格式化为某种二进制格式。例如,尝试将文件编码更改为自定义格式,而不是 UTF-8 或其他格式。这可能会减小它的大小。350 万行是相当多的字符循环......这可能是你正在做的大约 1.5 亿个字符比较。如果您可以在程序运行之前按行长对文件进行排序......您可以编写一个算法来更快地解析行 - 只需增加一个指针并测试您到达的字符,确保它是'\ n'。然后做你需要做的任何处理。您需要找到一种方法来维护已排序的文件,方法是使用这种方法将新数据插入适当的位置。

您可以更进一步 - 在对文件进行排序后,维护一个列表,列出文件中给定长度的行数。使用它来指导您对行的解析 - 直接跳到每行的末尾而无需进行字符比较。如果您无法对文件进行排序,只需创建从每行开头到其终止换行符的所有偏移量的列表。350 万次抵消。编写算法以在文件中插入/删除行时更新该列表

当您进入诸如此类的文件处理算法时……它开始类似于 noSQL 数据库的实现。另一种方法可能是将所有这些数据插入到 noSQL 数据库中。取决于你需要做什么:信不信由你,有时上面描述的原始自定义文件操作和维护比任何数据库实现都快,甚至是 noSQL 数据库。

还有一些事情:当您将这种流式方法与 read() 一起使用时,您必须注意处理边缘情况 - 您到达一个缓冲区的末尾,并开始一个新的缓冲区 - 适当地。这就是所谓的缓冲缝合。

最后,在大多数现代系统上,当您使用 read() 时,数据仍会存储在通用缓冲区缓存中,然后复制到您的进程中。这是一个额外的复制操作。在处理大文件的某些情况下,您可以禁用缓冲区缓存以加快 IO。请注意,这将禁用分页。但如果数据只在内存中短暂停留,这无关紧要。缓冲区缓存很重要——找到一种在 IO 完成后重新启用它的方法。也许只是为特定进程禁用它,在一个单独的进程中执行你的 IO,或者其他什么......我不确定细节,但这是可以做到的。不过,我认为这实际上不是您的问题,我认为字符比较-一旦您解决了它就可以了。

这是我所拥有的最好的,也许专家会有其他想法。继续前进!

于 2021-01-08T00:39:17.690 回答
4

阅读手册页mmap表明,可以通过添加's 标志MAP_POPULATE来防止页面错误:mmap

MAP_POPULATE (since Linux 2.5.46):为映射填充(预设)页表。对于文件映射,这会导致文件预读。以后对映射的访问不会被页面错误阻塞。

这样,页面错误的预加载线程(如 Jens 所建议的)将变得过时。

编辑: 首先,您所做的基准测试应该在刷新页面缓存的情况下完成,以获得有意义的结果:

    echo 3 | sudo tee /proc/sys/vm/drop_caches

另外:MADV_WILLNEEDwith 的通知madvise将预先设置所需的页面(与POSIX_FADV_WILLNEEDwith fadvise 相同)。目前不幸的是,这些调用会阻塞,直到请求的页面出现故障,即使文档说明不同。但是有一些内核补丁正在进行中,这些补丁将故障前请求排队到内核工作队列中,以使这些调用如人们所期望的那样异步 - 使单独的预读用户空间线程过时。

于 2013-01-19T21:27:26.300 回答