Files
keydr/docs/BUILDING.md
T
thallada a728c758a7 perf(build): upgrade rust-i18n v3 -> v4 to fix codegen blowup
Release builds consumed 5+ GB and never completed on a 4-core / 7.6 GB
VM, thrashing the machine badly enough to lose SSH access.

Root cause was a codegen bug in rust-i18n v3, not machine size. Its
macro emitted one HashMap::from([...]) and one add_translations() call
per translation string, all in a single function body -- 8,652 of each
for our 21 locales / ~9,000 keys. LLVM scales superlinearly on function
size, so optimising that one initialiser took ~4.5 GB.

Verified via cargo expand (lib target):

                          v3.1.5    v4.2.1
  HashMap::from            8,652         0
  add_translations         8,652        21   (one per locale)
  expanded lines          93,026    73,444

Fixed upstream in v4.0.0.

Clean release builds, same machine:

  v3, opt-level=3, thin LTO      5.2 GB   stalled >18m, never finished
  v3, opt-level=1, no LTO        1.8 GB   15m30s   (previous workaround)
  v4, opt-level=3, thin LTO      2.08 GB  3m25s
  v4, stock cargo defaults       1.92 GB  2m17s

Also:
- Remove the opt-level=1 / lto=false workaround; cargo's release
  defaults are correct now. No shipped code is de-optimised.
- Keep only genuine small-machine hygiene in .cargo/config.toml:
  jobs=3 (keeps the box interactive), lld linker, and trimmed debug
  info on dev/test profiles.
- Document the diagnosis, the cargo expand / llvm-lines workflow that
  found it, and the systemd-based safety nets in docs/BUILDING.md.
- Note in main.rs why i18n! must appear in both crate roots (t! expands
  to crate::_rust_i18n_t, and main.rs re-declares the module tree
  rather than depending on the lib target).

Test suite passes; release binary verified working.
2026-08-07 00:17:21 +00:00

5.8 KiB

Building keydr

TL;DR

cargo build
cargo test
cargo build --release

Plain cargo. No wrapper, no memory ceiling, no reduced optimisation. A clean release build peaks at 1.92 GB and takes 2m17s on a 4-core / 7.6 GB VM.

If that's not what you're seeing, read on.


The build used to be unusable — here's what was actually wrong

Release builds consumed 5+ GB and never finished; the VM thrashed so badly that SSH stopped responding and needed a console reboot.

The obvious explanations were all wrong:

  • Not too many parallel jobs. The peak came from a single rustc process. A single process's memory is unaffected by --jobs.
  • Not insufficient RAM. 8 GB is fine for a project this size.
  • Not LTO (though LTO made it worse).

The cause was a codegen bug in rust-i18n v3. Its macro emitted one HashMap::from([...]) construction and one add_translations() call per translation string — all inside a single function body. With 21 locales and ~9,000 keys that's 8,652 separate HashMap constructions in one function. LLVM's optimiser scales superlinearly on function size, so it consumed ~4.5 GB trying to optimise that one initialiser.

Verified by expanding the macro:

rust-i18n v3.1.5 rust-i18n v4.2.1
HashMap::from constructions 8,652 0
add_translations calls 8,652 21 (one per locale)
Expanded lines (lib target) 93,026 73,444

Upstream fixed this in v4.0.0. Reproduce the check yourself:

cargo install cargo-expand
cargo expand --lib | grep -c 'HashMap::from'

The fix

cargo add rust-i18n@4

That's it. Measured on this box, same machine, clean builds:

Configuration Peak Time
v3, opt-level=3, thin LTO 5.2 GB stalled >18 min, never finished
v3, opt-level=3, no LTO 4.5 GB stalled >12 min, never finished
v3, opt-level=1, no LTO (workaround) 1.8 GB 15m30s
v4, opt-level=3, thin LTO 2.08 GB 3m25s
v4, stock cargo defaults 1.92 GB 2m17s

The earlier workaround — dropping opt-level to 1 and disabling LTO — has been removed. It was treating a symptom.


What's still in .cargo/config.toml, and why

Nothing there reduces the optimisation of shipped code.

  • jobs = 3 — uses 3 of 4 cores so the box stays interactive while compiling. Purely about responsiveness; delete it if you don't care.
  • lld linker — ships with the Rust toolchain, no install needed. GNU ld is single-threaded and holds the whole link graph in memory.
  • debug = 1 for this crate, debug = 0 for dependencies (dev/test profiles only). Debug info is the largest contributor to target/ size and link time. Level 1 keeps line numbers, so backtraces still work.

[profile.release] is deliberately absent — cargo's defaults are right.


Known remaining inefficiency

src/main.rs re-declares the whole module tree (mod app; mod config; ...) instead of depending on the keydr lib target, so every module compiles twice — once for the lib, once for the bin. The i18n! macro must therefore be invoked in both crate roots (t!() expands to crate::_rust_i18n_t, so it must exist in each crate).

With v4 this duplication is cheap (21 calls, not 8,652), so it's no longer worth fixing for build performance alone. If you ever restructure main.rs to use keydr::..., build time would roughly halve again.


If a build ever does go wild again

The machine-level protections below need no per-project changes.

# Reserve memory for the SSH daemon so it can't be swapped out entirely.
sudo systemctl edit ssh        # [Service] / MemoryMin=128M

# Kill on memory *pressure* rather than waiting for true OOM. This reacts
# during thrashing — exactly the window where the box currently becomes
# unreachable. Ubuntu ships it but leaves it inactive.
sudo systemctl enable --now systemd-oomd

earlyoom is the lighter-weight alternative (sudo apt install earlyoom); it kills only the single highest-scoring process, which during a build is rustc itself.

Cap one build ad-hoc (no root)

memory is delegated to the user slice on this box, so you can confine a single command without any wrapper script:

systemd-run --user --scope -p MemoryMax=4G -p MemorySwapMax=0 \
    cargo build --release

MemorySwapMax=0 is the important half. The lockup was never really an OOM — it was thrashing. With swap available the kernel pages sshd out to feed the build and the box goes catatonic while oom_kill stays at 0. Denying the build swap turns a slow total failure into a fast contained one.

Do not add MemoryHigh. It sounds safer but traps the process in continuous reclaim so it grinds forever instead of dying. Measured against an identical 256 MB ceiling: MemoryMax alone → clean kill in seconds; MemoryMax + MemoryHigh → still spinning after 45 seconds.

Diagnosing which crate is expensive

cargo build --release --timings      # HTML report in target/cargo-timings/
cargo install cargo-llvm-lines
cargo llvm-lines --release | head -30 # which functions generate the most IR
cargo expand --lib | wc -l            # how much code a macro really emits

cargo llvm-lines and cargo expand are what actually found this bug. Reach for them before touching opt-level.

Upstream context

Cargo has no memory-aware job scheduling; it schedules on core count only. That's a known gap (rust-lang/cargo#12912), and maintainers have said they'd rather delegate resource limits to the OS (cgroups) than build it into cargo. So the systemd approach above isn't a hack — it's the sanctioned answer. But in this case none of it was needed: the real fix was a dependency upgrade.