Glauben Sie, dass es einen Kompromiss zwischen dem Schreiben von "nettem" objektorientiertem Code und dem Schreiben von sehr schnellem Code mit niedriger Latenz gibt? Vermeiden Sie beispielsweise virtuelle Funktionen in C ++ / den Overhead von Polymorphismus usw. Schreiben Sie Code neu, der böse aussieht, aber sehr schnell ist usw.?
Ich arbeite in einem Bereich, der sich ein bisschen mehr auf den Durchsatz als auf die Latenz konzentriert, aber sehr leistungskritisch ist, und ich würde "sorta" sagen .
Ein Problem ist jedoch, dass so viele Menschen völlig falsche Vorstellungen von Leistung haben. Neulinge bekommen oft fast alles falsch und ihr gesamtes konzeptionelles Modell der "Rechenkosten" muss überarbeitet werden, wobei nur die algorithmische Komplexität das Einzige ist, was sie richtig machen können. Fortgeschrittene machen eine Menge falsch. Experten verstehen manche Dinge falsch.
Das Messen mit präzisen Tools, die Metriken wie Cache-Fehler und Verzweigungsfehlervorhersagen liefern können, hält alle Personen mit unterschiedlichem Fachwissen auf diesem Gebiet in Schach.
Messen ist auch das, was darauf hinweist, nicht zu optimieren . Experten verbringen oft weniger Zeit mit der Optimierung als Anfänger, da sie echte gemessene Hotspots optimieren und nicht versuchen, wilde Stiche im Dunkeln zu optimieren, basierend auf der Vermutung, was langsam sein könnte (was in extremer Form zu einer Mikrooptimierung verleiten könnte) etwa jede zweite Zeile in der Codebasis).
Auf Leistung ausgelegt
Abgesehen davon kommt der Schlüssel zum Entwerfen für Leistung vom Entwurfsteil , wie im Schnittstellendesign. Eines der Probleme mit der Unerfahrenheit besteht darin, dass es in der Regel zu einer frühen Verschiebung der absoluten Implementierungsmetriken kommt, z. B. zu den Kosten eines indirekten Funktionsaufrufs in einem verallgemeinerten Kontext, als wären es die Kosten (die vom Standpunkt eines Optimierers aus auf den ersten Blick besser zu verstehen sind) ist eher ein Gesichtspunkt als ein Verzweigungsgesichtspunkt) ein Grund, dies in der gesamten Codebasis zu vermeiden.
Die Kosten sind relativ . Während ein indirekter Funktionsaufruf Kosten verursacht, sind z. B. alle Kosten relativ. Wenn Sie diese Kosten einmal bezahlen, um eine Funktion aufzurufen, die Millionen von Elementen durchläuft, ist die Sorge um diese Kosten, als würden Sie stundenlang um ein paar Cent für den Kauf eines Milliarden-Dollar-Produkts feilschen war ein Cent zu teuer.
Gröberes Schnittstellendesign
Der Interface Design Aspekt der Leistung sucht oft früher auf diese Kosten zu einer gröberen Ebene zu schieben. Anstatt zum Beispiel die Laufzeitabstraktionskosten für ein einzelnes Partikel zu zahlen, können wir diese Kosten auf die Ebene des Partikelsystems / Emitters verschieben und ein Partikel effektiv in ein Implementierungsdetail und / oder einfach in Rohdaten dieser Partikelsammlung rendern.
Das objektorientierte Design muss also nicht mit dem Leistungsentwurf unvereinbar sein (ob Latenz oder Durchsatz), aber es kann Versuchungen in einer Sprache geben, die sich darauf konzentriert, immer winziger werdende granulare Objekte zu modellieren, und das neueste Optimierungsprogramm kann dies nicht Hilfe. Es ist nicht möglich, eine Klasse, die einen einzelnen Punkt darstellt, auf eine Weise zusammenzufassen, die eine effiziente SoA-Darstellung für die Speicherzugriffsmuster der Software ergibt. Eine Sammlung von Punkten mit einem auf der Grobheitsebene modellierten Schnittstellendesign bietet diese Möglichkeit und ermöglicht es, bei Bedarf immer optimalere Lösungen zu finden. Ein solches Design wurde für Massenspeicher * entwickelt.
* Beachten Sie hier den Fokus auf Speicher und nicht auf Daten , da das Arbeiten in leistungskritischen Bereichen über einen längeren Zeitraum die Ansicht über Datentypen und Datenstrukturen und deren Verbindung zum Speicher verändert. Ein binärer Suchbaum geht nicht mehr nur um die logarithmische Komplexität in solchen Fällen, wie möglicherweise disparaten und Cache-unfreundlichen Speicherabschnitten für Baumknoten, es sei denn, dies wird von einem festen Allokator unterstützt. Die Ansicht schließt die algorithmische Komplexität nicht aus, sieht sie jedoch nicht mehr unabhängig von Speicherlayouts. Man fängt auch an, Iterationen der Arbeit als Iterationen des Speicherzugriffs zu betrachten. *
Viele leistungskritische Designs können tatsächlich sehr gut mit der Vorstellung von High-Level-Schnittstellendesigns kompatibel sein, die für den Menschen leicht zu verstehen und zu verwenden sind. Der Unterschied besteht darin, dass es sich bei "High-Level" in diesem Kontext um eine Massenaggregation des Speichers handelt, eine Schnittstelle, die für potenziell große Datensammlungen modelliert ist, und eine Implementierung unter der Haube, die unter Umständen recht Low-Level ist. Eine visuelle Analogie könnte ein Auto sein, das wirklich komfortabel und einfach zu fahren und zu handhaben ist und bei hoher Schallgeschwindigkeit sehr sicher ist. Wenn Sie jedoch die Motorhaube öffnen, sind darin kleine feuerspeiende Dämonen.
Mit einem gröberen Design können effizientere Sperrmuster und die Ausnutzung der Parallelität im Code auf einfachere Weise bereitgestellt werden (Multithreading ist ein umfassendes Thema, das ich hier überspringen werde).
Speicherpool
Ein kritischer Aspekt der Programmierung mit niedriger Latenz wird wahrscheinlich eine sehr explizite Steuerung des Speichers sein, um die Referenzlokalität sowie die allgemeine Geschwindigkeit der Zuweisung und Freigabe des Speichers zu verbessern. In einem benutzerdefinierten Allokator-Pooling-Speicher spiegelt sich die gleiche Art von Design-Denkweise wider, die wir beschrieben haben. Es ist für Bulk konzipiert ; es ist grob ausgelegt. Es reserviert Speicher in großen Blöcken und bündelt den bereits zugewiesenen Speicher in kleinen Blöcken.
Die Idee ist genau die gleiche, kostspielige Dinge (z. B. das Zuweisen eines Speicherabschnitts zu einem Allzweck-Allokator) einer immer gröberen Ebene zuzuweisen. Ein Speicherpool ist für den Umgang mit Massenspeicher konzipiert .
Typ Systeme Speicher trennen
Eine der Schwierigkeiten beim granularen objektorientierten Design in jeder Sprache ist, dass es oft viele kleine benutzerdefinierte Typen und Datenstrukturen einführen möchte. Diese Typen können dann in kleinen Blöcken zugewiesen werden, wenn sie dynamisch zugewiesen werden.
Ein gängiges Beispiel in C ++ wäre, wenn Polymorphismus erforderlich ist und die natürliche Versuchung darin besteht, jede Instanz einer Unterklasse einem Allzweck-Speicherzuweiser zuzuweisen.
Dies führt letztendlich dazu, dass möglicherweise zusammenhängende Speicherlayouts in kleine, kleinteilige Bits und Teile aufgeteilt werden, die über den Adressierungsbereich verstreut sind, was zu mehr Seitenfehlern und Cache-Fehlern führt.
In Bereichen mit der geringsten Latenz, ohne Stottern und mit deterministischer Reaktion können sich Hotspots nicht immer auf einen einzigen Engpass beschränken, in dem sich tatsächlich winzige Ineffizienzen "ansammeln" (etwas, das sich viele Menschen vorstellen) Wenn ein Profiler falsch vorgeht, um sie in Schach zu halten, kann es in latenzbedingten Fällen tatsächlich zu einigen seltenen Fällen kommen, in denen sich geringfügige Ineffizienzen ansammeln. Und viele der häufigsten Gründe für eine solche Anhäufung können folgende sein: die übermäßige Zuweisung von winzigen Erinnerungsstücken überall.
In Sprachen wie Java, kann es hilfreich sein, mehr Anordnungen von einfachen alten Datentypen , wenn möglich , bottlenecky Bereichen (Bereiche verarbeitet in engen Schleifen), wie eine Reihe von verwenden int
(aber immer noch hinter einer sperrigen High-Level - Schnittstelle) anstelle von, sagen sie , eines ArrayList
von benutzerdefinierten Integer
Objekten. Dies vermeidet die Speichersegregation, die typischerweise mit letzterer einhergeht. In C ++ müssen wir die Struktur nicht so stark herabsetzen, wenn unsere Speicherzuweisungsmuster effizient sind, da benutzerdefinierte Typen dort und sogar im Kontext eines generischen Containers zusammenhängend zugewiesen werden können.
Speicher wieder zusammenfügen
Eine Lösung besteht darin, einen benutzerdefinierten Allokator für homogene Datentypen und möglicherweise sogar für homogene Datentypen zu finden. Wenn winzige Datentypen und Datenstrukturen auf Bits und Bytes im Speicher abgeflacht werden, erhalten sie einen homogenen Charakter (wenn auch mit einigen unterschiedlichen Ausrichtungsanforderungen). Wenn wir sie nicht aus einer speicherorientierten Sicht betrachten, möchte das Typensystem der Programmiersprachen potenziell zusammenhängende Speicherbereiche in kleine, verstreute Teile aufteilen.
Der Stapel verwendet diesen speicherorientierten Fokus, um dies zu vermeiden und möglicherweise eine gemischte Kombination von benutzerdefinierten Typinstanzen darin zu speichern. Wenn möglich, ist es eine gute Idee, den Stapel stärker zu nutzen, da sich der obere Rand fast immer in einer Cache-Zeile befindet. Wir können jedoch auch Speicherzuordnungen entwerfen, die einige dieser Merkmale ohne ein LIFO-Muster imitieren und Speicher über verschiedene Datentypen hinweg in zusammenhängende Typen zusammenfassen Chunks auch für komplexere Speicherzuordnungen und Freigabemuster.
Moderne Hardware ist so konzipiert, dass sie bei der Verarbeitung zusammenhängender Speicherblöcke (z. B. wiederholter Zugriff auf dieselbe Cache-Zeile, dieselbe Seite) ihren Höchststand erreicht. Das Schlüsselwort dort ist Kontiguität, da dies nur dann von Vorteil ist, wenn Umgebungsdaten von Interesse sind. Der Schlüssel (aber auch die Schwierigkeit) für die Leistung besteht darin, getrennte Speicherbereiche wieder zu zusammenhängenden Blöcken zusammenzufassen, auf die vor der Räumung in ihrer Gesamtheit (alle Umgebungsdaten sind relevant) zugegriffen wird. Das umfangreiche Typensystem mit besonders benutzerdefinierten Typen in Programmiersprachen kann hier das größte Hindernis sein, aber wir können das Problem jederzeit durch einen benutzerdefinierten Zuweiser und / oder umfangreichere Designs lösen.
Hässlich
"Hässlich" ist schwer zu sagen. Es ist eine subjektive Metrik, und jemand, der in einem sehr leistungskritischen Bereich arbeitet, wird anfangen, seine Vorstellung von "Schönheit" in eine wesentlich datenorientiertere umzuwandeln und sich auf Schnittstellen zu konzentrieren, die Dinge in großen Mengen verarbeiten.
Gefährlich
"Gefährlich" könnte einfacher sein. Im Allgemeinen tendiert die Leistung dazu, auf Code niedrigerer Ebene zuzugreifen. Das Implementieren eines Speicherzuordners zum Beispiel ist unmöglich, ohne unter Datentypen zu greifen und auf der gefährlichen Ebene von Rohbits und Bytes zu arbeiten. Infolgedessen kann es hilfreich sein, das Augenmerk auf sorgfältige Testverfahren in diesen leistungskritischen Subsystemen zu richten und die Gründlichkeit der Tests an die angewandten Optimierungen anzupassen.
Schönheit
All dies würde sich jedoch auf der Ebene der Implementierungsdetails befinden. Sowohl in einer altgedienten, großangelegten als auch in einer leistungskritischen Denkweise tendiert "Schönheit" eher zu Schnittstellendesigns als zu Implementierungsdetails. Es wird zu einer exponentiell höheren Priorität, nach "schönen", verwendbaren, sicheren und effizienten Schnittstellen zu suchen, anstatt nach Implementierungen aufgrund von Kopplungs- und Kaskadenbrüchen, die angesichts einer Änderung des Schnittstellendesigns auftreten können. Implementierungen können jederzeit ausgetauscht werden. Wir iterieren in der Regel nach Bedarf zur Leistung und wie durch Messungen hervorgehoben. Der Schlüssel beim Schnittstellendesign besteht darin, grob genug zu modellieren, um Platz für solche Iterationen zu lassen, ohne das gesamte System zu beschädigen.
Tatsächlich würde ich vorschlagen, dass der Fokus eines Veteranen auf leistungskritische Entwicklung oft den Schwerpunkt auf Sicherheit, Tests und Wartbarkeit legt, genau wie der SE-Jünger im Allgemeinen, da eine umfangreiche Codebasis eine Reihe von Leistungen aufweist -kritische Subsysteme (Partikelsysteme, Bildverarbeitungsalgorithmen, Videoverarbeitung, Audio-Feedback, Raytracer, Mesh-Engines usw.) müssen der Softwareentwicklung besondere Aufmerksamkeit widmen, um ein Ertrinken in einem Wartungs-Albtraum zu vermeiden. Es ist kein Zufall, dass die erstaunlich effizientesten Produkte auch die geringste Anzahl von Fehlern aufweisen können.
TL; DR
Wie auch immer, das ist meine Sichtweise auf das Thema, angefangen von Prioritäten in wirklich leistungskritischen Bereichen, was die Latenz reduzieren und kleine Ineffizienzen akkumulieren kann und was eigentlich "Schönheit" ausmacht (wenn man die Dinge am produktivsten betrachtet).