Whole Programimplementation

Link-Time Optimization

LTO is a scheduling trick, not a new optimization: the compiler writes IR into object files instead of machine code, and the linker — the first component that has all of them — hands them back to the optimizer before generating any.

The question

What is -flto actually doing, and what is the difference between full LTO and ThinLTO?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

An object file whose .text is not machine code but serialised compiler IR — LLVM bitcode or GCC's GIMPLE — accompanied by an ordinary symbol table so the linker can still resolve references without understanding the payload. This hybrid representation exists to answer a scheduling question rather than a semantic one: how do you defer optimization until every unit is present, without giving up the per-file frontend parallelism that made the build fast in the first place?

What this phase may assume or do

The optimizer may treat the combined bitcode as one program only for the symbols whose definitions are final at link time. A symbol that another shared object may interpose at load time, one that may be replaced by a LD_PRELOAD definition, or one whose address is compared for identity, must keep its separate definition and its indirection. Beyond that, every transformation applied to the merged module carries its own ordinary precondition — LTO grants a wider view, never wider permission, so an inlining or devirtualization that would be illegal within one unit is still illegal across several.

Key points

  • LTO changes when code generation happens, not what optimizations exist: object files carry IR, and the optimizer runs after the linker has resolved symbols.
  • The linker feeds symbol resolution back to the optimizer, which is what lets it know whether a definition is final rather than merely present.
  • Full LTO merges everything for an exact view and destroys build parallelism, incrementality and memory headroom.
  • ThinLTO combines compact per-module summaries into a global index, plans which bodies each module imports, and runs the backend in parallel — trading exactness for parallelism, cacheability and distributability.
  • Because code generation moves to link time, target and sanitizer flags must be supplied at the link as well or they are silently ignored.
  • LTO does not introduce undefined behavior or ODR violations; it makes existing ones exploitable, which is why long-standing code can break when it is enabled.

The trick: emit IR, resolve late, generate later

Ordinarily the compiler runs the whole pipeline per file and hands the linker finished machine code. By then it is far too late for any cross-file reasoning: the linker sees bytes and relocations, and cannot inline a function it can only recognise as a range of instructions.

LTO changes only *when* code generation happens. Each -c compile runs the frontend, the type checker and usually a cheap first optimization pass, then serialises the IR into the object file and stops. The linker — via a plugin, because a linker does not know what bitcode is — recognises these files, resolves symbols across all of them, feeds the resolution back to the optimizer, and the optimizer generates real machine code with the full set of definitions in hand.

The symbol resolution feedback is the part that is easy to miss and is genuinely load-bearing. The optimizer needs to know not merely that a definition exists but whether *this* definition is the one that will win: whether it is preemptible, whether it is also defined in a shared library being linked, whether anything outside the bitcode set references it. Only the linker knows, which is why the two components have to talk rather than merely take turns.

Where code generation moves toimplementation
  1. Frontend, per filebuild time
    Source becomes a typed AST, then IR.
    Everything the language semantics decide. This stage stays fully parallel under LTO.
  2. Object filebuild time
    Serialised IR plus a real symbol table.
    A unit the linker can resolve against without understanding its contents.
    Nothing yet — which is the whole point. Under normal compilation the bodies would be gone by here.
  3. Symbol resolutionbuild time
    A global map of which definition wins for each name.
    Whether a definition is final, preemptible, or unused outside the bitcode set.
  4. Merge or summarisebuild time
    One combined module (full LTO) or a combined index of per-module summaries (ThinLTO).
    Cross-module visibility — complete in one case, planned in the other.
    Under full LTO, the ability to work on units independently.
  5. Interprocedural optimizationbuild time
    IR being rewritten with cross-module facts available.
    Cross-module inlining, devirtualization, dead-function elimination.
    Source correspondence, more aggressively than a per-file build would.
  6. Code generationbuild time
    Machine code, at last.
    The commitment to a target that a normal build made much earlier.
    The IR. From here it is an ordinary link.

Read it asOnly one thing moved: the boundary between "still IR" and "now machine code" slid past symbol resolution. Every stage exists in an ordinary build too. That is why LTO is best understood as a change to the build's shape rather than as a new optimization.

Full LTO and ThinLTO are different bargains

implementationThe full/thin split is LLVM's design and its vocabulary. GCC's LTO uses WHOPR — a whole-program analysis phase followed by parallel local transformation phases — which occupies similar ground with different partitioning and different flags (-flto=n, -flto-partition=). MSVC's /GL with /LTCG is closer to full LTO and has no thin equivalent. Numbers and behaviour do not carry between the three.

Full LTO does the obvious thing: merge every module into one, run the interprocedural optimizer over it, then generate code. The view is complete and the analysis is exact. It is also a single process holding the IR of the entire program, running a mostly sequential optimizer, and rerunning all of it whenever anything changes. On large programs this is where builds go to die — link steps measured in tens of minutes and tens of gigabytes.

ThinLTO replaces the merge with a plan. Each compile emits, alongside its bitcode, a compact summary: the module's call graph edges, per-function size and cost estimates, which symbols it defines and references, and enough type information to reason about virtual calls. The linker combines only the summaries — small, fast, one pass — into a global index, and from that index decides *which* functions each module should import from which other modules. Then the backend jobs run in parallel, one per module, each importing only the handful of bodies its plan named.

What ThinLTO trades is exactness. The plan is made from summaries, not from bodies, so the importing decisions are made on estimates, and a function not imported is not available to be inlined however profitable it would have been. In exchange it recovers the two properties full LTO destroys: the backend is parallel again, one job per module, and it is cacheable and incremental — an unchanged module whose imports did not change can reuse its previous object file.

In practice ThinLTO recovers a large majority of full LTO's runtime benefit at a small multiple of a normal build's cost rather than an enormous one, which is why it is what most large projects that use LTO at all actually use. The exact fraction is a property of the codebase, not a constant.

The two bargainsimplementation
Full LTOThinLTO
What the linker combinesEvery module's IR, into one modulePer-module summaries, into a global index
View available to the optimizerComplete: every body, exactlyPartial: summaries globally, imported bodies locally
Backend parallelismEssentially none — one processOne job per module, across all cores
Peak memoryWhole program resident at onceOne module plus its imports, per job
Incremental rebuildFull re-optimization of everythingReuse per module when its imports are unchanged
Distributable across machinesNoYes — the backend jobs are independent
What is given upBuild parallelism, incrementality, memory headroomExactness: decisions are made from estimates, and un-imported bodies cannot be inlined

Where it goes wrong in practice

The failures cluster into three families, and none of them is "the optimizer produced worse code".

The first is resources. Full LTO on a large binary is the single most memory-hungry step in most builds, and it fails by being killed rather than by reporting anything useful. The second is flag consistency: because code generation happens at link time, target flags supplied only at compile time may be silently ignored, and inconsistent flags across units now meet inside one optimizer. The third and most interesting is latent bugs surfacing. LTO does not create undefined behavior; it makes existing UB and existing one-definition-rule violations exploitable, because the optimizer can finally see both halves of the contradiction. Code that "worked" for a decade breaks, and the LTO flag gets the blame it does not quite deserve — the honest description is that it changed which assumption was profitable to act on. That distinction matters when you go looking for the fix, because reverting the flag hides the bug rather than removing it.

  • Out of memory at link. Full LTO holds the program's IR in one process. The fix is ThinLTO, partitioning, or a bigger worker — not a smaller optimization level.
  • Symbol interposition. In a shared library, default-visibility symbols may be replaced at load time, so the optimizer must keep the indirection. -fvisibility=hidden and -fno-semantic-interposition are how you tell it the definitions are final; enabling them changes the library's ABI contract, so it is a deliberate decision.
  • One-definition-rule violations. Two units that define the same inline function differently link fine without LTO and produce contradictory IR with it. The resulting miscompilation is far from the mistake.
  • Flags that arrive too late. -march, -mtune and sanitizer settings affect code generation, which now happens at link time, so they must be passed to the link as well. Passing them only at compile time is a quiet no-op.
  • Debug information volume. Cross-module inlining multiplies inline frames, so -g builds with LTO produce dramatically larger debug sections and slower symbolication — [[debug-information]].
  • Build-system integration. Static archives must be produced with a plugin-aware ar, or the bitcode is unreadable and the link silently falls back to non-LTO for those objects.

How it works

The steps, in the order the compiler takes them.

  • Each -c compile runs the frontend and a cheap optimization pass, then serialises IR into the object file instead of emitting machine code.
  • Under ThinLTO, the compile also emits a summary: call-graph edges, function sizes and costs, defined and referenced symbols, and virtual-call type information.
  • A linker plugin recognises the bitcode objects and reads their symbol tables, so ordinary symbol resolution proceeds normally.
  • The linker reports back, per symbol, which definition wins and whether it may be preempted at load time.
  • Full LTO merges all modules into one and runs the interprocedural optimizer over it; ThinLTO instead merges only summaries into a global index and computes an import plan.
  • ThinLTO then runs one backend job per module in parallel, each importing only the function bodies its plan named, and each cacheable by a key over its inputs.
  • Machine code is generated, and the link completes as an ordinary link over the resulting objects.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • The link step is killed by the OOM killer after twenty minutes, and the log names the linker, so the investigation starts in the wrong place.
  • A build that took four minutes takes forty, and adding CI cores does not help because the time is in one full-LTO process.
  • A program that ran correctly for years crashes with LTO enabled, because an existing undefined-behavior assumption became visible across a module boundary.
  • A -march=native build produces baseline instructions, because the flag was passed to the compiles and not to the link, where code generation now happens.
  • An LD_PRELOAD interception stops working for a shared library built with hidden visibility, because the call it was intercepting was inlined away.
  • Debug builds with LTO produce object files several times larger and a debugger that takes minutes to load symbols, from the multiplied inline frame records.
  • A static archive built with the wrong ar links successfully with no LTO applied to it at all, so the expected speedup simply does not appear and nothing reports why.

When it helps

  • Release builds of C, C++ and Rust binaries where abstraction crosses file boundaries and the per-file optimizer was giving up at every call.
  • Size-sensitive artifacts, where cross-module dead-function elimination removes more than inlining adds — WebAssembly bundles and embedded images especially.
  • Large projects that need most of the benefit without the cost, which is the case ThinLTO was designed for and where its distributability matters.
  • Codebases already using hidden visibility, where the closed-world premise is cheap to establish and the optimizer can act on it immediately.

When it hurts

  • Development builds, where the loss of incremental relinking is felt on every single iteration.
  • Memory-constrained build machines, where full LTO simply does not fit and the failure is abrupt rather than gradual.
  • Shared libraries whose whole purpose is to be interposed or hot-patched, where the transformations LTO enables are the ones you were relying on not happening.
  • Programs whose time is spent outside the compiled code, where the entire build cost buys nothing measurable.

What it costs

Every one of these is paid by something.

  • Full LTO buys an exact cross-module view and pays with build parallelism, incrementality and peak memory — all three at once, and the memory limit is usually what bites first.
  • ThinLTO buys back parallelism, incremental reuse and distributability, and pays in exactness: import decisions are made from summaries, and a body that was not imported cannot be inlined.
  • Deferring code generation to link time buys the wider view and pays by moving flag handling to the link, where build systems are most likely to get it wrong.
  • Hidden visibility and disabled semantic interposition buy the closed-world premise and pay with a real restriction on the library's contract — plugins and preloading stop working as they did.
  • The runtime and size gains are paid for in debug information volume and in stacks that no longer correspond to the source, which is a cost that lands during incident response.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Unity or "jumbo" builds concatenate source files so that one compiler invocation sees many units. Crude, requires no linker support, and gets a surprising amount of the benefit — at the cost of name collisions and much coarser incrementality.
  • Source-level exposure: put the hot functions in headers or mark them inline, so the ordinary per-file optimizer already has the bodies. Free, and it does not scale past a handful of functions.
  • Post-link binary optimization such as BOLT or Propeller, which reorders and rewrites the already-linked binary using samples. Complementary rather than alternative: it works on layout, where LTO works on interprocedural facts.
  • Monomorphization in the frontend, as Rust and C++ templates do, which specialises across the boundary before it exists — trading compile time and code size instead of build topology, see [[monomorphization]].
  • Not using it: for many programs the honest measurement shows no benefit, and [[compile-time-vs-runtime]] is the framing for deciding that.

See it for yourself

The flag, dump or tool that shows you this directly.

  • file a.o on an LTO build reports "LLVM IR bitcode" rather than an ELF relocatable — a five-second check that the flag took effect at all.
  • llvm-bcanalyzer a.o and llvm-dis a.o -o - read the bitcode; llvm-lto2 dump-symtab shows the symbol table the linker sees.
  • clang -flto=thin -Wl,--thinlto-index-only produces the import plan without running the backend, which is how you inspect what ThinLTO decided to import where.
  • -Wl,--stats with lld, and -ftime-report, attribute the link duration across phases.
  • /usr/bin/time -v on the link reports peak resident memory — the number that decides full LTO's viability.
  • clang -flto -Wl,--plugin-opt=save-temps writes the merged module before and after optimization, for diffing.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "LTO is an optimization level." It is a change to when code generation happens. The optimization level still applies, and applies at the link.
  • "ThinLTO is just LTO with a lower budget." It is a different algorithm: summaries and an import plan, rather than a merged module with fewer passes.
  • "LTO introduced a bug." It made an existing undefined-behavior or ODR problem exploitable. Turning the flag off hides the defect instead of fixing it.
  • "If the link succeeded, LTO ran." Objects produced without a plugin-aware archiver, or precompiled third-party objects, are silently linked as ordinary machine code.

Misconceptions

The claim, and what is actually true.

The linker performs the optimization.
The linker resolves symbols and hands the bitcode back to the compiler's optimizer through a plugin. It is the scheduler, not the optimizer.
LTO and whole-program optimization are the same thing.
LTO is the usual *implementation* of whole-program optimization, chosen because deferring code generation is the only way to keep the frontend parallel. ThinLTO is deliberately not whole-program: it works from summaries.
Enabling LTO cannot change program behavior.
It cannot change behavior that was defined. It routinely changes behavior that was undefined, which is why long-stable code breaks and the bug is older than the flag.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Normally each source file is compiled all the way to machine code, and by the time the linker combines them there is nothing left to optimize across. LTO stops each compile early, leaving the compiler's own intermediate form in the object file, and lets the linker hand everything back to the optimizer once all the pieces are present. Full LTO merges everything into one; ThinLTO instead shares small summaries, plans what to borrow from where, and keeps the work parallel.

practical

Start with ThinLTO, not full LTO — it is the version that survives contact with a real build. Check file on an object to confirm it took effect, watch peak link memory, and make sure target flags are on the link command as well as the compiles, because code generation moved there. Keep it off for development builds. If enabling it breaks something that has worked for years, look for undefined behavior or a duplicate inline definition before you look at the optimizer, and expect debug builds to grow considerably.

advanced

ThinLTO's summary is the interesting artifact, because it is a deliberate answer to "what is the smallest description of a module that supports good cross-module decisions?" It carries call-graph edges, per-function cost estimates, symbol definitions and references, and type-test information for virtual calls — and pointedly not the bodies. That choice is what makes the combining step cheap enough to be serial and the backend parallel enough to distribute, and it is also the source of every case where ThinLTO underperforms: an import decision made from an estimate is sometimes wrong, and a body not imported is unavailable at any budget. The design generalises well beyond LTO. It is the same shape as an interface file in [[interface-files]] and the same shape as a per-module index in an incremental compiler: compute a compact summary, combine only summaries, and fetch detail on demand.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationThe mechanism described is LLVM's: bitcode objects, a gold/lld plugin, and the full/thin split. GCC uses GIMPLE with the WHOPR partitioning model and different flags; MSVC's /GL plus /LTCG is a separate implementation with no thin analogue. Measurements, memory profiles and even the set of transformations enabled differ between them, so a result from one toolchain is not evidence about another.
typicalThinLTO usually recovers most of full LTO's runtime benefit for a fraction of the build cost, but "most" is a property of the codebase. Programs whose hot path crosses many module boundaries with large functions see a wider gap, because the import heuristic declines to bring in bodies that full LTO would have inlined.
targetBecause code generation happens at the link, -march, -mtune, -mcpu and sanitizer flags must be present on the link command or they are ignored. Symbol preemption rules also differ by platform — ELF default visibility is preemptible, Mach-O uses a two-level namespace, and Windows DLLs export explicitly — so the same visibility settings license different transformations on each.

If you were asked this in an interview

  • What is physically different about an object file produced with -flto?
  • Why does ThinLTO parallelise when full LTO does not, and what does it give up to do so?
  • A team enabled LTO and a five-year-old feature started crashing. What is your first hypothesis, and why is reverting the flag the wrong fix?

Connections

Computer Architectureinstruction-cache
Domains that do not exist yet
  • DevOps / Production Engineering — Build fleet sizing for a memory-hungry serial link step, and distributed backend execution
    Full LTO turns a parallel build into one enormous process, which is a capacity and scheduling problem for whoever runs the build fleet: worker memory limits, queueing behaviour and whether ThinLTO backend jobs can be farmed out. We stop at what the optimizer needs and why; sizing the machines that provide it is theirs.