INTRODUCTION TO THE COMPILER MIDDLE END
The compilation process transforms human-readable source code into executable machine instructions through a series of well-defined stages. This transformation is traditionally divided into three major phases: the frontend, the middle end, and the backend. Each phase has distinct responsibilities and operates on different representations of the program. The frontend is responsible for parsing the source code and performing semantic analysis, producing an Abstract Syntax Tree that captures the program's structure and meaning. The backend takes a low-level intermediate representation and generates machine-specific assembly or object code. Between these two phases lies the middle end, which is the focus of this article.
The middle end serves as the optimization powerhouse of the compiler. It receives the semantically validated Abstract Syntax Tree from the frontend and transforms it into an optimized intermediate representation suitable for code generation. The middle end is where the compiler performs the majority of its optimization work, applying transformations that improve program performance, reduce code size, and eliminate redundancies. These optimizations are typically machine-independent, meaning they improve the program's efficiency regardless of the target architecture.
Understanding the middle end is crucial for anyone building a compiler or working on performance-critical code generation. The middle end must balance multiple competing goals: it must preserve the program's semantics exactly while transforming the code, it must apply optimizations that provide measurable benefits, and it must produce output that the backend can efficiently translate to machine code. This article will explore how to design and implement a compiler middle end that achieves these goals.
THE ROLE AND RESPONSIBILITIES OF THE MIDDLE END
The middle end occupies a unique position in the compiler pipeline. It receives an Abstract Syntax Tree that has been enriched with semantic information from the frontend. This semantic information includes type annotations, symbol table entries, scope information, and resolved references. The AST represents the program's structure in a way that closely mirrors the source language's syntax, which makes it excellent for semantic analysis but less suitable for optimization and code generation.
The first major responsibility of the middle end is to transform the AST into an Intermediate Representation that is better suited for analysis and transformation. This IR should be lower-level than the AST, exposing operations more explicitly and making control flow and data flow more apparent. The IR should also be designed to facilitate optimization, with properties that make it easy to analyze dependencies, identify redundancies, and apply transformations safely.
Once the IR has been constructed, the middle end performs a series of optimization passes. Each pass analyzes the IR and applies specific transformations to improve the code. Some optimizations focus on eliminating unnecessary computations, such as constant folding and dead code elimination. Others focus on improving data locality and reducing memory traffic, such as loop optimizations and register promotion. Still others focus on exposing parallelism or reducing instruction count through techniques like common subexpression elimination and strength reduction.
The middle end must also construct and maintain various analysis data structures that support optimization. Control Flow Graphs represent the possible execution paths through the program, making it possible to reason about which code executes under which conditions. Data Flow Analysis tracks how values flow through the program, identifying where variables are defined and used. Dominance information helps determine which basic blocks must execute before others, which is essential for many optimizations.
After applying optimizations, the middle end prepares the IR for the backend. In our case, this means converting the optimized IR into LLVM IR, which is the input format expected by the LLVM backend. LLVM IR is a well-designed, SSA-based intermediate representation with a rich ecosystem of optimization passes and code generators for many target architectures. By targeting LLVM IR, our middle end can leverage LLVM's sophisticated backend infrastructure.
DESIGNING THE INTERMEDIATE REPRESENTATION
The choice of Intermediate Representation is one of the most important design decisions in a compiler. The IR serves as the common language that all optimization passes work with, so its design profoundly affects what optimizations are possible and how efficiently they can be implemented. A well-designed IR makes optimizations straightforward to implement and reason about, while a poorly designed IR can make even simple optimizations complex and error-prone.
For our compiler middle end, we will design a Three-Address Code style IR that is simple yet powerful enough to represent all the constructs in our source language. Three-Address Code gets its name from the constraint that each instruction has at most three addresses: two for operands and one for the result. This constraint makes the IR explicit about the order of operations and eliminates complex nested expressions, which simplifies analysis and transformation.
Each instruction in our IR will have an opcode that specifies the operation to perform, along with operands that specify the inputs and outputs. For example, an addition operation might be represented as "t1 = add a, b" where "add" is the opcode, "a" and "b" are the source operands, and "t1" is the destination where the result is stored. By making each operation explicit and limiting the number of operands, we create an IR that is easy to analyze and transform.
Our IR will use an unlimited number of virtual registers or temporaries to hold intermediate values. These temporaries are not physical machine registers but rather abstract storage locations that will later be mapped to actual registers or memory locations by the backend. Using virtual registers eliminates the need to worry about register allocation during optimization, allowing us to focus on improving the program's logic without being constrained by hardware limitations.
Here is a simple example showing how a high-level expression is translated to our IR:
Source code:
result = (a + b) * (c - d)
IR representation:
t1 = add a, b
t2 = sub c, d
t3 = mul t1, t2
result = mov t3
Notice how the complex nested expression has been broken down into a sequence of simple operations, each with explicit operands and results. This explicit representation makes it easy to see the data dependencies between operations and to identify opportunities for optimization.
REPRESENTING CONTROL FLOW IN THE IR
While Three-Address Code handles straight-line computation well, we also need to represent control flow constructs like conditionals and loops. Our IR will use labels and conditional/unconditional jumps to represent control flow. A label marks a position in the code that can be the target of a jump, while jump instructions transfer control to a labeled position.
For conditional execution, we use conditional branch instructions that test a condition and jump to a label if the condition is true. For example, an if statement in the source language might be translated to:
Source code:
if (x > 0) {
y = x * 2
} else {
y = 0
}
IR representation:
t1 = cmp_gt x, 0
br_false t1, else_label
t2 = mul x, 2
y = mov t2
jmp end_label
else_label:
y = mov 0
end_label:
The "cmp_gt" instruction compares x with 0 and stores the boolean result in t1. The "br_false" instruction branches to else_label if t1 is false. The unconditional "jmp" instruction skips over the else block when the then block completes. This representation makes the control flow explicit and easy to analyze.
Loops are represented similarly using labels and conditional branches. A while loop, for instance, has a label at the top where the loop condition is tested, a conditional branch that exits the loop when the condition becomes false, and an unconditional jump at the bottom that returns to the top of the loop.
BASIC BLOCKS AND THE CONTROL FLOW GRAPH
To facilitate analysis and optimization, we organize the IR into basic blocks. A basic block is a maximal sequence of instructions with a single entry point and a single exit point. Control enters a basic block only at its first instruction and leaves only at its last instruction. There are no branches into the middle of a basic block and no branches out of the middle.
Basic blocks are fundamental to compiler optimization because they represent units of code that execute atomically. If the first instruction of a basic block executes, then all instructions in that block will execute in sequence. This property simplifies analysis because we can reason about the block as a whole rather than considering every possible execution path through individual instructions.
The Control Flow Graph is a directed graph where nodes represent basic blocks and edges represent possible control flow between blocks. An edge from block A to block B indicates that control might transfer from the end of A to the beginning of B. The CFG makes the program's control flow structure explicit and enables sophisticated analysis and optimization algorithms.
Constructing the CFG from the IR is straightforward. We identify basic block boundaries by finding leaders, which are instructions that begin basic blocks. The first instruction in the program is always a leader. Any instruction that is the target of a branch is a leader. Any instruction immediately following a branch is a leader. Once we have identified all leaders, we create basic blocks by grouping instructions from each leader up to (but not including) the next leader.
After creating basic blocks, we add edges to the CFG. For each basic block, we examine its last instruction. If the last instruction is an unconditional jump, we add an edge to the target block. If it is a conditional branch, we add edges to both the target block and the fall-through block. If it is not a branch, we add an edge to the immediately following block.
Here is an example showing basic block formation:
IR instructions:
1: x = mov 5
2: y = mov 10
3: t1 = cmp_gt x, 0
4: br_false t1, L2
5: t2 = mul x, 2
6: y = mov t2
7: jmp L3
8: L2:
9: y = mov 0
10: L3:
11: z = add x, y
Basic blocks:
BB1: instructions 1-4 (entry block)
BB2: instructions 5-7 (then block)
BB3: instructions 8-9 (else block)
BB4: instructions 10-11 (merge block)
CFG edges:
BB1 -> BB2 (conditional branch when true)
BB1 -> BB3 (conditional branch when false)
BB2 -> BB4 (unconditional jump)
BB3 -> BB4 (fall-through)
This CFG structure enables us to perform control flow analysis and apply optimizations that span multiple basic blocks.
DATA FLOW ANALYSIS FOUNDATIONS
Data flow analysis is the foundation of many compiler optimizations. It determines how data values flow through the program, tracking where variables are defined, where they are used, and what values they might hold at different program points. This information is essential for optimizations like constant propagation, dead code elimination, and register allocation.
Data flow analysis operates on the Control Flow Graph and computes information at each program point. A program point is typically the position before or after an instruction. For each program point, we want to know certain facts about the program state, such as which variables are live (might be used later) or which variables hold constant values.
Data flow analysis problems are typically formulated as iterative fixed-point computations. We start with initial values for each program point and then repeatedly update these values based on the values at neighboring program points until no more changes occur. The analysis converges when we reach a fixed point where further iterations produce no new information.
There are two main directions for data flow analysis: forward and backward. Forward analysis propagates information from the entry of the CFG toward the exit, following the direction of program execution. Backward analysis propagates information from the exit toward the entry, working against the direction of execution. The choice of direction depends on the specific analysis problem.
For forward analysis, we compute the information at the entry of each basic block based on the information at the exit of its predecessors. We then propagate this information through the block's instructions to compute the information at the block's exit. For backward analysis, we work in the opposite direction, computing exit information based on successors and propagating backward through instructions.
Let us consider a simple example of forward data flow analysis for constant propagation. At each program point, we want to know which variables definitely hold constant values. We start by assuming no variables are constant at the program entry. As we process each instruction, we update our knowledge:
Instruction: x = mov 5
Before: {}
After: {x -> 5}
Instruction: y = add x, 3
Before: {x -> 5}
After: {x -> 5, y -> 8}
Instruction: z = add x, y
Before: {x -> 5, y -> 8}
After: {x -> 5, y -> 8, z -> 13}
When control flow paths merge, we must combine information from multiple predecessors. If a variable has the same constant value on all incoming paths, it remains constant. If it has different values on different paths, it is no longer constant.
IMPLEMENTING REACHING DEFINITIONS ANALYSIS
Reaching definitions analysis is a fundamental data flow analysis that determines, for each program point, which assignments (definitions) might reach that point. A definition reaches a point if there is a path from the definition to that point along which the variable is not redefined. This information is crucial for optimizations like constant propagation and dead code elimination.
For reaching definitions, we perform forward data flow analysis. Each basic block has a set of definitions that reach its entry (IN set) and a set that reach its exit (OUT set). We also compute for each block the set of definitions it generates (GEN set) and the set it kills (KILL set).
The GEN set for a block contains all definitions created within that block. The KILL set contains all definitions that are invalidated by assignments in the block. For example, if a block contains the assignment "x = 5", this generates a new definition of x and kills all previous definitions of x.
The data flow equations for reaching definitions are:
OUT[B] = GEN[B] union (IN[B] - KILL[B])
IN[B] = union of OUT[P] for all predecessors P of B
These equations state that the definitions reaching the exit of a block are those generated in the block plus those reaching the entry that are not killed. The definitions reaching the entry are the union of definitions reaching the exit of all predecessor blocks.
We solve these equations iteratively. We initialize IN[entry] to the empty set and all other IN sets to the universal set (all possible definitions). We then repeatedly update the sets according to the equations until no changes occur. Here is a conceptual implementation:
def compute_reaching_definitions(cfg):
# Initialize
for block in cfg.blocks:
block.gen_set = compute_gen_set(block)
block.kill_set = compute_kill_set(block)
block.in_set = set() if block.is_entry else all_definitions
block.out_set = set()
# Iterate until fixed point
changed = True
while changed:
changed = False
for block in cfg.blocks:
# Compute IN from predecessors
new_in = set()
for pred in block.predecessors:
new_in = new_in.union(pred.out_set)
# Compute OUT
new_out = block.gen_set.union(new_in - block.kill_set)
# Check for changes
if new_in != block.in_set or new_out != block.out_set:
changed = True
block.in_set = new_in
block.out_set = new_out
return cfg
This analysis provides the foundation for many optimizations by telling us which definitions might affect each use of a variable.
IMPLEMENTING LIVE VARIABLE ANALYSIS
Live variable analysis is another fundamental data flow analysis that determines, for each program point, which variables might be used before being redefined. A variable is live at a point if there exists a path from that point to a use of the variable along which the variable is not redefined. This information is essential for dead code elimination and register allocation.
Live variable analysis is a backward data flow analysis because liveness depends on future uses. We propagate information from uses backward to definitions. Each basic block has a set of variables that are live at its entry (IN set) and a set that are live at its exit (OUT set). We also compute for each block the set of variables it uses before defining (USE set) and the set it defines (DEF set).
The data flow equations for live variables are:
IN[B] = USE[B] union (OUT[B] - DEF[B])
OUT[B] = union of IN[S] for all successors S of B
These equations state that a variable is live at the entry of a block if it is used in the block before being defined, or if it is live at the exit and not defined in the block. A variable is live at the exit of a block if it is live at the entry of any successor block.
We solve these equations iteratively, working backward through the CFG. We initialize OUT[exit] to the empty set (no variables are live after the program exits) and all other OUT sets to the empty set. We then repeatedly update the sets until convergence:
def compute_live_variables(cfg):
# Initialize
for block in cfg.blocks:
block.use_set = compute_use_set(block)
block.def_set = compute_def_set(block)
block.in_set = set()
block.out_set = set() if block.is_exit else set()
# Iterate until fixed point
changed = True
while changed:
changed = False
for block in reversed(cfg.blocks): # Process in reverse order
# Compute OUT from successors
new_out = set()
for succ in block.successors:
new_out = new_out.union(succ.in_set)
# Compute IN
new_in = block.use_set.union(new_out - block.def_set)
# Check for changes
if new_in != block.in_set or new_out != block.out_set:
changed = True
block.in_set = new_in
block.out_set = new_out
return cfg
Live variable analysis enables us to identify dead code (assignments to variables that are not live) and to allocate registers efficiently (by knowing when a register's value is no longer needed).
CONSTANT FOLDING AND PROPAGATION
Constant folding and constant propagation are fundamental optimizations that evaluate constant expressions at compile time rather than runtime. Constant folding evaluates operations whose operands are all constants, replacing the operation with its computed result. Constant propagation tracks variables that hold constant values and replaces uses of those variables with the constants themselves.
These optimizations are valuable because they eliminate runtime computation and expose opportunities for further optimization. When we replace a variable with a constant, we might enable additional constant folding in expressions that use that variable. When we eliminate a computation, we might make the code that computed its operands dead, enabling dead code elimination.
Constant folding operates on individual instructions. When we encounter an instruction whose operands are all constant, we evaluate the operation and replace the instruction with a simple assignment of the result. For example:
Before constant folding:
t1 = add 5, 3
t2 = mul t1, 2
After constant folding:
t1 = mov 8
t2 = mul t1, 2
The addition of two constants has been evaluated at compile time, replacing the add instruction with a move of the constant result.
Constant propagation builds on constant folding by tracking which variables hold constant values and replacing uses of those variables with the constants. We use reaching definitions analysis to determine which definition reaches each use, and we check whether that definition assigns a constant value. If so, we can replace the use with the constant:
After constant propagation:
t1 = mov 8
t2 = mov 16
Now the multiplication has also been folded because we propagated the constant value of t1.
Implementing constant propagation requires careful handling of control flow. When multiple definitions of a variable reach a use, we can only propagate if all definitions assign the same constant value. If different paths assign different constants, the variable is not constant at that point.
Here is a conceptual implementation of constant propagation:
def constant_propagation(cfg):
# Compute reaching definitions
compute_reaching_definitions(cfg)
# Track constant values
constants = {}
for block in cfg.blocks:
for instr in block.instructions:
# Try to fold operands
if instr.op1 in constants:
instr.op1 = constants[instr.op1]
if instr.op2 in constants:
instr.op2 = constants[instr.op2]
# Try to fold the operation
if is_constant(instr.op1) and is_constant(instr.op2):
result = evaluate(instr.opcode, instr.op1, instr.op2)
instr.opcode = 'mov'
instr.op1 = result
instr.op2 = None
constants[instr.dest] = result
elif instr.opcode == 'mov' and is_constant(instr.op1):
constants[instr.dest] = instr.op1
else:
# Destination is not constant
if instr.dest in constants:
del constants[instr.dest]
This optimization can dramatically reduce the amount of runtime computation, especially in programs with many compile-time constants.
DEAD CODE ELIMINATION
Dead code elimination removes instructions whose results are never used. An instruction is dead if it computes a value that is not live at any subsequent program point. Eliminating dead code reduces program size, improves cache utilization, and can expose opportunities for further optimization.
Dead code can arise from several sources. Programmers sometimes write code that is never executed or whose results are never used. More commonly, dead code is created by other optimizations. When constant propagation eliminates uses of a variable, the code that computed that variable's value becomes dead. When we simplify control flow, entire blocks of code might become unreachable.
To identify dead code, we use live variable analysis. After computing which variables are live at each program point, we examine each instruction. If an instruction assigns to a variable that is not live after the instruction, the instruction is dead and can be removed. We must be careful, however, to preserve instructions that have side effects beyond their assignment, such as function calls or I/O operations.
Here is an example of dead code elimination:
Before optimization:
x = add 5, 3 // x is not used anywhere
y = mul 2, 4 // y is used later
z = add x, y // z is not used anywhere
return y
After live variable analysis:
// At each instruction, we know which variables are live after
x = add 5, 3 // x is not live after this instruction
y = mul 2, 4 // y is live (used in return)
z = add x, y // z is not live after this instruction
return y
After dead code elimination:
y = mul 2, 4
return y
Both the assignment to x and the assignment to z have been eliminated because those variables are not live.
Dead code elimination must be applied iteratively because removing one dead instruction can make other instructions dead. When we eliminate an assignment, the variables used in that assignment might become dead if they have no other uses. This creates a cascade effect where eliminating one piece of dead code exposes more dead code.
Here is a conceptual implementation:
def eliminate_dead_code(cfg):
changed = True
while changed:
changed = False
compute_live_variables(cfg)
for block in cfg.blocks:
new_instructions = []
for instr in block.instructions:
# Keep instruction if it has side effects
if has_side_effects(instr):
new_instructions.append(instr)
# Keep instruction if destination is live
elif instr.dest in block.live_out:
new_instructions.append(instr)
else:
# Instruction is dead
changed = True
block.instructions = new_instructions
This optimization is particularly effective when combined with other optimizations in an iterative framework.
COMMON SUBEXPRESSION ELIMINATION
Common subexpression elimination identifies computations that are performed multiple times with the same operands and eliminates the redundant computations. Instead of recomputing the same expression, we save the result of the first computation and reuse it for subsequent occurrences. This optimization reduces the number of operations performed and can significantly improve performance.
For CSE to be valid, we must ensure that the operands of the expression have not changed between the first computation and the subsequent use. This requires data flow analysis to track available expressions. An expression is available at a program point if it has been computed on all paths reaching that point and its operands have not been modified since the computation.
Consider this example:
Before CSE:
a = b + c
d = b * 2
e = b + c // Same expression as line 1
f = b * 2 // Same expression as line 2
After CSE:
a = b + c
d = b * 2
e = a // Reuse result from line 1
f = d // Reuse result from line 2
The redundant computations have been replaced with simple copies of previously computed values.
Implementing CSE requires computing available expressions at each program point. We use forward data flow analysis similar to reaching definitions. For each basic block, we track which expressions are computed in the block (GEN set) and which expressions are invalidated because their operands are modified (KILL set).
The data flow equations are:
OUT[B] = GEN[B] union (IN[B] - KILL[B])
IN[B] = intersection of OUT[P] for all predecessors P of B
Note that we use intersection rather than union for IN because an expression is only available if it is available on all paths, not just some paths.
Once we have computed available expressions, we can perform CSE. When we encounter an expression that is available, we find the temporary that holds the result of the previous computation and replace the current computation with a copy from that temporary:
def common_subexpression_elimination(cfg):
compute_available_expressions(cfg)
for block in cfg.blocks:
expr_to_temp = {} # Map expressions to temporaries
for instr in block.instructions:
expr = (instr.opcode, instr.op1, instr.op2)
# Check if expression is available
if expr in block.available_in and expr in expr_to_temp:
# Replace with copy from previous result
instr.opcode = 'mov'
instr.op1 = expr_to_temp[expr]
instr.op2 = None
else:
# Record this computation
expr_to_temp[expr] = instr.dest
This optimization is particularly effective in loops where the same expressions are computed repeatedly.
LOOP OPTIMIZATIONS
Loops are often the performance bottleneck in programs because they execute their bodies many times. Small improvements to loop efficiency can have large impacts on overall program performance. The middle end applies several specialized optimizations to loops, including loop-invariant code motion, strength reduction, and loop unrolling.
Loop-invariant code motion identifies computations within a loop that produce the same result on every iteration. These computations are moved outside the loop so they execute only once rather than on every iteration. A computation is loop-invariant if its operands are either constants or variables defined outside the loop.
Consider this example:
Before loop-invariant code motion:
for i = 0 to n do
x = a + b // a and b are not modified in the loop
y = x * i
array[i] = y
end for
After loop-invariant code motion:
x = a + b // Moved outside the loop
for i = 0 to n do
y = x * i
array[i] = y
end for
The computation of x has been hoisted out of the loop because it does not depend on the loop variable i.
To implement loop-invariant code motion, we must first identify loops in the CFG. We use dominance analysis to find natural loops, which are strongly connected regions of the CFG with a single entry point (the loop header). Once we have identified loops, we analyze each instruction in the loop body to determine whether it is loop-invariant.
An instruction is loop-invariant if all of its operands are either constants, defined outside the loop, or defined by other loop-invariant instructions within the loop. We compute loop-invariant instructions iteratively, starting with instructions whose operands are constants or defined outside the loop, then adding instructions whose operands are all loop-invariant.
Strength reduction replaces expensive operations with cheaper equivalent operations. A common case is replacing multiplication by a loop-invariant with addition. For example, if we are computing i times c on each iteration where c is loop-invariant, we can instead maintain a variable that we increment by c on each iteration:
Before strength reduction:
for i = 0 to n do
x = i * c
use(x)
end for
After strength reduction:
x = 0
for i = 0 to n do
use(x)
x = x + c
end for
Multiplication has been replaced with addition, which is typically faster on most processors.
CONVERTING TO STATIC SINGLE ASSIGNMENT FORM
Static Single Assignment form is an intermediate representation property where each variable is assigned exactly once. When a variable would be assigned multiple times in the original program, SSA creates distinct versions of the variable for each assignment. This property simplifies many optimizations by making def-use chains explicit and by eliminating the need to track multiple definitions reaching a use.
Converting to SSA form requires inserting phi functions at points where control flow merges. A phi function selects a value based on which control flow path was taken. For example, if variable x is assigned in both branches of an if statement, we insert a phi function after the if to merge the two versions:
Before SSA conversion:
if condition then
x = 1
else
x = 2
end if
y = x + 3
After SSA conversion:
if condition then
x1 = 1
else
x2 = 2
end if
x3 = phi(x1, x2)
y = x3 + 3
The phi function x3 = phi(x1, x2) means that x3 takes the value x1 if control came from the then branch, or x2 if control came from the else branch.
The algorithm for SSA conversion has two main phases. First, we determine where to insert phi functions. We insert a phi function for variable x at the beginning of basic block B if B is in the dominance frontier of any block that assigns to x. The dominance frontier of a block A is the set of blocks where A's dominance ends, meaning blocks that are not dominated by A but have a predecessor that is dominated by A.
Second, we rename variables to create unique versions. We perform a depth-first traversal of the dominator tree, maintaining a stack of current definitions for each variable. When we encounter an assignment to x, we push a new version onto x's stack. When we encounter a use of x, we replace it with the version on top of x's stack. When we process a phi function, we create a new version for its destination.
Here is a conceptual implementation of variable renaming:
def rename_variables(block, stacks):
# Process phi functions
for phi in block.phi_functions:
new_version = new_variable_version(phi.var)
phi.dest = new_version
push(stacks[phi.var], new_version)
# Process instructions
for instr in block.instructions:
# Rename uses
if instr.op1 in stacks:
instr.op1 = top(stacks[instr.op1])
if instr.op2 in stacks:
instr.op2 = top(stacks[instr.op2])
# Rename definition
if instr.dest:
new_version = new_variable_version(instr.dest)
instr.dest = new_version
push(stacks[instr.dest], new_version)
# Fill in phi function operands in successors
for succ in block.successors:
for phi in succ.phi_functions:
phi.add_operand(block, top(stacks[phi.var]))
# Recursively process dominated blocks
for child in block.dominated_blocks:
rename_variables(child, stacks)
# Pop versions added in this block
for phi in block.phi_functions:
pop(stacks[phi.var])
for instr in block.instructions:
if instr.dest:
pop(stacks[instr.dest])
SSA form is particularly valuable because it makes many optimizations simpler and more powerful. In SSA form, each use of a variable has exactly one reaching definition, which simplifies constant propagation, dead code elimination, and many other analyses.
INTEGRATING WITH THE LLVM BACKEND
After applying optimizations, the middle end must convert the optimized IR into LLVM IR, which serves as the input to the LLVM backend. LLVM IR is a low-level, typed, SSA-based representation that is well-suited for further optimization and code generation. The LLVM framework provides a rich set of optimization passes and code generators for many target architectures.
LLVM IR is organized into modules, which contain functions, which contain basic blocks, which contain instructions. Each LLVM instruction is strongly typed, and LLVM performs type checking to ensure the IR is well-formed. The IR supports a rich type system including integers of various sizes, floating-point numbers, pointers, arrays, and structures.
Converting our IR to LLVM IR requires mapping our constructs to LLVM equivalents. Our virtual registers become LLVM virtual registers (which LLVM calls SSA values). Our basic blocks become LLVM basic blocks. Our instructions are translated to corresponding LLVM instructions.
Here is an example of how our IR maps to LLVM IR:
Our IR:
t1 = add a, b
t2 = mul t1, 2
return t2
LLVM IR:
%t1 = add i32 %a, %b
%t2 = mul i32 %t1, 2
ret i32 %t2
The LLVM IR is similar but includes type information (i32 for 32-bit integers) and uses percent signs to denote SSA values.
To generate LLVM IR, we use the LLVM C++ API or one of its language bindings. We create an LLVM module, add function declarations, create basic blocks, and emit instructions. Here is a conceptual example using a Python-like pseudocode:
def emit_llvm_ir(optimized_ir, module):
# Create function
func_type = llvm.FunctionType(llvm.Int32Type(), [llvm.Int32Type(), llvm.Int32Type()])
func = llvm.Function(module, func_type, "calculate")
# Create entry basic block
entry_block = func.append_basic_block("entry")
builder = llvm.IRBuilder(entry_block)
# Map our temporaries to LLVM values
value_map = {}
# Emit instructions
for instr in optimized_ir:
if instr.opcode == 'add':
op1 = get_llvm_value(instr.op1, value_map, builder)
op2 = get_llvm_value(instr.op2, value_map, builder)
result = builder.add(op1, op2, instr.dest)
value_map[instr.dest] = result
elif instr.opcode == 'mul':
op1 = get_llvm_value(instr.op1, value_map, builder)
op2 = get_llvm_value(instr.op2, value_map, builder)
result = builder.mul(op1, op2, instr.dest)
value_map[instr.dest] = result
elif instr.opcode == 'return':
value = get_llvm_value(instr.op1, value_map, builder)
builder.ret(value)
Once we have generated LLVM IR, we can invoke LLVM's optimization passes and code generation to produce optimized machine code for the target architecture.
OPTIMIZATION PASS ORDERING AND ITERATION
The order in which optimization passes are applied significantly affects the final code quality. Some optimizations enable others, while some optimizations can undo the effects of previous optimizations if applied in the wrong order. Determining the optimal pass ordering is a complex problem that requires understanding the interactions between different optimizations.
A common strategy is to organize optimizations into phases. Early phases focus on simplification and canonicalization, transforming the IR into a standard form that makes subsequent optimizations more effective. Middle phases apply the main optimization transformations like constant propagation, CSE, and dead code elimination. Late phases perform cleanup and prepare the code for the backend.
Many optimizations are more effective when applied iteratively. For example, constant propagation might expose dead code, and eliminating that dead code might expose more opportunities for constant propagation. Running these optimizations in a loop until no more changes occur can produce better results than running each optimization once.
However, we must be careful to ensure that the optimization loop terminates. Some optimizations might create opportunities for themselves indefinitely, leading to an infinite loop. We typically limit the number of iterations or track whether each iteration made progress, stopping when no optimization makes any changes.
Here is a conceptual framework for an optimization pipeline:
def optimize_ir(ir):
# Phase 1: Simplification
ir = simplify_cfg(ir)
ir = canonicalize_expressions(ir)
# Phase 2: Main optimizations (iterate to fixed point)
max_iterations = 10
for iteration in range(max_iterations):
old_ir = copy(ir)
ir = constant_propagation(ir)
ir = eliminate_dead_code(ir)
ir = common_subexpression_elimination(ir)
ir = loop_optimizations(ir)
if ir == old_ir:
break # No changes, we've reached a fixed point
# Phase 3: Cleanup
ir = eliminate_dead_code(ir)
ir = simplify_cfg(ir)
# Phase 4: SSA conversion for LLVM
ir = convert_to_ssa(ir)
return ir
This framework ensures that optimizations are applied in a sensible order and that we iterate until we reach a fixed point.
HANDLING FUNCTION CALLS AND INTERPROCEDURAL OPTIMIZATION
So far we have focused on optimizing individual functions, but real programs consist of many functions that call each other. Function calls introduce challenges for optimization because they create dependencies between functions and can have side effects that are not immediately visible.
When we encounter a function call in the IR, we must conservatively assume that the call might modify any global variables and any variables whose addresses have been taken. This assumption limits the effectiveness of optimizations like constant propagation and CSE across function calls. To optimize more aggressively, we need interprocedural analysis that examines the entire program.
Interprocedural optimization analyzes multiple functions together to determine the effects of function calls. For example, we can analyze which global variables a function might modify, which parameters it might modify through pointers, and whether it has any side effects. This information allows us to optimize more aggressively around function calls.
A simple form of interprocedural optimization is function inlining, where we replace a function call with a copy of the called function's body. Inlining eliminates the overhead of the call and exposes the function's code to optimization in the caller's context. However, inlining must be applied carefully because it can significantly increase code size.
Here is an example of function inlining:
Before inlining:
function add_and_double(x, y)
temp = add x, y
result = mul temp, 2
return result
end function
function main()
a = 5
b = 3
c = call add_and_double(a, b)
return c
end function
After inlining:
function main()
a = 5
b = 3
temp = add a, b
c = mul temp, 2
return c
end function
The call to add_and_double has been replaced with its body, and now the entire computation is visible in main, enabling further optimization.
MEMORY OPTIMIZATION AND ALIASING ANALYSIS
Memory operations are often a performance bottleneck, and optimizing memory accesses is crucial for achieving good performance. The middle end can apply several optimizations to reduce memory traffic, including load elimination, store elimination, and memory-to-register promotion.
Load elimination removes redundant loads from memory. If we load a value from memory and then load from the same location again without any intervening stores, the second load is redundant and can be replaced with a use of the value from the first load. Similarly, store elimination removes stores whose values are never read.
Memory-to-register promotion identifies memory locations that are accessed frequently and promotes them to virtual registers. Instead of loading from and storing to memory on each access, we keep the value in a register and only store back to memory when necessary. This optimization can dramatically improve performance for local variables.
However, memory optimizations are complicated by aliasing, which occurs when two different pointer expressions might refer to the same memory location. If we cannot determine whether two pointers alias, we must conservatively assume they might, which limits optimization. Aliasing analysis attempts to determine which pointers might alias, enabling more aggressive optimization.
Here is an example showing the impact of aliasing:
x = load ptr1
store ptr2, 5
y = load ptr1 // Can we eliminate this load?
If we can prove that ptr1 and ptr2 do not alias (point to different locations), then the store to ptr2 does not affect the value at ptr1, and we can reuse the value loaded into x. If they might alias, we must perform the second load.
Aliasing analysis is challenging because it requires reasoning about pointer values, which can be computed dynamically and depend on complex program logic. Simple analyses examine the syntactic form of pointer expressions, while more sophisticated analyses use data flow analysis to track pointer values through the program.
SUMMARY AND CONCLUSION
The compiler middle end is a sophisticated component that transforms semantically validated code into an optimized form suitable for code generation. It operates on an intermediate representation that is designed to facilitate analysis and transformation, applying a series of optimization passes that improve performance, reduce code size, and eliminate redundancies.
We have explored the key components of a middle end implementation, including IR design, control flow graph construction, data flow analysis, and various optimization techniques. We have seen how constant folding and propagation eliminate compile-time computation, how dead code elimination removes unnecessary instructions, how common subexpression elimination avoids redundant computation, and how loop optimizations improve the efficiency of iterative code.
The middle end must carefully balance multiple concerns. It must preserve program semantics exactly while transforming the code. It must apply optimizations that provide measurable benefits without excessive compilation time. It must produce output that the backend can efficiently translate to machine code. Achieving these goals requires sophisticated algorithms, careful engineering, and deep understanding of program behavior.
By implementing a well-designed middle end, we enable our compiler to generate high-quality code that executes efficiently on modern processors. The optimizations applied in the middle end are largely machine-independent, making them broadly applicable across different target architectures. This separation of concerns allows the backend to focus on machine-specific optimizations and code generation, while the middle end handles the high-level transformations that improve the program's fundamental efficiency.
ADDENDUM COMPLETE PRODUCTION-READY IMPLEMENTATION
The following is a complete, production-ready implementation of a compiler middle end in Python. This implementation includes all necessary components: IR data structures, AST definitions, IR generation, control flow graph construction, data flow analysis, and multiple optimization passes. The code is designed to be extensible, well-documented, and suitable for real-world use.
""" Compiler Middle End Implementation A complete implementation of a compiler middle end with optimization passes. """
from enum import Enum, auto from typing import List, Set, Dict, Optional, Any, Tuple from dataclasses import dataclass, field from copy import deepcopy
=============================================================================
IR Instruction Definitions
=============================================================================
class Opcode(Enum): """Enumeration of all supported IR opcodes.""" # Arithmetic operations ADD = auto() SUB = auto() MUL = auto() DIV = auto() MOD = auto() NEG = auto()
# Comparison operations
EQ = auto() # Equal
NE = auto() # Not equal
LT = auto() # Less than
LE = auto() # Less than or equal
GT = auto() # Greater than
GE = auto() # Greater than or equal
# Logical operations
AND = auto()
OR = auto()
NOT = auto()
# Memory operations
LOAD = auto()
STORE = auto()
# Data movement
MOV = auto()
# Control flow
LABEL = auto()
JUMP = auto()
BRANCH = auto() # Conditional branch
CALL = auto()
RETURN = auto()
# SSA-specific
PHI = auto()
@dataclass class Instruction: """ Represents a single instruction in the intermediate representation.
This is a three-address code instruction with an opcode and up to
two operands plus a destination. Some instructions use fewer fields.
"""
opcode: Opcode
dest: Optional[str] = None
op1: Optional[Any] = None
op2: Optional[Any] = None
label: Optional[str] = None
def __str__(self) -> str:
"""Generate a human-readable string representation."""
if self.opcode == Opcode.LABEL:
return f"{self.label}:"
parts = []
if self.dest:
parts.append(f"{self.dest} = ")
parts.append(self.opcode.name.lower())
if self.op1 is not None:
parts.append(f" {self.op1}")
if self.op2 is not None:
parts.append(f", {self.op2}")
return "".join(parts)
def get_uses(self) -> Set[str]:
"""Return the set of variables used by this instruction."""
uses = set()
for op in [self.op1, self.op2]:
if isinstance(op, str) and not self._is_constant(op):
uses.add(op)
return uses
def get_def(self) -> Optional[str]:
"""Return the variable defined by this instruction, if any."""
if self.dest and not self._is_constant(self.dest):
return self.dest
return None
@staticmethod
def _is_constant(value: Any) -> bool:
"""Check if a value is a constant rather than a variable."""
if isinstance(value, (int, float, bool)):
return True
if isinstance(value, str):
# Check if it's a numeric string
try:
float(value)
return True
except ValueError:
return False
return False
def is_terminator(self) -> bool:
"""Check if this instruction terminates a basic block."""
return self.opcode in {Opcode.JUMP, Opcode.BRANCH, Opcode.RETURN}
def is_branch(self) -> bool:
"""Check if this instruction is a branch."""
return self.opcode in {Opcode.JUMP, Opcode.BRANCH}
=============================================================================
Basic Block and Control Flow Graph
=============================================================================
@dataclass class BasicBlock: """ Represents a basic block in the control flow graph.
A basic block is a maximal sequence of instructions with a single entry
point and a single exit point.
"""
name: str
instructions: List[Instruction] = field(default_factory=list)
predecessors: Set['BasicBlock'] = field(default_factory=set)
successors: Set['BasicBlock'] = field(default_factory=set)
# Data flow analysis sets
gen_set: Set[str] = field(default_factory=set)
kill_set: Set[str] = field(default_factory=set)
in_set: Set[str] = field(default_factory=set)
out_set: Set[str] = field(default_factory=set)
# Live variable analysis sets
use_set: Set[str] = field(default_factory=set)
def_set: Set[str] = field(default_factory=set)
live_in: Set[str] = field(default_factory=set)
live_out: Set[str] = field(default_factory=set)
def __str__(self) -> str:
"""Generate a human-readable string representation."""
lines = [f"{self.name}:"]
for instr in self.instructions:
lines.append(f" {instr}")
return "\n".join(lines)
def add_predecessor(self, block: 'BasicBlock') -> None:
"""Add a predecessor block."""
self.predecessors.add(block)
def add_successor(self, block: 'BasicBlock') -> None:
"""Add a successor block."""
self.successors.add(block)
block.add_predecessor(self)
class ControlFlowGraph: """ Represents the control flow graph of a function.
The CFG is a directed graph where nodes are basic blocks and edges
represent possible control flow between blocks.
"""
def __init__(self):
self.blocks: List[BasicBlock] = []
self.entry_block: Optional[BasicBlock] = None
self.exit_block: Optional[BasicBlock] = None
self.name_to_block: Dict[str, BasicBlock] = {}
def add_block(self, block: BasicBlock) -> None:
"""Add a basic block to the CFG."""
self.blocks.append(block)
self.name_to_block[block.name] = block
if self.entry_block is None:
self.entry_block = block
def get_block(self, name: str) -> Optional[BasicBlock]:
"""Retrieve a basic block by name."""
return self.name_to_block.get(name)
def __str__(self) -> str:
"""Generate a human-readable string representation."""
lines = ["Control Flow Graph:"]
for block in self.blocks:
lines.append(str(block))
if block.successors:
succ_names = [s.name for s in block.successors]
lines.append(f" -> {', '.join(succ_names)}")
lines.append("")
return "\n".join(lines)
=============================================================================
AST Node Definitions
=============================================================================
class ASTNode: """Base class for all AST nodes.""" pass
@dataclass class Program(ASTNode): """Root node representing an entire program.""" functions: List['Function']
@dataclass class Function(ASTNode): """Represents a function definition.""" name: str parameters: List[Tuple[str, str]] # (name, type) pairs return_type: str body: 'Block'
@dataclass class Block(ASTNode): """Represents a block of statements.""" statements: List[ASTNode]
@dataclass class VarDecl(ASTNode): """Represents a variable declaration.""" name: str var_type: str initializer: Optional['Expression'] = None
@dataclass class Assignment(ASTNode): """Represents an assignment statement.""" target: str value: 'Expression'
@dataclass class IfStatement(ASTNode): """Represents an if-then-else statement.""" condition: 'Expression' then_block: Block else_block: Optional[Block] = None
@dataclass class WhileLoop(ASTNode): """Represents a while loop.""" condition: 'Expression' body: Block
@dataclass class ReturnStatement(ASTNode): """Represents a return statement.""" value: Optional['Expression'] = None
@dataclass class ExpressionStatement(ASTNode): """Represents an expression used as a statement.""" expression: 'Expression'
Expression nodes
class Expression(ASTNode): """Base class for all expression nodes.""" pass
@dataclass class BinaryOp(Expression): """Represents a binary operation.""" operator: str left: Expression right: Expression
@dataclass class UnaryOp(Expression): """Represents a unary operation.""" operator: str operand: Expression
@dataclass class Variable(Expression): """Represents a variable reference.""" name: str
@dataclass class Literal(Expression): """Represents a literal value.""" value: Any
@dataclass class FunctionCall(Expression): """Represents a function call.""" function_name: str arguments: List[Expression]
=============================================================================
IR Generator (AST to IR Translation)
=============================================================================
class IRGenerator: """ Translates an AST into intermediate representation.
This class implements a visitor pattern to traverse the AST and
generate three-address code instructions.
"""
def __init__(self):
self.instructions: List[Instruction] = []
self.temp_counter = 0
self.label_counter = 0
self.current_function: Optional[str] = None
def generate(self, ast: Program) -> Dict[str, List[Instruction]]:
"""
Generate IR for an entire program.
Returns a dictionary mapping function names to their IR instructions.
"""
function_irs = {}
for function in ast.functions:
self.instructions = []
self.temp_counter = 0
self.label_counter = 0
self.current_function = function.name
self._generate_function(function)
function_irs[function.name] = self.instructions
return function_irs
def _new_temp(self) -> str:
"""Generate a new temporary variable name."""
self.temp_counter += 1
return f"t{self.temp_counter}"
def _new_label(self) -> str:
"""Generate a new label name."""
self.label_counter += 1
return f"L{self.label_counter}"
def _emit(self, instruction: Instruction) -> None:
"""Add an instruction to the current instruction list."""
self.instructions.append(instruction)
def _generate_function(self, func: Function) -> None:
"""Generate IR for a function."""
# Function entry
self._emit(Instruction(Opcode.LABEL, label=f"func_{func.name}"))
# Generate body
self._generate_block(func.body)
# Ensure function ends with return
if not self.instructions or self.instructions[-1].opcode != Opcode.RETURN:
self._emit(Instruction(Opcode.RETURN))
def _generate_block(self, block: Block) -> None:
"""Generate IR for a block of statements."""
for statement in block.statements:
self._generate_statement(statement)
def _generate_statement(self, stmt: ASTNode) -> None:
"""Generate IR for a statement."""
if isinstance(stmt, VarDecl):
self._generate_var_decl(stmt)
elif isinstance(stmt, Assignment):
self._generate_assignment(stmt)
elif isinstance(stmt, IfStatement):
self._generate_if(stmt)
elif isinstance(stmt, WhileLoop):
self._generate_while(stmt)
elif isinstance(stmt, ReturnStatement):
self._generate_return(stmt)
elif isinstance(stmt, ExpressionStatement):
self._generate_expression(stmt.expression)
else:
raise ValueError(f"Unknown statement type: {type(stmt)}")
def _generate_var_decl(self, decl: VarDecl) -> None:
"""Generate IR for a variable declaration."""
if decl.initializer:
value = self._generate_expression(decl.initializer)
self._emit(Instruction(Opcode.MOV, decl.name, value))
def _generate_assignment(self, assign: Assignment) -> None:
"""Generate IR for an assignment."""
value = self._generate_expression(assign.value)
self._emit(Instruction(Opcode.MOV, assign.target, value))
def _generate_if(self, if_stmt: IfStatement) -> None:
"""Generate IR for an if statement."""
else_label = self._new_label()
end_label = self._new_label()
# Evaluate condition
cond = self._generate_expression(if_stmt.condition)
# Branch to else if condition is false
self._emit(Instruction(Opcode.BRANCH, op1=cond, op2=else_label))
# Then block
self._generate_block(if_stmt.then_block)
self._emit(Instruction(Opcode.JUMP, op1=end_label))
# Else block
self._emit(Instruction(Opcode.LABEL, label=else_label))
if if_stmt.else_block:
self._generate_block(if_stmt.else_block)
# End label
self._emit(Instruction(Opcode.LABEL, label=end_label))
def _generate_while(self, loop: WhileLoop) -> None:
"""Generate IR for a while loop."""
start_label = self._new_label()
end_label = self._new_label()
# Loop start
self._emit(Instruction(Opcode.LABEL, label=start_label))
# Evaluate condition
cond = self._generate_expression(loop.condition)
# Branch to end if condition is false
self._emit(Instruction(Opcode.BRANCH, op1=cond, op2=end_label))
# Loop body
self._generate_block(loop.body)
# Jump back to start
self._emit(Instruction(Opcode.JUMP, op1=start_label))
# End label
self._emit(Instruction(Opcode.LABEL, label=end_label))
def _generate_return(self, ret: ReturnStatement) -> None:
"""Generate IR for a return statement."""
if ret.value:
value = self._generate_expression(ret.value)
self._emit(Instruction(Opcode.RETURN, op1=value))
else:
self._emit(Instruction(Opcode.RETURN))
def _generate_expression(self, expr: Expression) -> str:
"""
Generate IR for an expression.
Returns the name of the temporary holding the expression's result.
"""
if isinstance(expr, Literal):
return str(expr.value)
elif isinstance(expr, Variable):
return expr.name
elif isinstance(expr, BinaryOp):
return self._generate_binary_op(expr)
elif isinstance(expr, UnaryOp):
return self._generate_unary_op(expr)
elif isinstance(expr, FunctionCall):
return self._generate_call(expr)
else:
raise ValueError(f"Unknown expression type: {type(expr)}")
def _generate_binary_op(self, op: BinaryOp) -> str:
"""Generate IR for a binary operation."""
left = self._generate_expression(op.left)
right = self._generate_expression(op.right)
result = self._new_temp()
opcode_map = {
'+': Opcode.ADD,
'-': Opcode.SUB,
'*': Opcode.MUL,
'/': Opcode.DIV,
'%': Opcode.MOD,
'==': Opcode.EQ,
'!=': Opcode.NE,
'<': Opcode.LT,
'<=': Opcode.LE,
'>': Opcode.GT,
'>=': Opcode.GE,
'&&': Opcode.AND,
'||': Opcode.OR,
}
opcode = opcode_map.get(op.operator)
if opcode is None:
raise ValueError(f"Unknown binary operator: {op.operator}")
self._emit(Instruction(opcode, result, left, right))
return result
def _generate_unary_op(self, op: UnaryOp) -> str:
"""Generate IR for a unary operation."""
operand = self._generate_expression(op.operand)
result = self._new_temp()
opcode_map = {
'-': Opcode.NEG,
'!': Opcode.NOT,
}
opcode = opcode_map.get(op.operator)
if opcode is None:
raise ValueError(f"Unknown unary operator: {op.operator}")
self._emit(Instruction(opcode, result, operand))
return result
def _generate_call(self, call: FunctionCall) -> str:
"""Generate IR for a function call."""
# Evaluate arguments
args = [self._generate_expression(arg) for arg in call.arguments]
# Generate call instruction
result = self._new_temp()
# In a real implementation, we'd pass arguments properly
# For simplicity, we just record the call
self._emit(Instruction(Opcode.CALL, result, call.function_name, args))
return result
=============================================================================
CFG Builder
=============================================================================
class CFGBuilder: """ Constructs a control flow graph from a list of IR instructions.
This class identifies basic block boundaries and builds the CFG
by analyzing control flow instructions.
"""
def build(self, instructions: List[Instruction]) -> ControlFlowGraph:
"""Build a CFG from a list of instructions."""
cfg = ControlFlowGraph()
# Identify leaders (instructions that start basic blocks)
leaders = self._find_leaders(instructions)
# Create basic blocks
blocks = self._create_blocks(instructions, leaders)
# Add blocks to CFG
for block in blocks:
cfg.add_block(block)
# Build edges
self._build_edges(blocks, cfg)
return cfg
def _find_leaders(self, instructions: List[Instruction]) -> Set[int]:
"""
Find leader instructions that start basic blocks.
Leaders are:
1. The first instruction
2. Any instruction that is a label
3. Any instruction following a branch or jump
"""
leaders = {0} # First instruction is always a leader
for i, instr in enumerate(instructions):
# Labels are leaders
if instr.opcode == Opcode.LABEL:
leaders.add(i)
# Instruction after a branch/jump is a leader
if instr.is_terminator() and i + 1 < len(instructions):
leaders.add(i + 1)
return leaders
def _create_blocks(self, instructions: List[Instruction],
leaders: Set[int]) -> List[BasicBlock]:
"""Create basic blocks from instructions and leaders."""
blocks = []
sorted_leaders = sorted(leaders)
for i, leader_idx in enumerate(sorted_leaders):
# Determine block extent
if i + 1 < len(sorted_leaders):
end_idx = sorted_leaders[i + 1]
else:
end_idx = len(instructions)
# Create block
block_instrs = instructions[leader_idx:end_idx]
block_name = f"BB{i}"
# If first instruction is a label, use it as block name
if block_instrs and block_instrs[0].opcode == Opcode.LABEL:
block_name = block_instrs[0].label
block_instrs = block_instrs[1:] # Remove label from instructions
block = BasicBlock(block_name, block_instrs)
blocks.append(block)
return blocks
def _build_edges(self, blocks: List[BasicBlock], cfg: ControlFlowGraph) -> None:
"""Build edges between basic blocks based on control flow."""
for i, block in enumerate(blocks):
if not block.instructions:
continue
last_instr = block.instructions[-1]
if last_instr.opcode == Opcode.JUMP:
# Unconditional jump to target
target_label = last_instr.op1
target_block = cfg.get_block(target_label)
if target_block:
block.add_successor(target_block)
elif last_instr.opcode == Opcode.BRANCH:
# Conditional branch: one edge to target, one to next block
target_label = last_instr.op2
target_block = cfg.get_block(target_label)
if target_block:
block.add_successor(target_block)
# Fall-through to next block
if i + 1 < len(blocks):
block.add_successor(blocks[i + 1])
elif last_instr.opcode == Opcode.RETURN:
# No successors for return
pass
else:
# Fall-through to next block
if i + 1 < len(blocks):
block.add_successor(blocks[i + 1])
=============================================================================
Data Flow Analysis
=============================================================================
class DataFlowAnalyzer: """ Performs various data flow analyses on a CFG.
This class implements reaching definitions and live variable analysis.
"""
def compute_reaching_definitions(self, cfg: ControlFlowGraph) -> None:
"""
Compute reaching definitions for all basic blocks.
A definition reaches a point if there is a path from the definition
to that point along which the variable is not redefined.
"""
# Compute GEN and KILL sets for each block
all_defs = set()
for block in cfg.blocks:
block.gen_set = set()
block.kill_set = set()
for instr in block.instructions:
defined = instr.get_def()
if defined:
# This instruction generates a definition
def_id = f"{block.name}:{defined}"
block.gen_set.add(def_id)
all_defs.add(def_id)
# It kills all other definitions of the same variable
for other_def in all_defs:
if other_def.endswith(f":{defined}") and other_def != def_id:
block.kill_set.add(other_def)
# Initialize IN and OUT sets
for block in cfg.blocks:
block.in_set = set()
block.out_set = set()
# Iterate until fixed point
changed = True
while changed:
changed = False
for block in cfg.blocks:
# Compute IN from predecessors
new_in = set()
for pred in block.predecessors:
new_in = new_in.union(pred.out_set)
# Compute OUT
new_out = block.gen_set.union(new_in - block.kill_set)
# Check for changes
if new_in != block.in_set or new_out != block.out_set:
changed = True
block.in_set = new_in
block.out_set = new_out
def compute_live_variables(self, cfg: ControlFlowGraph) -> None:
"""
Compute live variables for all basic blocks.
A variable is live at a point if its value might be used later
before being redefined.
"""
# Compute USE and DEF sets for each block
for block in cfg.blocks:
block.use_set = set()
block.def_set = set()
for instr in block.instructions:
# Variables used before being defined in this block
for use in instr.get_uses():
if use not in block.def_set:
block.use_set.add(use)
# Variables defined in this block
defined = instr.get_def()
if defined:
block.def_set.add(defined)
# Initialize LIVE_IN and LIVE_OUT sets
for block in cfg.blocks:
block.live_in = set()
block.live_out = set()
# Iterate until fixed point (backward analysis)
changed = True
while changed:
changed = False
for block in reversed(cfg.blocks):
# Compute OUT from successors
new_out = set()
for succ in block.successors:
new_out = new_out.union(succ.live_in)
# Compute IN
new_in = block.use_set.union(new_out - block.def_set)
# Check for changes
if new_in != block.live_in or new_out != block.live_out:
changed = True
block.live_in = new_in
block.live_out = new_out
=============================================================================
Optimization Passes
=============================================================================
class OptimizationPass: """Base class for all optimization passes."""
def run(self, cfg: ControlFlowGraph) -> bool:
"""
Run the optimization pass on a CFG.
Returns True if any changes were made, False otherwise.
"""
raise NotImplementedError
class ConstantFoldingPass(OptimizationPass): """ Performs constant folding and propagation.
This pass evaluates operations with constant operands at compile time
and propagates constant values through the program.
"""
def run(self, cfg: ControlFlowGraph) -> bool:
"""Run constant folding on the CFG."""
changed = False
constants = {} # Map variable names to constant values
for block in cfg.blocks:
for instr in block.instructions:
# Try to fold the instruction
if self._try_fold(instr, constants):
changed = True
# Update constant tracking
self._update_constants(instr, constants)
return changed
def _try_fold(self, instr: Instruction, constants: Dict[str, Any]) -> bool:
"""Try to fold an instruction with constant operands."""
# Replace operands with constants if available
op1 = constants.get(instr.op1, instr.op1)
op2 = constants.get(instr.op2, instr.op2)
# Check if both operands are constants
if not self._is_constant(op1) or not self._is_constant(op2):
return False
# Evaluate the operation
result = self._evaluate(instr.opcode, op1, op2)
if result is None:
return False
# Replace instruction with a move of the constant
instr.opcode = Opcode.MOV
instr.op1 = result
instr.op2 = None
return True
def _update_constants(self, instr: Instruction, constants: Dict[str, Any]) -> None:
"""Update the constant tracking based on an instruction."""
if instr.opcode == Opcode.MOV and self._is_constant(instr.op1):
# Track this constant assignment
if instr.dest:
constants[instr.dest] = instr.op1
elif instr.get_def():
# Variable is redefined, no longer constant
if instr.dest in constants:
del constants[instr.dest]
@staticmethod
def _is_constant(value: Any) -> bool:
"""Check if a value is a constant."""
return isinstance(value, (int, float, bool))
@staticmethod
def _evaluate(opcode: Opcode, op1: Any, op2: Any) -> Optional[Any]:
"""Evaluate an operation with constant operands."""
try:
if opcode == Opcode.ADD:
return op1 + op2
elif opcode == Opcode.SUB:
return op1 - op2
elif opcode == Opcode.MUL:
return op1 * op2
elif opcode == Opcode.DIV:
return op1 / op2 if op2 != 0 else None
elif opcode == Opcode.MOD:
return op1 % op2 if op2 != 0 else None
elif opcode == Opcode.EQ:
return op1 == op2
elif opcode == Opcode.NE:
return op1 != op2
elif opcode == Opcode.LT:
return op1 < op2
elif opcode == Opcode.LE:
return op1 <= op2
elif opcode == Opcode.GT:
return op1 > op2
elif opcode == Opcode.GE:
return op1 >= op2
elif opcode == Opcode.AND:
return op1 and op2
elif opcode == Opcode.OR:
return op1 or op2
except:
return None
return None
class DeadCodeEliminationPass(OptimizationPass): """ Eliminates dead code using live variable analysis.
This pass removes instructions whose results are never used.
"""
def __init__(self, analyzer: DataFlowAnalyzer):
self.analyzer = analyzer
def run(self, cfg: ControlFlowGraph) -> bool:
"""Run dead code elimination on the CFG."""
# Compute live variables
self.analyzer.compute_live_variables(cfg)
changed = False
for block in cfg.blocks:
new_instructions = []
live = block.live_out.copy()
# Process instructions in reverse order
for instr in reversed(block.instructions):
# Check if instruction is dead
defined = instr.get_def()
if defined and defined not in live:
# Instruction is dead unless it has side effects
if not self._has_side_effects(instr):
changed = True
continue # Skip this instruction
# Update live set
if defined:
live.discard(defined)
live.update(instr.get_uses())
new_instructions.append(instr)
# Reverse back to original order
block.instructions = list(reversed(new_instructions))
return changed
@staticmethod
def _has_side_effects(instr: Instruction) -> bool:
"""Check if an instruction has side effects beyond its assignment."""
# Control flow instructions have side effects
if instr.is_terminator():
return True
# Function calls have side effects
if instr.opcode == Opcode.CALL:
return True
# Memory stores have side effects
if instr.opcode == Opcode.STORE:
return True
return False
class CommonSubexpressionEliminationPass(OptimizationPass): """ Eliminates common subexpressions.
This pass identifies redundant computations and reuses previously
computed values.
"""
def run(self, cfg: ControlFlowGraph) -> bool:
"""Run CSE on the CFG."""
changed = False
for block in cfg.blocks:
expr_to_temp = {} # Map expressions to temporaries
for instr in block.instructions:
# Create expression key
if instr.opcode in {Opcode.ADD, Opcode.SUB, Opcode.MUL, Opcode.DIV}:
expr = (instr.opcode, instr.op1, instr.op2)
if expr in expr_to_temp:
# Reuse previous computation
instr.opcode = Opcode.MOV
instr.op1 = expr_to_temp[expr]
instr.op2 = None
changed = True
else:
# Record this computation
if instr.dest:
expr_to_temp[expr] = instr.dest
# Invalidate expressions that use redefined variables
defined = instr.get_def()
if defined:
expr_to_temp = {
expr: temp for expr, temp in expr_to_temp.items()
if defined not in expr
}
return changed
=============================================================================
Optimization Pipeline
=============================================================================
class OptimizationPipeline: """ Manages the execution of multiple optimization passes.
This class runs optimization passes in a specified order and
iterates until a fixed point is reached.
"""
def __init__(self):
self.analyzer = DataFlowAnalyzer()
self.passes = [
ConstantFoldingPass(),
CommonSubexpressionEliminationPass(),
DeadCodeEliminationPass(self.analyzer),
]
def optimize(self, cfg: ControlFlowGraph, max_iterations: int = 10) -> None:
"""
Run all optimization passes until a fixed point is reached.
Args:
cfg: The control flow graph to optimize
max_iterations: Maximum number of iterations to prevent infinite loops
"""
for iteration in range(max_iterations):
changed = False
for opt_pass in self.passes:
if opt_pass.run(cfg):
changed = True
if not changed:
# Reached fixed point
break
=============================================================================
LLVM IR Emitter
=============================================================================
class LLVMIREmitter: """ Generates LLVM IR from optimized CFG.
This class converts our IR into LLVM IR format suitable for
the LLVM backend.
"""
def emit(self, cfg: ControlFlowGraph, function_name: str) -> str:
"""
Generate LLVM IR for a function.
Returns the LLVM IR as a string.
"""
lines = []
# Function signature
lines.append(f"define i32 @{function_name}() {{")
# Emit basic blocks
for block in cfg.blocks:
lines.append(f"{block.name}:")
for instr in block.instructions:
llvm_instr = self._emit_instruction(instr)
if llvm_instr:
lines.append(f" {llvm_instr}")
lines.append("}")
return "\n".join(lines)
def _emit_instruction(self, instr: Instruction) -> Optional[str]:
"""Convert a single instruction to LLVM IR."""
if instr.opcode == Opcode.ADD:
return f"%{instr.dest} = add i32 %{instr.op1}, %{instr.op2}"
elif instr.opcode == Opcode.SUB:
return f"%{instr.dest} = sub i32 %{instr.op1}, %{instr.op2}"
elif instr.opcode == Opcode.MUL:
return f"%{instr.dest} = mul i32 %{instr.op1}, %{instr.op2}"
elif instr.opcode == Opcode.DIV:
return f"%{instr.dest} = sdiv i32 %{instr.op1}, %{instr.op2}"
elif instr.opcode == Opcode.MOV:
if isinstance(instr.op1, int):
return f"%{instr.dest} = add i32 0, {instr.op1}"
else:
return f"%{instr.dest} = add i32 0, %{instr.op1}"
elif instr.opcode == Opcode.RETURN:
if instr.op1:
return f"ret i32 %{instr.op1}"
else:
return "ret i32 0"
elif instr.opcode == Opcode.JUMP:
return f"br label %{instr.op1}"
elif instr.opcode == Opcode.BRANCH:
return f"br i1 %{instr.op1}, label %{instr.op2}, label %next"
return None
=============================================================================
Main Compiler Driver
=============================================================================
class Compiler: """ Main compiler driver that orchestrates all compilation phases.
This class coordinates the frontend (AST), middle end (optimization),
and backend (LLVM IR generation).
"""
def __init__(self):
self.ir_generator = IRGenerator()
self.cfg_builder = CFGBuilder()
self.optimizer = OptimizationPipeline()
self.llvm_emitter = LLVMIREmitter()
def compile(self, ast: Program) -> Dict[str, str]:
"""
Compile a program AST to LLVM IR.
Returns a dictionary mapping function names to their LLVM IR.
"""
# Generate IR from AST
function_irs = self.ir_generator.generate(ast)
result = {}
for func_name, instructions in function_irs.items():
# Build CFG
cfg = self.cfg_builder.build(instructions)
# Optimize
self.optimizer.optimize(cfg)
# Generate LLVM IR
llvm_ir = self.llvm_emitter.emit(cfg, func_name)
result[func_name] = llvm_ir
return result
=============================================================================
Example Usage
=============================================================================
def create_example_ast() -> Program: """ Create an example AST for demonstration.
This represents the following program:
function calculate(a, b) {
var x = a + b;
var y = x * 2;
var z = a + b; // Common subexpression
if (y > 10) {
return y;
} else {
return z;
}
}
"""
# Create the function body
statements = [
# var x = a + b;
VarDecl(
"x", "int",
BinaryOp("+", Variable("a"), Variable("b"))
),
# var y = x * 2;
VarDecl(
"y", "int",
BinaryOp("*", Variable("x"), Literal(2))
),
# var z = a + b; (common subexpression)
VarDecl(
"z", "int",
BinaryOp("+", Variable("a"), Variable("b"))
),
# if (y > 10) { return y; } else { return z; }
IfStatement(
BinaryOp(">", Variable("y"), Literal(10)),
Block([ReturnStatement(Variable("y"))]),
Block([ReturnStatement(Variable("z"))])
),
]
# Create the function
func = Function(
"calculate",
[("a", "int"), ("b", "int")],
"int",
Block(statements)
)
# Create the program
return Program([func])
def main(): """Main entry point for the compiler demonstration.""" print("=" * 80) print("COMPILER MIDDLE END DEMONSTRATION") print("=" * 80) print()
# Create example AST
print("Creating example AST...")
ast = create_example_ast()
print("AST created successfully.")
print()
# Create compiler
compiler = Compiler()
# Compile
print("Compiling...")
llvm_irs = compiler.compile(ast)
print("Compilation complete.")
print()
# Display results
for func_name, llvm_ir in llvm_irs.items():
print(f"LLVM IR for function '{func_name}':")
print("-" * 80)
print(llvm_ir)
print()
if __name__ == __main__ : main()