25 Jul 2018

C++ I/O Tips: Parsing Huge Text File

收藏到CSDN网摘

问题提出


最近项目需要读取mesh文件,基本做过CFD的都知道,几千万Elements的mesh文件动辄几个Gb,庞大的体积让各种操作都很不方便.而获取数据需要与磁盘打交道,作为后续处理的第一步,是一切的开端.如果需要读取的文件是二进制格式,会有些帮助.但是不同的网格划分软件大都有其自有格式,等于是带着枷锁跳舞.

注意:这里的测试使用的都是文本文件,可能不适用于二进制文件读写.

下文使用的开发环境: VS2013 + Win10 + i7 CPU/16G Ram.
测试数据是ICEM导出的Star-CD的vrt格式.这种格式可以看作是文本文件,每一行一组数据表示一个Node(也就是一个三维点): Node_ID X Y Z.Mesh有多少个Node,文件就有多少行.



写在前面:


本文测试的过程中还碰到了其他以前写程序并没有特别注意的问题,一并记录下来.

a.计时问题


在matlab中习惯了tic/toc,非常好用,因此写一个C++版本的,习惯测试程序运行时间.绝对时间并不需要非常准确,但是比较程序的效率很有用.以前也写过一个python版本的tic/toc.下面做了全局变量用vector来模拟stack,可以实现tic/toc嵌套使用.

// for nested tic/toc
vector<int> MY_TIME_STACK;

void tic()
{
 MY_TIME_STACK.push_back(clock());
}

void toc(void)
{
 string s("Elapsed time");
 toc(s);
}

void toc(string s)
{
 if (MY_TIME_STACK.empty()) { return; }
 double the_time = MY_TIME_STACK.back();
 MY_TIME_STACK.pop_back();
 double elapse_t = (double)(clock() - the_time) / CLOCKS_PER_SEC;
 cout << s << ": " << setiosflags(ios::fixed) << setprecision(3) << elapse_t << " seconds" << endl;
}

b.程序内存分配问题


全局变量在堆上(heap)分配内存,在函数内部定义的临时变量在栈上(stack)分配内存,可能造成栈溢出.
如果使用了自定义类Point3d,那么下面三种情况的内存分配为:

vector<Point3d> data;   // data在栈上分配,其中的元素在堆上分配
vector<Point3d> *data = new vector<Point3d>;  // data在堆上分配,其中的元素也在堆上分配
vector<Point3d*> data;  // data在栈上分配,其中元素(这里是指针)在堆上分配(类似第一种),但是该指针指向的对象可能在堆上,也可能在栈上.

c.运算符重载来输出/输入自定义类


自定义class需要输入输出时,有一种方法是像python一样,自己实现一个__str__()或者__repr__()函数来转换对象为字符串,让其他接受字符串的函数(例如print()来输出),例如下面的print()函数就是如此.但是如果可以重载>>和<<往往能简化不少操作,可是现实 cout << myObj 或者 cin >> myObj 这样的操作.重载这两个操作符时定义为友元函数有其优点,不必使用 myObj >> cin 和 myObj << cout这种反人类(也跟C++已有的流操作相反)的操作,并且可以串联使用多个 >> 和 <<.

C++primer中有一段解释的话:

如果想要使用重载操作符为该类型提供I/O操作,就必须将它们定义
为非成员函数。IO 操作符通常对非公用数据成员进行读写,因此,类通常将I/O操作符设为友元。
测试中使用到的类Point3d定义:

class Point3d
{
private:
    int n;
    double x;
    double y;
    double z;
public:
    Point3d(void) : n(0), x(0), y(0), z(0) {};
    Point3d(int d, double a, double b, double c) : n(d), x(a), y(b), z(c) {};
    Point3d(const Point3d& p) : n(p.n), x(p.x), y(p.y), z(p.z) {};

    int getID(void) { return this->n; }
    double getX(void) { return this->x; }
    double getY(void) { return this->y; }
    double getZ(void) { return this->z; }

    void update(int d, double a, double b, double c)
    {
        this->n = d;
        this->x = a;
        this->y = b;
        this->z = c;
    }

    void print(void)
    {
        cout << this->n << ": [" << this->x << ", "
            << this->y << ", " << this->z << "]\n";
    }

    // can be used with cout or other output stream <<
    friend ostream &operator<< (ostream &s, const Point3d &p)
    {
        s << setw(10) << p.n << " " <<
            setw(15) << setprecision(9) << p.x << " " <<
            setw(15) << p.y << " " <<
            setw(15) << p.z;
        return s;
    }

    // can be used with cin or other input stream >>
    friend istream &operator>> (istream &s, Point3d &p)
    {
        s >> p.n >> p.x >> p.y >> p.z;
        return s;
    }
};

测试过程


1. FileStream


第一版程序如下(为了简化,类定义都放在另一个helper头文件中):

#include "HelperFuncs.hpp"

void ReadFile(char *fname, vector<Point3d> *pts)
{
 try
 {
  ifstream fin(fname);
  Point3d pt;
  while (fin >> pt)
  {
   pts->push_back(pt);
  }
 }
 catch(...)
 {
  pts->swap(vector<Point3d>());
 }
}

int main(int argc, char* argv[])
{
 if (argc <= 1) return 0;
 vector<Point3d> *pts = new vector<Point3d>();

 tic();
 ReadFile(argv[1],pts);
 toc();

 cout << "Nodes: " << pts->size() << endl;

 delete pts;
 return 0;
}

测试结果:
Elapsed time: 1.109 seconds
Nodes: 54101

看起来不错,这是由于最开始使用的测试文件只有不到4Mb,但是面对1.2Gb左右的大文件,就比较郁闷了.
Elapsed time: 426.618 seconds
Nodes: 18405139

2.增加缓存


搜索了一下,有人提到可能默认的ofstream的缓存针对小文件够用,大文件的时候会增加访问硬盘的次数,可以通过增加缓存来解决.
第二版程序

#include "HelperFuncs.hpp"

#define BUFFER_SIZE (512*1024)

void ReadFile(char *fname, vector<Point3d> *pts)
{
 try
 {
  ifstream fin;
  char buf[BUFFER_SIZE];
  fin.rdbuf()->pubsetbuf(&buf[0], BUFFER_SIZE);
  fin.open(fname, ios_base::in);

  Point3d pt;
  while (fin >> pt)
  {
   pts->push_back(pt);
  }
 }
 catch(...)
 {
  pts->swap(vector<Point3d>());
 }
}

int main(int argc, char* argv[])
{
 if (argc <= 1) return 0;
 vector<Point3d> *pts = new vector<Point3d>();

 tic();
 ReadFile(argv[1],pts);
 toc();

 cout << "Nodes: " << pts->size() << endl;

 delete pts;
 return 0;
}

小文件测试,几乎没有区别?:
Elapsed time: 1.078 seconds
Nodes: 54101

大文件测试一样:
Elapsed time: 421.935 seconds
Nodes: 18405139

3.使用getline()后后scanf的方式


继续research,有人提出使用c函数可能会有性能提高,本来持怀疑态度.不过测试效果让人大吃一惊.
#include "HelperFuncs.hpp"

#define BUFFER_SIZE (512*1024)

void ReadFile(char *fname, vector<Point3d> *pts)
{
 try
 {
  ifstream fin(fname);
  int vid = 0;
  double x, y, z;
  Point3d pt;
  string line;
  while (getline(fin, line))
  {
   sscanf(line.c_str(), "%d %lf %lf %lf", &vid, &x, &y, &z);
   pt.update(vid, x, y, z);
   pts->push_back(pt);
  }
 }
 catch(...)
 {
  pts->swap(vector<Point3d>());
 }
}

int main(int argc, char* argv[])
{
 if (argc <= 1) return 0;
 vector<Point3d> *pts = new vector<Point3d>();

 tic();
 ReadFile(argv[1],pts);
 toc();

 cout << "Nodes: " << pts->size() << endl;

 delete pts;
 return 0;
}

小文件测试飞速
Elapsed time: 0.124 seconds
Nodes: 54101

大文件也有8-9倍的速度提升
Elapsed time: 50.151 seconds
Nodes: 18405139


4.全部使用C版本的I/O函数呢?


第三步的测试让人很受鼓舞,继续思索,如果全部换成C风格的函数会不会进一步提升速度呢?FILE和fgets().

#include "HelperFuncs.hpp"

void ReadFile(char *fname, vector<Point3d> *pts)
{
 try
 {
  // line is not longer than 255 in the mesh file
  char buf[255];
  int vid = 0;
  double x, y, z;
  Point3d pt;
  FILE *fin = fopen(fname,"r");
  if (fin == NULL) return;

  
  while (fgets(buf,255,fin) != NULL)
  {
   sscanf(buf, "%d %lf %lf %lf", &vid, &x, &y, &z);
   pt.update(vid, x, y, z);
   pts->push_back(pt);
  }

  fclose(fin);
 }
 catch(...)
 {
  pts->swap(vector<Point3d>());
 }
}

int main(int argc, char* argv[])
{
 if (argc <= 1) return 0;
 vector<Point3d> *pts = new vector<Point3d>();

 tic();
 ReadFile(argv[1],pts);
 toc();

 cout << "Nodes: " << pts->size() << endl;

 delete pts;
 return 0;
}


小文件测试比第三步略有提升,但是效果不明显
Elapsed time: 0.093 seconds
Nodes: 54101

但是对于大文件能看出还有进一步提升的空间
Elapsed time: 37.883 seconds
Nodes: 18405139

5.Boost库的内存映射


查找资料的过程中,有人提到了内存映射,相当于将文件作为整体直接映射到内存中(相当于只有一次I/O操作?),然后在内存中对数据进行操作.在Linux系统中实做了mmap()来做内存映射.Windows也有自己的API,但是针对C++来说,Boost库的内存映射使用起来更方便.

#include "HelperFuncs.hpp"

#include <boost/spirit/include/qi.hpp>
#include <boost/iostreams/device/mapped_file.hpp>
namespace qi = boost::spirit::qi;

void ReadFile(char *fname, vector<Point3d> *pts)
{
 try
 {
  boost::iostreams::mapped_file mmap(fname, boost::iostreams::mapped_file::readonly);
  auto f = mmap.const_data();
  auto l = f + mmap.size();
  using namespace qi;
  vector<double> data;
  // parse all mapped content to a vector
  // format: int double double double
  // jump over all blank characters (space or tab)
  tic();
  bool ok = phrase_parse(f, l, (int_ > double_ > double_ > double_) % eol, blank, data);
  toc("parse file");

  // reserve space to speedup
  pts->reserve(data.size());

  tic();
  Point3d pt;
  for (auto i = 0; i < data.size(); i+=4)
  {
   pt.update(data.at(i), data.at(i + 1), data.at(i + 2), data.at(i + 3));
   pts->push_back(pt);
  }
  toc("insert to vector");
 }
 catch(...)
 {
  pts->swap(vector<Point3d>());
 }
}

int main(int argc, char* argv[])
{
 if (argc <= 1) return 0;
 vector<Point3d> *pts = new vector<Point3d>();

 tic();
 ReadFile(argv[1],pts);
 toc();

 cout << "Nodes: " << pts->size() << endl;

 delete pts;
 return 0;
}

再次读取有五万四千多Node的小文件,转瞬间就结束
parse file: 0.016 seconds
insert to vector: 0.002 seconds
Elapsed time: 0.022 seconds
Nodes: 54101

一千八百四十多万Node的大文件的测试,也是非常惊人
parse file: 7.928 seconds
insert to vector: 1.246 seconds
Elapsed time: 9.242 seconds
Nodes: 18405139

Note: boost库只做了简单了解,使用了phrase_parse()读取到vector再创建Point3d并加入结果的两步走的方法.对于boost库中更高级的parsing rule等其他知识点并无涉猎.但是测试结果显示,相对于8s左右的读取部分,不到2s的插入vector算是可以接受的,毕竟有一千多万个Node需要update后append.如果实际使用中,到了亿级别的数量,估计遍历vector也会花费不少时间.到那时,再精研boost的高级rule来避免后面的遍历可以获得更好的性能,这是后话.

结论


从上面的简单比较,可以得出结论:boost的内存映射的确是读取超大文本文件最快的方法,从最初的426s到最后的9.2s,速度提升接近50倍(46.3).但是需要引入第三方库,尽管这是一个免费且很流行的库.

如果实际需求读取的文本文件不大,可以采用文件输入输出流来操作;感觉到性能有需求,可以用c函数来加快读取速度;当数据量达到千万/亿级别的时候,再使用boost库做内存映射.毕竟杀鸡焉用牛刀!

后记


这篇文章中,有人提到一些有趣的性能测试数据(使用ssd硬盘):

  • IO流复合操作大概10MB/s,纯读取(fin >> a >> b >> c这种)比getline然后再sstringstream分析要快
  • C函数向fscanf这种大约是40MB/s
  • getline无其他操作,大约180MB/s
  • fread可以达到500~800MB/s

本文最终的测试,大约是150MB/s.考虑到我使用的并非固态硬盘,比较这篇文章的数据来看,150MB/s对于传统硬盘来说,算是很好的速度了.

1 comment :