Whole Programimplementation

The Compiler Is Also a Program With Performance Requirements

A compiler is judged on four axes that trade against each other — compile time, memory, incremental turnaround and generated code quality — and the first one changes how engineers work, not merely how long they wait.

The question

Why is my build slow, and what is the compiler actually trading away when it is fast?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The compiler itself as the program under analysis: a pipeline of passes over data structures whose sizes are set by the input program, with its own hot loops, allocation behaviour and asymptotics. This framing exists to answer a question that the compiler's output cannot — not "is this code good" but "what did producing it cost, and which of the four budgets did it spend?"

What this phase may assume or do

A compiler may skip or cheapen work only where doing so cannot change the correctness of the result. Dropping an optimization is always permitted, because optimizations are optional by construction; skipping a semantic check, reusing a cached analysis whose inputs have changed, or reusing an artifact whose dependencies moved is not. The dividing line is that speed may be bought from the *optional* half of the compiler — analysis and transformation — and never from the obligatory half, which is establishing that the program means what the language says it means.

Key points

  • A compiler is judged on four axes — compile time, memory, incremental turnaround, generated code quality — and they trade against each other rather than improving together.
  • Memory fails abruptly rather than gradually and caps usable parallelism, which makes it the axis that quietly decides how fast a build can be.
  • The dominant costs are usually in the frontend: textual header inclusion, template instantiation and monomorphization, not the optimizer.
  • Link-time optimization is unusually expensive because it converts a parallel build into one large serial memory-hungry step.
  • Debug information is frequently the largest single contributor to artifact size and a real share of link time.
  • A slow edit-build-test loop changes what work a team attempts: bigger batched changes, less local testing, and refactoring that stops happening.
  • Speed may be bought from the optional half of a compiler — analysis and transformation — and never from the obligatory half that establishes what the program means.

Four budgets, and they are not independent

It is tempting to describe a compiler as fast or slow. There are four axes, they are in tension, and a design that wins on one is usually paying on another.

Compile time — wall-clock for a full build, and separately for one file. Memory — peak resident set, which is the axis that fails abruptly rather than gradually, and which caps how much parallelism you can actually use. Incremental turnaround — the time from a one-line edit to a runnable result, which is the number engineers actually experience. Generated code quality — what all the analysis was for.

The tensions are structural. More analysis costs time and memory and buys code quality. Whole-program and link-time optimization buy code quality by spending all three others at once. An architecture designed for fast incremental rebuilds — fine-grained dependency tracking, persistent caches, a query engine rather than a batch pipeline — pays with substantial complexity and often with a slower cold build, because the bookkeeping is not free. Even memory and time trade directly: caching analysis results makes a pass faster and the process larger, and a compiler that runs out of memory at 32 parallel jobs has effectively lost the time it thought it was buying.

Go is the clearest deliberate position on this table. Its designers accepted a middle-end less aggressive than LLVM's in exchange for a compiler and linker fast enough that a large program builds in seconds, and structured the language — no textual header inclusion, an explicit dependency graph, no cyclic imports — so the compiler is not asked to do the expensive things in the first place. That is a language design choice made for compile time, which is the strongest evidence available that this axis is a first-class concern.

What a design decision costs on each axistypical
DecisionCompile timeMemoryIncrementalCode quality
More optimization passesWorseWorseUnchangedBetter, with diminishing returns
Link-time optimizationMuch worseMuch worseMuch worseBetter
Fine-grained incremental architectureWorse coldWorseMuch betterUnchanged
Textual header inclusionMuch worseWorseMuch worseUnchanged
Monomorphizing genericsWorseWorseWorseBetter
Erasing generics insteadBetterBetterBetterWorse for hot generic code
Full debug informationWorseWorseWorseUnchanged; debuggability much better

Where the time actually goes

implementationWhich of these dominates is entirely per-ecosystem. A large C++ build is usually dominated by header and template work in the frontend; a Rust build by monomorphization, trait resolution and LLVM time in the backend; a Go build by essentially none of them, by design; a TypeScript build by type checking that emits no code at all. Advice tuned for one of these is close to useless in another.

Slow builds have a small number of recurring causes, and the largest are usually not in the optimizer.

Header inclusion. In C and C++ the preprocessor pastes headers textually into every unit that includes them, so a header included by five hundred files is parsed five hundred times, and so is everything it includes transitively. The work is multiplicative in a way nothing else here is, and a single widely included header that grows by a few thousand lines can add minutes to a full build. This is the problem [[modules]] and [[interface-files]] exist to solve, and the reason precompiled headers were invented as a workaround — see [[the-preprocessor]].

Template instantiation. Each distinct set of template arguments produces a fresh instantiation to parse, check and optimize, and instantiations nest. The frontend, not the optimizer, is usually where the time goes, and much of the output is duplicate work that the linker later discards — see [[template-instantiation]].

Monomorphization. The same phenomenon in Rust and in generic C#, and by design: generic code is specialised per concrete type so it can be optimized as if it were hand-written. The compile-time bill is the price of the runtime benefit, and it is what [[monomorphization]] versus [[type-erasure]] is really a decision about.

Link-time optimization. Discussed at length in [[link-time-optimization]]; the relevant fact here is that it converts a parallel build into one large serial memory-hungry step, so its cost is worse than its share of the total work.

Debug information. DWARF for a large C++ program is frequently larger than the code it describes, and generating, writing and linking it is real time and real I/O. It is also, correctly, the last thing anyone should disable — the cost belongs on the list because it is often the surprising majority of a "why is my object file 80 MB" investigation.

And the serial tail. Linking is one process at the end of an otherwise parallel build, so on a large binary it can dominate the critical path however many cores are available. Modern parallel linkers exist specifically because of this.

The cost that is not measured in seconds

The reason compile time is a first-class engineering concern is not the wall-clock. It is that the length of the edit-build-test loop determines which work a team is willing to attempt.

At a ten-second loop, the natural unit of work is one small change, verified. People try things. A speculative refactor costs ten seconds to evaluate, so it gets evaluated. Tests run locally because running them is cheaper than reasoning about whether they would pass.

At a fifteen-minute loop, all of that inverts. Changes get batched to amortise the wait, which makes each one larger and harder to review and harder to bisect when it breaks. Local testing is replaced by pushing to CI and context-switching, which means the feedback arrives after the mental state that produced the change is gone. Refactoring stops, because a refactor is a change whose value is only visible once you see the result, and nobody spends fifteen minutes to look at something they might discard. Exploratory work — the printf-debugging loop, the "what happens if" — becomes disproportionately expensive.

None of that shows up on a build-time dashboard. It shows up as larger pull requests, more integration failures, and a codebase that stops being tidied. That is why "make the build faster" is frequently a higher-leverage investment than any optimization the build produces, and why the trade in [[compile-time-vs-runtime]] has to be evaluated with the human side included.

  • Batching. Longer loops produce larger changes, which are harder to review, harder to revert and harder to bisect.
  • Loss of local verification. Testing moves to CI, so feedback arrives after the context that produced the change is gone.
  • Refactoring stops. Its value is only visible after the fact, so it is the first activity a long loop prices out.
  • Exploration stops. Printf debugging, experiments and "what if I change this" all require many cheap iterations.
  • Tolerance for slowness compounds. Nobody notices a build getting 5% slower per quarter, and nobody can point at when the team stopped tidying.

What a compiler does about it

The mechanisms fall into three groups, and they attack different budgets.

Do less work. Skip optional analyses at low optimization levels; use cheaper algorithms where the expensive one's extra precision does not pay — linear-scan register allocation instead of graph colouring in a fast tier, for instance, which is [[linear-scan-allocation]]. Bound pass iteration counts. Cap inlining budgets so a pathological input cannot produce quadratic growth.

Do it once. Cache analysis results within a compile and across compiles. Reuse parsed headers via precompiled headers or a module interface. Deduplicate template instantiations. Memoise queries so that an incremental rebuild recomputes only what actually changed — the architecture behind [[incremental-compilation]], and the reason modern compilers are increasingly structured as demand-driven query engines rather than as batch pipelines.

Do it in parallel. Separate compilation gives per-file parallelism for free, which is [[separate-compilation]]'s underrated benefit. Within a compile, code generation parallelises per function; the frontend generally does not, because name resolution and type checking have genuine ordering dependencies. Parallel linkers attack the serial tail.

The uncomfortable observation is that these mechanisms conflict with the ones that improve generated code. Caching costs memory, which caps parallelism. Whole-program optimization destroys per-file parallelism and incrementality outright. Monomorphization improves generated code by generating more of it to compile. There is no configuration that is best on all four axes, which is precisely why "which compiler is fastest" is not a well-formed question and "fastest at what, on what, and at what cost to the other three" is.

How it works

The steps, in the order the compiler takes them.

  • Measure first: per-pass timing reports attribute compile time to frontend, middle-end, backend and link rather than to a feeling.
  • Reduce input size — trim header graphs, forward-declare, adopt modules — because frontend work is multiplied across every unit that includes the header.
  • Cheapen optional work at low optimization levels: fewer passes, cheaper algorithms, bounded iteration and capped inlining budgets.
  • Cache within and across compilations, with keys over the actual inputs so reuse is sound.
  • Restructure as a demand-driven query system so an incremental rebuild recomputes only what a change reached.
  • Parallelise what has no ordering dependency — one job per unit, per-function code generation, parallel linking — and accept that the frontend largely does not.
  • Watch peak memory as a constraint on parallelism, since a build that swaps or is OOM-killed has spent the time it was trying to save.

How it breaks

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

  • A widely included header grows, and a full build gains several minutes with no change to any file anyone edited.
  • A build parallelised to the core count starts swapping, and wall-clock time gets worse as more jobs are added.
  • An incremental build stops being incremental because one generated file is rewritten unconditionally, and every rebuild is a full rebuild that nobody has noticed.
  • Enabling LTO turns a four-minute parallel build into a forty-minute build whose time is one process, and adding CI workers does not help.
  • Object files grow enormously after enabling full debug information, the link becomes I/O bound, and the investigation starts by looking at the code size instead of the debug sections.
  • Compile time creeps up a few percent per quarter, nobody triggers on it, and two years later the team's pull requests have quietly doubled in size.
  • A template used with many argument combinations produces thousands of instantiations, most of which the linker later discards — work done, paid for, and thrown away.

When it helps

  • Diagnosing a slow build, where attributing time to frontend, backend or link decides the entire remedy and guessing usually picks the optimizer, which is usually wrong.
  • Evaluating a language or toolchain choice honestly, since compile time and incremental turnaround are properties of a language design as much as of an implementation.
  • Making the case for investment in build performance, by naming the behavioural costs that a wall-clock metric leaves out.
  • Understanding why a compiler declines to do something — bounded passes, capped inlining, cheaper allocators in fast tiers are budget decisions, not oversights.

When it hurts

  • As a reason to disable debug information or semantic checks. Speed bought from the obligatory half of the compiler is not speed, it is a deferred failure.
  • Optimizing the build before measuring it, which usually results in effort spent on the optimizer while the frontend does the same header work half a million times.
  • Comparing compilers on a single axis, which is how a project ends up with an implementation that is fast at exactly the thing it does not do.

What it costs

Every one of these is paid by something.

  • More analysis buys generated code quality and pays in compile time and peak memory, and peak memory caps how much of the build can run in parallel.
  • A fine-grained incremental architecture buys edit-to-result turnaround and pays in implementation complexity, bookkeeping memory and usually a slower cold build.
  • Monomorphization buys runtime speed for generic code and pays in compile time, memory and binary size; erasure makes the opposite trade and pays at run time.
  • Caching analysis results buys repeated-work savings and pays in memory, which is the axis that turns into a hard failure rather than a slowdown.
  • Whole-program optimization buys cross-module code quality and pays with all three other axes simultaneously, which is why it is a release-only decision.
  • Full debug information buys the ability to diagnose production failures and pays in compile time, link time and artifact size — a trade almost always worth making, and worth making knowingly.

What else you could do

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

  • Design the language so the expensive work does not exist: Go's explicit import graph and absence of textual inclusion make a whole category of compile-time cost unrepresentable.
  • Erase generics instead of monomorphizing, as Java does, trading generated code quality for compile time and code size — see [[type-erasure]].
  • Precompiled headers and unity builds: workarounds that attack header cost without changing the language, at the cost of fragile invalidation and coarse incrementality.
  • Distributed and cached compilation — sccache, distcc, remote execution — which buys wall-clock time with hardware rather than by making the compiler cheaper, and requires a sound cache key to be safe.
  • A separate fast development toolchain from the release one, such as Cranelift as a debug backend for Rust: excellent turnaround for the loop, with the aggressive backend reserved for release.

See it for yourself

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

  • clang -ftime-trace produces a Chrome-trace file attributing compile time to individual functions, template instantiations and passes — the single most useful tool here.
  • -ftime-report (GCC and Clang) and cargo build --timings give per-pass and per-crate breakdowns.
  • clang -H or gcc -H prints the include tree; include-what-you-use finds headers that can be dropped. Counting total preprocessed lines with -E | wc -l is a crude and revealing measurement.
  • /usr/bin/time -v on the slowest single compile and on the link, for peak resident set — the number that decides your parallelism ceiling.
  • size -A and objdump -h to see how much of an object file is debug sections rather than code.
  • Run the build twice with no changes. Anything that rebuilds is broken incrementality, and this thirty-second test finds more build time than most profiling does.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The build is slow because the optimizer is doing a lot." In C++ and Rust the frontend usually dominates, and in C++ most of that is parsing the same headers repeatedly.
  • "More cores will fix it." Only for the parallel part. Linking, full LTO and peak memory are all ceilings that cores do not raise.
  • "Compile time is a machine cost." The machine is the cheap half. The expensive half is the changes engineers stop attempting.
  • "A faster compiler is a worse compiler." It is a compiler making a different trade, sometimes with language support that removes the cost entirely rather than absorbing it.

Misconceptions

The claim, and what is actually true.

Compiler performance means the performance of the code it emits.
That is one of four axes. Compile time, memory and incremental turnaround are the three that engineers experience every day, and they trade against the fourth.
Fast compilers just optimize less.
Sometimes. More often they are designed — or their language is designed — so the expensive work does not arise: no textual inclusion, explicit dependency graphs, deduplicated instantiations, demand-driven recomputation.
Incremental compilation is strictly better than a batch pipeline.
It costs implementation complexity, bookkeeping memory and usually a slower cold build. It is a trade that pays off for interactive development and can lose for a one-shot CI build.
Build time is an engineering-comfort issue.
It determines the size of the changes a team makes and whether refactoring happens at all, both of which show up in the codebase long after anyone remembers why.

Go deeper

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

overview

The compiler is a program too, and it is judged on how long it takes, how much memory it uses, how fast it can redo a small change, and how good its output is. Those pull against each other. Slow builds usually come from the front of the pipeline — headers parsed over and over, generic code specialised many times — rather than from the optimizer, and their real cost is that a long wait changes what people are willing to try.

practical

Measure before acting: -ftime-trace or cargo build --timings will usually tell you within minutes that the answer is headers, or templates, or the link, and it is rarely the thing you assumed. Then run the build twice with no changes — anything that rebuilds is broken incrementality, and that is normally the cheapest large win available. Check peak memory on your slowest compile before raising the job count. Keep debug information on and pay for it knowingly. And treat a creeping build time as a defect with an owner, because nothing else will catch a few percent a quarter.

advanced

The structural shift worth understanding is from the batch pipeline to the demand-driven query engine. A batch compiler runs phases over the whole input in order, which is simple and makes incrementality an afterthought bolted on at file granularity. A query compiler expresses everything as memoised functions — "the type of this item", "the IR for this body" — with dependencies recorded automatically as they are demanded, so a change invalidates exactly the queries that transitively read what changed, at a granularity far below a file. Roslyn, rustc and modern language servers are all built this way, and the reason is not compile time in the batch sense but the fact that an IDE is a compiler required to answer questions in milliseconds after every keystroke. The cost is substantial: every intermediate result needs a stable key and a hash, cyclic dependencies must be detected rather than merely avoided, and the memory to hold the cache competes directly with the parallelism the machine could otherwise use. It is the largest example in this domain of an architecture chosen for a non-runtime axis.

How much this depends on

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

implementationWhere compile time goes is a property of a specific toolchain and version, not of compilation. Clang and GCC differ substantially in frontend cost on the same C++ translation unit; rustc's balance between its own frontend and LLVM shifts with every release and with the choice of backend; Go's compiler makes a completely different set of trades. Profile your build rather than transferring a conclusion from someone else's.
typicalThe claim that frontend work dominates is characteristic of header- and template-heavy C++ and of monomorphization-heavy Rust. It is not general: a build with modest generics and aggressive optimization can be backend-dominated, and a TypeScript build spends its time in a type checker that emits no code at all.
targetBackend cost varies with the target. Register allocation and instruction selection are more expensive for targets with irregular register files or complex addressing, and cross-compiling to a target with a different instruction count per source operation shifts the frontend-to-backend ratio measurably.

If you were asked this in an interview

  • Your C++ build takes twenty minutes. What do you measure first, and what are the two most likely answers?
  • Name the four axes a compiler is judged on and give a design decision that improves one at the direct expense of another.
  • Beyond the wall-clock time, what does a fifteen-minute build cost an engineering team?

Connections

Computer Architecturememory-hierarchyworking-set
Domains that do not exist yet
  • DevOps / Production Engineering — Build fleet capacity, remote execution and treating build duration as a monitored service level
    Once compile time is understood as an engineering cost rather than a machine cost, keeping it low becomes an operational commitment — worker sizing, cache hit rates, queue time and a regression alert on build duration. We stop at what the compiler spends its time on and why; running the fleet that absorbs it is theirs.