Optimización de la representación de una escena de la caricatura de Disney "Moana". Partes 4 y 5

imagen

Tengo una rama pbrt, que utilizo para probar nuevas ideas, implementar ideas interesantes de artículos científicos y, en general, para estudiar todo lo que generalmente da como resultado una nueva edición del libro de representación basada en la física . A diferencia de pbrt-v3 , que nos esforzamos por mantener lo más cerca posible del sistema descrito en el libro, en este hilo podemos cambiar cualquier cosa. Hoy veremos cómo los cambios más radicales en el sistema reducirán significativamente el uso de la memoria en la escena con la isla de la caricatura de Disney "Moana" .

Nota sobre la metodología: en las tres publicaciones anteriores, todas las estadísticas se midieron para la versión WIP (Work In Progress) de la escena con la que trabajé antes de su lanzamiento. En este artículo, pasaremos a la versión final, que es un poco más complicada.

Al renderizar la última escena de la isla desde Moana , se usaron 81 GB de RAM para almacenar la descripción de la escena para pbrt-v3. Actualmente, pbrt-next usa 41 GB, aproximadamente la mitad. Para obtener este resultado, fue suficiente para hacer pequeños cambios que se extendieron a varios cientos de líneas de código.

Primitivas reducidas


Recordemos que en pbrt Primitive es una combinación de geometría, su material, la función de radiación (si es una fuente de luz) y registra sobre el ambiente dentro y fuera de la superficie. En pbrt-v3, GeometricPrimitive almacena lo siguiente:

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

Como se indicó anteriormente , la mayor parte del tiempo areaLight es nullptr , y MediumInterface contiene un par de nullptr . Entonces, en pbrt-next agregué una opción Primitive llamada SimplePrimitive , que solo almacena punteros a la geometría y al material. Siempre que sea posible, se usa GeometricPrimitive posible en lugar de GeometricPrimitive :

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

Para las instancias de objetos no animados, ahora tenemos TransformedPrimitive , que solo almacena un puntero a la primitiva y la transformación, lo que nos ahorra unos 500 bytes de espacio desperdiciado que la instancia de AnimatedTransform agregó al renderizador TransformedPrimitive pbrt-v3.

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

(Existe AnimatedPrimitive en caso de que necesite una conversión animada a pbrt-next).

Después de todos estos cambios, las estadísticas informan que solo se usan 7.8 GB en Primitive , en lugar de 28.9 GB en pbrt-v3. Aunque es genial que hayamos ahorrado 21 GB, no es tanto como la disminución que podríamos esperar de estimaciones anteriores; Volveremos a esta discrepancia al final de esta parte.

Geometría reducida


Además, pbrt-next redujo significativamente la cantidad de memoria ocupada por la geometría: el espacio utilizado para los triángulos de malla disminuyó de 19.4 GB a 9.9 GB, y el espacio de almacenamiento para curvas de 1.4 a 1.1 GB. Un poco más de la mitad de estos ahorros provino de la simplificación de la clase Shape básica.

En pbrt-v3, Shape trae consigo varios miembros que se transfieren a todas las implementaciones de Shape ; estos son varios aspectos a los que es conveniente acceder en las implementaciones de Shape .

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

Para comprender por qué estas variables miembro causan problemas, será útil comprender cómo se representan las mallas triangulares en pbrt. Primero, está la clase TriangleMesh , que almacena los vértices y los búferes de índice para toda la malla:

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

Cada triángulo en la malla está representado por la clase Triangle , que hereda de Shape . La idea es mantener el Triangle más pequeño posible: solo almacenan un puntero a la malla de la que forman parte, y un puntero al desplazamiento en el búfer de índice en el que comienzan los índices de sus vértices:

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

Cuando las implementaciones de Triangle necesitan encontrar las posiciones de sus vértices, realiza la indexación correspondiente para obtenerlas de TriangleMesh .

El problema con Shape pbrt-v3 es que los valores almacenados en él son los mismos para todos los triángulos de la malla, por lo que es mejor guardarlos de cada malla completa en TriangleMesh , y luego dar a Triangle acceso a una sola copia de los valores comunes.

Este problema se solucionó en pbrt-next: la clase Shape básica en pbrt-next no contiene dichos miembros y, por lo tanto, cada Triangle tiene 24 bytes menos. La Curve geometría utiliza una estrategia similar y también se beneficia de una forma más compacta.

Tampones de triángulo compartido


A pesar del hecho de que la escena de la isla de Moana hace un uso extensivo de la creación de instancias de objetos para repetir la geometría explícitamente, tenía curiosidad por la frecuencia con la que se utilizan la reutilización de los búferes de índice, los búferes de coordenadas de textura, etc. para varias mallas triangulares.

Escribí una pequeña clase que procesa estos búferes al recibirlos y los almacena en el caché, y modifiqué TriangleMesh para que verifique el caché y use la versión ya guardada de cualquier búfer redundante que necesite. La ganancia fue muy buena: logré deshacerme de 4.7 GB de exceso de volumen, que es mucho más de lo que esperaba.

Bloqueo con std :: shared_ptr


Después de todos estos cambios, las estadísticas informan sobre 36 GB de memoria asignada conocida y, al comienzo de la representación, la top indica el uso de 53 GB. Asuntos

Tenía miedo de otra serie de corridas lentas de massif para descubrir qué memoria asignada falta en las estadísticas, pero luego apareció una carta de Arseny Kapulkin en mi bandeja de entrada. Arseny me explicó que mis estimaciones anteriores del uso de la memoria GeometricPrimitive estaban muy equivocadas. Tuve que resolverlo durante mucho tiempo, pero luego me di cuenta; Muchas gracias a Arseny por señalar el error y explicaciones detalladas.

Antes de escribir a Arseny, imaginé la implementación de std::shared_ptr siguiente manera: en estas líneas hay un descriptor común que almacena el recuento de referencias y un puntero al propio objeto colocado:

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

Luego sugerí que la instancia shared_ptr solo lo señala y lo usa:

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

En resumen, supuse que sizeof(shared_ptr<>) es el mismo que el tamaño del puntero y que se desperdician 16 bytes de espacio adicional en cada puntero compartido.

Pero esto no es así.

En la implementación de mi sistema, el descriptor común tiene un tamaño de 32 bytes y un tamaño de 16 bytes sizeof(shared_ptr<>) . Por lo tanto, GeometricPrimitive , que consiste principalmente en std::shared_ptr , es aproximadamente el doble de mis estimaciones. Si se pregunta por qué sucedió esto, estas dos publicaciones de Stack Overflow explican las razones con gran detalle: 1 y 2 .

En casi todos los casos de uso de std::shared_ptr en pbrt-next, no es necesario que compartan punteros. Mientras hacía un pirateo loco, reemplacé todo lo que pude con std::unique_ptr , que en realidad tiene el mismo tamaño que un puntero normal. Por ejemplo, así es SimplePrimitive ve SimplePrimitive ahora:

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

La recompensa resultó ser mayor de lo que esperaba: el uso de memoria al comienzo de la representación disminuyó de 53 GB a 41 GB, un ahorro de 12 GB, completamente inesperado hace unos días, y el volumen total es casi la mitad del utilizado por pbrt-v3. Genial

En la siguiente parte, finalmente completaremos esta serie de artículos: examine la velocidad de representación en pbrt-next y discuta ideas para otras formas de reducir la cantidad de memoria necesaria para esta escena.

Parte 5


Para resumir esta serie de artículos, comenzaremos explorando la velocidad de representación de la escena de la isla de la caricatura de Disney "Moana" en pbrt-next, la rama pbrt que uso para probar nuevas ideas. Haremos cambios más radicales de lo que es posible en pbrt-v3, que debería adherirse al sistema descrito en nuestro libro. Concluimos con una discusión de áreas para mejoras adicionales, desde la más simple hasta la más extrema.

Tiempo de renderizado


Pbrt-next realizó muchos cambios en los algoritmos de transferencia de luz, incluidos cambios en el muestreo BSDF y mejoras en los algoritmos de ruleta rusa. Como resultado, traza más rayos que pbrt-v3 para renderizar esta escena, por lo que no es posible comparar directamente el tiempo de ejecución de estos dos renderizadores. La velocidad generalmente es cercana, con una excepción importante: cuando se representa una escena de isla desde Moana , como se muestra a continuación, pbrt-v3 gasta el 14.5% del tiempo de ejecución buscando texturas ptex . Esto solía parecer bastante normal para mí, pero pbrt-next solo gasta el 2.2% del tiempo de ejecución. Todo esto es terriblemente interesante.

Después de estudiar las estadísticas, obtenemos 1 :

pbrt-v3:
Ptex 20828624
Ptex 712324767

pbrt-next:
Ptex 3378524
Ptex 825826507


Como vemos en pbrt-v3, la textura de ptex se lee del disco en promedio cada 34 búsquedas de textura. En pbrt-next, se lee solo después de cada 244 búsquedas, es decir, la E / S de disco ha disminuido aproximadamente 7 veces. Sugerí que esto sucede porque pbrt-next calcula las diferencias de rayos para los rayos indirectos, y esto conduce al acceso a niveles más altos de texturas MIP, lo que a su vez crea una serie más integrada de accesos al caché de texturas ptex, reduce el número de errores de caché y, por lo tanto, el número de operaciones de E / S 2 . Una breve comprobación confirmó mi suposición: cuando se apagó la diferencia del haz, la velocidad de ptex empeoró.

El aumento en la velocidad de ptex no solo ha afectado el costo de la computación y las E / S. En un sistema de 32 CPU, pbrt-v3 solo aceleró 14.9 veces después de analizar la descripción de la escena. pbrt generalmente muestra una escala paralela lineal, por lo que me decepcionó bastante. Debido al número mucho menor de conflictos durante los bloqueos en ptex, la versión pbrt-next fue 29.2 veces más rápida en un sistema con 32 CPU y 94.9 veces más rápida en un sistema con 96 CPU: volvemos a los indicadores que nos convienen.


Raíces de la escena de la isla Moana representada por pbrt con una resolución de 2048x858 a 256 muestras por píxel. El tiempo total de representación en una instancia de Google Compute Engine con 96 CPU virtuales con una frecuencia de 2 GHz en pbrt-next es de 41 min 22 s. La aceleración debido al mulithreading durante el renderizado fue 94.9 veces. (No entiendo muy bien qué está sucediendo con el mapeo de relieve).

Trabajar para el futuro


Disminuir la cantidad de memoria utilizada en escenas tan complejas es una experiencia emocionante: guardar unos pocos gigabytes con un pequeño cambio es mucho más agradable que decenas de megabytes guardados en una escena más simple. Tengo una buena lista de lo que espero aprender en el futuro, si el tiempo lo permite. Aquí hay un resumen rápido.

Disminución adicional de la memoria intermedia del triángulo


Incluso con el uso repetido de buffers que almacenan los mismos valores para varias mallas triangulares, todavía se usa mucha memoria debajo de los buffers triangulares. Aquí hay un desglose del uso de memoria para varios tipos de buffers triangulares en la escena:

TipoEl recuerdo
Líneas de pedido2,5 GB
Normal2,5 GB
UV98 MB
Índices252 MB

Entiendo que no se puede hacer nada con las posiciones de vértice transmitidas, pero para otros datos hay ahorros. Hay muchos tipos de representaciones de vectores normales en una forma de memoria eficiente que proporciona varias compensaciones entre el tamaño de la memoria / número de cálculos. El uso de una de las representaciones de 24 o 32 bits reducirá el espacio ocupado por las normales a 663 MB y 864 MB, lo que nos ahorrará más de 1.5 GB de RAM.

En esta escena, la cantidad de memoria utilizada para almacenar coordenadas de textura y búferes de índice es sorprendentemente pequeña. Supongo que esto sucedió debido a la presencia de muchas plantas generadas por procedimientos en la escena y al hecho de que todas las variaciones del mismo tipo de planta tienen la misma topología (y, por lo tanto, el búfer de índice) con la parametrización (y, por lo tanto, las coordenadas UV). A su vez, reutilizar buffers coincidentes es bastante eficiente.

Para otras escenas, el muestreo de coordenadas UV de texturas de 16 bits o el uso de valores flotantes de media precisión, dependiendo de su rango de valores, puede ser bastante adecuado. Parece que en esta escena, todos los valores de coordenadas de textura son cero o uno, lo que significa que pueden representarse por un bit , es decir, es posible reducir la memoria ocupada 32 veces. Este estado de cosas probablemente surgió debido al uso del formato ptex para texturizar, lo que elimina la necesidad de atlas UV. Dada la pequeña cantidad actualmente ocupada por las coordenadas de textura, la implementación de esta optimización no es particularmente necesaria.

pbrt siempre usa enteros de 32 bits para los búferes de índice. Para mallas pequeñas de menos de 256 vértices, solo son suficientes 8 bits por índice, y para mallas de menos de 65,536 vértices, se pueden usar 16 bits. Cambiar pbrt para adaptarlo a este formato no será muy difícil. Si quisiéramos optimizar al máximo, podríamos seleccionar exactamente tantos bits como sea necesario para representar el rango requerido en los índices, mientras que el precio sería aumentar la complejidad de encontrar sus valores. A pesar de que ahora solo se usa un cuarto de gigabyte de memoria para los índices de vértice, esta tarea no parece muy interesante en comparación con otras.

Uso pico de memoria de compilación BVH


Anteriormente, no discutimos otro detalle del uso de la memoria: inmediatamente antes del renderizado, se produce un pico a corto plazo de 10 GB de memoria adicionalmente utilizada. Esto sucede cuando se construye el BVH (grande) de toda la escena. El código para construir el BVH del renderizador pbrt está escrito para ejecutarse en dos fases: primero, crea un BVH con la representación tradicional : dos punteros secundarios a cada nodo. Después de construir el árbol, se convierte en un esquema de memoria eficiente en el que el primer hijo del nodo se encuentra directamente detrás de él en la memoria, y el desplazamiento al segundo hijo se almacena como un entero.

Tal separación era necesaria desde el punto de vista de la enseñanza de los estudiantes: era mucho más fácil comprender los algoritmos para construir BVH sin el caos asociado con la necesidad de convertir el árbol en una forma compacta durante el proceso de construcción. Sin embargo, el resultado es este pico en el uso de memoria; Teniendo en cuenta su influencia en la escena, la eliminación de este problema parece atractiva.

Convertir punteros a enteros


En varias estructuras de datos, hay muchos punteros de 64 bits que se pueden representar como enteros de 32 bits. Por ejemplo, cada SimplePrimitive contiene un puntero a un Material . La mayoría de las instancias de Material son comunes a muchos primitivos en la escena y nunca hay más de unos pocos miles; por lo tanto, podemos almacenar un único vector vector global vector todos los materiales:

 std::vector<Material *> allMaterials; 

y simplemente almacene compensaciones de enteros de 32 bits para este vector en SimplePrimitive , lo que nos ahorra 4 bytes. El mismo truco se puede usar con un puntero a TriangleMesh en cada Triangle , así como en muchos otros lugares.

Después de tal cambio, habrá una ligera redundancia en el acceso a los signos en sí mismos, y el sistema será un poco menos comprensible para los estudiantes que intenten comprender su trabajo; Además, este es probablemente el caso cuando, en el contexto de pbrt, es mejor mantener la implementación un poco más comprensible, aunque a costa de una optimización incompleta del uso de la memoria.

Alojamiento basado en arenas (áreas)


Para cada Triangle individual y primitivo, se realiza una llamada separada a new (en realidad, make_unique , pero esto es lo mismo). Dichas asignaciones de memoria conducen al uso de contabilidad de recursos adicionales, que ocupan unos cinco gigabytes de memoria, no contabilizados en las estadísticas. Dado que la vida útil de todas estas ubicaciones es la misma, hasta que se complete el renderizado, podemos deshacernos de esta contabilidad adicional seleccionándolas del campo de la memoria .

Vtable caqui


Mi última idea es terrible, y me disculpo por ello, pero ella me intrigó.

Cada triángulo en la escena tiene una carga adicional de al menos dos punteros vtable: uno para Triangle y otro para SimplePrimitive . Esto es de 16 bytes. La escena de la isla de Moana tiene un total de 146 162 124 triángulos únicos, que agrega casi 2.2 GB de punteros vtable redundantes.

¿Qué pasaría si no tuviéramos una clase base abstracta para Shape y cada implementación de geometría no heredara de nada? Esto nos ahorraría espacio en punteros vtable, pero, por supuesto, al pasar un puntero a una geometría, no sabríamos qué tipo de geometría es, es decir, sería inútil.

Resulta que en las CPU modernas x86 , solo se utilizan 48 bits de punteros de 64 bits . Por lo tanto, hay 16 bits adicionales que podemos pedir prestados para almacenar información ... por ejemplo, como la geometría a la que apuntamos. A su vez, agregando un poco de trabajo, podemos regresar a la posibilidad de crear un análogo de llamadas a funciones virtuales.

Así es como sucederá: primero definimos una estructura ShapeMethods que contiene punteros a funciones, como 3 :

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

Cada implementación de geometría implementará una función de restricción, una función de intersección, y así sucesivamente, recibiendo un análogo del puntero this como primer argumento:

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

Tendríamos una tabla global de estructuras ShapeMethods en la que el enésimo elemento sería para un tipo de geometría con índice n :

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

Al crear geometría, codificamos su tipo en algunos de los bits no utilizados del puntero de retorno. Luego, teniendo en cuenta el puntero a la geometría cuya llamada específica queremos realizar, extraeríamos este índice de tipo del puntero y lo shapeMethods como índice en shapeMethods para encontrar el puntero de función correspondiente. Esencialmente, implementaríamos vtable manualmente, procesando el despacho nosotros mismos. Si hiciéramos esto tanto para la geometría como para las primitivas, ahorraríamos 16 bytes por Triangle , pero al mismo tiempo lo hicimos de una manera bastante difícil.

Supongo que tal truco para implementar la administración de funciones virtuales no es nuevo, pero no pude encontrar enlaces a él en Internet. Aquí está la página de Wikipedia sobre punteros etiquetados , pero analiza cosas como el conteo de enlaces. Si conoce un enlace mejor, envíeme una carta.

Al compartir este truco incómodo, puedo terminar la serie de publicaciones. Nuevamente, muchas gracias a Disney por publicar esta escena. Fue increíblemente divertido trabajar con él; los engranajes en mi cabeza siguen girando.

Notas


  1. Al final, pbrt-next traza más rayos en esta escena que pbrt-v3, lo que probablemente explica el aumento en el número de operaciones de búsqueda.
  2. Las diferencias de rayos para los rayos indirectos en pbrt-next se calculan utilizando el mismo truco utilizado en la extensión de caché de textura para pbrt-v3. , , .
  3. Rayshade . , C . Rayshade .

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


All Articles