Почему разделение строки медленнее в C ++, чем в Python? - PullRequest
89 голосов
/ 21 февраля 2012

Я пытаюсь преобразовать некоторый код из Python в C ++, чтобы немного повысить скорость и отточить свои ржавые навыки C ++.Вчера я был шокирован, когда наивная реализация чтения строк из stdin была намного быстрее в Python, чем в C ++ (см. this ).Сегодня я наконец-то понял, как разбить строку в C ++ с помощью слитных разделителей (семантика похожа на python split ()), и теперь я испытываю дежа вю!Мой код на C ++ занимает намного больше времени (хотя и не на порядок больше, как в случае с вчерашним уроком).

Python Code:

#!/usr/bin/env python
from __future__ import print_function                                            
import time
import sys

count = 0
start_time = time.time()
dummy = None

for line in sys.stdin:
    dummy = line.split()
    count += 1

delta_sec = int(time.time() - start_time)
print("Python: Saw {0} lines in {1} seconds. ".format(count, delta_sec), end='')
if delta_sec > 0:
    lps = int(count/delta_sec)
    print("  Crunch Speed: {0}".format(lps))
else:
    print('')

C ++ Код:

#include <iostream>                                                              
#include <string>
#include <sstream>
#include <time.h>
#include <vector>

using namespace std;

void split1(vector<string> &tokens, const string &str,
        const string &delimiters = " ") {
    // Skip delimiters at beginning
    string::size_type lastPos = str.find_first_not_of(delimiters, 0);

    // Find first non-delimiter
    string::size_type pos = str.find_first_of(delimiters, lastPos);

    while (string::npos != pos || string::npos != lastPos) {
        // Found a token, add it to the vector
        tokens.push_back(str.substr(lastPos, pos - lastPos));
        // Skip delimiters
        lastPos = str.find_first_not_of(delimiters, pos);
        // Find next non-delimiter
        pos = str.find_first_of(delimiters, lastPos);
    }
}

void split2(vector<string> &tokens, const string &str, char delim=' ') {
    stringstream ss(str); //convert string to stream
    string item;
    while(getline(ss, item, delim)) {
        tokens.push_back(item); //add token to vector
    }
}

int main() {
    string input_line;
    vector<string> spline;
    long count = 0;
    int sec, lps;
    time_t start = time(NULL);

    cin.sync_with_stdio(false); //disable synchronous IO

    while(cin) {
        getline(cin, input_line);
        spline.clear(); //empty the vector for the next line to parse

        //I'm trying one of the two implementations, per compilation, obviously:
//        split1(spline, input_line);  
        split2(spline, input_line);

        count++;
    };

    count--; //subtract for final over-read
    sec = (int) time(NULL) - start;
    cerr << "C++   : Saw " << count << " lines in " << sec << " seconds." ;
    if (sec > 0) {
        lps = count / sec;
        cerr << "  Crunch speed: " << lps << endl;
    } else
        cerr << endl;
    return 0;

//compiled with: g++ -Wall -O3 -o split1 split_1.cpp

Обратите внимание, что я пробовал две разные реализации раскола.Один (split1) использует строковые методы для поиска токенов и может объединять несколько токенов, а также обрабатывать многочисленные токены (это происходит от здесь ).Второй (split2) использует getline для чтения строки как потока, не объединяет разделители и поддерживает только один символ разделителя (тот, который был опубликован несколькими пользователями StackOverflow в ответах на вопросы о разделении строк).

Я запускал это несколько раз в разных порядках.Мой тестовый компьютер - Macbook Pro (2011, 8 ГБ, Quad Core), но это не так уж важно.Я тестирую текстовый файл размером 20 Мб с тремя разделенными пробелами столбцами, каждый из которых выглядит примерно так: "foo.bar 127.0.0.1 home.foo.bar"

Результаты:

$ /usr/bin/time cat test_lines_double | ./split.py
       15.61 real         0.01 user         0.38 sys
Python: Saw 20000000 lines in 15 seconds.   Crunch Speed: 1333333
$ /usr/bin/time cat test_lines_double | ./split1
       23.50 real         0.01 user         0.46 sys
C++   : Saw 20000000 lines in 23 seconds.  Crunch speed: 869565
$ /usr/bin/time cat test_lines_double | ./split2
       44.69 real         0.02 user         0.62 sys
C++   : Saw 20000000 lines in 45 seconds.  Crunch speed: 444444

Что я делаю не так?Есть ли лучший способ сделать разделение строк в C ++, который не зависит от внешних библиотек (то есть без повышения), поддерживает объединение последовательностей разделителей (например, разделение Python), является потокобезопасным (так что нет strtok), и производительность которого по крайней меренаравне с python?

Редактировать 1 / Частичное решение?:

Я попытался сделать это более справедливым сравнением, заставив python сбрасывать фиктивный список и добавлять к нему каждыйвремя, как в C ++.Это все еще не совсем то, что делает код C ++, но это немного ближе.По сути, цикл теперь такой:

for line in sys.stdin:
    dummy = []
    dummy += line.split()
    count += 1

Производительность python теперь примерно такая же, как и в реализации split1 C ++.

/usr/bin/time cat test_lines_double | ./split5.py
       22.61 real         0.01 user         0.40 sys
Python: Saw 20000000 lines in 22 seconds.   Crunch Speed: 909090

Я все еще удивлен, что, даже если Python настолько оптимизирован для обработки строк (как предложил Мэтт Джоинер), эти реализации C ++ не будут быстрее.Если у кого-то есть идеи о том, как сделать это более оптимальным способом с помощью C ++, поделитесь своим кодом.(Я думаю, что моим следующим шагом будет попытка реализовать это на чистом C, хотя я не собираюсь жертвовать производительностью программиста, чтобы заново реализовать мой общий проект на C, так что это будет просто эксперимент по скорости разделения строк.)

Спасибо всем за помощь.

Окончательное редактирование / решение:

Пожалуйста, ознакомьтесь с принятым ответом Альфа.Поскольку python имеет дело со строками строго по ссылке, а строки STL часто копируются, производительность лучше в реализациях vanilla python.Для сравнения, я скомпилировал и прогнал свои данные с помощью кода Альфа, и здесь приведена производительность на том же компьютере, что и на всех других запусках, по сути, идентичная реализации наивного Python (хотя и быстрее, чем реализация Python, которая сбрасывает / добавляет список, так каккак показано в приведенном выше редакторе):

$ /usr/bin/time cat test_lines_double | ./split6
       15.09 real         0.01 user         0.45 sys
C++   : Saw 20000000 lines in 15 seconds.  Crunch speed: 1333333

Единственное, что мне осталось, - это объем кода, необходимый для выполнения C ++ в этом случае.

Один из уроков этого выпуска и вчерашней проблемы чтения строк стандартного ввода (ссылка выше) заключается в том, что следует всегда ставить тесты вместо того, чтобы делать наивные предположения об относительной производительности языков по умолчанию.Я ценю образование.

Еще раз спасибо всем за ваши предложения!

Ответы [ 8 ]

54 голосов
/ 21 февраля 2012

Предполагается, что строки Python являются неизменяемыми строками с подсчетом ссылок, поэтому в коде Python строки не копируются, а C ++ std::string является типом изменяемого значения и копируется при наименьшей возможности.

Если целью является быстрое разбиение, то можно использовать операции подстроки с постоянным временем, что означает только ссылку на части исходной строки, как в Python (и Java, и C #…).

Класс C ++ std::string имеет одну функцию выкупа, хотя: это стандарт , так что его можно использовать для безопасной и переносимой передачи строк там, где эффективность не является основным соображением.Но достаточно поболтать.Код - и на моей машине это, конечно, быстрее, чем Python, поскольку обработка строк Python реализована в C, который является подмножеством C ++ (хе-хе):

#include <iostream>                                                              
#include <string>
#include <sstream>
#include <time.h>
#include <vector>

using namespace std;

class StringRef
{
private:
    char const*     begin_;
    int             size_;

public:
    int size() const { return size_; }
    char const* begin() const { return begin_; }
    char const* end() const { return begin_ + size_; }

    StringRef( char const* const begin, int const size )
        : begin_( begin )
        , size_( size )
    {}
};

vector<StringRef> split3( string const& str, char delimiter = ' ' )
{
    vector<StringRef>   result;

    enum State { inSpace, inToken };

    State state = inSpace;
    char const*     pTokenBegin = 0;    // Init to satisfy compiler.
    for( auto it = str.begin(); it != str.end(); ++it )
    {
        State const newState = (*it == delimiter? inSpace : inToken);
        if( newState != state )
        {
            switch( newState )
            {
            case inSpace:
                result.push_back( StringRef( pTokenBegin, &*it - pTokenBegin ) );
                break;
            case inToken:
                pTokenBegin = &*it;
            }
        }
        state = newState;
    }
    if( state == inToken )
    {
        result.push_back( StringRef( pTokenBegin, &*str.end() - pTokenBegin ) );
    }
    return result;
}

int main() {
    string input_line;
    vector<string> spline;
    long count = 0;
    int sec, lps;
    time_t start = time(NULL);

    cin.sync_with_stdio(false); //disable synchronous IO

    while(cin) {
        getline(cin, input_line);
        //spline.clear(); //empty the vector for the next line to parse

        //I'm trying one of the two implementations, per compilation, obviously:
//        split1(spline, input_line);  
        //split2(spline, input_line);

        vector<StringRef> const v = split3( input_line );
        count++;
    };

    count--; //subtract for final over-read
    sec = (int) time(NULL) - start;
    cerr << "C++   : Saw " << count << " lines in " << sec << " seconds." ;
    if (sec > 0) {
        lps = count / sec;
        cerr << "  Crunch speed: " << lps << endl;
    } else
        cerr << endl;
    return 0;
}

//compiled with: g++ -Wall -O3 -o split1 split_1.cpp -std=c++0x

Отказ от ответственности: надеюсь, что нетНикаких ошибок.Я не проверял функциональность, а только проверял скорость.Но я думаю, что даже если есть ошибка или два, исправление, которое не окажет существенного влияния на скорость.

9 голосов
/ 11 марта 2012

Я не предоставляю более качественных решений (по крайней мере, с точки зрения производительности), но некоторые дополнительные данные могут быть интересны.

Использование strtok_r (реентерабельный вариант strtok):

void splitc1(vector<string> &tokens, const string &str,
        const string &delimiters = " ") {
    char *saveptr;
    char *cpy, *token;

    cpy = (char*)malloc(str.size() + 1);
    strcpy(cpy, str.c_str());

    for(token = strtok_r(cpy, delimiters.c_str(), &saveptr);
        token != NULL;
        token = strtok_r(NULL, delimiters.c_str(), &saveptr)) {
        tokens.push_back(string(token));
    }

    free(cpy);
}

Дополнительно используются символьные строки для параметров и fgets для ввода:

void splitc2(vector<string> &tokens, const char *str,
        const char *delimiters) {
    char *saveptr;
    char *cpy, *token;

    cpy = (char*)malloc(strlen(str) + 1);
    strcpy(cpy, str);

    for(token = strtok_r(cpy, delimiters, &saveptr);
        token != NULL;
        token = strtok_r(NULL, delimiters, &saveptr)) {
        tokens.push_back(string(token));
    }

    free(cpy);
}

И, в некоторых случаях, где допустимо уничтожение входной строки:

void splitc3(vector<string> &tokens, char *str,
        const char *delimiters) {
    char *saveptr;
    char *token;

    for(token = strtok_r(str, delimiters, &saveptr);
        token != NULL;
        token = strtok_r(NULL, delimiters, &saveptr)) {
        tokens.push_back(string(token));
    }
}

Время для этого следующее (включая мои результаты для других вариантов из вопроса и принятого ответа):

split1.cpp:  C++   : Saw 20000000 lines in 31 seconds.  Crunch speed: 645161
split2.cpp:  C++   : Saw 20000000 lines in 45 seconds.  Crunch speed: 444444
split.py:    Python: Saw 20000000 lines in 33 seconds.  Crunch Speed: 606060
split5.py:   Python: Saw 20000000 lines in 35 seconds.  Crunch Speed: 571428
split6.cpp:  C++   : Saw 20000000 lines in 18 seconds.  Crunch speed: 1111111

splitc1.cpp: C++   : Saw 20000000 lines in 27 seconds.  Crunch speed: 740740
splitc2.cpp: C++   : Saw 20000000 lines in 22 seconds.  Crunch speed: 909090
splitc3.cpp: C++   : Saw 20000000 lines in 20 seconds.  Crunch speed: 1000000

Как мы видим, решение из принятого ответа по-прежнему самое быстрое.

Для тех, кто хотел бы провести дополнительные тесты, я также разместил репозиторий Github со всеми программами из этого вопроса, принятым ответом, этим ответом, а также Makefile и скриптом для генерации тестовых данных:https://github.com/tobbez/string-splitting.

4 голосов
/ 21 февраля 2012

Я подозреваю, что это из-за способа изменения размера std::vector во время вызова функции push_back (). Если вы попытаетесь использовать std::list или std::vector::reserve(), чтобы зарезервировать достаточно места для предложений, вы получите намного лучшую производительность. Или вы можете использовать комбинацию обоих, как показано ниже для split1 ():

void split1(vector<string> &tokens, const string &str,
        const string &delimiters = " ") {
    // Skip delimiters at beginning
    string::size_type lastPos = str.find_first_not_of(delimiters, 0);

    // Find first non-delimiter
    string::size_type pos = str.find_first_of(delimiters, lastPos);
    list<string> token_list;

    while (string::npos != pos || string::npos != lastPos) {
        // Found a token, add it to the list
        token_list.push_back(str.substr(lastPos, pos - lastPos));
        // Skip delimiters
        lastPos = str.find_first_not_of(delimiters, pos);
        // Find next non-delimiter
        pos = str.find_first_of(delimiters, lastPos);
    }
    tokens.assign(token_list.begin(), token_list.end());
}

РЕДАКТИРОВАТЬ : Другая очевидная вещь, которую я вижу, состоит в том, что переменной Python dummy каждый раз присваивается , но она не изменяется. Так что это несправедливое сравнение с C ++. Вы должны попытаться изменить свой код Python на dummy = [], чтобы инициализировать его, а затем выполнить dummy += line.split(). Можете ли вы сообщить время выполнения после этого?

EDIT2 : Чтобы сделать его еще более справедливым, вы можете изменить цикл while в коде C ++ так:

    while(cin) {
        getline(cin, input_line);
        std::vector<string> spline; // create a new vector

        //I'm trying one of the two implementations, per compilation, obviously:
//        split1(spline, input_line);  
        split2(spline, input_line);

        count++;
    };
3 голосов
/ 28 апреля 2018

Я думаю, что следующий код лучше, используя некоторые функции C ++ 17 и C ++ 14:

// These codes are un-tested when I write this post, but I'll test it
// When I'm free, and I sincerely welcome others to test and modify this
// code.

// C++17
#include <istream>     // For std::istream.
#include <string_view> // new feature in C++17, sizeof(std::string_view) == 16 in libc++ on my x86-64 debian 9.4 computer.
#include <string>
#include <utility>     // C++14 feature std::move.

template <template <class...> class Container, class Allocator>
void split1(Container<std::string_view, Allocator> &tokens, 
            std::string_view str,
            std::string_view delimiter = " ") 
{
    /* 
     * The model of the input string:
     *
     * (optional) delimiter | content | delimiter | content | delimiter| 
     * ... | delimiter | content 
     *
     * Using std::string::find_first_not_of or 
     * std::string_view::find_first_not_of is a bad idea, because it 
     * actually does the following thing:
     * 
     *     Finds the first character not equal to any of the characters 
     *     in the given character sequence.
     * 
     * Which means it does not treeat your delimiters as a whole, but as
     * a group of characters.
     * 
     * This has 2 effects:
     *
     *  1. When your delimiters is not a single character, this function
     *  won't behave as you predicted.
     *
     *  2. When your delimiters is just a single character, the function
     *  may have an additional overhead due to the fact that it has to 
     *  check every character with a range of characters, although 
     * there's only one, but in order to assure the correctness, it still 
     * has an inner loop, which adds to the overhead.
     *
     * So, as a solution, I wrote the following code.
     *
     * The code below will skip the first delimiter prefix.
     * However, if there's nothing between 2 delimiter, this code'll 
     * still treat as if there's sth. there.
     *
     * Note: 
     * Here I use C++ std version of substring search algorithm, but u
     * can change it to Boyer-Moore, KMP(takes additional memory), 
     * Rabin-Karp and other algorithm to speed your code.
     * 
     */

    // Establish the loop invariant 1.
    typename std::string_view::size_type 
        next, 
        delimiter_size = delimiter.size(),  
        pos = str.find(delimiter) ? 0 : delimiter_size;

    // The loop invariant:
    //  1. At pos, it is the content that should be saved.
    //  2. The next pos of delimiter is stored in next, which could be 0
    //  or std::string_view::npos.

    do {
        // Find the next delimiter, maintain loop invariant 2.
        next = str.find(delimiter, pos);

        // Found a token, add it to the vector
        tokens.push_back(str.substr(pos, next));

        // Skip delimiters, maintain the loop invariant 1.
        //
        // @ next is the size of the just pushed token.
        // Because when next == std::string_view::npos, the loop will
        // terminate, so it doesn't matter even if the following 
        // expression have undefined behavior due to the overflow of 
        // argument.
        pos = next + delimiter_size;
    } while(next != std::string_view::npos);
}   

template <template <class...> class Container, class traits, class Allocator2, class Allocator>
void split2(Container<std::basic_string<char, traits, Allocator2>, Allocator> &tokens, 
            std::istream &stream,
            char delimiter = ' ')
{
    std::string<char, traits, Allocator2> item;

    // Unfortunately, std::getline can only accept a single-character 
    // delimiter.
    while(std::getline(stream, item, delimiter))
        // Move item into token. I haven't checked whether item can be 
        // reused after being moved.
        tokens.push_back(std::move(item));
}

Выбор контейнера:

  1. std::vector.

    Если исходный размер выделенного внутреннего массива равен 1, а конечный размер равен N, вы будете выделять и освобождать его для log2 (N) раз, и вы скопируете (2 ^ (log2)(N) + 1) - 1) = (2N - 1) раз.Как указано в Является ли низкая производительность std :: vector из-за того, что realloc не вызывается логарифмическим числом раз? , это может иметь плохую производительность, когда размер вектора непредсказуем и может быть очень большим.Но, если вы сможете оценить его размер, это будет меньше проблем.

  2. std::list.

    Для каждого push_back время, которое он потребляет, является постоянным, но, вероятно, это займет больше времени, чем std :: vector для отдельного push_back.Использование пула памяти для каждого потока и пользовательского распределителя может облегчить эту проблему.

  3. std::forward_list.

    То же, что и std :: list, но занимает меньше памяти на элемент.Требуется класс-оболочка для работы из-за отсутствия API push_back.

  4. std::array.

    Если вы знаете предел роста, вы можете использовать std :: array.Конечно, вы не можете использовать его напрямую, так как он не имеет API push_back.Но вы можете определить оболочку, и я думаю, что это самый быстрый способ и может сэкономить память, если ваша оценка достаточно точна.

  5. std::deque.

    Эта опция позволяет вам обменять память на производительность.Там не будет (2 ^ (N + 1) - 1) раз копия элемента, только N раз распределения, и не будет освобождения.Кроме того, у вас будет постоянное время произвольного доступа и возможность добавлять новые элементы с обоих концов.

Согласно std :: deque-cppreference

С другой стороны, запросы обычно имеют большую минимальную стоимость памяти;deque, содержащий только один элемент, должен выделить свой полный внутренний массив (например, 8-кратный размер объекта на 64-битной libstdc ++; 16-кратный размер объекта или 4096 байт, в зависимости от того, что больше, на 64-битной libc ++)

или вы можете использовать комбинацию из них:

  1. std::vector< std::array<T, 2 ^ M> >

    Это похоже на std :: deque, разница только в том, что этот контейнер не 'Поддержка добавления элемента на передней панели.Но это все еще быстрее в производительности, потому что он не будет копировать базовый массив std :: для (2 ^ (N + 1) - 1) раз, он просто скопирует массив указателей для (2 ^(N - M + 1) - 1) раз, и выделение нового массива только тогда, когда ток заполнен и не нужно ничего освобождать.Кстати, вы можете получить постоянное время произвольного доступа.

  2. std::list< std::array<T, ...> >

    Значительно облегчить давление фрагментации памяти.Он будет выделять новый массив только при заполнении текущего, и не нужно ничего копировать.Вам все равно придется заплатить цену за дополнительный указатель, сопоставленный с комбо 1. 1. 1067 *

  3. std::forward_list< std::array<T, ...> >

    То же, что 2, но стоит столько же памяти, сколько комбо 1.

2 голосов
/ 12 марта 2012

Если вы возьмете реализацию split1 и измените подпись, чтобы она более точно соответствовала подписи split2, изменив это:

void split1(vector<string> &tokens, const string &str, const string &delimiters = " ")

на следующее:

void split1(vector<string> &tokens, const string &str, const char delimiters = ' ')

Вы получите большерезкое различие между split1 и split2 и более справедливое сравнение:

split1  C++   : Saw 10000000 lines in 41 seconds.  Crunch speed: 243902
split2  C++   : Saw 10000000 lines in 144 seconds.  Crunch speed: 69444
split1' C++   : Saw 10000000 lines in 33 seconds.  Crunch speed: 303030
2 голосов
/ 21 февраля 2012

Вы ошибочно полагаете, что выбранная вами реализация C ++ обязательно быстрее, чем Python. Обработка строк в Python высоко оптимизирована. Подробности смотрите в этом вопросе: Почему операции std :: string выполняются плохо?

1 голос
/ 22 февраля 2012
void split5(vector<string> &tokens, const string &str, char delim=' ') {

    enum { do_token, do_delim } state = do_delim;
    int idx = 0, tok_start = 0;
    for (string::const_iterator it = str.begin() ; ; ++it, ++idx) {
        switch (state) {
            case do_token:
                if (it == str.end()) {
                    tokens.push_back (str.substr(tok_start, idx-tok_start));
                    return;
                }
                else if (*it == delim) {
                    state = do_delim;
                    tokens.push_back (str.substr(tok_start, idx-tok_start));
                }
                break;

            case do_delim:
                if (it == str.end()) {
                    return;
                }
                if (*it != delim) {
                    state = do_token;
                    tok_start = idx;
                }
                break;
        }
    }
}
0 голосов
/ 21 февраля 2012

Я подозреваю, что это связано с буферизацией на sys.stdin в Python, но не с буферизацией в реализации C ++.

См. Этот пост для получения подробной информации о том, как изменить размер буфера, затем попробуйте сравнение снова: Установка меньшего размера буфера для sys.stdin?

...