← Back to list

How the Compiler Decides What to Inline or Optimize in Embedded Code

Why some functions vanish into thin air, others stubbornly remain, and how Rust + LLVM quietly rewrite your firmware behind your back.

TheOpinionatedDev · 2026-01-11 20:59 · 4 claps · 4.9 min read paywalled
#compilers #inline #rust-programming-language #optimization #programming
Open on Medium ↗
Wiki topics: 💻 · Programming 🔧 · Data Engineering

How the Compiler Decides What to Inline or Optimize in Embedded Code

Why some functions vanish into thin air, others stubbornly remain, and how Rust + LLVM quietly rewrite your firmware behind your back.

There’s a moment every embedded developer hits sooner or later.

You write a clean helper function. You mark it #[inline]. You build your firmware. You open the disassembly.

…and the function is still there.

Then another function — one you didn’t care about at all — completely disappears.

No call. No symbol. No trace.

That’s when it hits you:

Inlining and optimization aren’t promises. They’re negotiations.

And the compiler is the one holding all the leverage.

This article is about how the Rust compiler actually decides what to inline, remove, reorder, or brutally optimize away when you’re writing embedded or no_std code — and why its decisions are often smarter than a human with a stopwatch.

First: A Hard Truth About Embedded Optimization

Most people think optimization works like this:

“I write code → compiler makes it faster.”

Reality is harsher and more interesting:

The compiler builds a cost model, simulates multiple futures, and chooses the version that best fits the target CPU, memory layout, and call graph.

Especially in embedded.

Inlining isn’t about speed alone. It’s about:

  • code size
  • instruction cache pressure
  • branch prediction
  • register pressure
  • stack usage
  • pipeline depth
  • call frequency
  • target architecture quirks

On a microcontroller, saving 8 bytes can matter more than saving 2 cycles.

The Big Picture: Who Actually Decides?

Let’s get this straight first.

Rust does not decide inlining.

LLVM does.

Rust:

  • parses your code
  • enforces ownership
  • monomorphizes generics
  • lowers everything to MIR
  • hands it to LLVM

LLVM:

  • builds the call graph
  • estimates costs
  • runs inlining heuristics
  • performs all real optimizations

So when we say “the compiler decided”, we really mean:

Rust prepared the battlefield. LLVM chose who lives.

The Embedded Compilation Pipeline (Mental Model)

Rust Source
   |
   v
HIR (High-level IR)
   |
   v
MIR (ownership, moves, drops)
   |
   v
LLVM IR (pure SSA, no Rust rules)
   |
   v
LLVM Optimizer
   ├─ Inliner
   ├─ Const Prop
   ├─ DCE
   ├─ Loop Opt
   ├─ Register Alloc
   |
   v
Target Backend (ARM / RISC-V)
   |
   v
Machine Instructions

Inlining decisions happen inside LLVM, after Rust is already gone.

That’s important.

Example 1: The Function That Vanishes

#![no_std]

fn square(x: u32) -> u32 {
    x * x
}
pub fn compute(v: u32) -> u32 {
    square(v) + 1
}

You might expect a function call.

But LLVM sees:

  • square is small
  • used once
  • pure (no side effects)
  • argument is a register
  • return value used immediately

So it rewrites compute as:

pub fn compute(v: u32) -> u32 {
    v * v + 1
}

Assembly (ARM Cortex-M):

mul r0, r0, r0
adds r0, r0, #1
bx lr

No call. No stack. No symbol for square.

This isn’t “aggressive optimization”. This is the default behavior.

How LLVM Decides to Inline (The Real Rules)

LLVM uses a cost model.

Not one rule — a weighted equation.

Some of the biggest factors:

1. Function size

Smaller functions are easier to inline.

Tiny arithmetic helpers? Gone.

Large state machines? Probably not.

2. Call frequency

If a function is:

  • called once → likely inline
  • called in a loop → maybe inline
  • called from many places → less likely

Inlining duplicates code. On embedded, code size matters.

3. Target architecture

Inlining rules change depending on:

  • ARM Cortex-M0 vs M7
  • RISC-V
  • AVR
  • instruction cache size
  • pipeline depth

On small MCUs, LLVM is more conservative.

4. Register pressure

Inlining increases live variables.

If inlining would spill registers to the stack, LLVM often refuses.

Stack access is expensive on MCUs.

5. Inlining enables other optimizations

This one is subtle but huge.

Inlining is often done not for speed directly, but because it enables:

  • constant propagation
  • dead code elimination
  • branch removal
  • loop unrolling

Inlining is a gateway drug.

Example 2: Inlining Enables Constant Folding

fn scale(x: u32, factor: u32) -> u32 {
    x * factor
}

pub fn compute() -> u32 {
    scale(10, 4)
}

After inlining:

pub fn compute() -> u32 {
    10 * 4
}

After constant folding:

pub fn compute() -> u32 {
    40
}

Assembly:

movs r0, #40
bx lr

The entire computation disappears.

Without inlining, this wouldn’t happen.

Why #[inline] Is Only a Hint

Let’s be honest: #[inline] is emotionally comforting — but weak.

#[inline]
fn helper(x: u32) -> u32 {
    x + 1
}

What it really means:

“Hey LLVM, this might be a good idea.”

LLVM can still say no.

#[inline(always)]

This is stronger — but still not absolute.

LLVM can still refuse if:

  • it would break correctness
  • it would explode code size
  • it would violate ABI rules

There is no absolute force-inline in Rust embedded code.

And that’s intentional.

Example 3: Why Big Functions Don’t Inline

fn process(data: &[u8]) -> u32 {
    let mut sum = 0;
    for b in data {
        sum += *b as u32;
    }
    sum
}

Inlining this into multiple call sites would:

  • duplicate loops
  • increase flash size
  • increase register pressure
  • increase I-cache pressure

LLVM usually keeps this as a call — even in embedded.

Speed isn’t everything. Predictability and size matter more.

Dead Code Elimination: The Most Brutal Optimization

This one surprises people.

fn debug_led() {
    // toggle GPIO
}

pub fn main_loop() {
    // debug_led();  // commented out
}

debug_led will not exist in the binary.

Not “unused”. Not “inactive”.

Gone.

Why?

Because Rust + LLVM see:

  • no references
  • no side effects (from the compiler’s POV)
  • no exports

In embedded builds, dead code elimination is ruthless.

This is why unused drivers, helpers, and abstractions simply vanish.

Ownership Helps Optimization (This Is the Secret)

Rust’s ownership rules give LLVM superpowers.

Example:

fn write(buf: &mut [u8]) {
    buf[0] = 1;
    buf[1] = 2;
}

LLVM knows:

  • buf has no aliases
  • nothing else can touch it
  • no hidden pointers exist

In C, LLVM must assume aliasing.

In Rust, it doesn’t.

That single fact enables:

  • better reordering
  • better vectorization
  • fewer loads
  • fewer stores
  • tighter code

This is huge in embedded.

Code Flow Diagram: Optimization Decision

Call Site
   |
   v
Is function small?
   |
   +-- No --> Keep call
   |
   Yes
   |
Is call hot?
   |
   +-- No --> Maybe inline
   |
   Yes
   |
Would inlining:
   - reduce branches?
   - enable const-prop?
   - avoid stack?
   |
   +-- Yes --> Inline
   |
   No --> Keep call

This happens thousands of times per build.

The Emotional Side (Yes, It Matters)

At first, this feels frustrating.

You want control. You want certainty. You want this function inlined and that one untouched.

But after enough firmware bugs… after enough stack overflows… after enough “why did this slow down?” moments…

You realize something:

The compiler is calmer than you are.

It doesn’t panic. It doesn’t guess. It doesn’t cargo-cult.

It measures. It simulates. It chooses.

And most of the time — especially in embedded — it chooses better than we would.

Final Thoughts

Inlining and optimization in embedded Rust are not magic.

They are:

  • cost models
  • architecture awareness
  • ownership-driven guarantees
  • ruthless dead code elimination
  • decades of LLVM tuning

When Rust compiles your firmware, it’s not “helping you a little”.

It’s re-authoring your program into something smaller, tighter, and more predictable than hand-written C.

Once you trust that process, embedded Rust stops feeling restrictive.

It starts feeling like having a senior compiler engineer sitting next to you — silently fixing your mistakes.


메타데이터
post_id
9032b7f91cbd
slug
how-the-compiler-decides-what-to-inline-or-optimize-in-embedded-code-9032b7f91cbd
url
https://medium.com/@theopinionatedev/how-the-compiler-decides-what-to-inline-or-optimize-in-embedded-code-9032b7f91cbd
canonical_url
https://medium.com/@theopinionatedev/how-the-compiler-decides-what-to-inline-or-optimize-in-embedded-code-9032b7f91cbd
author_url
https://medium.com/@theopinionatedev
status
ok
fetched_at
2026-07-13 12:20:31