Como escrevi uma introdução em 4K no Rust - e ganhou

Recentemente, escrevi minha primeira introdução em 4K no Rust e a apresentei na Nova 2020, onde conquistou o primeiro lugar no New School Intro Competition. Escrever uma introdução em 4K é complicado. Isso requer conhecimento de muitas áreas diferentes. Aqui vou focar em técnicas para reduzir o máximo possível o código de ferrugem.





Você pode assistir à demonstração no Youtube , baixar o executável no Pouet ou obter o código-fonte no Github .



A introdução de 4K é uma demonstração em que o programa inteiro (incluindo qualquer dado) tem 4096 bytes ou menos, por isso é importante que o código seja o mais eficiente possível. Rust tem reputação de criar executáveis ​​inchados, então eu queria ver se poderia ser um código eficiente e conciso.



Configuração



Toda a introdução é escrita em uma combinação de Rust e glsl. O Glsl é usado para renderização, mas o Rust faz todo o resto: criação de mundo, manipulação de câmera e objeto, criação de ferramenta, reprodução de música etc.



Existem dependências no código de alguns recursos que ainda não estão incluídos no Rust estável, portanto, uso a caixa de ferramentas Ferrugem noturna. Para instalar e usar esse pacote padrão, execute os seguintes comandos rustup:



rustup toolchain install nightly
rustup default nightly


Estou usando o crinkler para compactar um arquivo de objeto gerado pelo compilador Rust.



Eu também usei um minificador de shader para pré-processar o shader glslpara torná-lo menor e mais amigável ao crinkler. O shader minifier não suporta saída para .rs, então peguei a saída bruta e a copiei manualmente no meu arquivo shader.rs (em retrospectiva ficou claro que eu precisava automatizar essa etapa de alguma forma. Ou mesmo escrever uma solicitação de recebimento para o shader minifier) ...



O ponto de partida foi minha introdução anterior em 4K no Rust , que parecia bastante lacônica na época. Esse artigo também fornece mais detalhes sobre a configuração do arquivo tomle como usar o xargo para compilar o minúsculo binário.



Otimização do design do programa para reduzir o código



Muitas das otimizações de tamanho mais eficazes não são hacks inteligentes. Este é o resultado de uma repensação do design.



No meu projeto original, uma parte do código criou o mundo, incluindo o posicionamento das esferas, e a outra parte foi responsável por mover as esferas. Em algum momento, percebi que o código de posicionamento e o código de movimento da esfera fazem coisas muito semelhantes, e você pode combiná-los em uma função muito mais complexa que faz as duas coisas. Infelizmente, essas otimizações tornam o código menos elegante e menos legível.



Análise de código do assembler



Em algum momento, você deve examinar o montador compilado e descobrir em que o código é compilado e em quais otimizações de tamanho valem a pena. O compilador Rust tem uma opção muito útil --emit=asmpara gerar código de montagem. O comando a seguir cria um arquivo assembler .s:



xargo rustc --release --target i686-pc-windows-msvc -- --emit=asm


Você não precisa ser um especialista em montagem para se beneficiar do aprendizado da saída do assembler, mas é definitivamente melhor ter um entendimento básico da sintaxe. Esta opção opt-level = "zforça o compilador a otimizar o código o máximo possível para o menor tamanho. Depois disso, é um pouco mais difícil descobrir qual parte do código de montagem corresponde a qual parte do código Rust.



Descobri que o compilador Rust pode ser surpreendentemente bom em minificar, remover código não utilizado e parâmetros desnecessários. Também faz algumas coisas estranhas, por isso é muito importante estudar o resultado na montagem de tempos em tempos.



Funções adicionais



Eu trabalhei com duas versões do código. Um deles registra o processo e permite ao espectador manipular a câmera para criar trajetórias interessantes. Rust permite definir funções para essas ações adicionais. O arquivo tomlpossui uma seção [features] que permite declarar os recursos disponíveis e suas dependências. Na tomlminha introdução 4K, tenho o seguinte perfil:



[features]
logger = []
fullscreen = []


Nenhuma das funções adicionais possui dependências; portanto, elas atuam efetivamente como sinalizadores de compilação condicional. Blocos de código condicionais são precedidos por uma declaração #[cfg(feature)]. O uso de funções por si só não diminui o seu código, mas facilita muito o processo de desenvolvimento quando você alterna facilmente entre diferentes conjuntos de funções.



        #[cfg(feature = "fullscreen")]
        {
            //       ,    
        }

        #[cfg(not(feature = "fullscreen"))]
        {
            //       ,     
        }


Após examinar o código compilado, tenho certeza de que apenas os recursos selecionados estão incluídos.



Um dos principais usos das funções era habilitar o log e a verificação de erros para uma compilação de depuração. O carregamento do código e a compilação do glsl shader frequentemente falham e, sem mensagens de erro úteis, seria extremamente difícil encontrar problemas.



Usando get_unchecked



Ao colocar o código dentro do bloco, unsafe{}presumi que todas as verificações de segurança seriam desativadas, mas esse não é o caso. Todas as verificações usuais ainda são realizadas lá e são caras.



Por padrão, o intervalo verifica todas as chamadas para a matriz. Pegue o seguinte código de ferrugem:



    delay_counter = sequence[ play_pos ];


Antes da pesquisa da tabela, o compilador inserirá o código que verifica se play_pos não está indexado após o final da sequência e entra em pânico, se o fizer. Isso adiciona um tamanho significativo ao código, pois pode haver muitas dessas funções.



Vamos transformar o código da seguinte maneira:



    delay_counter = *sequence.get_unchecked( play_pos );


Isso diz ao compilador para não fazer nenhuma verificação de intervalo e apenas procurar a tabela. Esta é claramente uma operação perigosa e, portanto, só pode ser executada dentro do código unsafe.



Ciclos mais eficientes



Inicialmente, todos os meus loops foram executados idiomamente como esperado no Rust usando sintaxe for x in 0..10. Presumi que seria compilado o mais estreitamente possível. Surpreendentemente, este não é o caso. O caso mais simples:



for x in 0..10 {
    // do code
}


será compilado no código do assembly que faz o seguinte:



    setup loop variable
loop:
          
      ,   end
    //    
       loop
end:


considerando que o código a seguir



let x = 0;
loop{
    // do code
    x += 1;
    if x == 10 {
        break;
    }
}


compila diretamente para:



    setup loop variable
loop:
    //    
          
       ,   loop
end:


Observe que a condição é verificada no final de cada loop, tornando desnecessário um salto incondicional. Essa é uma pequena economia de espaço para um ciclo, mas eles realmente resultam em uma economia bastante boa quando há 30 ciclos no programa.



Outro problema muito mais difícil de entender com o loop idiomático de Rust é que, em alguns casos, o compilador adicionou algum código de configuração do iterador extra que realmente inchava o código. Ainda não descobri o que está causando essa configuração extra do iterador, pois sempre foi trivial substituir construções por for {}construções loop{}.



Usando instruções de vetor



Passei muito tempo otimizando o código glsl, e uma das melhores otimizações (que geralmente também faz o código funcionar mais rápido) é trabalhar com o vetor inteiro ao mesmo tempo, e não com cada componente por vez.



Por exemplo, o código de rastreamento de raios usa um algoritmo de malha rápida para verificar quais partes do mapa cada raio está visitando. O algoritmo original considera cada eixo separadamente, mas você pode reescrevê-lo para que considere todos os eixos ao mesmo tempo e não precise de ramificações. O Rust não possui um tipo de vetor próprio como o glsl, mas você pode usar elementos internos para instruí-lo a usar as instruções do SIMD.



Para usar as funções internas, eu converteria o seguinte código



        global_spheres[ CAMERA_ROT_IDX ][ 0 ] += camera_rot_speed[ 0 ]*camera_speed;
        global_spheres[ CAMERA_ROT_IDX ][ 1 ] += camera_rot_speed[ 1 ]*camera_speed;
        global_spheres[ CAMERA_ROT_IDX ][ 2 ] += camera_rot_speed[ 2 ]*camera_speed;


nisso:



        let mut dst:x86::__m128 = core::arch::x86::_mm_load_ps(global_spheres[ CAMERA_ROT_IDX ].as_mut_ptr());
        let mut src:x86::__m128 = core::arch::x86::_mm_load_ps(camera_rot_speed.as_mut_ptr());
        dst = core::arch::x86::_mm_add_ps( dst, src);
        core::arch::x86::_mm_store_ss( (&mut global_spheres[ CAMERA_ROT_IDX ]).as_mut_ptr(), dst );


que será um pouco menor (e muito menos legível). Infelizmente, por algum motivo, isso interrompeu a compilação de depuração, embora tenha funcionado bem na compilação do release. Claramente, o problema aqui é com o meu conhecimento dos componentes internos do Rust, não da linguagem em si. Vale a pena gastar mais tempo com isso ao preparar a próxima introdução em 4K, pois a redução na quantidade de código foi significativa.



Usando o OpenGL



Existem muitas caixas Rust padrão para carregar funções OpenGL, mas, por padrão, todas elas carregam um conjunto muito grande de funções. Cada função carregada ocupa algum espaço porque o carregador precisa saber seu nome. O Crinkler é muito bom em compactar esse tipo de código, mas não consegue se livrar completamente da sobrecarga, então tive que criar minha própria versão gl.rsque inclui apenas os recursos OpenGL necessários.



Conclusão



O objetivo principal era escrever uma introdução 4K competitivamente correta e provar que o Rust é adequado para demonstrações e cenários em que cada byte conta e você realmente precisa de algum controle de baixo nível. Como regra, apenas montador e C. foram considerados nessa área.O objetivo adicional era aproveitar ao máximo a oxidação idiomática.



Parece-me que lidei com a primeira tarefa com bastante sucesso. Nunca senti que Rust estava me segurando de alguma forma, ou que eu estava sacrificando desempenho ou recursos porque estava usando Rust e não C. A



segunda tarefa foi menos bem-sucedida. Há muito código inseguro que realmente não deveria estar lá.unsafetem um efeito destrutivo; é muito fácil usá-lo para executar rapidamente algo (por exemplo, usando variáveis ​​estáticas mutáveis), mas assim que o código não seguro aparece, ele gera ainda mais código não seguro e, de repente, está em todo lugar. No futuro, terei muito mais cuidado em usar unsafesomente quando realmente não houver alternativa.



All Articles