You can watch the demo on Youtube , download the executable on Pouet, or get the source code from Github .
The 4K intro is a demo where the entire program (including any data) is 4096 bytes or less, so it's important that the code is as efficient as possible. Rust has some reputation for building bloated executables, so I wanted to see if it could be efficient and concise code.
Configuration
The whole intro is written in a combination of Rust and glsl. Glsl is used for rendering, but Rust does everything else: world creation, camera and object control, tool creation, music playback, etc.
There are dependencies in the code on some features that are not yet included in stable Rust, so I use the toolbox Nightly Rust. To install and use this default bundle, run the following rustup commands:
rustup toolchain install nightly
rustup default nightly
I am using crinkler to compress an object file generated by the Rust compiler.
I also used a shader minifier to preprocess the shader
glsl
to make it smaller and more crinkler friendly. The shader minifier does not support output to .rs, so I took the raw output and manually copied it to my shader.rs file (hindsight it was clear that I needed to somehow automate this step. Or even write a pull request for the shader minifier) ...
The starting point was my past 4K intro on Rust , which seemed pretty laconic back then. That article also provides more details on configuring the file
toml
and how to use xargo to compile the tiny binary.
Optimization of program design to reduce code
Many of the most effective size optimizations are not smart hacks. This is the result of a rethinking of design.
In my original project, one part of the code created the world, including the placement of the spheres, and the other part was responsible for moving the spheres. At some point, I realized that the placement code and the sphere move code do very similar things, and you can combine them into one much more complex function that does both. Unfortunately, such optimizations make the code less elegant and less readable.
Assembler Code Analysis
At some point, you have to look at the compiled assembler and figure out what the code is compiled into and which size optimizations are worth it. The Rust compiler has a very useful option
--emit=asm
for outputting assembly code. The following command creates an assembler file .s
:
xargo rustc --release --target i686-pc-windows-msvc -- --emit=asm
You don't need to be an expert in assembly to benefit from learning the output of assembler, but it is definitely better to have a basic understanding of the syntax. This option
opt-level = "z
forces the compiler to optimize the code as much as possible for the smallest size. After that, it's a little more difficult to figure out which part of the assembly code corresponds to which part of the Rust code.
I've found that the Rust compiler can be surprisingly good at minifying, removing unused code and unnecessary parameters. It also does some strange things, so it is very important to study the result in assembly from time to time.
Additional functions
I've worked with two versions of the code. One records the process and allows the viewer to manipulate the camera to create interesting trajectories. Rust allows you to define functions for these additional actions. The file
toml
has a [features] section that allows you to declare the available features and their dependencies. In toml
my intro 4K have the following profile:
[features]
logger = []
fullscreen = []
None of the additional functions have dependencies, so they effectively act as conditional compilation flags. Conditional blocks of code are preceded by a statement
#[cfg(feature)]
. Using functions by itself doesn't make your code smaller, but it makes the development process much easier when you easily switch between different sets of functions.
#[cfg(feature = "fullscreen")]
{
// ,
}
#[cfg(not(feature = "fullscreen"))]
{
// ,
}
After examining the compiled code, I am sure that only the selected features are included.
One of the main uses of the functions was to enable logging and error checking for a debug build. Loading the code and compiling the glsl shader often failed, and without helpful error messages it would be extremely difficult to find problems.
Using get_unchecked
When placing the code inside the block,
unsafe{}
I kind of assumed that all security checks would be disabled, but this is not the case. All the usual checks are still performed there, and they are expensive.
By default, range checks all calls to the array. Take the following Rust code:
delay_counter = sequence[ play_pos ];
Before the table lookup, the compiler will insert code that checks that play_pos is not indexed past the end of the sequence, and panics if it does. This adds a significant size to the code because there can be many such functions.
Let's transform the code as follows:
delay_counter = *sequence.get_unchecked( play_pos );
This tells the compiler to not do any range checks and just look up the table. This is clearly a dangerous operation and therefore can only be performed within the code
unsafe
.
More efficient cycles
Initially, all of my loops ran idiomatically as expected in Rust using syntax
for x in 0..10
. I assumed that it would be compiled in as tight a loop as possible. Surprisingly, this is not the case. The simplest case:
for x in 0..10 {
// do code
}
will be compiled into assembly code that does the following:
setup loop variable
loop:
, end
//
loop
end:
whereas the following code
let x = 0;
loop{
// do code
x += 1;
if x == 10 {
break;
}
}
compiles directly to:
setup loop variable
loop:
//
, loop
end:
Note that the condition is checked at the end of each loop, making an unconditional jump unnecessary. This is a small space saving for one cycle, but they really add up to a pretty good saving when there are 30 cycles in the program.
Another, much more difficult to grasp problem with Rust's idiomatic loop is that in some cases the compiler added some extra iterator setup code that really bloated the code. I still haven't figured out what is causing this extra iterator setup, since it has always been trivial to replace constructs with
for {}
constructs loop{}
.
Using vector instructions
I spent a lot of time optimizing the code
glsl
, and one of the best optimizations (which usually also makes the code work faster) is to work with the entire vector at the same time, rather than with each component in turn.
For example, the ray tracing code uses a fast mesh traversal algorithm to check which parts of the map each ray is visiting. The original algorithm considers each axis separately, but you can rewrite it so that it considers all axes at the same time and does not need any branches. Rust doesn't actually have a vector type of its own like glsl, but you can use internals to tell it to use SIMD instructions.
To use the built-in functions, I would convert the following code
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;
into this:
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 );
which will be slightly smaller (and much less readable). Unfortunately, for some reason this broke the debug build, although it worked fine in the release build. Clearly the problem here is with my knowledge of Rust internals, not the language itself. It is worth spending more time on this when preparing the next 4K intro, since the reduction in the amount of code was significant.
Using OpenGL
There are many standard Rust crates for loading OpenGL functions, but by default they all load a very large set of functions. Each loaded function takes up some space because the loader needs to know its name. Crinkler is very good at compressing this kind of code, but it can't get rid of the overhead completely, so I had to create my own version
gl.rs
that includes only the OpenGL features I needed.
Conclusion
The main goal was to write a competitively correct 4K intro and prove that Rust is suitable for demoscene and scenarios where every byte counts and you really need low-level control. As a rule, only assembler and C were considered in this area. The additional goal was to make the most of idiomatic Rust.
It seems to me that I have coped with the first task quite successfully. It never felt like Rust was holding me back in some way, or that I was sacrificing performance or features because I was using Rust and not C. The
second task was less successful. There is too much unsafe code that really shouldn't be in there.
unsafe
has a destructive effect; it is very easy to use it to quickly execute something (for example, using mutable static variables), but as soon as unsafe code appears, it generates even more unsafe code, and suddenly it is all over the place. In the future, I will be much more careful to use unsafe
only when there really is no alternative.