Compilation Units
The unit the compiler processes at once decides everything about build cost. In C and C++ that unit is a translation unit — one source file plus every header it transitively includes — which is why editing one line of a header can rebuild half the project.
What does the compiler actually process in one invocation, and why does one header change rebuild so much?
A compilation unit is the largest program fragment a single compiler invocation holds at once. In C and C++ it is a *translation unit*: the source file after the preprocessor has textually pasted in every header it includes, transitively, with macros expanded and conditional blocks resolved. That expanded token stream — often two orders of magnitude larger than the file you edited — is what the frontend parses. It exists to answer one question: what is the compiler allowed to see, and therefore reason about, at this moment?
A unit may be compiled in isolation only if every name it uses is either defined inside it or declared inside it with a signature the linker can later match by symbol. The compiler is entitled to assume that each undefined symbol will be supplied by some other unit, and entitled to assume nothing else about it — not its body, not its size, not whether it can be inlined. Anything more requires the definition to be inside the unit, which is exactly what [[link-time-optimization]] restores later by other means.
Key points
- A compilation unit is what one compiler invocation processes at once; it is a language design decision, not a property of files.
- In C and C++ the unit is the preprocessed translation unit, so the cost of a header is paid once per including unit, not once per project.
- Unit boundaries are information boundaries: no inlining, no devirtualization and no dead-code elimination across them without extra mechanism.
- Larger units give better generated code and worse build behavior; smaller units give the reverse. Nothing in the language resolves this — the build system and LTO do.
- Rust and Go picked large units and then added internal subdivision and export data to claw back the parallelism and incrementality they gave up.
The file you edit is not the file that is compiled
Open a two-line C++ file that includes <vector> and <string> and ask the compiler what it saw. The answer is routinely several hundred thousand lines of tokens. The preprocessor is a text substitution engine with no notion of a module, a namespace or an already-seen file except the include guard you wrote yourself, so every header is pasted in full into every unit that asks for it — see [[the-preprocessor]].
This is the single fact that explains C++ build times. A project with 800 source files and one widely-included header does not have 800 files of parsing work; it has 800 copies of that header of parsing work, plus 800 copies of everything the header includes, and every template in there is instantiated separately in every unit that uses it before the linker throws the duplicates away — see [[template-instantiation]].
1// main.cpp — 3 lines as written2#include "widget.h"3int main() { return Widget{}.size(); }4 5// after preprocessing: 92,431 lines6// widget.h 417// <vector> 12,0068// <string> 19,8809// <memory>, <iterator>, ... 60,504The ratio, not the absolute number, is the lesson. Every unit that includes widget.h pays the whole transitive cost again, and nothing in the language makes the second unit cheaper than the first.
Every language chose a different unit
The translation unit is a C-family answer, not the only one. The unit is a design decision, and the whole cost profile of a build follows from it: a small unit gives fine-grained rebuilds and poor cross-unit optimization, a large unit gives the opposite, and no language gets both without extra machinery.
Rust is the clearest example of the large end. The compilation unit is the *crate*, not the file, so touching any file in a crate invalidates the crate — but rustc then subdivides the crate into codegen units internally to recover parallelism, and its query system recovers incrementality on top of that. Two mechanisms exist to undo the cost of the choice.
| Language | Unit | Consequence |
|---|---|---|
| C / C++ | Translation unit: one source file plus all transitively included headers | Header changes are expensive; the same template is instantiated in every unit that uses it |
| C++20 with modules | Named module interface, compiled once to a binary module interface | Importers read a prebuilt artifact instead of re-parsing text — see [[modules]] |
| Rustimplementation | One crate, all files at once | Crate-wide analysis is possible; crate-wide invalidation is the price |
| Go | One package | Package export data lets dependents compile without reading bodies — see [[interface-files]] |
| Java | A set of source files, typically compiled together | Dependents read .class files for signatures; recompilation granularity is per class |
| TypeScript | A project (a tsconfig.json), or a file under isolated transpilation | Type checking needs the project; emitting one file does not — the two granularities differ deliberately |
| Python | One module, compiled to bytecode on import | The unit is tiny and the cache is per-file, so there is nearly no build step to make incremental |
What the unit boundary costs you
The boundary is not only a build-time fact. It is an *information* boundary: the compiler cannot inline a function whose body lives in another unit, cannot prove a virtual call has one implementation if another unit might add one, and cannot delete a function that might be called from outside. Every one of those is a real optimization lost at the boundary, and the reason C++ headers are full of definitions that logically belong in a .cpp file.
So the unit size is a trade, and it is the same trade in every language: bigger units mean better code and worse builds. The rest of this module is the machinery for having some of both — interface files so dependents need not reparse implementations, dependency analysis so unchanged units are not touched, and LTO so the optimizer can look across boundaries after the fact.
- Cross-unit inlining is impossible without either the definition in a header, an interface artifact that carries the body, or IR retained for the linker.
- Dead-function elimination is impossible for anything externally visible, because another unit might call it — see
[[dead-code-elimination]]. - Devirtualization is impossible if another unit could define a further subclass, unless the language or a flag closes the hierarchy — see
[[devirtualization]]. - Each of those is bought back at a cost stated in
[[whole-program-optimization]], and the cost is always parallelism, incrementality, or both.
How it works
The steps, in the order the compiler takes them.
- The driver invokes the frontend once per unit, passing include paths, macro definitions and the target description.
- For C-family languages the preprocessor first expands includes, macros and conditionals into a single token stream, discarding the file structure entirely.
- The frontend lexes, parses and type-checks that stream, resolving every name against declarations visible inside the unit.
- Names with no definition in the unit become undefined symbols in the object file, carrying only a mangled name and, on some targets, type information — see
[[symbols-and-references]]. - The backend emits an object file whose optimization scope is exactly this unit, unless IR was retained for the linker instead.
- The linker later matches undefined symbols against definitions from other units, which is the first moment anything sees more than one unit at a time.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A one-line comment change in a widely-included header triggers a twenty-minute rebuild, and nobody can explain why because the change was obviously inert.
- Two translation units see different definitions of the same class because a macro was defined differently on two command lines; the program links cleanly and then corrupts memory at runtime when one unit writes a field the other does not believe exists.
- A function is fast in a microbenchmark and slow in the product, because in the benchmark its definition was in the same unit and got inlined, and in the product it was not.
- Build times grow superlinearly with team size: every new feature adds a header, every header gets included by more units, and the total parse work grows as the product of the two.
- A developer "fixes" a slow build by moving definitions into headers, which speeds up nothing and makes every dependent unit slower to compile.
When it helps
- Diagnosing a build that is slow for no visible reason: the unit boundary tells you what work is being repeated and how many times.
- Deciding where a definition belongs. A definition in a header is compiled everywhere; a definition in a source file is compiled once and cannot be inlined elsewhere.
- Reading another language's build behavior. Once you know what its unit is, its rebuild characteristics are largely predictable.
When it hurts
- Reasoning about small projects. Under a few hundred files the unit boundary costs almost nothing and optimizing for it is wasted effort.
- Assuming the C++ model when reading Rust or Go build output. A Rust crate rebuilding in full is not the same event as a C++ header change, and the fixes are different.
What it costs
Every one of these is paid by something.
- A small unit buys parallelism and fine-grained rebuilds, and pays in lost cross-unit optimization plus a linker that must resolve far more symbols.
- A large unit buys whole-unit analysis and better generated code, and pays in coarse invalidation — one edit anywhere in the unit recompiles all of it — and in peak compiler memory.
- Textual inclusion buys a preprocessor simple enough to be specified in a few pages, and pays with repeated parse work proportional to includes times units, plus macro-driven inconsistencies the type system never sees.
- Moving definitions into headers to enable inlining buys runtime speed and pays in compile time for every dependent unit and in a rebuild triggered by every implementation change.
What else you could do
What a different compiler or language does instead, and when that is better.
- Precompiled headers compile a fixed prefix once and reuse the serialized state, which cuts the repetition without changing the language — at the cost of fragility when flags or the prefix drift.
- C++20 modules replace textual inclusion with a compiled interface artifact, so a dependent reads a binary rather than re-parsing text — the mechanism
[[modules]]and[[interface-files]]describe. - Unity builds go the other way: concatenate many source files into one huge unit so headers are parsed once and everything can be inlined. Builds get faster and *less* incremental, and name collisions between formerly separate units appear.
- Languages with a package or crate unit — Go, Rust, Java — avoid the repetition entirely by making dependents read compiled metadata rather than source.
See it for yourself
The flag, dump or tool that shows you this directly.
clang -E main.cpp | wc -lorgcc -Eprints the preprocessed translation unit. Compare it withwc -l main.cppfor the expansion ratio.clang -H(orgcc -H) prints the include tree with nesting depth, which shows which header is dragging in the rest.clang -ftime-traceemits a Chrome-trace JSON of where frontend time went, per header and per template instantiation; open it inchrome://tracingor Speedscope.go list -deps ./...shows package units and their dependencies;cargo build --timingsshows crate units, their durations and their overlap.nm -C obj.olists what one unit defined and what it left undefined — the unit boundary made concrete.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The compilation unit is the file." It is the file *after preprocessing* in C and C++, and it is not the file at all in Rust, Go or Java.
- "Splitting a big file into ten small ones makes the build faster." It makes it more parallel and more incremental. If all ten include the same headers, the total parse work goes up, not down.
- "Header-only libraries have no build cost." They have no *link* cost. They move the entire cost into every unit that includes them.
- "If it links, the units agreed." Linkers match symbols, not meanings. Two units can hold contradictory definitions of the same type and link without complaint.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
The compiler works on one chunk at a time. In C and C++ that chunk is a source file with all its headers pasted in, which is usually far larger than the file you wrote. Because every file pastes its own copy, shared headers get compiled over and over, and changing one means everything that included it must be compiled again.
practical
When a build is slow, find out what is being repeated. clang -H shows the include tree and -ftime-trace shows where the time went. The usual answer is a header that pulled in a large chunk of the standard library, included by everything. The fixes are ordinary: forward-declare instead of including, move heavy includes out of headers into source files, use the pimpl idiom to keep implementation types out of the interface, and consider precompiled headers or modules for the stable prefix.
advanced
The unit size is really a knob on a single trade — visibility versus invalidation — and every mature toolchain ends up with two independent granularities so it can set the knob twice. rustc compiles a crate but codegens in units and caches at query granularity. Clang compiles a translation unit but can emit IR for whole-program work at link time. Go compiles a package but writes export data so dependents never see bodies. The pattern is the same each time: pick a coarse unit for semantic analysis, then introduce a finer unit for the thing that was made expensive by the choice.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Why does changing a comment in a widely-included C++ header cause a long rebuild?
- What can the compiler not do across a compilation unit boundary, and what mechanisms give each of those back?
- Rust compiles a whole crate at once. What does that buy, what does it cost, and how does rustc mitigate the cost?
Connections
- DevOps / Production Engineering — Build pipelines, caching layers and the CI machine that pays for all of this repeatedlyThe unit boundary decides how much work a CI job repeats on every commit, but the pipeline that runs those jobs, caches their outputs and distributes them is owned there. We only own the question of what one compiler invocation is obliged to process.