Inspirado pela
primeira vitória de análise com uma descrição de uma cena da ilha do desenho animado da Disney
em Moana , fui mais adiante no estudo do uso da memória. Muito ainda poderia ser feito com o prazo de entrega, mas decidi que seria útil investigar a situação primeiro.
Comecei a investigação de tempo de execução com as estatísticas internas do pbrt; O pbrt possui uma configuração manual para alocações significativas de memória para rastrear o uso da memória e, após a conclusão da renderização, um relatório de alocação de memória é exibido. Aqui está o que o relatório de alocação de memória para essa cena originalmente era:
BVH- 9,01
1,44
MIP- 2,00
11,02
Quanto ao tempo de execução, as estatísticas internas acabaram sendo breves e relataram apenas alocação de memória para objetos conhecidos de tamanho de 24 GB.
top
disse que, de fato, foram utilizados cerca de 70 GB de memória, ou seja, 45 GB não foram levados em consideração nas estatísticas. Pequenos desvios são bastante compreensíveis: alocadores de memória dinâmica requerem espaço adicional para registrar o uso de recursos, alguns são perdidos devido à fragmentação e assim por diante. Mas 45 GB? Algo ruim está definitivamente escondido aqui.
Para entender o que está faltando (e para garantir que rastreamos corretamente), usei o
maciço para rastrear a alocação real da memória dinâmica. É bastante lento, mas pelo menos funciona bem.
Primitivas
A primeira coisa que descobri ao rastrear o maciço foi duas linhas de código que alocavam na memória instâncias da classe base
Primitive
, que não são levadas em consideração nas estatísticas. Uma pequena supervisão que é
bastante fácil de corrigir . Depois disso, vemos o seguinte:
Primitives 24,67
Opa Então, o que é primitivo e por que toda essa memória?
O pbrt distingue entre
Shape
, que é geometria pura (esfera, triângulo, etc.) e
Primitive
, que é uma combinação de geometria, material, às vezes a função da radiação e o meio envolvido dentro e fora da superfície da geometria.
Existem
várias opções para a classe base
Primitive
:
GeometricPrimitive
, que é um caso padrão: uma combinação "baunilha" de geometria, material etc., além de
TransformedPrimitive
, que é um primitivo com transformações aplicadas a ele, como uma instância de um objeto ou para mover primitivas com transformações que mudam com o tempo. Acontece que nessa cena esses dois tipos são um desperdício de espaço.
GeométricoPrimitivo: 50% de espaço extra
Nota: algumas suposições errôneas são feitas nesta análise; eles são revisados no quarto post da série .4,3 GB usados no
GeometricPrimitive
. É engraçado viver em um mundo onde 4,3 GB de RAM usada não é seu maior problema, mas vamos ver de onde temos 4,3 GB de
GeometricPrimitive
. Aqui estão as partes relevantes da definição de classe:
class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; };
Temos um
ponteiro para vtable , mais três ponteiros e, em seguida, um
MediumInterface
contendo mais dois ponteiros com um tamanho total de 48 bytes. Existem apenas algumas malhas emissoras de luz nessa cena, portanto
areaLight
quase sempre é um ponteiro nulo e não há ambiente afetando a cena; portanto, os dois ponteiros
mediumInterface
também
mediumInterface
nulos. Assim, se tivéssemos uma implementação especializada da classe
Primitive
, que poderia ser usada na ausência de radiação e funções médias, pouparíamos quase metade do espaço em disco ocupado por
GeometricPrimitive
- no nosso caso, cerca de 2 GB.
No entanto, não o corrigi e adicionei uma nova implementação
Primitive
ao pbrt. Nós nos esforçamos para minimizar as diferenças entre o código-fonte pbrt-v3 no github e o sistema descrito em meu livro, por uma razão muito simples - mantê-los sincronizados facilita a leitura do livro e o trabalho com o código. Nesse caso, decidi que a implementação completamente nova do
Primitive
, nunca mencionada no livro, faria muita diferença. Mas essa correção definitivamente aparecerá na nova versão do pbrt.
Antes de prosseguir, vamos fazer uma renderização de teste:
Praia da ilha do filme "Moana", renderizado por pbrt-v3 com uma resolução de 2048x858 e 256 amostras por pixel. O tempo total de renderização na instância de 12 núcleos / 24 threads do Google Compute Engine com uma frequência de 2 GHz com a versão mais recente do pbrt-v3 foi de 2 horas 25 minutos 43 segundos.TransformedPrimitives: 95% de espaço desperdiçado
A memória alocada em 4,3 GB
GeometricPrimitive
foi um golpe bastante doloroso, mas e 17,4 GB em
TransformedPrimitive
?
Como mencionado acima,
TransformedPrimitive
usado tanto para transformações com uma mudança no tempo quanto para instâncias de objetos. Nos dois casos, precisamos aplicar uma transformação adicional ao
Primitive
existente. Existem apenas dois membros na classe
TransformedPrimitive
:
std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld;
Até aí tudo bem: um ponteiro para um primitivo e uma transformação que muda com o tempo. Mas o que é realmente armazenado no
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 {
Além de ponteiros para duas matrizes de transição e o tempo associado a elas, há também uma decomposição das matrizes em componentes de transporte, rotação e escala, além de valores pré-calculados usados para limitar o volume ocupado pela movimentação de caixas delimitadoras (consulte a seção 2.4.9 do nosso livro).
Renderização com Base Física ). Tudo isso adiciona até 456 bytes.
Mas
nada se move nesta cena. Do ponto de vista das transformações para instâncias de objetos, precisamos de um ponteiro para a transformação, e os valores para decomposição e caixas delimitadoras móveis não são necessários. (Ou seja, são necessários apenas 8 bytes). Se você criar uma implementação
Primitive
separada para instâncias fixas de objetos, 17,4 GB serão compactados no total para 900 MB (!).
Quanto ao
GeometricPrimitive
, corrigi-lo é uma mudança não trivial em comparação com o que está descrito no livro, portanto, também o adiaremos para a próxima versão do pbrt. Pelo menos agora entendemos o que está acontecendo com o caos de 24,7 GB de memória
Primitive
.
Problemas com o cache de conversão
O próximo maior bloco de memória não contabilizada definido pelo maciço foi o
TransformCache
, que ocupava aproximadamente 16 GB. (Aqui está um link para a
implementação original .) A idéia é que a mesma matriz de transformação seja usada várias vezes na cena, por isso é melhor ter uma única cópia dela na memória, para que todos os elementos que a utilizam simplesmente armazenem um ponteiro para a mesma coisa conversão.
TransformCache
usou o
std::map
para armazenar o cache, e o maciço relatou que 6 dos 16 GB foram usados para nós de árvore preto-vermelho no
std::map
. Isso é demais: 60% desse volume é usado para as próprias transformações. Vejamos a declaração para esta distribuição:
std::map<Transform, std::pair<Transform *, Transform *>> cache;
Aqui, o trabalho é feito perfeitamente: o
Transform
inteiramente usado como chaves para distribuição. Melhor ainda, o pbrt
Transform
armazena duas matrizes 4x4 (a matriz de transformação e sua matriz inversa), o que resulta em 128 bytes sendo armazenados em cada nó da árvore. Tudo isso é absolutamente desnecessário para o valor armazenado para ele.
Talvez essa estrutura seja bastante normal em um mundo onde é importante para nós que a mesma matriz de transformação seja usada em centenas ou milhares de primitivas, e em geral não existem muitas matrizes de transformação. Mas para uma cena com várias matrizes de transformação únicas, como no nosso caso, essa é apenas uma abordagem terrível.
Além do fato de o espaço ser desperdiçado nas teclas, uma pesquisa no
std::map
para percorrer a árvore vermelho-preta envolve muitas operações de ponteiro, portanto, parece lógico tentar algo completamente novo. Felizmente, pouco é escrito sobre o
TransformCache
no livro, por isso é totalmente aceitável reescrevê-lo completamente.
E por último, antes de começarmos: depois de examinar a assinatura do método
Lookup()
, outro problema se torna aparente:
void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse)
Quando a função de chamada fornece
Transform
, o cache salva e retorna ponteiros de conversão iguais ao passado, mas também passa a matriz inversa. Para tornar isso possível, na implementação original, ao adicionar uma transformação ao cache, a matriz inversa é sempre calculada e armazenada para que possa ser retornada.
O mais estúpido aqui é que a maioria dos pontos de discagem que usam o cache de transformação não consulta ou usa a matriz inversa. Ou seja, diferentes tipos de memória são desperdiçados em transformações inversas inaplicáveis.
Na
nova implementação , as seguintes melhorias são adicionadas:
- Ele usa uma tabela de hash para acelerar a pesquisa e não requer armazenamento de nada além da matriz
Transform *
, que, em essência, reduz a quantidade de memória usada para o valor realmente necessário para armazenar todas as Transform
. - A assinatura do método de pesquisa agora se parece com
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
; em um local onde a função de chamada deseja obter a matriz inversa do cache, ela chama Lookup()
duas vezes.
Para o hash, usei a
função de hash FNV1a . Após sua implementação, encontrei
o post de Aras sobre funções de hash ; talvez eu devesse ter usado xxHash ou CityHash porque o desempenho deles é melhor; talvez um dia minha vergonha vença e eu a conserte.
Graças à nova implementação do
TransformCache
, o tempo total de inicialização do sistema diminuiu significativamente - até 21 min 42 s. Ou seja, economizamos mais 5 minutos e 7 segundos ou aceleramos 1,27 vezes. Além disso, o uso mais eficiente da memória reduziu o espaço ocupado pelas matrizes de transformação de 16 para 5,7 GB, o que é quase igual à quantidade de dados armazenados. Isso nos permitiu não tentar tirar proveito do fato de que eles não são realmente projetivos e armazenar matrizes 3x4 em vez de 4x4. (No caso usual, eu ficaria cético em relação à importância desse tipo de otimização, mas aqui nos pouparia mais do que um gigabyte - muita memória! Isso definitivamente vale a pena ser feito no renderizador de produção.)
Pequena otimização de desempenho para concluir
Uma estrutura
TransformedPrimitive
muito generalizada nos custa memória e tempo: o criador de perfil disse que uma parte significativa do tempo na inicialização era gasta na função
AnimatedTransform::Decompose()
, que decompõe a transformação da matriz em rotação, transferência e escala de quaternário. Como nada está se movendo nessa cena, este trabalho é desnecessário, e uma verificação completa da implementação do
AnimatedTransform
mostrou que nenhum desses valores é acessado se as duas matrizes de transformação forem realmente idênticas.
Adicionando
duas linhas ao construtor para que as decomposições das transformações não sejam executadas quando não são necessárias, economizamos mais 1 min 31 a partir da hora de início: como resultado, chegamos a 20 min 9 s, ou seja, em geral eles aceleraram 1,73 vezes.
No
próximo artigo, abordaremos seriamente o analisador e analisaremos o que se tornou importante quando aceleramos o trabalho de outras partes.