C++ Core Guidelines: Verbesserte Performanz mit den Ein- und Ausgabestreams

Modernes C++  –  3 Kommentare

Anders als es der Titel und die Regeln zu den C++ Core Guidelines vermuten lassen, ist es keine Selbstverständlichkeit, höhere Performanz aus den Ein- und Ausagbestreams zu erhalten.

Okay, jetzt muss ich einen Schritt zurückgehen. Obwohl ich einige Tests ausgeführt habe, sind meine Zahlen deutlich kontroverser als ich dachte. Falls du irgendwelche Ideen, Verbesserungen und Klarstellungen hast, nenne sie mir. Ich werde im nächsten Artikel darauf eingehen.

Hier sind die Performanz-orientierten Regel der Guidelines zu Ein- und Ausgabestreams.

Ich denke, du kennst std::ios_base::sync_with_stdio nicht?

SL.io.10: Unless you use printf-family functions call ios_base::sync_with_stdio(false)

Per Default, werden Operationen auf den C++ Streams mit den C Streams synchronisiert. Die Synchronisation findet nach jeder Ein- und Ausgabeoperation statt.

  • C++ Streams: std::cin, std::cou, std::cerr, std::clog, std::wcin, std::wcout, std::wcerr und std::wclog.
  • C Streams: stdin, stout und stderr.

Diese Synchronisation erlaubt es C und C++ Operationen zu vermischen, da C++ Streams nicht gepuffert an die C Streams gehen. Es gibt noch eine weitere wichtige Beobachtung: Synchronisierte C++ Streams sind thread-sicher. Das heißt, alle Threads können ohne Synchronisation schreiben. Das Ergebnis mag ein Überlappen der Ausgabe sein; es ist aber kein Data-Race.

Wenn du std::ios_base::sync_with_stdio(false) setzt, finde die Synchronisation zwischen C++ Streams und C Streams nicht statt, denn die C++ Streams können ihre Ausgabe in einen Puffer schreiben. Dank des Puffers, können die Ein- und Ausgaben schneller werden. std::ios_base::sync_with_stdio(false) muss vor der ersten Ein- oder Ausgabeoperation aufgerufen werden.

Ich denke, dir ist aufgefallen, dass ich sehr oft "kann" geschrieben habe. Aus gutem Grunde.

Überlappen der C++ Streams und der C Streams

Zuerst einmal interessiert es mich, was passiert, wenn ich das folgende Programm mit verschiedenen Compilern ausführe.

// syncWithStdio.cpp

#include <iostream>
#include <cstdio>

int main(){

std::ios::sync_with_stdio(false);

std::cout << std::endl;

std::cout << "1";
std::printf("2");
std::cout << "3";

std::cout << std::endl;

}

Um ein besseres Bild meiner Compiler zu erhalten, habe ich ein wenig Information hinzugefügt.

GCC 8.2

Clang 8.0

cl.exe 19.20

Es scheint so, als ob nur der GCC unsynchronisiert ausgibt. Diese Beobachtung gilt nicht für clang oder cl.exe Compiler. Ein kleiner Performanztest bestätigte meinen ersten Eindruck.

Performance mit und ohne Synchronisation

Lass mich ein kleines Programm implementieren, dass mit und ohne Synchronisation auf die Konsole schreibt. Die Ausführung ohne Synchronisation sollte schneller sein.

Synchronisiert

// syncWithStdioPerformanceSync.cpp

#include <chrono>
#include <fstream>
#include <iostream>
#include <random>
#include <sstream>
#include <string>

constexpr int iterations = 10;

std::ifstream openFile(const std::string& myFile){

std::ifstream file(myFile, std::ios::in);
if ( !file ){
std::cerr << "Can't open file "+ myFile + "!" << std::endl;
exit(EXIT_FAILURE);
}
return file;

}

std::string readFile(std::ifstream file){

std::stringstream buffer;
buffer << file.rdbuf();

return buffer.str();

}

auto writeToConsole(const std::string& fileContent){

auto start = std::chrono::steady_clock::now();
for (auto c: fileContent) std::cout << c;
std::chrono::duration<double> dur = std::chrono::steady_clock::now() - start;
return dur;
}

template <typename Function>
auto measureTime(std::size_t iter, Function&& f){
std::chrono::duration<double> dur{};
for (int i = 0; i < iter; ++i){
dur += f();
}
return dur / iter;
}

int main(int argc, char* argv[]){

std::cout << std::endl;

// get the filename
std::string myFile;
if ( argc == 2 ){
myFile= argv[1];
}
else{
std::cerr << "Filename missing !" << std::endl;
exit(EXIT_FAILURE);
}

std::ifstream file = openFile(myFile); // (1)

std::string fileContent = readFile(std::move(file)); // (2)

// (3)
auto averageWithSync = measureTime(iterations, [&fileContent]{ return writeToConsole(fileContent); });

std::cout << std::endl;
// (4)
std::cout << "With Synchronisation: " << averageWithSync.count() << " seconds" << std::endl;

std::cout << std::endl;

}

Das Programm sollte einfach zu verstehen sein. Ich öffne in Zeile 1 eine Datei, lese in Zeile 2 ihren ganzen Inhalt ein und schreibe sie iterations-Mal auf die Konsole (Zeile 3). Die geschieht in der Funktion writeToConsole(fileContent). iterations ist in meinen konkreten Fall 10. Zum Abschluss gebe ich die durchschnittliche Zeit für die Ausgabe aus (Zeile 4).

Nicht Synchronisiert

 // syncWithStdioPerformanceWithoutSync.cpp

...

int main(int argc, char* argv[]){

std::ios::sync_with_stdio(false); // (1)

std::cout << std::endl;

// get the filename
std::string myFile;
if ( argc == 2 ){
myFile= argv[1];
}
else{
std::cerr << "Filename missing !" << std::endl;
exit(EXIT_FAILURE);
}

std::ifstream file = openFile(myFile);

std::string fileContent = readFile(std::move(file));

auto averageWithSync = measureTime(iterations, [&fileContent]{ return writeToConsole(fileContent); });

auto averageWithoutSync = measureTime(iterations, [&fileContent]{ return writeToConsole(fileContent); });

std::cout << std::endl;

std::cout << "Without Synchronisation: " << averageWithoutSync.count() << " seconds" << std::endl;

std::cout << std::endl;

}

Ich fügte lediglich die Zeile 1 zu dem Programm hinzu. Jetzt war ich natürlich auf die Performanzverbesserungen gespannt.

Mein Performanztests führte ich mit einer kleinen und einer großen Textdatei durch (600.000 Buchstaben). Die große Datei lieferte keine neuen Einsichten. Daher ignoriere ich sie in diesem Artikel.

>> syncWithStdioPerformanceSync syncWithStdioPerformanceSync.cpp  
>> syncWithStdioPerformanceWithoutSync syncWithStdioPerformanceSync.cpp
  • GCC
  • Clang
  • cl.exe

Die Zahlen – insbesondere unter Windows – überraschten mich.

  • Mit dem GCC hatte im unsynchronisierten Fall eine Performanzverbesserng von 70 Prozent.
  • Weder Clang noch cl.exe zeigten eine Performanzverbesserung. Es scheint so zu sein, als ob die unsynchronisierte Variante doch synchronisiert. Meine Zahlen bestätigen meine Beobachtungen des Programms syncWithStdio.cpp.
  • Nur für die Statistik. Ist dir aufgefallen, wie langsam die Konsole unter Windows ist?

Okay, ich bin schuldig. Ich breche fast immer die nächste Regel.

SL.io.50: Avoid endl

Warum solltest du std::endl vermeiden? Oder anders herum: Was ist der Unterschied zwischen dem Manipulator std::endl und '\n'?

  • std:endl schreibt ein newline und leert (flushed) den Puffer.
  • '\n' schreibt ein newline.

Den Puffer zu leeren ist eine teuere Operation und sollte daher vermieden werden. Wenn es notwendig ist, wird der Puffer automatisch geleert. Ehrlich gesagt, war ich sehr neugierig auf die Zahlen. Um den Effekt ganz deutlich zu sehen, präsentiere ich ein Programm, dass nach jedem Zeichen einen Zeilenumbruch (Zeile 3) einfügt.

// syncWithStdioPerformanceEndl.cpp

#include <chrono>
#include <fstream>
#include <iostream>
#include <random>
#include <sstream>
#include <string>

constexpr int iterations = 500; // (1)

std::ifstream openFile(const std::string& myFile){

std::ifstream file(myFile, std::ios::in);
if ( !file ){
std::cerr << "Can't open file "+ myFile + "!" << std::endl;
exit(EXIT_FAILURE);
}
return file;

}

std::string readFile(std::ifstream file){

std::stringstream buffer;
buffer << file.rdbuf();

return buffer.str();

}

template <typename End>
auto writeToConsole(const std::string& fileContent, End end){

auto start = std::chrono::steady_clock::now();
for (auto c: fileContent) std::cout << c << end; // (3)
std::chrono::duration<double> dur = std::chrono::steady_clock::now() - start;
return dur;
}

template <typename Function>
auto measureTime(std::size_t iter, Function&& f){
std::chrono::duration<double> dur{};
for (int i = 0; i < iter; ++i){
dur += f();
}
return dur / iter;
}

int main(int argc, char* argv[]){

std::cout << std::endl;

// get the filename
std::string myFile;
if ( argc == 2 ){
myFile= argv[1];
}
else{
std::cerr << "Filename missing !" << std::endl;
exit(EXIT_FAILURE);
}

std::ifstream file = openFile(myFile);

std::string fileContent = readFile(std::move(file));

auto averageWithFlush = measureTime(iterations,
[&fileContent]{ return writeToConsole(fileContent, std::endl<char, std::char_traits<char>>); }); // (2)
auto averageWithoutFlush = measureTime(iterations, [&fileContent]{ return writeToConsole(fileContent, '\n'); }); // (3)

std::cout << std::endl;
std::cout << "With flush(std::endl) " << averageWithFlush.count() << " seconds" << std::endl;
std::cout << "Without flush(\\n): " << averageWithoutFlush.count() << " seconds" << std::endl;
std::cout << "With Flush/Without Flush: " << averageWithFlush/averageWithoutFlush << std::endl;

std::cout << std::endl;

}

Im ersten Fall verwendete ich std::endl (Zeile 2) und im zweiten Fall '\n' (Zeile 3).

Das Programm ist dem vorherigen sehr ähnlich. Der große Unterschied ist, dass ich in diesem Fall 500 Wiederholungen (Zeile 1) durchführte. Warum? Ich wurde durch die Variationen der Zahlen sehr überrascht. Mit ein paar Iterationen konnte ich keinen Unterschied feststellen. Manchmal war die Variante mit std::endl doppelt so schnell wie die mit '\n'; manchmal war sie viermal langsamer. Dies galt sowohl für GCC als auch den cl.exe-Compiler. Ich verwendete auch verschiedene Compilerversionen. Ehrlich, das war nicht was ich erwartete. Wenn ich aber 500 Wiederholungen ausführte, bekam ich das Ergebnis, dass ich erwartete. ’\n' scheint ca. 10-20 Prozent schneller als std::endl zu sein.

Nochmals: Nur 10 bis 20 Prozent schneller.

  • GCC
  • cl.exe

Meine kleine Schlussfolgerung

Eine kleine Schlussfolgerung möchte ich aus meinen Performanztests ziehen.

  • std::ios_base::sync_with_stdio(false) kann eine starke Auswirkung auf deiner Plattform besitzen. Du verlierst aber dadurch die Zusicherung thread-sicher zu sein.
  • std::endl ist nicht so schlecht wie sein Ruf. Daher werde ich meine Gewohnheit nicht ändern.

Wie geht's weiter?

Es gibt nur eine Regel in den Abschnitten regex, chrono und der C Standard Bibliothek. Daher werde ich in meinem nächsten Artikel improvisieren müssen.