Optimización de la representación de una escena de la caricatura de Disney "Moana". Parte 2

imagen

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 { // ... Float kc, kx, ky, kz; }; DerivativeTerm c1[3], c2[3], c3[3], c4[3], c5[3]; 

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.

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


All Articles