问题提出
最近项目需要读取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操作,就必须将它们定义测试中使用到的类Point3d定义:
为非成员函数。IO 操作符通常对非公用数据成员进行读写,因此,类通常将I/O操作符设为友元。
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对于传统硬盘来说,算是很好的速度了.
c ++代码片段
ReplyDelete演示文件随机访问