Optimierung des Renderns einer Szene aus dem Disney-Cartoon "Moana". Teile 4 und 5

Bild

Ich habe einen pbrt-Zweig, in dem ich neue Ideen teste, interessante Ideen aus wissenschaftlichen Artikeln umsetze und im Allgemeinen alles studiere, was normalerweise zu einer neuen Ausgabe des Buches Physically Based Rendering führt . Im Gegensatz zu pbrt-v3 , das wir so nah wie möglich an dem im Buch beschriebenen System halten möchten , können wir in diesem Thread alles ändern. Heute werden wir sehen, wie radikalere Änderungen im System die Speichernutzung in der Szene mit der Insel aus dem Disney-Cartoon "Moana" erheblich reduzieren werden.

Hinweis zur Methodik: In den vorherigen drei Beiträgen wurden alle Statistiken für die WIP-Version (Work In Progress) der Szene gemessen, mit der ich vor ihrer Veröffentlichung gearbeitet habe. In diesem Artikel werden wir zur endgültigen Version übergehen, die etwas komplizierter ist.

Beim Rendern der letzten Inselszene aus Moana wurden 81 GB RAM verwendet, um die Szenenbeschreibung für pbrt-v3 zu speichern. Derzeit verbraucht pbrt-next 41 GB - ungefähr halb so viel. Um dieses Ergebnis zu erzielen, reichten kleine Änderungen aus, die sich auf mehrere hundert Codezeilen auswirkten.

Reduzierte Primitive


Denken Sie daran, dass in pbrt Primitive eine Kombination aus Geometrie, Material, Strahlungsfunktion (wenn es sich um eine Lichtquelle handelt) und Aufzeichnungen über die Umgebung innerhalb und außerhalb der Oberfläche besteht. In pbrt-v3 speichert GeometricPrimitive Folgendes:

  std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; 

Wie bereits erwähnt , ist nullptr die MediumInterface Zeit areaLight , und das MediumInterface enthält ein Paar nullptr . Deshalb habe ich in pbrt-next eine Primitive Option namens SimplePrimitive , die nur Zeiger auf Geometrie und Material speichert. Wenn möglich, wird es GeometricPrimitive Möglichkeit anstelle von GeometricPrimitive :

 class SimplePrimitive : public Primitive { // ... std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; }; 

Für nicht animierte Objektinstanzen haben wir jetzt TransformedPrimitive , das nur einen Zeiger auf das Grundelement und die Transformation speichert. Dadurch sparen wir etwa 500 Byte verschwendeten Speicherplatz , den die AnimatedTransform Instanz dem TransformedPrimitive Renderer pbrt-v3 hinzugefügt hat.

 class TransformedPrimitive : public Primitive { // ... std::shared_ptr<Primitive> primitive; std::shared_ptr<Transform> PrimitiveToWorld; }; 

(Es gibt AnimatedPrimitive für den Fall, dass Sie eine animierte Konvertierung nach pbrt-next benötigen.)

Nach all diesen Änderungen wird in Statistiken angegeben, dass unter Primitive nur 7,8 GB anstelle von 28,9 GB in pbrt-v3 verwendet werden. Obwohl es großartig ist, dass wir 21 GB eingespart haben, ist es nicht so sehr der Rückgang, den wir von früheren Schätzungen erwarten konnten. Wir werden am Ende dieses Teils auf diese Diskrepanz zurückkommen.

Reduzierte Geometrie


Außerdem hat pbrt-next den von der Geometrie belegten Speicher erheblich reduziert: Der für Maschendreiecke verwendete Speicherplatz wurde von 19,4 GB auf 9,9 GB und der Speicherplatz für Kurven von 1,4 auf 1,1 GB verringert. Etwas mehr als die Hälfte dieser Einsparungen resultierte aus der Vereinfachung der Shape .

In pbrt-v3 bringt Shape mehrere Mitglieder mit, die sich auf alle Shape Implementierungen übertragen lassen. Dies sind verschiedene Aspekte, auf die in Shape Implementierungen bequem Shape .

 class Shape { // .... const Transform *ObjectToWorld, *WorldToObject; const bool reverseOrientation; const bool transformSwapsHandedness; }; 

Um zu verstehen, warum diese Elementvariablen Probleme verursachen, ist es hilfreich zu verstehen, wie Dreiecksnetze in pbrt dargestellt werden. Erstens gibt es die TriangleMesh Klasse, in der die Eckpunkte und Indexpuffer für das gesamte Netz gespeichert sind:

 struct TriangleMesh { int nTriangles, nVertices; std::vector<int> vertexIndices; std::unique_ptr<Point3f[]> p; std::unique_ptr<Normal3f[]> n; // ... }; 

Jedes Dreieck im Netz wird durch die Triangle , die von Shape erbt. Die Idee ist, das Triangle so klein wie möglich zu halten: Sie speichern nur einen Zeiger auf das Netz, zu dem sie gehören, und einen Zeiger auf den Versatz im Indexpuffer, an dem die Indizes seiner Scheitelpunkte beginnen:

 class Triangle : public Shape { // ... std::shared_ptr<TriangleMesh> mesh; const int *v; }; 

Wenn Triangle Implementierungen die Positionen ihrer Scheitelpunkte finden müssen, führt sie die entsprechende Indizierung durch, um sie von TriangleMesh .

Das Problem mit Shape pbrt-v3 besteht darin, dass die darin gespeicherten Werte für alle Dreiecke des Netzes gleich sind. Es ist daher besser, sie von jedem ganzen Netz in TriangleMesh zu speichern und Triangle Zugriff auf eine einzelne Kopie der allgemeinen Werte zu gewähren.

Dieses Problem wurde in pbrt-next behoben: Die Basisformklasse in pbrt-next enthält keine solchen Elemente, und daher ist jedes Triangle 24 Byte kleiner. Die Geometriekurve verwendet eine ähnliche Strategie und profitiert auch von einer kompakteren Shape .

Geteilte Dreieckspuffer


Trotz der Tatsache, dass die Moana - Inselszene die Objektinstanziierung in großem Umfang zur expliziten Wiederholung der Geometrie verwendet, war ich neugierig, wie oft die Wiederverwendung von Indexpuffern, Texturkoordinatenpuffern usw. für verschiedene Dreiecksnetze verwendet wird.

Ich habe eine kleine Klasse geschrieben, die diese Puffer beim Empfang hascht und im Cache speichert, und TriangleMesh so geändert, dass es den Cache überprüft und die bereits gespeicherte Version eines benötigten redundanten Puffers verwendet. Der Gewinn war sehr gut: Ich habe es geschafft, 4,7 GB überschüssiges Volumen loszuwerden, was viel mehr ist als ich erwartet hatte.

Absturz mit std :: shared_ptr


Nach all diesen Änderungen werden in der Statistik etwa 36 GB bekannter zugeordneter Speicher angezeigt. Zu Beginn des Renderns gibt top die Verwendung von 53 GB an. Angelegenheiten.

Ich hatte Angst vor einer weiteren Reihe langsamer Massivläufe , um herauszufinden, welcher zugewiesene Speicher in der Statistik fehlt, aber dann erschien ein Brief von Arseny Kapulkin in meinem Posteingang. Arseny erklärte mir, dass meine früheren Schätzungen zur Speichernutzung von GeometricPrimitive sehr falsch waren. Ich musste es lange herausfinden, aber dann wurde mir klar; Vielen Dank an Arseny für den Hinweis auf den Fehler und die detaillierten Erklärungen.

Bevor ich an Arseny schrieb, stellte ich mir die Implementierung von std::shared_ptr wie folgt vor: In diesen Zeilen gibt es einen gemeinsamen Deskriptor, der den Referenzzähler und einen Zeiger auf das platzierte Objekt selbst speichert:

 template <typename T> class shared_ptr_info { std::atomic<int> refCount; T *ptr; }; 

Dann schlug ich vor, dass die Instanz shared_ptr nur darauf verweist und sie verwendet:

 template <typename T> class shared_ptr { // ... T *operator->() { return info->ptr; } shared_ptr_info<T> *info; }; 

Kurz gesagt, ich habe angenommen, dass sizeof(shared_ptr<>) der Größe des Zeigers entspricht und dass 16 Byte zusätzlicher Speicherplatz für jeden gemeinsam genutzten Zeiger verschwendet werden.

Aber das ist nicht so.

In meiner Systemimplementierung ist der allgemeine Deskriptor 32 Byte groß und 16 Byte groß sizeof(shared_ptr<>) . Daher ist GeometricPrimitive , das hauptsächlich aus std::shared_ptr , ungefähr doppelt so groß wie meine Schätzungen. Wenn Sie sich fragen, warum dies passiert ist, erklären diese beiden Stapelüberlauf-Beiträge die Gründe ausführlich: 1 und 2 .

In fast allen Fällen, in denen std::shared_ptr in pbrt-next verwendet wird, müssen sie keine gemeinsam genutzten Zeiger sein. Während ich verrücktes Hacken machte, ersetzte ich alles, was ich konnte, durch std::unique_ptr , das tatsächlich die gleiche Größe wie ein normaler Zeiger hat. So sieht SimplePrimitive jetzt beispielsweise aus:

 class SimplePrimitive : public Primitive { // ... std::unique_ptr<Shape> shape; const Material *material; }; 

Die Belohnung erwies sich als höher als erwartet: Die Speichernutzung zu Beginn des Renderns verringerte sich von 53 GB auf 41 GB - eine Einsparung von 12 GB, die vor einigen Tagen völlig unerwartet war, und das Gesamtvolumen ist fast halb so hoch wie das von pbrt-v3 verwendete. Großartig!

Im nächsten Teil werden wir diese Artikelserie endlich vervollständigen - untersuchen Sie die Rendergeschwindigkeit in pbrt-next und diskutieren Sie Ideen für andere Möglichkeiten, um den für diese Szene benötigten Speicherplatz zu reduzieren.

Teil 5


Um diese Artikelserie zusammenzufassen, untersuchen wir zunächst die Rendergeschwindigkeit der Inselszene aus dem Disney-Cartoon "Moana" in pbrt-next - dem pbrt-Zweig, mit dem ich neue Ideen teste. Wir werden radikalere Änderungen vornehmen, als dies in pbrt-v3 möglich ist, das dem in unserem Buch beschriebenen System entsprechen sollte. Wir schließen mit einer Diskussion der Bereiche für weitere Verbesserungen, von den einfachsten bis zu den leicht extremen.

Renderzeit


Pbrt-next nahm viele Änderungen an den Lichtübertragungsalgorithmen vor, einschließlich Änderungen an der BSDF-Abtastung und Verbesserungen an russischen Roulette-Algorithmen. Infolgedessen werden mehr Strahlen als pbrt-v3 verfolgt, um diese Szene zu rendern, sodass es nicht möglich ist, die Ausführungszeit dieser beiden Renderer direkt zu vergleichen. Die Geschwindigkeit ist im Allgemeinen nahe, mit einer wichtigen Ausnahme: Beim Rendern einer Inselszene aus Moana (siehe unten) verbringt pbrt-v3 14,5% der Ausführungszeit mit der Suche nach ptex- Texturen. Früher schien mir das ganz normal zu sein, aber pbrt-next verbringt nur 2,2% der Ausführungszeit. Das alles ist furchtbar interessant.

Nach dem Studium der Statistik erhalten wir 1 :

pbrt-v3:
Ptex 20828624
Ptex 712324767

pbrt-next:
Ptex 3378524
Ptex 825826507


Wie wir in pbrt-v3 sehen, wird die ptex-Textur durchschnittlich alle 34 Textursuchen von der Festplatte gelesen. In pbrt-next wird es erst nach jeweils 244 Suchvorgängen ausgelesen - das heißt, die Festplatten-E / A hat sich um das Siebenfache verringert. Ich schlug vor, dass dies geschieht, weil pbrt-next die Strahlendifferenzen für indirekte Strahlen berechnet und dies zu einem Zugriff auf höhere MIP-Ebenen von Texturen führt, was wiederum eine stärker integrierte Reihe von Zugriffen auf den ptex-Textur-Cache schafft. reduziert die Anzahl der Cache-Fehlschläge und damit die Anzahl der E / A-Operationen 2 . Eine kurze Überprüfung bestätigte meine Vermutung: Als die Strahldifferenz ausgeschaltet wurde, wurde die ptex-Geschwindigkeit viel schlechter.

Die Erhöhung der ptex-Geschwindigkeit hat nicht nur die Kosten für Computer und E / A beeinflusst. In einem 32-CPU-System wurde pbrt-v3 nach dem Parsen der Szenenbeschreibung nur um das 14,9-fache beschleunigt. pbrt zeigt normalerweise eine nahezu lineare parallele Skalierung, daher hat es mich ziemlich enttäuscht. Aufgrund der viel geringeren Anzahl von Konflikten während Sperren in ptex war die pbrt-next-Version in einem System mit 32 CPUs 29,2-mal schneller und in einem System mit 96 CPUs 94,9-mal schneller - wir sind wieder bei den Indikatoren, die zu uns passen.


Wurzeln aus der Moana-Inselszene, gerendert von pbrt mit einer Auflösung von 2048 x 858 bei 256 Samples pro Pixel. Die gesamte Renderzeit auf einer Google Compute Engine-Instanz mit 96 virtuellen CPUs mit einer Frequenz von 2 GHz in pbrt-next beträgt 41 Minuten und 22 Sekunden. Die Beschleunigung aufgrund von Mulithreading während des Renderns betrug das 94,9-fache. (Ich verstehe nicht ganz, was mit Bump Mapping passiert.)

Arbeit für die Zukunft


Das Verringern des in solchen komplexen Szenen verwendeten Speicherplatzes ist eine aufregende Erfahrung: Das Speichern einiger Gigabyte mit einer kleinen Änderung ist viel angenehmer als das Speichern von zehn Megabyte in einer einfacheren Szene. Ich habe eine gute Liste von dem, was ich in Zukunft lernen möchte, wenn es die Zeit erlaubt. Hier ist eine kurze Übersicht.

Weiter abnehmender Dreieckspufferspeicher


Selbst bei wiederholter Verwendung von Puffern, die dieselben Werte für mehrere Dreiecksnetze speichern, wird unter den Dreieckspuffern immer noch viel Speicher verwendet. Hier ist eine Aufschlüsselung der Speichernutzung für verschiedene Arten von Dreieckspuffern in der Szene:

TypDie Erinnerung
Werbebuchungen2,5 GB
Normal2,5 GB
UV98 MB
Indizes252 MB

Ich verstehe, dass mit den übertragenen Scheitelpunktpositionen nichts getan werden kann, aber für andere Daten gibt es Einsparungen. Es gibt viele Arten von Darstellungen normaler Vektoren in einer speichereffizienten Form , die verschiedene Kompromisse zwischen Speichergröße / Anzahl der Berechnungen bieten. Durch die Verwendung einer der 24-Bit- oder 32-Bit-Darstellungen wird der von den Normalen belegte Speicherplatz auf 663 MB und 864 MB reduziert, wodurch mehr als 1,5 GB RAM eingespart werden.

In dieser Szene ist der Speicherplatz zum Speichern von Texturkoordinaten und Indexpuffern überraschend gering. Ich nehme an, dass dies aufgrund des Vorhandenseins vieler prozedural erzeugter Pflanzen in der Szene und aufgrund der Tatsache geschah, dass alle Variationen desselben Pflanzentyps dieselbe Topologie (und damit den Indexpuffer) mit Parametrisierung (und damit UV-Koordinaten) aufweisen. Die Wiederverwendung von passenden Puffern ist wiederum sehr effizient.

Für andere Szenen kann es durchaus geeignet sein, 16-Bit-UV-Koordinaten von Texturen abzutasten oder Gleitkommawerte mit halber Genauigkeit zu verwenden, abhängig von ihrem Wertebereich. Es scheint, dass in dieser Szene alle Texturkoordinatenwerte Null oder Eins sind, was bedeutet, dass sie durch ein Bit dargestellt werden können - das heißt, es ist möglich, den belegten Speicher um das 32-fache zu reduzieren. Dieser Zustand ist wahrscheinlich auf die Verwendung des ptex-Formats für die Texturierung zurückzuführen, wodurch UV-Atlanten überflüssig werden. Angesichts der geringen Menge, die derzeit von den Texturkoordinaten belegt wird, ist die Implementierung dieser Optimierung nicht besonders notwendig.

pbrt verwendet immer 32-Bit-Ganzzahlen für Indexpuffer. Für kleine Netze mit weniger als 256 Scheitelpunkten sind nur 8 Bit pro Index ausreichend, und für Netze mit weniger als 65.536 Scheitelpunkten können 16 Bit verwendet werden. Das Ändern von pbrt, um es an dieses Format anzupassen, wird nicht sehr schwierig sein. Wenn wir maximal optimieren wollten, könnten wir genau so viele Bits auswählen, wie erforderlich sind, um den erforderlichen Bereich in den Indizes darzustellen, während der Preis darin bestehen würde, die Komplexität der Ermittlung ihrer Werte zu erhöhen. Trotz der Tatsache, dass jetzt nur ein Viertel Gigabyte Speicher für Scheitelpunktindizes verwendet wird, sieht diese Aufgabe im Vergleich zu anderen nicht sehr interessant aus.

Spitzenauslastung des BVH-Buildspeichers


Zuvor haben wir noch kein weiteres Detail der Speichernutzung besprochen: Unmittelbar vor dem Rendern tritt ein kurzfristiger Peak von 10 GB zusätzlich verwendetem Speicher auf. Dies geschieht, wenn der (große) BVH der gesamten Szene erstellt wird. Der Code zum Erstellen der BVH des pbrt-Renderers wird so geschrieben, dass er in zwei Phasen ausgeführt wird: Zunächst wird eine BVH mit der traditionellen Darstellung erstellt : zwei untergeordnete Zeiger auf jeden Knoten. Nach dem Erstellen des Baums wird er in ein speichereffizientes Schema konvertiert , in dem sich das erste untergeordnete Element des Knotens direkt dahinter im Speicher befindet und der Offset zum zweiten untergeordneten Element als Ganzzahl gespeichert wird.

Eine solche Trennung war aus Sicht der Lehramtsstudenten notwendig - es war viel einfacher, die Algorithmen zum Erstellen von BVH zu verstehen, ohne dass Chaos mit der Notwendigkeit verbunden war, den Baum während des Bauprozesses in eine kompakte Form umzuwandeln. Das Ergebnis ist jedoch dieser Spitzenwert bei der Speichernutzung. Angesichts seines Einflusses auf die Szene erscheint die Beseitigung dieses Problems attraktiv.

Konvertieren Sie Zeiger in Ganzzahlen


In verschiedenen Datenstrukturen gibt es viele 64-Bit-Zeiger, die als 32-Bit-Ganzzahlen dargestellt werden können. Beispielsweise enthält jedes SimplePrimitive einen Zeiger auf ein Material . Die meisten Fälle von Material sind vielen Grundelementen in der Szene gemeinsam, und es gibt nie mehr als einige Tausend; Daher können wir einen einzelnen globalen Vektorvektor aller Materialien speichern:

 std::vector<Material *> allMaterials; 

und speichern Sie einfach 32-Bit-Ganzzahl-Offsets für diesen Vektor in SimplePrimitive , wodurch wir 4 Bytes sparen. Der gleiche Trick kann mit einem Zeiger auf das TriangleMesh in jedem Triangle sowie an vielen anderen Stellen verwendet werden.

Nach einer solchen Änderung wird der Zugriff auf die Schilder selbst geringfügig redundant sein, und das System wird für Schüler, die versuchen, seine Arbeit zu verstehen, etwas weniger verständlich. Außerdem ist dies wahrscheinlich der Fall, wenn es im Zusammenhang mit pbrt besser ist, die Implementierung ein wenig verständlicher zu halten, wenn auch auf Kosten einer unvollständigen Optimierung der Speichernutzung.

Unterkunft basierend auf Arenen (Bereichen)


Für jedes einzelne Triangle und Primitiv wird ein separater Aufruf an new (eigentlich make_unique , aber das ist das gleiche) gemacht. Solche Speicherzuweisungen führen zur Verwendung einer zusätzlichen Ressourcenabrechnung, die etwa fünf Gigabyte Speicher belegt und in der Statistik nicht berücksichtigt wird. Da die Lebensdauer all dieser Platzierungen gleich ist - bis das Rendern abgeschlossen ist - können wir diese zusätzliche Abrechnung beseitigen, indem wir sie aus dem Speicherbereich auswählen.

Khaki vtable


Meine letzte Idee ist schrecklich und ich entschuldige mich dafür, aber sie hat mich fasziniert.

Jedes Dreieck in der Szene hat eine zusätzliche Last von mindestens zwei vtable-Zeigern: einen für Triangle und einen für SimplePrimitive . Das sind 16 Bytes. Die Moana- Inselszene hat insgesamt 146 162 124 einzigartige Dreiecke, wodurch fast 2,2 GB redundante vtable-Zeiger hinzugefügt werden.

Was wäre, wenn wir keine abstrakte Basisklasse für Shape und jede Geometrieimplementierung nichts erben würde? Dies würde uns Platz auf vtable-Zeigern sparen, aber wenn wir einen Zeiger auf eine Geometrie übergeben, wissen wir natürlich nicht, um welche Art von Geometrie es sich handelt, das heißt, es wäre nutzlos.

Es stellt sich heraus, dass auf modernen x86-CPUs tatsächlich nur 48 Bit 64-Bit-Zeiger verwendet werden . Daher gibt es zusätzliche 16 Bits, die wir ausleihen können, um einige Informationen zu speichern ... zum Beispiel die Geometrie, auf die wir zeigen. Wenn wir ein wenig Arbeit hinzufügen, können wir auf die Möglichkeit zurückgreifen, ein Analogon von Aufrufen virtueller Funktionen zu erstellen.

So wird es passieren: Zuerst definieren wir eine ShapeMethods Struktur, die Zeiger auf Funktionen enthält, wie z. B. 3 :

 struct ShapeMethods { Bounds3f (*WorldBound)(void *); // Intersect, etc. ... }; 

Jede Geometrieimplementierung implementiert eine Einschränkungsfunktion, eine Schnittfunktion usw. und empfängt als erstes Argument ein Analogon this Zeigers:

 Bounds3f TriangleWorldBound(void *t) { //       Triangle. Triangle *tri = (Triangle *)t; // ... 

Wir hätten eine globale Tabelle mit ShapeMethods Strukturen, in der das n-te Element für einen Geometrietyp mit dem Index n gilt :

 ShapeMethods shapeMethods[] = { { TriangleWorldBound, /*...*/ }, { CurveWorldBound, /*...*/ }; // ... }; 

Beim Erstellen von Geometrie codieren wir ihren Typ in einige der nicht verwendeten Bits des Rückgabezeigers. Unter Berücksichtigung des Zeigers auf die Geometrie, deren spezifischen Aufruf wir ausführen möchten, extrahieren wir diesen shapeMethods aus dem Zeiger und verwenden ihn als Index in shapeMethods , um den entsprechenden Funktionszeiger zu finden. Im Wesentlichen würden wir vtable manuell implementieren und den Versand selbst verarbeiten. Wenn wir dies sowohl für Geometrie als auch für Grundelemente tun würden, würden wir 16 Bytes pro Triangle sparen, aber gleichzeitig haben wir einen ziemlich schwierigen Weg gemacht.

Ich nehme an, dass ein solcher Hack zur Implementierung des Managements virtueller Funktionen nicht neu ist, aber ich konnte im Internet keine Links dazu finden. Hier ist die Wikipedia-Seite über markierte Zeiger , aber sie befasst sich mit Dingen wie der Anzahl der Links. Wenn Sie einen besseren Link kennen, senden Sie mir einen Brief.

Indem ich diesen unangenehmen Hack teile, kann ich die Reihe der Beiträge beenden. Nochmals vielen Dank an Disney für die Veröffentlichung dieser Szene. Es hat unglaublich viel Spaß gemacht, damit zu arbeiten. Die Zahnräder in meinem Kopf drehen sich weiter.

Anmerkungen


  1. Am Ende verfolgt pbrt-next mehr Strahlen in dieser Szene als pbrt-v3, was wahrscheinlich die Zunahme der Anzahl von Suchoperationen erklärt.
  2. Die Strahldifferenzen für indirekte Strahlen in pbrt-next werden unter Verwendung des gleichen Hacks berechnet, der in der Textur-Cache-Erweiterung für pbrt-v3 verwendet wird. , , .
  3. Rayshade . , C . Rayshade .

Source: https://habr.com/ru/post/de417939/


All Articles