Inspirado por la
primera victoria de análisis con una descripción de una escena de la isla de
la caricatura de
Moana de Disney, profundicé en el estudio del uso de la memoria. Aún se podía hacer mucho con el tiempo de entrega, pero decidí que sería útil investigar primero la situación.
Comencé la investigación de tiempo de ejecución con las estadísticas pbrt incorporadas; pbrt tiene una configuración manual para asignaciones de memoria significativas para rastrear el uso de memoria, y después de que se completa la representación, se muestra un informe de asignación de memoria. Esto es lo que originalmente era el informe de asignación de memoria para esta escena:
BVH- 9,01
1,44
MIP- 2,00
11,02
En cuanto al tiempo de ejecución, las estadísticas integradas resultaron ser breves y solo informaron la asignación de memoria para objetos conocidos de 24 GB de tamaño.
top
dijo que, de hecho, se utilizaron unos 70 GB de memoria, es decir, 45 GB no se tuvieron en cuenta en las estadísticas. Las pequeñas desviaciones son bastante comprensibles: los asignadores de memoria dinámica requieren espacio adicional para registrar el uso de recursos, algunos se pierden debido a la fragmentación, etc. ¿Pero 45 GB? Algo malo definitivamente se esconde aquí.
Para comprender lo que nos falta (y para asegurarnos de que lo rastreamos correctamente), utilicé el
macizo para rastrear la asignación real de la memoria dinámica. Es bastante lento, pero al menos funciona bien.
Primitivas
Lo primero que encontré al rastrear el macizo fue dos líneas de código que asignaban instancias de la clase base
Primitive
, que no se tiene en cuenta en las estadísticas, en la memoria. Un pequeño descuido que es
bastante fácil de solucionar . Después de eso, vemos lo siguiente:
Primitives 24,67
Ups Entonces, ¿qué es un primitivo y por qué toda esta memoria?
pbrt distingue entre
Shape
, que es geometría pura (esfera, triángulo, etc.) y
Primitive
, que es una combinación de geometría, material, a veces la función de la radiación y el medio involucrado dentro y fuera de la superficie de la geometría.
Hay
varias opciones para la clase base
Primitive
:
GeometricPrimitive
, que es un caso estándar: una combinación "vainilla" de geometría, material, etc., así como
TransformedPrimitive
, que es una primitiva con transformaciones aplicadas, ya sea como una instancia de un objeto o para mover primitivas con transformaciones que cambian con el tiempo. Resulta que en esta escena ambos tipos son una pérdida de espacio.
Geométrico: 50% de espacio extra
Nota: se hacen algunas suposiciones erróneas en este análisis; son revisados en el cuarto post de la serie .4,3 GB utilizados en
GeometricPrimitive
. Es divertido vivir en un mundo donde 4.3 GB de RAM usada no es su mayor problema, pero veamos de dónde obtuvimos 4.3 GB de
GeometricPrimitive
. Aquí están las partes relevantes de la definición de clase:
class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; };
Tenemos un
puntero a vtable , tres punteros más, y luego una
MediumInterface
contiene dos punteros más con un tamaño total de 48 bytes. Solo hay unas pocas mallas emisoras de luz en esta escena, por lo que
areaLight
casi siempre es un puntero nulo, y no hay entorno que afecte la escena, por lo que ambos punteros de
mediumInterface
también
mediumInterface
nulos. Por lo tanto, si tuviéramos una implementación especializada de la clase
Primitive
, que podría usarse en ausencia de las funciones de radiación y medio, ahorraríamos casi la mitad del espacio en disco ocupado por
GeometricPrimitive
, en nuestro caso, aproximadamente 2 GB.
Sin embargo, no lo arreglé y agregué una nueva implementación
Primitive
a pbrt. Nos esforzamos por minimizar las diferencias entre el código fuente pbrt-v3 en github y el sistema descrito en mi libro, por una razón muy simple: mantenerlos sincronizados facilita la lectura del libro y el trabajo con el código. En este caso, decidí que la implementación completamente nueva de
Primitive
, nunca mencionada en el libro, sería una gran diferencia. Pero esta solución definitivamente aparecerá en la nueva versión de pbrt.
Antes de continuar, hagamos un render de prueba:
Playa de la isla de la película "Moana" renderizada por pbrt-v3 con una resolución de 2048x858 y 256 muestras por píxel. El tiempo total de representación en la instancia de 12 núcleos / 24 hilos de Google Compute Engine con una frecuencia de 2 GHz con la última versión de pbrt-v3 fue de 2 horas 25 minutos 43 segundos.Primitivos transformados: 95% de espacio desperdiciado
La memoria asignada por debajo de 4.3 GB
GeometricPrimitive
fue un éxito bastante doloroso, pero ¿qué pasa con 17.4 GB bajo
TransformedPrimitive
?
Como se mencionó anteriormente,
TransformedPrimitive
usa tanto para transformaciones con un cambio en el tiempo como para instancias de objetos. En ambos casos, necesitamos aplicar una transformación adicional al
Primitive
existente. Solo hay dos miembros en la clase
TransformedPrimitive
:
std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld;
Hasta ahora todo bien: un puntero a una primitiva y una transformación que cambia con el tiempo. Pero, ¿qué se almacena realmente en
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 {
Además de los punteros a dos matrices de transición y el tiempo asociado con ellas, también hay una descomposición de las matrices en componentes de transporte, rotación y escala, así como valores precalculados utilizados para limitar el volumen ocupado moviendo cuadros delimitadores (consulte la sección 2.4.9 de nuestro libro).
Representación basada en la física ). Todo esto suma hasta 456 bytes.
Pero
nada se mueve en esta escena. Desde el punto de vista de las transformaciones para instancias de objetos, necesitamos un puntero a la transformación, y no se necesitan los valores para descomposición y cuadros de límite móviles. (Es decir, solo se necesitan 8 bytes). Si crea una implementación
Primitive
separada para instancias fijas de objetos, 17.4 GB se comprimen en total a 900 MB (!).
En cuanto a
GeometricPrimitive
, arreglarlo es un cambio no trivial en comparación con lo que se describe en el libro, por lo que también lo pospondremos en la próxima versión de pbrt. Al menos ahora entendemos lo que está sucediendo con el caos de 24.7 GB de memoria
Primitive
.
Problemas con la caché de conversión
El siguiente bloque más grande de memoria no contabilizada definido por macizo fue
TransformCache
, que ocupaba aproximadamente 16 GB. (Aquí hay un enlace a la
implementación original ). La idea es que la misma matriz de transformación a menudo se usa varias veces en la escena, por lo que es mejor tener una sola copia en la memoria, para que todos los elementos que la usan simplemente almacenen un puntero a la misma cosa conversión
TransformCache
usó
std::map
para almacenar el caché, y macizo informó que 6 de 16 GB se usaron para nodos de árbol negro-rojo en
std::map
. Este es un lote horrible: el 60% de este volumen se usa para las transformaciones en sí. Veamos la declaración para esta distribución:
std::map<Transform, std::pair<Transform *, Transform *>> cache;
Aquí, el trabajo se hace a la perfección:
Transform
utiliza por completo como claves para la distribución. Aún mejor, pbrt
Transform
almacena dos matrices 4x4 (la matriz de transformación y su matriz inversa), lo que resulta en 128 bytes almacenados en cada nodo del árbol. Todo esto es absolutamente innecesario para el valor almacenado para él.
Quizás tal estructura es bastante normal en un mundo donde es importante para nosotros que se use la misma matriz de transformación en cientos o miles de primitivas, y en general no hay muchas matrices de transformación. Pero para una escena con un montón de matrices de transformación en su mayoría únicas, como en nuestro caso, este es solo un enfoque terrible.
Además del hecho de que el espacio se desperdicia en las teclas, una búsqueda en
std::map
para atravesar el árbol rojo-negro implica muchas operaciones de puntero, por lo que parece lógico intentar algo completamente nuevo. Afortunadamente, poco se escribe sobre
TransformCache
en el libro, por lo que es completamente aceptable reescribirlo por completo.
Y por último, antes de comenzar: después de examinar la firma del método
Lookup()
, se hace evidente otro problema:
void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse)
Cuando la función de llamada proporciona
Transform
, el caché guarda y devuelve punteros de conversión iguales al pasado, pero también pasa la matriz inversa. Para hacer esto posible, en la implementación original, al agregar una transformación a la memoria caché, la matriz inversa siempre se calcula y almacena para que pueda devolverse.
Lo estúpido aquí es que la mayoría de los pares de marcado que usan el caché de transformación no consultan ni usan la matriz inversa. Es decir, se desperdician diferentes tipos de memoria en transformaciones inversas inaplicables.
En la
nueva implementación , se agregan las siguientes mejoras:
- Utiliza una tabla hash para acelerar la búsqueda y no requiere almacenamiento de otra cosa que no sea la matriz
Transform *
, lo que, en esencia, reduce la cantidad de memoria utilizada al valor realmente necesario para almacenar todas las Transform
. - La firma del método de búsqueda ahora se ve como
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
; en un lugar donde la función de llamada quiere obtener la matriz inversa del caché, solo llama a Lookup()
dos veces.
Para el hash, utilicé la
función hash FNV1a . Después de su implementación, encontré
la publicación de Aras sobre funciones hash ; quizás debería haber usado xxHash o CityHash porque su rendimiento es mejor; quizás algún día mi vergüenza gane y la arregle.
Gracias a la nueva implementación de
TransformCache
, el tiempo total de inicio del sistema ha disminuido significativamente, hasta 21 min 42 s. Es decir, ahorramos otros 5 minutos y 7 segundos, o aceleramos 1.27 veces. Además, el uso más eficiente de la memoria ha reducido el espacio ocupado por las matrices de transformación de 16 a 5,7 GB, que es casi igual a la cantidad de datos almacenados. Esto nos permitió no tratar de aprovechar el hecho de que en realidad no son proyectivos, y almacenar matrices de 3x4 en lugar de 4x4. (En el caso habitual, sería escéptico sobre la importancia de este tipo de optimización, pero aquí nos ahorraría más de un gigabyte, ¡mucha memoria! Definitivamente vale la pena hacerlo en el procesador de producción).
Pequeña optimización de rendimiento para completar
Una estructura
TransformedPrimitive
demasiado generalizada nos cuesta memoria y tiempo: el generador de perfiles dijo que una parte significativa del tiempo al inicio se gastó en la función
AnimatedTransform::Decompose()
, que descompone la transformación de la matriz en rotación, transferencia y escala de cuaterniones. Como nada se mueve en esta escena, este trabajo es innecesario, y una verificación exhaustiva de la implementación de
AnimatedTransform
ha demostrado que no se accede a ninguno de estos valores si las dos matrices de transformación son realmente idénticas.
Añadiendo
dos líneas al constructor para que las descomposiciones de las transformaciones no se realicen cuando no son necesarias, guardamos otros 1 min 31 del tiempo de inicio: como resultado, llegamos a 20 min 9 s, es decir, en general se aceleraron 1.73 veces.
En el
próximo artículo, abordaremos seriamente el analizador y analizaremos lo que se volvió importante cuando aceleramos el trabajo de otras partes.