Wie ich ein 4K-Intro in Rust geschrieben habe - und es hat gewonnen

Ich habe kürzlich mein erstes 4K-Intro in Rust geschrieben und es auf der Nova 2020 vorgestellt, wo es den ersten Platz beim New School Intro-Wettbewerb gewann. Das Schreiben eines 4K-Intro ist schwierig. Dies erfordert Kenntnisse in vielen verschiedenen Bereichen. Hier werde ich mich auf Techniken konzentrieren, mit denen Rust-Code so weit wie möglich gekürzt werden kann.





Sie können die Demo auf Youtube ansehen , die ausführbare Datei auf Pouet herunterladen oder den Quellcode von Github erhalten .



Das 4K-Intro ist eine Demo, bei der das gesamte Programm (einschließlich aller Daten) 4096 Byte oder weniger umfasst. Daher ist es wichtig, dass der Code so effizient wie möglich ist. Rust hat den Ruf, aufgeblähte ausführbare Dateien zu erstellen, und ich wollte wissen, ob es sich um effizienten und präzisen Code handelt.



Aufbau



Das gesamte Intro ist in einer Kombination aus Rust und glsl geschrieben. Glsl wird zum Rendern verwendet, aber Rust erledigt alles andere: Weltenerstellung, Kamera- und Objektmanipulation, Werkzeugerstellung, Musikwiedergabe usw.



Der Code enthält Abhängigkeiten von einigen Funktionen, die noch nicht in stabilem Rust enthalten sind. Daher verwende ich die Toolbox Nächtlicher Rost. Führen Sie die folgenden Rustup-Befehle aus, um dieses Standardpaket zu installieren und zu verwenden:



rustup toolchain install nightly
rustup default nightly


Ich verwende Crinkler , um eine vom Rust-Compiler generierte Objektdatei zu komprimieren.



Ich habe auch einen Shader-Minifier verwendet, um den Shader glslvorzuverarbeiten, damit er kleiner und crinklerfreundlicher wird. Der Shader-Minifier unterstützt keine Ausgabe in .rs, daher habe ich die Rohausgabe genommen und manuell in meine Datei shader.rs kopiert (im Nachhinein war klar, dass ich diesen Schritt irgendwie automatisieren musste. Oder sogar eine Pull-Anfrage für den Shader-Minifier schreiben). ...



Der Ausgangspunkt war mein letztes 4K-Intro auf Rust , das damals ziemlich lakonisch wirkte. Dieser Artikel enthält außerdem weitere Informationen zum Konfigurieren der Datei tomlund zur Verwendung von xargo zum Kompilieren der winzigen Binärdatei.



Optimierung des Programmdesigns zur Reduzierung des Codes



Viele der effektivsten Größenoptimierungen sind keine intelligenten Hacks. Dies ist das Ergebnis eines Umdenkens des Designs.



In meinem ursprünglichen Projekt hat ein Teil des Codes die Welt geschaffen, einschließlich der Platzierung der Kugeln, und der andere Teil war für das Verschieben der Kugeln verantwortlich. Irgendwann wurde mir klar, dass der Platzierungscode und der Kugelbewegungscode sehr ähnliche Dinge tun, und Sie können sie zu einer viel komplexeren Funktion kombinieren, die beides bewirkt. Leider machen solche Optimierungen den Code weniger elegant und weniger lesbar.



Assembler-Code-Analyse



Irgendwann müssen Sie sich den kompilierten Assembler ansehen und herausfinden, in was der Code kompiliert wird und welche Größenoptimierungen sich lohnen. Der Rust-Compiler bietet eine sehr nützliche Option --emit=asmfür die Ausgabe von Assembly-Code. Der folgende Befehl erstellt eine Assembler-Datei .s:



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


Sie müssen kein Assembler-Experte sein, um die Ausgabe von Assembler zu erlernen, aber es ist definitiv besser, ein grundlegendes Verständnis der Syntax zu haben. Diese Option opt-level = "zzwingt den Compiler, den Code so weit wie möglich für die kleinste Größe zu optimieren. Danach ist es etwas schwieriger herauszufinden, welcher Teil des Assembler-Codes welchem ​​Teil des Rust-Codes entspricht.



Ich habe festgestellt, dass der Rust-Compiler überraschend gut darin ist, nicht verwendeten Code und unnötige Parameter zu minimieren, zu entfernen. Es macht auch einige seltsame Dinge, daher ist es sehr wichtig, das Ergebnis bei der Montage von Zeit zu Zeit zu untersuchen.



Zusätzliche Funktionen



Ich habe mit zwei Versionen des Codes gearbeitet. Man zeichnet den Prozess auf und ermöglicht dem Betrachter, die Kamera zu manipulieren, um interessante Flugbahnen zu erstellen. Mit Rust können Sie Funktionen für diese zusätzlichen Aktionen definieren. Die Datei tomlenthält einen Abschnitt [Features] , in dem Sie die verfügbaren Features und ihre Abhängigkeiten deklarieren können. In tomlmeinem Intro haben 4K folgendes Profil:



[features]
logger = []
fullscreen = []


Keine der zusätzlichen Funktionen weist Abhängigkeiten auf, sodass sie effektiv als bedingte Kompilierungsflags fungieren. Bedingten Codeblöcken geht eine Anweisung voraus #[cfg(feature)]. Die Verwendung von Funktionen allein macht Ihren Code nicht kleiner, erleichtert jedoch den Entwicklungsprozess erheblich, wenn Sie problemlos zwischen verschiedenen Funktionssätzen wechseln können.



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

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


Nach Prüfung des kompilierten Codes bin ich sicher, dass nur die ausgewählten Funktionen enthalten sind.



Eine der Hauptanwendungen der Funktionen bestand darin, die Protokollierung und Fehlerprüfung für einen Debug-Build zu aktivieren. Das Laden des Codes und das Kompilieren des glsl-Shaders schlug häufig fehl, und ohne hilfreiche Fehlermeldungen wäre es äußerst schwierig, Probleme zu finden.



Verwenden von get_unchecked



Beim Platzieren des Codes innerhalb des Blocks habe unsafe{}ich angenommen, dass alle Sicherheitsüberprüfungen deaktiviert sind, aber dies ist nicht der Fall. Dort werden noch alle üblichen Prüfungen durchgeführt, die teuer sind.



Standardmäßig überprüft range alle Aufrufe des Arrays. Nehmen Sie den folgenden Rostcode:



    delay_counter = sequence[ play_pos ];


Vor der Tabellensuche fügt der Compiler Code ein, der überprüft, ob play_pos nicht nach dem Ende der Sequenz indiziert ist, und gerät in Panik, wenn dies der Fall ist. Dies fügt dem Code eine signifikante Größe hinzu, da es viele solcher Funktionen geben kann.



Lassen Sie uns den Code wie folgt transformieren:



    delay_counter = *sequence.get_unchecked( play_pos );


Dies weist den Compiler an, keine Bereichsprüfungen durchzuführen und nur die Tabelle nachzuschlagen. Dies ist eindeutig eine gefährliche Operation und kann daher nur innerhalb des Codes ausgeführt werden unsafe.



Effizientere Zyklen



Anfangs liefen alle meine Schleifen wie erwartet in Rust idiomatisch mit Syntax for x in 0..10. Ich ging davon aus, dass es in einer möglichst engen Schleife kompiliert werden würde. Überraschenderweise ist dies nicht der Fall. Der einfachste Fall:



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


wird in Assembler-Code kompiliert, der Folgendes ausführt:



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


während der folgende Code



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


Kompiliert direkt zu:



    setup loop variable
loop:
    //    
          
       ,   loop
end:


Beachten Sie, dass die Bedingung am Ende jeder Schleife überprüft wird, sodass ein bedingungsloser Sprung nicht erforderlich ist. Dies ist eine kleine Platzersparnis für einen Zyklus, aber sie summieren sich wirklich zu einer ziemlich guten Einsparung, wenn das Programm 30 Zyklen enthält.



Ein weiteres, viel schwieriger zu fassendes Problem mit Rusts idiomatischer Schleife ist, dass der Compiler in einigen Fällen zusätzlichen Iterator-Setup-Code hinzugefügt hat, der den Code wirklich aufgebläht hat. Ich habe immer noch nicht herausgefunden, was dieses zusätzliche Iterator-Setup verursacht, da es immer trivial war, Konstrukte durch for {}Konstrukte zu ersetzen loop{}.



Verwenden von Vektoranweisungen



Ich habe viel Zeit damit verbracht, den Code zu optimieren glsl, und eine der besten Optimierungen (die normalerweise auch dazu führen, dass der Code schneller arbeitet) besteht darin, gleichzeitig mit dem gesamten Vektor und nicht mit jeder Komponente nacheinander zu arbeiten.



Beispielsweise verwendet der Raytracing-Code einen Fast- Mesh-Traversal-Algorithmus , um zu überprüfen, welche Teile der Karte jeder Ray besucht. Der ursprüngliche Algorithmus berücksichtigt jede Achse separat, Sie können sie jedoch so umschreiben, dass alle Achsen gleichzeitig berücksichtigt werden und keine Verzweigungen erforderlich sind. Rust hat eigentlich keinen eigenen Vektortyp wie glsl, aber Sie können Interna verwenden, um ihn anzuweisen, SIMD-Anweisungen zu verwenden.



Um die eingebauten Funktionen zu verwenden, würde ich den folgenden Code konvertieren



        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;


das mögen:



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


das wird etwas kleiner (und viel weniger lesbar). Leider hat dies aus irgendeinem Grund den Debug-Build unterbrochen, obwohl er im Release-Build einwandfrei funktioniert hat. Das Problem liegt hier eindeutig in meinen Kenntnissen der Rust-Interna, nicht in der Sprache selbst. Es lohnt sich, bei der Vorbereitung des nächsten 4K-Intro mehr Zeit darauf zu verwenden, da die Reduzierung der Codemenge erheblich war.



OpenGL verwenden



Es gibt viele Standard-Rostkisten zum Laden von OpenGL-Funktionen, aber standardmäßig laden alle eine sehr große Anzahl von Funktionen. Jede geladene Funktion nimmt etwas Platz ein, da der Loader seinen Namen kennen muss. Crinkler ist sehr gut darin, diese Art von Code zu komprimieren, aber es kann den Overhead nicht vollständig beseitigen, sodass ich meine eigene Version erstellen musste, gl.rsdie nur die OpenGL-Funktionen enthielt, die ich benötigte.



Fazit



Das Hauptziel war es, ein wettbewerbsfähig korrektes 4K-Intro zu schreiben und zu beweisen, dass Rust für Demoszenen und Szenarien geeignet ist, in denen jedes Byte zählt und Sie wirklich eine Steuerung auf niedriger Ebene benötigen. In diesem Bereich wurden in der Regel nur Assembler und C berücksichtigt. Das zusätzliche Ziel bestand darin, das Beste aus dem idiomatischen Rust herauszuholen.



Es scheint mir, dass ich die erste Aufgabe recht erfolgreich gemeistert habe. Es hatte nie das Gefühl, dass Rust mich irgendwie zurückhielt oder dass ich Leistung oder Funktionen opferte, weil ich Rust und nicht C verwendete. Die



zweite Aufgabe war weniger erfolgreich. Es gibt zu viel unsicheren Code, der wirklich nicht enthalten sein sollte.unsafehat eine zerstörerische Wirkung; Es ist sehr einfach, damit schnell etwas auszuführen (z. B. mit veränderlichen statischen Variablen). Sobald jedoch unsicherer Code angezeigt wird, wird noch mehr unsicherer Code generiert, und plötzlich ist er überall zu finden. In Zukunft werde ich nur dann viel vorsichtiger sein, unsafewenn es wirklich keine Alternative gibt.



All Articles