Home CS Fundamentals Intermediate Representation: SSA, CFG, and IR Design in Compilers
Advanced 3 min · July 13, 2026

Intermediate Representation: SSA, CFG, and IR Design in Compilers

Master intermediate representation in compilers: SSA form, control flow graphs, and IR design.

N
Naren Founder & Principal Engineer

20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.

Follow
Production
production tested
July 13, 2026
last updated
2,165
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic understanding of compilers (lexical analysis, parsing)
  • Familiarity with C++ programming
  • Knowledge of data structures (graphs, trees)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.
✦ Definition~90s read
What is Intermediate Representation?

An intermediate representation (IR) is a compiler's internal representation of source code, designed to be independent of both source and target languages, enabling analysis and optimization.

Think of a compiler like a translator between two languages.
Plain-English First

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.

simple_ir.cppCPP
1
2
3
4
// Example of a simple three-address code IR
// Representing: int x = a + b * c;
1: t1 = b * c
2: x = a + t1
🔥IR Design Trade-offs
📊 Production Insight
In production compilers, IR often goes through multiple lowering stages. For example, Clang generates LLVM IR, which is then optimized and lowered to machine code. Debugging IR issues often requires examining the IR at different stages using tools like 'opt -print-after-all'.
🎯 Key Takeaway
IR is the bridge between source and target code; its design determines the compiler's optimization capabilities and complexity.

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.

cfg_example.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// C++ code
int max(int a, int b) {
    int result;
    if (a > b)
        result = a;
    else
        result = b;
    return result;
}

// Corresponding CFG (basic blocks)
// Entry: a, b parameters
// Block1: if a > b goto Block2 else goto Block3
// Block2: result = a; goto Block4
// Block3: result = b; goto Block4
// Block4: return result
💡Building CFG from IR
📊 Production Insight
When debugging CFG-related issues, use 'opt -dot-cfg' to generate a DOT file and visualize the graph. This helps spot missing edges or incorrect block ordering.
🎯 Key Takeaway
CFG provides a structured view of program control flow, enabling many compiler optimizations and analyses.

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).

ssa_example.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SSA form of max function (pseudo-IR)
entry:
    a0 = param
    b0 = param
    if a0 > b0 goto then else goto else

then:
    result1 = a0
    goto merge

else:
    result2 = b0
    goto merge

merge:
    result3 = phi(result1, result2)
    return result3
⚠ Phi Nodes Are Not Executable
📊 Production Insight
In LLVM, the 'mem2reg' pass promotes memory to SSA registers. If you see 'alloca' instructions in optimized IR, the pass may have failed. Use '-mem2reg' to force it.
🎯 Key Takeaway
SSA form simplifies data-flow analysis by making each variable unique, but requires phi nodes to handle merging paths.

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.

ir_design.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// LLVM IR example (typed, SSA)
define i32 @max(i32 %a, i32 %b) {
entry:
  %cmp = icmp sgt i32 %a, %b
  br i1 %cmp, label %then, label %else

then:
  br label %merge

else:
  br label %merge

merge:
  %result = phi i32 [ %a, %then ], [ %b, %else ]
  ret i32 %result
}
🔥LLVM IR is a 'Low-Level Virtual Machine' IR
📊 Production Insight
When designing a new IR, start with a minimal set of instructions and add only what's necessary. Overly complex IRs slow down compilation and optimization.
🎯 Key Takeaway
IR design is a trade-off; the best choice depends on compiler goals. Many compilers use multiple IRs at different stages.

Optimizations Using SSA and CFG

SSA and CFG enable classic compiler optimizations
  • 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.

opt_example.cppCPP
1
2
3
4
5
6
7
// Before constant propagation
%x = add i32 5, 3
%y = mul i32 %x, 2

// After constant propagation
%x = add i32 5, 3  // could be folded to 8
%y = mul i32 8, 2  // folded to 16
💡Order of Optimizations Matters
📊 Production Insight
In LLVM, the 'opt' tool applies a sequence of passes. Use '-O2' to get a standard optimization pipeline. Custom pipelines can be built with '-passes'.
🎯 Key Takeaway
SSA and CFG are the foundation for many classic optimizations, making them more efficient and effective.

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.

simple_ir_impl.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <vector>
#include <string>
#include <unordered_map>

enum Opcode { ADD, SUB, MUL, DIV, CONST, PHI, BR, RET };

struct Instruction {
    Opcode op;
    std::vector<int> operands; // indices into value table
    int result; // index of result value
};

struct BasicBlock {
    std::vector<Instruction> instructions;
    std::string label;
};

struct Function {
    std::vector<BasicBlock> blocks;
    std::unordered_map<std::string, int> symbolTable;
    int nextValue = 0;
};

// Example: create a constant instruction
Instruction createConst(int value) {
    return {CONST, {}, value};
}
🔥Keep It Simple
📊 Production Insight
Real compilers like LLVM use complex IR classes with inheritance. For learning, a simple vector-based IR is sufficient.
🎯 Key Takeaway
Building a simple IR from scratch helps demystify compiler internals and gives hands-on experience with design decisions.

Common Pitfalls in IR Design and Use

  1. Ignoring dominance: Without proper dominance analysis, SSA construction may produce incorrect phi placements.
  2. Overcomplicating IR: Adding too many opcodes early makes analysis and optimization harder.
  3. 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.
  4. Forgetting to update SSA after transformations: Any pass that modifies control flow must update phi nodes and dominance information.
  5. Mixing levels of abstraction: Using high-level constructs in low-level IR can hinder optimizations.
pitfall_example.cppCPP
1
2
3
4
5
6
7
8
9
10
11
12
// Example of a critical edge
// Block A has two successors B and C; B has two predecessors A and D.
// Edge A->B is critical. Splitting it by inserting a new block helps SSA.

// Before splitting:
// A: br cond, B, C
// B: phi [val1, A], [val2, D]

// After splitting:
// A: br cond, E, C
// E: br B
// B: phi [val1, E], [val2, D]
⚠ Critical Edges Can Break SSA
📊 Production Insight
LLVM's 'break-crit-edges' pass splits critical edges automatically. Run it early in your pipeline.
🎯 Key Takeaway
Avoid common pitfalls like ignoring dominance or critical edges to ensure correct and efficient IR.
● Production incidentPOST-MORTEMseverity: high

The Case of the Vanishing Variable: An SSA Bug in LLVM

Symptom
Users reported that a program produced wrong results when compiled with -O2 optimization, but worked fine with -O0.
Assumption
The developer assumed the bug was in the frontend or a specific optimization pass like loop unrolling.
Root cause
The SSA construction pass failed to insert a phi node at a join point, causing a variable to have an undefined value along one path.
Fix
Added a missing phi node insertion in the SSA construction algorithm, ensuring all paths define the variable before use.
Key lesson
  • 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.
Production debug guideSymptom to Action4 entries
Symptom · 01
Wrong program output after optimization
Fix
Disable optimization passes one by one to isolate the faulty pass. Use -O1, -O2, -O3 flags incrementally.
Symptom · 02
Compiler crash during optimization
Fix
Enable debug output (e.g., -debug in LLVM) to see which pass causes the crash. Check for invalid IR (e.g., dangling references).
Symptom · 03
Slow compilation time
Fix
Profile the compiler to identify slow passes. Consider simplifying IR or using less aggressive optimization levels.
Symptom · 04
Incorrect code generation for specific architecture
Fix
Verify that the IR is target-independent. Check for target-specific lowering issues. Use -emit-llvm to examine IR before code generation.
★ Quick Debug Cheat Sheet for IR IssuesCommon IR problems and immediate actions
Undefined variable in SSA
Immediate action
Check phi node placement at join points
Commands
opt -verify <input.ll>
llc -debug-only=ssa <input.ll>
Fix now
Insert missing phi node or ensure all paths define the variable.
Infinite loop in optimization pass+
Immediate action
Check for infinite recursion or missing termination condition
Commands
opt -time-passes <input.ll>
gdb --args opt <input.ll>
Fix now
Add iteration limit or fix the pass logic.
Memory corruption in IR+
Immediate action
Use address sanitizer
Commands
clang -fsanitize=address -c <input.c>
opt -verify <input.ll>
Fix now
Fix use-after-free or double free in the pass.
PropertySSA IRNon-SSA IR
Variable assignmentEach variable assigned onceMultiple assignments allowed
Phi nodesRequired at join pointsNot needed
Data-flow analysisSimpler (use-def chains)More complex (reaching definitions)
Optimization powerHigher (e.g., constant propagation)Lower
Construction complexityHigher (dominance frontiers)Lower
Code generationRequires phi resolutionStraightforward
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
simple_ir.cpp1: t1 = b * cWhat is Intermediate Representation?
cfg_example.cppint max(int a, int b) {Control Flow Graph (CFG)
ssa_example.cppentry:Static Single Assignment (SSA) Form
ir_design.cppdefine i32 @max(i32 %a, i32 %b) {IR Design Principles and Trade-offs
opt_example.cpp%x = add i32 5, 3Optimizations Using SSA and CFG
simple_ir_impl.cppenum Opcode { ADD, SUB, MUL, DIV, CONST, PHI, BR, RET };Implementing a Simple IR in C++

Key takeaways

1
Intermediate representation is the backbone of modern compilers, enabling portability and optimization.
2
SSA form simplifies data-flow analysis by ensuring each variable is assigned once, with phi nodes at join points.
3
CFG provides a structured view of control flow, essential for many optimizations.
4
IR design involves trade-offs between abstraction level, simplicity, and expressiveness.
5
Common pitfalls include critical edges, overcomplicated IR, and neglecting SSA updates after transformations.

Common mistakes to avoid

3 patterns
×

Not splitting critical edges before SSA construction

×

Using too many opcodes in the IR

×

Forgetting to update SSA after a transformation

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain SSA form and its advantages.
Q02SENIOR
How do you construct SSA from a CFG?
Q03SENIOR
What is a critical edge and why is it problematic?
Q04SENIOR
Describe the role of IR in a just-in-time (JIT) compiler.
Q01 of 04SENIOR

Explain SSA form and its advantages.

ANSWER
SSA form ensures each variable is assigned exactly once. Advantages: simplifies data-flow analysis (e.g., constant propagation, dead code elimination), enables more aggressive optimizations, and reduces complexity of analysis algorithms.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between SSA and non-SSA IR?
02
How do phi nodes work in practice?
03
Why is CFG important for optimization?
04
Can a compiler have multiple IRs?
05
What is a basic block?
N
Naren Founder & Principal Engineer

20+ years shipping production systems from the metal up. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 13, 2026
last updated
2,165
articles · all by Naren
🔥

That's Compiler Design. Mark it forged?

3 min read · try the examples if you haven't

Previous
LLVM Compiler Infrastructure Deep-Dive
11 / 13 · Compiler Design
Next
Compiler Optimization and Auto-Vectorization