Pipelinespec

From Source to Behavior

A source file is a byte sequence with no meaning of its own. What turns it into behavior is a language definition plus an implementation that honours it — and knowing which of the two you are arguing with is most of the skill.

The question

What has to be true before the text in my editor does anything at all?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Plain source text: a byte sequence plus an encoding, and nothing else. It has no structure, no name refers to anything, and no operator has an interpretation. The only question this representation can answer is "which characters are in this file". Every representation after it exists because that answer is not enough.

What this phase may assume or do

Nothing has been transformed yet, so the precondition runs the other way round: whatever the toolchain eventually produces must exhibit the behavior the *language definition* ascribes to this text. That document, not the compiler and not the machine, is what "correct" means for every stage that follows, and it is the only thing that makes a later optimization legal or illegal.

Key points

  • A source file is bytes plus an encoding. Structure, names and meaning are all added later, by phases that can each be wrong.
  • What a program means is defined by a language definition, not by the compiler you happen to run; the compiler is one implementation of that definition.
  • The as-if rule is the whole licence for optimization: match the defined observable behavior and the rest is the implementation's business.
  • Work can happen at authoring, build, install, load or run time, and the choice of moment is the main axis every implementation strategy varies along.
  • Implementation-defined, unspecified, undefined and target-determined are four different freedoms, and confusing them produces four different classes of bug.

The file is not the program

specThese are language-definition facts, not compiler behavior. C signed overflow is undefined by ISO C, so a conforming C compiler on any target may assume it does not occur; Rust defines the behavior in both profiles, which is why rustc cannot make the same assumption. Carrying "the compiler assumes no overflow" from C to Rust is wrong for exactly this reason.

Open a source file in a hex editor and there is nothing there but bytes. 61 20 2B 20 62 is a + b in ASCII and in UTF-8, and it is five bytes of something else in UTF-16. Before any structure exists, an implementation has already made a decision — which encoding to decode the file as — and getting that decision wrong produces an error at byte zero that mentions neither encoding nor bytes.

The deeper point is that the same characters mean different things in different languages, and the difference is not stylistic. It changes what the program computes, what it can fail with, and what the compiler is allowed to assume about it afterwards.

The characters a + b, four languages, four different questions for the compilerspec
LanguageWhat `+` means hereWhat the implementation must therefore decide
CspecInteger addition, pointer arithmetic, or floating-point addition, chosen by the static types of the operands. Signed overflow is undefined.Which operation to emit, and whether it may assume overflow does not happen.
PythonspecA call to a.__add__(b) with a fallback to b.__radd__(a), resolved from the runtime types. Concatenates lists and strings.Nothing at compile time: the operand types are not known until the instruction executes.
JavaScriptspecAddition or string concatenation after abstract coercion of both operands, resolved at run time.Whether to emit a generic add, or a specialised one guarded by a type check that can fail.
Rustimplementationstd::ops::Add::add, resolved statically by trait selection, with overflow defined to panic in debug and wrap in release.Which impl was selected, and which of the two overflow behaviors the current profile asks for.

Behavior is defined by a document, not by a compiler

implementationDictionary ordering is guaranteed by the Python language reference from 3.7 onwards; in CPython 3.6 it was an artefact of the compact dict layout and was documented as not to be relied on. Deterministic destruction at the last reference drop remains CPython-specific: PyPy and other implementations use tracing collection and will finalise later or not at all before exit.

When people ask what a program does, they usually mean what it did on their machine last Tuesday. That is an observation, not a definition. A language definition describes an abstract machine and says what an execution of the program on that machine produces — which outputs appear in which order, which side effects are sequenced against which, and which programs have no defined meaning at all.

C and C++ make this explicit: the standard defines an abstract machine and then permits any implementation whose *observable behavior* matches it. That permission is the as-if rule, and it is the licence under which every optimization in this domain operates. An implementation may delete your loop, reorder your arithmetic and keep your variable in a register forever, provided the outputs and the volatile accesses come out the same. See [[observable-behaviour]] and [[as-if-rule]].

Many languages have no such document. Python has a language reference and a reference implementation, and where they disagree in practice, CPython usually wins the argument by being what everyone runs. That is not a criticism; it is a fact you have to hold, because it changes what "portable" means. A Python program that depends on dictionary insertion order is depending on a guarantee that became specification in 3.7 after being a CPython 3.6 implementation detail — and a program that depends on reference-counted destruction timing is still depending on an implementation detail today.

Four moments, not one

The gap between text and behavior is closed at up to four distinct moments, and every implementation strategy in this domain is a choice about which moment does which work. Nothing forces the work to happen at build time; nothing forbids it either.

The rail below is deliberately coarse. [[compiler-phases]] refines the build column into thirteen stages; [[compiler-vs-interpreter]] shows three real implementations that distribute the same work across these four moments completely differently.

When the work can happentypical
  1. Authoringyou write it
    Text in an editor, plus whatever a language server has already computed about it in the background.
    Immediate feedback: the same analysis a compiler does, run continuously and thrown away.
  2. Buildbuild time
    Whatever artifact the implementation produces — machine code, bytecode, another language's source, or nothing at all.
    Every answer that does not depend on the actual inputs: names resolved, types checked, code selected.
    Source-level identity, unless debug metadata was requested and preserved.
  3. Install or deployinstall time
    The artifact plus its dependency closure, on the machine that will run it.
    The concrete versions of everything the build only named. Ahead-of-time compilation on the target machine, where an installer does it, happens here.
  4. Loadload time
    A process image: code mapped into memory, symbols bound, relocations applied.
    Actual addresses. Until this point every reference to another module was a name and a promise.
  5. Runrun time
    Machine instructions executing, plus whatever runtime support the language requires alongside them.
    The actual argument values, the actual types, the actual hot paths — none of which any earlier moment could know.

Read it asRead this as a budget rather than a sequence. Work moved earlier costs build time and portability and buys startup latency; work moved later costs warmup and memory and buys knowledge the earlier moments did not have. [[aot-compilation]] and [[jit-compilation]] are the two ends of that trade, and most real systems sit somewhere in between.

Same source, same language, different behavior

targetFused multiply-add contraction is on by default in GCC and Clang at standard conformance levels below strict, and off under -ffp-contract=off; on targets without an FMA instruction the question does not arise at all. A numerical test that passes on x86-64 without FMA and fails on AArch64, which has it, is usually this and not a real regression.

Even with the encoding settled and the language fixed, the definition usually leaves room, and the room is where portability bugs live. It is worth learning the four categories by name, because compiler documentation uses them precisely and engineers usually do not.

  • Implementation-defined: the implementation must choose and must document the choice. The width of int, whether char is signed, the alignment of a struct. Your program is portable if it does not depend on the choice, and a compiler flag can often change it.
  • Unspecified: the implementation chooses and need not tell you, and may choose differently on two lines of the same file. The order in which function arguments are evaluated in C and C++ is the classic case, and it is why f(i++, i++) is a bug rather than a puzzle.
  • Undefined: the definition places no requirement at all, which licences the compiler to assume the situation does not arise. This is not "it crashes" — it is "the compiler may reason as though your program never does this", which is why the symptom appears somewhere else entirely. See [[undefined-behavior]] and [[ub-and-optimization]].
  • Target-determined: the definition allows a range and the hardware picks. Floating-point contraction of a multiply and an add into one fused instruction changes the last bits of the result and is permitted by default in several compilers.

How it works

The steps, in the order the compiler takes them.

  • The implementation decodes the file under an assumed or declared encoding, producing a character sequence.
  • It analyses that sequence against the language definition and rejects it if it is not a program, which is the last point at which "this is not valid" is a cheap answer.
  • It fixes a meaning for every construct by consulting the definition's static semantics — types, name binding, overload resolution — and records the result.
  • It chooses a strategy for producing the defined behavior: execute the annotated tree directly, emit bytecode for a virtual machine, emit machine code, or emit another language.
  • Whatever it emits is required only to match the defined observable behavior, not to resemble the source.

How it breaks

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

  • The file is UTF-8 with a byte-order mark and the compiler was not expecting one, so the first diagnostic points at column 1 of line 1 and names a character the developer cannot see in the editor.
  • A program built for two targets computes different results because long is 64-bit on one and 32-bit on the other, and the difference only appears at values above two billion, months later.
  • Two compilers evaluate function arguments in opposite orders, and a call whose arguments have side effects produces different output under each; neither compiler is wrong.
  • A defensive null check disappears in an optimized build because an earlier dereference already made null undefined, and the crash moves from a clean error message to a corrupted write.
  • A test suite passes locally and fails on the CI machine only in the last decimal place, because one target contracted a multiply-add into a fused instruction and the other did not.

When it helps

  • Deciding whether a surprising behavior is a compiler bug or a language rule. Almost always it is a language rule, and knowing which of the four freedoms applies tells you where to look.
  • Porting: the list of things the definition leaves open is exactly the list of things to audit before trusting a build on a new target.
  • Reading a standard or a language reference at all. The abstract machine framing is what makes the rest of the document parse.

When it hurts

  • Treating the specification as a description of what a compiler does. It is an upper bound on what a compiler may do; real compilers exercise a fraction of it, and the fraction changes between releases.
  • Reasoning about a language with no specification as though its reference implementation were one. CPython's behavior is not Python's definition, and the places where that bites are exactly the places nobody thinks to check.

What it costs

Every one of these is paid by something.

  • A tight specification buys portability and cross-implementation agreement, and costs optimization headroom — every behavior you pin down is a transformation someone can no longer perform. Java pinned floating-point semantics early and spent years paying for it on hardware that wanted to be looser.
  • A loose specification buys implementation freedom and speed, and costs the engineer: undefined behavior converts a local mistake into a non-local, version-dependent symptom, and the debugging bill is paid by people who never read the standard.
  • Having no specification at all buys development velocity for the language, and costs every alternative implementation that then has to reverse-engineer the reference one, plus every user who cannot tell a guarantee from an artefact.

What else you could do

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

  • A language defined by an executable semantics rather than prose — Standard ML has a formal definition, and WebAssembly ships a mechanised one — trading accessibility of the document for the ability to prove things about implementations. See [[verified-compilers]].
  • A language defined by a conformance test suite instead of a document, which is effectively how several web platform behaviors are fixed: precise, checkable and silent about anything nobody thought to test.
  • A language with a single blessed implementation and no portability promise at all, which is a defensible choice for a domain-specific language and a bad one for infrastructure. See [[dsl-tooling-cost]].

See it for yourself

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

  • What the compiler actually read: file --mime-encoding src.c, then clang -E src.c to see the text after preprocessing, which is not the text you wrote.
  • What the implementation chose where the definition left it open: cpp -dM /dev/null for the predefined macros, and printf "%zu\n", sizeof(long) on each target.
  • Whether a surprise is undefined behavior: rebuild with -fsanitize=undefined,address and run the test again. The sanitizer reports at the point of the operation rather than at the point of the symptom.
  • Whether two implementations agree: run the same source through several compilers and versions side by side in Compiler Explorer, and diff the output rather than reading either one.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The compiler decides what my code means." The language definition decides; the compiler decides how to achieve it. Where the definition is silent, the compiler decides — and that silence is the part worth learning.
  • "Undefined behavior means it crashes." It means the definition imposes no requirement, so the most common outcome is that it works, quietly, until an optimization level or a compiler version changes.
  • "If it produces the right answer, the program is correct." It produced the right answer under one implementation of a definition that permitted several. That is evidence, not correctness.
  • "Source code is the program." Source code is one representation of it. The artifact that runs has usually lost the variable names, the comments and most of the structure — see [[information-loss]].

Misconceptions

The claim, and what is actually true.

A language is fast or slow.
A language definition constrains what an implementation may assume. Implementations are fast or slow, and the same definition supports both — which is why the same Python source runs under a bytecode interpreter and under a tracing JIT.
The standard tells you what the compiler will do.
It tells you the outer boundary of what any conforming compiler may do. Most compilers do far less than they are permitted to, right up until the release where they do more.
Portable code is code that compiles everywhere.
Portable code is code whose behavior does not depend on the freedoms the definition granted. Code that compiles everywhere and depends on int being 32 bits is not portable, it is untested.

Go deeper

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

overview

Source code is text. It becomes behavior only because a language definition says what the text means and an implementation produces something that behaves that way. Those are two separate things, and most confusing bugs come from arguing with one when the answer lives in the other.

practical

When behavior differs between two builds, sort the cause into four bins before debugging. Different encoding or preprocessing means you are not compiling the same text. Different target means integer widths, alignment or floating point. Different optimization level with no source change means undefined behavior almost every time — run the sanitizers first, not last. Different compiler with the same flags means you relied on something unspecified, most often evaluation order.

advanced

The design tension worth internalising is that every guarantee a language adds removes an optimization from every future implementation of it, permanently. Java specified strict floating point and later had to relax it; C left signed overflow undefined and bought loop analyses that Rust's release profile can only obtain by defining wrapping and then proving the loop bounds another way. When you read a specification, read the omissions: they are the compiler's working capital, and they are also the list of ways your program can be miscompiled by being wrong in a way nobody diagnoses.

How much this depends on

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

specThe abstract-machine-plus-as-if framing is explicit in ISO C and C++ and in the WebAssembly specification. Other languages get to the same place with different vocabulary, and a few — Python and Ruby among them — describe behavior without ever defining an abstract machine, which is why their optimization stories are more conservative.
implementationEverything said about CPython is true of CPython and not of Python. PyPy, GraalPy and MicroPython each honour the language reference and differ in destruction timing, integer caching and the availability of C extension internals.
targetInteger widths, struct alignment, floating-point contraction and the signedness of plain char are target and ABI properties, not compiler properties. Changing compiler will not change them; changing target triple will. See [[target-triples]].

If you were asked this in an interview

  • What does it mean for a compiler to be conforming, and what does that permit it to do to my code?
  • Give me an example of behavior that is unspecified but not undefined, and say why the distinction matters.
  • A test passes at -O0 and fails at -O2 with no source change. What is your first hypothesis and how do you check it?

Connections

OS & Networkingprogram-vs-process
Domains that do not exist yet
  • Programming Languages & Runtime Internals — The runtime support a language definition quietly requires — allocation, finalisation, dynamic dispatch
    The definition says what must happen; the runtime is half of what makes it happen. This domain owns only the compiler-side half: what the compiler must emit so the runtime can do its job.