Understanding Core LLVM Optimization Passes

Mục tiêu: An overview of essential LLVM optimization passes. Understand the problems with naively generated IR and discover how mem2reg, instcombine, and gvn solve them through register promotion, instruction combining, and common subexpression elimination.


Congratulations on completing the entire core code generation step for SimpliLang! This is a monumental milestone. You have successfully built a compiler that can take a high-level source file, parse it, validate its logic, and translate it into correct, functional LLVM Intermediate Representation. You’ve bridged the gap between human-readable code and a low-level, machine-understandable format.

The IR your compiler currently produces is correct, which is the most important goal. However, it’s not necessarily efficient. Because our code generator translates AST nodes directly and naively, it often produces verbose and suboptimal patterns. For example, every single local variable is currently being allocated on the stack with alloca, written to with store, and read from with load. While this works, constant memory access is much slower than using CPU registers.

This is where the true power of LLVM shines. LLVM is not just a target for code generation; it’s a world-class optimization framework. It provides a vast library of optimization passes that can analyze the generated IR and transform it into a much more efficient form. Your job isn’t to be a master of optimization; it’s to be a master of leveraging the incredible tools LLVM provides.

This task is about understanding some of the most common and impactful of these passes. By the end of this article, you’ll understand why your generated code can be improved and how these specific passes achieve that improvement.

What is an LLVM Optimization Pass?

Think of an optimization pass as a modular plugin for the compiler. Each pass is a self-contained algorithm that performs a very specific job: it traverses the LLVM IR, looks for a particular pattern of inefficiency, and transforms it into a better one. Passes can be chained together in a sequence, with the output of one pass becoming the input for the next. This modular design is what makes LLVM so powerful and extensible.

Let’s dive into three of the most fundamental and effective passes.

1. mem2reg: Promoting Memory to Registers

This is arguably the single most important optimization for the code your compiler is currently generating.

  • The Problem: Your CodeGen currently does this for let x: int = 10;: llvm ; Before mem2reg %x.addr = alloca i64 ; 1. Allocate space on the stack store i64 10, i64* %x.addr ; 2. Store 10 into that space ... %tmp = load i64, i64* %x.addr ; 3. Load the value from the stack to use it This involves three instructions and two memory operations (a store and a load) just to use the value 10.
  • The Solution (mem2reg): This pass analyzes the use of variables allocated with alloca. If it finds a variable that is never used in a way that truly requires it to have a memory address (e.g., its address is never taken to be used by a pointer), it can eliminate the memory operations entirely. The variable is “promoted” to live in a virtual CPU register.
  • How it Works: To do this, the pass must first transform the code into Static Single Assignment (SSA) form. SSA is a property of IR where every variable is assigned exactly once. If you have code like x = 5; x = x + 1;, SSA would rename it to x1 = 5; x2 = x1 + 1;. When control flow paths merge (like after an if/else), mem2reg inserts a special phi instruction to select the correct version of the variable based on which path was taken.
  • The Result: After mem2reg runs on the code above, it would look like this: llvm ; After mem2reg ; The alloca, store, and load instructions are GONE. ; The value 10 is now directly associated with a virtual register, like %x. ; Any instruction that needed to load from %x.addr now just uses %x directly. This is a massive performance win. It eliminates unnecessary memory traffic and makes the data flow explicit, which in turn enables many other optimizations to work more effectively.

2. instcombine: Instruction Combining

This pass is like a master artisan who tidies up the code, finding clever ways to simplify and improve small sequences of instructions. It’s a “peephole” optimizer that looks for common patterns and replaces them with something better.

  • The Problem: A naive code generator might produce redundant or overly complex instructions. For example, your compiler might generate code for x * 2 as a generic multiplication.
  • The Solution (instcombine): This pass applies thousands of algebraic identities and strength-reduction rules to clean up the code.

    • Algebraic Simplification:
      • x + 0 becomes x
      • (x * 4) / 2 becomes x * 2
    • Strength Reduction: Replaces an expensive instruction with a cheaper one.
      • x * 2 becomes shl x, 1 (shift left by 1)
      • x / 8 (for unsigned int) becomes lshr x, 3 (logical shift right by 3)
    • Constant Folding: While you could implement constant folding in your compiler’s front-end, instcombine does it at the IR level.
      • add i64 5, 10 becomes i64 15
  • The Result: The code becomes smaller, faster, and uses simpler instructions, which are often more efficient on the target CPU. instcombine is a workhorse pass that provides a significant amount of “bang for your buck.”

3. gvn: Global Value Numbering

This pass is a powerful form of Common Subexpression Elimination (CSE). It’s designed to prevent the program from calculating the same value more than once.

  • The Problem: Imagine the SimpliLang code let y = (a + b) * c; let z = (a + b) / d;. A naive compiler would generate two separate add instructions to calculate a + b.
  • The Solution (gvn): This pass scans a function and assigns an internal “value number” to every computation. When it sees a new instruction, it looks at the value numbers of its operands. If it has seen this exact combination of operation and operand values before, it realizes the computation is redundant. It then replaces the new, redundant instruction with a reference to the result of the original one.
  • The Result: ```llvm ; Before gvn %tmp1 = add i64 %a, %b %y = mul i64 %tmp1, %c … %tmp2 = add i64 %a, %b ; Redundant! %z = sdiv i64 %tmp2, %d

    ; After gvn %tmp1 = add i64 %a, %b %y = mul i64 %tmp1, %c … ; The second add is eliminated. ; %z is computed directly using the result of the first add. %z = sdiv i64 %tmp1, %d ``gvnis highly effective aftermem2reg` because promoting variables to SSA form makes it much easier to see when two expressions are truly equivalent.

Next Steps

You now have the theoretical knowledge of what these key optimization passes do and why they are so important. You can see how mem2reg directly targets the code you’ve written, and how instcombine and gvn can further refine it.

In the next task, you will take this knowledge and put it into practice. You will learn about the LLVM PassManager, which is the tool used to schedule and run a sequence of these passes. You will modify your CodeGen module to create a pass manager and add mem2reg, instcombine, gvn, and a few others, officially turning your compiler from just a translator into a true optimizer.

Further Reading

Implement the LLVM PassManager Framework

Mục tiêu: Create and initialize an LLVM PassManager at the end of the function generation process. This task sets up the foundational infrastructure for running optimization passes on the generated Intermediate Representation (IR).


Excellent! You’ve successfully completed the theoretical groundwork by researching some of LLVM’s most powerful optimization passes. You now understand what tools like mem2reg and instcombine do and why they are critical for transforming your correct but naive Intermediate Representation into highly efficient code.

Theory is the map, but now it’s time to build the vehicle. To apply these powerful transformations, we need a mechanism to schedule and run them in a coordinated way. In the world of LLVM, this vehicle is the PassManager. This task is about creating the foundational framework for optimization by instantiating this manager at the correct point in your compilation pipeline.

From Theory to Practice: Introducing the PassManager

A PassManager is exactly what its name implies: a manager for a sequence of optimization passes. It acts as a scheduler, taking a list of passes you want to run and executing them in order over a given unit of code (in our case, a function). The output of one pass becomes the input to the next, allowing for a cascade of powerful, layered optimizations.

The ideal place to run our optimizations is immediately after a function’s IR has been fully generated but before we move on to the next function. This allows us to work on a complete, self-contained unit of code. Therefore, we will add the logic to create and run the PassManager at the very end of our generate_function_definition method.

Implementing the PassManager Framework

Let’s modify src/codegen/generator.rs to create our pass manager. First, you’ll need to add a new use statement at the top of the file to bring the PassManager into scope.

// Add this to the `use` statements at the top of src/codegen/generator.rs
use inkwell::passes::PassManager;

Now, we will add the new logic to the generate_function_definition method. The new code will be added right at the end of the function, just before the closing brace.

// in src/codegen/generator.rs

// ... (inside the `impl<'ctx> CodeGen<'ctx>` block)

fn generate_function_definition(&mut self, func: &ast::FunctionDefinition) {
    // This existing code generates the function signature, entry block,
    // parameters, and the entire function body.
    /* ... all previous code in this function remains the same ... */

    self.generate_block(&func.body);

    if func.return_type.is_none() {
        if let Some(last_block) = self.builder.get_insert_block() {
            if last_block.get_terminator().is_none() {
                self.builder.build_return(None);
            }
        }
    }

    // --- NEW: Create and run the optimization PassManager ---

    // 1. Create a PassManager specifically for functions.
    // The PassManager is created for the entire module, but it will be
    // configured to run passes that operate on the `FunctionValue` level.
    let pass_manager = PassManager::create(&self.module);

    // In the next task, we will add passes here. For example:
    // pass_manager.add_instruction_combining_pass();
    // pass_manager.add_reassociate_pass();
    // pass_manager.add_gvn_pass();
    // pass_manager.add_cfg_simplification_pass();
    // pass_manager.add_basic_alias_analysis_pass();
    // pass_manager.add_promote_memory_to_register_pass(); // mem2reg

    // 2. Initialize the pass manager. This is a required step.
    pass_manager.initialize();

    // 3. Run the configured passes on the current function.
    // The `run_on` method returns `true` if the function was modified
    // by any of the passes, and `false` otherwise.
    pass_manager.run_on(&function);
}

Dissecting the Implementation

This small block of code is the gateway to LLVM’s entire optimization ecosystem. Let’s break it down:

  1. PassManager::create(&self.module): This is how we create a new pass manager. While we intend to run it on a single function, the manager itself is associated with the entire Module. This is because some advanced, inter-procedural optimizations might need module-level context. inkwell provides a safe, high-level API for this.
  2. pass_manager.initialize(): This is a mandatory boilerplate step. Before a pass manager can be run, it must be initialized. This call sets up the internal state of the manager, preparing it to execute the passes you will add.
  3. pass_manager.run_on(&function): This is the “Go” button. This single method call takes the FunctionValue we’ve just finished generating (function) and subjects it to the entire pipeline of optimizations that we will add to the pass_manager. LLVM will now internally traverse the IR of this one function, applying the transformations from each pass in sequence. The function’s IR is modified in-place.

You have now successfully created the framework for optimization. You have an empty, but fully functional, PassManager ready and waiting at the end of your function generation pipeline. The stage is set to turn your correct compiler into a truly optimizing compiler.

Next Steps

The PassManager is created, but it’s empty. It’s like having a car factory with a conveyor belt but no robotic arms on it. In the next task, you will bring the factory to life by populating this PassManager with the very optimization passes you researched: mem2reg, instcombine, gvn, and a few other highly effective ones.

Further Reading

Implement a Basic LLVM Optimization Pipeline

Mục tiêu: Populate the LLVM PassManager with a foundational set of optimization passes, including mem2reg, instruction combining, reassociation, GVN, and CFG simplification, to transform the compiler into an optimizing compiler.


In the previous task, you successfully set up the core infrastructure for optimization. You created an LLVM PassManager at the end of your function generation pipeline—a powerful but empty conveyor belt, ready and waiting. You’ve built the factory; now it’s time to install the robotic arms that will forge your raw, functional IR into a masterpiece of efficiency.

This task is all about populating that PassManager with a carefully selected “starter pack” of LLVM’s most effective optimization passes. While LLVM offers hundreds of passes, a small, foundational set provides the majority of the performance gains for a language like SimpliLang. By adding these passes, you are officially transforming your compiler from a mere translator into a true optimizing compiler.

The “Starter Pack” of Optimizations

The order in which passes are run can be important. A general best practice is to run passes that simplify the code structure first, as this often “unlocks” opportunities for other, more specific optimizations. Our chosen set of passes follows this philosophy. We will add them to the PassManager between its creation and its initialization.

Let’s update the generate_function_definition method in src/codegen/generator.rs with the new pass-adding logic.

// in src/codegen/generator.rs

// ... (inside the `impl<'ctx> CodeGen<'ctx>` block)

fn generate_function_definition(&mut self, func: &ast::FunctionDefinition) {
    // ... (all the existing code for function generation remains the same)
    /*
        let function = self.module.add_function(...);
        let entry_block = self.context.append_basic_block(function, "entry");
        self.builder.position_at_end(entry_block);
        self.variables.clear();
        // ... parameter handling ...
        self.generate_block(&func.body);
        // ... implicit return handling ...
    */

    let pass_manager = PassManager::create(&self.module);

    // --- NEW: Add the optimization passes to the manager ---

    // The "mem2reg" pass is the most critical for our current code generation strategy.
    // It promotes stack allocations (`alloca`) to virtual registers, eliminating
    // unnecessary memory access and enabling many other optimizations.
    pass_manager.add_promote_memory_to_register_pass();

    // The instruction combining pass cleans up redundant or algebraically simple instructions.
    // E.g., `x + 0` becomes `x`, `(x * 4) / 2` becomes `x * 2`.
    pass_manager.add_instruction_combining_pass();

    // The reassociate pass rearranges associative expressions to group constants,
    // which can open up more opportunities for `instcombine`.
    // E.g., `(a + 5) + b` becomes `a + b + 5`.
    pass_manager.add_reassociate_pass();

    // The Global Value Numbering (GVN) pass performs common subexpression elimination.
    // If an expression like `(a + b)` is calculated multiple times, GVN will
    // replace the later calculations with the result of the first one.
    pass_manager.add_gvn_pass();

    // The CFG Simplification pass cleans up the control flow graph.
    // It can remove unreachable basic blocks or merge blocks that have only one predecessor.
    // This makes the code cleaner for subsequent passes.
    pass_manager.add_cfg_simplification_pass();

    pass_manager.initialize();

    pass_manager.run_on(&function);
}

Dissecting Your New Optimization Pipeline

You have just defined a powerful, multi-stage optimization pipeline. Let’s break down the role of each new line of code:

  • add_promote_memory_to_register_pass() (mem2reg): This is the MVP (Most Valuable Pass) for your compiler. As you know from the research task, your code generator currently uses alloca, store, and load for every single local variable. This pass analyzes the IR and, where possible, completely eliminates these memory operations, promoting the variables to live in super-fast virtual registers. It does this by converting the function to Static Single Assignment (SSA) form, which makes the data flow explicit and is a prerequisite for many other advanced optimizations. This is the single most important pass you can run.
  • add_instruction_combining_pass() (instcombine): Think of this as the great simplifier. It’s a “peephole” optimizer that looks for small, local patterns of instructions and replaces them with more efficient ones. It performs constant folding (add i64 5, 10 becomes the value 15 directly) and strength reduction (x * 2 becomes a faster bitwise shift shl x, 1). It’s a workhorse that cleans up the code generated by your compiler.
  • add_reassociate_pass(): This pass uses the associative properties of operators (like addition and multiplication) to re-order expressions. For example, it might transform (x + 5) - 3 into x + 2. Its main benefit is grouping constants and re-arranging expressions into a canonical form that makes them easier for other passes, like instcombine and gvn, to recognize and optimize.
  • add_gvn_pass() (Global Value Numbering): This is your redundant work eliminator. It performs Common Subexpression Elimination (CSE). If it sees your code calculate a + b in two different places, it will ensure the addition is only performed once and the result is reused. This is especially effective after mem2reg has made the true values of variables visible in registers.
  • add_cfg_simplification_pass(): This is the janitor for your Control Flow Graph (CFG). It performs various clean-up tasks, such as removing basic blocks that can never be reached (dead code) or merging a block that only contains an unconditional jump to another block. This simplifies the function’s structure, reduces code size, and makes it easier for other analyses to run.

You have armed your PassManager with a powerful suite of tools. Your compiler is no longer just a translator; it is now a true optimizer, capable of taking high-level SimpliLang code and producing low-level LLVM IR that is not just correct, but also lean and efficient.

Next Steps

Your PassManager is now fully configured and is already being run on every function you generate. The next logical step is to give the user control over this powerful feature. Not every compilation needs to be optimized (e.g., a quick debug build). According to the project roadmap, the next task is to add a command-line flag (e.g., -O) to your compiler’s CLI, allowing the user to enable or disable this entire optimization step. This will involve wrapping the PassManager logic in a conditional check.

Further Reading

  • Inkwell’s PassManagerBuilder: While you’ve used direct add_*_pass methods, inkwell also provides a PassManagerBuilder that can configure a pipeline similar to what clang uses for its -O1, -O2, and -O3 levels. It’s a great next step for more advanced optimization control.
  • LLVM’s List of Passes: The official LLVM documentation listing a huge number of available passes. It’s a fascinating read to see the sheer scope of what’s possible.
  • The SSA Book: For a truly deep dive into the theory that makes modern compilers possible, this online book is a fantastic resource on Static Single Assignment form.

Execute the LLVM Optimization Pass Manager

Mục tiêu: Run the configured LLVM PassManager on a newly generated function. This applies a pipeline of optimization passes, transforming the function’s Intermediate Representation (IR) in-place into more efficient, professional-grade code.


Excellent work! You have successfully constructed a powerful optimization pipeline for your compiler. In the last task, you equipped your PassManager with a suite of LLVM’s most effective optimization passes—the robotic arms of your code generation factory are installed and ready. Now, all that’s left is to press the big green “Go” button and unleash this power on your generated code.

This task is about that final, crucial step: executing the PassManager on the function you’ve just generated. This is the moment where your naive, but correct, Intermediate Representation is transformed into lean, efficient, and professional-grade code.

Activating the Optimization Pipeline

You’ve already seen the line of code that accomplishes this, as it was the natural conclusion to setting up the PassManager. Let’s now dive deep into what this single, powerful line of code, pass_manager.run_on(&function);, is actually doing under the hood.

This method call is the trigger for a cascade of complex operations:

  1. The Target: It takes a reference to the FunctionValue you’ve spent the last several tasks meticulously building. This is the complete unit of IR that the passes will operate on.
  2. The Pipeline Execution: The PassManager now iterates through the list of passes you added, in the exact order you added them.
  3. In-Place Transformation: Each pass analyzes the function’s IR. If it finds a pattern it can improve, it modifies the IR directly. The output of the mem2reg pass (the version of the function without stack allocations) becomes the input for the instcombine pass, which then further refines it. This chain reaction is what makes the pipeline so effective.
  4. The Result: After the run_on method completes, the function object itself has been mutated. It no longer contains the original, naive IR. It now holds the final, optimized version.

The Return Value: A Signal of Change

The run_on method returns a boolean value. This is a subtle but important piece of information:

  • true: The IR was changed by at least one of the passes.
  • false: The IR was already optimal according to the passes, and no changes were made.

For our current implementation, we don’t need to use this boolean value. However, in more advanced compiler designs, it can be used to implement sophisticated optimization strategies, such as running a set of passes repeatedly until no more changes are made (this is known as optimizing to a “fixed point”).

Code in Context

Let’s look at the complete optimization block at the end of your generate_function_definition method one more time. The final line is where all the action happens.

// In src/codegen/generator.rs, at the end of `generate_function_definition`

    // Create the PassManager for the entire module.
    let pass_manager = PassManager::create(&self.module);

    // Add our "starter pack" of powerful optimization passes.
    pass_manager.add_promote_memory_to_register_pass(); // mem2reg
    pass_manager.add_instruction_combining_pass();
    pass_manager.add_reassociate_pass();
    pass_manager.add_gvn_pass();
    pass_manager.add_cfg_simplification_pass();

    // Initialize the pass manager before running it.
    pass_manager.initialize();

    // --- This is the focus of our current task ---
    //
    // Run the entire pipeline of optimization passes on the newly generated function.
    //
    // - `&function`: We pass a reference to the `FunctionValue` we want to optimize.
    // - In-Place Mutation: This call modifies the `function`'s IR directly. The naive
    //   code with `alloca` and `load`/`store` is transformed into an efficient,
    //   register-based SSA form.
    // - Returns `bool`: The method returns `true` if any pass changed the code,
    //   `false` otherwise. We are not using this return value for now.
    pass_manager.run_on(&function);

By executing this line, you have officially bridged the gap between a simple translator and a true optimizing compiler. The LLVM IR that will be passed to the next stages of compilation is now significantly more performant. The unnecessary memory accesses from your alloca-based variable strategy have been eliminated, constant expressions have been folded, and redundant computations have been removed.

Next Steps

Your optimization engine is now fully operational! However, running a full optimization pipeline can slow down compilation, which isn’t always desirable, especially during development and debugging. The next logical step is to give the user control over this powerful feature. In the next task, you will implement a command-line flag (e.g., -O) for your compiler’s CLI, allowing the user to choose whether to run this optimization pipeline or to perform a faster, unoptimized “debug” build.

Further Reading

  • Inkwell PassManager::run_on Documentation: The official documentation for the method you’ve just implemented.
  • Fixed-Point Iteration in Computer Science: A concept from mathematics and computer science that describes the process of repeating a function until its output no longer changes. This is the theory behind running optimization passes until they stop modifying the code.
  • Compiler Explorer (godbolt.org): An amazing interactive tool that lets you see the assembly output of compilers like GCC and Clang with different optimization levels. It’s a fantastic way to build intuition for what -O1, -O2, and -O3 actually do to the code.

Implement a CLI Flag to Control Optimizations

Mục tiêu: Add a command-line flag (-O/–optimize) using the clap crate to conditionally enable or disable the LLVM optimization pass pipeline, giving users control over the compile-time versus run-time performance trade-off.


In the previous tasks, you successfully built and activated a powerful optimization pipeline using LLVM’s PassManager. Your compiler is now capable of transforming its own naive, but correct, output into lean, efficient Intermediate Representation. This is a hallmark of a professional-grade compiler.

However, power should always come with a control switch. Optimization is not a “free” operation; it consumes compilation time. During rapid development and debugging, a programmer often values fast compilation over fast execution. They want to see if their logic is correct, not wait for the compiler to produce the most performant binary possible. Conversely, for a “release” build, they want the compiler to take its time and apply every possible optimization.

This task is about giving the user of your compiler that crucial choice. We will add a standard command-line flag, -O, that will act as the master switch for the entire optimization pipeline you just built.

Introducing the Control Switch: A CLI Flag

The clap crate, which you added at the very beginning of the project, makes adding command-line arguments incredibly simple. We’ll add a new boolean flag to our main CLI struct. By convention, this is often a short flag -O and a longer, more descriptive flag like --optimize.

Let’s assume your CLI struct is defined in src/main.rs. We’ll modify it to include the new optimize field.

// In src/main.rs (or wherever your CLI struct is defined)

use clap::Parser;

// NOTE: Your CLI struct might have other fields, like an output file path.
// We are only showing the addition of the new `optimize` flag.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// The path to the SimpliLang source file
    source_path: String,

    // --- NEW: Add the optimization flag ---
    /// Enable optimizations
    #[arg(short = 'O', long = "optimize")]
    optimize: bool,
}

This small addition does a lot of work for us:

  • #[derive(Parser)]: The core derive macro from clap that generates all the parsing logic.
  • optimize: bool: This defines a field that will hold true if the flag is present on the command line and false otherwise. It’s a “boolean flag.”
  • #[arg(short = 'O', long = "optimize")]: This attribute tells clap how to recognize the flag. It will now accept both -O and --optimize.

Plumbing the Configuration

Now that we can parse the optimization setting in main.rs, we need to get that information to the place where it’s actually used—deep inside our CodeGen module. The process of passing configuration from the top level of an application down to its components is often called “plumbing.”

Step 1: Update the CodeGen Struct and Constructor

First, we’ll give our CodeGen struct a place to store this setting. Then, we’ll modify its new constructor to accept the flag.

Open src/codegen/generator.rs and make the following changes.

// In src/codegen/generator.rs

// ... (use statements)

pub struct CodeGen<'ctx> {
    pub context: &'ctx Context,
    pub builder: Builder<'ctx>,
    pub module: Module<'ctx>,
    variables: HashMap<String, PointerValue<'ctx>>,
    functions: HashMap<String, FunctionValue<'ctx>>,
    // NEW: Add a field to store the optimization setting.
    optimize: bool,
}

impl<'ctx> CodeGen<'ctx> {
    // UPDATED: The `new` function now accepts the `optimize` flag.
    pub fn new(context: &'ctx Context, module_name: &str, optimize: bool) -> Self {
        let module = context.create_module(module_name);
        let builder = context.create_builder();

        CodeGen {
            context,
            builder,
            module,
            variables: HashMap::new(),
            functions: HashMap::new(),
            // Store the flag in the struct's state.
            optimize,
        }
    }

    // ... (rest of the methods)
}

Step 2: Pass the Flag During CodeGen Creation

Next, we’ll update the main function to pass the parsed CLI value when it creates the CodeGen instance.

// In src/main.rs

fn main() {
    let cli = Cli::parse();

    // ... (file reading, lexer, parser, semantic analysis logic) ...

    let context = Context::create();
    // UPDATED: Pass the `cli.optimize` value to the `CodeGen` constructor.
    let mut codegen = CodeGen::new(&context, "simplilang_module", cli.optimize);

    codegen.generate(&program);

    // ... (rest of the compilation pipeline) ...
}

Step 3: Implementing the Conditional Logic

The plumbing is complete! The CodeGen struct is now aware of whether it should be optimizing. The final step is to use this information. We’ll wrap the entire PassManager block in generate_function_definition inside a simple if statement.

// In src/codegen/generator.rs

fn generate_function_definition(&mut self, func: &ast::FunctionDefinition) {
    // ... (all the existing code for function generation remains the same)
    /*
        let function = self.module.add_function(...);
        ...
        self.generate_block(&func.body);
        ...
    */

    // --- UPDATED: Conditionally run the optimization pipeline ---

    // Only set up and run the PassManager if the `optimize` flag is true.
    if self.optimize {
        let pass_manager = PassManager::create(&self.module);

        // Add all our chosen passes.
        pass_manager.add_promote_memory_to_register_pass();
        pass_manager.add_instruction_combining_pass();
        pass_manager.add_reassociate_pass();
        pass_manager.add_gvn_pass();
        pass_manager.add_cfg_simplification_pass();

        pass_manager.initialize();
        pass_manager.run_on(&function);
    }
}

And with that, you have a fully configurable optimization pipeline. If the user runs simplilang_compiler my_code.spl, the if self.optimize check will be false, and the PassManager logic will be skipped entirely, resulting in a fast, unoptimized build. If they run simplilang_compiler -O my_code.spl, the check will be true, and every function will be passed through the full suite of powerful optimizations you configured.

Next Steps

You’ve built the machinery and added the control switch. The next logical and exciting step is to see the results of your work. In the next task, you will “Generate and inspect the LLVM IR with and without optimizations to observe the improvements.” You’ll learn how to print the LLVM IR to the console or a file, allowing you to run your compiler twice on the same source file—once with -O and once without—and see the dramatic difference your optimization passes make.

Further Reading

Visualize Compiler Optimizations by Emitting LLVM IR

Mục tiêu: Add a --emit-llvm command-line flag to the compiler to print the generated LLVM Intermediate Representation. Use this feature to visually compare the unoptimized and optimized code, observing the powerful effects of LLVM’s optimization passes like mem2reg.


You have done a phenomenal job building a fully configurable optimization pipeline. By adding the -O command-line flag in the last task, you’ve given the user of your compiler the crucial choice between a fast, unoptimized “debug” build and a slower, highly-optimized “release” build. The machinery is in place, the control switch is wired up. Now comes the most satisfying part of this entire process: seeing the results of your hard work.

This task is all about observation and verification. We will add a feature to your compiler to “emit” the human-readable LLVM IR, and then use it to directly compare the code generated with and without the -O flag. This is the moment where the abstract concepts of mem2reg and instcombine become tangible, visible transformations in your compiler’s output.

The Ultimate Debugging Tool: Emitting LLVM IR

Before a compiler generates a native executable, the LLVM IR is the final, human-readable stage. Being able to inspect this IR is one of the most powerful debugging and learning tools available to a compiler developer. It allows you to see exactly what your CodeGen produced and how LLVM’s optimizers transformed it.

Following the tradition of compilers like clang, we will add a new CLI flag, --emit-llvm, that instructs our compiler to stop after code generation and simply print the final LLVM IR to the console.

Step 1: Add the --emit-llvm Flag

First, let’s update your Cli struct in src/main.rs to recognize this new flag.

// In src/main.rs

use clap::Parser;

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// The path to the SimpliLang source file
    source_path: String,

    /// Enable optimizations
    #[arg(short = 'O', long = "optimize")]
    optimize: bool,

    // --- NEW: Add the flag to emit LLVM IR ---
    /// Emit the LLVM IR to the console instead of compiling an executable
    #[arg(long = "emit-llvm")]
    emit_llvm: bool,
}

Step 2: Implement the Logic in main

Now, in your main function, after the code generation is complete, we’ll check for this flag. If it’s present, we’ll use a handy inkwell method to get the module’s IR as a string and print it. Otherwise, we’ll proceed to the later compilation steps.

// In src/main.rs's `main` function

fn main() {
    // 1. Parse CLI arguments
    let cli = Cli::parse();

    // 2. Read source file, run lexer, parser, and semantic analyzer...
    // ... (Your existing front-end pipeline) ...

    // 3. Run the Code Generation
    let context = Context::create();
    let mut codegen = CodeGen::new(&context, "simplilang_module", cli.optimize);
    codegen.generate(&program);

    // --- NEW: Conditionally emit LLVM IR ---

    // 4. Check if the user requested to see the IR
    if cli.emit_llvm {
        // Use the module's `print_to_string` method to get the human-readable IR.
        let ir_string = codegen.module.print_to_string().to_string();
        println!("{}", ir_string);
    } else {
        // This is where the logic from future steps (compiling to an object
        // file and linking) will go. For now, it can be empty.
        println!("(Skipping LLVM IR emission. Ready for compilation step.)");
    }
}

With this in place, your compiler now has a powerful inspection tool.

The Grand Comparison: A Test Case

To see the optimizers in action, we need a good test case. Let’s create a file named test.spl with the following SimpliLang code. This function is simple but contains a parameter and local variables, making it a perfect target for the mem2reg pass.

// in test.spl
fn calculate(a: int) -> int {
    let b: int = a * 2;
    let c: int = b + 10;
    return c;
}

Now, let’s run the compiler and see what happens.

The “Before” Picture: Unoptimized IR

Run your compiler on the test file without the optimization flag, but with the new emit flag:

cargo run -- test.spl --emit-llvm

Your compiler’s output should look very similar to this. This is the direct, naive translation of your AST.

; ModuleID = 'simplilang_module'
source_filename = "simplilang_module"

define i64 @calculate(i64 %0) {
entry:
  ; 1. Stack allocations for all named values, including the parameter 'a'.
  %a.addr = alloca i64, align 8
  %b = alloca i64, align 8
  %c = alloca i64, align 8

  ; 2. Store the incoming parameter value (%0) onto the stack.
  store i64 %0, i64* %a.addr, align 8

  ; 3. To calculate `b`, we must first load 'a' back from the stack.
  %1 = load i64, i64* %a.addr, align 8
  %multmp = mul nsw i64 %1, 2
  store i64 %multmp, i64* %b, align 8

  ; 4. To calculate `c`, we must load 'b' from the stack.
  %2 = load i64, i64* %b, align 8
  %addtmp = add nsw i64 %2, 10
  store i64 %addtmp, i64* %c, align 8

  ; 5. To return `c`, we must load it from the stack one last time.
  %3 = load i64, i64* %c, align 8
  ret i64 %3
}

Analysis: Look at all those memory operations! Every single variable, even the function parameter, lives on the stack. To do a simple calculation, we have to store a value into memory and immediately load it back out. This is correct, but it’s incredibly inefficient.

The “After” Picture: Optimized IR

Now for the magic. Run the exact same command, but this time, flip the -O switch:

cargo run -- test.spl --emit-llvm -O

The output will be dramatically different and should look like this:

; ModuleID = 'simplilang_module'
source_filename = "simplilang_module"

define i64 @calculate(i64 %a) {
entry:
  ; 1. The `mul` instruction operates DIRECTLY on the incoming parameter '%a'.
  ;    No stack interaction needed. The result is stored in a virtual register '%multmp'.
  %multmp = mul nsw i64 %a, 2

  ; 2. The `add` instruction operates DIRECTLY on the result of the multiplication.
  ;    Again, no memory access.
  %addtmp = add nsw i64 %multmp, 10

  ; 3. The result of the final addition is returned DIRECTLY.
  ret i64 %addtmp
}

Analysis: This is a night-and-day difference.

  • No alloca! The mem2reg pass saw that a, b, and c were simple local variables and promoted them entirely to virtual registers. The stack is not touched at all.
  • No load or store! The constant back-and-forth between registers and memory is gone. The data flows directly from one instruction to the next.
  • SSA Form: The code is now in Static Single Assignment form. The incoming parameter is named %a, the result of the multiplication is %multmp, and the result of the addition is %addtmp. Each “variable” is assigned exactly once.

You have just witnessed the incredible power of leveraging a mature optimization framework. With just a few lines of code to enable the PassManager, you transformed inefficient, memory-bound code into the optimal, register-based equivalent.

Next Steps

You’ve seen that the optimized IR is different, but is it still correct? The next and final task in this step is to “Verify that the optimized code still produces the correct output.” This involves completing the compilation pipeline by generating an object file and linking it into a real executable, which you can then run to confirm that both the optimized and unoptimized versions compute the same result.

Further Reading

Verify Optimization Correctness by Compiling and Linking

Mục tiêu: Complete the compiler pipeline by adding steps to compile LLVM IR into a native object file and link it into an executable. Verify that LLVM optimizations preserve the program’s original behavior by compiling a test program with and without optimizations and ensuring both executables produce the same correct exit code.


In the last task, you did something truly remarkable: you visually confirmed the incredible power of LLVM’s optimization passes. By comparing the IR generated with and without the -O flag, you saw firsthand how verbose, memory-bound code was transformed into a lean, efficient, register-based equivalent. The difference was stark, tangible, and deeply satisfying.

However, a successful optimization is not just one that makes code look better; it’s one that preserves the code’s original meaning perfectly. The cardinal rule of optimization is: never change the program’s behavior. This final task in the optimization step is dedicated to proving that our powerful new pipeline adheres to this rule. We will compile our code both with and without optimizations, run the resulting executables, and verify that they produce the exact same correct output.

The Litmus Test: A Program with a Verifiable Result

To verify correctness, we need a program whose output is easy to check. The calculate function from the last task was great for inspecting IR, but since it’s not the main function, we can’t run it directly. Let’s create a new SimpliLang file, verify.spl, with a main function that returns a specific, non-zero value. In most operating systems, the value returned by main becomes the program’s exit code, which is simple to check from the command line.

Create a new file named verify.spl:

// in verify.spl

// The `main` function is the entry point of a SimpliLang program.
// The integer value it returns becomes the program's exit code.
fn main() -> int {
    let x: int = 5;
    let y: int = (x * 2) + 7; // This should calculate to 17
    return y;
}

This is a perfect test case. It involves local variables (x and y), which are prime targets for the mem2reg pass, and a chain of arithmetic operations that instcombine can analyze. The expected exit code is 17.

A Glimpse into the Future: The Final Compilation Steps

Right now, your compiler’s main function knows how to emit LLVM IR, but the else block that should handle the final compilation is likely empty. To perform our verification, we need to complete the pipeline. This involves taking the final LLVM IR (whether optimized or not), compiling it into a native object file (.o), and then using a system linker (like clang or gcc) to turn that object file into a runnable executable.

The following code is a preview of the next major step in your project. We’ll add it to your main.rs to make this verification possible.

First, add the necessary use statements at the top of src/main.rs:

// In src/main.rs

use inkwell::targets::{Target, InitializationConfig, TargetMachine, FileType};
use inkwell::OptimizationLevel;
use std::process::Command;
use std::path::Path;

Now, let’s fill in the else block where you previously had a placeholder. We will also need to update your Cli struct to accept an output file name.

Step 1: Update your Cli struct

// In src/main.rs

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// The path to the SimpliLang source file
    source_path: String,

    /// The name of the output executable
    #[arg(short, long, default_value = "a.out")]
    output: String,

    /// Enable optimizations
    #[arg(short = 'O', long)]
    optimize: bool,

    /// Emit the LLVM IR to the console instead of compiling an executable
    #[arg(long = "emit-llvm")]
    emit_llvm: bool,
}

Step 2: Implement the compilation and linking logic

// In src/main.rs's `main` function

fn main() {
    // 1. Parse CLI arguments
    let cli = Cli::parse();

    // 2. Read source file, run lexer, parser, and semantic analyzer...
    // ... (Your existing front-end pipeline) ...

    // 3. Run the Code Generation
    let context = Context::create();
    let mut codegen = CodeGen::new(&context, &cli.source_path, cli.optimize);
    codegen.generate(&program);

    // 4. Check if the user requested to see the IR
    if cli.emit_llvm {
        let ir_string = codegen.module.print_to_string().to_string();
        println!("{}", ir_string);
    } else {
        // --- NEW: The Compilation and Linking Pipeline ---

        // It's a best practice to verify the module before compiling.
        // This catches any malformed IR we might have generated.
        codegen.module.verify().expect("LLVM module verification failed.");

        // Initialize the native target. This tells LLVM to prepare for code
        // generation for the architecture your computer is running on.
        Target::initialize_native(&InitializationConfig::default())
            .expect("Failed to initialize native target");

        // Get the "triple" for the host machine, e.g., "x86_64-apple-darwin".
        let target_triple = TargetMachine::get_default_triple();
        let target = Target::from_triple(&target_triple).unwrap();

        // Create a "target machine", which represents the specific CPU we are compiling for.
        // We use a generic CPU and feature set for broad compatibility.
        let target_machine = target
            .create_target_machine(
                &target_triple,
                "generic",
                "",
                OptimizationLevel::Default, // Use LLVM's default opt level
                inkwell::targets::RelocMode::Default,
                inkwell::targets::CodeModel::Default,
            )
            .unwrap();

        // Define the output path for our temporary object file.
        let object_file_path = Path::new(&cli.output).with_extension("o");

        // Compile the LLVM module into a native object file.
        target_machine
            .write_to_file(&codegen.module, FileType::Object, object_file_path.as_path())
            .expect("Failed to write object file.");

        println!("Successfully compiled to object file: {:?}", object_file_path);

        // Use `clang` to link the object file into a final executable.
        let linker_output = Command::new("clang")
            .arg(object_file_path.as_path())
            .arg("-o")
            .arg(&cli.output)
            .output()
            .expect("Failed to execute linker.");

        if !linker_output.status.success() {
            eprintln!("Linker error: {}", String::from_utf8_lossy(&linker_output.stderr));
        } else {
            println!("Successfully linked executable: {}", &cli.output);
            // Clean up the temporary object file.
            std::fs::remove_file(object_file_path).expect("Failed to remove object file.");
        }
    }
}

The Moment of Truth: Running the Test

Your compiler is now a complete end-to-end tool. Let’s perform the verification.

Run 1: Unoptimized Compilation

Compile your verify.spl file without the -O flag. We’ll name the output verify_unopt.

cargo run -- verify.spl -o verify_unopt

Now, run the executable and immediately check its exit code. * On macOS or Linux: bash ./verify_unopt; echo $? * On Windows (Command Prompt): bash .\verify_unopt.exe & echo %errorlevel% The terminal should print the number 17. This confirms our unoptimized code generation is correct.

Run 2: Optimized Compilation

Now, do the exact same thing, but flip the optimization switch. We’ll name this output verify_opt.

cargo run -- verify.spl -o verify_opt -O

Again, run the new executable and check its exit code. * On macOS or Linux: bash ./verify_opt; echo $? * On Windows (Command Prompt): bash .\verify_opt.exe & echo %errorlevel% The terminal should also print the number 17.

This is the proof. Even though you saw in the last task that the underlying LLVM IR for these two executables is dramatically different, their final, observable behavior is identical. You have successfully verified that your optimization pipeline makes the code more efficient without altering its correctness.

Next Steps

Congratulations! You have successfully completed the entire optimization step of the SimpliLang compiler. This is a massive achievement that elevates your project from a simple translator to a true, performance-conscious compiler.

The code you added to main.rs was a “sneak peek” into the next phase. You’ve already done the work, but the next step, Step 8: Compile the optimized LLVM IR into a native object file and link it, is about formalizing this process, understanding each part (like TargetMachine and TargetTriple) in greater detail, and making the implementation more robust.

Further Reading