web-dev-qa-db-fra.com

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

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 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 ostringstream et stringbuf exécutent moins d'itérations car elles sont beaucoup plus lentes.

Sur ideone, le ostringstream est environ 3 fois plus lent que std:copy + back_inserter + std::vector, et environ 15 fois plus lent que memcpy dans 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, de sorte que la lenteur des iostreams ne peut pas être imputée à des E/S de disque lentes, trop de vidage, de synchronisation avec stdio ou d'autres choses que les gens utilisent pour excuser la lenteur observée de la bibliothèque standard C++ iostream.

Ce serait bien de voir des références 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 garder 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 boucle externe (k).

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> itérateur et vérification des limites: 11,4 ms
  • char[]: 3,7 ms

Sur mon ordinateur portable (Visual C++ 2010 x86, cl /Ox /EHsc, Windows 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> itérateur et vérification 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 optimisation guidée par le profil cl /Ox /EHsc /GL /c, link /ltcg:pgi, courir, 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> itérateur et vérification des limites: 4,0 ms, 4,0 ms
  • char[]: 3,57 ms, 3,75 ms

Même ordinateur portable, 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> itérateur et vérification 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> itérateur et vérification des limites: 0,99 ms, 0,99 ms
  • char[]: 1,25 ms, 1,24 ms

EDIT: Ran tous deux fois pour voir à quel point les résultats étaient cohérents. IMO assez cohérent.

REMARQUE: Sur mon ordinateur portable, comme je peux épargner plus de temps CPU que ne le permet ideone, j'ai défini le nombre d'itérations sur 1000 pour toutes les méthodes. Cela signifie que la réallocation ostringstream et vector, qui a lieu uniquement lors de la première passe, devrait avoir peu d'impact sur les résultats finaux.

EDIT: Oups, a trouvé un bogue dans le vector- avec-itérateur ordinaire, l'itérateur n'était pas avancé et donc il y avait trop de hits de cache. Je me demandais comment vector<char> surperformait char[]. Cela n'a pas fait beaucoup de différence, vector<char> est toujours plus rapide que char[] 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 plus 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 à Pour ce faire, il suffit de vider le tampon actuel et de le réutiliser. Il devrait donc s'agir d'une limite supérieure du coût de la mise en mémoire tampon de la sortie. Et c'est exactement ce qui est nécessaire pour créer un tampon en mémoire qui fonctionne.

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

193
Ben Voigt

Ne répondant pas tant aux détails de votre question qu'au titre: le 2006 Rapport technique sur les performances C++ a une section intéressante sur IOStreams (p.68). Le plus pertinent pour votre question est 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 code ostringstream 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% en main
  • 6,73% dans std::ostream::sentry::sentry(std::ostream&)
  • 0,96% dans std::string::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)
  • 0,96% dans 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 passée dans xsputn, qui appelle finalement std::copy() après de nombreuses vérifications et mises à jour des positions des curseurs et des tampons (regardez dans c++\bits\streambuf.tcc pour les 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 write avait été appelé 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.

47
beldaz

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 Visual Studio de ostream, l'objet sentry (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 pour formater les messages assez sévèrement. L'utilisation de stringbuf évite directement 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::write d'un facteur trois par rapport au sous-jacent stringbuf::sputn appel.

En regardant les données du profileur de beldaz sur newlib , il semble clair que sentry de gcc ne fait rien de fou comme ça. ostringstream::write sous gcc ne prend environ 50% de plus que stringbuf::sputn, mais stringbuf 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> pour la mise en mémoire tampon des E/S, mais pas avec la même marge que sous VC++.

27
Ben Voigt

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 quelques appels/retours de fonction et d'autres guff de ménage 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.

8
Roddy

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 plutôt 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 une utilité bien plus grande 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.

1
Clifford