Intermediate Representation: SSA, CFG, and IR Design in Compilers
Master intermediate representation in compilers: SSA form, control flow graphs, and IR design.
20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.
- ✓Basic understanding of compilers (lexical analysis, parsing)
- ✓Familiarity with C++ programming
- ✓Knowledge of data structures (graphs, trees)
- IR is a compiler's internal representation of code, bridging source and target languages.
- SSA form ensures each variable is assigned exactly once, simplifying optimizations.
- CFG models program control flow, enabling analysis and transformations.
- IR design choices (e.g., tree vs. linear) impact compiler performance and optimization capability.
Think of a compiler like a translator between two languages. The intermediate representation is like a detailed outline or blueprint that captures the meaning of the original text without being tied to the exact words. SSA form is like ensuring every idea is written only once, making it easier to spot contradictions. CFG is like a flowchart showing all possible paths through the program. Together, they help the compiler optimize code efficiently.
When you write code in a high-level language like C++, the compiler doesn't directly translate it to machine code. Instead, it uses an intermediate representation (IR) — a structured, platform-independent form that captures the program's semantics. This IR is the heart of modern compilers, enabling powerful optimizations and portability across architectures.
Two foundational concepts in IR design are Static Single Assignment (SSA) form and Control Flow Graphs (CFG). SSA ensures every variable is assigned exactly once, which simplifies data-flow analysis and enables optimizations like constant propagation and dead code elimination. CFGs model the program's control flow as a directed graph, where nodes represent basic blocks and edges represent jumps. Together, they form the backbone of compiler optimization passes.
In this tutorial, you'll learn how to design and work with IRs, understand SSA and CFG in depth, and see real-world examples from LLVM and GCC. We'll also cover common pitfalls and production debugging techniques. By the end, you'll have a solid grasp of how compilers represent and transform code internally.
What is Intermediate Representation?
Intermediate Representation (IR) is a data structure used internally by a compiler to represent source code. It is designed to be independent of the source language and target machine, facilitating analysis and transformation. Common IR forms include abstract syntax trees (ASTs), three-address code (TAC), and static single assignment (SSA) form. IRs can be high-level (close to source) or low-level (close to machine). The choice of IR affects compiler performance, optimization opportunities, and portability.
For example, LLVM uses a low-level IR that is SSA-based, while GCC uses a series of IRs (GIMPLE, RTL). The IR must be expressive enough to capture all source constructs while being simple enough for efficient analysis. Key properties of a good IR include: unambiguous semantics, compact representation, and support for control flow and data dependencies.
Control Flow Graph (CFG)
A Control Flow Graph (CFG) is a directed graph where nodes represent basic blocks (straight-line code sequences with no jumps) and edges represent control flow (branches, loops, function calls). CFGs are fundamental for optimizations like dead code elimination, loop invariant code motion, and register allocation.
A basic block ends with a terminator instruction (e.g., branch, return). The CFG has a unique entry block and may have multiple exit blocks. Edges can be conditional or unconditional. Dominance relationships (e.g., dominator tree) are derived from CFG and used for SSA construction.
Example: A simple if-else statement produces a CFG with three blocks: entry, then, else, and merge.
Static Single Assignment (SSA) Form
SSA form is an IR property where each variable is assigned exactly once, and every use is reached by exactly one definition. This simplifies data-flow analysis because variable names are unique. To handle multiple definitions (e.g., from if-else), special phi (φ) functions are inserted at join points. Phi functions select a value based on which incoming path was taken.
SSA construction involves two steps: inserting phi nodes at dominance frontiers and renaming variables. The dominance frontier of a block is the set of blocks where the block's dominance ends. Phi nodes are placed at the dominance frontiers of each variable's definition.
Example: In the max function, the variable 'result' has two definitions (in Block2 and Block3). At the merge block (Block4), a phi node is inserted: result = phi(Block2: a, Block3: b).
IR Design Principles and Trade-offs
Designing an IR involves balancing expressiveness, simplicity, and efficiency. Key considerations: - Level of abstraction: High-level IRs (e.g., AST) preserve source structure but are harder to optimize. Low-level IRs (e.g., LLVM IR) expose machine details but require more lowering. - SSA vs. non-SSA: SSA simplifies many optimizations but adds phi nodes. Non-SSA (e.g., three-address code) is simpler but requires reaching definitions analysis. - Graph vs. linear: Graph-based IRs (e.g., CFG) are natural for control flow; linear IRs (e.g., bytecode) are compact and easy to interpret. - Typed vs. untyped: Typed IRs (e.g., LLVM IR) catch errors early but add complexity. Untyped IRs are simpler but require type inference.
Modern compilers often use multiple IRs. For example, GCC uses GENERIC (high-level), GIMPLE (SSA-based), and RTL (low-level). LLVM uses a single SSA-based IR throughout.
Optimizations Using SSA and CFG
- Constant propagation: Since each variable is defined once, constant values can be propagated forward easily.
- Dead code elimination: If a variable's value is never used, its defining instruction can be removed.
- Global value numbering: Identifies redundant computations by numbering expressions.
- Loop optimizations: CFG helps identify loops (natural loops, reducible flow graphs). SSA simplifies loop invariant code motion.
- Register allocation: SSA form simplifies live range analysis; phi nodes are resolved later.
Example: Constant propagation in SSA: if a variable is defined as a constant, all uses can be replaced by that constant. This may enable further simplifications.
Implementing a Simple IR in C++
To understand IR deeply, let's implement a minimal IR in C++. We'll define classes for basic blocks, instructions, and a function. This IR will be linear (list of instructions) with SSA-like properties.
We'll support a few opcodes: ADD, SUB, MUL, DIV, CONST, PHI, BR, RET. Each instruction has an opcode, operands (as indices), and a result index. We'll also maintain a symbol table for variable names.
This simple IR can be used to build a toy compiler or educational tool.
Common Pitfalls in IR Design and Use
- Ignoring dominance: Without proper dominance analysis, SSA construction may produce incorrect phi placements.
- Overcomplicating IR: Adding too many opcodes early makes analysis and optimization harder.
- Not handling critical edges: Critical edges (from a block with multiple successors to a block with multiple predecessors) can cause issues in SSA; splitting them is often necessary.
- Forgetting to update SSA after transformations: Any pass that modifies control flow must update phi nodes and dominance information.
- Mixing levels of abstraction: Using high-level constructs in low-level IR can hinder optimizations.
The Case of the Vanishing Variable: An SSA Bug in LLVM
- Always verify SSA form correctness after any transformation that modifies control flow.
- Use compiler verification tools like LLVM's -verify pass to catch SSA violations early.
- Write unit tests that cover edge cases with multiple control flow paths.
- Understand that phi nodes are not just an optimization but a correctness requirement in SSA.
- When debugging, isolate the optimization pass that introduces the bug using bisection.
opt -verify <input.ll>llc -debug-only=ssa <input.ll>| File | Command / Code | Purpose |
|---|---|---|
| simple_ir.cpp | 1: t1 = b * c | What is Intermediate Representation? |
| cfg_example.cpp | int max(int a, int b) { | Control Flow Graph (CFG) |
| ssa_example.cpp | entry: | Static Single Assignment (SSA) Form |
| ir_design.cpp | define i32 @max(i32 %a, i32 %b) { | IR Design Principles and Trade-offs |
| opt_example.cpp | %x = add i32 5, 3 | Optimizations Using SSA and CFG |
| simple_ir_impl.cpp | enum Opcode { ADD, SUB, MUL, DIV, CONST, PHI, BR, RET }; | Implementing a Simple IR in C++ |
Key takeaways
Common mistakes to avoid
3 patternsNot splitting critical edges before SSA construction
Using too many opcodes in the IR
Forgetting to update SSA after a transformation
Interview Questions on This Topic
Explain SSA form and its advantages.
Frequently Asked Questions
20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.
That's Compiler Design. Mark it forged?
3 min read · try the examples if you haven't