Inspiré par la
première victoire de l' analyse avec une description d'une scène insulaire du dessin animé
Moana de Disney, je suis allé plus loin dans l'étude de l'utilisation de la mémoire. Beaucoup pourrait encore être fait avec le délai d'exécution, mais j'ai décidé qu'il serait utile d'enquêter d'abord sur la situation.
J'ai commencé l'enquête d'exécution avec les statistiques pbrt intégrées; pbrt a un réglage manuel pour les allocations de mémoire importantes pour suivre l'utilisation de la mémoire, et une fois le rendu terminé, un rapport d'allocation de mémoire s'affiche. Voici à l'origine le rapport d'allocation de mémoire pour cette scène:
BVH- 9,01
1,44
MIP- 2,00
11,02
En ce qui concerne le runtime, les statistiques intégrées se sont avérées brèves et n'ont signalé que l'allocation de mémoire pour les objets connus de 24 Go.
top
indiqué qu'en fait, environ 70 Go de mémoire étaient utilisés, c'est-à-dire que 45 Go n'étaient pas pris en compte dans les statistiques. Les petits écarts sont tout à fait compréhensibles: les allocateurs de mémoire dynamique nécessitent de l'espace supplémentaire pour enregistrer l'utilisation des ressources, certains sont perdus en raison de la fragmentation, etc. Mais 45 Go? Quelque chose de mal se cache définitivement ici.
Pour comprendre ce qui nous manque (et pour nous assurer que nous avons suivi correctement), j'ai utilisé
massif pour tracer l'allocation réelle de la mémoire dynamique. C'est assez lent, mais au moins ça marche bien.
Primitifs
La première chose que j'ai trouvée en traçant le massif était deux lignes de code qui allouaient en mémoire des instances de la classe de base
Primitive
, qui n'est pas prise en compte dans les statistiques. Un petit oubli qui est
assez facile à corriger . Après cela, nous voyons ce qui suit:
Primitives 24,67
Oups Alors qu'est-ce qu'une primitive, et pourquoi toute cette mémoire?
pbrt distingue entre
Shape
, qui est une géométrie pure (sphère, triangle, etc.) et
Primitive
, qui est une combinaison de géométrie, de matériau, parfois la fonction du rayonnement et le milieu impliqué à l'intérieur et à l'extérieur de la surface de la géométrie.
Il existe
plusieurs options pour la classe de base
Primitive
:
GeometricPrimitive
, qui est un cas standard: une combinaison «vanille» de géométrie, de matériau, etc., ainsi que
TransformedPrimitive
, qui est une primitive avec des transformations qui lui sont appliquées, soit comme une instance d'un objet, soit pour déplacer des primitives avec des transformations qui changent avec le temps. Il s'avère que dans cette scène, ces deux types sont une perte d'espace.
GeometricPrimitive: 50% d'espace supplémentaire
Remarque: certaines hypothèses erronées sont faites dans cette analyse; ils sont révisés dans le quatrième post de la série .4,3 Go utilisés sur
GeometricPrimitive
. C'est drôle de vivre dans un monde où 4,3 Go de RAM utilisée n'est pas votre plus gros problème, mais voyons quand même où nous avons obtenu 4,3 Go de
GeometricPrimitive
. Voici les parties pertinentes de la définition de classe:
class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; };
Nous avons un
pointeur sur vtable , trois autres pointeurs, puis une
MediumInterface
contenant deux autres pointeurs d'une taille totale de 48 octets. Il n'y a que quelques mailles émettrices de lumière dans cette scène, donc
areaLight
presque toujours un pointeur nul, et aucun environnement n'affecte la scène, donc les deux pointeurs
mediumInterface
également nuls. Ainsi, si nous avions une implémentation spécialisée de la classe
Primitive
, qui pourrait être utilisée en l'absence des fonctions de rayonnement et de milieu, nous économiserions près de la moitié de l'espace disque occupé par
GeometricPrimitive
- dans notre cas, environ 2 Go.
Cependant, je ne l'ai pas corrigé et j'ai ajouté une nouvelle implémentation
Primitive
à pbrt. Nous nous efforçons de minimiser les différences entre le code source de pbrt-v3 sur github et le système décrit dans mon livre, pour une raison très simple - les garder synchronisés facilite la lecture du livre et l'utilisation du code. Dans ce cas, j'ai décidé que la toute nouvelle implémentation de
Primitive
, jamais mentionnée dans le livre, ferait trop de différence. Mais ce correctif apparaîtra certainement dans la nouvelle version de pbrt.
Avant de continuer, faisons un test de rendu:
Plage de l'île du film "Moana" rendu par pbrt-v3 avec une résolution de 2048x858 et 256 échantillons par pixel. Le temps de rendu total sur l'instance 12 cœurs / 24 threads de Google Compute Engine avec une fréquence de 2 GHz avec la dernière version de pbrt-v3 était de 2 heures 25 minutes 43 secondes.TransformedPrimitives: 95% d'espace gaspillé
La mémoire allouée sous 4,3 Go
GeometricPrimitive
été un coup assez douloureux, mais qu'en est-il de 17,4 Go sous
TransformedPrimitive
?
Comme mentionné ci-dessus,
TransformedPrimitive
utilisé à la fois pour les transformations avec un changement dans le temps et pour les instances d'objets. Dans les deux cas, nous devons appliquer une transformation supplémentaire à la
Primitive
existante. Il n'y a que deux membres dans la classe
TransformedPrimitive
:
std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld;
Jusqu'ici tout va bien: un pointeur vers une primitive et une transformation qui change avec le temps. Mais qu'est-ce qui est réellement stocké dans
AnimatedTransform
?
const Transform *startTransform, *endTransform; const Float startTime, endTime; const bool actuallyAnimated; Vector3f T[2]; Quaternion R[2]; Matrix4x4 S[2]; bool hasRotation; struct DerivativeTerm {
Outre les pointeurs vers deux matrices de transition et le temps qui leur est associé, il existe également une décomposition des matrices en composants de transport, de rotation et de mise à l'échelle, ainsi que des valeurs précalculées utilisées pour limiter le volume occupé par le déplacement des boîtes englobantes (voir la section 2.4.9 de notre livre
Rendu physique ). Tout cela représente jusqu'à 456 octets.
Mais
rien ne bouge dans cette scène. Du point de vue des transformations pour les instances d'objets, nous avons besoin d'un pointeur vers la transformation, et les valeurs de décomposition et de boîtes de délimitation mobiles ne sont pas nécessaires. (Autrement dit, seuls 8 octets sont nécessaires). Si vous créez une implémentation
Primitive
distincte pour des instances fixes d'objets, 17,4 Go sont compressés au total à 900 Mo (!).
Quant à
GeometricPrimitive
, le corriger est un changement non trivial par rapport à ce qui est décrit dans le livre, nous allons donc également le reporter à la prochaine version de pbrt. Au moins, nous comprenons maintenant ce qui se passe avec le chaos de 24,7 Go de mémoire
Primitive
.
Problème avec le cache de conversion
Le deuxième plus grand bloc de mémoire non défini défini par le massif était
TransformCache
, qui occupait environ 16 Go. (Voici un lien vers l'
implémentation d'origine .) L'idée est que la même matrice de transformation est souvent utilisée plusieurs fois dans la scène, il est donc préférable d'en avoir une seule copie en mémoire, de sorte que tous les éléments l'utilisant stockent simplement un pointeur vers la même chose conversion.
TransformCache
utilisé
std::map
pour stocker le cache, et massif a signalé que 6 sur 16 Go étaient utilisés pour les nœuds d'arbre noir-rouge dans
std::map
. C'est énorme: 60% de ce volume est utilisé pour les transformations elles-mêmes. Regardons la déclaration de cette distribution:
std::map<Transform, std::pair<Transform *, Transform *>> cache;
Ici, le travail est parfaitement fait:
Transform
entièrement utilisé comme clés de distribution. Encore mieux, pbrt
Transform
stocke deux matrices 4x4 (la matrice de transformation et sa matrice inverse), ce qui entraîne le stockage de 128 octets dans chaque nœud de l'arbre. Tout cela est absolument inutile pour la valeur stockée pour lui.
Peut-être qu'une telle structure est tout à fait normale dans un monde où il est important pour nous que la même matrice de transformation soit utilisée dans des centaines ou des milliers de primitives, et en général il n'y a pas beaucoup de matrices de transformation. Mais pour une scène avec un tas de matrices de transformation pour la plupart uniques, comme dans notre cas, c'est juste une approche terrible.
Outre le fait que l'espace est gaspillé en clés, une recherche dans
std::map
pour parcourir l'arbre rouge-noir implique beaucoup d'opérations de pointeur, il semble donc logique d'essayer quelque chose de complètement nouveau. Heureusement, peu de choses sont écrites sur
TransformCache
dans le livre, il est donc tout à fait acceptable de le réécrire complètement.
Et enfin, avant de commencer: après avoir examiné la signature de la méthode
Lookup()
, un autre problème apparaît:
void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse)
Lorsque la fonction appelante fournit
Transform
, le cache enregistre et renvoie des pointeurs de conversion égaux à ceux transmis, mais transmet également la matrice inverse. Pour rendre cela possible, dans l'implémentation d'origine, lors de l'ajout d'une transformation dans le cache, la matrice inverse est toujours calculée et stockée afin qu'elle puisse être renvoyée.
La chose stupide ici est que la plupart des homologues de numérotation qui utilisent le cache de transformation n'interrogent pas ou n'utilisent pas la matrice inverse. Autrement dit, différents types de mémoire sont gaspillés dans des transformations inverses inapplicables.
Dans la
nouvelle implémentation , les améliorations suivantes sont ajoutées:
- Il utilise une table de hachage pour accélérer la recherche et ne nécessite pas de stockage d'autre chose que le tableau
Transform *
, ce qui, en substance, réduit la quantité de mémoire utilisée à la valeur vraiment nécessaire pour stocker toutes les Transform
. - La signature de la méthode de recherche ressemble maintenant à
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
; à un endroit où la fonction appelante veut obtenir la matrice inverse du cache, elle appelle simplement Lookup()
deux fois.
Pour le hachage, j'ai utilisé la
fonction de hachage FNV1a . Après sa mise en œuvre, j'ai trouvé
le post d'Aras sur les fonctions de hachage ; peut-être que j'aurais dû utiliser xxHash ou CityHash parce que leurs performances sont meilleures; peut-être qu'un jour ma honte gagnera et je la réparerai.
Grâce à la nouvelle implémentation de
TransformCache
, le temps de démarrage global du système a considérablement diminué - jusqu'à 21 min 42 s. Autrement dit, nous avons enregistré encore 5 minutes 7 secondes, ou accéléré 1,27 fois. De plus, une utilisation plus efficace de la mémoire a réduit l'espace occupé par les matrices de transformation de 16 à 5,7 Go, ce qui est presque égal à la quantité de données stockées. Cela nous a permis de ne pas essayer de profiter du fait qu'elles ne sont pas réellement projectives, et de stocker des matrices 3x4 au lieu de 4x4. (Dans le cas habituel, je serais sceptique quant à l'importance de ce type d'optimisation, mais ici, cela nous ferait économiser plus d'un gigaoctet - beaucoup de mémoire! Cela vaut vraiment la peine de le faire dans le rendu de production.)
Petite optimisation des performances à compléter
Une structure
TransformedPrimitive
trop généralisée nous coûte à la fois de la mémoire et du temps: le profileur a déclaré qu'une partie importante du temps au démarrage était consacrée à la fonction
AnimatedTransform::Decompose()
, qui décompose la transformation de la matrice en rotation, transfert et échelle du quaternion. Comme rien ne bouge dans cette scène, ce travail est inutile et une vérification approfondie de la mise en œuvre d'
AnimatedTransform
a montré qu'aucune de ces valeurs n'est accessible si les deux matrices de transformation sont réellement identiques.
En ajoutant
deux lignes au constructeur pour que les décompositions des transformations ne soient pas effectuées lorsqu'elles ne sont pas nécessaires, nous avons économisé encore 1 min 31 de l'heure de début: en conséquence, nous sommes arrivés à 20 min 9 s, c'est-à-dire qu'en général, elles ont accéléré 1,73 fois.
Dans le
prochain article, nous aborderons sérieusement l'analyseur et analyserons ce qui est devenu important lorsque nous avons accéléré le travail d'autres parties.