Inspiriert vom
ersten Parsing-
Sieg mit einer Beschreibung einer Inselszene aus Disneys
Moana- Cartoon, ging ich weiter auf das Studium der Speichernutzung ein. Mit der Vorlaufzeit konnte noch viel getan werden, aber ich entschied, dass es nützlich wäre, zuerst die Situation zu untersuchen.
Ich habe die Laufzeituntersuchung mit den integrierten pbrt-Statistiken gestartet. pbrt verfügt über eine manuelle Einstellung für wichtige Speicherzuweisungen, um die Speichernutzung zu verfolgen. Nach Abschluss des Renderns wird ein Speicherzuweisungsbericht angezeigt. Der Speicherzuordnungsbericht für diese Szene lautete ursprünglich wie folgt:
BVH- 9,01
1,44
MIP- 2,00
11,02
In Bezug auf die Laufzeit stellte sich heraus, dass die integrierten Statistiken kurz waren und nur die Speicherzuordnung für bekannte Objekte mit einer Größe von 24 GB meldeten.
top
sagte, dass tatsächlich etwa 70 GB Speicher verwendet wurden, dh 45 GB wurden in der Statistik nicht berücksichtigt. Kleine Abweichungen sind durchaus verständlich: Dynamische Speicherzuordnungen benötigen zusätzlichen Speicherplatz zum Registrieren der Ressourcennutzung, einige gehen aufgrund von Fragmentierung verloren und so weiter. Aber 45 GB? Hier versteckt sich definitiv etwas Schlimmes.
Um zu verstehen, was uns fehlt (und um sicherzustellen, dass wir richtig verfolgt haben), habe ich
massif verwendet , um die tatsächliche Zuordnung des dynamischen Speichers zu verfolgen. Es ist ziemlich langsam, aber zumindest funktioniert es gut.
Primitive
Das erste, was ich beim Verfolgen des Massivs fand, waren zwei Codezeilen, die Instanzen der Basisklasse
Primitive
, die in der Statistik nicht berücksichtigt werden, im Speicher zuordneten. Ein kleines Versehen, das
ziemlich einfach zu beheben ist . Danach sehen wir Folgendes:
Primitives 24,67
Ups Was ist also ein Primitiv und warum all diese Erinnerung?
pbrt unterscheidet zwischen
Shape
, bei der es sich um reine Geometrie (Kugel, Dreieck usw.) handelt, und
Primitive
, bei der es sich um eine Kombination aus Geometrie, Material, manchmal der Funktion der Strahlung und dem Medium handelt, das innerhalb und außerhalb der Oberfläche der Geometrie beteiligt ist.
Für die Basisklasse
Primitive
gibt es
mehrere Optionen :
GeometricPrimitive
, ein Standardfall: eine Vanillekombination aus Geometrie, Material usw., sowie
TransformedPrimitive
, ein Primitiv, auf das Transformationen angewendet werden, entweder als Instanz eines Objekts oder zum Verschieben von Primitiven mit Transformationen, die sich im Laufe der Zeit ändern. Es stellt sich heraus, dass in dieser Szene beide Typen Platzverschwendung sind.
GeometricPrimitive: 50% zusätzlicher Speicherplatz
Hinweis: Bei dieser Analyse werden einige falsche Annahmen getroffen. Sie werden im vierten Beitrag der Serie überarbeitet.4,3 GB für
GeometricPrimitive
. Es ist lustig, in einer Welt zu leben, in der 4,3 GB verwendeter RAM nicht Ihr größtes Problem sind, aber lassen Sie uns trotzdem sehen, woher wir 4,3 GB
GeometricPrimitive
. Hier sind die relevanten Teile der Klassendefinition:
class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; };
Wir haben einen
Zeiger auf vtable , drei weitere Zeiger und dann ein
MediumInterface
das zwei weitere Zeiger mit einer Gesamtgröße von 48 Bytes enthält. Es gibt nur wenige lichtemittierende Netze in dieser Szene, daher ist
areaLight
fast immer ein Nullzeiger, und es gibt keine Umgebung, die die Szene beeinflusst, sodass beide
mediumInterface
ebenfalls null sind. Wenn wir also eine spezielle Implementierung der
Primitive
Klasse hätten, die ohne die Strahlungs- und Medienfunktionen verwendet werden könnte, würden wir fast die Hälfte des von
GeometricPrimitive
belegten Speicherplatzes einsparen - in unserem Fall etwa 2 GB.
Ich habe es jedoch nicht behoben und pbrt eine neue
Primitive
Implementierung hinzugefügt. Wir bemühen uns aus einem sehr einfachen Grund, die Unterschiede zwischen dem pbrt-v3-Quellcode auf github und dem in meinem Buch beschriebenen System zu minimieren. Wenn Sie sie synchron halten, können Sie das Buch leicht lesen und mit dem Code arbeiten. In diesem Fall entschied ich, dass die völlig neue Implementierung von
Primitive
, die im Buch nie erwähnt wurde, einen zu großen Unterschied darstellen würde. Aber dieses Update wird definitiv in der neuen Version von pbrt erscheinen.
Bevor wir fortfahren, machen wir ein Test-Rendering:
Strand von der Insel aus dem Film "Moana" von pbrt-v3 mit einer Auflösung von 2048x858 und 256 Samples pro Pixel. Die gesamte Renderzeit auf der 12-Core / 24-Thread-Instanz von Google Compute Engine mit einer Frequenz von 2 GHz mit der neuesten Version von pbrt-v3 betrug 2 Stunden 25 Minuten 43 Sekunden.TransformedPrimitives: 95% verschwendeter Speicherplatz
Der unter 4,3 GB
GeometricPrimitive
zugewiesene Speicher war ein ziemlich schmerzhafter Treffer, aber was ist mit 17,4 GB unter
TransformedPrimitive
?
Wie oben erwähnt, wird
TransformedPrimitive
sowohl für Transformationen mit zeitlicher Änderung als auch für Instanzen von Objekten verwendet. In beiden Fällen müssen wir eine zusätzliche Transformation auf das vorhandene
Primitive
anwenden. Die
TransformedPrimitive
Klasse enthält nur zwei Mitglieder:
std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld;
So weit so gut: ein Zeiger auf ein Primitiv und eine Transformation, die sich im Laufe der Zeit ändert. Aber was ist eigentlich in
AnimatedTransform
gespeichert?
const Transform *startTransform, *endTransform; const Float startTime, endTime; const bool actuallyAnimated; Vector3f T[2]; Quaternion R[2]; Matrix4x4 S[2]; bool hasRotation; struct DerivativeTerm {
Neben Zeigern auf zwei Übergangsmatrizen und der damit verbundenen Zeit gibt es auch eine Zerlegung der Matrizen in Transport-, Rotations- und Skalierungskomponenten sowie vorberechnete Werte zur Begrenzung des Volumens, das durch das Verschieben von Begrenzungsrahmen belegt wird (siehe Abschnitt 2.4.9 unseres Buches)
Physikalisch basiertes Rendern ). All dies summiert sich auf 456 Bytes.
Aber in dieser Szene
bewegt sich nichts . Unter dem Gesichtspunkt von Transformationen für Instanzen von Objekten benötigen wir einen Zeiger auf die Transformation, und die Werte für Zerlegung und bewegliche Begrenzungsrahmen werden nicht benötigt. (Das heißt, es werden nur 8 Bytes benötigt). Wenn Sie eine separate
Primitive
Implementierung für feste Instanzen von Objekten erstellen, werden insgesamt 17,4 GB auf 900 MB (!) Komprimiert.
Was
GeometricPrimitive
betrifft, ist die Korrektur eine nicht triviale Änderung im Vergleich zu dem, was im Buch beschrieben wird. Daher werden wir es auch auf die nächste Version von pbrt verschieben. Zumindest verstehen wir jetzt, was mit dem Chaos von 24,7 GB
Primitive
Speicher passiert.
Probleme mit dem Konvertierungscache
Der nächstgrößte Block nicht erfassten Speichers, der vom Massiv definiert wurde, war
TransformCache
, der ungefähr 16 GB belegte. (Hier ist ein Link zur
ursprünglichen Implementierung .) Die Idee ist, dass dieselbe Transformationsmatrix häufig mehrmals in der Szene verwendet wird. Daher ist es am besten, eine einzige Kopie davon im Speicher zu haben, damit alle Elemente, die sie verwenden, einfach einen Zeiger auf dasselbe Objekt speichern Umwandlung.
TransformCache
verwendete
std::map
, um den Cache zu speichern, und massif berichtete, dass 6 von 16 GB für schwarz-rote Baumknoten in
std::map
. Das ist eine Menge: 60% dieses Volumens werden für die Transformationen selbst verwendet. Schauen wir uns die Deklaration für diese Distribution an:
std::map<Transform, std::pair<Transform *, Transform *>> cache;
Hier ist die Arbeit perfekt erledigt:
Transform
vollständig als Schlüssel für die Verteilung verwendet. Noch besser ist, dass pbrt
Transform
zwei 4x4-Matrizen (die Transformationsmatrix und ihre inverse Matrix) speichert, was dazu führt, dass 128 Bytes in jedem Knoten des Baums gespeichert werden. All dies ist für den für ihn gespeicherten Wert absolut unnötig.
Vielleicht ist eine solche Struktur in einer Welt ganz normal, in der es für uns wichtig ist, dass dieselbe Transformationsmatrix in Hunderten oder Tausenden von Grundelementen verwendet wird, und im Allgemeinen gibt es nicht viele Transformationsmatrizen. Aber für eine Szene mit einer Reihe von meist einzigartigen Transformationsmatrizen, wie in unserem Fall, ist dies nur ein schrecklicher Ansatz.
Abgesehen von der Tatsache, dass der Speicherplatz für Schlüssel verschwendet wird, beinhaltet eine Suche in
std::map
zum Durchlaufen des rot-schwarzen Baums viele Zeigeroperationen. Daher erscheint es logisch, etwas völlig Neues auszuprobieren. Glücklicherweise wird in dem Buch wenig über
TransformCache
geschrieben, sodass es durchaus akzeptabel ist, es vollständig neu zu schreiben.
Und bevor wir anfangen: Nachdem wir die Signatur der
Lookup()
-Methode untersucht haben, wird ein weiteres Problem offensichtlich:
void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse)
Wenn die aufrufende Funktion
Transform
bereitstellt, speichert der Cache Konvertierungszeiger, die dem übergebenen entsprechen, und gibt sie zurück, übergibt aber auch die inverse Matrix. Um dies zu ermöglichen, wird in der ursprünglichen Implementierung beim Hinzufügen einer Transformation zum Cache die inverse Matrix immer berechnet und gespeichert, damit sie zurückgegeben werden kann.
Das Dumme dabei ist, dass die meisten Dial Peers, die den Transformationscache verwenden, die inverse Matrix nicht abfragen oder verwenden. Das heißt, verschiedene Arten von Speicher werden für nicht anwendbare inverse Transformationen verschwendet.
In der
neuen Implementierung werden die folgenden Verbesserungen hinzugefügt:
- Es verwendet eine Hash-Tabelle, um die Suche zu beschleunigen, und erfordert keine Speicherung von etwas anderem als dem
Transform *
-Array, wodurch der Speicherbedarf im Wesentlichen auf den Wert reduziert wird, der tatsächlich zum Speichern aller Transform
. - Die Signatur der Suchmethode sieht jetzt wie
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
; An einer Stelle, an der die aufrufende Funktion die inverse Matrix aus dem Cache Lookup()
möchte, wird Lookup()
zweimal Lookup()
.
Zum Hashing habe ich die
Hash-Funktion FNV1a verwendet . Nach seiner Implementierung fand ich
Aras 'Beitrag zu Hash-Funktionen ; Vielleicht hätte ich einfach xxHash oder CityHash verwenden sollen, weil ihre Leistung besser ist. Vielleicht wird meine Schande eines Tages gewinnen und ich werde es reparieren.
Dank der neuen
TransformCache
Implementierung konnte die Systemstartzeit erheblich verkürzt werden - bis zu 21 Minuten und 42 Sekunden. Das heißt, wir haben weitere 5 Minuten und 7 Sekunden gespart oder 1,27-mal beschleunigt. Darüber hinaus hat eine effizientere Speichernutzung den von den Transformationsmatrizen belegten Speicherplatz von 16 auf 5,7 GB reduziert, was fast der gespeicherten Datenmenge entspricht. Dies erlaubte uns, nicht zu versuchen, die Tatsache auszunutzen, dass sie nicht wirklich projektiv sind, und 3x4-Matrizen anstelle von 4x4 zu speichern. (Im Normalfall wäre ich skeptisch, wie wichtig diese Art der Optimierung ist, aber hier würden wir mehr als ein Gigabyte sparen - viel Speicher! Dies lohnt sich auf jeden Fall im Produktionsrenderer.)
Kleine Leistungsoptimierung zu vervollständigen
Eine zu verallgemeinerte
TransformedPrimitive
Struktur kostet uns sowohl Speicher als auch Zeit: Der Profiler gab an, dass ein erheblicher Teil der Zeit beim Start in der Funktion
AnimatedTransform::Decompose()
, die die Transformation der Matrix in Quaternionsrotation, -übertragung und -skalierung zerlegt. Da sich in dieser Szene nichts bewegt, ist diese Arbeit nicht erforderlich, und eine gründliche Überprüfung der Implementierung von
AnimatedTransform
hat gezeigt, dass auf keinen dieser Werte zugegriffen wird, wenn die beiden Transformationsmatrizen tatsächlich identisch sind.
Durch Hinzufügen von
zwei Zeilen zum Konstruktor, damit die Zerlegungen der Transformationen nicht ausgeführt werden, wenn sie nicht erforderlich sind, haben wir ab der Startzeit weitere 1 min 31 gespeichert: Als Ergebnis kamen wir zu 20 min 9 s, dh sie beschleunigten im Allgemeinen 1,73-mal.
Im
nächsten Artikel werden wir den Parser ernsthaft aufgreifen und analysieren, was wichtig wurde, als wir die Arbeit anderer Teile beschleunigten.