Eu tenho um ramo pbrt, que eu uso para testar novas idéias, implementar idéias interessantes de artigos científicos e, em geral, estudar tudo o que normalmente resulta em uma nova edição do livro
Renderização com Base Física . Diferente do
pbrt-v3 , que nos esforçamos para manter o mais próximo possível do sistema descrito no livro, neste tópico podemos mudar qualquer coisa. Hoje veremos como mudanças mais radicais no sistema reduzirão significativamente o uso de memória na cena com a ilha do desenho animado da Disney
"Moana" .
Nota sobre a metodologia: nos três posts anteriores, todas as estatísticas foram medidas para a versão WIP (Work In Progress) da cena com a qual trabalhei antes de seu lançamento. Neste artigo, passaremos para a versão final, que é um pouco mais complicada.
Ao renderizar a última cena da ilha a partir de
Moana , 81 GB de RAM foram usados para armazenar a descrição da cena para pbrt-v3. Atualmente, o pbrt-next usa 41 GB - cerca da metade. Para obter esse resultado, bastava fazer pequenas alterações que se espalharam por várias centenas de linhas de código.
Primitivos reduzidos
Lembremos que no pbrt
Primitive
há uma combinação de geometria, seu material, a função de radiação (se for uma fonte de luz) e registros sobre o ambiente dentro e fora da superfície. No pbrt-v3, o
GeometricPrimitive
armazena o seguinte:
std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface;
Como
afirmado anteriormente , a maior parte do tempo
areaLight
é
nullptr
, e o
MediumInterface
contém um par de
nullptr
. Assim, no pbrt-next, adicionei uma opção
Primitive
chamada
SimplePrimitive
, que armazena apenas ponteiros para geometria e material. Onde possível, é usado
GeometricPrimitive
possível, em vez de
GeometricPrimitive
:
class SimplePrimitive : public Primitive {
Para instâncias de objetos não animados, agora temos o
TransformedPrimitive
, que armazena apenas um ponteiro para o primitivo e a transformação, o que economiza cerca de 500 bytes de
espaço desperdiçado que a instância
AnimatedTransform
adicionou ao renderizador
TransformedPrimitive
pbrt-v3.
class TransformedPrimitive : public Primitive {
(Existe
AnimatedPrimitive
caso você precise de uma conversão animada para pbrt-next.)
Após todas essas alterações, as estatísticas relatam que apenas 7,8 GB são usados no
Primitive
, em vez de 28,9 GB no pbrt-v3. Embora tenha sido ótimo economizarmos 21 GB, não é apenas a diminuição que poderíamos esperar de estimativas anteriores; retornaremos a essa discrepância no final desta parte.
Geometria reduzida
Além disso, o pbrt-next reduziu significativamente a quantidade de memória ocupada pela geometria: o espaço usado para triângulos de malha diminuiu de 19,4 GB para 9,9 GB e o espaço de armazenamento para curvas de 1,4 para 1,1 GB. Pouco mais da metade dessa economia veio da simplificação da classe
Shape
básica.
No pbrt-v3, o
Shape
traz vários membros que são transportados para todas as implementações do
Shape
- esses são vários aspectos convenientes para acessar nas implementações do
Shape
.
class Shape {
Para entender por que essas variáveis-membro causam problemas, será útil entender como as malhas triangulares são representadas no pbrt. Primeiro, há a classe
TriangleMesh
, que armazena os vértices e os buffers de índice para toda a malha:
struct TriangleMesh { int nTriangles, nVertices; std::vector<int> vertexIndices; std::unique_ptr<Point3f[]> p; std::unique_ptr<Normal3f[]> n;
Cada triângulo na malha é representado pela classe
Triangle
, que herda de
Shape
. A idéia é manter o
Triangle
o menor possível: eles armazenam apenas um ponteiro para a malha da qual fazem parte e um ponteiro para o deslocamento no buffer do índice no qual os índices de seus vértices começam:
class Triangle : public Shape {
Quando as implementações do
Triangle
precisam encontrar as posições de seus vértices, ele executa a indexação correspondente para obtê-las no
TriangleMesh
.
O problema com o
Shape
pbrt-v3 é que os valores armazenados são os mesmos para todos os triângulos da malha, portanto, é melhor salvá-los de cada malha inteira no
TriangleMesh
e conceder ao
Triangle
acesso a uma única cópia dos valores comuns.
Esse problema foi corrigido no pbrt-next: a classe
Shape
básica no pbrt-next não contém esses membros e, portanto, cada
Triangle
tem 24 bytes a menos. A
Curve
geometria usa uma estratégia semelhante e também se beneficia de uma forma mais compacta.
Buffers de triângulo compartilhado
Apesar de a cena da ilha
Moana fazer uso extensivo da instanciação de objetos para repetir explicitamente a geometria, fiquei curioso com a frequência com que a reutilização de buffers de índice, buffers de coordenadas de textura e assim por diante é usada para várias malhas de triângulo.
Eu escrevi uma classe pequena que mistura esses buffers após o recebimento e os armazena no cache, e modifiquei o
TriangleMesh
para que ele verifique o cache e use a versão já salva de qualquer buffer redundante necessário. O ganho foi muito bom: consegui me livrar de 4,7 GB de volume em excesso, o que é muito mais do que eu esperava.
Falha no std :: shared_ptr
Após todas essas alterações, as estatísticas relatam cerca de 36 GB de memória alocada conhecida e, no início da renderização, o
top
indica o uso de 53 GB. Assuntos.
Eu tinha medo de outra série de execuções lentas de
massif
para descobrir qual memória alocada está faltando nas estatísticas, mas uma carta de
Arseny Kapulkin apareceu na minha caixa de entrada. Arseny me explicou que
minhas estimativas anteriores do uso da memória
GeometricPrimitive
estavam muito erradas. Eu tive que descobrir por um longo tempo, mas então percebi; muito obrigado a Arseny por apontar o erro e explicações detalhadas.
Antes de escrever para Arseny, imaginei a implementação de
std::shared_ptr
seguinte maneira: nessas linhas, existe um descritor comum que armazena a contagem de referência e um ponteiro para o próprio objeto colocado:
template <typename T> class shared_ptr_info { std::atomic<int> refCount; T *ptr; };
Sugeri então que a instância
shared_ptr
apenas aponta para ela e a usa:
template <typename T> class shared_ptr {
Em suma, presumi que
sizeof(shared_ptr<>)
é igual ao tamanho do ponteiro e que 16 bytes de espaço extra são desperdiçados em cada ponteiro compartilhado.
Mas isso não é verdade.
Na implementação do meu sistema, o descritor comum tem 32 bytes de tamanho e 16 bytes de
sizeof(shared_ptr<>)
. Portanto,
GeometricPrimitive
, que consiste principalmente em
std::shared_ptr
, é cerca de duas vezes maior que minhas estimativas. Se você está se perguntando por que isso aconteceu, essas duas postagens de Stack Overflow explicam os motivos em detalhes:
1 e
2 .
Em quase todos os casos de uso de
std::shared_ptr
no pbrt-next, eles não precisam ser ponteiros compartilhados. Enquanto fazia hackers malucos, substituí tudo o que pude por
std::unique_ptr
, que na verdade tem o mesmo tamanho que um ponteiro comum. Por exemplo, aqui está a aparência do
SimplePrimitive
agora:
class SimplePrimitive : public Primitive {
A recompensa acabou sendo maior do que eu esperava: o uso da memória no início da renderização diminuiu de 53 GB para 41 GB - uma economia de 12 GB, completamente inesperada alguns dias atrás, e o volume total é quase a metade do usado pelo pbrt-v3. Ótimo!
Na próxima parte, finalmente concluiremos esta série de artigos - examinamos a velocidade de renderização no pbrt-next e discutimos idéias de outras maneiras de reduzir a quantidade de memória necessária para essa cena.
Parte 5
Para resumir esta série de artigos, começaremos explorando a velocidade de renderização da cena da ilha a partir do desenho animado da Disney
"Moana" em pbrt-next - o ramo pbrt que eu uso para testar novas idéias. Faremos mudanças mais radicais do que é possível no pbrt-v3, que deve aderir ao sistema descrito em nosso livro. Concluímos com uma discussão de áreas para melhorias adicionais, do mais simples ao um pouco extremo.
Tempo de renderização
A Pbrt-next fez muitas alterações nos algoritmos de transferência de luz, incluindo alterações na amostragem BSDF e melhorias nos algoritmos de roleta russa. Como resultado, ele rastreia mais raios do que o pbrt-v3 para renderizar essa cena; portanto, não é possível comparar diretamente o tempo de execução desses dois renderizadores. A velocidade geralmente é próxima, com uma exceção importante: ao renderizar uma cena da ilha de
Moana , mostrada abaixo, o pbrt-v3 gasta 14,5% do tempo de execução pesquisando texturas
ptex . Isso costumava parecer bastante normal para mim, mas o pbrt-next gasta apenas 2,2% do tempo de execução. Tudo isso é muito interessante.
Depois de estudar as estatísticas, obtemos
1 :
pbrt-v3:
Ptex 20828624
Ptex 712324767
pbrt-next:
Ptex 3378524
Ptex 825826507
Como vemos no pbrt-v3, a textura ptex é lida em disco, em média, a cada 34 pesquisas de textura. No pbrt-next, ele é lido somente após cada 244 pesquisas - ou seja, a E / S do disco diminuiu cerca de 7 vezes. Sugeri que isso acontecesse porque o pbrt-next calcula as diferenças de raio para raios indiretos e isso leva ao acesso a níveis mais altos de texturas MIP, o que, por sua vez, cria uma série mais integrada de acessos ao cache de textura ptex, reduz o número de falhas de cache e, portanto, o número de operações de E / S
2 . Uma breve verificação confirmou meu palpite: quando a diferença do feixe foi desativada, a velocidade do ptex ficou muito pior.
O aumento na velocidade do ptex não afetou apenas o custo da computação e da E / S. Em um sistema com 32 CPUs, o pbrt-v3 foi acelerado apenas 14,9 vezes após analisar a descrição da cena. O pbrt geralmente mostra uma escala paralela linear próxima, então me decepcionou. Devido ao número muito menor de conflitos durante bloqueios no ptex, a versão pbrt-next foi 29,2 vezes mais rápida em um sistema com 32 CPUs e 94,9 vezes mais rápida em um sistema com 96 CPUs - voltamos aos indicadores que nos convêm.
Raízes da cena da ilha de Moana renderizada por pbrt com uma resolução de 2048x858 a 256 amostras por pixel. O tempo total de renderização em uma instância do Google Compute Engine com 96 CPUs virtuais com uma frequência de 2 GHz em pbrt-next é de 41 min 22 s. A aceleração devido ao multithreading durante a renderização foi de 94,9 vezes. (Não entendo direito o que está acontecendo com o mapeamento de bump.)Trabalhe para o futuro
Diminuir a quantidade de memória usada em cenas tão complexas é uma experiência emocionante: economizar alguns gigabytes com uma pequena alteração é muito mais agradável do que dezenas de megabytes economizados em uma cena mais simples. Tenho uma boa lista do que espero aprender no futuro, se o tempo permitir. Aqui está uma rápida visão geral.
Memória de buffer triângulo decrescente adicional
Mesmo com o uso repetido de buffers que armazenam os mesmos valores para várias malhas de triângulo, muita memória ainda é usada sob os buffers de triângulo. Aqui está um detalhamento do uso de memória para vários tipos de buffers de triângulo na cena:
Tipo | A memória |
---|
Itens de linha | 2,5 GB |
Normal | 2,5 GB |
UV | 98 MB |
Índices | 252 MB |
Entendo que nada pode ser feito com as posições dos vértices transmitidos, mas para outros dados há economia. Existem muitos
tipos de representações de vetores normais em uma forma eficiente de memória que fornece várias vantagens e desvantagens entre o tamanho da memória / número de cálculos. O uso de uma das representações de 24 ou 32 bits reduzirá o espaço ocupado pelos normais para 663 MB e 864 MB, o que economizará mais de 1,5 GB de RAM.
Nesta cena, a quantidade de memória usada para armazenar coordenadas de textura e buffers de índice é surpreendentemente pequena. Suponho que isso aconteceu devido à presença de muitas plantas geradas processualmente na cena e porque todas as variações do mesmo tipo de planta têm a mesma topologia (e, portanto, o buffer de índice) com parametrização (e, portanto, as coordenadas UV). Por sua vez, reutilizar buffers correspondentes é bastante eficiente.
Para outras cenas, a amostragem de coordenadas UV de 16 bits de texturas ou o uso de valores flutuantes de meia precisão, dependendo da faixa de valores, pode ser bastante adequado. Parece que nesta cena, todos os valores das coordenadas da textura são zero ou um, o que significa que eles podem ser representados por um
bit - ou seja, é possível reduzir a memória ocupada em 32 vezes. Esse estado de coisas provavelmente surgiu devido ao uso do formato ptex para texturização, o que elimina a necessidade de atlas de UV. Dada a pequena quantidade atualmente ocupada pelas coordenadas de textura, a implementação dessa otimização não é particularmente necessária.
O pbrt sempre usa números inteiros de 32 bits para buffers de índice. Para malhas pequenas com menos de 256 vértices, apenas 8 bits por índice são suficientes e, para malhas com menos de 65.536 vértices, 16 bits podem ser usados. Alterar o pbrt para adaptá-lo a este formato não será muito difícil. Se desejássemos otimizar ao máximo, poderíamos selecionar exatamente quantos bits forem necessários para representar o intervalo necessário nos índices, enquanto o preço seria aumentar a complexidade de encontrar seus valores. Apesar do fato de que agora apenas um quarto de gigabyte de memória é usado para índices de vértices, essa tarefa não parece muito interessante em comparação com outras.
Uso de memória de construção de pico BVH
Anteriormente, não discutimos ainda outro detalhe do uso da memória: imediatamente antes da renderização, ocorre um pico de curto prazo de 10 GB de memória usada adicionalmente. Isso acontece quando o BVH (grande) de toda a cena é construído. O código para construir o BVH do renderizador pbrt é escrito para ser executado em duas fases: primeiro, ele cria um BVH com a
representação tradicional : dois ponteiros filhos para cada nó. Após a construção da árvore, ela é convertida em
um esquema eficiente de memória, no qual o primeiro filho do nó está localizado diretamente atrás dele na memória, e o deslocamento para o segundo filho é armazenado como um número inteiro.
Essa separação era necessária do ponto de vista do ensino dos alunos - era muito mais fácil entender os algoritmos para a construção de BVH sem o caos associado à necessidade de converter a árvore em uma forma compacta durante o processo de construção. No entanto, o resultado é esse pico no uso da memória; levando em consideração sua influência na cena, a eliminação desse problema parece atraente.
Converter ponteiros em números inteiros
Em várias estruturas de dados, existem muitos ponteiros de 64 bits que podem ser representados como números inteiros de 32 bits. Por exemplo, cada
SimplePrimitive
contém um ponteiro para um
Material
. A maioria dos casos de
Material
é comum a muitos primitivos na cena e nunca há mais do que alguns milhares; portanto, podemos armazenar um único vetor
vector
global
vector
todos os materiais:
std::vector<Material *> allMaterials;
e apenas armazene deslocamentos inteiros de 32 bits para esse vetor no
SimplePrimitive
, o que nos economiza 4 bytes. O mesmo truque pode ser usado com um ponteiro para o
TriangleMesh
em cada
Triangle
, assim como em muitos outros lugares.
Após essa mudança, haverá uma ligeira redundância no acesso aos próprios sinais, e o sistema se tornará um pouco menos compreensível para os alunos que tentam entender seu trabalho; além disso, esse é provavelmente o caso quando, no contexto do pbrt, é melhor manter a implementação um pouco mais compreensível, embora à custa da otimização incompleta do uso da memória.
Alojamento baseado em arenas (áreas)
Para cada
Triangle
individual e primitivo, é feita uma chamada separada para
new
(realmente
make_unique
, mas é o mesmo). Essas alocações de memória levam ao uso de contabilidade de recursos adicionais, ocupando cerca de cinco gigabytes de memória, não contabilizados nas estatísticas. Como a vida útil de todos esses canais é a mesma - até a renderização estar concluída -, podemos nos livrar dessa contabilidade adicional selecionando-os na
arena da
memória .
Tabela cáqui
Minha última ideia é terrível, e peço desculpas por isso, mas ela me intrigou.
Cada triângulo na cena possui uma carga extra de pelo menos dois ponteiros de tabela: um para
Triangle
e outro para
SimplePrimitive
. Isso é 16 bytes. A cena da ilha de
Moana possui um total de 146 162 124 triângulos únicos, o que adiciona quase 2,2 GB de ponteiros redimensionáveis de tabela.
E se não tivéssemos uma classe base abstrata para
Shape
e cada implementação de geometria não herdasse nada? Isso nos pouparia espaço em ponteiros vtable, mas, é claro, ao passar um ponteiro para uma geometria, não saberíamos que tipo de geometria é, ou seja, seria inútil.
Acontece que nas modernas CPUs x86
, apenas 48 bits de ponteiros de 64 bits são realmente
usados . Portanto, existem 16 bits extras que podemos emprestar para armazenar algumas informações ... por exemplo, como a geometria que estamos apontando. Por sua vez, adicionando um pouco de trabalho, podemos voltar à possibilidade de criar um análogo de chamadas para funções virtuais.
Veja como isso acontecerá: primeiro, definimos uma estrutura
ShapeMethods
que contém ponteiros para funções, como por exemplo
3 :
struct ShapeMethods { Bounds3f (*WorldBound)(void *);
Cada implementação de geometria implementará uma função de restrição, uma função de interseção e assim por diante, recebendo um análogo
this
ponteiro como o primeiro argumento:
Bounds3f TriangleWorldBound(void *t) {
Teríamos uma tabela global de estruturas
ShapeMethods
na qual o
enésimo elemento seria para um tipo de geometria com o índice
n :
ShapeMethods shapeMethods[] = { { TriangleWorldBound, }, { CurveWorldBound, };
Ao criar geometria, codificamos seu tipo em alguns dos bits não utilizados do ponteiro de retorno. Então, levando em conta o ponteiro para a geometria cuja chamada específica queremos executar, extraímos esse índice de tipo do ponteiro e o usamos como um índice em
shapeMethods
para encontrar o ponteiro de função correspondente. Essencialmente, implementaríamos o vtable manualmente, processando a expedição por nós mesmos. Se fizéssemos isso tanto para geometria quanto para primitivas, salvaríamos 16 bytes por
Triangle
, mas, ao mesmo tempo, fizemos um caminho bastante difícil.
Suponho que esse tipo de invasão para implementar o gerenciamento de funções virtuais não seja novo, mas não consegui encontrar links para ele na Internet. Aqui está a página da Wikipedia sobre
ponteiros com
tags , mas trata de coisas como contagem de links. Se você conhece um link melhor, envie-me uma carta.
Ao compartilhar esse truque estranho, posso terminar a série de postagens. Mais uma vez, muito obrigado à Disney por publicar esta cena. Foi incrivelmente divertido trabalhar com; as engrenagens na minha cabeça continuam girando.
Anotações
- No final, o pbrt-next rastreia mais raios nessa cena do que o pbrt-v3, o que provavelmente explica o aumento no número de operações de pesquisa.
- As diferenças de raio para raios indiretos no pbrt-next são calculadas usando o mesmo hack usado na extensão do cache de textura para o pbrt-v3. , , .
- Rayshade . , C . Rayshade .