La norme C ++ impose-t-elle des performances médiocres aux stream iostream, ou suis-je confronté à une mauvaise implémentation?

Chaque fois que je mentionne la performance lente des iostreams de la bibliothèque standard C ++, je suis confronté à une vague d’incrédulité. Pourtant, j’ai des résultats de profileur montrant une grande quantité de temps passé dans le code de bibliothèque iostream (optimisations complètes du compilateur) et le passage des iostream aux API d’E / S spécifiques au système

Quel travail supplémentaire la bibliothèque standard C ++ fait-elle, est-elle requirejse par la norme et est-elle utile dans la pratique? Ou certains compilateurs fournissent-ils des implémentations de stream iostream compétitifs avec la gestion manuelle des tampons?

Repères

Pour faire bouger les choses, j’ai écrit quelques petits programmes pour exercer la mémoire tampon interne de iostreams:

  • mettre des données binarys dans un ossortingngstream http://ideone.com/2PPYw
  • mettre des données binarys dans un char[] buffer http://ideone.com/Ni5ct
  • mettre des données binarys dans un vector utilisant back_inserter http://ideone.com/Mj2Fi
  • NOUVEAU : vector iterator simple http://ideone.com/9iitv
  • NOUVEAU : mettre des données binarys directement dans ssortingngbuf http://ideone.com/qc9QA
  • NOUVEAU : vector iterator simple plus les bornes vérifier http://ideone.com/YyrKy

Notez que les versions ossortingngstream et ssortingngbuf exécutent moins d’itérations car elles sont beaucoup plus lentes.

Sur ideone, l’ ossortingngstream 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-après lorsque je suis passé de mon application réelle à la 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, un vidage excessif, la synchronisation avec stdio ou tout autre élément utilisé pour excuser la lenteur observée iostream.

Il serait intéressant de voir des tests sur d’autres systèmes et des commentaires sur ce que font les implémentations courantes (telles que libc ++, Visual C ++, Intel C ++ de gcc) et la quantité de surcharge imposée par la norme.

Justification de ce test

Un certain nombre de personnes ont correctement souligné que les stream iostream sont plus couramment utilisés pour les sorties formatées. Cependant, ils sont également la seule API moderne fournie par la norme C ++ pour l’access aux fichiers binarys. 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 fourni avec les données brutes, comment peuvent-ils également suivre le formatage?

Timing de référence

Tous ceux-ci sont par itération de la boucle externe ( k ).

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

  • ossortingngstream : 53 millisecondes
  • ssortingngbuf : 27 ms
  • vector et back_inserter : 17.6 ms
  • vector avec un iterator ordinaire: 10.6 ms
  • vérification de l’iterator et des limites du vector : 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):

  • ossortingngstream : 73,4 millisecondes, 71,6 ms
  • ssortingngbuf : 21,7 ms, 21,3 ms
  • vector et back_inserter : 34.6 ms, 34.4 ms
  • vector avec un iterator ordinaire: 1,10 ms, 1,04 ms
  • vérification de l’iterator et des limites du vector : 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 profil cl /Ox /EHsc /GL /c , link /ltcg:pgi , run, link /ltcg:pgo , mesure:

  • ossortingngstream : 61,2 ms, 60,5 ms
  • vector avec un iterator ordinaire: 1.04 ms, 1.03 ms

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

  • ossortingngstream : 62,7 ms, 60,5 ms
  • ssortingngbuf : 44,4 ms, 44,5 ms
  • vector et back_inserter : 13,5 ms, 13,6 ms
  • vector avec un iterator ordinaire: 4,1 ms, 3,9 ms
  • vérification de l’iterator et des limites du vector : 4,0 ms, 4,0 ms
  • char[] : 3.57 ms, 3.75 ms

Même ordinateur portable, Visual C ++ 2008 SP1, cl /Ox /EHsc :

  • ossortingngstream : 88,7 ms, 87,6 ms
  • ssortingngbuf : 23,3 ms, 23,4 ms
  • vector et back_inserter : 26,1 ms, 24,5 ms
  • vector avec un iterator ordinaire: 3.13 ms, 2.48 ms
  • vérification de l’iterator et des limites du vector : 2.97 ms, 2.53 ms
  • char[] : 1.52 ms, 1.25 ms

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

  • ossortingngstream : 48,6 ms, 45,0 ms
  • ssortingngbuf : 16,2 ms, 16,0 ms
  • vector et back_inserter : 26,3 ms, 26,5 ms
  • vector avec un iterator ordinaire: 0.87 ms, 0.89 ms
  • vérification de l’iterator et des limites du vector : 0.99 ms, 0.99 ms
  • char[] : 1,25 ms, 1,24 ms

EDIT: Tous les deux pour voir à quel point les résultats étaient cohérents. Assez cohérent IMO.

NOTE: Sur mon ordinateur portable, comme je peux économiser plus de temps processeur que ne le permet ideone, je règle le nombre d’itérations à 1000 pour toutes les méthodes. Cela signifie que la ossortingngstream et de vector , qui ne se produit qu’au premier passage, devrait avoir peu d’impact sur les résultats finaux.

EDIT: Oups, a trouvé un bogue dans le vector -with-ordinaire-iterator, l’iterator n’était pas avancé et par conséquent il y avait trop de résultats de cache. Je me demandais comment le vector était plus performant que char[] . Cela n’a pas fait beaucoup de différence, le vector est toujours plus rapide que char[] sous VC ++ 2010.

Conclusions

La mise en mémoire tampon des stream 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 posté, ” vector iterator simple 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 rentre pas. Comme Clifford l’a souligné, la mise en mémoire tampon dans une classe d’E / S de fichier ne devrait pas faire cela, elle viderait simplement le tampon actuel et le réutiliserait. Donc, cela devrait être une limite supérieure sur le 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 ssortingngbuf est- ssortingngbuf 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.

Ne répondant pas aux spécificités de votre question autant que le titre: le rapport technique 2006 sur les performances C ++ comporte une section intéressante sur IOStreams (p.68). La section 6.1.2 (“Vitesse d’exécution”) est la plus pertinente pour votre question:

Certains aspects du traitement des stream IOS étant 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 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é au § 6.2.3 et au § 6.2.5.

Depuis que le rapport a été rédigé en 2006, on pourrait espérer que de nombreuses recommandations auraient été incorporées dans les 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 dirais pas cela aveuglément). Alors qu’est-ce que la fonctionnalité? L’exécution de GProf sur votre code ossortingngstream compilé avec GCC donne la répartition suivante:

  • 44.23% dans std::basic_streambuf::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::ssortingng::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)
  • 0,96% dans std::basic_ossortingngstream::basic_ossortingngstream(std::_Ios_Openmode)
  • 0.00% dans std::fpos::fpos(long long)

Donc, le gros du temps est passé dans xsputn , qui appelle finalement std::copy() après beaucoup de vérifications et de mises à jour des positions du curseur et des tampons (regardez dans c++\bits\streambuf.tcc pour les détails).

Mon sharepoint vue est que vous vous êtes concentré sur le pire des cas. Toute la vérification effectuée ne représenterait qu’une petite fraction du travail total effectué si vous traitiez des blocs de données raisonnablement volumineux. Mais votre code transfère les données en quatre octets à la fois et entraîne tous les coûts supplémentaires à chaque fois. Évidemment, on éviterait de le faire dans une situation réelle – considérez combien la pénalité aurait été négligeable si on write sur un tableau de 1 m ints au lieu de 1 m sur un int. Et dans une situation réelle, on apprécierait vraiment les caractéristiques importantes de IOStreams, à savoir sa conception sécurisée et sa mémoire sûre. Ces avantages ont un prix et vous avez écrit un test qui fait que ces coûts dominent le temps d’exécution.

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

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

Cela blesse le code qui utilise ossortingngstream pour formater les messages assez sévèrement. L’utilisation du ssortingngbuf évite directement l’utilisation de la sentry , mais les opérateurs d’insertion formatés ne peuvent pas travailler directement sur streambuf . Pour Visual C ++ 2010, la section critique ralentit ossortingngstream::write d’un facteur trois par rapport à l’appel ssortingngbuf::sputn sous-jacent.

En regardant les données de profil de beldaz sur newlib , il semble clair que la sentry de gcc ne fait rien de tel. ossortingngstream::write sous gcc ne prend que 50% de plus que ssortingngbuf::sputn , mais ssortingngbuf lui-même est beaucoup plus lent que sous VC ++. Et les deux comparent encore très défavorablement à l’utilisation d’un vector pour la mise en mémoire tampon des E / S, mais pas de la même manière que sous VC ++.

Le problème que vous voyez est entièrement lié à chaque appel à écrire (). Chaque niveau d’abstraction que vous ajoutez (char [] -> vector -> ssortingng -> ossortingngstream) ajoute un peu plus d’appels / retours de fonctions et d’autres fonctions de gestion qui, si vous l’appelez un million de fois, s’ajoutent.

J’ai modifié deux des exemples sur ideone pour écrire dix pouces à la fois. Le temps d’anticipation est passé de 53 à 6 ms (presque 10 fois l’amélioration), tandis que la boucle de caractères s’est améliorée (de 3,7 à 1,5), ce qui n’est utile que d’un facteur de deux.

Si vous êtes préoccupé par la performance, vous devez choisir le bon outil pour le travail. ossortingngstream est utile et flexible, mais il y a une pénalité pour l’utiliser de la manière que vous essayez. char [] est un travail plus difficile, mais les gains de performance peuvent être formidables (rappelez-vous que le gcc va probablement également inclure les memcpys).

En bref, ossortingngstream n’est pas cassé, mais plus le métal est proche, plus votre code s’exécute rapidement. L’assembleur a encore des avantages pour certaines personnes.

Pour obtenir de meilleures performances, vous devez comprendre le fonctionnement des conteneurs que vous utilisez. Dans votre exemple de tableau char [], le tableau de la taille requirejse est alloué à l’avance. Dans votre exemple vector et ossortingngstream, vous forcez les objects à allouer et à réallouer de manière répétée et éventuellement à copier des données plusieurs fois au fur et à mesure de la croissance de l’object.

Avec std :: vector, cela est facilement résolu en initialisant la taille du vecteur à la taille finale comme vous l’avez fait avec le tableau de caractères; au lieu de cela, vous paralyse plutôt injustement la performance en redimensionnant à zéro! Ce n’est guère une comparaison équitable.

En ce qui concerne le stream sanguin, il n’est pas possible de pré-allouer l’espace, je dirais qu’il s’agit d’une utilisation inapplicable. 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 devrez payer la surcharge dans tous les cas. Au lieu de cela, il devrait être utilisé pour ce qu’il est bon pour – formater des données dans une chaîne. C ++ fournit une large gamme de conteneurs et un ossortingngstram est parmi les moins appropriés à cette fin.

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