
Esta é uma breve introdução à nossa nova pilha de tecnologia orientada a dados (
DOTS ). Compartilharemos algumas idéias para ajudar você a entender como e por que o Unity se tornou assim hoje e também mostraremos em que direção planejamos desenvolver. No futuro, planejamos publicar novos artigos no blog DOTS no Unity.
Vamos falar sobre C ++. Esta é a linguagem na qual a Unidade moderna é escrita.
Um dos problemas mais complexos que um desenvolvedor de jogos tem que lidar de uma maneira ou de outra é o seguinte: o programador deve fornecer um arquivo executável com instruções claras para o processador de destino e, quando o processador executar essas instruções, o jogo deverá iniciar.
Na parte do código que é sensível ao desempenho, sabemos com antecedência quais devem ser as instruções finais. Precisamos apenas de uma maneira simples que permita descrever consistentemente nossa lógica e, em seguida, verifique e garanta que as instruções necessárias sejam geradas.
Acreditamos que a linguagem C ++ não é muito boa para esta tarefa. Por exemplo, quero que meu loop seja vetorizado, mas pode haver um milhão de razões pelas quais o compilador não poderá vetorizá-lo. Hoje, ele está sendo vetorizado e amanhã não, devido a uma mudança aparentemente insignificante. É difícil garantir que todos os meus compiladores C / C ++ vetorizem meu código.
Decidimos desenvolver nossa própria “maneira bastante conveniente de gerar código de máquina” que atendesse a todos os nossos desejos. Seria possível gastar muito tempo para dobrar levemente toda a sequência do design do C ++ na direção que precisamos, mas decidimos que seria muito mais razoável investir nossa força no desenvolvimento de uma cadeia de ferramentas que resolvesse completamente todos os problemas de design que nos confrontam. Nós o desenvolveríamos levando em conta precisamente as tarefas que o desenvolvedor do jogo tem que resolver.
Quais fatores priorizamos?
- Desempenho = correto. Eu deveria ser capaz de dizer: “se por algum motivo esse loop não for vetorizado, isso deve ser um erro do compilador e não uma situação da categoria” oh, o código começou a funcionar apenas oito vezes mais devagar, mas ainda dá valores verdadeiros, algo comercial! ”
- Plataforma cruzada. O código de entrada que eu escrevo deve permanecer exatamente o mesmo, independentemente da plataforma de destino - seja iOS ou Xbox.
- Deveríamos ter um loop de iteração puro, no qual eu possa ver facilmente o código da máquina gerado para qualquer arquitetura, à medida que altero meu código-fonte. O “visualizador” do código da máquina deve ser de grande ajuda com treinamento / explicação quando você precisar entender o que todas essas instruções da máquina fazem.
- Segurança Como regra, os desenvolvedores de jogos não colocam a segurança em uma posição alta em sua lista de prioridades, mas acreditamos que um dos recursos mais interessantes do Unity é que é realmente muito difícil danificar a memória nele. Deve haver um modo no qual executamos qualquer código - e corrigimos inequivocamente um erro no qual uma letra grande exibe uma mensagem sobre o que aconteceu aqui: por exemplo, fui além dos limites ao ler / escrever ou tentei desreferir zero.
Então, depois de descobrir o que é importante para nós, passemos à próxima pergunta: em qual idioma é melhor escrever programas a partir dos quais esse código de máquina será gerado? Digamos que temos as seguintes opções:
- Idioma próprio
- Alguma adaptação / subconjunto de C ou C ++
- Subconjunto de c #
C #? Para nossos circuitos internos, cujo desempenho é especialmente crítico? Sim C # é uma escolha completamente natural, com a qual no contexto do Unity existem muitas coisas muito agradáveis:
- Esse é o idioma com o qual nossos usuários já estão trabalhando hoje.
- Possui um excelente IDE, tanto para edição / refatoração quanto para depuração.
- Já existe um compilador que converte C # em uma IL intermediária (estamos falando do compilador Roslyn para C # da Microsoft) e você pode simplesmente usá-lo em vez de escrever o seu próprio. Temos uma vasta experiência na conversão de uma linguagem intermediária em IL, portanto, precisamos apenas gerar o código e pós-processar um programa específico.
- C # é desprovido de muitos problemas de C ++ (inferno com a inclusão de cabeçalhos, padrões PIMPL, longo tempo de compilação)
Eu mesmo gosto muito de escrever código em C #. No entanto, o C # tradicional não é o melhor idioma em termos de desempenho. A equipe de desenvolvimento do C #, as equipes responsáveis pela biblioteca padrão e pelo tempo de execução nos últimos dois anos, fizeram um tremendo progresso nessa área. No entanto, ao trabalhar com C #, é impossível controlar exatamente onde seus dados estão localizados na memória. E é precisamente esse problema que precisamos resolver para aumentar a produtividade.
Além disso, a biblioteca padrão desse idioma é organizada em torno de "objetos na pilha" e "objetos que possuem ponteiros para outros objetos".
Ao mesmo tempo, trabalhando com um fragmento de código no qual o desempenho é crítico, você pode ficar quase completamente sem uma biblioteca padrão (adeus ao Linq, StringFormatter, List, Dictionary), proibir operações de seleção (= sem classes, apenas estruturas), reflexão, desativar o coletor de lixo e o virtual chamadas e adicione alguns novos contêineres com permissão para uso (NativeArray e empresa). Nesse caso, os elementos restantes da linguagem C # já parecem muito bons. Veja o blog da Aras para exemplos, onde ele descreve um projeto de rastreador de caminho improvisado.
Esse subconjunto nos ajudará a lidar facilmente com todas as tarefas relevantes ao trabalhar com ciclos quentes. Como esse é um subconjunto completo de C #, você pode trabalhar com ele como em C # regular. Podemos receber erros associados a ir para o exterior ao tentar acessar, receberemos excelentes mensagens de erro, teremos um depurador suportado e a velocidade de compilação será tal que você já se esqueceu de trabalhar com C ++. Costumamos nos referir a esse subconjunto como C # de alto desempenho ou HPC #.
Compilador Burst: o que hoje?
Escrevemos um gerador / compilador de código chamado Burst. Está disponível na versão
Unity 2018.1 e superior como um pacote no modo "visualização". Muito trabalho ainda precisa ser feito com ele, mas hoje estamos satisfeitos com ele.
Às vezes, conseguimos trabalhar mais rápido do que em C ++, geralmente - ainda mais lentamente que em C ++. A segunda categoria inclui erros de desempenho que, estamos convencidos, serão capazes de lidar.
No entanto, simplesmente comparar o desempenho não é suficiente. Não menos importante é o que precisa ser feito para alcançar esse desempenho. Exemplo: pegamos o código de seleção do nosso atual renderizador C ++ e o portamos para Burst. O desempenho não mudou, mas na versão C ++ tivemos que fazer um incrível ato de equilíbrio para convencer nossos compiladores C ++ a fazer a vetorização. A versão com Burst era cerca de quatro vezes mais compacta.
Honestamente, toda a história com "você deve reescrever seu código crítico para o desempenho em C #" à primeira vista não atraiu ninguém da equipe interna do Unity. Para a maioria de nós, parecia "mais próximo do hardware!" Ao trabalhar com C ++. Mas agora a situação mudou. Usando C #, controlamos completamente todo o processo, desde a compilação do código-fonte até a geração do código da máquina e, se não gostamos de nenhum detalhe, apenas o pegamos e o corrigimos.
Vamos portar lenta mas seguramente todo o código crítico de desempenho, de C ++ para HPC #. Nesse idioma, é mais fácil obter o desempenho que precisamos, mais difícil escrever um bug e mais fácil trabalhar.
Aqui está uma captura de tela do Burst Inspector, onde você pode ver facilmente quais instruções de montagem foram geradas para seus vários loops quentes:

A unidade tem muitos usuários diferentes. Alguns deles podem lembrar da memória todo o conjunto de instruções arm64, enquanto outros simplesmente criam com entusiasmo, mesmo sem um doutorado em ciência da computação.
Todos os usuários vencem quando acelera a fração do tempo do quadro gasto na execução do código do mecanismo (geralmente 90% +). A parte de trabalhar com o código executável do pacote do Asset Store está realmente acelerando, pois os autores do pacote do Asset Store estão adotando o HPC #.
Usuários avançados também se beneficiarão do fato de poderem escrever seu próprio código de alto desempenho em HPC #.
Otimização de pontos
No C ++, é muito difícil fazer com que o compilador tome diferentes decisões de comprometimento na otimização do código em diferentes partes do seu projeto. A otimização mais detalhada com a qual você pode contar é uma indicação arquivo a arquivo do nível de otimização.
O Burst foi projetado para que você possa aceitar o único método deste programa como uma entrada, a saber: o ponto de entrada para o hot loop. O Burst compila essa função, bem como tudo o que chama (esses elementos chamados devem ser conhecidos com antecedência: não permitimos funções virtuais ou ponteiros de função).
Como o Burst opera em apenas uma parte relativamente pequena do programa, definimos o nível de otimização para 11. O Burst incorpora quase todos os sites de chamadas. Remova as verificações de if, que de outra forma não seriam excluídas, pois no formulário incorporado obtemos informações mais completas sobre os argumentos da função.
Como isso ajuda a resolver problemas comuns de encadeamento?
O C ++ (assim como o C #) não ajuda particularmente os desenvolvedores a escrever código de thread-safe.
Ainda hoje, mais de uma década depois que um processador de jogo típico começou a ser equipado com dois ou mais núcleos, é muito difícil escrever programas que usem com eficiência vários núcleos.
Corridas de dados, não-determinismo e impasses são os principais desafios que dificultam a escrita de códigos multithread. Nesse contexto, precisamos de recursos da categoria "verifique se essa função e tudo o que chama nunca começarão a ler ou escrever o estado global". Queremos que todas as violações desta regra gerem erros do compilador e não permaneçam "regras que esperamos que todos os programadores sigam". Burst gera um erro de compilação.
É altamente recomendável que os usuários do Unity (e mantemos o mesmo em seu círculo) escrevam código para que todas as transformações de dados planejadas nele sejam divididas em tarefas. Cada tarefa é "funcional" e, como efeito colateral, gratuita. Indica explicitamente buffers somente leitura e buffers de leitura / gravação com os quais tem que trabalhar. Qualquer tentativa de acessar outros dados causará um erro de compilação.
O Agendador de tarefas garante que ninguém gravará no buffer somente leitura enquanto a tarefa estiver em execução. E garantimos que, durante a duração da tarefa, ninguém lerá seu buffer, projetado para leitura e gravação.
Sempre que você atribuir uma tarefa que viole essas regras, você receberá um erro de compilação. Não apenas em um evento tão infeliz como as condições da corrida. A mensagem de erro explicará que você está tentando atribuir uma tarefa que deve ser lida no buffer A, mas anteriormente você atribuiu uma tarefa que será gravada em A. Portanto, se você realmente deseja fazer isso, a tarefa anterior deve ser especificada como uma dependência .
Acreditamos que esse mecanismo de segurança ajuda a capturar muitos bugs antes de serem corrigidos e, portanto, garante o uso eficiente de todos os núcleos. Torna-se impossível provocar condições de corrida ou impasse. Os resultados são garantidos como determinísticos, independentemente de quantos threads você possui ou de quantas vezes um thread é interrompido devido à intervenção de algum outro processo.
Domine toda a pilha
Quando podemos chegar ao fundo de todos esses componentes, também podemos garantir que eles estejam cientes um do outro. Por exemplo, um motivo comum para falha de vetorização é o seguinte: o compilador não pode garantir que dois ponteiros não apontem para o mesmo ponto de memória (alias). Sabemos que dois NativeArray não se sobrepõem dessa maneira, porque eles escreveram uma biblioteca de coleções e podemos usar esse conhecimento no Burst, para que não nos recusemos a otimizar apenas por medo de que dois ponteiros sejam direcionados a um. o mesmo pedaço de memória.
Da mesma forma, escrevemos a biblioteca de matemática
Unity.Mathematics . Burst, ela é conhecida "completamente". Burst (no futuro) será capaz de indicar a opção de exclusão da otimização em casos como math.sin (). Como Burst math.sin () não é apenas um método C # comum que precisa ser compilado, ele também entenderá as propriedades trigonométricas de sin (); ele entenderá que sin (x) == x para valores x pequenos (que Burst pode provar independentemente ), entenderá que ele pode ser substituído pela expansão na série Taylor, sacrificando parcialmente a precisão. No futuro, a Burst também planeja implementar o determinismo entre plataformas e o design com um ponto flutuante - acreditamos que esses objetivos sejam alcançáveis.
As diferenças entre o código do mecanismo do jogo e o código do jogo são borradas
Quando escrevemos o código de tempo de execução do Unity em HPC #, o mecanismo de jogo e o jogo são escritos no mesmo idioma. Podemos distribuir os sistemas de tempo de execução que convertemos para HPC # como código-fonte. Todos podem aprender com eles, melhorá-los, adaptá-los por si mesmos. Teremos um campo de jogo de um certo nível e nada impedirá que nossos usuários escrevam um sistema de partículas melhor, física de jogo ou renderizador do que escrevemos. Ao aproximar nossos processos de desenvolvimento interno dos processos de desenvolvimento do usuário, também podemos nos sentir melhor no lugar do usuário, para que envidemos todos os nossos esforços na construção de um único fluxo de trabalho, e não dois diferentes.