Почему чтение строк из stdin происходит намного медленнее на С++, чем Python?

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


(Ответ TL;DR: cin.sync_with_stdio(false) утверждение: cin.sync_with_stdio(false) или просто используйте вместо него fgets.

Результаты TL;DR: прокрутите до конца моего вопроса и посмотрите на таблицу.)


C++ код:

#include <iostream>
#include <time.h>

using namespace std;

int main() {
    string input_line;
    long line_count = 0;
    time_t start = time(NULL);
    int sec;
    int lps;

    while (cin) {
        getline(cin, input_line);
        if (!cin.eof())
            line_count++;
    };

    sec = (int) time(NULL) - start;
    cerr << "Read " << line_count << " lines in " << sec << " seconds.";
    if (sec > 0) {
        lps = line_count / sec;
        cerr << " LPS: " << lps << endl;
    } else
        cerr << endl;
    return 0;
}

// Compiled with:
// g++ -O3 -o readline_test_cpp foo.cpp

Эквивалент Python:

#!/usr/bin/env python
import time
import sys

count = 0
start = time.time()

for line in  sys.stdin:
    count += 1

delta_sec = int(time.time() - start_time)
if delta_sec >= 0:
    lines_per_sec = int(round(count/delta_sec))
    print("Read {0} lines in {1} seconds. LPS: {2}".format(count, delta_sec,
       lines_per_sec))

Вот мои результаты:

$ cat test_lines | ./readline_test_cpp
Read 5570000 lines in 9 seconds. LPS: 618889

$cat test_lines | ./readline_test.py
Read 5570000 lines in 1 seconds. LPS: 5570000

Должен отметить, что я пробовал это как в Mac OS X v10.6.8 (Snow Leopard), так и в Linux 2.6.32 (Red Hat Linux 6.2). Первый - это MacBook Pro, а второй - очень мощный сервер, не то чтобы это было слишком уместно.

$ for i in {1..5}; do echo "Test run $i at 'date'"; echo -n "CPP:"; cat test_lines | ./readline_test_cpp ; echo -n "Python:"; cat test_lines | ./readline_test.py ; done
Test run 1 at Mon Feb 20 21:29:28 EST 2012
CPP:   Read 5570001 lines in 9 seconds. LPS: 618889
Python:Read 5570000 lines in 1 seconds. LPS: 5570000
Test run 2 at Mon Feb 20 21:29:39 EST 2012
CPP:   Read 5570001 lines in 9 seconds. LPS: 618889
Python:Read 5570000 lines in 1 seconds. LPS: 5570000
Test run 3 at Mon Feb 20 21:29:50 EST 2012
CPP:   Read 5570001 lines in 9 seconds. LPS: 618889
Python:Read 5570000 lines in 1 seconds. LPS: 5570000
Test run 4 at Mon Feb 20 21:30:01 EST 2012
CPP:   Read 5570001 lines in 9 seconds. LPS: 618889
Python:Read 5570000 lines in 1 seconds. LPS: 5570000
Test run 5 at Mon Feb 20 21:30:11 EST 2012
CPP:   Read 5570001 lines in 10 seconds. LPS: 557000
Python:Read 5570000 lines in  1 seconds. LPS: 5570000

Крошечное приложение и резюме

Для полноты я подумал, что обновлю скорость чтения для того же файла в том же окне с помощью исходного (синхронизированного) кода C++. Опять же, это для 100-строчного файла на быстром диске. Вот сравнение, с несколькими решениями/подходами:

Implementation      Lines per second
python (default)           3,571,428
cin (default/naive)          819,672
cin (no sync)             12,500,000
fgets                     14,285,714
wc (not fair comparison)  54,644,808

Ответы

Ответ 1

По умолчанию, cin синхронизируется с stdio, что приводит к тому, что он не допускает буферизации ввода. Если вы добавите это в начало своей основной, вы должны увидеть гораздо лучшую производительность:

std::ios_base::sync_with_stdio(false);

Обычно, когда входной поток буферизуется, вместо того, чтобы читать один символ за раз, поток будет считываться в больших кусках. Это уменьшает количество системных вызовов, которые обычно относительно дороги. Однако, поскольку FILE* на основе stdio и iostreams часто имеют отдельные реализации и, следовательно, отдельные буферы, это может привести к проблеме, если оба они используются вместе. Например:

int myvalue1;
cin >> myvalue1;
int myvalue2;
scanf("%d",&myvalue2);

Если больше ввода было прочитано cin, чем было на самом деле, то второе целочисленное значение не было бы доступно для функции scanf, которая имеет свой собственный независимый буфер. Это приведет к неожиданным результатам.

Чтобы избежать этого, по умолчанию потоки синхронизируются с stdio. Один из распространенных способов достижения этого состоит в том, чтобы cin считывать каждый символ по мере необходимости с помощью stdio. К сожалению, это приводит к большим накладным расходам. Для небольшого количества входных данных это не большая проблема, но когда вы читаете миллионы строк, оценка производительности значительна.

К счастью, разработчики библиотеки решили, что вы также можете отключить эту функцию, чтобы получить улучшенную производительность, если вы знали, что делаете, поэтому они предоставили sync_with_stdio.

Ответ 2

Просто из любопытства я посмотрел, что происходит под капотом, и я использовал dtruss/strace для каждого теста.

C++

./a.out < in
Saw 6512403 lines in 8 seconds.  Crunch speed: 814050

syscalls sudo dtruss -c ./a.out < in

CALL                                        COUNT
__mac_syscall                                   1
<snip>
open                                            6
pread                                           8
mprotect                                       17
mmap                                           22
stat64                                         30
read_nocancel                               25958

Python

./a.py < in
Read 6512402 lines in 1 seconds. LPS: 6512402

syscalls sudo dtruss -c ./a.py < in

CALL                                        COUNT
__mac_syscall                                   1
<snip>
open                                            5
pread                                           8
mprotect                                       17
mmap                                           21
stat64                                         29

Ответ 3

Я здесь на несколько лет позади, но:

В "Редактировании 4/5/6" исходного поста вы используете конструкцию:

$ /usr/bin/time cat big_file | program_to_benchmark

Это неправильно по нескольким причинам:

  1. Вы на самом деле рассчитываете выполнение "кота", а не своего эталона. Использование ЦП 'user' и 'sys', отображаемое в 'time', - это использование 'cat', а не вашей тестовой программы. Хуже того, "реальное" время также не обязательно точное. В зависимости от реализации 'cat' и конвейеров в вашей локальной ОС, вполне возможно, что 'cat' записывает окончательный гигантский буфер и завершает работу задолго до того, как процесс чтения завершит свою работу.

  2. Использование "кошка" не является необходимым и на самом деле контрпродуктивно; вы добавляете движущиеся части. Если вы работали в достаточно старой системе (т.е. С одним ЦП и - в некоторых поколениях компьютеров - вводом-выводом быстрее, чем ЦП) - сам факт запуска "кошки" может существенно повлиять на результаты. Вы также подвержены любой буферизации ввода и вывода и другой обработке 'cat'. (Это, вероятно, принесло бы вам награду "Бесполезное использование кошки", если бы я был Рэндал Шварц.

Лучшая конструкция будет:

$ /usr/bin/time program_to_benchmark < big_file

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

Я упомяну два возможных, но на самом деле неправильных, "исправления", которые также могут быть рассмотрены (но я "нумерую" их по-разному, поскольку это не те вещи, которые были неверны в оригинальном посте):

О. Вы можете "исправить" это, синхронизируя только вашу программу:

$ cat big_file | /usr/bin/time program_to_benchmark

Б. или путем синхронизации всего трубопровода:

$ /usr/bin/time sh -c 'cat big_file | program_to_benchmark'

Это неправильно по тем же причинам, что и №2: они все еще используют "кошку" без необходимости. Я упоминаю их по нескольким причинам:

  • они более "естественны" для людей, которым не совсем удобны средства перенаправления ввода/вывода оболочки POSIX

  • могут быть случаи, когда требуется 'cat' (например, для чтения файла требуется какая-то привилегия для доступа, и вы не хотите предоставлять эту привилегию программе для сравнительного анализа: 'sudo cat/dev/sda |/usr/bin/time my_compression_test --no-output ')

  • на практике, на современных машинах добавленная "кошка" в конвейере, вероятно, не имеет реального значения

Но я говорю это последнее с некоторой нерешительностью. Если мы рассмотрим последний результат в "Редактировать 5" -

$ /usr/bin/time cat temp_big_file | wc -l
0.01user 1.34system 0:01.83elapsed 74%CPU ...

- это утверждает, что "кошка" потребляла 74% ЦП во время теста; и действительно, 1,34/1,83 составляет примерно 74%. Возможно пробег:

$ /usr/bin/time wc -l < temp_big_file

заняло бы только оставшиеся 49 секунд! Вероятно, нет: здесь 'cat' должен был платить за системные вызовы read() (или эквивалентные), которые передавали файл с 'диска' (фактически буферный кеш), а также за канал, записывающий их для доставки их в 'wc'. Правильный тест все равно должен был бы выполнять эти вызовы read(); только вызовы write-to-pipe и read-from-pipe были бы сохранены, и они должны быть довольно дешевыми.

Тем не менее, я предсказываю, что вы сможете измерить разницу между 'cat file | wc -l 'и' wc -l <file 'и найдите заметную (двузначный процент) разницу. Каждый из более медленных тестов будет платить аналогичный штраф в абсолютном времени; что, однако, составило бы меньшую долю его большего общего времени.

На самом деле я провел несколько быстрых тестов с мусорным файлом объемом 1,5 гигабайта в системе Linux 3.13 (Ubuntu 14.04), получив эти результаты (на самом деле это результаты "best of 3"; после заполнения кеша, конечно):

$ time wc -l < /tmp/junk
real 0.280s user 0.156s sys 0.124s (total cpu 0.280s)
$ time cat /tmp/junk | wc -l
real 0.407s user 0.157s sys 0.618s (total cpu 0.775s)
$ time sh -c 'cat /tmp/junk | wc -l'
real 0.411s user 0.118s sys 0.660s (total cpu 0.778s)

Обратите внимание, что результаты двух конвейеров утверждают, что они заняли больше процессорного времени (user + sys), чем в реальном времени. Это потому, что я использую встроенную в оболочку команду "время", которая осведомлена о конвейере; и я нахожусь на многоядерной машине, где отдельные процессы в конвейере могут использовать отдельные ядра, накапливая процессорное время быстрее, чем в реальном времени. Используя /usr/bin/time, я вижу меньше процессорного времени, чем в реальном времени, - показывая, что он может рассчитывать только один элемент конвейера, переданный ему в его командной строке. Кроме того, вывод оболочки дает миллисекунды, в то время как /usr/bin/time дает только сотни секунд.

Таким образом, на уровне эффективности "wc -l" "кошка" имеет огромное значение: 409/283 = 1.453 или на 45.3% больше в реальном времени и 775/280 = 2.768, или колоссальные 177% больше используемого процессора! На моем случайном тестовом боксе.

Я должен добавить, что между этими стилями тестирования есть по крайней мере еще одно существенное различие, и я не могу сказать, является ли это преимуществом или недостатком; Вы должны решить это самостоятельно:

Когда вы запускаете 'cat big_file |/usr/bin/time my_program ', ваша программа получает входные данные из конвейера точно в темпе, который посылает' cat ', и кусками не больше, чем записано' cat '.

Когда вы запускаете '/usr/bin/time my_program <big_file', ваша программа получает дескриптор открытого файла к реальному файлу. Ваша программа - или во многих случаях библиотеки ввода/вывода того языка, на котором она была написана, - может выполнять различные действия при представлении файлового дескриптора, ссылающегося на обычный файл. Он может использовать mmap (2) для отображения входного файла в его адресное пространство вместо использования явных системных вызовов read (2). Эти различия могут оказать гораздо большее влияние на результаты теста, чем небольшая стоимость запуска двоичного файла 'cat'.

Конечно, это интересный результат теста, если одна и та же программа работает существенно по-разному в двух случаях. Это показывает, что программа или ее библиотеки ввода/вывода действительно делают что-то интересное, например, использование mmap(). Таким образом, на практике может быть полезно выполнить тесты в обоих направлениях; возможно, не учитывая результат "кошка", чтобы "простить" стоимость запуска самой "кошки".

Ответ 4

Я воспроизвел исходный результат на своем компьютере с помощью g++ на Mac.

Добавление следующих инструкций в версию С++ перед циклом while приводит его в строку с Python:

std::ios_base::sync_with_stdio(false);
char buffer[1048576];
std::cin.rdbuf()->pubsetbuf(buffer, sizeof(buffer));

sync_with_stdio улучшила скорость до 2 секунд, а установка большего буфера уменьшила ее до 1 секунды.

Ответ 5

getline, stream scanf, scanf, могут быть удобны, если вам не важно время загрузки файла или если вы загружаете небольшие текстовые файлы. Но если вам важна производительность, вам нужно просто поместить весь файл в память (при условии, что он уместится).

Вот пример:

//open file in binary mode
std::fstream file( filename, std::ios::in|::std::ios::binary );
if( !file ) return NULL;

//read the size...
file.seekg(0, std::ios::end);
size_t length = (size_t)file.tellg();
file.seekg(0, std::ios::beg);

//read into memory buffer, then close it.
char *filebuf = new char[length+1];
file.read(filebuf, length);
filebuf[length] = '\0'; //make it null-terminated
file.close();

Если вы хотите, вы можете обернуть поток вокруг этого буфера для более удобного доступа, например так:

std::istrstream header(&filebuf[0], length);

Кроме того, если вы контролируете файл, рассмотрите возможность использования плоского двоичного формата данных вместо текста. Надежнее читать и писать, потому что вам не нужно иметь дело со всеми неопределенностями пробелов. Он также меньше и намного быстрее разбирается.

Ответ 6

Кстати, причина, по которой счетчик строк для версии С++ больше, чем счетчик для версии Python, заключается в том, что флаг eof устанавливается только тогда, когда делается попытка прочитать за пределами eof. Таким образом, правильный цикл будет:

while (cin) {
    getline(cin, input_line);

    if (!cin.eof())
        line_count++;
};

Ответ 7

Следующий код был быстрее для меня, чем другой код, размещенный здесь до сих пор: (Visual Studio 2013, 64-битный, 500 МБ файл с длиной строки равномерно в [0, 1000)).

const int buffer_size = 500 * 1024;  // Too large/small buffer is not good.
std::vector<char> buffer(buffer_size);
int size;
while ((size = fread(buffer.data(), sizeof(char), buffer_size, stdin)) > 0) {
    line_count += count_if(buffer.begin(), buffer.begin() + size, [](char ch) { return ch == '\n'; });
}

Это превосходит все мои попытки Python более чем в 2 раза.

Ответ 8

В вашем втором примере (с помощью scanf()) причина, по которой это еще медленнее, может быть вызвана тем, что scanf ( "% s" ) анализирует строку и ищет любое пространство char (пробел, табуляция, новая строка).

Кроме того, да, CPython делает некоторое кэширование, чтобы избежать чтения жесткого диска.

Ответ 9

Первый элемент ответа: <iostream> медленный. Проклятье медленно. Я получаю огромное повышение производительности с помощью scanf, как показано ниже, но он все еще в два раза медленнее, чем Python.

#include <iostream>
#include <time.h>
#include <cstdio>

using namespace std;

int main() {
    char buffer[10000];
    long line_count = 0;
    time_t start = time(NULL);
    int sec;
    int lps;

    int read = 1;
    while(read > 0) {
        read = scanf("%s", buffer);
        line_count++;
    };
    sec = (int) time(NULL) - start;
    line_count--;
    cerr << "Saw " << line_count << " lines in " << sec << " seconds." ;
    if (sec > 0) {
        lps = line_count / sec;
        cerr << "  Crunch speed: " << lps << endl;
    } 
    else
        cerr << endl;
    return 0;
}

Ответ 10

Хорошо, я вижу, что в вашем втором решении вы переключились с cin на scanf, что было первым предложением, которое я собирался сделать вам (cin - sloooooooooooow). Теперь, если вы переключитесь с scanf на fgets, вы увидите еще одно повышение производительности: fgets - это самая быстрая функция С++ для ввода строки.

Кстати, не знал об этой синхронизации, хорошо. Но вы должны попробовать fgets.

Ответ 11

Хорошая почта. Но я хотел бы упомянуть, что проблему переполнения буфера с помощью scanf можно обработать, указав количество символов для чтения (для любого типа данных).

См. параметр ширины, упомянутый в ссылка.

В качестве примера:

    char s[10];
    scanf("%9s",s);    //This will read at most 9 characters from the input.

    int x;
    scanf("%2d",&x);   //This will read a 2 digit number from the input. (just mentioning)

Это может привести к переполнению буфера. Также динамическая ширина не может быть указана, но для преодоления этого можно просто сгенерировать строку формата во время выполнения (хотя это предотвратит проверку scanf для проверки работоспособности при компиляции).

Ответ 12

Когда программа С++ должна была читать строки, она должна была прочитать файл с диска. Когда вы запускаете Python, файл уже кэшируется в памяти. Вероятно, поэтому программа Python оказалась быстрее.

Кроме того, ваша С++-программа всегда будет подсчитывать дополнительную строку, потому что вы не проверяете, удалось ли getline превзойти счет. Ваша проверка eof является ненужной и неправильной (потому что после неудачи вы ошибаетесь).