【问题标题】:C++ - Why is reading lines from a file by tokenizer is so slow?C++ - 为什么通过标记器从文件中读取行这么慢?
【发布时间】:2012-09-10 02:26:27
【问题描述】:

我正在尝试从文件中读取 200,000 条记录,然后使用 tokenizer 解析字符串并删除每个部分周围的引号。但是与正常读取字符串相比,运行时间非常高。读取这些记录需要 25 秒(每条记录 0.0001 秒????)。我的编程有什么问题吗?如果没有,有更快的方法吗?

int main()
{
    int counter = 0;
    std::string getcontent;
    std::vector<std::string> line;
    std::vector< std::vector<std::string> > lines;

    boost::escaped_list_separator<char> sep( '\\', '*', '"' ) ;
    boost::tokenizer<> tok(getcontent);

    std::ifstream openfile ("test.txt");

    if(openfile.is_open())
    {
        while(!openfile.eof())
        {
            getline(openfile,getcontent);

            // THIS LINE TAKES A LOT OF TIME
            boost::tokenizer<> tok(getcontent); 

            for (boost::tokenizer<>::iterator beg=tok.begin(); beg!=tok.end(); ++beg){
                line.push_back(*beg);
            }

            lines.push_back(line);
            line.clear();
            counter++;
        }
        openfile.close();
    }
    else std::cout << "No such file" << std::endl;

    return 0;
}

【问题讨论】:

  • 所以你只是想读取一个没有引号的文件?
  • 虽然无关紧要,但while (!ioenfile.eof()) 是错误的说法。使用while (getline(openfile, getcontent))
  • 请不要在没有 eof 时循环。那不会有理想的行为。如果!openfile.eof() 为真,则不能保证读取会成功。见stackoverflow.com/questions/5605125/…
  • 我想做的就是阅读大量记录并提取它们的部分。其中一些有引号,但引号内没有任何特殊字符。
  • @POD:如果你只是想去掉引号,你可以很容易地做到这一点,而无需使用 boost::tokenizer。但是,您仍然没有明确说明这是否足以满足您的需求。

标签: c++ parsing tokenize


【解决方案1】:

至少如果我没看错的话,我想我会采取一种更像 C 的方法。不是读取一行,然后将其分解为标记,并剥离您不想要的字符,而是一次读取一个字符,并根据我读取的字符,决定是否将其添加到当前记号,结束记号并将其添加到当前行,或者结束行并将其添加到行向量中:

#include <vector>
#include <string>
#include <stdio.h>
#include <time.h>

std::vector<std::vector<std::string> > read_tokens(char const *filename) {
    std::vector<std::vector<std::string> > lines;
    FILE *infile= fopen(filename, "r");

    int ch;

    std::vector<std::string> line;
    std::string token;

    while (EOF != (ch = getc(infile))) {
        switch(ch) {
            case '\n':
                lines.push_back(line);
                line.clear();
                token.clear();
                break;
            case '"':
                break;
            case '*':
                line.push_back(token);
                token.clear();
                break;
            default:
                token.push_back(ch);
        }
    }
    return lines;
}

int main() {
    clock_t start = clock();
    std::vector<std::vector<std::string> > lines = read_tokens("sample_tokens.txt");
    clock_t finish = clock();
    printf("%f seconds\n", double(finish-start)/CLOCKS_PER_SEC);
    return 0;
}

使用您在评论中提供的样本的 20 万多份副本对此文件进行快速测试,它正在读取并(显然)使用 gcc 大约 3.5 秒或使用 VC++ 大约 4.5 秒标记数据。看到任何东西都变得更快(至少没有更快的硬件),我会有点惊讶。

顺便说一句,这是像您最初那样处理内存,这(至少在我看来)是非常有力的证据,表明在向量中管理内存可能不是主要瓶颈。

【讨论】:

  • @JerryCoffin Paddy 还提到首先将整个文件读入内存。这将如何影响速度?这有很大的不同吗?
  • @pod 如果你把它变成字符串,那也没什么用。我说的是阅读全部内容,就地标记它,并对其进行索引(因此您的字符串将是 char*)。如果我真的关心速度,我只会这样做。
  • @pod 该数据文件是否适合 RAM?它有多大?您需要一次将整个内容存储在内存中吗?
  • 将其全部读入内存并从那里进行标记化,对于 1 亿条记录来说可能是值得的。如果你在 Linux 上,最好使用mmap 而不是直接阅读它。为了说明您的期望,请考虑我链接到​​的用于将文件读入字符串的其他代码在 0.3 秒内读取当前测试文件。但是,我会使用原始char * 作为标记——我会将它封装在一个string_ref 类中,该类包含一个char * 和长度,或者另一个指向“字符串”的结尾。
  • @paddy 这 200,000 条记录只是一个非常小的样本。我的原始文件有超过 1 亿条记录。我认为速度在这里很重要。
【解决方案2】:

而不是 boost::tokenizer&lt;&gt; tok(getcontent);,每次调用 getline 时都会构造一个新的 boost::tokenizer。使用assign成员函数:

boost::escaped_list_separator<char> sep( '\\', '*', '"' ) ;
boost::tokenizer<boost::escaped_list_separator<char>> tok(getcontent, sep);

// Other code
while(getline(openfile,getcontent))
{
    tok.assign(getcontent.begin(), getcontent.end()); // Use assign here
    line.assign(tok.begin(), tok.end()); // Instead of for-loop
    lines.push_back(line);
    counter++;
}

看看是否有帮助。 另外,如果可能,请尝试预先分配向量内存。

【讨论】:

  • @POD:抱歉,我使用的是 C++11 语言功能 (auto),我将代码改回了 C++03 中的样子。
  • @POD:我将assign 改为使用tok.assign(getcontent.begin(), getcontent.end());,因为sep 永远不会改变。
  • @POD:我再次修改了代码,去掉了 for 循环和clear
  • 代码很干净,但是性能和我的没啥区别。运行大约需要 50 秒。
  • @POD:是的,我并不感到惊讶。 See this link,他们的基准在 boost::tokenizerStrTk 之间,通常 boost::tokenizer 最多慢 3 倍!
【解决方案3】:

好的,从 cmets 看来,您需要一个尽可能快的解决方案。

为了达到接近该要求,我将采取以下措施。

虽然您可能会获得一个内存池分配器来分配您的字符串,但 STL 不是我的强项,所以我将手动完成。请注意,这不一定是 C++ 方法。所以 C++-heads 可能会有点畏缩。有时,当您想要一些专业的东西时,您只需要这样做。

所以,您的数据文件大约为 10 GB...将其分配在单个块中是个坏主意。您的操作系统很可能会拒绝。但是可以把它分成一大堆相当大的块。也许这里有一个神奇的数字,但我们说大约 64MB。是寻呼专家的人可以在这里发表评论吗?我记得有一次读到,使用小于精确的页面大小倍数是很好的(虽然我不记得为什么),所以让我们撕掉几个 kB:

const size_t blockSize = 64 * 1048576 - 4096;

现在,一个结构来跟踪你的记忆怎么样?不妨把它列个清单,这样你就可以把它们放在一起。

struct Block {
    SBlock *next;
    char *data;    // Some APIs use data[1] so you can use the first element, but
                   // that's a hack that might not work on all compilers.
} SBlock;

是的,所以您需要分配一个块 - 您将分配一大块内存并使用第一个小块来存储一些信息。请注意,如果需要对齐内存,可以更改data 指针:

SBlock * NewBlock( size_t blockSize, SBlock *prev = NULL )
{
    SBlock * b = (SBlock*)new char [sizeof(SBlock) + blockSize];
    if( prev != NULL ) prev->next = b;
    b->next = NULL;
    b->data = (char*)(blocks + 1);      // First char following struct
    b->length = blockSize;
    return b;
}

现在你要读...

FILE *infile = fopen( "mydata.csv", "rb" );  // Told you C++ers would hate me

SBlock *blocks = NULL;
SBlock *block = NULL;
size_t spilloverBytes = 0;

while( !feof(infile) ) {
    // Allocate new block.  If there was spillover, a new block will already
    // be waiting so don't do anything.
    if( spilloverBytes == 0 ) block = NewBlock( blockSize, block );

    // Set list head.
    if( blocks == NULL ) blocks = block;

    // Read a block of data
    size_t nBytesReq = block->length - spilloverBytes;
    char* front = block->data + spilloverBytes;
    size_t nBytes = fread( (void*)front, 1, nBytesReq, infile );
    if( nBytes == 0 ) {
        block->length = spilloverBytes;
        break;
    }

    // Search backwards for a newline and treat all characters after that newline
    // as spillover -- they will be copied into the next block.
    char *back = front + nBytes - 1;
    while( back > front && *back != '\n' ) back--;
    back++;

    spilloverBytes = block->length - (back - front);
    block->length = back - block->data;

    // Transfer that data to a new block and resize current block.
    if( spilloverBytes > 0 ) {
        block = NewBlock( blockSize, block );
        memcpy( block->data, back, spilloverBytes );
    }
}

fclose(infile);

好的,类似的。你明白了。请注意,此时您读取文件的速度可能比多次调用std::getline 快得多。如果您可以禁用任何缓存,您仍然可以变得更快。在 Windows 中,您可以使用 CreateFile API 并对其进行调整以实现真正的快速读取。因此,我之前关于可能对齐数据块(与磁盘扇区大小)的评论。不确定 Linux 或其他操作系统。

所以,这是一种将整个文件放入内存的复杂方法,但它足够简单,易于访问且具有适度的灵活性。希望我没有犯太多错误。现在你只想浏览你的块列表并开始索引它们。

我不打算在这里详细介绍,但总体思路是这样的。您可以通过在适当的位置闪现 NULL 值并跟踪每个标记的开始位置来就地标记化。

SBlock *block = blocks;

while( block ) {
    char *c = block->data;
    char *back = c + block->length;
    char *token = NULL;

    // Find first token
    while( c != back ) {
        if( c != '"' && c != '*' ** c != '\n' ) break;
        c++;
    }
    token = c;

    // Tokenise entire block
    while( c != back ) {
        switch( *c ) {
            case '"':
                // For speed, we assume all closing quotes have opening quotes.  If
                // we have closing quote without opening quote, this won't be correct
                if( token != c) {
                    *c = 0;
                    token++;
                }
                break;

            case '*':
                // Record separator
                *c = 0;
                tokens.push_back(token);  // You can do better than this...
                token = c + 1;
                break;

            case '\n':
                // Record and line separator
                *c = 0;
                tokens.push_back(token);  // You can do better than this...
                lines.push_back(tokens);  // ... and WAY better than this...
                tokens.clear();           // Arrrgh!
                token = c + 1;
                break;
        }

        c++;
    }

    // Next block.
    block = block->next;
}

最后,您将在上面看到那些类似矢量的调用。现在,再次,如果你可以记忆你的向量,那是伟大而简单的。但是再一次,我只是从不这样做,因为我发现直接使用内存更直观。您可以执行与我对文件块所做的类似的操作,但为数组(或列表)创建内存。您将所有令牌(只是 8 字节指针)添加到此内存区域并根据需要添加新的内存块。

您甚至可以制作一个小标题来跟踪其中一个令牌数组中有多少项。关键是永远不要计算一次以后可以计算而无需额外成本的东西(即数组大小——您只需要在添加最后一个元素后计算它)。

你对线条再次做同样的事情。你所需要的只是一个指向标记块中相关部分的指针(如果你想要数组索引,如果一行吃进一个新块,你必须做溢出的事情)。

你最终会得到一个指向标记数组的行数组,它直接指向你从文件中吞下的内存。虽然有一点内存浪费,但可能并不过分。这是您为快速编写代码所付出的代价。

我相信这一切都可以完美地包含在几个简单的类中,但我在这里将它原封不动地提供给你。即使您将一堆 STL 容器进行内存池化,我预计这些分配器的开销以及容器本身仍然会比我给您的速度慢。很抱歉,答案很长。我想我只是喜欢这些东西。玩得开心,希望对您有所帮助。

【讨论】:

  • @peddy 这是对内存分配的一个很好的解释。
  • 不用担心...顺便说一句,不要低估读取数据的瓶颈。我已经为您提供了快速标记并避免了过多的小内存分配,这是一个很大的改进。但在我在 Windows 上的测试中,fread 的速度大约是 CreateFile 的 60%,但关闭了缓冲/缓存。如果您的磁盘可以提供 100 MB/s 的优化速度,而不是 60 MB/s 的缓冲/缓存,那将是一个很大的改进。
猜你喜欢
  • 2018-09-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-03-11
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多