Rustで4Kのイントロを書いた方法

私は最近、Rustで最初の4Kイントロを作成し、それをNova 2020で発表しました。そこでは、ニュースクールイントロコンペティションで1位を獲得しました。4Kのイントロを書くのは難しいです。これには、さまざまな分野の知識が必要です。ここでは、Rustコードをできるだけ短くする方法に焦点を当てます。





Youtube デモを見たり、Pouetで実行可能ファイルをダウンロードしたり、Githubからソースコードを入手したりできます。



4Kイントロは、プログラム全体(データを含む)が4096バイト以下のデモなので、コードができるだけ効率的であることが重要です。Rustは肥大化した実行可能ファイルの構築である程度の評判があるため、効率的で簡潔なコードにできるかどうかを確認したいと思いました。



構成



イントロ全体は、Rustとglslを組み合わせて書かれています。 Glslはレンダリングに使用されますが、Rustは他のすべてを行います:世界の作成、カメラとオブジェクトの制御、ツールの作成、音楽の再生など。



コードには、まだ安定したRustに含まれていない機能の依存関係があるため、ツールボックスを使用します毎晩の錆。このデフォルトのバンドルをインストールして使用するには、次のrustupコマンドを実行します。



rustup toolchain install nightly
rustup default nightly


私が使用していますcrinklerを錆コンパイラによって生成されたオブジェクトファイルを圧縮します。



また、シェーダーミニファイアー使用してシェーダーを前処理し、glslより小さく、よりクリンカーに適したものにしました。シェーダーミニファイアは.rsへの出力をサポートしていないため、生の出力を取り、それを手動で自分のshader.rsファイルにコピーしました(このステップを何らかの方法で自動化する必要があることは明らかでした。または、シェーダーミニファイアのプルリクエストを作成することもできます)。 ...



出発点は、Rustでの私の過去の4Kイントロでした。この記事では、ファイルの構成と、tomlxargoを使用して小さなバイナリをコンパイルする方法についても詳しく説明しています



コードを削減するためのプログラム設計の最適化



最も効果的なサイズ最適化の多くはスマートハックではありません。これはデザインの再考の結果です。



私の元のプロジェクトでは、球の配置を含むコードの一部が世界を作成し、他の部分は球の移動を担当していました。ある時点で、配置コードと球移動コードは非常によく似た動作をすることに気づきました。これらを組み合わせて、両方を行う1つのはるかに複雑な関数にすることができます。残念ながら、このような最適化により、コードはエレガントで読みにくくなります。



アセンブラコード分析



ある時点で、コンパイルされたアセンブラーを調べて、コードがコンパイルされる対象と、それに値するサイズ最適化を理解する必要があります。 Rustコンパイラには、--emit=asmアセンブリコードを出力するための非常に便利なオプションがあります。次のコマンドは、アセンブラファイルを作成します.s



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


アセンブラーの出力を学ぶことで利益を得るために、アセンブリーの専門家である必要はありませんが、構文の基本を理解している方が間違いなく優れています。このオプションopt-level = "zにより、コンパイラーはコードを可能な限り最小サイズに最適化します。その後、アセンブリコードのどの部分がRustコードのどの部分と一致するかを理解するのが少し難しくなります。



Rustコンパイラーは、未使用のコードと不要なパラメーターを縮小、削除するのに驚くほど優れていることがわかりました。また、奇妙なことを行うため、時々組み立ての結果を研究することは非常に重要です。



追加機能



私は2つのバージョンのコードで作業しました。1つはプロセスを記録し、視聴者がカメラを操作して興味深い軌跡を作成できるようにします。Rustでは、これらの追加アクションの関数を定義できます。このファイルにtoml、使用可能な機能とその依存関係を宣言できる[features]セクションがあります。toml、私のイントロ4Kには、以下のプロファイルを持っています:



[features]
logger = []
fullscreen = []


追加の関数はどれも依存関係がないため、条件付きコンパイルフラグとして効果的に機能します。コードの条件付きブロックの前には、ステートメントがあり#[cfg(feature)]ます。関数を単独で使用してもコードは小さくなりませんが、関数の異なるセットを簡単に切り替えると、開発プロセスがはるかに簡単になります。



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

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


コンパイルされたコードを調べたところ、選択した機能のみが含まれていることがわかりました。



関数の主な用途の1つは、デバッグビルドのログ記録とエラーチェックを有効にすることでした。コードのロードとglslシェーダーのコンパイルは失敗することが多く、役立つエラーメッセージがないと、問題を見つけるのが非常に困難になります。



get_uncheckedの使用



コードをブロック内に配置するときunsafe{}、すべてのセキュリティチェックが無効になると思いましたが、そうではありません。通常のチェックはすべてそこでも行われ、コストがかかります。



デフォルトでは、範囲は配列へのすべての呼び出しをチェックします。次のRustコードを見てください。



    delay_counter = sequence[ play_pos ];


テーブルルックアップの前に、コンパイラーはplay_posがシーケンスの最後を超えてインデックス付けされていないことを確認するコードを挿入し、インデックス付けされている場合はパニックします。このような関数が多数存在する可能性があるため、コードのサイズが大幅に増加します。



次のようにコードを変換してみましょう。



    delay_counter = *sequence.get_unchecked( play_pos );


これは、範囲チェックを行わずにテーブルを検索するようコンパイラーに指示します。これは明らかに危険な操作であるため、コード内でのみ実行できますunsafe



より効率的なサイクル



最初、すべてのループはRustで構文を使用して期待どおりに慣用的に実行されましたfor x in 0..10私はそれが可能な限りタイトなループでコンパイルされると仮定しました。驚いたことに、これは事実ではありません。最も単純なケース:



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


以下を実行するアセンブリコードにコンパイルされます。



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


一方、次のコード



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


直接コンパイルして:



    setup loop variable
loop:
    //    
          
       ,   loop
end:


条件は各ループの最後にチェックされるため、無条件ジャンプは不要になることに注意してください。これは1サイクルの小さなスペース節約ですが、プログラムに30サイクルある場合、実際にはかなり良い節約になります。



Rustの慣用ループの問題を把握するのがはるかに難しいもう1つの問題は、場合によっては、コンパイラーが追加のイテレーターセットアップコードを追加して、コードを実際に肥大化させたことです。コンストラクトをfor {}コンストラクトで置き換えるのは常に簡単なことなので、この余分なイテレータのセットアップの原因を理解できていませんloop{}



ベクトル命令の使用



私はコードの最適化に多くの時間を費やしましたが、最適glslな最適化(通常はコードの処理速度も向上します)の1つは、各コンポーネントではなく、ベクトル全体を同時に処理することです。



たとえば、レイトレーシングコードは高速メッシュトラバーサルアルゴリズム使用して、各レイがマップのどの部分にアクセスしているかをチェックします。元のアルゴリズムは各軸を個別に考慮しますが、すべての軸を同時に考慮し、分岐を必要としないように書き直すことができます。Rustには実際にはglslのような独自のベクトル型はありませんが、内部を使用して、SIMD命令を使用するように指示できます。



組み込み関数を使用するには、次のコードを変換します



        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;


これに:



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


これはわずかに小さくなります(そして読みにくくなります)。残念ながら、リリースビルドでは問題なく機能していましたが、何らかの理由でデバッグビルドに失敗しました。明らかに、ここでの問題は、言語自体ではなく、Rustの内部に関する私の知識にあります。コードの量が大幅に削減されたため、次の4Kのイントロを準備するときに、これにさらに時間を費やす価値があります。



OpenGLの使用



OpenGL関数をロードするための多くの標準Rustクレートがありますが、デフォルトではすべて非常に大きな関数のセットをロードします。ローダーはその名前を知る必要があるため、ロードされた各関数はいくらかのスペースを占有します。Crinklerはこの種のコードの圧縮に非常に優れていますが、オーバーヘッドを完全に取り除くことはできないため、gl.rs必要なOpenGL機能のみを含む独自のバージョンを作成する必要がありました。



結論



主な目標は、競争的に正しい4Kのイントロを書き、Rustがすべてのバイトが重要であり、低レベルの制御が本当に必要なデモシーンやシナリオに適していることを証明することでした。原則として、この領域ではアセンブラとCのみが考慮され、追加の目標は慣用的なRustを最大限に活用することでした。



最初の仕事にはかなりうまく対処できたようです。 Rustが何らかの形で私を引き留めているように感じたり、CではなくRustを使用していたためにパフォーマンスや機能を犠牲にしていると感じたことはありません



。2番目のタスクはあまり成功しませんでした。安全なコードが多すぎて、そこに入れるべきではありません。unsafe破壊的な効果があります。これを使用して何かをすばやく実行することは非常に簡単です(たとえば、可変の静的変数を使用)。ただし、安全でないコードが表示されるとすぐに、さらに安全でないコードが生成され、突然、すべての場所に配置されます。将来的には、unsafe本当に他に選択肢がないときにだけ使うようにもっと注意していきます



All Articles