web-dev-qa-db-fra.com

Pourquoi les lignes de lecture de stdin sont-elles beaucoup plus lentes en C ++ qu'en Python?

Je souhaitais comparer les lignes de lecture des entrées de chaîne de stdin à l'aide de Python et de C++. J'ai été choqué de voir mon code C++ exécuter un ordre de grandeur plus lent que le code équivalent Python. Comme mon C++ est rouillé et que je ne suis pas encore un expert pythoniste, dites-moi s'il vous plaît si je fais quelque chose de mal ou si je ne comprends pas bien quelque chose.


(Réponse TLDR: incluez la déclaration: cin.sync_with_stdio(false) ou utilisez simplement fgets à la place.

Résultats TLDR: faites défiler tout le chemin jusqu'au bas de ma question et regardez le tableau.)


code 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

équivalent 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))

Voici mes résultats:

$ 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

Je dois noter que j'ai essayé cette technique sous Mac OS X v10.6.8 (Snow Leopard) et Linux 2.6.32 (Red Hat Linux 6.2). Le premier est un MacBook Pro, et le dernier est un serveur très costaud, sans que cela soit trop pertinent.

$ 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

Petit additif de référence et récapitulation

Pour être complet, j'ai pensé mettre à jour la vitesse de lecture du même fichier dans la même boîte avec le code C++ d'origine (synchronisé). Encore une fois, il s’agit d’un fichier de 100 millions de lignes sur un disque rapide. Voici la comparaison, avec plusieurs solutions/approches:

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
1700
JJC

Par défaut, cin est synchronisé avec stdio, ce qui lui évite toute mise en mémoire tampon des entrées. Si vous ajoutez ceci en haut de votre liste principale, vous obtiendrez de bien meilleures performances:

_std::ios_base::sync_with_stdio(false);
_

Normalement, lorsqu'un flux d'entrée est mis en mémoire tampon, au lieu de lire un caractère à la fois, le flux sera lu par gros morceaux. Cela réduit le nombre d'appels système, qui sont généralement relativement coûteux. Cependant, étant donné que les FILE*stdio et iostreams basés sur __ ont souvent des implémentations séparées et donc des tampons séparés, cela pourrait poser un problème si les deux étaient utilisés ensemble. Par exemple:

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

Si plus d'entrées ont été lues par cin que nécessaire, alors la deuxième valeur entière ne serait pas disponible pour la fonction scanf, qui possède son propre tampon indépendant. Cela conduirait à des résultats inattendus.

Pour éviter cela, par défaut, les flux sont synchronisés avec stdio. Une manière courante d’atteindre cet objectif consiste à faire en sorte que cin lise chaque caractère un par un à l’aide des fonctions stdio. Malheureusement, cela introduit beaucoup de frais généraux. Pour de petites quantités d’entrées, ce n’est pas un gros problème, mais lorsque vous lisez des millions de lignes, la performance est pénalisée.

Heureusement, les concepteurs de la bibliothèque ont décidé que vous devriez également pouvoir désactiver cette fonctionnalité pour améliorer les performances si vous saviez ce que vous faisiez. Ils ont donc fourni la méthode sync_with_stdio .

1519
Vaughn Cato

Par simple curiosité, j’ai jeté un œil à ce qui se passe sous le capot et j’ai utilisé dtruss/strace pour chaque test.

C++

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

appels système 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

appels système Sudo dtruss -c ./a.py < in

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

J'ai quelques années de retard ici, mais:

Dans 'Edit 4/5/6' de la publication d'origine, vous utilisez la construction:

$ /usr/bin/time cat big_file | program_to_benchmark

Ceci est faux de différentes manières:

  1. En fait, vous chronométrez l'exécution de `cat`, pas votre repère. Les informations sur l’utilisation du processeur par les utilisateurs et les systèmes associées à sys sont celles de cat, pas celles de votre programme. Pire encore, le "temps réel" n’est pas non plus nécessairement exact. Selon l’implémentation de `cat` et des pipelines dans votre système d’exploitation local, il est possible que` cat` écrit un tampon final géant et se termine bien avant la fin du processus de lecture.

  2. L'utilisation de `cat` est inutile et en fait contre-productive; vous ajoutez des pièces mobiles. Si vous étiez sur un système suffisamment ancien (c’est-à-dire avec un seul processeur et - dans certaines générations d’ordinateurs - plus rapide que les processeurs), le simple fait que `cat’ était en cours d’exécution pouvait considérablement colorer les résultats. Vous êtes également soumis à la mise en mémoire tampon d’entrée et de sortie et à tout autre traitement que `cat` peut faire. (Cela vous rapporterait probablement 'utilisation inutile de chat' récompense si j'étais Randal Schwartz.

Une meilleure construction serait:

$ /usr/bin/time program_to_benchmark < big_file

Dans cette déclaration, c’est le shell qui ouvre big_file et le transmet à votre programme (enfin à `time` qui exécute ensuite votre programme en tant que sous-processus ) en tant que descripteur de fichier déjà ouvert. La lecture des fichiers relève à 100% de la responsabilité du programme que vous essayez de comparer. Cela vous donne une véritable lecture de ses performances sans complications parasites.

Je vais mentionner deux 'correctifs' possibles, mais en réalité erronés, qui pourraient également être pris en compte (mais je les "numérote" différemment car ce ne sont pas des choses qui se sont trompées dans le post original):

A. Vous pouvez "résoudre" ceci en chronométrant uniquement votre programme:

$ cat big_file | /usr/bin/time program_to_benchmark

B. ou en chronométrant l'ensemble du pipeline:

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

Ce sont les mêmes pour les mêmes raisons que # 2: ils utilisent toujours inutilement `cat`. Je les mentionne pour quelques raisons:

  • ils sont plus "naturels" pour les personnes qui ne sont pas tout à fait à l'aise avec les fonctions de redirection d'E/S du shell POSIX

  • il peut y avoir des cas où `cat` est nécessaire (par exemple: le fichier à lire nécessite une sorte de privilège d'accès, et vous ne voulez pas accorder ce privilège au programme pour qu'il soit référencé: `Sudo cat/dev/sda |/usr/bin/time my_compression_test --no-output`)

  • en pratique , sur les machines modernes, le `cat` ajouté dans le pipeline n'a probablement aucune conséquence réelle

Mais je dis cette dernière chose avec une certaine hésitation. Si nous examinons le dernier résultat dans 'Edit 5' -

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

- cela prétend que `cat` a consommé 74% de la CPU pendant le test; et en effet 1,34/1,83 correspond à environ 74%. Peut-être une série de:

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

aurait pris que les .49 secondes restantes! Probablement pas: `cat` ici a dû payer pour les appels système read () (ou équivalents) qui ont transféré le fichier à partir de 'disk' (en fait, le cache), ainsi que le tube écrit pour les remettre à` wc`. Le test correct aurait toujours dû faire ces appels read (); seuls les appels d'écriture dans le canal et de lecture du canal auraient été enregistrés, et ceux-ci devraient être relativement peu coûteux.

Néanmoins, je prédis que vous seriez capable de mesurer la différence entre `cat file | wc -l` et `wc -l <fichier` et trouvez une différence notable (pourcentage à 2 chiffres). Chacun des tests les plus lents aura payé une pénalité similaire en temps absolu; ce qui représenterait toutefois une fraction plus petite de son temps total plus long.

En fait, j'ai fait quelques tests rapides avec un fichier garbage de 1,5 gigaoctet, sur un système Linux 3.13 (Ubuntu 14.04), en obtenant ces résultats (ce sont en fait les meilleurs résultats parmi les 3; après avoir amorcé le cache, bien sûr):

$ 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)

Notez que les deux résultats de pipeline prétendent avoir pris plus de temps processeur (utilisateur + système) qu'en temps réel. En effet, j'utilise la commande "time" intégrée du shell (Bash), qui connaît le pipeline; et je suis sur une machine multicœur où des processus distincts dans un pipeline peuvent utiliser des cœurs séparés, accumulant du temps CPU plus rapidement qu'en temps réel. En utilisant/usr/bin/time, je vois un temps de calcul plus court qu'en temps réel, ce qui montre qu'il ne peut chronométrer que le seul élément de pipeline qui lui est transmis sur sa ligne de commande. De plus, la sortie du shell donne des millisecondes tandis que/usr/bin/time ne donne que des centaines de secondes.

Ainsi, au niveau d'efficacité de "wc -l", le "cat" fait une énorme différence: 409/283 = 1,453 ou 45,3% de plus en temps réel, et 775/280 = 2,768, soit un énorme processeur utilisé à 177%! Sur ma boîte de test au hasard, c’était là.

Je devrais ajouter qu'il existe au moins une autre différence significative entre ces styles de test, et je ne peux pas dire si c'est un avantage ou un défaut; vous devez décider vous-même:

Lorsque vous exécutez `cat big_file |/usr/bin/time my_program`, votre programme reçoit les entrées d'un canal, à la cadence indiquée par `cat`, et par morceaux ne dépassant pas celui écrit par` cat`.

Lorsque vous exécutez `/ usr/bin/time mon_programme <big_file`, votre programme reçoit un descripteur de fichier ouvert dans le fichier réel. Votre programme - ou dans de nombreux cas, les bibliothèques d'E/S de la langue dans laquelle il a été écrit - peut exécuter différentes actions lorsqu'il est présenté avec un fichier descripteur référençant un fichier normal. Il peut utiliser mmap (2) pour mapper le fichier d'entrée dans son espace d'adressage, au lieu d'utiliser des appels système explicites de read (2). Ces différences pourraient avoir un effet beaucoup plus important sur vos résultats de référence que le faible coût d’exécution du binaire `cat`.

Bien sûr, c’est un résultat de référence intéressant si le même programme fonctionne de manière très différente entre les deux cas. Cela montre en effet que le programme ou ses bibliothèques d'E/S font quelque chose d'intéressant, comme utiliser mmap (). Donc, dans la pratique, il peut être intéressant d’utiliser les critères de référence dans les deux sens; peut-être en déduisant le résultat "cat" d'un petit facteur pour "pardonner" le coût de l'exécution de "cat" lui-même.

127
Bela Lubkin

J'ai reproduit le résultat d'origine sur mon ordinateur à l'aide de g ++ sur un Mac.

L'ajout des instructions suivantes à la version C++ juste avant la boucle while l'ajoute à la version Python :

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

sync_with_stdio a amélioré la vitesse à 2 secondes et la définition d'un tampon plus important l'a réduite à 1 seconde.

86
karunski

getline, opérateurs de flux, scanf, peuvent être pratiques si vous ne vous souciez pas du temps de chargement des fichiers ou si vous chargez de petits fichiers texte. Mais, si la performance est quelque chose qui vous tient à cœur, vous devriez vraiment mettre tout le fichier dans la mémoire tampon (en supposant qu'il conviendra).

Voici un exemple:

//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();

Si vous le souhaitez, vous pouvez envelopper un tampon autour de ce tampon pour un accès plus pratique, comme ceci:

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

De même, si vous contrôlez le fichier, envisagez d'utiliser un format de données binaire plat au lieu du texte. Il est plus fiable de lire et d’écrire parce que vous n’aurez pas à faire face aux ambiguïtés des espaces. Il est également plus petit et beaucoup plus rapide à analyser.

37
Stu

Soit dit en passant, le nombre de lignes pour la version C++ est supérieur à celui de la version Python parce que l'indicateur eof n'est défini que si une tentative de lecture au-delà de eof est effectuée. Donc, la bonne boucle serait:

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

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

Le code suivant était plus rapide pour moi que les autres codes publiés jusqu'à présent: (fichier Visual Studio 2013, 64 bits, 500 Mo avec une longueur de ligne uniforme dans [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'; });
}

Il bat toutes mes Python de plus d'un facteur 2.

15
Petter

Dans votre deuxième exemple (avec scanf ()), la raison pour laquelle cela est encore plus lent peut-être parce que scanf ("% s") analyse chaîne et recherche tout caractère espace (espace, tabulation, nouvelle ligne).

De plus, oui, CPython fait de la mise en cache pour éviter les lectures sur le disque dur.

13
davinchi

Un premier élément de réponse: <iostream> est lent. Zut lent. Les performances de scanf sont énormément améliorées, mais il est toujours deux fois plus lent que 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;
}
11
J.N.

Eh bien, je vois que dans votre deuxième solution, vous êtes passé de cin à scanf, ce qui était la première suggestion que je voulais vous faire (cin est sloooooooooooow). Maintenant, si vous passez de scanf à fgets, vous obtiendrez une autre amélioration des performances: fgets est la fonction C++ la plus rapide pour la saisie de chaînes.

BTW, ne savait pas sur cette chose de synchronisation, Nice. Mais vous devriez quand même essayer fgets.