Cómo escribí una introducción 4K en Rust, y ganó

Recientemente escribí mi primera introducción 4K en Rust y la presenté en Nova 2020, donde ganó el primer lugar en la competencia de introducción de la Nueva Escuela. Escribir una introducción de 4K es complicado. Esto requiere el conocimiento de muchas áreas diferentes. Aquí me centraré en las técnicas para acortar el código Rust tanto como sea posible.





Puede ver la demostración en Youtube , descargar el ejecutable en Pouet u obtener el código fuente de Github .



La introducción de 4K es una demostración en la que todo el programa (incluidos los datos) tiene 4096 bytes o menos, por lo que es importante que el código sea lo más eficiente posible. Rust tiene cierta reputación por construir ejecutables hinchados, por lo que quería ver si podría ser un código eficiente y conciso.



Configuración



Toda la introducción está escrita en una combinación de Rust y glsl. Glsl se usa para renderizar, pero Rust hace todo lo demás: creación de mundo, control de cámara y objetos, creación de herramientas, reproducción de música, etc.



Hay dependencias en el código de algunas características que aún no están incluidas en Rust estable, por lo que utilizo la caja de herramientas Óxido nocturno. Para instalar y usar este paquete predeterminado, ejecute los siguientes comandos de oxidación:



rustup toolchain install nightly
rustup default nightly


Estoy usando crinkler para comprimir un archivo de objeto generado por el compilador Rust.



También utilicé un minificador de sombreador para preprocesar el sombreador glslpara hacerlo más pequeño y más fácil de usar. El minificador de sombreadores no admite la salida a .rs, así que tomé la salida sin procesar y la copié manualmente en mi archivo shader.rs (en retrospectiva, estaba claro que necesitaba automatizar este paso de alguna manera. O incluso escribir una solicitud de extracción para el minificador de sombreadores) ...



El punto de partida fue mi introducción 4K anterior en Rust , que parecía bastante lacónica en ese entonces. Ese artículo también proporciona más detalles sobre la configuración del archivo tomly cómo usar xargo para compilar el pequeño binario.



Optimización del diseño del programa para reducir el código.



Muchas de las optimizaciones de tamaño más efectivas no son hacks inteligentes. Este es el resultado de un replanteamiento del diseño.



En mi proyecto original, una parte del código creó el mundo, incluida la colocación de las esferas, y la otra parte fue responsable de mover las esferas. En algún momento, me di cuenta de que el código de ubicación y el código de movimiento de la esfera hacen cosas muy similares, y puede combinarlos en una función mucho más compleja que hace ambas cosas. Desafortunadamente, tales optimizaciones hacen que el código sea menos elegante y menos legible.



Análisis de código de ensamblador



En algún momento, debe mirar el ensamblador compilado y averiguar en qué se compila el código y qué optimizaciones de tamaño valen la pena. El compilador Rust tiene una opción muy útil --emit=asmpara generar código de ensamblaje. El siguiente comando crea un archivo ensamblador .s:



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


No necesita ser un experto en ensamblaje para beneficiarse de aprender el resultado del ensamblador, pero definitivamente es mejor tener una comprensión básica de la sintaxis. Esta opción opt-level = "zobliga al compilador a optimizar el código tanto como sea posible para el tamaño más pequeño. Después de eso, es un poco más difícil determinar qué parte del código de ensamblaje corresponde a qué parte del código Rust.



Descubrí que el compilador Rust puede ser sorprendentemente bueno para minimizar, eliminar código no utilizado y parámetros innecesarios. También hace algunas cosas extrañas, por lo que es muy importante estudiar el resultado en el ensamblaje de vez en cuando.



Funciones adicionales



He trabajado con dos versiones del código. Uno registra el proceso y permite al espectador manipular la cámara para crear trayectorias interesantes. Rust le permite definir funciones para estas acciones adicionales. El archivo tomltiene una sección [características] que le permite declarar las características disponibles y sus dependencias. En tomlmi introducción 4K tengo el siguiente perfil:



[features]
logger = []
fullscreen = []


Ninguna de las funciones adicionales tiene dependencias, por lo que efectivamente actúan como indicadores de compilación condicional. Los bloques condicionales de código están precedidos por una declaración #[cfg(feature)]. El uso de funciones por sí solo no hace que su código sea más pequeño, pero hace que el proceso de desarrollo sea mucho más fácil cuando cambia fácilmente entre diferentes conjuntos de funciones.



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

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


Después de examinar el código compilado, estoy seguro de que solo se incluyen las funciones seleccionadas.



Uno de los principales usos de las funciones fue habilitar el registro y la comprobación de errores para una compilación de depuración. La carga del código y la compilación del sombreador glsl a menudo fallaban, y sin mensajes de error útiles sería extremadamente difícil encontrar problemas.



Usando get_unchecked



Al colocar el código dentro del bloque, unsafe{}supuse que todas las comprobaciones de seguridad estarían deshabilitadas, pero este no es el caso. Todos los controles habituales todavía se realizan allí, y son caros.



Por defecto, el rango verifica todas las llamadas a la matriz. Tome el siguiente código de Rust:



    delay_counter = sequence[ play_pos ];


Antes de la búsqueda de la tabla, el compilador insertará un código que verifica que play_pos no esté indexado más allá del final de la secuencia, y entra en pánico si lo hace. Esto agrega un tamaño significativo al código porque puede haber muchas de esas funciones.



Transformemos el código de la siguiente manera:



    delay_counter = *sequence.get_unchecked( play_pos );


Esto le dice al compilador que no haga ninguna verificación de rango y solo busque la tabla. Esta es claramente una operación peligrosa y, por lo tanto, solo se puede realizar dentro del código unsafe.



Ciclos mas eficientes



Inicialmente, todos mis bucles se ejecutaron idiomáticamente como se esperaba en Rust usando la sintaxis for x in 0..10. Supuse que se compilaría en un bucle lo más ajustado posible. Sorprendentemente, este no es el caso. El caso más simple:



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


se compilará en un código de ensamblaje que haga lo siguiente:



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


mientras que el siguiente código



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


compila directamente a:



    setup loop variable
loop:
    //    
          
       ,   loop
end:


Tenga en cuenta que la condición se verifica al final de cada ciclo, lo que hace innecesario un salto incondicional. Este es un pequeño ahorro de espacio para un ciclo, pero realmente se suman a un ahorro bastante bueno cuando hay 30 ciclos en el programa.



Otro problema mucho más difícil de comprender con el bucle idiomático de Rust es que, en algunos casos, el compilador agregó un código de configuración de iterador adicional que realmente hinchó el código. Todavía no he descubierto qué está causando esta configuración de iterador adicional, ya que siempre ha sido trivial reemplazar construcciones con for {}construcciones loop{}.



Usando instrucciones vectoriales



Pasé mucho tiempo optimizando el código glsl, y una de las mejores optimizaciones (que generalmente también hace que el código funcione más rápido) es trabajar con todo el vector al mismo tiempo, en lugar de con cada componente por turno.



Por ejemplo, el código de trazado de rayos utiliza un algoritmo de recorrido de malla rápido para verificar qué partes del mapa está visitando cada rayo. El algoritmo original considera cada eje por separado, pero puede reescribirlo para que considere todos los ejes al mismo tiempo y no necesite ninguna rama. Rust en realidad no tiene un tipo de vector propio como glsl, pero puede usar elementos internos para decirle que use instrucciones SIMD.



Para usar las funciones integradas, convertiría el siguiente 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;


dentro de esto:



        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á un poco más pequeño (y mucho menos legible). Desafortunadamente, por alguna razón esto rompió la compilación de depuración, aunque funcionó bien en la compilación de lanzamiento. Claramente, el problema aquí es con mi conocimiento de los componentes internos de Rust, no con el lenguaje en sí. Vale la pena dedicar más tiempo a esto al preparar la próxima introducción 4K, ya que la reducción en la cantidad de código fue significativa.



Usando OpenGL



Hay muchas cajas Rust estándar para cargar funciones OpenGL, pero de forma predeterminada todas cargan un conjunto muy grande de funciones. Cada función cargada ocupa algo de espacio porque el cargador necesita saber su nombre. Crinkler es muy bueno para comprimir este tipo de código, pero no puede eliminar la sobrecarga por completo, por lo que tuve que crear mi propia versión gl.rsque solo incluyera las características de OpenGL que necesitaba.



Conclusión



El objetivo principal era escribir una introducción 4K competitiva y correcta y demostrar que Rust es adecuado para demoscene y escenarios donde cada byte cuenta y realmente se necesita un control de bajo nivel. Como regla general, solo se consideraron ensamblador y C en esta área. El objetivo adicional era aprovechar al máximo el óxido idiomático.



Me parece que he hecho frente a la primera tarea con bastante éxito. Nunca sentí que Rust me estaba frenando de alguna manera, o que estaba sacrificando el rendimiento o las características porque estaba usando Rust y no C. La



segunda tarea fue menos exitosa. Hay demasiado código inseguro que realmente no debería estar allí.unsafetiene un efecto destructivo; es muy fácil de usar para ejecutar rápidamente algo (por ejemplo, usando variables estáticas mutables), pero tan pronto como aparece un código inseguro, genera aún más código inseguro, y de repente está por todas partes. En el futuro, tendré mucho más cuidado de usar unsafesolo cuando realmente no haya otra alternativa.



All Articles