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 {
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 {
(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 {
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 {
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 {
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 {
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:
Tipo | El recuerdo |
---|
Líneas de pedido | 2,5 GB |
Normal | 2,5 GB |
UV | 98 MB |
Índices | 252 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 *);
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) {
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
- 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.
- 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. , , .
- Rayshade . , C . Rayshade .