Otimização do desempenho do carregamento: Entendendo o pipeline de upload assíncrono

Ninguém gosta de telas de carregamento. Você sabia que pode ajustar rapidamente os parâmetros do pipeline de upload assíncrono (AUP) para melhorar significativamente o tempo de carregamento? Este artigo detalha como as malhas e as texturas são carregadas por meio do AUP. Esse entendimento pode ajudá-lo a acelerar significativamente o tempo de carregamento - alguns projetos observaram mais de duas vezes mais melhorias no desempenho!
Continue lendo para saber como a PUA funciona do ponto de vista técnico e quais APIs você deve usar para tirar o máximo proveito dela.
A implementação mais recente e otimizada do pipeline de upload de ativos está disponível na versão beta 2018.3.
Faça o download do 2018.3 Beta hoje
Primeiro, vamos dar uma olhada detalhada em quando a PUA é usada e como funciona o processo de carregamento.
Antes da versão 2018.3, a AUP lidava apenas com texturas. A partir da versão beta 2018.3, o AUP agora carrega texturas e malhas, mas há algumas exceções. As texturas habilitadas para leitura/gravação ou as malhas habilitadas para leitura/gravação ou compactadas não usarão o AUP. (Observe que o Texture Mipmap Streaming, que foi introduzido na versão 2018.2, também usa o AUP).
Durante o processo de compilação, o objeto de textura ou malha é gravado em um arquivo serializado e os dados binários grandes (dados de textura ou vértice) são gravados em um arquivo .resS que o acompanha. Esse layout se aplica tanto aos dados do jogador quanto aos pacotes de ativos. A separação do objeto e dos dados binários permite o carregamento mais rápido do arquivo serializado (que geralmente contém objetos pequenos) e permite o carregamento simplificado dos dados binários grandes do arquivo .resS depois. Quando o objeto Texture ou Mesh é desserializado, ele envia um comando para a fila de comandos do AUP. Quando esse comando for concluído, os dados de textura ou malha terão sido carregados na GPU e o objeto poderá ser integrado no thread principal.

Durante o processo de upload, os dados binários grandes do arquivo .resS são lidos em um buffer em anel de tamanho fixo. Uma vez na memória, os dados são carregados para a GPU em uma forma dividida por tempo no thread de renderização. O tamanho do buffer de anel e a duração do intervalo de tempo são os dois parâmetros que você pode alterar para afetar o comportamento do sistema.
O pipeline de upload assíncrono tem o seguinte processo para cada comando:
1. Aguarde até que a memória necessária esteja disponível no buffer de anel.
2. Ler dados do arquivo .resS de origem para a memória alocada.
3. Execute o pós-processamento (descompressão de textura, geração de colisão de malha, correção por plataforma, etc.).
4. Faça o upload em um tempo dividido no thread de renderização
5. Libere a memória do Ring Buffer.
Vários comandos podem estar em andamento simultaneamente, mas todos devem alocar a memória necessária do mesmo buffer de anel compartilhado. Quando o buffer de anel estiver cheio, novos comandos aguardarão; essa espera não causará bloqueio do thread principal nem afetará a taxa de quadros, apenas retardará o processo de carregamento assíncrono.
Um resumo desses impactos é o seguinte:

Para aproveitar ao máximo a PUA na versão 2018.3, há três parâmetros que podem ser ajustados em tempo de execução para esse sistema:
- QualitySettings.asyncUploadTimeSlice - O tempo, em milissegundos, gasto com o upload de texturas e dados de malha no thread de renderização para cada quadro. Quando uma operação de carregamento assíncrono estiver em andamento, o sistema executará duas fatias de tempo desse tamanho. O valor padrão é 2ms. Se esse valor for muito pequeno, você poderá ter um gargalo no carregamento da GPU de textura/malha. Um valor muito grande, por outro lado, pode resultar em problemas na taxa de quadros.
- QualitySettings.asyncUploadBufferSize - O tamanho do Ring Buffer em Megabytes. Quando a fatia de tempo de upload ocorre a cada quadro, queremos ter certeza de que temos dados suficientes no buffer em anel para utilizar toda a fatia de tempo. Se o buffer de anel for muito pequeno, a fatia de tempo de upload será reduzida. O padrão era de 4 MB na versão 2018.2, mas aumentou para 16 MB na versão 2018.3.
- QualitySettings.asyncUploadPersistentBuffer - Introduzido em 2018.3, esse sinalizador determina se o buffer de anel de upload é desalocado quando todas as leituras pendentes são concluídas. A alocação e a desalocação desse buffer podem, muitas vezes, causar fragmentação da memória, portanto, ele deve ser deixado em seu padrão (true). Se você realmente precisar recuperar a memória quando não estiver carregando, poderá definir esse valor como falso.
Essas configurações podem ser ajustadas por meio da API de script ou do menu QualitySettings.

Vamos examinar uma carga de trabalho com muitas texturas e malhas sendo carregadas por meio do pipeline de upload assíncrono usando o intervalo de tempo padrão de 2 ms e um buffer de anel de 4 MB. Como estamos carregando, obtemos duas fatias de tempo por quadro de renderização, portanto, devemos ter 4 milissegundos de tempo de upload. Observando os dados do profiler, usamos apenas cerca de 1,5 milissegundos. Também podemos ver que, imediatamente após o upload, uma nova operação de leitura é emitida, agora que a memória está disponível no buffer de anel. Isso é um sinal de que é necessário um buffer de anel maior.

Vamos tentar aumentar o Ring Buffer e, como estamos em uma tela de carregamento, também é uma boa ideia aumentar a fatia de tempo de upload. Esta é a aparência de um buffer de anel de 16 MB e uma fatia de tempo de 4 milissegundos:

Agora podemos ver que estamos gastando quase todo o tempo do thread de renderização fazendo upload, e apenas um curto período entre os uploads renderizando o quadro.
Abaixo estão os tempos de carregamento da carga de trabalho de amostra com uma variedade de fatias de tempo de upload e tamanhos de buffer de anel. Os testes foram realizados em um MacBook Pro, Intel Core i7 de 2,8 GHz, executando o OS X El Capitan. As velocidades de upload e de E/S variam em diferentes plataformas e dispositivos. A carga de trabalho é um subconjunto do projeto de amostra do Viking Village que usamos internamente para testes de desempenho. Como há outros objetos sendo carregados, não é possível obter o ganho de desempenho preciso dos diferentes valores. No entanto, é seguro dizer que, nesse caso, o carregamento de texturas e malhas é pelo menos duas vezes mais rápido ao mudar das configurações de 4 MB/2MS para as configurações de 16 MB/4MS.
O experimento com esses parâmetros produz os seguintes resultados.

Para otimizar o tempo de carregamento desse projeto de amostra específico, devemos, portanto, definir configurações como esta:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Recomendações gerais para otimizar a velocidade de carregamento de texturas e malhas:
- Escolha o maior QualitySettings.asyncUploadTimeSlice que não resulte em perda de quadros.
- Durante as telas de carregamento, aumente temporariamente o QualitySettings.asyncUploadTimeSlice.
- Use o criador de perfil para examinar a utilização da fatia de tempo. O intervalo de tempo será exibido como AsyncUploadManager.AsyncResourceUpload no criador de perfil. Aumente o QualitySettings.asyncUploadBufferSize se a fatia de tempo não estiver sendo totalmente utilizada.
- Em geral, as coisas serão carregadas mais rapidamente com um QualitySettings.asyncUploadBufferSize maior, portanto, se você puder dispor da memória, aumente-a para 16 MB ou 32 MB.
- Deixe QualitySettings.asyncUploadPersistentBuffer definido como true, a menos que você tenha um motivo convincente para reduzir o uso de memória em tempo de execução enquanto não estiver carregando.
Q: Com que frequência o upload em fatias de tempo ocorrerá no thread de renderização?
- O upload em fatias de tempo ocorrerá uma vez por quadro de renderização ou duas vezes durante uma operação de carregamento assíncrono. O VSync afeta esse pipeline. Enquanto o thread de renderização estiver aguardando um VSync, você poderá fazer o upload. Se você estiver executando quadros de 16 ms e um quadro for longo, digamos 17 ms, você acabará aguardando o vsync por 15 ms. Em geral, quanto maior a taxa de quadros, mais frequentemente ocorrerão as fatias de tempo de upload.
Q: O que é carregado por meio da PUA?
- As texturas que não são habilitadas para leitura/gravação são carregadas por meio do AUP.
- A partir da versão 2018.2, os mipmaps de textura são transmitidos por meio do AUP.
- A partir da versão 2018.3, as malhas também são carregadas por meio do AUP, desde que estejam descompactadas e não habilitadas para leitura/gravação.
Q: E se o buffer de anel não for grande o suficiente para conter os dados que estão sendo carregados (por exemplo, uma textura muito grande)?
- Os comandos de upload que forem maiores que o buffer de anel aguardarão até que o buffer de anel seja totalmente consumido e, em seguida, o buffer de anel será realocado para caber na alocação grande. Quando o upload for concluído, o buffer de anel será realocado para seu tamanho original.
Q: Como funcionam as APIs de carregamento síncrono? Por exemplo, Resources.Load, AssetBundle.LoadAsset, etc.
- As chamadas de carregamento síncrono usam o AUP e, essencialmente, bloqueiam o thread principal até que a operação de upload assíncrono seja concluída. O tipo de API de carregamento usado não é relevante.
Estamos sempre buscando feedback. Diga-nos o que você acha nos comentários ou no fórum da versão beta do Unity 2018.3!