Comment j'ai écrit une intro 4K dans Rust - et ça a gagné

J'ai récemment écrit ma première intro 4K à Rust et je l'ai présentée à Nova 2020, où elle a remporté la première place au concours d'introduction de la nouvelle école. Écrire une intro 4K est délicat. Cela nécessite une connaissance de nombreux domaines différents. Ici, je vais me concentrer sur les techniques permettant de raccourcir le plus possible le code Rust.





Vous pouvez regarder la démo sur Youtube , télécharger l'exécutable sur Pouet ou récupérer le code source sur Github .



L'intro 4K est une démo où l'ensemble du programme (y compris toutes les données) est de 4096 octets ou moins, il est donc important que le code soit aussi efficace que possible. Rust a la réputation de créer des exécutables gonflés, alors je voulais voir si cela pouvait être un code efficace et concis.



Configuration



L'intro entière est écrite dans une combinaison de Rust et glsl. Glsl est utilisé pour le rendu, mais Rust fait tout le reste: création de monde, contrôle de la caméra et des objets, création d'outils, lecture de musique, etc.



Il y a des dépendances dans le code sur certaines fonctionnalités qui ne sont pas encore incluses dans Rust stable, j'utilise donc la boîte à outils Rouille nocturne. Pour installer et utiliser ce bundle par défaut, exécutez les commandes rustup suivantes:



rustup toolchain install nightly
rustup default nightly


J'utilise Crinkler pour compresser un fichier objet généré par le compilateur Rust.



J'ai également utilisé un minificateur de shader pour prétraiter le shader glslafin de le rendre plus petit et plus convivial. Le shader minifier ne prend pas en charge la sortie vers .rs, j'ai donc pris la sortie brute et l'ai copiée manuellement dans mon fichier shader.rs (avec le recul, il était clair que je devais automatiser d'une manière ou d'une autre cette étape. Ou même écrire une demande d'extraction pour le shader minifier) ...



Le point de départ était ma dernière intro 4K sur Rust , qui semblait assez laconique à l'époque. Cet article fournit également plus de détails sur la configuration du fichier tomlet comment utiliser xargo pour compiler le petit binaire.



Optimisation de la conception des programmes pour réduire le code



La plupart des optimisations de taille les plus efficaces ne sont pas des hacks intelligents. C'est le résultat d'une refonte du design.



Dans mon projet original, une partie du code créait le monde, y compris le placement des sphères, et l'autre partie était responsable du déplacement des sphères. À un moment donné, j'ai réalisé que le code de placement et le code de déplacement de sphère faisaient des choses très similaires et que vous pouviez les combiner en une fonction beaucoup plus complexe qui fait les deux. Malheureusement, de telles optimisations rendent le code moins élégant et moins lisible.



Analyse du code de l'assembleur



À un moment donné, vous devez examiner l'assembleur compilé et déterminer dans quoi le code est compilé et quelles optimisations de taille en valent la peine. Le compilateur Rust a une option très utile --emit=asmpour la sortie du code d'assemblage. La commande suivante crée un fichier assembleur .s:



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


Vous n'avez pas besoin d'être un expert en assemblage pour bénéficier de l'apprentissage de la sortie de l'assembleur, mais il est certainement préférable d'avoir une compréhension de base de la syntaxe. Cette option opt-level = "zforce le compilateur à optimiser le code autant que possible pour la plus petite taille. Après cela, il est un peu plus difficile de déterminer quelle partie du code d'assemblage correspond à quelle partie du code Rust.



J'ai trouvé que le compilateur Rust peut être étonnamment efficace pour réduire, supprimer le code inutilisé et les paramètres inutiles. Il fait aussi des choses étranges, il est donc très important d'étudier le résultat lors de l'assemblage de temps en temps.



Fonctions supplémentaires



J'ai travaillé avec deux versions du code. On enregistre le processus et permet au spectateur de manipuler la caméra pour créer des trajectoires intéressantes. Rust vous permet de définir des fonctions pour ces actions supplémentaires. Le fichier tomla une section [features] qui vous permet de déclarer les fonctionnalités disponibles et leurs dépendances. Dans tomlmon intro 4K ont le profil suivant:



[features]
logger = []
fullscreen = []


Aucune des fonctions supplémentaires n'a de dépendances, elles agissent donc comme des indicateurs de compilation conditionnelle. Les blocs de code conditionnels sont précédés d'une instruction #[cfg(feature)]. L'utilisation de fonctions en soi ne réduit pas la taille de votre code, mais facilite grandement le processus de développement lorsque vous basculez facilement entre différents ensembles de fonctions.



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

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


Après avoir examiné le code compilé, je suis sûr que seules les fonctionnalités sélectionnées sont incluses.



L'une des principales utilisations des fonctions était d'activer la journalisation et la vérification des erreurs pour une version de débogage. Le chargement du code et la compilation du shader glsl échouaient souvent, et sans messages d'erreur utiles, il serait extrêmement difficile de trouver des problèmes.



Utilisation de get_unchecked



En plaçant le code à l'intérieur du bloc, unsafe{}j'ai en quelque sorte supposé que tous les contrôles de sécurité seraient désactivés, mais ce n'est pas le cas. Tous les contrôles habituels y sont encore effectués, et ils coûtent cher.



Par défaut, range vérifie tous les appels au tableau. Prenez le code Rust suivant:



    delay_counter = sequence[ play_pos ];


Avant la recherche dans la table, le compilateur insérera du code qui vérifie que play_pos n'est pas indexé après la fin de la séquence, et panique si c'est le cas. Cela ajoute une taille significative au code car il peut y avoir de nombreuses fonctions de ce type.



Transformons le code comme suit:



    delay_counter = *sequence.get_unchecked( play_pos );


Cela indique au compilateur de ne faire aucune vérification de plage et de simplement rechercher la table. Il s'agit clairement d'une opération dangereuse et ne peut donc être effectuée que dans le code unsafe.



Des cycles plus efficaces



Au départ, toutes mes boucles fonctionnaient de manière idiomatique comme prévu dans Rust en utilisant la syntaxe for x in 0..10. J'ai supposé qu'il serait compilé dans une boucle aussi serrée que possible. Etonnamment, ce n'est pas le cas. Le cas le plus simple:



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


sera compilé en code d'assembly qui effectue les opérations suivantes:



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


alors que le code suivant



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


compile directement vers:



    setup loop variable
loop:
    //    
          
       ,   loop
end:


Notez que la condition est vérifiée à la fin de chaque boucle, ce qui rend inutile un saut inconditionnel. C'est un petit gain de place pour un cycle, mais ils s'ajoutent vraiment à une assez bonne économie quand il y a 30 cycles dans le programme.



Un autre problème beaucoup plus difficile à saisir avec la boucle idiomatique de Rust est que, dans certains cas, le compilateur a ajouté un code de configuration d'itérateur supplémentaire qui a vraiment gonflé le code. Je n'ai toujours pas compris ce qui cause cette configuration d'itérateur supplémentaire, car il a toujours été trivial de remplacer les constructions par des for {}constructions loop{}.



Utilisation d'instructions vectorielles



J'ai passé beaucoup de temps à optimiser le code glsl, et l'une des meilleures optimisations (ce qui accélère généralement le travail du code) est de travailler avec le vecteur entier en même temps, plutôt qu'avec chaque composant à son tour.



Par exemple, le code de traçage de rayons utilise un algorithme de traversée de maillage rapide pour vérifier les parties de la carte que chaque rayon visite. L'algorithme d'origine considère chaque axe séparément, mais vous pouvez le réécrire afin qu'il considère tous les axes en même temps et n'ait pas besoin de branches. Rust n'a pas réellement de type de vecteur propre comme glsl, mais vous pouvez utiliser des composants internes pour lui dire d'utiliser les instructions SIMD.



Pour utiliser les fonctions intégrées, je convertirais le code suivant



        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;


dans ceci:



        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 );


qui sera légèrement plus petit (et beaucoup moins lisible). Malheureusement, pour une raison quelconque, cela a cassé la version de débogage, bien que cela ait fonctionné correctement dans la version de version. Il est clair que le problème ici est lié à ma connaissance des composants internes de Rust, pas au langage lui-même. Cela vaut la peine de passer plus de temps là-dessus lors de la préparation de la prochaine intro 4K, car la réduction de la quantité de code était significative.



Utilisation d'OpenGL



Il existe de nombreuses caisses Rust standard pour charger des fonctions OpenGL, mais par défaut, elles chargent toutes un très grand ensemble de fonctions. Chaque fonction chargée prend de la place car le chargeur a besoin de connaître son nom. Crinkler est très bon pour compresser ce type de code, mais il ne peut pas se débarrasser complètement de la surcharge, donc j'ai dû créer ma propre version gl.rsqui n'inclut que les fonctionnalités OpenGL dont j'avais besoin.



Conclusion



L'objectif principal était d'écrire une intro 4K compétitive et de prouver que Rust convient aux démoscènes et aux scénarios où chaque octet compte et où vous avez vraiment besoin d'un contrôle de bas niveau. En règle générale, seuls l'assembleur et le C. ont été pris en compte dans ce domaine, l'objectif supplémentaire étant de tirer le meilleur parti de Rust idiomatique.



Il me semble que j'ai géré la première tâche avec beaucoup de succès. Je n'ai jamais eu l'impression que Rust me retenait d'une manière ou d'une autre, ou que je sacrifiais des performances ou des fonctionnalités parce que j'utilisais Rust et non C. La



deuxième tâche a été moins réussie. Il y a trop de code dangereux qui ne devrait vraiment pas être là.unsafea un effet destructeur; il est très facile de l'utiliser pour exécuter rapidement quelque chose (par exemple, en utilisant des variables statiques mutables), mais dès qu'un code dangereux apparaît, il génère encore plus de code dangereux, et tout à coup il est partout. À l'avenir, je serai beaucoup plus prudent de unsafene l' utiliser que lorsqu'il n'y a vraiment pas d'alternative.



All Articles