Pipelineimplementation

What Dies at Each Stage

Every handover in the pipeline destroys something, and every tool you rely on afterwards — diagnostics, debuggers, profilers, stack traces, source maps — is an attempt to buy back one specific casualty at a specific price.

The question

Why can the debugger not show me that variable, and why does the error message point at the wrong thing?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The pipeline read as a sequence of deletions rather than a sequence of additions. Each representation answers a new question by discarding what it no longer needs, so the useful description of a stage is not only what it computed but what it stopped being able to say. Debug information is a parallel representation carried alongside, existing solely to reverse some of those deletions on demand.

What this phase may assume or do

A stage may discard anything the language definition does not require to be observable, which is nearly everything about the source: names, comments, layout, expression nesting, even the existence of a variable. Preserving any of it is a deliberate, paid-for choice rather than an obligation. The one hard constraint is that discarding must not change defined observable behavior — an optimizer may delete a variable, but not a volatile access to it, because that access is defined to be observable and is therefore not information the stage is entitled to lose.

Key points

  • Every stage of the pipeline destroys something in order to answer the next question; the deletions are the useful description of the stage.
  • Debug information is a parallel representation whose only purpose is to undo specific deletions on demand, and it routinely exceeds the size of the code.
  • A variable's location is a function of the program counter after optimization, not a fixed fact, and sometimes there is no location because the value was never computed.
  • "Optimized out" is usually accurate rather than a tooling failure.
  • Intent, comments and names are the losses no format recovers, which is why source is the artifact of record and the binary never becomes one.

Read the pipeline backwards

The usual telling of the pipeline is additive: tokens add grouping, trees add structure, types add meaning. The additive story explains how compilation works and explains none of the things engineers actually hit. Read the same rail as a list of deletions and the entire tooling landscape becomes predictable.

Every row below has a tool built to undo it, and every one of those tools has a cost that shows up somewhere else — in build time, in artifact size, in optimization quality, or in an approximation that is wrong just often enough to mislead.

The same pipeline, listed by what it destroystypical
  1. Lexingbuild time
    Tokens with spans.
    Whitespace, comments and formatting. A formatter cannot work from this, which is why it needs a concrete syntax tree instead — [[concrete-syntax-tree]].
  2. Parsingbuild time
    An AST.
    Parentheses, punctuation and the grammar path taken. (a + b) * c and a + b under a precedence-shaped tree become indistinguishable in form from each other's structure, and a source-to-source tool cannot round-trip.
  3. Desugaringbuild time
    A smaller core language.
    Which surface syntax the developer wrote. A diagnostic emitted after this points at a for loop the user never typed, unless spans were threaded through the rewrite — [[desugaring]].
  4. Type erasurebuild time
    A program whose generic parameters have been replaced by their bounds.
    The type argument itself. This is why Java cannot ask a List what it holds, and why the check has to be inserted at every extraction point instead — [[type-erasure]].
  5. Lowering to IRbuild time
    Three-address instructions over virtual registers.
    Expression nesting, and most source-level type structure. A struct becomes an offset; a method becomes a function with an extra argument.
  6. Optimizationbuild time
    A transformed IR.
    The correspondence between instructions and source positions. Inlining destroys the call frame; hoisting moves a computation out of the line it was written on; dead-code elimination removes lines entirely.
  7. Register allocationbuild time
    Physical registers and stack slots.
    Variable identity. A source variable may live in three different registers at three points and nowhere at all in between — which is the literal meaning of "optimized out".
  8. Assembly and linkingbuild time
    Encoded bytes with a symbol table.
    All local names. Only exported symbols survive, and stripping removes even those — after which a stack trace is a list of addresses.
  9. Minificationbuild time
    Semantically equivalent source with short names.
    Every identifier and all layout. The JavaScript case, and the reason [[source-maps]] exist as a separate artifact.

Read it asEvery loses entry is a support ticket. "The error points at the wrong line" is span threading. "The debugger says optimized out" is register allocation. "The stack trace is addresses" is stripping. "The stack trace is a.b.c at line 1 column 48213" is minification. None of these are bugs; they are the pipeline working, and each has a specific, purchasable remedy.

Debug information is a second program

implementationDWARF is the format used by Clang, GCC and rustc on Unix-like targets; Windows toolchains use PDB, and the JVM and JavaScript ecosystems use entirely different mechanisms — line-number tables in class files, and JSON source maps for JavaScript. The principle that variable location is a function of program counter holds across all of them; the encoding, the size and the tooling do not transfer at all.

The remedy for most of the list is to emit a parallel representation that records the mapping from what the machine will do back to what the developer wrote. In native toolchains that representation is DWARF, and it is genuinely large: line tables, variable location lists describing which register or stack slot holds each variable *at each instruction range*, type descriptions, and the inlining tree needed to reconstruct a call stack that no longer physically exists.

It is worth internalising that a variable location is a function of the program counter, not a fact. After optimization, x might be in rbx for one instruction range, in a stack slot for another, and nowhere at all for a third because its value is recomputable and the compiler chose not to keep it. DWARF can express all three. What it cannot express is a value that was never computed, which is why some variables are honestly unavailable rather than merely hidden.

The cost is real and often surprising: debug sections routinely exceed the size of the code they describe, which is why release builds strip them into separate files — .dSYM bundles, .pdb files, debuginfod servers — and why [[symbolication]] is a deployment concern rather than a debugging one.

Why "optimized out" is honest

Engineers usually read "optimized out" as the debugger failing. It is more often the debugger being truthful about a program in which the variable does not exist at that point.

Consider a local whose value is used once, immediately, in the next instruction. The compiler may keep it in a register that is overwritten straight afterwards; it may never materialise it at all, folding the computation into the instruction that consumes it; or it may have proved the whole computation dead and deleted it. In the first case the value exists briefly and DWARF can describe when. In the second it exists only as part of another instruction's operand. In the third it never existed. All three report as unavailable, and only the first is recoverable in principle.

This is why [[debug-vs-release]] is a real decision rather than laziness. Building at -O0 with -g keeps every variable in a stack slot for its whole scope, which makes the debugger tell you everything and makes the program slower by a factor that varies enormously. The middle option — -Og, or optimized builds with full debug information — is a genuine compromise and behaves like one.

What each remedy buys, and what it costs
CasualtyRemedyPaid in
Source position of a diagnosticSpans threaded through every phase, including desugaring and macro expansionMemory on every node, and discipline in every transformation — a pass that forgets to carry a span breaks a message years later
Variable names and locationsimplementationDWARF or PDB, with location lists per instruction rangeDebug sections often larger than the code, plus a symbol store per shipped build
The call stack after inliningInlined-frame records, so one physical frame reports as several logical onesMore metadata, and a debugger that must be taught to read it
Identifiers after minificationA source map published alongside the bundleA second artifact to build, version and serve — and a decision about whether to expose your source to anyone who fetches it
Function names after strippingAn archived unstripped binary or a symbol server, plus symbolication at report timeStorage per build forever, and a process that must have been in place before the crash
Types after erasureReified generics, or run-time type tokens passed explicitlyCode size from monomorphization, or an extra parameter on every generic call — see [[monomorphization]]

The loss that is not recoverable at all

Some information is not merely discarded but was never expressible. Intent is the main one: the compiler knows that a loop sums an array, and does not know that the array is a customer balance and the sum must not silently overflow. Comments are gone by the second stage and no metadata format carries them. Variable names survive only as labels, which is why a decompiler produces correct code with names like v3 — the semantics were preserved perfectly and the meaning was not.

This is the honest limit of the whole enterprise, and it is why source remains the artifact of record. A binary is a complete description of what will happen and a poor description of what was meant, and every review process, every test suite and every comment exists on the source side of that gap for exactly this reason.

How it works

The steps, in the order the compiler takes them.

  • Each phase records only what the next phase needs, discarding the rest to keep the representation small and its invariants simple.
  • A compiler asked for debug information emits, in parallel, a line table mapping instruction addresses to source positions and a location list mapping each variable to a register or stack slot per address range.
  • Every transformation is responsible for updating that metadata; a pass that moves an instruction without moving its debug location silently corrupts the mapping.
  • Inlining records an inlined-frame chain so a single physical frame can be reported as several logical ones.
  • Linking merges the metadata; stripping removes it into a separate file addressed by a build id, which a symbolication step later resolves.

How it breaks

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

  • A breakpoint set on a line never fires, because the line's code was hoisted, merged or deleted, and the debugger placed it on the nearest surviving instruction which belongs to another line.
  • Stepping through an optimized build jumps backwards and forwards between lines apparently at random, because instructions from several source lines were interleaved by scheduling.
  • A production crash report contains a stack of addresses and the matching unstripped binary was not archived, so the report can never be resolved.
  • A JavaScript error reports line 1 column 48213 because the bundle was minified and the source map was not published, or was published and points at a stale build.
  • An error message blames a construct the developer did not write — a desugared loop, an expanded macro, a synthesised constructor — because the rewrite did not carry the original span.
  • A profiler attributes ninety percent of runtime to one enormous function, because everything was inlined into it and the inlined-frame metadata was not emitted.

When it helps

  • Diagnosing tooling behavior that looks like a bug: nearly every "the debugger is lying" report resolves to a specific, named deletion with a specific remedy.
  • Deciding what to ship. Symbols, source maps and debug sections are each a separate decision with a separate cost, and they are usually made by default rather than deliberately.
  • Designing a compiler or a code generator, where the question "what will someone need to reconstruct later" has to be answered before the first pass is written, because retrofitting span threading is close to impossible.

When it hurts

  • Publishing source maps or unstripped binaries publicly, which hands out the source you thought you had shipped as a bundle. The remedy has a security cost, and it is usually not weighed.
  • Insisting on full debuggability in a release build, which constrains the optimizer in ways that are hard to bound and produces neither a fast build nor a debuggable one.

What it costs

Every one of these is paid by something.

  • Threading spans through every stage buys accurate diagnostics for the life of the compiler, and costs memory on every node plus a permanent discipline: every transformation, forever, must carry them or a message somewhere becomes wrong.
  • Emitting debug information buys a working debugger and symbolicated crash reports, and costs artifact size — commonly more than the code itself — plus a symbol store per shipped build that must be retained for as long as any deployed version might crash.
  • Optimizing aggressively buys runtime speed and pays in correspondence: variables disappear, lines interleave, frames merge, and stepping becomes unreliable. That is the actual content of [[debug-vs-release]].
  • Publishing source maps buys usable production error reports and costs the confidentiality of the original source, which for a commercial front end is a real decision and not a formality.

What else you could do

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

  • Keep a concrete syntax tree instead of an AST, preserving every token and every space, which is what formatters, linters and refactoring tools do and what makes them slower and larger — [[concrete-syntax-tree]].
  • Reify types instead of erasing them, so run-time code can ask what a generic was instantiated with. C# does this and pays with a more complex runtime; Java did not and pays with unrecoverable type arguments.
  • Ship a runtime that reports its own stack traces with names, which is what the JVM and every scripting runtime do — the information never dies because the artifact carries it by design.
  • Retain the intermediate representation itself alongside the binary, which is what fat-LTO artifacts and WebAssembly custom sections allow, at the cost of an artifact that contains most of your source's structure.

See it for yourself

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

  • What debug information exists: readelf --debug-dump=info ./program | head -50 on Linux, dwarfdump on macOS. size -A ./program shows how much of the artifact the debug sections are.
  • Where a variable actually lives: in gdb, info address x and info locals inside an optimized frame, and compare against the same build at -O0.
  • How much was inlined into one function: nm --size-sort before and after -O2, or perf report with and without --no-children.
  • Whether spans survived a rewrite: compile a macro-heavy or generic-heavy file with an intentional error and see whether the caret points at your source or at expanded code.
  • JavaScript: compare a stack trace with and without the source map applied, and check the sourcesContent field to see whether you are publishing your source along with it.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The debugger is broken." It is reporting a program that does not have the shape of your source any more. At -O0 it will tell you everything, which is the fastest way to confirm it.
  • "Debug symbols make the program slower." They make the artifact larger. What makes the program slower is the lower optimization level usually enabled alongside them; -O2 -g is a normal and useful combination.
  • "Stripping is a size optimization." It is a size optimization that permanently converts every future crash report into an unsolvable one, unless the symbols were archived first.
  • "A source map is a debugging convenience." A source map published on a public server is a copy of your source code, served to anyone who requests it.

Misconceptions

The claim, and what is actually true.

Compiled code contains my variable names.
It contains exported symbol names and whatever debug metadata was requested. Local names exist only in that metadata, and stripping removes it entirely.
If I can see it in the source, the debugger can show it to me.
Only if the value still exists at that point and the compiler recorded where. After optimization, frequently neither holds.
Source maps restore the original program.
They map positions and names back. The code that ran is still the transformed code, and stepping through a source map is stepping through an approximation of a program that was not executed.

Go deeper

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

overview

Every stage of compilation throws something away in order to answer the next question. Comments go first, then formatting, then structure, then names, then the identity of variables themselves. Debuggers and error messages work only because compilers are asked to write down, separately, enough to undo some of it.

practical

When a tool tells you something unhelpful, name the deletion. Wrong line in an error: spans, probably lost in a desugaring or a macro. Optimized out: register allocation, and -O0 will confirm it in thirty seconds. Addresses in a stack trace: stripping, and the fix had to happen before the crash. Line 1 column 48213: minification without a published map. Each one is a known casualty with a known remedy and a known bill.

advanced

The design principle worth carrying is that information is cheap to keep and impossible to reconstruct. A compiler that threads spans from the first day pays a few percent in memory; one that decides to add them in year three finds that every pass, every rewrite and every synthesised node is a place where the information does not exist and cannot be invented. The same asymmetry explains why language servers are built on compiler frontends rather than on separate parsers, why formatters need their own tree, and why every serious debugging format has grown in the direction of expressing more about optimized code rather than asking optimizers to do less. The compiler's job is to lose information; the toolchain's job is to decide, in advance, which losses it will refuse to accept.

How much this depends on

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

implementationDWARF, PDB, JVM line-number tables and JavaScript source maps are four unrelated mechanisms with different capabilities. DWARF can express per-range variable locations and inlined frames; JavaScript source maps map positions and names and cannot express a variable location at all, which is why minified debugging is worse than optimized native debugging in a specific, structural way.
typicalThe claim that debug sections often exceed code size is typical of C++ and Rust builds with full debug information and depends heavily on template and generic instantiation counts; for a small C program the ratio is much smaller. Measure with size -A rather than assuming either direction.
simplifiedThe stage list treats each deletion as happening at one point. In practice several happen gradually — inlining destroys frames over many passes, and register allocation destroys variable identity in stages as values are coalesced and rematerialised. The metadata degrades continuously rather than in steps.

If you were asked this in an interview

  • A variable shows as optimized out in a release build. Explain what that actually means and what you would do next.
  • What information does a compiler have to carry deliberately, and what happens to a toolchain that decides to add it later?
  • Why does an error message sometimes point at code the developer did not write?

Connections

Computer Architectureregistersinstruction-decode
Domains that do not exist yet
  • Testing & Reliability Engineering — Crash reporting pipelines, symbol servers and error aggregation as an operational practice
    This domain owns what the compiler must emit for a crash report to be resolvable at all. Collecting, deduplicating and acting on those reports is a reliability practice and is owned there.
  • Programming Languages & Runtime Internals — Runtime-produced stack traces and reflective metadata
    A managed runtime keeps names alive by design rather than by emitting a side table, which is why its debugging story is structurally different. How the runtime holds that information is theirs; what a compiler must emit for a native toolchain is ours.