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.
Why do we compile files separately and link afterwards instead of compiling the whole program at once?
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".
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.
- Edityou write itOne source file differs from the last build.
- Dependency analysisbuild timeA graph of units and the artifacts each depends on.Which units are affected — usually one, plus anything that included a changed header.
- Compile (parallel)build timeObject 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.
- Linkbuild timeOne 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.
/* a.c */ /* b.c */
int f(void) { int g(int x) {
return g(2); return x * x;
} }/* a.o, with the definition of g visible */
int f(void) {
return 4;
}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.
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
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.cthennm -C a.o— theTsymbols are what this unit defines, theUsymbols are what it assumed someone else would.objdump -r a.olists the relocations: each one is a place the compiler could not know an address and deferred to the linker.ld --verboseorlld --verboseshows 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 -Soutput. The difference is the cost of the boundary. make -jversusmake -j1on 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.
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.
-flto changes the model without changing a line of source, and some vendor toolchains for embedded targets enable whole-program mode by default instead.-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
- DevOps / Production Engineering — Distributed build farms and shared artifact cachesIndependent 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.