Technopedia

The technologist’s encyclopedia

Compilation

Compilation studies how source code is transformed into executable programs. It is relevant whenever a program must be translated from one representation to another for execution on a target architecture.

Authoritative sources

Compiler pipeline

A compiler transforms source code into machine code through a sequence of passes:

source→tokenizetokens→parseAST→type checktyped AST→IR genIR→optimizeIR′→code genmachine code\text{source} \xrightarrow{\text{tokenize}} \text{tokens} \xrightarrow{\text{parse}} \text{AST} \xrightarrow{\text{type check}} \text{typed AST} \xrightarrow{\text{IR gen}} \text{IR} \xrightarrow{\text{optimize}} \text{IR}' \xrightarrow{\text{code gen}} \text{machine code}
Details

The pipeline is a sequence of passes, each of which reads one representation and produces the next. Passes are independent: a compiler can be re-targeted by replacing the back-end (IR → machine code) while keeping the same front-end (source → IR).

Front-end: parsing and type checking

The front-end translates source text into a structured, type-checked representation. It is target-independent: the same front-end can feed any back-end.

Details

The front-end is where most user-facing error messages originate. A good front-end reports the first error with a precise location and a suggestion. The AST is a tree whose nodes are syntactic constructs (function calls, conditionals, assignments) and whose leaves are identifiers and literals. The type checker annotates each node with its type, producing a typed AST that the IR generator can consume.

The front-end is entirely target-independent. The same C front-end can produce x86, ARM, or RISC-V code by swapping the back-end. This separation is the key architectural insight of the compiler pipeline.

Intermediate Representations

An intermediate representation (IR) is a machine-independent, structured form of the program that sits between the front-end and the back-end. IRs exist because:

  1. The AST is too high-level for optimization (it mirrors source syntax, not computation).
  2. The target ISA is too low-level to be portable (each architecture has different registers, opcodes, and calling conventions).
  3. A middle form lets the compiler apply optimizations once, independent of the target.

Three-address code (TAC)

Every operation is expressed with at most three operands: x←y op zx \leftarrow y \ op \ z. This eliminates nested expressions and makes data dependencies explicit.

// Source:  c = (a + b) * (a - b)
// TAC:
  t1 = a + b
  t2 = a - b
  c  = t1 * t2

Static Single Assignment (SSA) form

In SSA, every variable is assigned exactly once. Multiple logical assignments to the same variable become distinct SSA names (x1,x2,x3x_1, x_2, x_3), joined by phi (φ) nodes at control-flow merge points.

// Source:
//   if (cond) { x = 1; } else { x = 2; }
//   y = x + 3;
// SSA:
  x1 = 1
  x2 = 2
  x3 = φ(x1, x2)   // at the merge point
  y  = x3 + 3

SSA makes data-flow analysis (liveness, reaching definitions, constant propagation) linear-time and local, because each variable has a single definition site.

Details

Why SSA matters for optimization: In non-SSA form, a variable like x may be defined in many places, so a pass that wants to know “what value does x have here?” must search all preceding definitions. In SSA, the value at any point is determined by following the single definition chain (or a φ node). This turns global data-flow problems into local graph traversals.

Common IRs in practice:

The choice of IR is a trade-off: higher-level IRs preserve more structure (loops, arrays, pointers) for aggressive optimizations; lower-level IRs are closer to the target and easier to schedule.

Optimization passes

Optimization transforms the IR to reduce execution time, code size, or power consumption while preserving semantics. Passes are organized by the scope of analysis they perform.

Local (basic-block) optimizations

Loop optimizations

Global (whole-function) optimizations

Details

Register allocation is the most complex optimization. It is modeled as a graph coloring problem:

a b c d e Interference graph: 5 variables, 3 physical registers → 2 spills needed

Chaitin’s algorithm (Chaitin et al., 1981) extends greedy coloring with a spilling strategy: if a node has degree ≥ kk (more neighbors than available registers), it is tentatively removed, the graph is colored, and the removed node is assigned a stack slot (spill). The interference graph is built in O(n2)O(n^2) time (pairwise liveness checks over nn variables); the coloring step is O(n+m)O(n + m) where mm is the number of edges. The overall complexity depends on the spilling strategy chosen.

Simpler alternatives: linear-scan allocation (used by V8, .NET) processes variables in order of their live range and assigns the first free register, spilling when none is available. It is O(nlog⁡n)O(n \log n) and fast enough for JIT compilers where compile time is critical.

Code generation

Code generation is the final, target-specific phase: it translates the optimized IR into a sequence of instructions for a specific ISA.

Instruction selection

Match IR patterns to target instructions. This is a tree-covering or dynamic-programming problem: find the cheapest sequence of instructions that computes the same result as the IR expression tree.

// IR:  t1 = a + b
//      t2 = t1 * 4
// x86-64 selection:
  mov  rax, [a]
  add  rax, [b]      ; t1 = a + b
  shl  rax, 2        ; t2 = t1 * 4  (shift is cheaper than imul by power of 2)

Instruction scheduling

Reorder instructions within a basic block to hide pipeline stalls (data hazards, branch mispredictions). The scheduler respects data dependencies but may reorder independent instructions to fill pipeline slots. See CPU Architecture for pipelining and out-of-order execution details.

Peephole optimization

Examine a small window of consecutive instructions and replace them with a shorter or faster equivalent:

Details

Code generation is the most target-specific part of the compiler. A single IR can be lowered to x86-64, AArch64, or RISC-V by swapping the instruction selection table and the scheduling model. The calling convention (which registers hold arguments, which are caller-saved vs. callee-saved) is part of the target ABI and is handled here. See CPU Architecture for the full ISA description, register files, and calling conventions.

The output of code generation is an object file (ELF on Linux, Mach-O on macOS, PE/COFF on Windows) containing:

Linking

The linker combines multiple object files (and libraries) into a single executable or shared library. It is distinct from the loader, which maps the final binary into memory at process start.

Static linking

The linker resolves all symbol references at build time, copying the needed code from libraries (e.g., libc.a) directly into the executable. The result is a self-contained binary with no runtime library dependencies.

Dynamic linking

The linker records references to shared libraries (e.g., libc.so) and the dynamic linker (runtime loader, e.g., ld-linux.so) resolves them at process start or on first use. This reduces binary size and allows multiple processes to share one copy of the library in memory.

Symbol resolution and relocation

Each object file exports symbols (functions, globals) and imports symbols it references. The linker:

  1. Merges symbol tables from all input objects.
  2. Resolves each undefined symbol to exactly one definition (or reports an error).
  3. Performs relocations: patches placeholder addresses in the code and data sections with the final addresses assigned by the linker.

Linking vs. loading

Details

Position-independent code (PIC): dynamically linked code must be position-independent because the shared library may be loaded at any address. PIC uses a Global Offset Table (GOT) and Procedure Linkage Table (PLT) to resolve addresses at runtime. Modern compilers (x86-64, AArch64) generate PIC by default.

Link-time optimization (LTO): the linker receives IR (not machine code) from each translation unit, enabling cross-module inlining, dead code elimination, and register allocation across function boundaries. This is a hybrid of compilation and linking.

Execution models

Three models govern when and how compilation happens relative to execution:

Details

Bytecode is a platform-independent intermediate representation. The JVM bytecode, .NET IL, and Python bytecode are all examples. Bytecode is easier to verify (security: the JVM verifier checks type safety before execution) and to optimize (JIT: the runtime can specialize code based on observed types) than native code.

Tiered compilation (used by HotSpot, V8): the runtime starts with a fast, unoptimized JIT (or interpreter) and progressively compiles hot functions with more aggressive optimizations. V8 uses three tiers: Sparkplug (baseline), Maglev (mid-tier), and TurboFan (optimizing, with deoptimization if assumptions are violated).

AOT + JIT hybrid: .NET Native (AOT) and GraalVM (AOT for Java) compile to native code at build time but may still use runtime profiling for adaptive optimization.

Example problem

Problem: A C function int sum(int *a, int n) sums an array of nn integers. A caller invokes it three times with arrays of lengths n1,n2,n3n_1, n_2, n_3. After inlining, how many times does the loop body execute?

Solution

Inlining copies the function body into each call site. The loop body executes n1+n2+n3n_1 + n_2 + n_3 times total — once per element across all three arrays. The inlining itself does not change the iteration count; it eliminates the call/return overhead (register save/restore, jump) and exposes the loop to further optimizations (unrolling, vectorization, register allocation) that reduce the per-iteration cost.