Aber könnte diese OOP ein Nachteil für leistungsbasierte Software sein, dh wie schnell wird das Programm ausgeführt?
Oft ja !!! ABER...
Mit anderen Worten, könnten viele Referenzen zwischen vielen verschiedenen Objekten oder die Verwendung vieler Methoden aus vielen Klassen zu einer "schweren" Implementierung führen?
Nicht unbedingt. Dies hängt von der Sprache / dem Compiler ab. Zum Beispiel wird ein optimierender C ++ - Compiler, vorausgesetzt, Sie verwenden keine virtuellen Funktionen, Ihren Objekt-Overhead häufig auf Null reduzieren. Sie können beispielsweise einen Wrapper über int
einen solchen schreiben oder einen intelligenten Zeiger mit Gültigkeitsbereich über einen einfachen alten Zeiger, der genauso schnell arbeitet wie die direkte Verwendung dieser einfachen alten Datentypen.
In anderen Sprachen wie Java ist der Aufwand für ein Objekt geringfügig (in vielen Fällen recht gering, in einigen seltenen Fällen jedoch astronomisch, wenn es sich um sehr kleine Objekte handelt). Beispielsweise ist Integer
es wesentlich weniger effizient als int
(benötigt 16 Bytes im Gegensatz zu 4 bei 64-Bit). Dabei handelt es sich nicht nur um krassen Müll oder ähnliches. Im Gegenzug bietet Java die Möglichkeit, jeden benutzerdefinierten Typ einheitlich zu reflektieren und alle nicht als gekennzeichneten Funktionen außer Kraft zu setzen final
.
Nehmen wir jedoch das beste Szenario: den optimierenden C ++ - Compiler, mit dem Objektschnittstellen bis auf Null optimiert werden können . Selbst dann verschlechtert OOP häufig die Leistung und verhindert, dass sie den Höchstwert erreicht. Das mag sich wie ein komplettes Paradox anhören: Wie könnte es sein? Das Problem liegt in:
Interface Design und Encapsulation
Das Problem ist, dass selbst wenn ein Compiler die Struktur eines Objekts auf Null reduzieren kann (was zumindest sehr oft für die Optimierung von C ++ - Compilern zutrifft), die Kapselung und das Schnittstellendesign (und die angesammelten Abhängigkeiten) von feinkörnigen Objekten häufig das verhindern Optimalste Datendarstellung für Objekte, die von der Masse aggregiert werden sollen (was bei leistungskritischer Software häufig der Fall ist).
Nehmen Sie dieses Beispiel:
class Particle
{
public:
...
private:
double birth; // 8 bytes
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
/*padding*/ // 4 bytes of padding
};
Particle particles[1000000]; // 1mil particles (~24 megs)
Angenommen, unser Speicherzugriffsmuster besteht darin, diese Partikel einfach nacheinander zu durchlaufen und sie wiederholt um jedes Bild zu bewegen, sie von den Ecken des Bildschirms abzuprallen und dann das Ergebnis zu rendern.
Es ist bereits ein eklatanter 4-Byte-Padding-Overhead zu sehen, der erforderlich ist, um das birth
Element ordnungsgemäß auszurichten , wenn Partikel zusammenhängend aggregiert werden. Bereits ~ 16,7% des Speichers werden mit dem für die Ausrichtung verwendeten Totraum verschwendet.
Dies könnte problematisch erscheinen, da wir heutzutage Gigabyte DRAM haben. Doch selbst die scheußlichsten Maschinen, die wir heute haben, haben oft nur 8 Megabyte, wenn es um die langsamste und größte Region des CPU-Caches (L3) geht. Je weniger wir hineinpassen können, desto mehr zahlen wir für den wiederholten DRAM-Zugriff und desto langsamer wird es. Plötzlich scheint es nicht mehr trivial zu sein, 16,7% des Arbeitsspeichers zu verschwenden.
Diesen Overhead können wir leicht beseitigen, ohne die Ausrichtung des Feldes zu beeinträchtigen:
class Particle
{
public:
...
private:
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
};
Particle particles[1000000]; // 1mil particles (~12 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Jetzt haben wir den Speicher von 24 MB auf 20 MB reduziert. Mit einem sequentiellen Zugriffsmuster verbraucht das Gerät diese Daten jetzt viel schneller.
Aber schauen wir uns dieses birth
Feld etwas genauer an. Nehmen wir an, es zeichnet die Startzeit auf, zu der ein Partikel geboren (erzeugt) wird. Stellen Sie sich vor, auf das Feld wird nur zugegriffen, wenn ein Partikel zum ersten Mal erstellt wird, und alle 10 Sekunden, um zu prüfen, ob ein Partikel stirbt und an einer zufälligen Stelle auf dem Bildschirm wiedergeboren wird. In diesem Fall birth
ist ein kaltes Feld. In unseren leistungskritischen Schleifen wird nicht darauf zugegriffen.
Infolgedessen sind die tatsächlichen leistungskritischen Daten nicht 20 Megabyte, sondern ein zusammenhängender Block von 12 Megabyte. Der aktuelle Arbeitsspeicher, auf den wir häufig zugreifen, ist auf die Hälfte seiner Größe geschrumpft ! Erwarten Sie erhebliche Geschwindigkeitssteigerungen gegenüber unserer ursprünglichen 24-Megabyte-Lösung (muss nicht gemessen werden - wurde bereits tausend Mal durchgeführt, ist aber im Zweifelsfall unverbindlich).
Beachten Sie jedoch, was wir hier gemacht haben. Wir haben die Einkapselung dieses Partikelobjekts vollständig aufgehoben. Sein Status ist jetzt zwischen Particle
den privaten Feldern eines Typs und einem separaten, parallelen Array aufgeteilt. Und hier steht granulares objektorientiertes Design im Weg.
Wir können die optimale Datendarstellung nicht ausdrücken, wenn wir uns auf das Interface-Design eines einzelnen, sehr granularen Objekts wie eines einzelnen Partikels, eines einzelnen Pixels, sogar eines einzelnen 4-Komponenten-Vektors, möglicherweise sogar eines einzelnen "Kreaturen" -Objekts in einem Spiel beschränken usw. Die Geschwindigkeit eines Geparden wird verschwendet, wenn er auf einer 2 Quadratmeter großen kleinen Insel steht, und genau das leistet ein sehr granulares objektorientiertes Design häufig in Bezug auf die Leistung. Es beschränkt die Datendarstellung auf einen suboptimalen Charakter.
Nehmen wir an, wir bewegen nur Partikel, und greifen in drei separaten Schleifen auf deren x / y / z-Felder zu. In diesem Fall können wir von der SoA-ähnlichen SIMD-Struktur mit AVX-Registern profitieren, die 8 SPFP-Operationen parallel vektorisieren können. Aber um dies zu tun, müssen wir jetzt diese Darstellung verwenden:
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Jetzt fliegen wir mit der Partikelsimulation, aber schauen Sie, was mit unserem Partikeldesign passiert ist. Es wurde komplett abgerissen und wir betrachten nun 4 parallele Arrays und haben kein Objekt, sie zu aggregieren. Unser objektorientiertes Particle
Design ist sayonara gegangen.
Das ist mir schon oft passiert, als ich in leistungskritischen Bereichen gearbeitet habe, in denen Benutzer Geschwindigkeit fordern und nur die Korrektheit das ist, was sie mehr fordern. Diese kleinen, winzigen objektorientierten Entwürfe mussten abgerissen werden, und die Kaskadenbrüche erforderten oft eine langsame Abschreibungsstrategie für den schnelleren Entwurf.
Lösung
Das obige Szenario stellt nur ein Problem bei granularen objektorientierten Designs dar. In diesen Fällen müssen wir häufig die Struktur abreißen, um effizientere Darstellungen als Ergebnis von SoA-Wiederholungen, Aufteilen von Heiß- / Kaltfeldern und Reduzierung des Paddings für sequentielle Zugriffsmuster zu erzielen (Padding ist manchmal hilfreich für die Leistung bei wahlfreiem Zugriff) Muster in AoS-Fällen, aber fast immer ein Hindernis für sequentielle Zugriffsmuster usw.
Wir können jedoch diese endgültige Darstellung übernehmen und dennoch eine objektorientierte Schnittstelle modellieren:
// Represents a collection of particles.
class ParticleSystem
{
public:
...
private:
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
};
Jetzt geht es uns gut. Wir können alle objektorientierten Goodies bekommen, die wir mögen. Der Gepard hat ein ganzes Land, durch das er so schnell wie möglich rennen kann. Unsere Schnittstellendesigns führen uns nicht länger in eine Engpass-Ecke.
ParticleSystem
kann möglicherweise sogar abstrakt sein und virtuelle Funktionen verwenden. Es ist strittig, wir zahlen für den Overhead auf Partikelebene anstatt auf Partikelebene . Der Overhead beträgt 1 / 1.000.000stel dessen, was sonst wäre, wenn wir Objekte auf der Ebene der einzelnen Partikel modellieren würden.
Das ist also die Lösung in wirklich leistungskritischen Bereichen, die eine hohe Last bewältigen, und für alle Arten von Programmiersprachen (von dieser Technik profitieren C, C ++, Python, Java, JavaScript, Lua, Swift usw.). Und es kann nicht einfach als "vorzeitige Optimierung" bezeichnet werden, da es sich um das Interface-Design und die Architektur der . Wir können keine Codebasis schreiben, die ein einzelnes Partikel als Objekt mit einer Schiffsladung von Client-Abhängigkeiten zu a modelliertParticle's
öffentliche Schnittstelle und dann später unsere Meinung ändern. Das habe ich oft getan, als ich aufgerufen wurde, um ältere Codebasen zu optimieren, und es kann Monate dauern, Zehntausende von Codezeilen sorgfältig umzuschreiben, um das umfangreichere Design zu verwenden. Dies wirkt sich idealerweise darauf aus, wie wir die Dinge im Voraus gestalten, vorausgesetzt, wir können mit einer hohen Last rechnen.
Ich halte diese Antwort in der einen oder anderen Form in vielen Performance-Fragen, insbesondere im Zusammenhang mit objektorientiertem Design, aufrecht. Objektorientiertes Design kann immer noch mit den höchsten Leistungsanforderungen kompatibel sein, aber wir müssen die Art und Weise, wie wir darüber nachdenken, ein wenig ändern. Wir müssen dem Geparden etwas Raum geben, damit er so schnell wie möglich rennt, und das ist oft unmöglich, wenn wir kleine Objekte entwerfen, die kaum irgendeinen Zustand speichern.