I have liked Rust for a long time; its advantages are obvious, and its disadvantages are equally clear. It offers memory Safety without GC, 零成本抽象, 并发安全, arguably the best 工具链 and 配套外围建设, an extensive coverage range (from Web 云服务器 down to 单片机), and its error‑handling design is excellent. As for the downsides: a steep learning curve? Difficulty understanding Ownership, Borrow and similar model constraints; and writing code can be slow?

For a long time we have considered this a drawback of Rust, but in fact the first two can be solved by training our thinking and cognition. However, the slow compilation speed has continuously slowed down my workflow. A few years ago this seemed fine; before AI swept over, I had patience and would often write an algorithm or a feature until the early morning. But now? I can hardly say I haven't changed. I have become completely impatient, not wanting to think about how to write Rust code, unable to wait for Cargo’s long progress bar, especially when the ld stage shows no progress and just stalls—it’s truly uncomfortable.

Is there really no way around all of this? Actually, there is. About a month ago, I tried a few methods to speed up my Rust development workflow, including workflow optimizations and the like, but the waiting time was ultimately always stuck on Cargo. If we don't solve this, it won't address the root cause. So, taking this opportunity, I'd like to talk a bit about Cargo.toml configuration tuning in Rust development along with its supporting peripheral configurations.

Backend Theory

In Rust's design, LLVM is a bit lazy, but it’s a good choice; unlike Go, which maintains its own machine‑code generation process, letting LLVM’s large ecosystem handle mature optimizations works very well. The overall Rust compilation flow can be roughly described as follows: the source code first undergoes lexical⟦en-US⟧ In Rust's design, LLVM is a bit lazy, but it’s a good choice; unlike Go, which maintains its own machine‑code generation process, letting LLVM’s large ecosystem handle mature optimizations works very well. The overall Rust compilation flow can be roughly described as follows: the source code first undergoes lexical analysis and parsing to produce an AST, then macro expansion and HIR lowering flatten it. Next come the usual type checks and a small amount of borrow checking, finally generating MIR; up to this point, everything is handled by the Rust compiler’s front end. After MIR, the work is handed to LLVM, which translates MIR into LLVM IR, runs its series of optimization passes (ranging from O0 to O3), and finally emits machine code for the target platform, which the linker then assembles into an executable BIN file.

.rs sourceLexer + Parser→ ASTMacro expansionDesugaringHIR loweringName resolutionType checkingTrait solvingBorrow checkerLifetime validationMIR generationCFG,const eval,inliningLLVM IR codegenTranslate MIR → IRLLVM optimizationsO0 ~ O3 passesMachine codeTarget-specificLinkerSystem linkerExecutable binary

So where exactly does the problem lie? The answer is actually quite simple: LLVM is a heavy infrastructure—being heavy is right in a production environment, as trading compilation time for execution time is a great philosophy—and it bridges upper-level Rust as well as a whole host of languages like C, C++, and Swift. This means its optimization pipeline is designed for "generating optimal machine code," not for "completing compilation quickly." Running those dozens of optimization passes once in release mode is extremely time-consuming; even at O0 in debug mode, LLVM still has to complete the full pipeline of IR generation and machine code generation, a process that itself is far from lightweight. On top of that, Rust's generic monomorphization expands a massive amount of code at compile time, so the volume of IR handed to LLVM is vastly larger than your source code appears, and the workload LLVM has to process naturally inflates accordingly.

Cranelift

So essentially, a large portion of the slow compilation time isn’t spent on Rust’s own front‑end but on LLVM, this “heavyweight back‑end.” That raises the question: is it possible to drop it? The answer is yes, meow. There is a back‑end called Cranelift that was created for exactly this need. It originally started as Cretonne, launched in 2016, developed by the Bytecode Alliance. It was initially designed as the code‑generation back‑end for the Wasmtime package; later the Rust team adopted it as an optional codegen back‑end.

A low-level retargetable code generator. , github.com, opens in new tab
A screenshot of the Bytecode Alliance's documentation page for Cranelift, with a top navigation bar linking to Documentation, API Reference, Contributing, Chat, and a GitHub icon. Below a large "Cranelift" heading, the body text describes it as a fast, secure, relatively simple, and innovative compiler backend written in Rust that compiles intermediate representations into executable machine code, used by the Wasmtime WebAssembly virtual machine for JIT and AOT compilation and experimentally as a Rust compiler backend. It goes on to state that Cranelift currently supports x86-64, aarch64 (ARM64), s390x (IBM Z), and riscv64 platforms, is retargetable, and welcomes further ISA contributions, and notes it is actively maintained for running sandboxed untrusted code with close-to-native performance, following Wasmtime's release and security policies. The page serves as evidence of Cranelift's project scope, supported architectures, and its role within the Bytecode Alliance ecosystem.

So where should Cranelift actually be fast? The ideal stage is during development. At this time, you might not need near-perfect machine code that is maximally optimized for execution efficiency at all; maybe we just need fast feedback, as long as this not-so-great machine code is semantically equivalent to the machine code properly generated by LLVM. In its early days, Cranelift couldn't really achieve this because there were plenty of Edge Cases. While there are still some now, it has improved significantly. Nowadays, the probability of Cranelift working while LLVM breaks is roughly similar to triggering a rustc ICE when writing Rust. To generate maximally optimized machine code, LLVM runs dozens of optimization passes, such as loop unrolling, vectorization, constant propagation, dead code elimination, and many, many more, grinding through layer by layer. Cranelift’s design philosophy is completely different: it drastically cuts down on these optimization steps, performing only the most basic register allocation and instruction selection, completing code generation in a single linear scan without repeated iterations. At the same time, its IR design is much more lightweight, specifically tailored for rapidly translating higher-level IR into machine code, unlike LLVM IR which carries decades of generalization baggage.

MIRLLVM IRMulti-layer IR100+ opt passesO0 ~ O3,LTOMachine codeComplex reg allocLinkMIRCLIF IRSingle-layer IRLightweight optsFewer passesMachine codeSimple reg allocLink

So, now that we've covered the benefits, what's the catch? First, the obvious downside is that code generated by Cranelift has worse runtime performance than LLVM, being roughly 10%–30% slower depending on the scenario. But if you think about it, you'll realize this doesn't matter at all during the development phase. Why do I need it to be so fast? Are you benchmarking or doing a CI release? I just damn well want cargo build to run faster so I can see if the logic works out. Besides, I bet most of the programs you write won't spend over 80% of their time maxing out compute resources; there has to be business logic coming in, and how heavy can your workload even be during dev? The CPU is probably slacking off the whole time anyway! What I actually care about is seeing the results and verifying the logic. So trading compilation time for runtime efficiency—shouldn't that math be flipped on its head in the development loop?

Codegen Units

Additionally, let’s discuss codegen-units. This is actually a parameter that controls how many smallest units the compiler splits a crate into for the backend to process in parallel. By default, debug mode uses 256 units and release mode uses 16 units. The larger the number, the higher the parallelism and the faster the compilation—because multiple CPU cores can be used simultaneously to run backend code generation. The trade‑off is poorer optimization, as each unit is optimized independently; LLVM (or Cranelift) sees a smaller context, so cross‑unit inlining and optimization opportunities are reduced.

MIRCGU 1CGU 2CGU 3CGU …NLLVM opts + codegenLLVM opts + codegenLLVM opts + codegenLLVM opts + codegen.o.o.o.oLink

But in a real Release, you should generally use codegen-units = 1, an extreme option; only this way can the product achieve maximal optimization. After all, you’re using Rust—trading compilation time for runtime performance is only natural.

Optimization Level

Another setting that is often overlooked but can be tuned is opt-level. By default, it should be "3", but for Release, a more common practice is to set it to "z". This is a free artifact size optimization you can get without modifying code, so why not enable it? However, in development mode, this should be configured as "0", since completely disabling optimization yields the fastest speed.

Additionally, there's a small tip: Cargo.toml can actually set different optimization levels for dependencies and your own code.

toml
[profile.dev.package."*"]
opt-level = 3

This way, you can compile third-party dependencies with O3. This will only increase the time taken for the initial cold compilation, as subsequent builds will be incremental. Since external dependencies generally don't update frequently, using O3 for dependencies while using O0 for your own business logic provides a great dev mode experience that balances speed and binary size under normal circumstances. However, in Release mode, I still recommend maxing out with O3 or Z without question.

LTO (Link‑Time Optimization) is another thing that consumes a lot of memory and compilation performance, and it’s something most projects recommend enabling for Release builds. In a normal compilation, each crate is optimized independently, and the compiler backend can’t see the call relationships between crates, so certain cross‑crate inlining and dead‑code elimination can’t be performed, a situation that occurs very often; LTO breaks this boundary by giving the optimizer access to the IR of all crates at link time for a global optimization pass. However, never mindlessly add lto = true in the profile, as that will make your Rust compilation so slow you’ll question life; just make sure to add it when using a release profile.

Remove Debug Symbols

Just like everything else, Rust's debuginfo and symbols reside right here. Enabling strip in release mode can reduce the binary size significantly—an insane difference like (usually 50MB vs 5MB). Stripping symbols is also safer, after all, most frontend source code leaks occur when maps are accidentally pushed to npm :(

So there’s not much to say in the dev profile; you can’t just strip the debuginfo, otherwise you won’t be able to use gdb / lldb for normal debugging and the backtrace won’t show any meaningful function names. However, note that ArchLinux applies strip by default when packaging, which creates a small friction with us because we have already stripped it, potentially causing errors later. When writing an AUR package, you can explicitly skip this step.

Full binary.text.rodata / .data.symtab.debug_* (DWARF).eh_frame (unwind)strip=debuginfo.text.rodata / .data.symtab.debug_*.eh_frame (unwind)strip=symbols.text.rodata / .data.symtab.debug_*.eh_frame (unwind)+ panic="abort".text.rodata / .data.symtab.debug_*.eh_frame

Abandoning panic!

In normal business logic, panic should not exist. When normal business code encounters an error at runtime, any Err designed with fault tolerance should be safely returned rather than violently panicking. Just like React ErrorBoundary, it should be treated normally as an error; panic should only be triggered when the code firmly believes it has entered an impossible, unrecoverable state. My personal philosophy is to abandon panic. Why? Because in practice, business code can be divided into 3 layers of testing: the first layer is the happy path, the second layer is error paths (one of an infinite number of possibilities), and the third layer consists of Edge Cases exhaustively found through fuzzing or collision testing. From an engineering standpoint, the correct path can achieve 100% test coverage, and for error paths, you can pick 1 out of n variants per category. Collision testing is purely a matter of time and is mostly not worth it; when your user base actually reaches that scale, you will naturally know when to do it. Usually, I don't do it at all.

So for good code, you should cover tests for one kind of happy path and at least one kind of error path. Covering just one error path is enough to test the case where an Err is returned, allowing you to write fallback logic, and thus you can naturally thiserror enumerate or anyhow intercept and flatten log records. In short, your code forces you to implement mechanisms to handle these situations; if you have achieved this layer, panic becomes useless for you—it can be understood as “theoretically impossible”, but even that is only theoretical. In the real world there are OS errors, memory errors, cosmic‑ray single‑bit flips, weird overflows… You can never cover all of these cases. So why avoid panic? Because these situations are mostly not your code's fault—about 90 % of the time they aren’t caused by you. The essence of panic is that when it occurs, Rust walks back up the call stack frame by frame, invoking each frame’s destructor (drop) to clean up resources. This process requires the compiler to generate extra unwind tables, which help you locate logical errors in your business code. If the errors are mostly not yours, what value does that information have? It also inflates the binary size and adds work for the linker—turning it off is the right solution. Once your code is well‑tested and you’re confident, you can configure panic = abort.

Actual Benchmark

All that talk is pointless; let's actually see the gains from these combinations. Here, I’ll take a small toy project I built a few months ago as an example.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 CSS                       5          152          113           16           23
 Go                       77        14556        12409          584         1563
 JavaScript               23         2506         1999          232          275
 JSON                     93         1780         1780            0            0
 Just                      1          408          275           71           62
 Makefile                  1           15            8            3            4
 Shell                    16         1440         1075          166          199
 SVG                       1            9            9            0            0
 TOML                     20          502          419            3           80
 TSX                      75         3257         2718          132          407
 TypeScript              390        36643        30350         1627         4666
─────────────────────────────────────────────────────────────────────────────────
 HTML                      2           41           32            7            2
 |- CSS                    1           37           37            0            0
 (Total)                               78           69            7            2
─────────────────────────────────────────────────────────────────────────────────
 Markdown                 95         5344            0         3578         1766
 |- BASH                   3            8            8            0            0
 |- Go                     2           21           21            0            0
 |- HTML                   1           25           15           10            0
 |- JSON                   6          317          317            0            0
 |- Rust                   1           10            9            0            1
 |- TSX                    1           11            9            0            2
 (Total)                             5736          379         3588         1769
─────────────────────────────────────────────────────────────────────────────────
 Rust                    181        31654        26951         1032         3671
 |- Markdown              86          581            0          564           17
 (Total)                            32235        26951         1596         3688
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                   980        99317        78554         8025        12738
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

In this project, the Rust portion is roughly 32K SCoL Rust, not including the size of dependencies.

::github
repo = "canmi21/seam"
align = "left"
ref = "a7f34cb47df324c84de7725296c9ef539e05b791"

This repository is actually a monorepo with many subprojects, but two typical examples can be identified within it. First, looking at the Skeleton package, its characteristics are obvious: it has a large amount of code and very few dependencies, making it the highest proportion of codegen in the whole project, which makes it well suited for Cranelift to shine. Additionally, this package is also very clean, with “clean” meaning it is pure Safe Rust.

::cargo
crate = "seam-skeleton"

On the other hand, the CLI package below is not quite suitable. Having many dependencies isn't really a problem—in theory, this better demonstrates efficiency—but here there is a classic either-or choice. You can see in the upper left corner that the ring dependency is marked as optional; that's because the project originally used aws-lc-rs, both of which are cryptographic algorithm Backends in Rust. But why make it optional? That is because ring is written in pure Rust, while the other is brought in via Asm assembly FFI, specifically designed for assembly acceleration on mainstream cpu architectures like x86 and arm64. Yet that is also where it falls short, because Cranelift's magic is limited strictly to pure Rust. Once you introduce Unsafe Code, or FFI C, ASM, etc., Cranelift's compatibility actually becomes extremely poor.

::cargo
crate = "seam-cli"

However, this is not unsolvable; you can actually select the backend with cfg and, in Cranelift mode, pass ring to the compiler. After configuring the dev and release profiles as described above, you can simply run a CLI compilation comparison. In this case, development is at least three times faster than release, even with cold compilation and no incremental builds; and using O0 and disabling LTO, the dev profile should be dozens of times faster than release in subsequent incremental updates. Because LTO‑type magic actually works by flattening the boundaries of each crate, once everything becomes a single unit, changing a piece of code forces the entire project to be recompiled, preventing proper incremental updates.

This is a screenshot of a terminal window titled "~/C/P/seam" showing the tail end of a `cargo build --release` run for a project called "seam." The scroll lists dozens of dependency crates compiling in sequence — walkdir, rand, tungstenite, sha2, toml, notify, rquickjs, clap and others — followed by the project's own workspace members such as seam-codegen, seam-skeleton, seam-injector-wasm, seam-engine-wasm, seam-server, seam-server-axum, seam-cli, and several example backends (demo-server-rust, github-dashboard-axum, markdown-demo-rust, i18n-demo-axum). The final lines confirm success: "Finished `release` profile [optimized] target(s) in 1m 05s," followed by an idle shell prompt reading `canmi@xyy ~/C/P/seam (main)>`. It is evidence of a clean, complete release build of a multi-crate Rust monorepo with WASM and Axum server components. A terminal screenshot shows the tail end of a Cargo build log for a Rust workspace called "seam", titled `~/C/P/seam` with the shell running under user `canmi` on branch `main`. Dozens of dependency crates compile in sequence (console, reqwest, clap, tokio-tungstenite, notify, indicatif, and others), followed by the project's own crates — seam-codegen, seam-server-axum, seam-skeleton, seam-injector-wasm, seam-engine-wasm, seam-cli, all at version 0.5.38 — and several example backends (github-dashboard-axum, i18n-demo-axum, markdown-demo-rust, demo-server-rust). The log ends with `Finished \\`dev\\` profile [unoptimized + debuginfo] target(s) in 19.23s`, indicating a successful debug build of the whole workspace including its example applications.

Looking at the overall results, these two aren't actually all that different—it's hardly a game-changer, but you can definitely notice the difference clearly. The main reason is that Apple Silicon is just ridiculously powerful; the single-core performance and memory bandwidth of M-series chips are insanely high, and heavy compute + heavy I/O tasks like compilation happen to leverage these exact strengths. Normally, running this on Linux would show an even bigger gap, but if you were to run something like Asahi Linux on a MacBook, Linux would definitely win out.

Legacy Linker

On Linux, Rust defaults to using GNU ld (bfd)—yes, that oldest and slowest one. Being single-threaded and processing both symbol resolution and relocation via serial scanning, it noticeably drags down performance once the project gets large. On Linux, you can try mold written by @Rui Ueyama; configuring it is super simple, just a matter of adding a single line inside .cargo/config.toml.

toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
This is a bar chart comparing link times across four linkers -- GNU ld 2.42, GNU gold 2.38, LLVM lld 19.0.0, and mold 2.4.0 -- on three programs of increasing binary size: MySQL 8.3 (0.47 GiB), Clang 19.0 (1.56 GiB), and Chromium 124 (1.35 GiB), with time to link in seconds on the vertical axis. GNU ld takes about 11 seconds on MySQL and roughly 42 seconds on Clang but has no bar for Chromium, GNU gold rises from about 7.5 seconds to 33 seconds to 27 seconds across the three, while LLVM lld and mold stay far lower throughout, with lld around 2, 5, and 6 seconds and mold consistently under 2 seconds. The chart is evidence that mold and LLVM lld scale far better with binary size than the traditional GNU ld and gold linkers, whose link times grow much more steeply as the program being linked gets larger.

The improvement is still quite noticeable, but unfortunately on macOS there is only sold, a commercial project. The good news, however, is that lld on macOS or Apple's built-in ld64 itself is no longer slow—especially the new linker introduced in Xcode 15, which brings a super dramatic speedup. The only downside to this thing is that on unattended macOS CI and after updates, you have to sudo Accept an agreement every time, which caused my scheduled CI tasks to fail several times.

MUSL

I personally am a big fan of MUSL and really dislike GNU glibc, but for development, I still recommend you use x86_64-unknown-linux-gnu. Why? Because it's fast. MUSL's biggest selling point is its ability to generate fully statically linked binaries—without depending on any dynamic libraries on the system, whatever you compile can be copied to any Linux machine and run right away, making it especially suitable for containers and embedded systems. However, its slowness also comes from static linking: since the linker needs to bundle everything in, the workload during the linking phase is significantly greater than with dynamic linking.

But the good news is that if you use macOS, you don't have to worry at all—aarch64-apple-darwin is currently the only choice. macOS does not support fully static linking by design, which is actually a good thing from the standpoint of compilation speed. So for the dev profile, use gnulibc on Linux and libSystem on macOS, while for the release profile, you can use musl on both macOS and Linux. Due to issues with macOS's default choice of linker and ar, the only thing worth noting is that you might need to configure .cargo/config.toml.

toml
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
ar = "x86_64-linux-musl-ar"

By the way, don't use zig at all; the nightly Rust's zbuild currently has big problems. When you need cross‑compilation and the host is Linux x86, we recommend cross. Using Docker to build works great and provides a full environment. If you prefer glibc, that also works fine, because glibc only maintains backward compatibility. Usually you compile against the glibc version of a major Debian release (e.g., Debian‑2). Otherwise a too‑new glibc will make many machines unable to run; this isn’t because a new feature was used, just because the version string is being checked to annoy you. If the host is mac, please use the native rustup target + cargo build --release .

UPX is a Bad Thing

Another point worth discussing is the use of UPX; I know that saying I like musl while also pursuing small binary size actually sounds quite contradictory and doesn't seem to make sense...

                 ooooo     ooo  ooooooooo.  ooooooo  ooooo
                 `888'     `8'  `888   `Y88. `8888    d8'
                  888       8    888   .d88'   Y888..8P
                  888       8    888ooo88P'     `8888'
                  888       8    888           .8PY888.
                  `88.    .8'    888          d8'  `888b
                    `YbodP'     o888o       o888o  o88888o


                    The Ultimate Packer for eXecutables
   Copyright (c) 1996-2026 Markus Oberhumer, Laszlo Molnar & John Reiser
                           https://upx.github.io

However, in practice, the existence of UPX solves this problem. The principle behind UPX is actually compressing your BIN and wrapping it with a decompression stub, decompressing it in memory at runtime before executing it. This allows the binary size to be reduced to about 30%–50% of the original, making the size advantage very obvious. But precisely because of this, combining UPX with GNU glibc dynamic linking leads to major issues: dynamically linked glibc binaries contain special sections and dynamic loading mechanisms that UPX compression might corrupt, resulting in dynamic libraries not being found at runtime segment fault; on the other hand, musl is exceptionally well-suited for UPX—it is fully static by default and internally stable, so decompression and compression after UPX are very clean. Therefore, adding an extra UPX step to musl's release CI is actually a huge plus.

Additionally, UPX shouldn't be abused. UPX is only suitable for long-running tasks or low-frequency tasks. For long-running tasks, UPX needs to decompress in memory upon startup; although this process is fully automatic, it requires a bit of cold start time. While this impact is usually negligible, it can be fatal on high-frequency paths. So launching a long-running task once and letting it run in the background is a great fit. However, I don't recommend mindlessly stuffing UPX into Docker images for long-running services, because in reality, this trades memory for disk space—a terrible trade-off. Furthermore, container Layer compression already achieves the same effect as UPX in reducing download size during distribution. Instead, the recommended environment is for certain CLI use cases: it saves a huge amount of disk space with virtually imperceptible startup decompression time. Plus, musl is naturally suited for CLI tools. Otherwise, I'd have to call out AUR helpers—that thing written in Go without static linking, which once caused yay on my ArchLinux to break due to missing dynamic libraries. Since it is itself a system package manager, I had no choice but to boot into a LiveISO and run chroot to unbrick the system.

Summary

Although these handy configurations can help you quickly distinguish between dev and release profiles, their benefits become increasingly obvious as the project code grows. However, I still recommend adding a CI job to run the Rust Stable LLVM build, preferably together with all your tests. This way you won’t need to switch to a nightly Rust locally, Edge Cases won’t bite you, and any problems will be caught by CI when you push. This is currently the most comfortable DX setup; that’s all for now, I’ll explore more next time when I have time.