为什么用mmap顺序读取一个大文件,而madvise顺序比fgets慢?

时间:2011-05-19 08:31:23

标签: c mmap systems-programming

概述

我有一个由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

mmap版本:

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'的运行时,我猜这可能不是这种情况。

3 个答案:

答案 0 :(得分:4)

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

两个解决方案之间的区别在于mmap要求将文件映射到虚拟地址空间中,而fgets在内核空间中完全完成IO并将页面复制到缓冲区不会改变。

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

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

答案 1 :(得分:4)

您正在做的事情 - 读取整个 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 优化比内存占用更重要。不正确 - 传输到磁盘所需的时间比 CPU 操作时间高出大约 8 个数量级,即使使用今天的 SSD。因此,当关注程序执行速度时,内存占用和利用率就显得尤为重要。

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 或其他事情...我不确定细节,但这是可以完成的事情。 不过,我认为这实际上不是你的问题,但我认为角色比较 - 一旦你修复它应该没问题。

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

答案 2 :(得分:3)

阅读mmap的手册页,可以通过将MAP_POPULATE添加到mmap的标记来防止页面错误:

  

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

这样一个页面错误预加载线程(由Jens建议)将变得过时。

修改 首先,您应该使用页面缓存刷新来获得有意义的结果:

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

此外:MADV_WILLNEED的{​​{1}}建议会对所需的网页进行预先排除(与madvise fadvise相同)。目前很遗憾,这些调用会阻塞,直到请求的页面出现故障,即使文档说明的方式不同。但是正在进行内核补丁,它们将故障前请求排入内核工作队列,以使这些调用异步,就像预期的那样 - 使单独的预读用户空间线程过时。