Buildstypical

Separate Compilation

Compile each unit independently, link the results together. It buys parallelism and incremental rebuilds, and pays with an optimizer that cannot see past the boundary — which is precisely the gap LTO exists to fill.

The question

Why do we compile files separately and link afterwards instead of compiling the whole program at once?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The program is a set of independently produced object files, each one a complete translation of its own unit into machine code plus two tables: the symbols it defines and the symbols it needs. No object file contains a representation of the whole program; the whole program does not exist as a representation until the linker builds one. That is the design, and the question it answers is "how do I recompile one part without touching the others".

What this phase may assume or do

Compiling a unit alone preserves behavior only if every cross-unit reference is consistent between the unit that declares it and the unit that defines it — same name after mangling, same type, same calling convention, same layout for any type they share. The compiler is entitled to assume that consistency because it cannot verify it; the linker checks only the mangled name. When the assumption is false the program links and then misbehaves, which is why [[abi-stability]] and [[name-mangling]] are load-bearing rather than trivia.

Key points

  • Separate compilation buys exactly two things — parallel compilation and rebuilds proportional to the change — and both are worth a great deal at scale.
  • It costs every optimization that requires seeing two units at once, most importantly cross-module inlining and devirtualization.
  • The declaration is a contract the compiler must assume and cannot verify; the linker checks only the mangled symbol name.
  • Broken cross-unit contracts produce link-time or run-time failures, never compile-time ones, which makes them among the hardest bugs in the toolchain.
  • LTO is not a different build model; it is separate compilation with IR in the object files and the optimizer deferred to link time.

What independence buys

Two properties, and they are the reason every large codebase does this. First, parallelism: N units can be compiled on N cores at once because none of them needs the output of any other. Second, incrementality: if one unit changes, only that unit and the link need to be redone, so the cost of a small change is proportional to the change rather than to the codebase.

Those two properties are worth an enormous amount. A million-line C++ program that had to be compiled as one unit would take hours to build and could not use more than one core to do it. The linker exists so that it does not have to be.

One source change through a separately compiled buildtypical
  1. Edityou write it
    One source file differs from the last build.
  2. Dependency analysisbuild time
    A graph of units and the artifacts each depends on.
    Which units are affected — usually one, plus anything that included a changed header.
  3. Compile (parallel)build time
    Object files, one per affected unit.
    Machine code, optimized within the unit only.
    Any possibility of optimizing against unchanged units, because they were not re-read.
  4. Linkbuild time
    One executable or shared library.
    Resolved cross-unit references, and the first view of the whole program.
    The unit structure — see [[object-files]].

Read it asOnly the third row does real compiler work, and only for the affected units. That is the whole argument: the expensive stage was made proportional to the edit. The fourth row is where the cost of that choice shows up, because it is the only place the whole program is visible and it is far too late to optimize much.

What independence costs

The compiler working on unit A knows nothing about the body of a function defined in unit B. It cannot inline it, cannot see that it never returns, cannot learn that its argument is always the constant 3, cannot prove that its only implementation makes a virtual call monomorphic. Every optimization that needs to see two units at once is off the table.

This is not a small loss. Cross-module inlining is often the single most valuable transformation in a large program, because it is what turns a wall of tiny accessor calls into arithmetic — see [[inlining]]. Separate compilation deletes that opportunity by construction, and [[link-time-optimization]] is the mechanism that gets it back by keeping IR in the object files instead of machine code.

The inline that separate compilation forbids
Before
/* a.c */          /* b.c */
int f(void) {      int g(int x) {
  return g(2);       return x * x;
}                  }
After
/* a.o, with the definition of g visible */
int f(void) {
  return 4;
}
Legal only when

Only if the compiler can see the body of g and can prove it will be the body that runs: g must not be interposable by a shared library at load time, must not be redefined by another unit, and must be free of observable side effects other than its return value. Under separate compilation the compiler translating a.c can establish none of those, so the call stands.

Illegal when

g is exported from a shared library where LD_PRELOAD or a symbol earlier in the search order may substitute a different implementation — see [[symbol-resolution-order]]. Then folding the call to a constant changes what the program does, and the same rewrite that is correct in a static link is a miscompilation in a dynamic one.

The declaration is the contract, and nobody checks it

targetWhat "consistent" means is fixed by the platform ABI, not the language: argument registers, struct return conventions, alignment and name decoration differ between x86-64 System V, Windows x64 and AArch64 AAPCS. Two objects built for the same source with different ABI assumptions link on some platforms and are rejected on others, and the failures that get through are the silent ones.

For a unit to compile alone it needs a *declaration* of everything it uses: a name, a type, and enough about the calling convention to emit the call. That declaration is a promise about a definition the compiler will never see. In C the promise lives in a header; in Go and Rust the compiler reads a compiled artifact instead, which is why they cannot suffer this class of bug — see [[interface-files]].

When the promise is broken, C-family toolchains find out at runtime and not before. The linker matches mangled names; if two units disagree about a struct layout, the mangled names are identical and the link succeeds. This is the reason C++ mangles types into names at all, and the reason it is still not enough — mangling covers function signatures, not the layouts of the types inside them.

How it works

The steps, in the order the compiler takes them.

  • Each unit is compiled independently to an object file containing machine code, a symbol table of definitions, and a list of undefined references — see [[object-files]].
  • References to symbols outside the unit are emitted as relocations: a placeholder plus an instruction to the linker about how to patch it once the address is known.
  • The build system decides which units need recompiling by comparing each unit's inputs against a record of the last build.
  • Unaffected units are not recompiled; their existing object files are reused verbatim.
  • The linker concatenates sections, resolves each undefined symbol against a definition, applies relocations, and produces one image — see [[what-a-linker-does]].
  • Under LTO the object files hold serialized IR instead of machine code, and the linker calls back into the optimizer before any of this happens.

How it breaks

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

  • A change to a struct in one unit is not seen by another that was not rebuilt, and the program reads a field at the wrong offset — a wrong value appears in a function that never touched the struct.
  • Two units are compiled with different optimization or ABI flags and the program crashes in a stack unwind that worked yesterday, with a backtrace that points nowhere useful.
  • A missing definition produces an undefined-symbol error that names a mangled string nobody can read, at link time, minutes after the compile that should have caught it.
  • A function is duplicated into two units by an inline definition, the linker silently keeps one, and behavior depends on link order.
  • A benchmark improves by 40% when a helper is moved into the same unit as its caller, and the team concludes the helper was slow rather than that the call was no longer inlinable.

When it helps

  • Any codebase where full builds take longer than the time between edits — which is nearly all of them past a few hundred files.
  • Distributed and cached builds: independent units are the reason a remote cache or a build farm can help at all.
  • Shipping shared libraries, where the whole point is that the consumer was compiled without the provider present.

When it hurts

  • Small, performance-critical programs where the lost cross-unit optimization matters more than the build time, and a single-unit or LTO build is simply better.
  • Codebases with unstable cross-unit contracts, where the assumption the model rests on is repeatedly violated and the failures are all runtime ones.

What it costs

Every one of these is paid by something.

  • Parallel, incremental builds are bought at the cost of every cross-unit optimization, and the runtime difference on call-heavy code can be large.
  • The declaration-as-contract model buys the ability to compile against code you do not have, and pays with a class of error that no compile-time check can catch.
  • Recovering the lost optimization with LTO costs the incrementality and the parallelism that motivated separate compilation in the first place, so the build gets slow again in a different place — see [[link-time-optimization]].
  • Fine-grained units reduce rebuild cost and increase link cost: more symbols to resolve, more relocations to apply, and a link step that becomes the serial bottleneck of the build.

What else you could do

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

  • Whole-program compilation gives the optimizer everything at once and gives up parallelism and incrementality — the trade set out in [[whole-program-optimization]].
  • ThinLTO keeps units separate and exchanges summaries, so cross-unit decisions are made from a partial view and the build stays parallel.
  • Export-data models — Go's and Rust's — keep units separate but let the compiler read a compiled description of a dependency rather than a hand-written declaration, which removes the unverified-contract problem without removing the boundary.
  • A JIT sidesteps the question entirely: it sees the whole loaded program at runtime and can inline across any boundary, at the cost of doing the work every time the program starts — see [[jit-compilation]].

See it for yourself

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

  • gcc -c a.c then nm -C a.o — the T symbols are what this unit defines, the U symbols are what it assumed someone else would.
  • objdump -r a.o lists the relocations: each one is a place the compiler could not know an address and deferred to the linker.
  • ld --verbose or lld --verbose shows the order units and libraries were searched, which is what decides duplicate resolution.
  • Compile the same function in the same unit as its caller and in a different one, and diff clang -O2 -S output. The difference is the cost of the boundary.
  • make -j versus make -j1 on a full build measures what the parallelism was worth on your machine.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Separate compilation is just how compilers work." It is a choice, made for build economics. A compiler that reads the whole program at once is simpler and produces better code.
  • "The linker will catch mismatches." It matches symbol names. Layout, invariants and semantics are outside its knowledge entirely.
  • "Since each unit is optimized, the program is optimized." Each unit is optimized in ignorance of the others. The most valuable optimizations in a large program are exactly the ones that need both.
  • "LTO makes separate compilation unnecessary." LTO is built on top of it. The units are still compiled separately; what changes is what they contain.

Misconceptions

The claim, and what is actually true.

Object files are just machine code.
They are machine code plus symbol tables plus relocations plus, under LTO, IR. The tables are what make separate compilation possible at all.
If each file compiles, the program is correct.
Each file compiles against declarations. Nothing has checked that the declarations match the definitions in any way beyond the mangled name.
More, smaller files always build faster.
They parallelize better and invalidate less, but they add link work and, in C++, duplicate header parsing. Past a point the link becomes the bottleneck.

Go deeper

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

overview

Each source file is turned into an object file on its own, and a linker joins the object files at the end. Because the files are independent, they can be compiled at the same time on different cores, and when you change one file only that one has to be redone.

practical

The practical consequence is that performance-critical code and its callers should usually be in the same unit, or the definition should be visible in a header, or the build should use LTO. If a function shows up hot in a profile and is a small accessor, check first whether it is being inlined at all — a cross-unit call to a three-line function is pure overhead, and the fix is a build-model change rather than a code change.

advanced

The deep property is that separate compilation makes the *declaration* the unit of trust, and everything difficult follows from that. ABI stability is the problem of keeping a declaration true across versions. Name mangling is the problem of making a declaration checkable by a linker that understands only strings. Interposition is the problem of a declaration whose definition is chosen after compilation. Languages that replaced declarations with compiler-generated interface artifacts — Go export data, Rust crate metadata, C++20 BMIs — eliminated an entire family of bugs, and paid for it by making the build graph depend on compiler-version-specific binary formats.

How much this depends on

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

typicalMainstream C, C++ and Fortran toolchains default to per-unit optimization with no cross-unit visibility. This is a default, not a rule: passing -flto changes the model without changing a line of source, and some vendor toolchains for embedded targets enable whole-program mode by default instead.
targetThe consistency requirement between units is fixed by the platform ABI. x86-64 System V, Windows x64 and AArch64 AAPCS differ in argument registers, struct-return rules and name decoration, so "the same source compiled twice" is only interchangeable within one ABI.
implementationWhether a symbol may be interposed at load time — and therefore whether the compiler may reason about its body at all — depends on visibility settings and the linker. On ELF with default visibility a shared-library function is interposable; with -fvisibility=hidden or on Windows DLLs the default is the opposite.

If you were asked this in an interview

  • What can an optimizer not do under separate compilation, and what would you change to get it back?
  • A function is inlined in one build and not another with identical source. Name three reasons.
  • Two object files disagree about a struct layout. Where does that fail, and why not sooner?

Connections

DSAdag
Computer Architectureinstruction-cache
Domains that do not exist yet
  • DevOps / Production Engineering — Distributed build farms and shared artifact caches
    Independent units are the precondition for distributing compilation across machines and reusing artifacts between developers, but the scheduler, the cache protocol and the trust model for shared artifacts belong to that domain rather than to the compiler.