Shrinking Rust Static Libraries for Go

Article illustration 1

Developers who expose Rust code to Go usually do so via a C‑compatible static or dynamic library. Static linking is the Go default, producing a single binary that’s trivial to ship, but it comes at a cost: the archive can become massive. In the original case, a 132 MB libnickel_lang.a exceeded GitHub’s 50 MB upload limit, forcing the author to rethink the build pipeline.

Why Static Libraries Grow So Big

A static library is simply a collection of object files bundled together with an old‑school archive format. Each object file contains:

  1. Compiled machine code for the functions it defines.
  2. LLVM bitcode (when LTO is enabled) – the intermediate representation that the linker can still optimise.
  3. Debug and metadata sections that aid debugging but are irrelevant for distribution.
  4. Section headers for every tiny function, a feature that lets the linker drop unused code.

Because the archive is a dump of everything the compiler produced, it inevitably carries a lot of unused or redundant data.

Step‑by‑Step Size Reduction

The author’s strategy uses two complementary toolchains: the classic GNU/LLVM linker (ld) and the LLVM optimizer (opt). Below is a concise walk‑through.

1. Merge and Garbage‑Collect Sections

# Unpack the original archive
ar x libnickel_lang.a

# Link all objects into one relocatable object, dropping unreachable code
ld --relocatable --gc-sections -o merged.o *.o -u nickel_context_alloc -u nickel_context_free

# Re‑archive the cleaned object
ar rcs libsmaller_nickel_lang.a merged.o

Result: 107 MB – a 19 % reduction.

2. Strip LLVM Bitcode

The merged object still contains a huge .llvmbc section (≈84 MB). Removing it cuts the size dramatically.

objcopy --remove-section .llvmbc merged.o without_llvmbc.o
strip --strip-unneeded without_llvmbc.o -o stripped.o

Result: 25 MB.

3. Merge Tiny Sections

Rust places each function in its own section. After garbage collection, the remaining 48 000 section headers still waste space. A linker script consolidates them.

/* merge.ld */
SECTIONS {
  .text : { *(.text .text.*) }
  .rodata : { *(.rodata .rodata.*) }
}
ld --relocatable --script merge.ld stripped.o -o without_tiny_sections.o
ar rcs libsmaller_nickel_lang.a without_tiny_sections.o

Result: 19 MB – an 85 % reduction from the original.

LLVM‑Specific Workflow (Cross‑Platform)

On macOS, the linker lacks --gc-sections, so the author switches to an LLVM‑centric pipeline that operates on bitcode.

# Dump bitcode from each object
for f in ./*.o; do llvm-objcopy --dump-section=__LLVM,__bitcode="$f.bc" "$f"; done

# Merge all bitcode files
llvm-link -o merged.bc ./*.bc

# Internalize everything except the public API and run dead‑code elimination
opt \
  --internalize-public-api-list=nickel_context_alloc,... \
  --passes='internalize,globaldce' \
  -o small.bc merged.bc

# Re‑compile to machine code
llc --filetype=obj --relocation-model=pic small.bc -o small.o
ar rcs libsmaller_nickel_lang.a small.o

Result: 19 MB, identical to the classic method, but now fully portable across Linux, macOS, and Windows.

Trade‑offs and Practical Considerations

Technique Pros Cons
Classic ld + objcopy Fast (seconds), works on any static library Requires GNU tools, less portable to macOS without workarounds
LLVM bitcode pipeline Cross‑platform, eliminates the need for --gc-sections Slower (≈1 min), re‑compiles entire library
Stripping debug info Saves space Backtraces lose readability
Merging sections Removes 48 k section headers Loses ability to drop unused code in future link‑time optimisations

For the Nickel project, the LLVM route was chosen because it works everywhere and the final size is the same. The author also noted that the library’s public API is small; after stripping, only ~150 KB of dead code remains, so the loss of future optimisation potential is negligible.

The Dragonfire Connection

A newer tool, dragonfire, deduplicates object files across multiple static libraries. While it can save space, it cannot be combined with section merging because the two approaches target different redundancy levels. Nevertheless, dragonfire is a promising addition to the Rust‑to‑Go ecosystem.

Bottom Line

A 132 MB Rust static library can be trimmed to 19 MB with a combination of linker tricks and LLVM optimisations. The key takeaways for developers are:

  1. Unpack, merge, and GC‑sections first.
  2. Strip LLVM bitcode and debug sections for immediate gains.
  3. Merge tiny sections to eliminate header overhead.
  4. Choose the classic or LLVM pipeline based on target platforms.
  5. Accept a small loss of future optimisation for a massive size reduction.

These steps enable Go developers to pull in Rust libraries via go get without hitting repository size limits, while keeping binaries lean and portable.

Source: https://www.tweag.io/blog/2025-11-27-shrinking-static-libs/