Bagaimana saya menulis intro 4K di Rust - dan itu menang

Saya baru-baru ini menulis intro 4K pertama saya di Rust dan mempresentasikannya di Nova 2020, di mana ia memenangkan tempat pertama di Kompetisi Intro Sekolah Baru. Menulis intro 4K itu rumit. Ini membutuhkan pengetahuan tentang berbagai bidang. Di sini saya akan fokus pada teknik untuk cara mempersingkat kode Rust sebanyak mungkin.





Anda dapat menonton demo di Youtube , mengunduh executable di Pouet, atau mendapatkan kode sumber dari Github .



Intro 4K adalah demo di mana keseluruhan program (termasuk data apa pun) adalah 4.096 byte atau kurang, jadi penting agar kode seefisien mungkin. Rust memiliki reputasi untuk membangun executable yang membengkak, jadi saya ingin melihat apakah itu bisa menjadi kode yang efisien dan ringkas.



Konfigurasi



Seluruh intro ditulis dalam kombinasi Rust dan glsl. Glsl digunakan untuk rendering, tetapi Rust melakukan segalanya: penciptaan dunia, kamera dan kontrol objek, pembuatan alat, pemutaran musik, dll.



Ada dependensi dalam kode pada beberapa fitur yang belum termasuk dalam Rust yang stabil, jadi saya menggunakan toolbox Karat Malam Hari. Untuk menginstal dan menggunakan bundel default ini, jalankan perintah rustup berikut:



rustup toolchain install nightly
rustup default nightly


Saya menggunakan crinkler untuk mengkompres file objek yang dihasilkan oleh kompiler Rust.



Saya juga menggunakan shading minifier untuk preprocess shader glsluntuk membuatnya lebih kecil dan lebih ramah crinkler. Pengukur shader tidak mendukung output ke .rs, jadi saya mengambil output mentah dan secara manual menyalinnya ke file shader.rs saya (kalau dipikir-pikir, saya perlu mengotomatiskan langkah ini entah bagaimana. Atau bahkan menulis permintaan tarik untuk pengubah shader) ...



Titik awalnya adalah intro 4K masa lalu saya di Rust , yang tampaknya cukup singkat waktu itu. Artikel itu juga memberikan rincian lebih lanjut tentang mengkonfigurasi file tomldan cara menggunakan xargo untuk mengkompilasi biner kecil.



Optimalisasi desain program untuk mengurangi kode



Banyak optimasi ukuran yang paling efektif bukanlah peretasan cerdas. Ini adalah hasil dari pemikiran ulang desain.



Dalam proyek asli saya, satu bagian dari kode menciptakan dunia, termasuk penempatan bola, dan bagian lain bertanggung jawab untuk memindahkan bola. Pada titik tertentu, saya menyadari bahwa kode penempatan dan kode gerakan bola melakukan hal-hal yang sangat mirip, dan Anda dapat menggabungkannya menjadi satu fungsi yang jauh lebih kompleks yang melakukan keduanya. Sayangnya, optimasi seperti itu membuat kode kurang elegan dan kurang mudah dibaca.



Analisis Kode Assembler



Pada titik tertentu, Anda harus melihat assembler yang dikompilasi dan mencari tahu kode apa yang dikompilasi dan ukuran optimasi mana yang layak. Compiler Rust memiliki opsi yang sangat berguna --emit=asmuntuk mengeluarkan kode rakitan. Perintah berikut membuat file assembler .s:



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


Anda tidak perlu menjadi ahli dalam perakitan untuk mendapat manfaat dari mempelajari output assembler, tetapi jelas lebih baik untuk memiliki pemahaman dasar tentang sintaksis. Opsi ini opt-level = "zmemaksa kompiler untuk mengoptimalkan kode sebanyak mungkin untuk ukuran terkecil. Setelah itu, sedikit lebih sulit untuk mengetahui bagian mana dari kode rakitan yang sesuai dengan bagian mana dari kode Rust.



Saya telah menemukan bahwa kompiler Rust bisa sangat baik dalam meminimalkan, menghapus kode yang tidak digunakan dan parameter yang tidak perlu. Ini juga melakukan beberapa hal aneh, jadi sangat penting untuk mempelajari hasilnya dalam pertemuan dari waktu ke waktu.



Fungsi tambahan



Saya telah bekerja dengan dua versi kode. Satu merekam proses dan memungkinkan pemirsa untuk memanipulasi kamera untuk membuat lintasan yang menarik. Karat memungkinkan Anda untuk menentukan fungsi untuk tindakan tambahan ini. File ini tomlmemiliki bagian [fitur] yang memungkinkan Anda untuk mendeklarasikan fitur yang tersedia dan ketergantungannya. Dalam tomlintro 4K saya memiliki profil berikut:



[features]
logger = []
fullscreen = []


Tak satu pun dari fungsi tambahan memiliki dependensi, sehingga mereka secara efektif bertindak sebagai flag kompilasi bersyarat. Blok kode kondisional diawali dengan pernyataan #[cfg(feature)]. Menggunakan fungsi dengan sendirinya tidak membuat kode Anda lebih kecil, tetapi membuat proses pengembangan lebih mudah ketika Anda dengan mudah beralih di antara berbagai fungsi.



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

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


Setelah memeriksa kode yang dikompilasi, saya yakin hanya fitur yang dipilih yang disertakan.



Salah satu kegunaan utama dari fungsi-fungsi ini adalah untuk memungkinkan logging dan pengecekan error untuk membangun debug. Memuat kode dan kompilasi glsl shader sering gagal, dan tanpa pesan kesalahan yang membantu akan sangat sulit untuk menemukan masalah.



Menggunakan get_unchecked



Ketika menempatkan kode di dalam blok, unsafe{}saya berasumsi bahwa semua pemeriksaan keamanan akan dinonaktifkan, tetapi ini tidak terjadi. Semua cek biasa masih dilakukan di sana, dan itu mahal.



Secara default, range memeriksa semua panggilan ke array. Ambil kode Rust berikut:



    delay_counter = sequence[ play_pos ];


Sebelum pencarian tabel, kompiler akan menyisipkan kode yang memeriksa bahwa play_pos tidak diindeks melewati akhir urutan, dan panik jika tidak. Ini menambah ukuran signifikan pada kode karena ada banyak fungsi seperti itu.



Mari kita ubah kodenya sebagai berikut:



    delay_counter = *sequence.get_unchecked( play_pos );


Ini memberitahu kompiler untuk tidak melakukan pemeriksaan jangkauan dan hanya melihat tabel. Ini jelas operasi yang berbahaya dan karenanya hanya dapat dilakukan dalam kode unsafe.



Siklus yang lebih efisien



Awalnya, semua loop saya berjalan secara idiomatis seperti yang diharapkan di Rust menggunakan sintaks for x in 0..10. Saya berasumsi bahwa itu akan dikompilasi dalam satu loop ketat mungkin. Anehnya, ini bukan masalahnya. Kasus paling sederhana:



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


akan dikompilasi menjadi kode rakitan yang melakukan hal berikut:



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


sedangkan kode berikut



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


kompilasi langsung ke:



    setup loop variable
loop:
    //    
          
       ,   loop
end:


Perhatikan bahwa kondisi diperiksa pada akhir setiap loop, membuat lompatan tanpa syarat tidak diperlukan. Ini adalah penghematan ruang kecil untuk satu siklus, tetapi mereka benar-benar menambah penghematan yang cukup bagus ketika ada 30 siklus dalam program ini.



Lain, jauh lebih sulit untuk memahami masalah dengan loop idiomatik Rust adalah bahwa dalam beberapa kasus kompiler menambahkan beberapa kode pengaturan iterator tambahan yang benar-benar membengkak kode. Saya masih belum menemukan apa yang menyebabkan pengaturan iterator ekstra ini, karena selalu sepele untuk mengganti konstruk dengan for {}konstruk loop{}.



Menggunakan instruksi vektor



Saya menghabiskan banyak waktu untuk mengoptimalkan kode glsl, dan salah satu optimasi terbaik (yang biasanya juga membuat kode bekerja lebih cepat) adalah bekerja dengan seluruh vektor pada saat yang sama, daripada dengan masing-masing komponen pada gilirannya.



Misalnya, kode pelacakan ray menggunakan algoritma traversal mesh cepat untuk memeriksa bagian mana dari peta yang dikunjungi oleh setiap sinar. Algoritma asli mempertimbangkan setiap sumbu secara terpisah, tetapi Anda dapat menulis ulang sehingga mempertimbangkan semua sumbu pada saat yang sama dan tidak memerlukan cabang apa pun. Rust sebenarnya tidak memiliki tipe vektor sendiri seperti glsl, tetapi Anda dapat menggunakan internal untuk memerintahkannya menggunakan instruksi SIMD.



Untuk menggunakan fungsi bawaan, saya akan mengonversi kode berikut



        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;


dalam hal ini:



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


yang akan sedikit lebih kecil (dan jauh lebih mudah dibaca). Sayangnya, untuk beberapa alasan ini merusak build debug, meskipun itu berfungsi dengan baik dalam rilis build. Jelas masalahnya di sini adalah pengetahuan saya tentang Rust internal, bukan bahasa itu sendiri. Perlu menghabiskan lebih banyak waktu untuk hal ini ketika menyiapkan intro 4K berikutnya, karena pengurangan jumlah kode sangat signifikan.



Menggunakan OpenGL



Ada banyak peti Rust standar untuk memuat fungsi OpenGL, tetapi secara default semuanya memuat sekumpulan fungsi yang sangat besar. Setiap fungsi yang dimuat membutuhkan ruang karena loader perlu tahu namanya. Crinkler sangat pandai mengompresi kode semacam ini, tetapi tidak bisa menghilangkan overhead sepenuhnya, jadi saya harus membuat versi saya sendiri gl.rsyang hanya menyertakan fitur OpenGL yang saya butuhkan.



Kesimpulan



Tujuan utamanya adalah untuk menulis pengantar 4K yang benar secara kompetitif dan membuktikan bahwa Rust cocok untuk demoscene dan skenario di mana setiap byte dihitung dan Anda benar-benar memerlukan kontrol level rendah. Sebagai aturan, hanya assembler dan C yang dipertimbangkan di area ini.Tujuan tambahannya adalah memanfaatkan Rust secara idiomatis.



Sepertinya saya berhasil mengatasi tugas pertama dengan cukup sukses. Tidak pernah terasa seperti Rust menahan saya dalam beberapa cara, atau bahwa saya mengorbankan kinerja atau fitur karena saya menggunakan Rust dan bukan C.



Tugas kedua kurang berhasil. Ada terlalu banyak kode tidak aman yang seharusnya tidak ada di sana.unsafememiliki efek destruktif; sangat mudah untuk menggunakannya untuk mengeksekusi sesuatu dengan cepat (misalnya, menggunakan variabel statis yang dapat berubah-ubah), tetapi segera setelah kode yang tidak aman muncul, itu menghasilkan kode yang bahkan lebih tidak aman, dan tiba-tiba semuanya ada di tempat. Di masa depan, saya akan lebih berhati-hati menggunakan unsafehanya ketika benar-benar tidak ada alternatif.



All Articles