Skip to content

docs: hello-world-small example size update #50

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 10, 2024

Conversation

polarathene
Copy link
Contributor

@polarathene polarathene commented Oct 5, 2024

Summary

  • Rust nightly (1.83) now builds these targets (from a glibc host) at 13-20KB smaller.
  • Delta reduced from the ~13KB difference to a 6KB difference.
  • When built with LLD musl is additionally smaller by 5KB, roughly on par with eyra at ~300 bytes delta (nightly fluctuations observed).

Reproduction

# Docker with Fedora 41 for the base reproduction environment:
$ docker run --rm -it --workdir /example fedora:41

# Prep environment:
$ dnf install -y gcc rustup nano
$ rustup-init -y --profile minimal --default-toolchain nightly --target x86_64-unknown-linux-gnu x86_64-unknown-linux-musl --component rust-src
$ . "$HOME/.cargo/env"

# Create basic hello world example:
$ cargo init
# Add the release profile:
# https://github.com/sunfishcode/eyra/blob/v0.17.0/example-crates/hello-world-small/Cargo.toml#L10-L16
$ nano Cargo.toml

musl (25KB)

$ RUSTFLAGS="-Z location-detail=none -C relocation-model=static -C target-feature=+crt-static" cargo +nightly build -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort --target x86_64-unknown-linux-musl --release

$ du --bytes target/x86_64-unknown-linux-musl/release/example
30288   target/x86_64-unknown-linux-musl/release/example

# NOTE:
# 24,984 bytes with `-C link-arg=-fuse-ld=lld`
# 389,744 bytes with LLD and without `-Z build-std` args

musl + zig (26.4KB)

dnf install -y zig
cargo install cargo-zigbuild

# Only differs by replacing the `build` sub-command with `zigbuild`:
$ RUSTFLAGS="-Z location-detail=none -C relocation-model=static -C target-feature=+crt-static" cargo +nightly zigbuild -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort --target x86_64-unknown-linux-musl --release

$ du --bytes target/x86_64-unknown-linux-musl/release/example
26424   target/x86_64-unknown-linux-musl/release/example

# NOTE:
# Zig does not presently support static glibc builds, nor is it compatible with Eyra due to duplicate `_start` from Zig
# 351,728 bytes without `-Z build-std` args (Zig uses LLD by default).

glibc (834KB)

# glibc static libs are needed for gnu target to link statically:
$ dnf -y install glibc-static

# Same command as before, only adjusted `--target`
$ RUSTFLAGS="-Z location-detail=none -C relocation-model=static -C target-feature=+crt-static" cargo +nightly build -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort --target x86_64-unknown-linux-gnu --release

$ du --bytes target/x86_64-unknown-linux-gnu/release/example
834224  target/x86_64-unknown-linux-gnu/release/example

# NOTE:
# No size difference when linking with LLD (slightly larger when linking with mold, as per usual)
# 1,121,168 bytes without `-Z build-std` args

eyra (24.7KB)

$ cargo add eyra --no-default-features
# `moreutils` provides the `sponge` command (or you could just edit via nano):
$ dnf -y install moreutils
# Add this line to the top of `src/main.rs`
$ echo 'extern crate eyra;' | cat - src/main.rs | sponge src/main.rs

# NOTE: Only differs by prepending `-C link-arg=nostartfiles`
$ RUSTFLAGS="-C link-arg=-nostartfiles -Z location-detail=none -C relocation-model=static -C target-feature=+crt-static" cargo +nightly build -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort --target x86_64-unknown-linux-gnu --release

$ du --bytes target/x86_64-unknown-linux-gnu/release/example
24616   target/x86_64-unknown-linux-gnu/release/example

# NOTE:
# No size difference when linking with LLD.
# Increased to 24,696 bytes on nightly 2 days later.
# 388,384 bytes without `-Z build-std` args

nostd reference

For an additional reference the nostd example that was added at a later date:

  • Presently builds with eyra to 5,592 bytes 😎 (with the same Cargo.toml release profile used below)

    • When built without the -Z build-std args increases to 10,144 bytes, or 7,608 bytes by adding -C linker-plugin-lto -C linker=clang -C link-arg=-flto=full -C link-arg=-fuse-ld=mold (note: eyra fails to build if -C link-arg=-fuse-ld=lld is used with -C linker-plugin-lto).
    • Nightly will be required to build for a while.
  • For the musl target (without eyra):

    • It builds to 13,464 bytes, but requires -C link-arg=-lc to build successfully. Adding -C link-arg=-fuse-ld=lld reduces this down to 3,776 bytes.
    • When built with Zig the size is 3,200 bytes, or 2,928 bytes after stripping some ELF strings (-C link-arg=-lc not required, Zig also defaults the linker to LLD already):
      objcopy \
        --remove-section=.comment \
        --remove-section=.note.gnu.build-id \
        target/x86_64-unknown-linux-musl/release/example
  • Static glibc gnu target (without eyra):

    • also builds smaller at 4,760 bytes. This also requires -C link-arg=-lc to be successful. (Nevermind that segfaults and unexpectedly dynamic links glibc. Without +crt-static it'll build dynamically linked successfully at 4,440 bytes)
    • This target requires -C link-arg=/usr/lib64/libc.a -C link-arg=/usr/lib/gcc/x86_64-redhat-linux/14/libgcc_eh.a (at least on Fedora 41 with the glibc-static package) to successfully build and weighs in at 692,976 bytes with the -Z build-std flags (otherwise 64 bytes larger at 693,040 bytes).
    • Possibly similar to the musl limitation, except the linker change barely decreases the size. Tried -C linker-plugin-lto -C linker=clang -C link-arg=-flto=full for cross-language LTO, but no improvement.

@polarathene
Copy link
Contributor Author

polarathene commented Oct 6, 2024

Reference

Unified example for the -gnu (glibc & eyra) + -musl targets for nostd:

src/main.rs:

#![no_std]
#![no_main]

// This approach to include eyra specific lines seems acceptable since Eyra only works with nightly:
// https://github.com/sunfishcode/c-ward/issues/144
#![cfg_attr(feature = "eyra", feature(cfg_match, lang_items), allow(internal_features))]
cfg_match! {
    cfg(feature = "eyra") => {
      extern crate eyra;

      #[global_allocator]
      static GLOBAL_ALLOCATOR: rustix_dlmalloc::GlobalDlmalloc = rustix_dlmalloc::GlobalDlmalloc;

      #[lang = "eh_personality"]
      extern "C" fn eh_personality() {}
    }
}

// NOTE: This differs from the official example,
// Provides visible feedback to stdout at the expense of extra size:
#[no_mangle]
pub extern "C" fn main() -> isize {
    const HELLO: &'static str = "Hello, world!\n";
    unsafe { write(1, HELLO.as_ptr() as *const i8, HELLO.len()) };
    0
}

extern "C" {
    fn write(fd: i32, buf: *const i8, count: usize) -> isize;
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }

build.rs:

fn main() {
  let target_is_musl = std::env::var("CARGO_CFG_TARGET_ENV")
    .is_ok_and(|v| v == "musl");
  let target_is_glibc = std::env::var("CARGO_CFG_TARGET_ENV")
    .is_ok_and(|v| v == "gnu");

  // Pass `-nostartfiles` to the linker, when Eyra is enabled.
  if cfg!(feature = "eyra") {
    println!("cargo:rustc-link-arg=-nostartfiles");
  } else {
    // NOTE: Not required when building with `cargo zigbuild`:
    if target_is_musl {
      println!("cargo:rustc-link-arg=-lc");
    }

    // NOTE: Absolute paths specific to Fedora 41 used here.
    // Not providing the static libraries will dynamically link libc and segfault at runtime.
    if target_is_glibc {
      println!("cargo:rustc-link-arg=/usr/lib64/libc.a");
      println!("cargo:rustc-link-arg=/usr/lib/gcc/x86_64-redhat-linux/14/libgcc_eh.a");
    }
  }
}

Cargo.toml:

[package]
name = "example"
version = "0.1.0"
edition = "2021"

[dependencies]
eyra = { version = "0.17.0", default-features = false, optional = true }
rustix-dlmalloc = { version = "0.1.0", features = ["global"], optional = true }

[features]
eyra = ["dep:eyra", "dep:rustix-dlmalloc"]

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"
strip = true

rust-toolchain.toml: (optional, pins nightly version used)

[toolchain]
profile = "minimal"
channel = "nightly-2024-10-04"
components = ["rust-src"]
targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"]

glibc (692,976 B)

# NOTE: The `-Z build-std*` args aren't doing much for the above `nostd` focused example:
# LLD as the linker benefits musl notably in size (~14KB => ~4KB), gnu targets minimally.
RUSTFLAGS="-Z location-detail=none -C link-arg=-fuse-ld=lld -C relocation-model=static -C target-feature=+crt-static" \
  cargo +nightly build --target x86_64-unknown-linux-gnu --release \
    -Z build-std=std,panic_abort \
    -Z build-std-features=panic_immediate_abort

eyra (5,712 B)

# NOTE: The `-Z build-std*` args aren't doing much for the above `nostd` focused example:
# LLD as the linker benefits musl notably in size (~14KB => ~4KB), gnu targets minimally.
RUSTFLAGS="-Z location-detail=none -C link-arg=-fuse-ld=lld -C relocation-model=static -C target-feature=+crt-static" \
  cargo +nightly build --target x86_64-unknown-linux-gnu --release \
    -Z build-std=std,panic_abort \
    -Z build-std-features=panic_immediate_abort \
    --features eyra

musl (3,952 B)

# LLD as the linker benefits musl notably in size (~14KB => ~4KB), gnu targets minimally.
RUSTFLAGS="-Z location-detail=none -C link-arg=-fuse-ld=lld -C relocation-model=static -C target-feature=+crt-static" \
  cargo +nightly build --target x86_64-unknown-linux-musl --release \
    -Z build-std=std,panic_abort \
    -Z build-std-features=panic_immediate_abort

@sunfishcode sunfishcode merged commit 5a96c77 into sunfishcode:main Oct 10, 2024
5 checks passed
@sunfishcode
Copy link
Owner

Thanks!

Also, if you're using #![no_std] and #![no_main], you may also be interested in using origin directly, which can produce even smaller binaries.

@polarathene
Copy link
Contributor Author

No worries!

This was just from having some time spare to go over my prior notes on the topic and do a revision / summary over my original issue ( #27 ).

I was a bit surprised with some of the musl insights, especially when changing to lld for the linker having a notable improvement.


Also, if you're using #![no_std] and #![no_main], you may also be interested in using origin directly, which can produce even smaller binaries.

I hit a bit of a snag not long after the docs PR here was merged nightly releases were failing due to a change that affected unwinding 0.2.2, but while unwinding 0.2.3 resolved that, in some builds with Eyra I encountered a new failure that didn't apply with unwinding 0.2.2.

Unfortunately due to the semver resolution (despite not changing my Eyra version), the unwinding crate would resolve to 0.2.3 (which will fail on my pinned nightly version in rust-toolchain.toml). So presently I need to pin unwinding 0.2.2 in my Cargo.lock or via an addition to my Cargo.toml along with the nightly pin, or run my patched unwinding 0.2.3 which apparently is not a valid fix (would break builds for others).

Just adding that context for anyone that lands here and attempts to reproduce the examples without a Cargo.lock 😅

@polarathene
Copy link
Contributor Author

polarathene commented Oct 19, 2024

As for size yes I got 352 bytes with your origin example IIRC, while a similar "Hello World" with rustix directly was 344 bytes. At that extreme -gnu / -musl target was irrelevant due to no libc usage?

I had come across this blog article prior to trying your origin example (they managed 640 bytes), where at the end their "Hello World" string version was 888 bytes.

The article (at least at the time I read it) was lacking information to reproduce their final result but after I saw the syscall usage to avoid libc I immediately thought rustix might let me do that without having to think about managing syscalls directly (I had not used rustix, but it was a nice reason to try it).

Origin Examples (352 & 504 bytes)

After that I got around to looking at the origin examples and realized you did roughly the same (but with proper error handling), and I got that down to 504 bytes:

# The nightly `-Z build-std` flags trim off almost 500 more bytes:
RUSTFLAGS='-C link-arg=-Wl,--build-id=none,--omagic,-z,nognustack -C link-arg=-fuse-ld=lld -C relocation-model=static -C target-feature=+crt-static -C link-arg=-nostartfiles' \
  cargo build --release --target x86_64-unknown-linux-gnu \
  -Z build-std=core,panic_abort \
  -Z build-std-features=panic_immediate_abort

# 720 bytes down to 504 bytes (nightly `.comment` content is larger than stable toolchain due to version info):
objcopy -R .comment target/x86_64-unknown-linux-gnu/release/example

NOTE: That was with origin = 0.23.0, since the current 0.23.1 release sets unwinding = 0.2.3 as the minimum, preventing me from using cargo update unwinding --precise 0.22.

With my rustix attempt, I do remember a bit of a slow down when looking at how to approach the exit() call as while I found it in rustix source, the docs didn't cover it.

Off-topic: Eyra docs.rs are failing to build, last successful docs publish was 0.16.0.

Hello World examples - 344 bytes + 456 bytes

For anyone interested in reproducing this, I'll share it, but at this point it doesn't help evaluate Origin or Eyra as it's too simple now there is no overhead that they can reduce:

#![no_std]
#![no_main]

#[no_mangle]
pub extern "C" fn _start() -> ! {
  exit(); // +8 bytes to size vs using `loop() {}`
}

fn exit() -> ! { unsafe { rustix::runtime::exit_thread(42) } }

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }
[package]
name = "example"
version = "0.0.0"
edition = "2021"

[dependencies]
rustix = { version = "0.38.37", default-features = false, features = ["runtime"] }

[profile.release]
lto = true
panic = "abort"
opt-level = "z"
strip = true
# Current stable Rust (1.81.0):
$ RUSTFLAGS='-C link-arg=-Wl,--build-id=none,--nmagic,-z,nognustack -C link-arg=-fuse-ld=lld -C relocation-model=static -C target-feature=+crt-static -C link-arg=-nostartfiles' \
  cargo build --release --target x86_64-unknown-linux-gnu

# Remove some extra weight:
$ objcopy -R .comment target/x86_64-unknown-linux-gnu/release/example

# Only 344 bytes:
$ du --bytes target/x86_64-unknown-linux-gnu/release/example
344     target/x86_64-unknown-linux-gnu/release/example

$ ldd target/x86_64-unknown-linux-gnu/release/example
        not a dynamic executable

# It works:
$ target/x86_64-unknown-linux-gnu/release/example
$ echo $?
42

For a little more functionality, add the stdio feature to the rustix dep, and in src/main.rs update _start() to call this method before exit():

#[inline(always)]
fn hello_world() {
  rustix::io::write(
    unsafe { rustix::stdio::stdout() },
    "Hello, world!\n".as_bytes()
  ).unwrap();
}

This will have some extra content we can trim away via other flags (if min size was the goal, these aren't always advised of course):

# Additional linker arg `--no-eh-frame-hdr`:
$ RUSTFLAGS='-C link-arg=-Wl,--build-id=none,--nmagic,-z,nognustack,--no-eh-frame-hdr -C link-arg=-fuse-ld=lld -C relocation-model=static -C target-feature=+crt-static -C link-arg=-nostartfiles' \
  cargo build --release --target x86_64-unknown-linux-gnu

# Also remove `.eh_frame`:
# NOTE: `--build-id=none` above is more optimal vs `-R .note.gnu.build-id` post-build:
$ objcopy -R .comment -R .eh_frame target/x86_64-unknown-linux-gnu/release/example

# Only 584 bytes:
$ du --bytes target/x86_64-unknown-linux-gnu/release/example
584     target/x86_64-unknown-linux-gnu/release/example

$ ldd target/x86_64-unknown-linux-gnu/release/example
        not a dynamic executable

$ target/x86_64-unknown-linux-gnu/release/example
Hello, world!

Alternatively, the --no-eh-frame-hdr and objcopy -R .eh_frame aren't relevant if you use -Z build-std=core -Z build-std-features=panic_immediate_abort, which when --nmagic is swapped for --omagic in this case results in 456 bytes (provides no improvement for the original 344 bytes version).


April 2025 update

Built with Rust 1.88.0-nightly (456 bytes) or Rust 1.86.0 (stable, 568 bytes).

Observation notes

Changes required:

  • #[no_mangle] => #[unsafe(no_mangle)]
  • When running the objcopy command, -R .eh_frame seems to cause a segmentation fault now for some reason (at least with the final "Hello, world!" (584 bytes) example), the size is increased to 784 bytes as a result.
    • Changing --nmagic to --omagic is compatible with -R .eh_frame, 648 bytes is the final size.
    • Building with --nmagic and using -R .eh_frame does work on stable rust (1.86.0), but requires removing -C link-arg=-fuse-ld=lld (nightly toolchain bundles lld). This produces a 568 bytes executable that doesn't segfault. Nightly still fails with this.
    • With the alternative build suggestion with -Z build-std + --omagic the size remains at 456 bytes. This requires nightly toolchain.

NOTE: -C link-arg=-fuse-ld=lld is a no-op with the nightly toolchain. Even when lld is installed as a system package, this arg (when used with the nightly toolchain) will prefer the bundled lld linker instead (add -C link-self-contained=no to avoid the bundled linker).

  • On stable toolchain you'll need the system package installed to use the linker
    • However lld no longer seems compatible with this optimization via objcopy -R .eh_frame for --nmagic (800 bytes without objcopy -R .eh_frame), but is compatible with --omagic instead (664 bytes).
    • Without --nmagic / --omagic, the size is 776 bytes after objcopy reduction.
    • Without -C link-arg=-fuse-ld=lld, nor --nmagic / --omagic, the size is 8,488 bytes. However when either --nmagic or --omagic is used that drops back down to 568 bytes (after objcopy reduction) and works without segfault. Thus using lld isn't too important for the reduction, but it does make a similar sizeable difference without --nmagic / --omagic optimizations applied.
    • On the nightly toolchain, when using the alternative -Z build-std, either --nmagic (512 bytes) / --omagic (456 bytes) works without a segfault when using either lld (bundled or system).
  • Before running objcopy you can inspect the .comment section to see the linker used (In Fedora 42, lld was 20.1.3, in Rust 1.88.0-nightly it is 20.1.2):
    $ readelf -p .comment target/x86_64-unknown-linux-gnu/release/example
    
    String dump of section '.comment':
      [     1]  rustc version 1.88.0-nightly (6bc57c6bf 2025-04-22)
      [    35]  Linker: LLD 20.1.2 (/checkout/src/llvm-project/llvm a9865ceca08101071e25f3bba97bba8bf0ea9719)

Build environment setup:

# Docker with Fedora 42 for the base reproduction environment:
$ docker run --rm -it --workdir /example fedora:42

# Prep environment:
$ dnf install -y gcc rustup nano
$ rustup-init -y --profile minimal --default-toolchain stable --target x86_64-unknown-linux-gnu x86_64-unknown-linux-musl --component rust-src
$ . "$HOME/.cargo/env"

Rustix - No std (568 B stable, 456 B nightly)

src/main.rs:

#![no_std]
#![no_main]

#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
  hello_world();
  exit();
}

fn exit() -> ! { unsafe { rustix::runtime::exit_thread(42) } }

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }

#[inline(always)]
fn hello_world() {
  rustix::io::write(
    unsafe { rustix::stdio::stdout() },
    "Hello, world!\n".as_bytes()
  ).unwrap();
}

Cargo.toml:

[package]
name = "example"
version = "0.1.0"
edition = "2024"

[dependencies]
rustix = { version = "1.0.0", default-features = false, features = ["runtime", "stdio"] }

[profile.release]
lto = true
panic = "abort"
opt-level = "z"
strip = true
# Current stable Rust (1.86.0):
$ RUSTFLAGS='-C link-arg=-Wl,--build-id=none,--nmagic,-z,nognustack,--no-eh-frame-hdr -C relocation-model=static -C target-feature=+crt-static -C link-arg=-nostartfiles' \
  cargo build --release --target x86_64-unknown-linux-gnu

# Remove some extra weight:
$ objcopy -R .comment -R .eh_frame target/x86_64-unknown-linux-gnu/release/example

# Only 568 bytes:
$ du --bytes target/x86_64-unknown-linux-gnu/release/example
568     target/x86_64-unknown-linux-gnu/release/example

$ ldd target/x86_64-unknown-linux-gnu/release/example
        not a dynamic executable

$ target/x86_64-unknown-linux-gnu/release/example
Hello, world!

musl (395 KB stable, 18.2 KB nightly)

Click to view

src/main.rs:

fn main() {
  println!("Hello, world!");
}

Cargo.toml:

[package]
name = "example"
version = "0.1.0"
edition = "2024"

[profile.release]
lto = true
panic = "abort"
opt-level = "z"
strip = true
# Add the nightly toolchain, musl target, and `rust-src` (to support `-Z build-std`):
# Biggest reduction for musl will come from building std without all the panic logic
$ rustup toolchain install nightly
$ rustup target install --toolchain nightly x86_64-unknown-linux-musl
$ rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu

# 410,000 bytes (some extra RUSTFLAGS will squeeze this down to 395,208):
$ cargo +stable build --release --target x86_64-unknown-linux-musl

# 22,488 bytes (with more verbosity you could squeeze this down to 18,200 bytes):
$ cargo +nightly build --release --target x86_64-unknown-linux-musl -Z build-std=core,std,panic_abort -Z build-std-features=panic_immediate_abort

Size breakdown:

# 410,000 (stable) vs 397,720 (nightly)
# NOTE: `-C target-feature=+crt-static` is implicit for the `musl` target, thus not required.
$ cargo +stable build --release --target x86_64-unknown-linux-musl
$ cargo +nightly build --release --target x86_64-unknown-linux-musl
# A minor reduction from using lld with musl target:
# 409,112 (stable) vs 391,712 (nightly)
RUSTFLAGS='-C link-arg=-fuse-ld=lld' cargo +nightly build --release --target x86_64-unknown-linux-musl
# Another minor reduction with linker args (`--nmagic` / `--omagic` not compatible would segfault):
# 405,144 (stable) vs 387,840 (nightly)
RUSTFLAGS='-C link-arg=-Wl,--build-id=none,-z,nognustack,--no-eh-frame-hdr -C link-arg=-fuse-ld=lld' cargo +nightly build --release --target x86_64-unknown-linux-musl
# Again when adding static relocation:
# 395,208 (stable) vs 379,504 (nightly)
RUSTFLAGS='-C link-arg=-Wl,--build-id=none,-z,nognustack,--no-eh-frame-hdr -C link-arg=-fuse-ld=lld -C relocation-model=static' cargo +nightly build --release --target x86_64-unknown-linux-musl

# Nightly only reductions:
# `-Z location-detail=none` has no effect to size in this minimal "hello world",
# not until paired with `-Z build-std=std,panic_abort`
# 379,504 => 301,856 vs 286,336 (with `-Z location-detail=none`)
$ RUSTFLAGS='-Z location-detail=none -C link-arg=-Wl,--build-id=none,-z,nognustack,--no-eh-frame-hdr -C link-arg=-fuse-ld=lld -C relocation-model=static' cargo +nightly build --release --target x86_64-unknown-linux-musl -Z build-std=std,panic_abort
# `-Z build-std-features=panic_immediate_abort` makes the largest reduction,
# `-Z location-detail=none` once again becomes redundant as it was only stripping content for panics.
# 286,336 => 18,440
$ RUSTFLAGS='-C link-arg=-Wl,--build-id=none,-z,nognustack,--no-eh-frame-hdr -C link-arg=-fuse-ld=lld -C relocation-model=static' cargo +nightly build --release --target x86_64-unknown-linux-musl -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort
# 18,440 => 18,200
$ objcopy -R .comment -R .eh_frame target/x86_64-unknown-linux-musl/release/example
# Still functional:
$ target/x86_64-unknown-linux-musl/release/example
Hello, world!

# When not using lld, the size difference is similar to when first introduced:
# 18,440 => 25,912 (dropping only lld) vs 22488 (dropping all RUSTFLAGS)
# This is also a rare scenario where additionally removing `-C relocation-model=static` actually
# reduces the weight. At this point the linker args remove less than 50 bytes, so drop those too.
$ cargo +nightly build --release --target x86_64-unknown-linux-musl -Z build-std=core,std,panic_abort -Z build-std-features=panic_immediate_abort
# 22,488 => 22,264
# Dropping the `.eh_frame` section would cause segfault now (since we didn't skip it via linker arg):
$ objcopy -R .comment -R .note.gnu.build-id target/x86_64-unknown-linux-musl/release/example
# Still functional:
$ target/x86_64-unknown-linux-musl/release/example
Hello, world!

musl no-std (3,336 B for both stable + nightly, 3000 B via Zig)

Click to view

Adapting the earlier unified no-std example just for the musl target.

src/main.rs:

#![no_std]
#![no_main]

#[unsafe(no_mangle)]
pub extern "C" fn main() -> isize {
    const HELLO: &'static str = "Hello, world!\n";
    unsafe { write(1, HELLO.as_ptr() as *const i8, HELLO.len()) };
    0
}

unsafe extern "C" {
    fn write(fd: i32, buf: *const i8, count: usize) -> isize;
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }

Cargo.toml:

[package]
name = "example"
version = "0.1.0"
edition = "2024"

[dependencies]

[profile.release]
lto = true
panic = "abort"
opt-level = "z"
strip = true

Size breakdown:

# Starting size: 9,792 bytes
$ RUSTFLAGS='-C link-arg=-lc' cargo +stable build --release --target x86_64-unknown-linux-musl
# `-C relocation-model=static` was not helpful and increased size this time to 13,456 bytes:
$ RUSTFLAGS='-C link-arg=-lc -C relocation-model=static' cargo +stable build --release --target x86_64-unknown-linux-musl
# `--nmagic` / `--omagic` seem incompatible with musl, but `-C link-arg=-fuse-ld=lld` makes a difference:
# 9,792 => 5,456
$ RUSTFLAGS='-C link-arg=-lc -C link-arg=-fuse-ld=lld' cargo +stable build --release --target x86_64-unknown-linux-musl
# `-C relocation-model=static` now makes a difference when paired with the `lld` linker:
# 5,456 => 3,944
$ RUSTFLAGS='-C link-arg=-lc -C link-arg=-fuse-ld=lld -C relocation-model=static' cargo +stable build --release --target x86_64-unknown-linux-musl
# Nightly is roughly the same, 8 bytes larger:
# 3,944 => 3,952 (however both stable + nightly after objcopy become 3,792)
$ RUSTFLAGS='-C link-arg=-lc -C link-arg=-fuse-ld=lld -C relocation-model=static' cargo +nightly build --release --target x86_64-unknown-linux-musl
objcopy -R .comment target/x86_64-unknown-linux-musl/release/example
# With extra link args and removal of the `.eh_frame` section size reduces a little further:
# 3,952 => 3,568 vs 3,336 (after objcopy)
# NOTE: No extra size reduction from: `-Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort`
$ RUSTFLAGS='-C link-arg=-lc -C link-arg=-fuse-ld=lld -C relocation-model=static -C link-arg=-Wl,--build-id=none,-z,nognustack,--no-eh-frame-hdr' cargo +nightly build --release --target x86_64-unknown-linux-musl
$ objcopy -R .comment -R .eh_frame target/x86_64-unknown-linux-musl/release/example
$ target/x86_64-unknown-linux-musl/release/example
Hello, world!

# With Zig (stable/nightly both produce the same size, no benefit from `-Z build-std`)
$ dnf install -y zig
$ cargo install cargo-zigbuild
# Removals: No need for `-lc`or `lld` with Zig, `-z,nognustack` is not supported:
# 3,568 => 3,192 vs 3,000 (after objcopy)
$ RUSTFLAGS='-C relocation-model=static -C link-arg=-Wl,--build-id=none,--no-eh-frame-hdr' cargo +nightly zigbuild --release --target x86_64-unknown-linux-musl
$ objcopy -R .comment -R .eh_frame target/x86_64-unknown-linux-musl/release/example
$ target/x86_64-unknown-linux-musl/release/example
Hello, world!

eyra (18 KB nightly)

Click to view

Eyra won't build on stable toolchain releases. Nightly only.

rust-toolchain.toml:

[toolchain]
profile = "minimal"
channel = "nightly-2025-04-14"
components = ["rust-src"]
targets = ["x86_64-unknown-linux-gnu"]

src/main.rs:

extern crate eyra;

fn main() {
  println!("Hello, world!");
}

Cargo.toml:

[package]
name = "example"
version = "0.1.0"
edition = "2024"

[dependencies]
eyra = { version = "0.21.0", default-features = false }

[profile.release]
lto = true
panic = "abort"
opt-level = "z"
strip = true

Size breakdown:

# Starting size: 391,952 bytes
$ RUSTFLAGS='-C target-feature=+crt-static -C link-arg=-nostartfiles' cargo build --release --target x86_64-unknown-linux-gnu
# With extra RUSTFLAGS:
# 391,952 => 375,944
$ RUSTFLAGS='-C link-arg=-Wl,--build-id=none,--nmagic,-z,nognustack,--no-eh-frame-hdr -C relocation-model=static -C target-feature=+crt-static -C link-arg=-nostartfiles' cargo build --release --target x86_64-unknown-linux-gnu

# Add `-Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort`:
# 375,944 => 18,816
$ RUSTFLAGS='-C link-arg=-Wl,--build-id=none,--nmagic,-z,nognustack,--no-eh-frame-hdr -C relocation-model=static -C target-feature=+crt-static -C link-arg=-nostartfiles' cargo build --release --target x86_64-unknown-linux-gnu -Z build-std=core,std,panic_abort -Z build-std-features=panic_immediate_abort
# Final reduction:
# 18,816 => 18,488
$ objcopy -R .comment -R .eh_frame target/x86_64-unknown-linux-gnu/release/example
$ target/x86_64-unknown-linux-gnu/release/example
Hello, world!

NOTE:

  • Using -C target-feature=+crt-static with Eyra requires -C relocation-model=static, or the opt-in Eyra feature experimental-relocate; otherwise it'll segfault at runtime (ref, the README doesn't specifically mention segfault outcome but does communicate the requirement).

  • Not using -C target-feature=+crt-static will have ldd report statically linked, but file or patchelf --print-interpreter will show the dependency on a libc interpreter.

  • When you've properly built a static linked executable, you'll get the following outputs where patchelf better communicates the lack of .interp / .dynamic sections in the ELF binary:

    $ patchelf --print-interpreter target/x86_64-unknown-linux-gnu/release/example
    patchelf: cannot find section '.interp'. The input file is most likely statically linked
    
    $ patchelf --print-needed target/x86_64-unknown-linux-gnu/release/example
    patchelf: cannot find section '.dynamic'. The input file is most likely statically linked
    
    $ ldd target/x86_64-unknown-linux-gnu/release/example
            not a dynamic executable
    
    # Statically linked with no interpreter:
    $ file target/x86_64-unknown-linux-gnu/release/example
    ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

    While the following only indicates via the interpreter:

    $ patchelf --print-interpreter target/x86_64-unknown-linux-gnu/release/example
    /lib64/ld-linux-x86-64.so.2
    
    # No output as no linked deps:
    $ patchelf --print-needed target/x86_64-unknown-linux-gnu/release/example
    
    $ ldd target/x86_64-unknown-linux-gnu/release/example
            statically linked
    
    # More reliably than ldd: `dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2`
    $ file target/x86_64-unknown-linux-gnu/release/example
    ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped

eyra no-std (5,392 B nightly)

Click to view

eyra has since added opt-in features for nostd builds to configure the allocator (for rustix-dlmalloc) and panic + eh handler stubs. Using those features trims down noise from src/main.rs 👍 (like the earlier unified no-std example)

src/main.rs:

#![no_std]
#![no_main]
#![feature(lang_items)]
#![allow(internal_features)]

extern crate eyra;

// NOTE: This differs from the official example (linked ref),
// Provides visible feedback to stdout at the expense of extra size (120 bytes):
// https://github.com/sunfishcode/eyra/blob/v0.21.0/example-crates/no-std/src/main.rs#L6-L9

#[unsafe(no_mangle)]
pub extern "C" fn main() -> isize {
    const HELLO: &'static str = "Hello, world!\n";
    unsafe { write(1, HELLO.as_ptr() as *const i8, HELLO.len()) };
    0
}

unsafe extern "C" {
    fn write(fd: i32, buf: *const i8, count: usize) -> isize;
}

Cargo.toml:

[package]
name = "example"
version = "0.1.0"
edition = "2024"

[dependencies]
eyra = { version = "0.21.0", default-features = false, features = ["global-allocator", "panic-handler-trap", "eh-personality-continue"] }
rustix-dlmalloc = { version = "0.2.1", features = ["global"] }

[profile.release]
lto = true
panic = "abort"
opt-level = "z"
strip = true

Size breakdown:

# Starting size: 7,808 bytes
$ RUSTFLAGS='-C relocation-model=static -C link-arg=-Wl,--build-id=none -C target-feature=+crt-static -C link-arg=-nostartfiles' cargo build --release --target x86_64-unknown-linux-gnu
# Add `-Z build-std` args:
# 7,808 => 5,608 (5,392 after objcopy)
$ RUSTFLAGS='-C relocation-model=static -C link-arg=-Wl,--build-id=none -C target-feature=+crt-static -C link-arg=-nostartfiles' cargo build --release --target x86_64-unknown-linux-gnu -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort
$ objcopy -R .comment target/x86_64-unknown-linux-gnu/release/example
$ target/x86_64-unknown-linux-gnu/release/example
Hello, world!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants