Die Kunst, C++-Anwendungen zu profilieren

Die Kunst, C++-Anwendungen zu profilieren

Die Kunst, C++-Anwendungen zu profilieren

Einführung

Aufführungsarbeit zieht zwei gegensätzliche Formen der Eitelkeit an. Ein Ingenieur möchte glauben, dass Intuition ausreicht und dass ein gutes Gespür für heißen Code Beweise ersetzen kann. Ein anderer möchte glauben, dass ein Profiler-Screenshot selbst eine Schlussfolgerung ist, als ob das Drücken der Messtaste Verwirrung in Wissen verwandeln würde. Beide Instinkte sind verführerisch und beide verursachen Schaden.

Die Profilerstellung in C++ ist gerade deshalb wertvoll, weil C++ uns so viel Spielraum für plausible Fehler gibt. Ein langsames System kann tatsächlich unter Cache-Fehlern, Sperrkonflikten, Zuweisungswechsel, verzweigungsintensiven Hot Loops, Vektorisierungsblockern oder zu vielen Kopien leiden. Möglicherweise wartet es auch auf E/A, während alle im Raum über die CPU streiten. Es kann sein, dass die Serialisierung der Ergebnisse mehr Zeit in Anspruch nimmt als sie zu berechnen. Die Skalierung ist möglicherweise schlecht, nicht weil der Algorithmus schlecht ist, sondern weil Threads ständig auf eine Weise kollidieren, vor der uns kein Codekommentar gewarnt hat. In einer so ausdrucksstarken und maschinennahen Sprache vermehren sich schnell plausible Erklärungen.

Deshalb sollte Profiling nicht als Spezialaktivität für Leistungsbesessene verstanden werden, sondern als Disziplin der Ehrlichkeit. Es lehrt uns, elegante Geschichten durch maßvolle zu ersetzen. Es verlangsamt die Eile, neu zu schreiben. Es bewahrt Teams davor, eine Woche damit zu verschwenden, etwas zu verbessern, das sich als nur vier Prozent des Problems herausstellte. Und wenn es gut gemacht wird, hat es eine überraschend menschliche Wirkung auf die Ingenieurskultur, weil es Argumente weniger theatralisch und kollaborativer macht. Der Profiler wird nicht zur Waffe, sondern zum Schiedsrichter.

Die Profilerstellung beginnt, bevor das Tool geöffnet wird

Eine sinnvolle Profilierungssitzung beginnt lange vor der Entnahme der ersten Probe. Es beginnt, wenn wir entscheiden, welche Frage wir beantworten wollen. „Warum ist das Programm langsam?“ ist fast nie eine ausreichend gute Frage. Es ist zu vage, um die Werkzeugauswahl zu leiten, und zu vage, um es zu verfälschen. Bessere Fragen klingen konkreter. Warum hat sich die p99-Latenz nach einer Parser-Änderung verringert? Warum verbessert sich der Durchsatz nach acht Threads nicht mehr? Warum verhält sich eine Maschinenklasse schlechter als eine andere? Warum wurde die Binärdatei durch eine Vereinfachung des Codes unter Last langsamer?

Die Qualität der Frage prägt den Rest der Arbeit. Wenn das Symptom ein Rückgang der Anforderungslatenz ist, benötigen wir repräsentative Anforderungspfade und eine klare Definition, wo diese Latenz beobachtet wird. Wenn das Symptom ein Durchsatzplateau ist, müssen wir wissen, ob CPU, Warten, Speicherbandbreite oder Synchronisierung das Wachstum einschränken. Wenn es sich bei dem Symptom um maschinenspezifisches Verhalten handelt, sind Hardwareindikatoren, Affinität und Bereitstellungsunterschiede möglicherweise wichtiger als der Quellcode selbst. Das Stellen einer guten Frage ist bereits eine Form der Optimierung, da es den Bereich der Dinge einschränkt, bei denen wir bereit sind, Fehler zu machen.

Hier sabotieren sich auch viele Teams still und leise selbst. Sie profilieren sich unter unrealistischer Belastung, mit der falschen Binärdatei, mit Spielzeugeingaben und in einer Umgebung, die so laut ist, dass Messungen zum Theater werden. Anschließend präsentieren sie Ergebnisse mit der Sicherheit der Astronomie und der Beweiskraft der Wetterfolklore. Der Profiler hat sie nicht enttäuscht. Ihr Experimentdesign scheiterte. Bei der Leistungsarbeit beginnt die Strenge bereits an der Aufbaulinie.

Bauen Sie eine Messumgebung auf, der Sie vertrauen können

C++-Programme offenbaren unterschiedliche Persönlichkeiten unter unterschiedlichen Bedingungen. Ein Debug-Build kann aus Gründen, die nichts mit der Produktion zu tun haben, verheerend langsam aussehen. Ein Release-Build ohne Symbole läuft möglicherweise schnell genug, verbirgt aber den Pfad, den wir sehen müssen. Eine winzige synthetische Eingabe passt möglicherweise so perfekt in den Cache, dass sie einem schlechten Design schmeichelt. Eine Maschine unter thermischem Druck oder Hintergrundgeräuschen kann Ergebnisse liefern, die sich präzise anfühlen, während sie tatsächlich zufällige Störungen beschreiben.

Eine vertrauenswürdige Umgebung muss nicht perfekt sein, aber sie muss bewusst sein. Verwenden Sie die Binärdatei, die dem, was Benutzer tatsächlich ausführen, am nächsten kommt. Bewahren Sie Debug-Informationen oder Frame-Zeiger dort auf, wo Ihre Tools davon profitieren. Geben Sie dem Programm realistische Eingaben oder zumindest Eingaben, die die qualitativen Merkmale der realen Arbeitslast bewahren: Datengrößen, Verzweigungsunregelmäßigkeiten, Konfliktmuster, Zuweisungsdruck und Anforderungsmix. Messen Sie nicht nur die durchschnittliche Laufzeit, sondern auch die Ausgaben, die für das System wichtig sind: Tail-Latenz, Durchsatz, Zeit in der Phase, Zuweisungsvolumen, Sperrwartezeit, Cache-Verhalten oder Startzeit, je nach Problem.

Es liegt eine tiefe Güte darin, dies gut zu machen. Wenn ein Ingenieur unter ehrlichen Bedingungen ein Profil erstellt, erspart er dem gesamten Team Streitereien um Geister. Ein fehlerhafter Aufbau bringt alle dazu, Theorien zu verteidigen. Ein gutes Setup lässt Theorien schnell sterben. Das ist eines der kostengünstigsten Geschenke, die ein leistungsorientierter Ingenieur einem Projekt machen kann.

Lernen Sie, Arbeit vom Warten zu unterscheiden

Einer der häufigsten Fehler bei der Profilerstellung besteht darin, alle Langsamkeiten so zu behandeln, als ob es sich um CPU-Arbeit handelte. C++-Ingenieure sind besonders anfällig für diesen Fehler, da die Sprache zum Denken auf niedriger Ebene einlädt. Wenn ein Dienst langsam ist, beginnen wir, uns Anweisungen, Verzweigungen, Cache-Zeilen und Inlining-Entscheidungen vorzustellen. Manchmal ist dieser Instinkt genau richtig. In anderen Fällen wartet das System hauptsächlich: Warten auf Sperren, Warten auf Warteschlangen, Warten auf E/A, Warten auf überkoordinierte Thread-Pools, Warten auf eine Ressource, die der Hot Loop nicht reparieren kann, indem er etwas hübscher wird.

Eine gute Profilerstellung beginnt daher breit und wird erst mikroskopisch klein, wenn das Gesamtbild klar ist. Sampling-Profiler eignen sich hervorragend, um herauszufinden, wo die CPU-Zeit tatsächlich vergeht. Mithilfe von Nachverfolgungstools lässt sich erkennen, wann das Problem tatsächlich in der Reihenfolge, im Warten oder in der Phaseninteraktion liegt. Heap- und Zuordnungstools sagen uns, ob die Speichergeschichte alles andere verunreinigt. Hardware-Zähler werden nützlich, wenn der Pfad tatsächlich so heiß ist, dass Fehler, Verzweigungen, Spekulationen oder die Qualität der Vektorisierung Beachtung verdienen. Jedes Tool ist eine Möglichkeit, eine andere Frage zu stellen. Probleme beginnen, wenn Teams eine Frage stellen und dann die Antwort so interpretieren, als ob sie eine andere Frage lösen würde.

Ein bekanntes Beispiel veranschaulicht die Falle. Angenommen, ein Parser erscheint oben in einem CPU-Profil. Ein ungeduldiger Ingenieur könnte zu dem Schluss kommen, dass der Parser neu geschrieben werden muss. Eine Zeitleistenansicht zeigt jedoch möglicherweise, dass der Parser nur deshalb dominant erscheint, weil der Rest der Pipeline häufig blockiert wird, wodurch der aktive CPU-Bereich proportional größer erscheint, als er tatsächlich ist. In einem anderen Fall ist ein Parser wirklich teuer, aber eine kleine gezielte Änderung der Zuweisungen beseitigt den größten Teil der Kosten, ohne dass ein dramatischer Umbau erforderlich ist. Die Gabe des Profilers besteht nicht darin, dass er uns in einem einzigen Schritt sagt, was wir optimieren sollen. Seine Gabe besteht darin, dass es die wesentliche Arbeit immer wieder von der Theaterarbeit trennt.

Das Werkzeug ist weniger wichtig als die Gewohnheit der Interpretation

Ingenieure fragen oft, welcher Profiler der beste ist, als ob es eine allgemein richtige Antwort gäbe. In der Praxis ist die bessere Frage, welche Art von Wahrheit Sie als nächstes brauchen. perf, VTune, die Profiler von Visual Studio, Tracy, Perfetto, Flame Graphs, Callgrind und Heap-Profiler beleuchten jeweils eine andere Oberfläche der Realität. Die reife Gewohnheit ist keine Werkzeugtreue. Es ist interpretierende Disziplin.

Ein Flammendiagramm eignet sich hervorragend, um zu zeigen, wo sich CPU-Samples ansammeln, es erklärt jedoch nicht die Warteschlangenverzögerung allein. Eine Zeitleistenansicht eignet sich hervorragend zur Darstellung der Phaseninteraktion und des Wartens, sie verrät Ihnen jedoch möglicherweise nicht, warum es bei einer engen Schleife zu falschen Verzweigungsvorhersagen kommt. Ein Heap-Profil kann eine Zuordnungsänderung aufdecken, die den gesamten Pfad vergiftet, aber es entscheidet nicht automatisch, ob Ihr Thread-Modell kohärent ist. Ingenieure werden gefährlich, wenn sie die optische Attraktivität eines Werkzeugs mit der Vollständigkeit des Verständnisses verwechseln.

Aus diesem Grund hat die Profilerstellung eine künstlerische Dimension, auch wenn sie auf Messung basiert. Die Kunst ist keine Mystik. Es ist ein Urteil. Es geht darum zu wissen, wann ein Hotspot primär und wann sekundär ist, wann ein Mikrobenchmark ehrlich ist und wann er der falschen Arbeitsform schmeichelt, wann ein Hardware-Zähler Vertrauen verdient und wann er nur ein weiteres Experiment provozieren sollte. Es geht auch darum, zu wissen, wann man aufhören sollte, nach unten zu graben, und stattdessen die Architektur vereinfachen sollte, die die Messungen überhaupt hässlich gemacht hat.

Die charakteristischen Formen von C++-Leistungsproblemen

C++-Leistungsprobleme fallen häufig in erkennbare Familien. Einige sind eindeutig rechnerisch: enge Schleifen, die zu viel Arbeit machen, schlechte Vektorisierung, verzweigungsintensiver Hotcode oder Datenstrukturen, die schlecht mit dem Cache interagieren. Einige sind speicherbedingt: zu viele Zuordnungen, instabile Eigentumsmuster, unnötige Kopien, Fragmentierung oder Layouts, die heiße Daten verstreuen, bis die CPU mehr Zeit mit Warten als mit Rechnen verbringt. Bei einigen handelt es sich um Koordinationsprobleme: Sperren, die harmlos aussahen, Warteschlangen, die einen zusätzlichen Hop zu viel hinzufügten, arbeitsraubende Designs, die zu einem durchschnittlichen Durchsatz bei gleichzeitiger Verschlechterung des Tail-Verhaltens beitrugen, oder Thread-Anzahlen, die die Fähigkeit der Architektur, Ordnung zu halten, übersteigen.

Was Profiling so wirkungsvoll macht, ist die Tatsache, dass sich diese Familien oft als einander ausgeben. Ein Speicherproblem kann wie ein CPU-Problem aussehen. Ein Warteproblem kann wie ein algorithmisches Problem aussehen. Ein Protokollierungspfad kann irrelevant erscheinen, bis eine Tail-Latenz-Ansicht zeigt, dass er den gesamten Dienst kontaminiert. Eine trivial aussehende Kopie kann nur deshalb von Bedeutung sein, weil sie an der einen Stelle auftritt, die sich der Anforderungspfad nicht leisten kann. Ohne Messung sind diese Interaktionen leicht zu beschreiben und schwer einzuordnen.

Ein guter Profiler entwickelt daher ein Gespür für Proportionen. Nicht jede Ineffizienz ist wichtig. Nicht jede hässliche Funktion ist es wert, gerettet zu werden. Nicht jede saubere Funktion ist unschuldig. Das Programm lehrt uns, wo Würde und Dringlichkeit zusammenpassen, und oft ist dieser Ort nicht der Ort, auf den der Codeprüfer zuerst hingewiesen hat.

Eine Fallstudie zur Fehldiagnose

Stellen Sie sich einen Dienst vor, der Datensätze aufnimmt, normalisiert, bewertet und Ergebnisse ausgibt. Nach einer Veröffentlichung sinkt der Durchsatz und die p99-Latenz verschlechtert sich. Die erste Theorie im Raum besagt, dass eine neue Bewertungsroutine teure Mathematik eingeführt hat. Die zweite Theorie besagt, dass der Parser jetzt zu verzweigt ist. Der dritte Grund ist, dass der Allokator nach einem Bibliotheks-Upgrade zurückgegangen ist. Jede Theorie ist plausibel genug, um in einer Besprechung klug zu klingen.

Ein breites CPU-Profil zeigt, dass sowohl der Parser als auch der Scorer sichtbare Zeit verbrauchen, aber nicht genug, um die vollständige Latenzregression zu erklären. Eine Zeitleistenverfolgung zeigt Wartezeiten rund um eine gemeinsame Ausgabestufe. Die Heap-Analyse zeigt wiederholte Zuordnungs- und Formatierungsarbeiten am Ende des Anforderungspfads. Ein kleines Experiment, das Puffer pro Thread beibehält und die Formatierung verzögert, reduziert das Wartemuster und beseitigt überraschend viel Tail-Latenz. Erst danach zeigt ein fokussiertes CPU-Profil, dass der Scorer immer noch eine kleinere Bereinigung für Kopien verdient, die neu sichtbar wurden, nachdem der größere Engpass beseitigt war.

Dies ist eine gewöhnliche Geschichte, und genau deshalb ist sie wichtig. Echte Profilerstellung endet selten mit einem dramatischen Bösewicht. Häufiger wird eine Reihe gewöhnlicher Kosten sichtbar, die jeweils durch die anderen verstärkt werden. Der Ingenieur, der eine filmische Lösung erwartet hatte, erfährt stattdessen, wie sich Systeme tatsächlich verschlechtern: durch Akkumulation, Interaktion und vernachlässigte Proportionen. Diese Lektion ist mehr wert als jede einzelne Beschleunigung, weil sie die Art und Weise verändert, wie zukünftige Untersuchungen beginnen.

Profiling als Teamgewohnheit

Die besten Teams betrachten Profiling nicht als ein Notfallritual. Sie bauen es in Überprüfungen, Regressionen und größere Designänderungen ein. Sie führen repräsentative Datensätze. Sie speichern Flammendiagramme, Spuren und Benchmark-Artefakte sowie Erklärungen zu den Änderungen. Sie machen es zur Normalität, sich zu fragen, ob eine vorgeschlagene Vereinfachung Zuweisungen, Endlatenz oder Phasengrenzen verändert. Sie fetischisieren Leistung nicht, aber sie respektieren sie genug, um sie zu messen, bevor sie zu laut sprechen.

Diese Angewohnheit verändert das emotionale Leben einer Codebasis. Ingenieure werden weniger defensiv, weil Profiling das Problem externalisiert. Ein langsames System ist kein Vorwurf mehr gegen die Person, die zuletzt den Code berührt hat. Es wird zu einem gemeinsamen Rätsel mit Beweisen. Sogar junge Ingenieure werden in diesem Umfeld effektiver, weil sie lernen, Fragen und Experimenten mehr zu vertrauen als Prestige. Eine so aufgebaute Leistungskultur ist nicht nur schneller. Es ist ruhiger.

Deshalb ist die Kunst der Profilerstellung in C++ so wichtig. Die Sprache gibt uns die Kraft, hervorragende Systeme zu bauen, aber Exzellenz entsteht nicht nur durch Klugheit. Es entsteht durch wiederholte, disziplinierte Akte des Bemerkens. Profiling ist eine der besten Möglichkeiten für Ingenieure, zu erkennen, was die Maschine die ganze Zeit über sagen wollte.

Hands-On Lab: Profilieren Sie ein bewusst ineffizientes Programm

Lassen Sie uns ein kleines Programm erstellen, das absichtlich ein wenig dumm ist. Das ist nützlich, denn echte Profiling-Fähigkeiten werden am schnellsten erlernt, wenn die Fehler konkret genug sind, um gefunden zu werden.

main.cpp

#include <algorithm>
#include <chrono>
#include <iostream>
#include <mutex>
#include <random>
#include <string>
#include <thread>
#include <vector>

std::mutex g_lock;

static std::string make_payload(std::mt19937& rng) {
    std::uniform_int_distribution<int> len_dist(20, 120);
    std::uniform_int_distribution<int> ch_dist(0, 25);

    std::string s;
    const int len = len_dist(rng);
    for (int i = 0; i < len; ++i) {
        s.push_back(static_cast<char>('a' + ch_dist(rng)));
    }
    return s;
}

static uint64_t score_payload(const std::string& s) {
    uint64_t total = 0;
    for (char c : s) {
        total += static_cast<unsigned char>(c);
    }
    return total;
}

int main() {
    constexpr size_t N = 400000;
    std::vector<std::string> rows;
    rows.reserve(N);

    std::mt19937 rng{42};
    for (size_t i = 0; i < N; ++i) {
        rows.push_back(make_payload(rng));
    }

    std::vector<uint64_t> out;
    out.reserve(N);

    auto worker = [&](size_t begin, size_t end) {
        for (size_t i = begin; i < end; ++i) {
            auto copy = rows[i];
            std::sort(copy.begin(), copy.end());
            uint64_t value = score_payload(copy);

            std::lock_guard<std::mutex> guard(g_lock);
            out.push_back(value);
        }
    };

    const auto t0 = std::chrono::steady_clock::now();

    std::thread t1(worker, 0, N / 2);
    std::thread t2(worker, N / 2, N);
    t1.join();
    t2.join();

    const auto t1_end = std::chrono::steady_clock::now();
    const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1_end - t0).count();

    std::cout << "done in " << ms << " ms, values=" << out.size() << "\n";
}

Dieses Programm enthält mehrere klassische Performance-Gerüche:

  • wiederholte String-Kopien
  • unnötiges Sortieren im heißen Pfad
  • zentraler Sperrkonflikt bei der Ausgabe
  • Zuordnungsintensive String-Generierung

Für die Profilerstellung erstellen

Unter Linux:

g++ -O2 -g -fno-omit-frame-pointer -std=c++20 -pthread -o bad_profile main.cpp

Unter Windows mit MSVC:

cl /O2 /Zi /std:c++20 main.cpp

Erstes Profil

Unter Linux:

perf record -g ./bad_profile
perf report

Oder erstellen Sie ein Flammendiagramm, wenn dies Teil Ihres Arbeitsablaufs ist.

Was Sie beachten sollten

Ein gutes Profil sollte schnell darauf hinweisen, dass das System nicht an einem einzigen mystischen Problem leidet. Es leidet unter einer Anhäufung sehr gewöhnlicher technischer Entscheidungen. Das ist die richtige Lektion.

Testaufgaben für Enthusiasten

  1. Entfernen Sie den zentralen mutex, indem Sie einen Ausgabevektor pro Thread verwenden. Nachmessen.
  2. Entfernen Sie den unnötigen std::sort und bestätigen Sie, wie viel der Kosten theatralisch und nicht unbedingt erforderlich waren.
  3. Ersetzen Sie auto copy = rows[i]; durch eine Alternative mit niedrigerer Kopiezahl und prüfen Sie, ob sich das Profil wie erwartet ändert.
  4. Erhöhen Sie die Thread-Anzahl und beobachten Sie, ob der Durchsatz skaliert oder ob die Koordination dominiert.
  5. Erstellen Sie dasselbe Programm mit und ohne -fno-omit-frame-pointer und vergleichen Sie die Qualität Ihrer Stacks.

Wenn Sie diese fünf Schritte sorgfältig ausführen, haben Sie etwas viel Wertvolleres gelernt als die Namen der Profilierungstools. Sie werden gelernt haben, wie eine schlechte Theorie angesichts der Messung stirbt.

Zusammenfassung

Die Kunst, C++-Anwendungen zu profilieren, ist die Kunst, ehrlich zu bleiben.

Bei einer guten Profilerstellung geht es nicht darum, die ausgefallensten Screenshots zu sammeln oder sich jeden Hardware-Zähler zu merken. Es geht darum, präzise Fragen zu stellen, unter realistischen Bedingungen zu messen, CPU-Arbeit vom Warten zu trennen, das Speicherverhalten zu verstehen und das richtige Tool für die richtige Ebene des Problems zu verwenden.

Verwenden Sie Sampling, um die allgemeine CPU-Wahrheit zu ermitteln. Verwenden Sie Tracing, um Zeit und Koordination zu verstehen. Verwenden Sie die Heap-Analyse, wenn das Zuordnungsverhalten dominiert. Verwenden Sie Hardwarezähler, wenn Caches und Spekulationen zur Realität werden. Und vor allem: Profilieren, bevor Sie optimieren.

In C++ macht diese Disziplin oft den Unterschied zwischen eleganter Hochleistungstechnik und teurem Aberglauben aus.

Referenzen

  1. Linux perf Manpage: https://man7.org/linux/man-pages/man1/perf.1.html
  2. Linux perf-stat Manpage: https://man7.org/linux/man-pages/man1/perf-stat.1.html
  3. Dokumentation zum Intel VTune Profiler: https://www.intel.com/content/www/us/en/docs/vtune-profiler/overview.html
  4. Tour zu den Visual Studio-Profilierungsfunktionen: https://learn.microsoft.com/visualstudio/profiling/profiling-feature-tour
  5. Tracy-Profiler-Repository: https://github.com/wolfpld/tracy
  6. Perfetto-Dokumentation: https://perfetto.dev/docs/
  7. Flammendiagramme von Brendan Gregg: https://www.brendangregg.com/flamegraphs.html
  8. Callgrind-Handbuch: https://valgrind.org/docs/manual/cl-manual.html
  9. Heaptrack-Repository: https://github.com/KDE/heaptrack
  10. AddressSanitizer-Dokumentation: https://clang.llvm.org/docs/AddressSanitizer.html
Philip P.

Philip P. – Technischer Leiter

Zurück zum Blog

Kontakt

Starten Sie das Gespräch

Ein paar klare Zeilen genügen. Beschreiben Sie das System, den Druck und die Entscheidung, die blockiert wird. Oder schreiben Sie direkt an midgard@stofu.io.

01 What the system does
02 What hurts now
03 What decision is blocked
04 Optional: logs, specs, traces, diffs
0 / 10000