Otimização da renderização de uma cena do desenho animado da Disney "Moana". Parte 2

imagem

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

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.

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


All Articles