为什么并行阅读大文本文件不好?

时间:2018-02-10 10:55:53

标签: c++ performance io parallel-processing

我有一个包含大约3000万行的大文本文件,每行由一个行分隔符\n分隔。我想将所有行都读到无序列表(例如std::list<std::string>)。

std::list<std::string> list;
std::ifstream file(path);
while(file.good())
{
    std::string tmp;
    std::getline(file, tmp);
    list.emplace_back(tmp);
}
process_data(list);

目前的实施速度很慢,所以我学习了如何按块读取数据。

但在看到this comment之后:

  在HDD上并行化将使事情变得更糟,其影响取决于HDD上文件的分布。在SSD上它可以(!)改进。

并行读取文件是不是很糟糕?什么算法将文件的所有行尽可能快地读取到无序容器(例如std::list,普通数组,...),而不使用任何库,并且代码必须是交叉的平台?

1 个答案:

答案 0 :(得分:4)

  

并行读取文件是不是很糟糕?什么算法读取所有   文件行到无序容器(例如std :: list,normal   数组,...)尽可能快,不使用任何库,和   代码必须是跨平台的吗?

我想我会尝试回答这个问题,以避免垃圾评论。在多种情况下,我已经基本上使用多线程加速了文本文件解析。但是,这里的关键字是解析,而不是磁盘I / O(尽管任何文本文件读取涉及某种程度的解析)。现在首先要做的事情是:

enter image description here

VTune告诉我,我的顶级热点正在解析(抱歉,这张图片是在几年前拍摄的,我没有扩展调用图,以显示obj_load内部大部分时间内的内容,但它是sscanf)。这个分析会议实际上让我感到很惊讶。尽管已经进行了几十年的分析,但我的预感并不太准确(不够准确以至于无法进行分析,请注意,甚至不要接近,但我已经足够调整了我的直觉蜘蛛感觉即使没有任何明显的算法效率低下,分析会议通常也不会让我感到惊讶 虽然我可能仍然不知道为什么它们存在,因为我不太擅长组件)。

然而这次我真的被收回并感到震惊所以这个例子一直是我曾经表现出来的,即使是那些不想使用剖析器来表达为什么剖析如此重要的最持怀疑态度的同事。他们中的一些人实际上善于猜测热点存在的地方,有些人实际上创造了非常有能力的解决方案,尽管从未使用过它们,但他们都没有擅长猜测不是一个热点,他们都不能根据他们的预感绘制一个调用图。所以我总是喜欢用这个例子来试图转换怀疑论者并让他们花一天时间试试VTune(我们有一大堆免费许可证来自与我们合作的英特尔,这很大程度上会浪费在我们的团队上我认为这是一个悲剧,因为VTune是一个非常昂贵的软件。)

这次我被收回的原因不是因为我对sscanf热点感到惊讶。对于史诗文本文件的非平凡解析通常会受到字符串解析的瓶颈,这是一种明智的选择。我猜对了。从未接触过探查器的同事们可能已经猜到了这一点。我无法猜到的是它有多大的瓶颈。考虑到我正在加载数百万个多边形和顶点,纹理坐标,法线,创建边缘和查找邻接数据这一事实,使用索引FOR压缩,将MTL文件中的材料与多边形相关联,存储在OBJ中的逆向工程对象法线归档并合并它们以形成边缘折痕等。我至少会在网格系统中分配很多时间(我猜想在网格引擎中花费了25-33%的时间)。

原来,网状系统几乎没有时间用于我最惊喜的事情,而且我的预感完全是关于它的。到目前为止,解析这是一个超级瓶颈(不是磁盘I / O,而不是网格引擎)。

因此,当我将这种优化应用于多线程解析时,在那里它帮助了很多。我甚至最初开始使用一个非常适度的多线程实现,几乎没有进行任何解析,除了在每个线程中扫描行结束的字符缓冲区以最终在加载线程中解析,并且已经帮助了相当大的数量(减少了操作来自16秒到大约14 IIRC,我最终把它降到了大约8秒,这是在一个只有两个内核和超线程的i3上。所以,无论如何,你可以通过多线程解析从单个线程中的文本文件读入的字符缓冲区来加快速度。我不会使用线程来更快地制作磁盘I / O.

我将二进制文件中的字符读入单个线程中的大字符缓冲区,然后使用并行循环让线程计算出该缓冲区中行的整数范围。

// Stores all the characters read in from the file in big chunks.
// This is shared for read-only access across threads.
vector<char> buffer;

// Local to a thread:
// Stores the starting position of each line.
vector<size_t> line_start;
// Stores the assigned buffer range for the thread:
size_t buffer_start, buffer_end;

基本上如此:

enter image description here

LINE1和LINE2被认为属于THREAD 1,而LINE3被认为属于THREAD 2. LINE6不被认为属于任何线程,因为它没有EOL。相反,LINE6的字符将与从文件中读取的下一个粗块缓冲区组合。

每个线程首先查看其指定字符缓冲区范围内的第一个字符。然后它向后工作,直到找到EOL或到达缓冲区的开头。之后,它向前工作并解析每一行,寻找EOL并执行我们想要的任何其他操作,直到它到达其指定的字符缓冲区范围的末尾。最后一条&#34;不完整的行&#34;不是由线程处理,而是由下一个线程处理(或者如果线程是最后一个线程,则在第一个线程读取的下一个大块缓冲区处理它)。这个图很小(不太适合)但我在线程中以大块(兆字节)读取文件中的字符缓冲区,然后线程在并行循环中解析它们,然后每个线程可以解析数千个来自指定缓冲区的行。

std::list<std::string> list;
std::ifstream file(path);

while(file.good())
{
    std::string tmp;
    std::getline(file, tmp);
    list.emplace_back(tmp);
}
process_data(list);

有点回应Veedrac的评论,如果你想真正快速加载史诗般的行数,那么将行存储在std::list<std::string>中并不是一个好主意。与多线程相比,这实际上是一个更重要的优先事项。我将其转换为std::vector<char> all_lines存储所有字符串,您可以使用std::vector<size_t> line_start存储nth行的起始行位置,您可以像这样检索:

// note that 'line' will be EOL-terminated rather than null-terminated 
// if it points to the original buffer.
const char* line = all_lines.data() + line_start[n];

没有自定义分配器的std::list的直接问题是每个节点的堆分配。最重要的是,我们浪费了每行存储两个额外指针的内存。 std::string在这里是有问题的,因为SBO优化以避免堆分配将使得它为小字符串占用太多内存(从而增加缓存未命中)或者仍然最终为每个非小字符串调用堆分配。因此,您最终会避免所有这些问题,只需将所有内容存储在一个巨型字符缓冲区中,例如std::vector<char>。 I / O流,包括字符串流和像getline这样的函数,对于性能而言也很可怕,只是非常糟糕,因为我的第一个OBJ加载器使用它们的速度让我感到非常失望,它比第二个慢了20多倍我移植了所有这些I / O流操作符和函数以及使用std::string来使用C函数和我自己在char缓冲区上运行的手工操作的版本。在性能关键的上下文中进行解析时,像sscanfmemchr这样的C函数和普通的旧字符缓冲区往往比C ++的方式快得多,但你至少还是可以使用std::vector<char>存储大量缓冲区,例如,以避免处理malloc/free并在访问存储在其中的字符缓冲区时获得一些调试构建健全性检查。