I am working on an IOT project which requires a simple HTTP server to run on OpenWrt Hardware (MIPS, with as little as 8MB flash storage to hold the entire OS). I decided to give Rust a try and knocked up a quick prototype for comparison. Firstly WOW! I though that Nim was a complex language until I tried Rust. Nim is much nicer to work with. But back to the point, the Rust binary has much less functionality than the Nim one, so this comparison is a little unfair to Nim:
Note: these prototype binaries are compiled on x86 linux (not the final MIPS target)
Build Type | Rust Binary | Nim Binary |
---|---|---|
Debug | 21,931,536 | 876,232 |
Release | 4,514,752 | 482,408 |
Release (Stripped) | 1,804,712 | 391,848 |
Opt Size (Stripped) | 1,214,784 | 277,136 |
Obviously there are many more things than binary size to consider when choosing a language, but for resource constrained devices, this is a show-stopper for Rust.
For completeness, the Opt Size binary on Rust is compiled with the --release option, with the following parameters in Cargo.toml:
[profile.release]
opt-level = 'z' # Optimize for size.
lto = true # Enable linke time optimization
codegen-units = 1
panic = 'abort'
The rust binary started out comparable with a 'Hello World' application which is why I prototyped. After adding the simple-server module to the rust version, there was no competition. The Nim version uses the asynchttp module, and has euantoranos serial.nim for other functionality, the Rust version is just the functioning HTTP Server.
Inspecting both binaries with ldd, they seem to both be dynamically linked:
Rust Version:
ldd usbserver
linux-vdso.so.1 (0x00007ffc1c1f7000)
libdl.so.2 => /usr/lib/libdl.so.2 (0x00007fc3a2704000)
libpthread.so.0 => /usr/lib/libpthread.so.0 (0x00007fc3a26e2000)
libgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007fc3a26c8000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007fc3a2501000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fc3a2885000)
Nim Version:
ldd main
linux-vdso.so.1 (0x00007fff5dfe5000)
libm.so.6 => /usr/lib/libm.so.6 (0x00007fddfd43c000)
librt.so.1 => /usr/lib/librt.so.1 (0x00007fddfd431000)
libdl.so.2 => /usr/lib/libdl.so.2 (0x00007fddfd42c000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007fddfd265000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fddfd6ab000)
libpthread.so.0 => /usr/lib/libpthread.so.0 (0x00007fddfd243000)
Rust pulls in pthread because simple-server is threaded by default, whereas asynchttp uses async methods. Which is another point, async/await support in Rust is very new, and seems (IMHO) to be very clunky when compared to async/await support in Nim.The tinyrocket article you link to is interesting. The author had to jump through a ton of hoops and then compress the binary with UPX to get values comparable to "nim -d:release && strip". Rust just doesn't provide enough advantages over Nim for me to go through that.
Getting Rust to work on OpenWrt cross toolchains is also a pain, Nim on the other hand is a breeze to integrate into the buildroot.
Just out of curiosity, I also did a spot check of the ram usage of both my binaries using pmap. It seems that rust is also a bit of a memory hog in comparison to nim:
Nim: 11,140K
Rust: 545,104K
I find this result very interesting. The Rust binary had only served 1 JSON API request of a few (static) bytes, whilst the Nim binary had been running for a few hours, had served maybe 50-100 requests, and was maintaining state, and a USB connection. I would have expected Rusts "borrow checking" memory allocation to be fairly efficient, but obviously it isn't!To get small executables with Nim you may try
nim c -d:danger --gc:arc -d:useMalloc -flto myprog.nim
Im best case use also gcc10, it can generate smaller binaries than gcc9 with -flto. (It shrinks my chess game to 105k.) Also there was a tutorial by Mr Felsing about shrinking, and maybe you should watch the last PMunch talk also.
Shared conditions:
hello.nim
echo "Hello, World!"
Nim compile conf
nim \
--gcc.exe:/usr//bin/musl-gcc \
--gcc.linkerexe:/usr/bin/musl-gcc \
--passC:"-flto" \
--passL:"-flto" \
--passL:"-static" \
--panics:on \
--gc:arc \
-d:release \
--opt:size \
c hello \
&& strip -s hello
hello.rs
fn main() {
println!("Hello, world!");
}
Cargo.toml
[package]
name = "min-sized-rust"
version = "0.1.0"
edition = "2021"
[profile.release]
strip = true # Automatically strip symbols from the binary.
opt-level = "z" # Optimize for size.
lto = true
codegen-units = 1
panic = "abort"
Rust compile conf
cargo +nightly build \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abort \
--target="x86_64-unknown-linux-musl" \
--release
Results (bytes):
If you know how to compile with clang and statically compile musl with Nim please let me know
Yes, you can use Zig (its C compiler feature). Just install Zig itself and then install https://github.com/enthus1ast/zigcc , after that you can tell Nim to use zigcc as the Clang binary. Then you just have to specify a C compiler option -target x86_64-linux-musl for it to statically link with musl.
By the way, another thing about Rust - are you actually sure build-std is needed? Nim has dead code elimination always enabled, so I'm not sure why do you mention the stdlib explicitly.
I'm not accustomed to Rust so I don't know how does it work regarding safety checks - are they enabled in release?
Some are, some are not. Signed Integer Over/Underflow is a panic in debug, but unchecked in release. Though array indices are range checked.
Thanks for the feedback.
By the way, another thing about Rust - are you actually sure that build-std is needed for Rust? Nim has dead code elimination always enabled, so I'm not sure why do you mention the stdlib explicitly.
Afaik, by default Rust links the precompiled stdlib blob statically, which is built for speed and not size. build-std let you choose your options, but compile speed is slower. Without this, the output size is much larger. See:
cargo +nightly build \
-Z build-std-features=panic_immediate_abort \
--target="x86_64-unknown-linux-musl" \
--release
= 370896 bytes
cargo +nightly build \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abort \
--target="x86_64-unknown-linux-musl" \
--release
= 51296 bytes
I'm not accustomed to Rust so I don't know how does it work regarding safety checks - are they enabled in release? If so, then it's fair, otherwise you need to use -d:danger as Nim has safety checks (like range checks) enabled with -d:release
I selected release instead of danger as I don't know about Rust runtime checks, but according to what @ElegantBeef says seems not the wrong choice here.
-d:useMalloc - make Nim use the default C malloc (in this case Musl's) instead of its own, which will bring the binary size down. Only relevant to ARC and ORC (there's a PR to add it to refc but it's unmerged)
I tried this flag, but it increased the binary size from 30392 to 34488 bytes
--os:any -d:posix - make Nim target the "any" OS with POSIX features enabled, also brings the size down but is only suitable for small CLI programs that don't interface with the OS that much
I flag this as unfair to Rust, but I tested it and reduced binary size from 30392 to 26296 bytes
-d:noSignalHandler - disable signal handling - Nim always registers signal handlers by default to catch things like SIGINT, and those handlers take a little bit of binary size, so disabling them reduces it.
Surprisingly, this decreased the binary size the same amount as previous flag: from 30392 to 26296 bytes
--threads:off - only applies to Nim 2.x as threads are disabled by default for Nim 1.x
No change in both stable and devel (if threads are not used it's not added by default?)
Yes, you can use Zig (its C compiler feature). Just install Zig itself and then install https://github.com/enthus1ast/zigcc , after that you can tell Nim to use zigcc as the Clang binary. Then you just have to specify a C compiler option -target x86_64-linux-musl for it to statically link with musl.
Thanks! Here some results with
nim \
--cc:clang --clang.exe="zigcc" --clang.linkerexe="zigcc" --forceBuild:on \
--passC:"-target x86_64-linux-musl" \
--passL:"-target x86_64-linux-musl" \
--passC:"-flto" \
--passL:"-flto" \
--passL:"-static" \
--panics:on \
--gc:arc \
-d:release \
--opt:size \
c hello \
&& strip -s hello
The output seems larger than gcc+musl: