10000 chamadas Update()

VALENTIN SIMONOV / UNITY TECHNOLOGIESCollaborator
Dec 23, 2015|9 Min
10000 chamadas Update()
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.
O Unity tem o chamado sistema de mensagens que permite definir vários métodos mágicos em seus scripts que serão chamados em eventos específicos durante a execução do jogo. Esse é um conceito muito simples e fácil de entender, especialmente bom para novos usuários. Basta definir um método Update como este e ele será chamado uma vez por quadro!

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.

Para um desenvolvedor experiente, esse código é um pouco estranho.

1. Não está claro como exatamente esse método é chamado.

2. Não está claro em que ordem esses métodos são chamados se você tiver vários objetos em uma cena.

3. Esse estilo de código não funciona com o intellisense.

Como o Update é chamado

Não, o Unity não usa o System.Reflection para encontrar um método mágico toda vez que precisa chamá-lo.

Em vez disso, na primeira vez que um MonoBehaviour de um determinado tipo é acessado, o script subjacente é inspecionado por meio do tempo de execução do script (Mono ou IL2CPP) para verificar se ele tem algum método mágico definido, e essas informações são armazenadas em cache. Se um MonoBehaviour tiver um método específico, ele será adicionado a uma lista adequada; por exemplo, se um script tiver o método Update definido, ele será adicionado a uma lista de scripts que precisam ser atualizados a cada quadro.

Durante o jogo, o Unity apenas itera através dessas listas e executa métodos a partir delas - simples assim. Além disso, é por isso que não importa se o método Update é público ou privado.

Em que ordem as atualizações são executadas

A ordem é especificada por Script Execution Order Settings (menu: Editar > Configurações do projeto > Ordem de execução do script). Pode não ser a melhor maneira de definir manualmente a ordem de 1000 scripts, mas se você quiser que um script seja executado depois de todos os outros, essa maneira é aceitável. É claro que, no futuro, queremos ter uma maneira mais conveniente de especificar a ordem de execução, usando um atributo no código, por exemplo.

Ele não funciona com o intellisense

Todos nós usamos um IDE de algum tipo para editar nossos scripts C# no Unity, e a maioria deles não gosta de métodos mágicos para os quais não é possível descobrir onde são chamados, se é que são chamados. Isso gera avisos e dificulta a navegação no código.

Às vezes, os desenvolvedores adicionam uma classe abstrata que estende o MonoBehaviour, chamam-na de BaseMonoBehaviour ou algo semelhante e fazem com que todos os scripts do projeto estendam essa classe. Eles colocaram algumas funcionalidades básicas úteis nele, juntamente com vários métodos mágicos virtuais, como este:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.

Essa estrutura torna o uso de MonoBehaviours em seu código mais lógico, mas tem uma pequena falha. Aposto que você já descobriu...

Todos os seus MonoBehaviours estarão em todas as listas de atualização que o Unity usa internamente, e todos esses métodos serão chamados a cada quadro para todos os seus scripts, na maioria das vezes sem fazer nada!

Alguém pode se perguntar: por que alguém deveria se preocupar com um método vazio? O fato é que essas são as chamadas da terra do C++ nativo para a terra do C# gerenciado, e elas têm um custo. Vamos ver qual é esse custo.

Chamando 10000 atualizações

Para esta postagem, criei um pequeno projeto de exemplo que está disponível no Github. Ele tem 2 cenas que podem ser alteradas tocando em um dispositivo ou pressionando qualquer tecla no editor:

(1) Na primeira cena, 10000 MonoBehaviours são criados com esse código:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.

(2) Na segunda cena, outros 10.000 MonoBehaviours são criados, mas, em vez de terem um Update, eles têm um método UpdateMe personalizado que é chamado por um script de gerenciador a cada quadro, da seguinte forma:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.

O projeto de teste foi executado em 2 dispositivos iOS compilados para Mono e IL2CPP no modo não-desenvolvimento na configuração de versão. O tempo foi medido da seguinte forma:

Configure um cronômetro na primeira atualização chamada (configurada em Script Execution Order),

Pare o cronômetro em LateUpdate,

Faça a média dos tempos em alguns minutos.

Versão Unity: 5.2.2f1
Versão iOS: 9.0

Mono
Imagem

UAU! Isso é muito! Deve haver algo errado com o teste!

Na verdade, esqueci de definir a Otimização de chamada de script como Rápida, mas sem exceções, mas agora podemos ver o impacto que essa configuração específica tem sobre o desempenho... não que alguém se importe mais com o IL2CPP.

Mono (rápido, mas sem exceções)
Imagem

OK, assim está melhor. Vamos mudar para IL2CPP.

IL2CPP
ImagemImagem

Aqui vemos duas coisas:

1. Essa otimização específica ainda faz sentido no IL2CPP.

2. O IL2CPP ainda pode ser aprimorado e, enquanto escrevo esta postagem, as equipes do Scripting e do IL2CPP estão trabalhando arduamente para aumentar o desempenho. Por exemplo, a última ramificação de Scripting contém otimizações que tornam a execução do teste 35% mais rápida.

Explicarei o que o Unity está fazendo nos bastidores em alguns instantes. Mas agora vamos alterar o código do nosso gerenciador para torná-lo 5 vezes mais rápido!

Chamadas de interface, chamadas virtuais e acesso a matrizes

Se você ainda não leu esta excelente série de publicações sobre os componentes internos do IL2CPP, deve fazê-lo logo após terminar de ler esta!

Acontece que, se você quisesse iterar uma lista de 10.000 elementos a cada quadro, seria melhor usar uma matriz em vez de uma lista porque, nesse caso, o código C++ gerado é mais simples e o acesso à matriz é mais rápido.

No próximo teste, alterei List<ManagedUpdateBehavior> para ManagedUpdateBehavior[].

Imagem

Isso parece muito melhor!

Atualizar: Executei o teste com o array no Mono e obtive 0,23 ms.

Instrumentos para o resgate!

Descobrimos que chamar funções de C++ para C# não é rápido, mas vamos descobrir o que o Unity está realmente fazendo ao chamar Updates em todos esses objetos. A maneira mais fácil de fazer isso é usar o Time Profiler da Apple Instruments.

Observe que não se trata de uma disputa entre mono e mono. Teste IL2CPP - a maioria das coisas descritas a seguir também é verdadeira para uma compilação Mono iOS.

Iniciei o teste no iPhone 6 com o Time Profiler, gravei alguns minutos de dados e selecionei um intervalo de um minuto para inspecionar. Estamos interessados em tudo que começa a partir dessa linha:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.

Se você nunca usou o Instruments antes, à direita você verá as funções classificadas por tempo de execução e outras funções que elas chamam. A coluna mais à esquerda é o tempo de CPU em ms e a porcentagem dessas funções e das funções que elas chamam combinadas; a segunda coluna à esquerda é o tempo de execução automática da função. Observe que, como a CPU não foi totalmente usada pelo Unity durante esse experimento, vemos 10 segundos de tempo de CPU gastos em nossos Updates em um intervalo de 60 segundos. Obviamente, estamos interessados nas funções que levam mais tempo para serem executadas.

Usei minhas habilidades loucas no Photoshop e codifiquei algumas áreas por cores para que você entenda melhor o que está acontecendo.

Imagem
UpdateBehavior.Update()

No meio, você vê nosso método Update ou como o IL2CPP o chama - UpdateBehavior_Update_m18. Mas antes de chegar lá, o Unity faz muitas outras coisas.

Iterar sobre todos os comportamentos

O Unity examina todos os Behaviours para atualizá-los. A classe especial de iterador, SafeIterator, garante que nada será interrompido se alguém decidir excluir o próximo item da lista. Apenas a iteração de todos os comportamentos registrados leva 1517ms de um total de 9979ms.

Verificar se a chamada é válida

Em seguida, o Unity faz várias verificações para se certificar de que está chamando um método válido existente em um GameObject ativo que foi inicializado e seu método Start foi chamado. Você não quer que seu jogo trave se você destruir um GameObject durante o Update, quer? Essas verificações levam mais 2188ms de um total de 9979ms.

Prepare-se para chamar o método

O Unity cria uma instância de ScriptingInvocationNoArgs (que representa uma chamada do lado nativo para o lado gerenciado) juntamente com ScriptingArguments e solicita que a máquina virtual IL2CPP invoque o método (função scripting_method_invoke). Essa etapa leva 2061ms de um total de 9979ms.

Chamar o método

A função scripting_method_invoke verifica se os argumentos passados são válidos (900 ms) e, em seguida, chama o método Runtime::Invoke da máquina virtual IL2CPP (1520 ms). Primeiro, o Runtime::Invoke verifica se esse método existe (1018 ms). Em seguida, ele chama uma função RuntimeInvoker gerada para a assinatura do método (283 ms). Por sua vez, ele chama nossa função Update que, de acordo com o Time Profiler, leva 42 ms para ser executada.

E uma bela mesa colorida.

Imagem
Atualizações gerenciadas

Agora vamos usar o Time Profiler com o teste do gerenciador. Você pode ver na captura de tela que existem os mesmos métodos (alguns deles levam menos de 1 ms no total, portanto, nem são mostrados), mas a maior parte do tempo de execução vai para a função UpdateMe (ou como o IL2CPP a chama - ManagedUpdateBehavior_UpdateMe_m14). Além disso, há uma verificação de nulidade inserida pelo IL2CPP para garantir que a matriz sobre a qual estamos iterando não seja nula.

A próxima imagem usa as mesmas cores.

Imagem

Então, o que você acha agora? Devemos nos preocupar com uma pequena chamada de método?

Algumas palavras sobre o teste

Para ser sincero, esse teste não é totalmente justo. O Unity faz um ótimo trabalho ao proteger você e seu jogo de comportamentos não intencionais e falhas: Esse GameObject está ativo? Ele não foi destruído durante esse ciclo de atualização? O método Update existe no objeto? O que fazer com um MonoBehaviour criado durante esse loop de atualização? - Meu script de gerenciador não lida com nada disso, ele apenas itera por uma lista de objetos a serem atualizados.

No mundo real, o script do gerenciador provavelmente teria sido mais complicado e de execução mais lenta. Mas, nesse caso, eu sou o desenvolvedor - sei o que meu código deve fazer e arquiteto minha classe de gerente sabendo qual comportamento é possível e qual não é no meu jogo. Infelizmente, o Unity não possui esse conhecimento.

O que você deve fazer?

É claro que tudo depende do seu projeto, mas no campo não é raro ver um jogo usando um grande número de GameObjects na cena, cada um executando alguma lógica a cada quadro. Normalmente, é um pequeno trecho de código que não parece afetar nada, mas quando o número aumenta muito, a sobrecarga de chamar milhares de métodos de atualização começa a ser perceptível. Nesse ponto, talvez já seja tarde demais para alterar a arquitetura do jogo e refatorar todos esses objetos no padrão de gerenciador.

Você tem os dados agora, pense nisso no início de seu próximo projeto.