Erfordert der C ++ - Standard eine schlechte Leistung für iostreams oder habe ich es nur mit einer schlechten Implementierung zu tun?

196

Jedes Mal, wenn ich die langsame Leistung von Iostreams der C ++ - Standardbibliothek erwähne, stoße ich auf eine Welle des Unglaubens. Ich habe jedoch Profiler-Ergebnisse, die zeigen, wie viel Zeit im iostream-Bibliothekscode verbracht wurde (vollständige Compiler-Optimierungen), und der Wechsel von iostreams zu betriebssystemspezifischen E / A-APIs und die benutzerdefinierte Pufferverwaltung führen zu einer Verbesserung um eine Größenordnung.

Welche zusätzliche Arbeit leistet die C ++ - Standardbibliothek, wird sie vom Standard verlangt und ist sie in der Praxis nützlich? Oder bieten einige Compiler Implementierungen von iostreams an, die mit der manuellen Pufferverwaltung konkurrenzfähig sind?

Benchmarks

Um die Dinge in Bewegung zu bringen, habe ich ein paar kurze Programme geschrieben, um die interne Pufferung von iostreams zu üben:

Beachten Sie, dass die ostringstreamund stringbufVersionen weniger Iterationen laufen , weil sie so viel langsamer sind.

Auf ideone ist das ostringstreamungefähr 3 mal langsamer als std:copy+ back_inserter+ std::vectorund ungefähr 15 mal langsamer als memcpyin einem Rohpuffer. Dies steht im Einklang mit der Vorher-Nachher-Profilerstellung, als ich meine reale Anwendung auf benutzerdefinierte Pufferung umstellte.

Dies sind alles In-Memory-Puffer, sodass die Langsamkeit von Iostreams nicht auf langsame Festplatten-E / A, zu viel Leeren, Synchronisation mit stdio oder andere Dinge zurückzuführen ist, mit denen die beobachtete Langsamkeit der C ++ - Standardbibliothek entschuldigt wird iostream.

Es wäre schön, Benchmarks auf anderen Systemen zu sehen und Kommentare zu den üblichen Implementierungen (wie gccs libc ++, Visual C ++, Intel C ++) und zu sehen, wie viel Overhead vom Standard vorgeschrieben wird.

Begründung für diesen Test

Einige Leute haben richtig darauf hingewiesen, dass iostreams häufiger für formatierte Ausgaben verwendet werden. Sie sind jedoch auch die einzige moderne API, die vom C ++ - Standard für den Zugriff auf Binärdateien bereitgestellt wird. Der eigentliche Grund für die Durchführung von Leistungstests für die interne Pufferung gilt jedoch für die typischen formatierten E / A: Wenn iostreams den Festplattencontroller nicht mit Rohdaten versorgen können, wie können sie möglicherweise mithalten, wenn sie auch für die Formatierung verantwortlich sind?

Benchmark-Timing

All dies erfolgt pro Iteration der äußeren ( k) Schleife.

Auf ideone (gcc-4.3.4, unbekanntes Betriebssystem und unbekannte Hardware):

  • ostringstream: 53 Millisekunden
  • stringbuf: 27 ms
  • vector<char>und back_inserter: 17,6 ms
  • vector<char> mit gewöhnlichem Iterator: 10,6 ms
  • vector<char> Iterator- und Grenzüberprüfung: 11,4 ms
  • char[]: 3,7 ms

Auf meinem Laptop (Visual C ++ 2010 x86, cl /Ox /EHscWindows 7 Ultimate 64-Bit, Intel Core i7, 8 GB RAM):

  • ostringstream: 73,4 Millisekunden, 71,6 ms
  • stringbuf: 21,7 ms, 21,3 ms
  • vector<char>und back_inserter: 34,6 ms, 34,4 ms
  • vector<char> mit gewöhnlichem Iterator: 1,10 ms, 1,04 ms
  • vector<char> Überprüfung des Iterators und der Grenzen: 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, mit Profil-geführte Optimierung cl /Ox /EHsc /GL /c, link /ltcg:pgi, laufen, link /ltcg:pgo, Maße:

  • ostringstream: 61,2 ms, 60,5 ms
  • vector<char> mit gewöhnlichem Iterator: 1,04 ms, 1,03 ms

Gleicher Laptop, gleiches Betriebssystem mit cygwin gcc 4.3.4 g++ -O3:

  • ostringstream: 62,7 ms, 60,5 ms
  • stringbuf: 44,4 ms, 44,5 ms
  • vector<char>und back_inserter: 13,5 ms, 13,6 ms
  • vector<char> mit gewöhnlichem Iterator: 4,1 ms, 3,9 ms
  • vector<char> Iterator- und Grenzüberprüfung: 4,0 ms, 4,0 ms
  • char[]: 3,57 ms, 3,75 ms

Gleicher Laptop, Visual C ++ 2008 SP1 , cl /Ox /EHsc:

  • ostringstream: 88,7 ms, 87,6 ms
  • stringbuf: 23,3 ms, 23,4 ms
  • vector<char>und back_inserter: 26,1 ms, 24,5 ms
  • vector<char> mit gewöhnlichem Iterator: 3,13 ms, 2,48 ms
  • vector<char> Iterator- und Grenzüberprüfung: 2,97 ms, 2,53 ms
  • char[]: 1,52 ms, 1,25 ms

Gleicher Laptop, Visual C ++ 2010 64-Bit-Compiler:

  • ostringstream: 48,6 ms, 45,0 ms
  • stringbuf: 16,2 ms, 16,0 ms
  • vector<char>und back_inserter: 26,3 ms, 26,5 ms
  • vector<char> mit gewöhnlichem Iterator: 0,87 ms, 0,89 ms
  • vector<char> Iterator- und Grenzüberprüfung: 0,99 ms, 0,99 ms
  • char[]: 1,25 ms, 1,24 ms

EDIT: Lief alle zweimal, um zu sehen, wie konsistent die Ergebnisse waren. Ziemlich konsistente IMO.

HINWEIS: Da ich auf meinem Laptop mehr CPU-Zeit sparen kann, als ideone zulässt, setze ich die Anzahl der Iterationen für alle Methoden auf 1000. Dies bedeutet , dass ostringstreamund vectorUmverteilung, die erst beim ersten Durchgang nimmt, sollte nur geringe Auswirkungen auf die endgültigen Ergebnisse haben.

EDIT: Ups, habe einen Fehler im vectornormalen Iterator gefunden, der Iterator wurde nicht weiterentwickelt und daher gab es zu viele Cache-Treffer. Ich habe mich gefragt, wie gut es vector<char>war char[]. Es machte jedoch keinen großen Unterschied, vector<char>ist immer noch schneller als char[]unter VC ++ 2010.

Schlussfolgerungen

Das Puffern von Ausgabestreams erfordert jedes Mal, wenn Daten angehängt werden, drei Schritte:

  • Überprüfen Sie, ob der eingehende Block zum verfügbaren Pufferplatz passt.
  • Kopieren Sie den eingehenden Block.
  • Aktualisieren Sie den Datenende-Zeiger.

Das neueste Code-Snippet, das ich veröffentlicht habe, " vector<char>einfacher Iterator plus Grenzüberprüfung", tut dies nicht nur, es weist auch zusätzlichen Speicherplatz zu und verschiebt die vorhandenen Daten, wenn der eingehende Block nicht passt. Wie Clifford betonte, müsste das Puffern in einer Datei-E / A-Klasse dies nicht tun, sondern nur den aktuellen Puffer leeren und wiederverwenden. Dies sollte also eine Obergrenze für die Kosten für die Pufferung der Ausgabe sein. Und genau das ist erforderlich, um einen funktionierenden In-Memory-Puffer zu erstellen.

Warum ist stringbufideone 2,5-mal langsamer und mindestens 10-mal langsamer, wenn ich es teste? Es wird in diesem einfachen Mikro-Benchmark nicht polymorph verwendet, das erklärt es also nicht.

Ben Voigt
quelle
24
Sie schreiben jeweils eine Million Zeichen und fragen sich, warum dies langsamer ist als das Kopieren in einen vorab zugewiesenen Puffer?
Anon.
19
@Anon: Ich puffere vier Millionen Bytes vier auf einmal, und ja, ich frage mich, warum das langsam ist. Wenn std::ostringstreames nicht klug genug ist, die Puffergröße exponentiell zu erhöhen std::vector, ist dies (A) dumm und (B) etwas, über das Leute nachdenken sollten, die über die E / A-Leistung nachdenken. Auf jeden Fall wird der Puffer wiederverwendet und nicht jedes Mal neu zugewiesen. Und verwendet std::vectorauch einen dynamisch wachsenden Puffer. Ich versuche hier fair zu sein.
Ben Voigt
14
Welche Aufgabe versuchen Sie eigentlich zu bewerten? Wenn Sie keine der Formatierungsfunktionen von verwenden ostringstreamund eine möglichst schnelle Leistung wünschen, sollten Sie in Betracht ziehen, direkt zu wechseln stringbuf. Es ostreamwird angenommen, dass die Klassen länderspezifische Formatierungsfunktionen mit flexibler Pufferauswahl (Datei, Zeichenfolge usw.) rdbuf()und ihrer virtuellen Funktionsschnittstelle verbinden. Wenn Sie keine Formatierung vornehmen, wird diese zusätzliche Indirektionsebene im Vergleich zu anderen Ansätzen sicherlich proportional teuer aussehen.
CB Bailey
5
+1 für die Wahrheit op. Wir haben eine Beschleunigung der Reihenfolge oder Größenordnung erreicht, indem wir bei der Ausgabe von Protokollinformationen mit Doppelwerten von ofstreamzu fprintfgewechselt sind. MSVC 2008 unter WinXPsp3. iostreams ist nur hundeschwach.
KitsuneYMG
6
Hier ist ein Test auf der Komiteeseite: open-std.org/jtc1/sc22/wg21/docs/D_5.cpp
Johannes Schaub - litb

Antworten:

49

Beantworten Sie nicht die Einzelheiten Ihrer Frage, sondern den Titel: das Jahr 2006 Einzelheiten technische Bericht zur C ++ - Leistung enthält einen interessanten Abschnitt zu IOStreams (S.68). Am relevantesten für Ihre Frage ist in Abschnitt 6.1.2 ("Ausführungsgeschwindigkeit"):

Da bestimmte Aspekte der IOStreams-Verarbeitung auf mehrere Facetten verteilt sind, scheint der Standard eine ineffiziente Implementierung vorzuschreiben. Dies ist jedoch nicht der Fall - durch die Verwendung einer Vorverarbeitung kann ein Großteil der Arbeit vermieden werden. Mit einem etwas intelligenteren Linker als normalerweise verwendet, können einige dieser Ineffizienzen beseitigt werden. Dies wird in §6.2.3 und §6.2.5 erörtert.

Da der Bericht im Jahr 2006 verfasst wurde, würde man hoffen, dass viele der Empfehlungen in aktuelle Compiler aufgenommen wurden, aber vielleicht ist dies nicht der Fall.

Wie Sie bereits erwähnt haben, sind Facetten möglicherweise nicht enthalten write()(aber ich würde das nicht blind annehmen). Was ist also? Das Ausführen von GProf auf Ihrem ostringstreammit GCC kompilierten Code führt zu folgender Aufschlüsselung:

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

Der Großteil der Zeit wird also in verbracht xsputn, was schließlich std::copy()nach vielen Überprüfungen und Aktualisierungen der Cursorpositionen und Puffer aufruft (siehe c++\bits\streambuf.tccDetails).

Ich gehe davon aus, dass Sie sich auf den schlimmsten Fall konzentriert haben. Alle Überprüfungen, die durchgeführt werden, machen nur einen kleinen Teil der gesamten geleisteten Arbeit aus, wenn Sie mit relativ großen Datenblöcken arbeiten. Ihr Code verschiebt jedoch Daten in jeweils vier Bytes und verursacht jedes Mal alle zusätzlichen Kosten. Es ist klar, dass man dies in einer realen Situation vermeiden würde - überlegen Sie, wie vernachlässigbar die Strafe gewesen wäre, wenn writesie auf einem Array von 1 m Ints statt auf 1 m Mal auf einem Int aufgerufen worden wäre . Und in einer realen Situation würde man die wichtigen Funktionen von IOStreams wirklich schätzen, nämlich das speichersichere und typsichere Design. Solche Vorteile haben ihren Preis, und Sie haben einen Test geschrieben, bei dem diese Kosten die Ausführungszeit dominieren.

beldaz
quelle
Klingt nach großartigen Informationen für eine zukünftige Frage zur Leistung des formatierten Einfügens / Extrahierens von Iostreams, die ich wahrscheinlich bald stellen werde. Aber ich glaube nicht, dass es irgendwelche Facetten gibt ostream::write().
Ben Voigt
4
+1 für die Profilerstellung (das ist vermutlich eine Linux-Maschine?). Allerdings füge ich tatsächlich vier Bytes gleichzeitig hinzu (tatsächlich sizeof ihaben alle Compiler, mit denen ich teste, 4 Byte int). Und das scheint mir gar nicht so unrealistisch zu sein, an welche Größe Chunks Ihrer Meinung nach bei jedem Aufruf xsputnin einem typischen Code wie übergeben werden stream << "VAR: " << var.x << ", " << var.y << endl;.
Ben Voigt
39
@beldaz: Dieses "typische" Codebeispiel, das nur xsputnfünfmal aufgerufen wird, könnte sich sehr gut in einer Schleife befinden, die eine Datei mit 10 Millionen Zeilen schreibt. Das Übergeben von Daten an Iostreams in großen Blöcken ist weitaus weniger ein reales Szenario als mein Benchmark-Code. Warum sollte ich in einen gepufferten Stream mit der Mindestanzahl von Anrufen schreiben müssen ? Wenn ich meine eigene Pufferung durchführen muss, wozu dienen iostreams überhaupt? Und mit Binärdaten habe ich die Möglichkeit, sie selbst zu puffern. Wenn ich Millionen von Zahlen in eine Textdatei schreibe, existiert die Massenoption einfach nicht. Ich MUSS operator <<für jede einzelne aufrufen .
Ben Voigt
1
@beldaz: Mit einer einfachen Berechnung kann man abschätzen, wann E / A zu dominieren beginnt. Bei einer durchschnittlichen Schreibrate von 90 MB / s, die für aktuelle Festplatten für Endverbraucher typisch ist, dauert das Leeren des 4-MB-Puffers <45 ms (Durchsatz, Latenz ist aufgrund des OS-Schreibcaches unwichtig). Wenn das Ausfüllen der inneren Schleife länger dauert, um den Puffer zu füllen, ist die CPU der begrenzende Faktor. Wenn die innere Schleife schneller läuft, ist E / A der begrenzende Faktor, oder es bleibt zumindest etwas CPU-Zeit übrig, um die eigentliche Arbeit zu erledigen.
Ben Voigt
5
Das bedeutet natürlich nicht, dass die Verwendung von iostreams notwendigerweise ein langsames Programm bedeutet. Wenn E / A ein sehr kleiner Teil des Programms ist, hat die Verwendung einer E / A-Bibliothek mit schlechter Leistung insgesamt keine großen Auswirkungen. Aber nicht oft genug aufgerufen zu werden, um eine Rolle zu spielen, ist nicht gleichbedeutend mit einer guten Leistung, und in E / A-Anwendungen ist dies wichtig.
Ben Voigt
27

Ich bin ziemlich enttäuscht von den Visual Studio-Benutzern da draußen, die eher einen Gimme zu diesem Thema hatten:

  • In der Visual Studio-Implementierung von ostreamtritt das sentryObjekt (das vom Standard gefordert wird) in einen kritischen Abschnitt ein , der das Objekt schützt streambuf(was nicht erforderlich ist). Dies scheint nicht optional zu sein, daher zahlen Sie die Kosten für die Thread-Synchronisierung selbst für einen lokalen Stream, der von einem einzelnen Thread verwendet wird und für den keine Synchronisierung erforderlich ist.

Dies schadet dem Code, mit ostringstreamdem Nachrichten ziemlich stark formatiert werden. Durch die stringbufdirekte Verwendung von sentrywird die Verwendung von vermieden , aber die formatierten Einfügeoperatoren können nicht direkt mit streambufs arbeiten. Bei Visual C ++ 2010 verlangsamt sich der kritische Abschnitt ostringstream::writegegenüber dem Basiswert um den Faktor dreistringbuf::sputn Aufruf .

Wenn man sich die Profilerdaten von beldaz auf newlib ansieht , scheint es klar zu sein, dass gcc's sentryso etwas Verrücktes nicht macht. ostringstream::writeunter gcc dauert nur etwa 50% länger als stringbuf::sputn, aber stringbufselbst ist viel langsamer als unter VC ++. Und beide sind immer noch sehr ungünstig mit der Verwendung einer vector<char>E / A-Pufferung, wenn auch nicht mit dem gleichen Abstand wie unter VC ++.

Ben Voigt
quelle
Sind diese Informationen noch aktuell? Die mit GCC gelieferte AFAIK, C ++ 11-Implementierung führt diese "verrückte" Sperre durch. Natürlich macht VS2010 das auch noch. Könnte jemand dieses Verhalten klären und ob 'was nicht erforderlich ist' in C ++ 11 noch gilt?
Mloskot
2
@mloskot: Ich sehe keine Thread-Sicherheitsanforderungen für sentry... "Der Klassenwächter definiert eine Klasse, die für die Ausführung ausnahmesicherer Präfix- und Suffixoperationen verantwortlich ist." und ein Hinweis "Der Sentry-Konstruktor und der Destruktor können auch zusätzliche implementierungsabhängige Operationen ausführen." Man kann auch aus dem C ++ - Prinzip "Sie zahlen nicht für das, was Sie nicht verwenden" vermuten, dass das C ++ - Komitee eine solch verschwenderische Anforderung niemals genehmigen würde. Sie können jedoch gerne eine Frage zur Sicherheit von iostream-Threads stellen.
Ben Voigt
8

Das Problem, das Sie sehen, liegt im Overhead um jeden Aufruf von write (). Jede Abstraktionsebene, die Sie hinzufügen (char [] -> vector -> string -> ostringstream), fügt ein paar weitere Funktionsaufrufe / Rückgaben und andere Housekeeping-Probleme hinzu, die sich - wenn Sie sie millionenfach aufrufen - summieren.

Ich habe zwei der Beispiele auf ideone so modifiziert, dass sie jeweils zehn Ints schreiben. Die Ostringstream-Zeit stieg von 53 auf 6 ms (fast 10-fache Verbesserung), während sich die Char-Schleife verbesserte (3,7 auf 1,5) - nützlich, aber nur um den Faktor zwei.

Wenn Sie so besorgt über die Leistung sind, müssen Sie das richtige Werkzeug für den Job auswählen. ostringstream ist nützlich und flexibel, aber es gibt eine Strafe dafür, es so zu verwenden, wie Sie es versuchen. char [] ist härtere Arbeit, aber die Leistungssteigerungen können großartig sein (denken Sie daran, dass der gcc wahrscheinlich auch die Memcpys für Sie inline wird).

Kurz gesagt, ostringstream ist nicht kaputt, aber je näher Sie dem Metall kommen, desto schneller wird Ihr Code ausgeführt. Assembler hat für einige Leute immer noch Vorteile.

Roddy
quelle
8
Was muss das nicht ostringstream::write()tun vector::push_back()? Wenn überhaupt, sollte es schneller sein, da anstelle von vier einzelnen Elementen ein Block übergeben wird. Wenn ostringstreames langsamer ist als std::vectorohne zusätzliche Funktionen, dann würde ich das als kaputt bezeichnen.
Ben Voigt
1
@ Ben Voigt: Im Gegenteil, es ist etwas, was der Vektor tun muss, was der Ostringstream NICHT tun muss, was den Vektor in diesem Fall leistungsfähiger macht. Es ist garantiert, dass der Vektor im Speicher zusammenhängend ist, während dies bei ostringstream nicht der Fall ist. Vector ist eine der Klassen, die performant sein sollen, Ostringstream hingegen nicht.
Dragontamer5788
2
@Ben Voigt: Durch die stringbufdirekte Verwendung werden nicht alle Funktionsaufrufe entfernt, da stringbufdie öffentliche Schnittstelle aus öffentlichen nicht virtuellen Funktionen in der Basisklasse besteht, die dann an geschützte virtuelle Funktionen in der abgeleiteten Klasse gesendet werden .
CB Bailey
2
@Charles: Auf jedem anständigen Compiler sollte dies der Fall sein, da der öffentliche Funktionsaufruf in einen Kontext eingebunden wird, in dem der dynamische Typ dem Compiler bekannt ist, kann er die Indirektion entfernen und diese Aufrufe sogar einbinden.
Ben Voigt
6
@Roddy: Ich sollte denken, dass dies alles Inline-Vorlagencode ist, der in jeder Kompilierungseinheit sichtbar ist. Aber ich denke, das kann je nach Implementierung variieren. Mit Sicherheit würde ich erwarten, dass der zur Diskussion stehende Aufruf, die öffentliche sputnFunktion, die den virtuellen Schutz xsputnaufruft, inline ist. Auch wenn dies xsputnnicht inline ist, kann der Compiler beim Inlining sputndie genaue xsputnerforderliche Überschreibung ermitteln und einen direkten Aufruf generieren, ohne die vtable zu durchlaufen.
Ben Voigt
1

Um eine bessere Leistung zu erzielen, müssen Sie verstehen, wie die von Ihnen verwendeten Container funktionieren. In Ihrem Array-Beispiel char [] wird das Array mit der erforderlichen Größe im Voraus zugewiesen. In Ihrem Vektor- und Ostringstream-Beispiel zwingen Sie die Objekte, Daten wiederholt zuzuweisen, neu zuzuweisen und möglicherweise viele Male zu kopieren, wenn das Objekt wächst.

Mit std :: vector lässt sich dies leicht beheben, indem die Größe des Vektors wie beim char-Array auf die endgültige Größe initialisiert wird. Stattdessen lähmen Sie die Leistung zu Unrecht, indem Sie die Größe auf Null ändern! Das ist kaum ein fairer Vergleich.

In Bezug auf ostringstream ist eine Vorbelegung des Raums nicht möglich. Ich würde vorschlagen, dass dies eine unangemessene Verwendung ist. Die Klasse hat einen weitaus größeren Nutzen als ein einfaches char-Array. Wenn Sie dieses Dienstprogramm jedoch nicht benötigen, verwenden Sie es nicht, da Sie den Overhead auf jeden Fall bezahlen müssen. Stattdessen sollte es für das verwendet werden, wofür es gut ist - das Formatieren von Daten in eine Zeichenfolge. C ++ bietet eine große Auswahl an Containern, und ein Ostringstram ist für diesen Zweck am wenigsten geeignet.

Im Fall des Vektors und des ostringstream erhalten Sie Schutz vor Pufferüberlauf, den Sie mit einem char-Array nicht erhalten, und dieser Schutz ist nicht kostenlos.

Clifford
quelle
1
Die Zuordnung scheint für ostringstream nicht das Problem zu sein. Er sucht nur für nachfolgende Iterationen auf Null zurück. Keine Kürzung. Auch ich habe es versucht ostringstream.str.reserve(4000000)und es machte keinen Unterschied.
Roddy
Ich denke mit ostringstream, Sie könnten "reservieren", indem Sie eine Dummy-Zeichenfolge übergeben, dh: ostringstream str(string(1000000 * sizeof(int), '\0'));Mit vector, das resizegibt keinen Platz frei, es wird nur erweitert, wenn es benötigt wird.
Nim
1
"Vektor .. Schutz vor Pufferüberlauf". Ein häufiges Missverständnis: Der vector[]Operator wird normalerweise NICHT standardmäßig auf Grenzfehler überprüft. vector.at()ist jedoch.
Roddy
2
vector<T>::resize(0)
ordnet
2
@Roddy: Nicht verwenden operator[], aber push_back()(über back_inserter), was definitiv auf Überlauf testet. Es wurde eine andere Version hinzugefügt, die nicht verwendet wird push_back.
Ben Voigt