La norme C ++ impose-t-elle de mauvaises performances pour les flux ios, ou ai-je simplement affaire à une mauvaise implémentation?

197

Chaque fois que je mentionne la lenteur des performances des iostreams de la bibliothèque standard C ++, je reçois une vague d'incrédulité. Pourtant, j'ai des résultats de profileur montrant de grandes quantités de temps passé dans le code de la bibliothèque iostream (optimisations complètes du compilateur), et le passage d'iostreams à des API d'E / S spécifiques au système d'exploitation et à la gestion de la mémoire tampon personnalisée donne une amélioration de l'ordre de grandeur.

Quel travail supplémentaire la bibliothèque standard C ++ fait-elle, est-elle requise par la norme et est-elle utile dans la pratique? Ou certains compilateurs fournissent-ils des implémentations d'iostreams qui sont compétitives avec la gestion manuelle des tampons?

Repères

Pour faire avancer les choses, j'ai écrit quelques programmes courts pour exercer la mise en mémoire tampon interne iostreams:

Notez que les versions ostringstreamet stringbufexécutent moins d'itérations car elles sont beaucoup plus lentes.

Sur ideone, le ostringstreamest environ 3 fois plus lent que std:copy+ back_inserter+ std::vector, et environ 15 fois plus lent que memcpydans un tampon brut. Cela semble cohérent avec le profilage avant et après lorsque j'ai basculé ma véritable application vers une mise en mémoire tampon personnalisée.

Ce sont tous des tampons en mémoire, donc la lenteur des iostreams ne peut pas être imputée à des E / S de disque lentes, trop de vidage, de synchronisation avec stdio, ou toute autre chose que les gens utilisent pour excuser la lenteur observée de la bibliothèque standard C ++ iostream.

Ce serait bien de voir des repères sur d'autres systèmes et des commentaires sur les choses que font les implémentations courantes (telles que la libc ++ de gcc, Visual C ++, Intel C ++) et combien de la surcharge est mandatée par la norme.

Justification de ce test

Un certain nombre de personnes ont correctement souligné que les flux ios sont plus couramment utilisés pour la sortie formatée. Cependant, ils sont également la seule API moderne fournie par la norme C ++ pour l'accès aux fichiers binaires. Mais la vraie raison de faire des tests de performances sur la mise en mémoire tampon interne s'applique aux E / S formatées typiques: si les iostreams ne peuvent pas maintenir le contrôleur de disque alimenté en données brutes, comment peuvent-ils éventuellement suivre lorsqu'ils sont également responsables du formatage?

Calendrier de référence

Tous ces éléments sont par itération de la kboucle externe ( ).

Sur ideone (gcc-4.3.4, OS et matériel inconnus):

  • ostringstream: 53 millisecondes
  • stringbuf: 27 ms
  • vector<char>et back_inserter: 17,6 ms
  • vector<char> avec itérateur ordinaire: 10,6 ms
  • vector<char> vérification de l'itérateur et des limites: 11,4 ms
  • char[]: 3,7 ms

Sur mon ordinateur portable (Visual C ++ 2010 x86,, cl /Ox /EHscWindows 7 Ultimate 64 bits, Intel Core i7, 8 Go de RAM):

  • ostringstream: 73,4 millisecondes, 71,6 ms
  • stringbuf: 21,7 ms, 21,3 ms
  • vector<char>et back_inserter: 34,6 ms, 34,4 ms
  • vector<char> avec itérateur ordinaire: 1,10 ms, 1,04 ms
  • vector<char> vérification de l'itérateur et des limites: 1,11 ms, 0,87 ms, 1,12 ms, 0,89 ms, 1,02 ms, 1,14 ms
  • char[]: 1,48 ms, 1,57 ms

Visual C ++ 2010 x86, avec l' optimisation du profil guidée cl /Ox /EHsc /GL /c, link /ltcg:pgi, course, link /ltcg:pgo, mesure:

  • ostringstream: 61,2 ms, 60,5 ms
  • vector<char> avec itérateur ordinaire: 1,04 ms, 1,03 ms

Même ordinateur portable, même système d'exploitation, utilisant cygwin gcc 4.3.4 g++ -O3:

  • ostringstream: 62,7 ms, 60,5 ms
  • stringbuf: 44,4 ms, 44,5 ms
  • vector<char>et back_inserter: 13,5 ms, 13,6 ms
  • vector<char> avec itérateur ordinaire: 4,1 ms, 3,9 ms
  • vector<char> vérification de l'itérateur et des limites: 4,0 ms, 4,0 ms
  • char[]: 3,57 ms, 3,75 ms

Ordinateur portable même, Visual C ++ 2008 SP1, cl /Ox /EHsc:

  • ostringstream: 88,7 ms, 87,6 ms
  • stringbuf: 23,3 ms, 23,4 ms
  • vector<char>et back_inserter: 26,1 ms, 24,5 ms
  • vector<char> avec itérateur ordinaire: 3,13 ms, 2,48 ms
  • vector<char> vérification de l'itérateur et des limites: 2,97 ms, 2,53 ms
  • char[]: 1,52 ms, 1,25 ms

Même ordinateur portable, compilateur Visual C ++ 2010 64 bits:

  • ostringstream: 48,6 ms, 45,0 ms
  • stringbuf: 16,2 ms, 16,0 ms
  • vector<char>et back_inserter: 26,3 ms, 26,5 ms
  • vector<char> avec itérateur ordinaire: 0,87 ms, 0,89 ms
  • vector<char> vérification de l'itérateur et des limites: 0,99 ms, 0,99 ms
  • char[]: 1,25 ms, 1,24 ms

EDIT: Ran tous deux fois pour voir la cohérence des résultats. IMO assez cohérent.

REMARQUE: sur mon ordinateur portable, comme je peux épargner plus de temps processeur que ne le permet ideone, j'ai défini le nombre d'itérations sur 1000 pour toutes les méthodes. Cela signifie que ostringstreamet la vectorréaffectation, qui se déroule uniquement sur la première passe, devrait avoir peu d' impact sur les résultats finaux.

EDIT: Oups, a trouvé un bogue dans l' vectoritérateur -with-ordinaire, l'itérateur n'était pas avancé et donc il y avait trop de hits de cache. Je me demandais comment vector<char>était surperformant char[]. Cela n'a pas fait beaucoup de différence cependant, vector<char>est toujours plus rapide quechar[] sous VC ++ 2010.

Conclusions

La mise en mémoire tampon des flux de sortie nécessite trois étapes chaque fois que des données sont ajoutées:

  • Vérifiez que le bloc entrant correspond à l'espace tampon disponible.
  • Copiez le bloc entrant.
  • Mettez à jour le pointeur de fin de données.

Le dernier extrait de code que j'ai publié, " vector<char>simple itérateur et vérification des limites", non seulement cela, il alloue également de l'espace supplémentaire et déplace les données existantes lorsque le bloc entrant ne tient pas. Comme l'a souligné Clifford, la mise en mémoire tampon dans une classe d'E / S de fichiers n'aurait pas à faire cela, elle viderait simplement la mémoire tampon actuelle et la réutiliser. Cela devrait donc être une limite supérieure du coût de la mise en mémoire tampon de la sortie. Et c'est exactement ce qu'il faut pour créer un tampon en mémoire qui fonctionne.

Alors, pourquoi est stringbuf2,5 fois plus lent sur ideone, et au moins 10 fois plus lent lorsque je le teste? Il n'est pas utilisé de manière polymorphe dans ce micro-benchmark simple, donc cela ne l'explique pas.

Ben Voigt
la source
24
Vous écrivez un million de caractères un par un et vous vous demandez pourquoi c'est plus lent que de copier dans un tampon préalloué?
Anon.
20
@Anon: Je mets en mémoire tampon quatre millions d'octets quatre à la fois, et oui je me demande pourquoi c'est lent. Si ce std::ostringstreamn'est pas assez intelligent pour augmenter de façon exponentielle sa taille de tampon comme c'est le cas std::vector, c'est (A) stupide et (B) quelque chose que les gens qui pensent aux performances d'E / S devraient penser. Quoi qu'il en soit, le tampon est réutilisé, il n'est pas réaffecté à chaque fois. Et std::vectorutilise également un tampon à croissance dynamique. J'essaie d'être juste ici.
Ben Voigt
14
Quelle tâche essayez-vous réellement de comparer? Si vous n'utilisez aucune des fonctionnalités de formatage de ostringstreamet que vous souhaitez des performances aussi rapides que possible, vous devriez envisager d'aller directement à stringbuf. Les ostreamclasses sont supposées lier la fonctionnalité de formatage sensible aux paramètres régionaux avec un choix de tampon flexible (fichier, chaîne, etc.) via rdbuf()et son interface de fonction virtuelle. Si vous ne faites aucun formatage, ce niveau supplémentaire d'indirection va certainement sembler proportionnellement cher par rapport à d'autres approches.
CB Bailey
5
+1 pour vérité op. Nous avons obtenu des accélérations d'ordre ou d'amplitude en passant de ofstreamà fprintflors de la sortie d'informations de journalisation impliquant des doublons. MSVC 2008 sur WinXPsp3. iostreams est juste un chien lent.
KitsuneYMG
6
Voici quelques tests sur le site du comité: open-std.org/jtc1/sc22/wg21/docs/D_5.cpp
Johannes Schaub - litb

Réponses:

49

Ne répondant pas tant aux détails de votre question qu'au titre: le rapport technique 2006 sur les performances C ++ contient une section intéressante sur les flux IOS (p.68). Le plus pertinent pour votre question se trouve dans la section 6.1.2 («Vitesse d'exécution»):

Étant donné que certains aspects du traitement IOStreams sont répartis sur plusieurs facettes, il semble que la norme impose une implémentation inefficace. Mais ce n'est pas le cas - en utilisant une certaine forme de prétraitement, une grande partie du travail peut être évitée. Avec un éditeur de liens légèrement plus intelligent que celui généralement utilisé, il est possible de supprimer certaines de ces inefficacités. Ceci est discuté aux §6.2.3 et §6.2.5.

Depuis la rédaction du rapport en 2006, on pourrait espérer que de nombreuses recommandations auraient été intégrées aux compilateurs actuels, mais ce n'est peut-être pas le cas.

Comme vous le mentionnez, les facettes peuvent ne pas figurer dans write()(mais je ne le suppose pas aveuglément). Alors, qu'est-ce qui caractérise? Exécuter GProf sur votre ostringstreamcode compilé avec GCC donne la répartition suivante:

  • 44,23% dans std::basic_streambuf<char>::xsputn(char const*, int)
  • 34,62% ​​dans std::ostream::write(char const*, int)
  • 12,50% dans main
  • 6,73% std::ostream::sentry::sentry(std::ostream&)
  • 0,96% std::string::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)
  • 0,96% std::basic_ostringstream<char>::basic_ostringstream(std::_Ios_Openmode)
  • 0,00% dans std::fpos<int>::fpos(long long)

Ainsi, la majeure partie du temps est consacrée à xsputn, ce qui finit par appeler std::copy()après de nombreuses vérifications et mises à jour des positions et des tampons du curseur (consultez c++\bits\streambuf.tccles détails).

Je pense que vous vous êtes concentré sur le pire des cas. Toutes les vérifications effectuées ne représenteraient qu'une petite fraction du travail total effectué si vous aviez à traiter des blocs de données assez volumineux. Mais votre code déplace les données sur quatre octets à la fois et entraîne chaque fois tous les coûts supplémentaires. De toute évidence, on éviterait de le faire dans une situation réelle - considérez à quel point la pénalité aurait été négligeable si elle writeavait été appelée sur un tableau de 1 m d'entiers au lieu de 1 m de fois sur un int. Et dans une situation réelle, on apprécierait vraiment les caractéristiques importantes d'IOStreams, à savoir sa conception à mémoire sûre et à type sûr. Ces avantages ont un prix, et vous avez écrit un test qui fait que ces coûts dominent le temps d'exécution.

beldaz
la source
Cela ressemble à d'excellentes informations pour une future question sur les performances d'insertion / extraction formatée d'iostreams que je vais probablement poser bientôt. Mais je ne crois pas qu'il y ait de facettes en jeu ostream::write().
Ben Voigt
4
+1 pour le profilage (c'est une machine Linux je suppose?). Cependant, j'ajoute en fait quatre octets à la fois (en fait sizeof i, mais tous les compilateurs avec lesquels je teste ont 4 octets int). Et cela ne me semble pas du tout irréaliste, à quelle taille de morceaux pensez-vous passer à chaque appel xsputndans du code typique comme stream << "VAR: " << var.x << ", " << var.y << endl;.
Ben Voigt
39
@beldaz: Cet exemple de code "typique" qui n'appelle que xsputncinq fois pourrait très bien se trouver dans une boucle qui écrit un fichier de 10 millions de lignes. Passer des données à des iostreams en gros morceaux est beaucoup moins un scénario réel que mon code de référence. Pourquoi devrais-je avoir à écrire dans un flux en mémoire tampon avec le nombre minimal d'appels? Si je dois faire ma propre mise en mémoire tampon, quel est l'intérêt des iostreams de toute façon? Et avec les données binaires, j'ai la possibilité de les tamponner moi-même, lorsque j'écris des millions de nombres dans un fichier texte, l'option en bloc n'existe tout simplement pas, je DOIS appeler operator <<pour chacun.
Ben Voigt
1
@beldaz: On peut estimer quand les E / S commencent à dominer avec un simple calcul. À un taux d'écriture moyen de 90 Mo / s, ce qui est typique des disques durs actuels de qualité grand public, le vidage du tampon de 4 Mo prend <45 ms (débit, latence peu important en raison du cache d'écriture du système d'exploitation). Si l'exécution de la boucle interne prend plus de temps que cela pour remplir le tampon, le CPU sera le facteur limitant. Si la boucle interne s'exécute plus rapidement, les E / S seront le facteur limitant, ou au moins il reste du temps CPU pour faire le vrai travail.
Ben Voigt
5
Bien sûr, cela ne signifie pas que l'utilisation d'iostreams signifie nécessairement un programme lent. Si les E / S sont une très petite partie du programme, l'utilisation d'une bibliothèque d'E / S avec de mauvaises performances n'aura pas beaucoup d'impact global. Mais ne pas être appelé assez souvent pour être important n'est pas la même chose que de bonnes performances, et dans les applications lourdes d'E / S, cela compte.
Ben Voigt
27

Je suis plutôt déçu par les utilisateurs de Visual Studio, qui ont plutôt eu un aperçu de celui-ci:

  • Dans l'implémentation de Visual Studio de ostream, l' sentryobjet (qui est requis par la norme) entre dans une section critique protégeant le streambuf(qui n'est pas requis). Cela ne semble pas être facultatif, vous payez donc le coût de la synchronisation des threads même pour un flux local utilisé par un seul thread, qui n'a pas besoin de synchronisation.

Cela nuit au code qui utilise ostringstream sévèrement le pour formater les messages. L'utilisation stringbufdirecte de évite l'utilisation de sentry, mais les opérateurs d'insertion formatés ne peuvent pas fonctionner directement sur streambufs. Pour Visual C ++ 2010, la section critique ralentit ostringstream::writed'un facteur trois par rapport à l' stringbuf::sputnappel sous - jacent .

En regardant les données du profileur de beldaz sur newlib , il semble clair que gcc sentryne fait rien de fou comme ça. ostringstream::writesous gcc ne prend environ 50% de plus que stringbuf::sputn, maisstringbuf lui-même est beaucoup plus lent que sous VC ++. Et les deux se comparent toujours très défavorablement à l'utilisation d'un vector<char>tampon d'E / S, mais pas avec la même marge que sous VC ++.

Ben Voigt
la source
Ces informations sont-elles toujours à jour? AFAIK, l'implémentation C ++ 11 livrée avec GCC effectue ce verrouillage «fou». Certes, VS2010 le fait aussi. Quelqu'un pourrait-il clarifier ce comportement et si «ce qui n'est pas requis» est toujours valable en C ++ 11?
mloskot
2
@mloskot: Je ne vois aucune exigence de sécurité des threads sur sentry... "La sentinelle de classe définit une classe qui est responsable des opérations de préfixe et de suffixe sans exception." et une note "Le constructeur et le destructeur sentinelle peuvent également effectuer des opérations supplémentaires en fonction de l'implémentation." On peut également présumer du principe C ++ de «vous ne payez pas pour ce que vous n'utilisez pas» que le comité C ++ n'approuverait jamais une telle exigence de gaspillage. Mais n'hésitez pas à poser une question sur la sécurité des fils iostream.
Ben Voigt
8

Le problème que vous voyez est tout dans la surcharge autour de chaque appel à write (). Chaque niveau d'abstraction que vous ajoutez (char [] -> vector -> string -> ostringstream) ajoute un peu plus d'appels de fonction / retours et d'autres guff de gestion qui - si vous l'appelez un million de fois - s'additionnent.

J'ai modifié deux des exemples sur ideone pour écrire dix pouces à la fois. Le temps en aval est passé de 53 à 6 ms (près de 10 fois l'amélioration) tandis que la boucle char s'est améliorée (3,7 à 1,5) - utile, mais seulement par un facteur de deux.

Si vous êtes préoccupé par les performances, vous devez choisir le bon outil pour le travail. ostringstream est utile et flexible, mais il y a une pénalité pour l'utiliser comme vous essayez. char [] est un travail plus difficile, mais les gains de performances peuvent être importants (rappelez-vous que le gcc inclura probablement les memcpys pour vous également).

En bref, ostringstream n'est pas cassé, mais plus vous vous rapprochez du métal, plus votre code s'exécutera rapidement. L'assembleur a encore des avantages pour certains.

Roddy
la source
8
Qu'est-ce qui ostringstream::write()doit faire ça vector::push_back()non? Si quoi que ce soit, cela devrait être plus rapide car il est remis un bloc au lieu de quatre éléments individuels. Si ostringstreamc'est plus lent que std::vectorsans fournir de fonctionnalités supplémentaires, alors je dirais que c'est cassé.
Ben Voigt
1
@Ben Voigt: Au contraire, son vecteur quelque chose doit faire que l'ostringstream N'A PAS à faire, ce qui rend le vecteur plus performant dans ce cas. Le vecteur est garanti contigu en mémoire, contrairement à ostringstream. Le vecteur est l'une des classes conçues pour être performantes, contrairement à ostringstream.
Dragontamer5788
2
@Ben Voigt: L'utilisation stringbufdirecte ne supprimera pas tous les appels de fonction car stringbufl'interface publique de se compose de fonctions publiques non virtuelles dans la classe de base qui sont ensuite envoyées à la fonction virtuelle protégée dans la classe dérivée.
CB Bailey
2
@Charles: Sur n'importe quel compilateur décent, cela devrait être le cas, puisque l'appel de fonction publique sera inséré dans un contexte où le type dynamique est connu du compilateur, il peut supprimer l'indirection et même incorporer ces appels.
Ben Voigt
6
@Roddy: Je pense que tout cela est du code de modèle en ligne, visible dans chaque unité de compilation. Mais je suppose que cela pourrait varier selon la mise en œuvre. Pour certains, je m'attendrais à ce que l'appel en discussion, la sputnfonction publique qui appelle le virtuel protégé xsputn, soit en ligne. Même s'il xsputnn'est pas en ligne, le compilateur peut, tout en étant en ligne sputn, déterminer le xsputnremplacement exact nécessaire et générer un appel direct sans passer par la vtable.
Ben Voigt
1

Pour obtenir de meilleures performances, vous devez comprendre comment fonctionnent les conteneurs que vous utilisez. Dans votre exemple de tableau char [], le tableau de la taille requise est alloué à l'avance. Dans votre exemple vectoriel et ostringstream, vous forcez les objets à allouer et réallouer à plusieurs reprises et éventuellement à copier des données plusieurs fois à mesure que l'objet se développe.

Avec std :: vector, cela est facilement résolu en initialisant la taille du vecteur à la taille finale comme vous l'avez fait pour le tableau char; au lieu de cela, vous paralysez injustement les performances en redimensionnant à zéro! Ce n'est pas une comparaison juste.

En ce qui concerne l'ostringstream, la préallocation de l'espace n'est pas possible, je dirais que c'est une utilisation inappropriée. La classe a beaucoup plus d'utilité qu'un simple tableau de caractères, mais si vous n'avez pas besoin de cet utilitaire, ne l'utilisez pas, car vous paierez les frais généraux dans tous les cas. Au lieu de cela, il doit être utilisé pour ce qu'il est bon - le formatage des données dans une chaîne. C ++ fournit une large gamme de conteneurs et un ostringstram est parmi les moins appropriés à cet effet.

Dans le cas du vecteur et de l'ostringstream, vous obtenez une protection contre le dépassement de tampon, vous n'obtenez pas cela avec un tableau de caractères, et cette protection n'est pas gratuite.

Clifford
la source
1
L'allocation ne semble pas être le problème pour ostringstream. Il revient juste à zéro pour les itérations suivantes. Pas de troncature. J'ai aussi essayé ostringstream.str.reserve(4000000)et cela n'a fait aucune différence.
Roddy
Je pense qu'avec ostringstream, vous pouvez "réserver" en passant une chaîne fictive, c'est-à-dire: ostringstream str(string(1000000 * sizeof(int), '\0'));avec vector, le resizene désalloue aucun espace, il ne se dilate que s'il le faut.
Nim
1
msgstr "vecteur .. protection contre le dépassement de tampon". Une idée fausse commune - l' vector[]opérateur n'est généralement PAS vérifié pour les erreurs de limites par défaut. vector.at()est cependant.
Roddy
2
vector<T>::resize(0)ne
réalloue
2
@Roddy: Ne pas utiliser operator[], mais push_back()(à titre de back_inserter), ce qui teste définitivement le débordement. Ajout d'une autre version qui n'utilise pas push_back.
Ben Voigt