J'ai une branche pbrt, que j'utilise pour tester de nouvelles idées, mettre en œuvre des idées intéressantes à partir d'articles scientifiques, et en général pour étudier tout ce qui aboutit généralement à une nouvelle édition du livre sur le
rendu physique . Contrairement Ă
pbrt-v3 , que nous nous efforçons de garder le plus près possible du système décrit dans le livre, dans ce fil, nous pouvons tout changer. Aujourd'hui, nous verrons comment des changements plus radicaux dans le système réduiront considérablement l'utilisation de la mémoire dans la scène avec l'île du dessin animé Disney
"Moana" .
Note sur la méthodologie: dans les trois posts précédents, toutes les statistiques ont été mesurées pour la version WIP (Work In Progress) de la scène avec laquelle je travaillais avant sa sortie. Dans cet article, nous allons passer à la version finale, qui est un peu plus compliquée.
Lors du rendu de la dernière scène d'îlot de
Moana , 81 Go de RAM ont été utilisés pour stocker la description de la scène pour pbrt-v3. Actuellement, pbrt-next utilise 41 Go, soit environ la moitié. Pour obtenir ce résultat, il suffisait de faire de petites modifications qui s'étalaient sur plusieurs centaines de lignes de code.
Primitives réduites
Rappelons que dans pbrt
Primitive
est une combinaison de géométrie, de son matériau, de la fonction de rayonnement (s'il s'agit d'une source de lumière) et d'enregistrements sur l'environnement à l'intérieur et à l'extérieur de la surface. Dans pbrt-v3,
GeometricPrimitive
stocke les éléments suivants:
std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface;
Comme
indiqué précédemment , la plupart de la zone de temps
areaLight
est
nullptr
, et le
MediumInterface
contient une paire de
nullptr
. Donc, dans pbrt-next, j'ai ajouté une option
Primitive
appelée
SimplePrimitive
, qui ne stocke que des pointeurs sur la géométrie et le matériau. Dans la mesure du possible, il est utilisé
GeometricPrimitive
possible au lieu de
GeometricPrimitive
:
class SimplePrimitive : public Primitive {
Pour les instances d'objets non animés, nous avons maintenant
TransformedPrimitive
, qui ne stocke qu'un pointeur sur la primitive et la transformation, ce qui nous fait économiser environ 500 octets d'
espace gaspillé que l'instance
AnimatedTransform
ajouté au moteur de rendu
TransformedPrimitive
pbrt-v3.
class TransformedPrimitive : public Primitive {
(Il y a
AnimatedPrimitive
au cas où vous auriez besoin d'une conversion animée en pbrt-next.)
Après toutes ces modifications, les statistiques indiquent que seulement 7,8 Go sont utilisés sous
Primitive
, au lieu de 28,9 Go dans pbrt-v3. Bien que nous ayons économisé 21 Go, ce n'est pas autant que la diminution que nous pourrions attendre des estimations précédentes; nous reviendrons sur cet écart à la fin de cette partie.
Géométrie réduite
De plus, pbrt-next a considérablement réduit la quantité de mémoire occupée par la géométrie: l'espace utilisé pour les triangles maillés est passé de 19,4 Go à 9,9 Go, et l'espace de stockage pour les courbes de 1,4 à 1,1 Go. Un peu plus de la moitié de ces économies proviennent de la simplification de la classe
Shape
base.
Dans pbrt-v3,
Shape
apporte plusieurs membres qui se répercutent dans toutes les implémentations
Shape
- ce sont plusieurs aspects auxquels il est facile d'accéder dans les implémentations
Shape
.
class Shape {
Pour comprendre pourquoi ces variables membres posent des problèmes, il sera utile de comprendre comment les maillages triangulaires sont représentés dans pbrt. Tout d'abord, il y a la classe
TriangleMesh
, qui stocke les sommets et les tampons d'index pour le maillage entier:
struct TriangleMesh { int nTriangles, nVertices; std::vector<int> vertexIndices; std::unique_ptr<Point3f[]> p; std::unique_ptr<Normal3f[]> n;
Chaque triangle du maillage est représenté par la classe
Triangle
, qui hérite de
Shape
. L'idée est de garder le
Triangle
plus petit possible: ils ne stockent qu'un pointeur sur le maillage dont ils font partie, et un pointeur sur l'offset dans le tampon d'index auquel commencent les indices de ses sommets:
class Triangle : public Shape {
Lorsque les implémentations
Triangle
doivent trouver les positions de leurs sommets, il effectue l'indexation correspondante pour les obtenir de
TriangleMesh
.
Le problème avec
Shape
pbrt-v3 est que les valeurs qui y sont stockées sont les mêmes pour tous les triangles du maillage, il est donc préférable de les enregistrer de chaque maillage entier dans
TriangleMesh
, puis de donner Ă
Triangle
accès à une seule copie des valeurs communes.
Ce problème a été corrigé dans pbrt-next: la classe
Shape
base dans pbrt-next ne contient pas de tels membres, et donc chaque
Triangle
est de 24 octets de moins. La
Curve
géométrie utilise une stratégie similaire et bénéficie également d'une forme plus compacte.
Tampons triangulaires partagés
Malgré le fait que la scène de l'île de
Moana utilise largement l'instanciation d'objets pour répéter explicitement la géométrie, j'étais curieux de savoir à quelle fréquence la réutilisation des tampons d'index, des tampons de coordonnées de texture, etc. est utilisée pour diverses mailles de triangle.
J'ai écrit une petite classe qui hache ces tampons à la réception et les stocke dans le cache, et j'ai modifié
TriangleMesh
pour qu'il vérifie le cache et utilise la version déjà enregistrée de tout tampon redondant dont il a besoin. Le gain était très bon: j'ai réussi à me débarrasser de 4,7 Go de volume excédentaire, ce qui est bien plus que ce à quoi je m'attendais.
Crash avec std :: shared_ptr
Après toutes ces modifications, les statistiques rapportent environ 36 Go de mémoire allouée connue, et au début du rendu,
top
indique l'utilisation de 53 Go. Affaires.
J'avais peur d'une autre série de descentes lentes du
massif
pour savoir quelle mémoire allouée manquait dans les statistiques, mais une lettre d'
Arseny Kapulkin est apparue dans ma boîte de réception. Arseny m'a expliqué que
mes estimations précédentes de l'utilisation de la mémoire
GeometricPrimitive
étaient très fausses. J'ai dû le comprendre pendant longtemps, mais j'ai ensuite réalisé; un grand merci à Arseny pour avoir signalé l'erreur et les explications détaillées.
Avant d'écrire à Arseny, j'ai imaginé l'implémentation de
std::shared_ptr
comme suit: dans ces lignes, il y a un descripteur commun qui stocke le nombre de références et un pointeur sur l'objet placé lui-même:
template <typename T> class shared_ptr_info { std::atomic<int> refCount; T *ptr; };
Ensuite, j'ai suggéré que l'instance
shared_ptr
pointe simplement vers elle et l'utilise:
template <typename T> class shared_ptr {
En bref, j'ai supposé que
sizeof(shared_ptr<>)
est identique à la taille du pointeur et que 16 octets d'espace supplémentaire sont gaspillés sur chaque pointeur partagé.
Mais ce n'est pas le cas.
Dans mon implémentation système, le descripteur commun est de 32 octets de taille et 16 octets de
sizeof(shared_ptr<>)
. Par conséquent,
GeometricPrimitive
, qui se compose principalement de
std::shared_ptr
, est environ deux fois plus grand que mes estimations. Si vous vous demandez pourquoi cela s'est produit, ces deux messages Stack Overflow expliquent les raisons en détail:
1 et
2 .
Dans presque tous les cas d'utilisation de
std::shared_ptr
dans pbrt-next, il n'est pas nécessaire qu'ils soient des pointeurs partagés. En faisant un piratage fou, j'ai remplacé tout ce que je pouvais par
std::unique_ptr
, qui a en fait la mĂŞme taille qu'un pointeur normal. Par exemple, voici Ă quoi ressemble
SimplePrimitive
:
class SimplePrimitive : public Primitive {
La récompense s'est avérée plus importante que ce à quoi je m'attendais: l'utilisation de la mémoire au début du rendu est passée de 53 Go à 41 Go - une économie de 12 Go, complètement inattendue il y a quelques jours, et le montant total est presque la moitié de celui utilisé par pbrt-v3. Super!
Dans la partie suivante, nous terminerons enfin cette série d'articles - examinons la vitesse de rendu dans pbrt-next et discutons d'idées d'autres façons de réduire la quantité de mémoire nécessaire pour cette scène.
Partie 5
Pour résumer cette série d'articles, nous commencerons par explorer la vitesse de rendu de la scène insulaire du dessin animé Disney
"Moana" dans pbrt-next - la branche pbrt que j'utilise pour tester de nouvelles idées. Nous effectuerons des changements plus radicaux que ce qui est possible dans pbrt-v3, qui devrait adhérer au système décrit dans notre livre. Nous concluons par une discussion sur les domaines à améliorer, du plus simple au légèrement extrême.
Temps de rendu
Pbrt-next a apporté de nombreuses modifications aux algorithmes de transfert de lumière, y compris des modifications à l'échantillonnage BSDF et des améliorations aux algorithmes de roulette russe. Par conséquent, il trace plus de rayons que pbrt-v3 pour rendre cette scène, il n'est donc pas possible de comparer directement le temps d'exécution de ces deux moteurs de rendu. La vitesse est généralement proche, à une exception importante près: lors du rendu d'une scène d'îlot à partir de
Moana , illustré ci-dessous, pbrt-v3 passe 14,5% du temps d'exécution à rechercher des textures
ptex . Cela me paraissait tout à fait normal, mais pbrt-next ne passe que 2,2% du temps d'exécution. Tout cela est terriblement intéressant.
Après avoir étudié les statistiques, nous obtenons
1 :
pbrt-v3:
Ptex 20828624
Ptex 712324767
pbrt-next:
Ptex 3378524
Ptex 825826507
Comme nous le voyons dans pbrt-v3, la texture ptex est lue sur le disque en moyenne toutes les 34 recherches de texture. Dans pbrt-next, il n'est lu qu'après 244 recherches, c'est-à -dire que les E / S disque ont diminué d'environ 7 fois. J'ai suggéré que cela se produise parce que pbrt-next calcule les différences de rayons pour les rayons indirects, ce qui conduit à accéder à des niveaux de textures MIP plus élevés, ce qui crée à son tour une série d'accès plus intégrée au cache de texture ptex, réduit le nombre d'échecs de cache, et donc le nombre d'opérations d'E / S
2 . Une brève vérification a confirmé ma supposition: lorsque la différence de faisceau a été désactivée, la vitesse ptex est devenue bien pire.
L'augmentation de la vitesse ptex n'a pas seulement affecté le coût de l'informatique et des E / S. Dans un système à 32 CPU, pbrt-v3 n'a accéléré que 14,9 fois après avoir analysé la description de la scène. pbrt montre généralement une mise à l'échelle parallèle proche du linéaire, donc cela m'a plutôt déçu. En raison du nombre beaucoup plus faible de conflits pendant les verrous dans ptex, la version pbrt-next était 29,2 fois plus rapide dans un système à 32 processeurs et 94,9 fois plus rapide dans un système à 96 processeurs - nous revenons aux indicateurs qui nous conviennent.
Racines de la scène de l'île Moana rendues par pbrt avec une résolution de 2048x858 à 256 échantillons par pixel. Le temps de rendu total sur une instance de Google Compute Engine avec 96 CPU virtuels avec une fréquence de 2 GHz en pbrt-next est de 41 min 22 s. L'accélération due au mulithreading pendant le rendu était 94,9 fois. (Je ne comprends pas très bien ce qui se passe avec le mappage de relief.)Travailler pour l'avenir
Diminuer la quantité de mémoire utilisée dans des scènes aussi complexes est une expérience passionnante: économiser quelques gigaoctets avec un petit changement est beaucoup plus agréable que des dizaines de mégaoctets enregistrés dans une scène plus simple. J'ai une bonne liste de ce que j'espère apprendre à l'avenir, si le temps le permet. Voici un bref aperçu.
Mémoire tampon triangle encore décroissante
Même avec l'utilisation répétée de tampons qui stockent les mêmes valeurs pour plusieurs maillages triangulaires, une grande quantité de mémoire est toujours utilisée sous les tampons triangulaires. Voici une ventilation de l'utilisation de la mémoire pour différents types de tampons triangulaires dans la scène:
Tapez | La mémoire |
---|
Éléments de campagne | 2,5 Go |
Normal | 2,5 Go |
UV | 98 Mo |
Indices | 252 Mo |
Je comprends que rien ne peut être fait avec les positions des sommets transmises, mais pour d'autres données, il y a des économies. Il existe de nombreux
types de représentations de vecteurs normaux sous une forme économe en mémoire qui offre divers compromis entre la taille de la mémoire et le nombre de calculs. L'utilisation de l'une des représentations 24 bits ou 32 bits réduira l'espace occupé par les normales à 663 Mo et 864 Mo, ce qui nous fera économiser plus de 1,5 Go de RAM.
Dans cette scène, la quantité de mémoire utilisée pour stocker les coordonnées de texture et les tampons d'index est étonnamment petite. Je suppose que cela s'est produit en raison de la présence de nombreuses plantes générées de manière procédurale dans la scène et parce que toutes les variations du même type de plante ont la même topologie (et donc le tampon d'index) avec paramétrage (et donc coordonnées UV). À son tour, la réutilisation des tampons correspondants est assez efficace.
Pour d'autres scènes, l'échantillonnage des coordonnées UV 16 bits des textures ou l'utilisation de valeurs flottantes à demi-précision, selon leur plage de valeurs, peut être tout à fait approprié. Il semble que dans cette scène, toutes les valeurs de coordonnées de texture soient nulles ou égales à un, ce qui signifie qu'elles peuvent être représentées par un
bit - c'est-à -dire qu'il est possible de réduire la mémoire occupée de 32 fois. Cet état de fait est probablement dû à l'utilisation du format ptex pour la texturation, ce qui élimine le besoin d'atlas UV. Compte tenu de la faible quantité actuellement occupée par les coordonnées de texture, la mise en œuvre de cette optimisation n'est pas particulièrement nécessaire.
pbrt utilise toujours des entiers 32 bits pour les tampons d'index. Pour les petites mailles de moins de 256 sommets, seuls 8 bits par index sont suffisants, et pour les mailles de moins de 65 536 sommets, 16 bits peuvent être utilisés. Changer pbrt pour l'adapter à ce format ne sera pas très difficile. Si nous voulions optimiser au maximum, nous pourrions sélectionner exactement autant de bits que nécessaire pour représenter la plage requise dans les indices, tandis que le prix serait d'augmenter la complexité de trouver leurs valeurs. Malgré le fait que maintenant seulement un quart de gigaoctet de mémoire est utilisé pour les indices de vertex, cette tâche ne semble pas très intéressante par rapport aux autres.
Utilisation maximale de la mémoire de génération BVH
Plus tôt, nous n'avons pas encore discuté d'un autre détail de l'utilisation de la mémoire: juste avant le rendu, un pic à court terme de 10 Go de mémoire supplémentaire est utilisé. Cela se produit lorsque le (grand) BVH de la scène entière est construit. Le code de construction du BVH du rendu pbrt est écrit pour être exécuté en deux phases: tout d'abord, il crée un BVH avec la
représentation traditionnelle : deux pointeurs enfants vers chaque nœud. Après avoir construit l'arbre, il est converti en
un schéma efficace en mémoire dans lequel le premier enfant du nœud est situé directement derrière lui dans la mémoire, et le décalage par rapport au deuxième enfant est stocké sous forme d'entier.
Une telle séparation était nécessaire du point de vue de l'enseignement aux étudiants - il était beaucoup plus facile de comprendre les algorithmes pour construire BVH sans chaos associé à la nécessité de convertir l'arbre en une forme compacte pendant le processus de construction. Cependant, le résultat est ce pic d'utilisation de la mémoire; compte tenu de son influence sur la scène, l'élimination de ce problème semble séduisante.
Convertir des pointeurs en entiers
Dans diverses structures de données, il existe de nombreux pointeurs 64 bits qui peuvent être représentés comme des entiers 32 bits. Par exemple, chaque
SimplePrimitive
contient un pointeur sur un
Material
. La plupart des exemples de
Material
sont communs à de nombreuses primitives de la scène et il n'y en a jamais plus de quelques milliers; par conséquent, nous pouvons stocker un seul vecteur
vector
global
vector
tous les matériaux:
std::vector<Material *> allMaterials;
et il suffit de stocker des décalages entiers 32 bits pour ce vecteur dans
SimplePrimitive
, ce qui nous fait gagner 4 octets. La même astuce peut être utilisée avec un pointeur sur le
TriangleMesh
dans chaque
Triangle
, ainsi que dans de nombreux autres endroits.
Après un tel changement, il y aura une légère redondance dans l'accès aux panneaux eux-mêmes, et le système deviendra un peu moins compréhensible pour les étudiants essayant de comprendre son travail; en outre, c'est probablement le cas lorsque, dans le contexte de pbrt, il vaut mieux garder l'implémentation un peu plus compréhensible, mais au prix d'une optimisation incomplète de l'utilisation de la mémoire.
Hébergement basé sur les arènes (zones)
Pour chaque
Triangle
et primitive individuels, un appel distinct est effectué vers
new
(en fait
make_unique
, mais c'est la même chose). De telles allocations de mémoire conduisent à l'utilisation de la comptabilité des ressources supplémentaires, occupant environ cinq gigaoctets de mémoire, non comptabilisés dans les statistiques. Étant donné que la durée de vie de tous ces emplacements est la même - jusqu'à ce que le rendu soit terminé - nous pouvons nous débarrasser de cette comptabilité supplémentaire en les sélectionnant dans l'
arène mémoire .
Kaki vtable
Ma dernière idée est terrible, et je m'en excuse, mais elle m'a intrigué.
Chaque triangle de la scène a une charge supplémentaire d'au moins deux pointeurs vtable: un pour
Triangle
et un autre pour
SimplePrimitive
. C'est 16 octets. La scène de l'île de
Moana compte au total 146 162 124 triangles uniques, ce qui ajoute près de 2,2 Go de pointeurs vtables redondants.
Et si nous n'avions pas de classe de base abstraite pour
Shape
et que chaque implémentation de géométrie n'héritait de rien? Cela nous permettrait d'économiser de l'espace sur des pointeurs vtables, mais, bien sûr, lors du passage d'un pointeur vers une géométrie, nous ne saurions pas de quel type de géométrie il s'agit, c'est-à -dire qu'il serait inutile.
Il s'avère que sur les processeurs x86 modernes
, seuls 48 bits de pointeurs 64 bits sont réellement
utilisés . Par conséquent, il y a 16 bits supplémentaires que nous pouvons emprunter pour stocker des informations ... par exemple, comme la géométrie vers laquelle nous pointons. À son tour, en ajoutant un peu de travail, nous pouvons revenir à la possibilité de créer un analogue d'appels à des fonctions virtuelles.
Voici comment cela se produira: nous définissons d'abord une structure
ShapeMethods
qui contient des pointeurs vers des fonctions, comme
3 :
struct ShapeMethods { Bounds3f (*WorldBound)(void *);
Chaque implémentation de géométrie implémentera une fonction de contrainte, une fonction d'intersection, etc., recevant un analogue du pointeur
this
comme premier argument:
Bounds3f TriangleWorldBound(void *t) {
Nous aurions une table globale des structures
ShapeMethods
dans laquelle le
nième élément serait pour un type de géométrie d'index
n :
ShapeMethods shapeMethods[] = { { TriangleWorldBound, }, { CurveWorldBound, };
Lors de la création de la géométrie, nous encodons son type dans certains des bits inutilisés du pointeur de retour. Ensuite, en tenant compte du pointeur sur la géométrie dont nous voulons effectuer l'appel spécifique, nous extrayions ce type d'index du pointeur et l'
shapeMethods
comme index dans
shapeMethods
pour trouver le pointeur de fonction correspondant. Essentiellement, nous implémenterions vtable manuellement, en traitant nous-mêmes la répartition. Si nous faisions cela à la fois pour la géométrie et pour les primitives, alors nous économiserions 16 octets par
Triangle
, mais en mĂŞme temps, nous avons fait un chemin assez difficile.
Je suppose qu'un tel hack pour implémenter la gestion des fonctions virtuelles n'est pas nouveau, mais je n'ai pas pu trouver de liens vers celui-ci sur Internet. Voici la page Wikipedia sur les
pointeurs marqués , mais elle examine des choses comme le nombre de liens. Si vous connaissez un meilleur lien, envoyez-moi une lettre.
En partageant ce hack maladroit, je peux terminer la série de messages. Encore merci à Disney d'avoir publié cette scène. C'était incroyablement amusant de travailler avec; les engrenages dans ma tête continuent de tourner.
Remarques
- Au final, pbrt-next trace plus de rayons dans cette scène que pbrt-v3, ce qui explique probablement l'augmentation du nombre d'opérations de recherche.
- Les différences de rayons pour les rayons indirects dans pbrt-next sont calculées en utilisant le même hack utilisé dans l' extension de cache de texture pour pbrt-v3. , , .
- Rayshade . , C . Rayshade .