Kaleidoscope Tutorial
This tutorial teaches pliron by building a small Kaleidoscope-like language and a compiler pipeline for it.
The tutorial takes you through the following chapters:
- Parse source text (in the Kaleidoscope language) into an AST.
- Define a new Kaleidoscope dialect for the language.
- Lower AST nodes into the Kaleidoscope dialect IR.
- Lower the Kaleidoscope dialect into LLVM dialect IR.
- Use LLVM-JIT to compile and run the resulting LLVM IR.
Prerequisites
- Comfortable with Rust (did you read the Rust book?).
- Familiarity with basic compiler terms (AST, IR, lowering).
How to use this tutorial
- Read a chapter.
- Try out the example tests.
- Modify it (as desired) or add new tests and re-run.
- Move to the next chapter.
Each chapter mentions runnable example tests that you can execute. Typically, you can run them with:
cargo test --example kaleidoscope -- <test_name> --show-output
For a chapter-to-test map, see the Examples and Tests Index.
Build and view the tutorial offline (optional):
mdbook build kaleidoscope
mdbook serve kaleidoscope
Chapter 1: Parsing Kaleidoscope
This chapter implements a complete combine-based parser for the Kaleidoscope
language used throughout the tutorial.
You may skip this chapter if you already have an AST (or know how to build one)
and want to use pliron to build a compiler pipeline on top of it. The rest of
the tutorial only requires the AST and not the parser implementation.
The implementation for this chapter lives in examples/kaleidoscope/ast.rs
What is combine?
combine is a parser combinator library.
“Parser combinators are higher-order functions that accept parsers as input and return a new parser as output.” – Wikipedia.
They allow for the construction of complex parsers by combining simpler ones.
As an example, a parser for an integer literal can be combined with a parser for a plus sign and another integer literal to create a parser for an addition expression.
Why combine?
We use combine in this tutorial because pliron itself already uses combine
for most IR parsing tasks. Learning it here makes it easier to read and extend
parser-related code in the pliron codebase.
At the same time, combine is not a requirement for your own compiler front
end. If you are already productive with another parser library or approach,
you can use that instead. The rest of the tutorial only requires an AST.
This flexibility is especially useful because combine may have a steeper learning
curve than some alternatives.
The language
Kaleidoscope as used here is a simple integer language.
Example: Fibonacci program in Kaleidoscope:
def main(n) {
return fib(n);
}
def fib(n) {
if n < 2 {
return n;
} else {
return fib(n - 1) + fib(n - 2);
}
}
Example: Factorial program in Kaleidoscope:
def main(n) {
return factorial(n);
}
def factorial(n) {
var result = 1;
var i = n;
while i > 0 {
result = result * i;
i = i - 1;
}
return result;
}
Supported constructs:
| Construct | Syntax |
|---|---|
| Integer literal | 42 |
| Arithmetic | +, -, * |
| Comparison | <, >, ==, !=, <=, >= |
| Variable | name |
| Function call | name(args...) |
| If statement | if cond { stmts } else { stmts } |
| Variable declaration | var name; or var name = expr; |
| Assignment | name = expr; |
| Return | return expr; |
| While loop | while cond { stmts } |
| Function definition | def name(params) { stmts } |
if and while are statements, so neither directly produces a value.
Grammar
program := func_def* eof
func_def := 'def' ident '(' params ')' block
params := (ident (',' ident)*)?
block := '{' stmt* '}'
stmt := 'var' ident ('=' expr)? ';'
| ident '=' expr ';'
| 'return' expr ';'
| 'while' expr block
| 'if' expr block 'else' block
| expr ';'
expr := cmp_expr
cmp_expr := add_expr (cmp_op add_expr)?
add_expr := mul_expr (('+' | '-') mul_expr)*
mul_expr := primary ('*' primary)*
primary := integer
| ident '(' (expr (',' expr)*)? ')'
| ident
| '(' expr ')'
cmp_op := '<' | '>' | '==' | '!=' | '<=' | '>='
integer := [0-9]+
ident := [a-zA-Z_][a-zA-Z0-9_]*
Parsing into an AST
Before looking at any parsing code, it helps to know what we are building towards. The parser’s sole job is to turn source text into this tree of Rust types:
Expressions
Expressions always produce a value. All values are implicitly 64-bit integers.
#![allow(unused)]
fn main() {
/// An expression (has a value).
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
/// A non-negative integer literal.
Integer(i64),
/// A variable reference.
Variable(String),
/// A binary operation.
BinOp {
op: BinOp,
lhs: Box<Expr>,
rhs: Box<Expr>,
},
/// A function call.
Call { callee: String, args: Vec<Expr> },
}
}
Statements
Statements do not produce a value. If and While are the structured
statement forms.
#![allow(unused)]
fn main() {
/// A statement (no direct value).
#[derive(Debug, Clone, PartialEq)]
pub enum Stmt {
/// Variable declaration: `var name;` or `var name = expr;`
VarDecl { name: String, init: Option<Expr> },
/// Assignment to an existing variable: `name = expr;`
Assign { name: String, value: Expr },
/// Return statement: `return expr;`
Return(Expr),
/// While loop: `while cond { body }`.
While { cond: Expr, body: Vec<Stmt> },
/// Conditional statement: `if cond { then_body } else { else_body }`.
If {
cond: Expr,
then_body: Vec<Stmt>,
else_body: Vec<Stmt>,
},
/// An expression used as a statement: `expr;`
Expr(Expr),
}
}
Functions
A function is just a name, a list of parameter names, and a list of statements.
#![allow(unused)]
fn main() {
/// A top-level function definition.
#[derive(Debug, Clone, PartialEq)]
pub struct Function {
pub name: String,
pub params: Vec<String>,
pub body: Vec<Stmt>,
}
}
Note:
- The AST intentionally does not include types, all values are implicitly 64-bit integers.
ExprandStmtare recursiveBinOpandCallcontain nested expression nodes,IfandWhilestatements recursively contain statement blocks.
- This shapes how the parser must be structured (see below).
Lexical building blocks
Before we dive into the small parsers, here is how to read their signature:
#![allow(unused)]
fn main() {
fn my_parser<Input>() -> impl Parser<Input, Output = T> where Input: Stream<Token = char>, ...
}
Here, Input is the input stream type (here, a stream of chars), Output = T
is the AST/token value the parser produces, and impl Parser<...> means
“some concrete parser type” chosen by combine and inferred by Rust. The long
where clause is mostly plumbing so the parser works with any compatible
character stream; for learning, the key part is just:
Calling
my_parserreturns a parser that parses characters and returns aTon success.
Skipping Whitespaces
combine provides the spaces() parser, which matches zero or more whitespace characters.
We wrap it once so that every token parser can end with .skip(ws()) and consume trailing
spaces and newlines automatically.
#![allow(unused)]
fn main() {
fn ws<Input>() -> impl Parser<Input, Output = ()>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
spaces().map(|_| ())
}
}
Parsing Identifiers
An identifier starts with a letter or _, followed by any number of letters,
digits, or _. combine’s letter(), alpha_num(), and char('_') match
single characters; .or() tries alternatives; many() collects zero or more
into a String. The first character and the rest are sequenced with a tuple
and joined by .map().
#![allow(unused)]
fn main() {
fn ident_<Input>() -> impl Parser<Input, Output = String>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(letter().or(char('_')), many(alpha_num().or(char('_'))))
.map(|(first, rest): (char, String)| {
let mut s = String::new();
s.push(first);
s.push_str(&rest);
s
})
.skip(ws())
}
}
Keywords
A plain string("if") would match the prefix of iffy, leaving fy in the
stream — wrong. not_followed_by(alpha_num().or(char('_'))) asserts that the
matched text is not followed by an identifier character. attempt() wraps
the whole thing so that on failure the cursor is rewound; without it, a partial
match would leave the stream half-consumed.
#![allow(unused)]
fn main() {
fn keyword<Input>(kw: &'static str) -> impl Parser<Input, Output = ()>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
attempt(string(kw).skip(not_followed_by(alpha_num().or(char('_')))))
.skip(ws())
.map(|_| ())
}
}
Parsing expressions
Expressions are parsed in layers from highest to lowest precedence:
primary -> mul_expr -> add_expr -> cmp_expr. Each layer calls the one
below it, so precedence emerges naturally.
Primary expressions
A primary is the highest-precedence building block. choice!() tries each
alternative in order and returns the first that succeeds.
A function call and a plain variable both start with an identifier, so the
call branch is wrapped in attempt() to allow backtracking when no ( follows.
between(tok('('), tok(')'), sep_by(...)) handles the argument list cleanly.
#![allow(unused)]
fn main() {
fn primary_<Input>() -> impl Parser<Input, Output = Expr>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
choice!(
integer_(),
// Function call must be tried before plain variable reference so that
// `foo(...)` doesn't parse as variable `foo` followed by junk.
attempt(
ident_()
.and(between(
tok('('),
tok(')'),
sep_by(
// Each argument is a full expression; trailing whitespace
// after the comma is already consumed by tok(',').
combine::parser(expr_fn::<Input>),
tok(','),
),
))
.map(|(callee, args)| Expr::Call { callee, args })
),
between(tok('('), tok(')'), combine::parser(expr_fn::<Input>)),
ident_().map(Expr::Variable)
)
}
}
Arithmetic expressions and operator precedence
mul_expr_() handles * (higher precedence) and add_expr_() handles +
and - on top of it. Both follow the same fold pattern:
- Parse the leftmost operand.
- Collect zero or more
(operator, operand)pairs withmany(). - Fold left-to-right into nested
Expr::BinOpnodes, so1 + 2 + 3becomesBinOp(Add, BinOp(Add, 1, 2), 3).
Multiplication is parsed in its own layer first:
#![allow(unused)]
fn main() {
fn mul_expr_<Input>() -> impl Parser<Input, Output = Expr>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
primary_(),
many::<Vec<_>, _, _>((tok('*').map(|_| BinOp::Mul)).and(primary_())),
)
.map(|(first, rest)| {
rest.into_iter().fold(first, |acc, (op, rhs)| Expr::BinOp {
op,
lhs: Box::new(acc),
rhs: Box::new(rhs),
})
})
}
}
and addition/subtraction on top of it:
#![allow(unused)]
fn main() {
fn add_expr_<Input>() -> impl Parser<Input, Output = Expr>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
mul_expr_(),
many::<Vec<_>, _, _>(
choice!(tok('+').map(|_| BinOp::Add), tok('-').map(|_| BinOp::Sub)).and(mul_expr_()),
),
)
.map(|(first, rest)| {
rest.into_iter().fold(first, |acc, (op, rhs)| Expr::BinOp {
op,
lhs: Box::new(acc),
rhs: Box::new(rhs),
})
})
}
}
Handling recursion in combine
The grammar is recursive: expr -> primary -> if expr or (expr).
combine’s impl Parser return type does not allow self-reference, so a named
function pointer breaks the type cycle:
#![allow(unused)]
fn main() {
fn expr_fn<Input>(input: &mut Input) -> StdParseResult<Expr, Input>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
cmp_expr_().parse_stream(input).into_result()
}
}
expr_fn calls cmp_expr_() specifically because cmp_expr is the top
expression layer in the grammar (expr := cmp_expr). That makes
expr_fn the single “parse any expression” entry point while still preserving
the full precedence stack (cmp on top of add on top of mul on top of
primary).
Because fn(&mut Input) -> StdParseResult<O, Input> implements Parser<Input>
in combine, expr_fn::<Input> can be used directly wherever a parser is
expected. The concrete function-pointer type is what breaks the infinite type
recursion that chained impl Parser returns would otherwise cause.
Parsing statements
Statements are what a function body contains. The while parser shows a
typical pattern: parse a keyword, parse an expression for the condition, then
parse a brace-delimited block of nested statements. The nested statements
require the same stmt_fn function-pointer trick used for expressions above.
#![allow(unused)]
fn main() {
fn while_stmt_<Input>() -> impl Parser<Input, Output = Stmt>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
keyword("while"),
combine::parser(expr_fn::<Input>),
between(tok('{'), tok('}'), many(combine::parser(stmt_fn::<Input>))),
)
.map(|(_, cond, body)| Stmt::While { cond, body })
}
}
The if statement parser follows the same block-structured pattern, with both
branches containing nested statements:
#![allow(unused)]
fn main() {
fn if_stmt_<Input>() -> impl Parser<Input, Output = Stmt>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
keyword("if"),
combine::parser(expr_fn::<Input>),
between(tok('{'), tok('}'), many(combine::parser(stmt_fn::<Input>))),
keyword("else"),
between(tok('{'), tok('}'), many(combine::parser(stmt_fn::<Input>))),
)
.map(|(_, cond, then_body, _, else_body)| Stmt::If {
cond,
then_body,
else_body,
})
}
}
The other statement kinds (var declaration, assignment, return,
expression-statement) follow the
same shape. They are all wired together in a single dispatcher:
#![allow(unused)]
fn main() {
fn stmt_<Input>() -> impl Parser<Input, Output = Stmt>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
choice!(
var_decl_stmt_(),
return_stmt_(),
while_stmt_(),
if_stmt_(),
attempt(assign_stmt_()),
expr_stmt_()
)
}
}
Parsing functions and the full program
A function definition is a def keyword followed by a name, a parenthesised
parameter list, and a block. sep_by(ident_(), tok(',')) collects the
comma-separated parameter names into a Vec<String>.
#![allow(unused)]
fn main() {
fn func_def_<Input>() -> impl Parser<Input, Output = Function>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
keyword("def"),
ident_(),
between(tok('('), tok(')'), sep_by(ident_(), tok(','))),
block_(),
)
.map(|(_, name, params, body)| Function { name, params, body })
}
}
A program is just zero or more function definitions followed by end-of-file.
ws().with(...) skips any leading whitespace before the first definition.
#![allow(unused)]
fn main() {
fn program_<Input>() -> impl Parser<Input, Output = Vec<Function>>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
ws().with(many(func_def_())).skip(eof())
}
}
parse_program is the public entry point that drives the parser and converts
combine’s error type into a plain String:
#![allow(unused)]
fn main() {
pub fn parse_program(src: &str) -> Result<Vec<Function>, String> {
program_()
.easy_parse(src)
.map(|(funcs, _rest)| funcs)
.map_err(|err| err.to_string())
}
}
Try it out
cargo test --example kaleidoscope -- test_ast_fibonacci --show-output
cargo test --example kaleidoscope -- test_ast_factorial --show-output
Next step
Chapter 2 defines the Kaleidoscope dialect operation set in a reusable module and demonstrates constructing those ops.
Chapter 2: The Kaleidoscope Dialect
In this chapter, we’ll learn some of pliron’s core IR concepts
by defining a dialect for the Kaleidoscope language. As new IR
concepts are introduced, you may find it useful to refer to the
pliron documentation
for details and examples.
The implementation for this chapter lives in examples/kaleidoscope/dialect.rs.
What is a Dialect?
pliron is an extensible compiler IR framework. This means that it does not
restrict you to a fixed set of operations (or instructions) or types.
Instead, you can define your own operations and types that capture the
semantics of your source language.
A dialect is merely a grouping of operations, types, and attributes that define the IR for a particular domain, language or purpose. It serves as the foundation for representing and manipulating programs in that domain.
IR Entities / Structure
Every IR entity (such as an operation or type) is owned by a Context.
Almost every method in the framework requires a Context argument to
either look up existing entities or create new ones.
An Operation is a basic unit of execution. It has operands (or input values),
results (or output values), attributes (or metadata), and may own regions.
A sequential list of operations forms a BasicBlock. At the end of a block,
a terminator operation (like ReturnOp or BranchOp) indicates the end
of the sequence and a possible transfer of control. Basic-blocks can have
one or more arguments.
Operation results and BasicBlock arguments are
SSA values.
BasicBlock arguments are an alternative (but equivalent and more convenient)
form of SSA ϕ-nodes.
The control-flow-graph (i.e., a directed graph of BasicBlocks) is contained
within a Region. The Region itself is owned by a parent operation.
For example, an IfOp owns two regions: one for the “then” block and one for
the “else” block.
These three entities (Operation, BasicBlock, Region) form the core structural IR
elements. They are the building blocks for representing programs in pliron.
All of these entities are owned by a Context, and are handled in the IR
through a Ptr (e.g., Ptr<Operation>, Ptr<Region>, etc.). A Ptr
can be dereferenced to access the underlying entity using the deref
and deref_mut methods, which return the underlying Ref or RefMut
of the entity. See interior mutability.
Types and Attributes
Every value (BasicBlock operand or Operation result) has a type.
Types are first-class IR entities that can be defined by the user.
For example, IntegerType in the builtin dialect represents integers.
Dialects can define new types as needed.
Attributes allow attaching arbitrary metadata to operations. For example,
in a ConstantOp, the constant value is stored as an attribute (e.g., IntegerAttr).
Ops
Operations are the core IR entities that represent instructions or statements in the program.
Ops are thin wrappers around the underlying Operation struct, providing “opcode”
specific APIs and invariants. For example, a BinOp represents a binary operation (like addition)
and has specific attributes to indicate the kind of binary operation (e.g., BinOpKind::Add).
You can imagine an Op to look like:
struct MyOp {
op: Ptr<Operation>
}
impl MyOp {
...
}
Defining The Kaleidoscope Dialect
Let’s dive in now and see how we can define a ConstantOp.
ConstantOp: A Simple Example
The constant operation represents a literal integer value in the IR. It has no operands and one result (the constant value). The constant value is stored as an attribute of the operation.
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.constant",
format = "attr($value, $IntegerAttr) ` : ` type($0)",
interfaces = [NOpdsInterface<0>, OneResultInterface, NResultsInterface<1>],
attributes = (value: IntegerAttr),
verifier = "succ",
)]
pub struct ConstantOp;
}
We define ConstantOp as simply an empty struct, annotated with the pliron_op
macro. In addition to adding the op: Ptr<Operation> field (that we saw above),
this macro fills up boilerplate code required for implementing pliron’s
Op trait.
The pliron_op macro has one mandatory argument: A string specifying the dialect
and opcode name (e.g., "kaleidoscope.constant").
We then specify, using the attributes field in the macro, that this operation has
one attribute named value of type IntegerAttr.
In this example, we’re also specifying the format for the Op. This defines
the syntax for the operation when printed as IR. The full specification for the format
syntax string is available in the docs. In this case, the format string says that
the attribute (named value) containing the constant value (as an IntegerAttr)
should be printed first, followed by the result type (after a colon).
Defining a syntax amounts to automatically deriving the Printable and Parsable
traits for the operation, which are used by pliron’s IR printer and parser respectively.
The format field can have its string argument skipped, in which case a canonical
(default) syntax is used.
Next, we have the interfaces field in the macro. Interfaces are Rust
traits that are annotated with the #[op_interface] macro. Interfaces allow
capturing common behaviour and invariants across multiple operations.
In this case, NOpdsInterface<0> says that ConstantOp has zero operands,
and OneResultInterface says that it has exactly one result.
And finally we have the verifier field, which specifies the verifier function for
this operation. The verifier is responsible for enforcing invariants not captured
by the interfaces that this Op implements. In this case, we can use the built-in succ
verifier, which simply returns Ok(()) to indicate that the operation is always valid.
For more complex invariants, you can write a custom verifier function by implementing
the Verify trait for the Op.
Note: We have not defined IntegerAttr in this dialect. We are using the IntegerAttr
defined in the builtin dialect. This is an important point: The IR is not restricted
to using only the operations, types, and attributes defined in a single dialect. You can
mix and match entities from different dialects as needed.
DeclOp: Variable Declarations
A DeclOp returns a pointer-typed result while storing source-level variable type as a TypeAttr.
This represents local allocation of variables. (Analogous to LLVM’s AllocaInst)
The PointerType is taken from pliron’s LLVM dialect.
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.decl",
format = "attr($var_type, $TypeAttr) ` : ` type($0)",
interfaces = [
NOpdsInterface<0>,
OneResultInterface,
NResultsInterface<1>,
ResultNOfType<0, PointerType>
],
attributes = (var_type: TypeAttr),
verifier = "succ",
)]
pub struct DeclOp;
}
BinOp: Binary Operations
BinOp has two operands (the left-hand side and right-hand side of the binary operation)
and one result (the computed value). The specific binary operation (e.g., add, subtract,
multiply, divide) is captured as an attribute (BinOpKind).
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.binop",
format = "$0 ` `attr($kind, $BinOpKind) ` ` $1 ` : ` type($0)",
interfaces = [
AtLeastNOpdsInterface<1>,
AtLeastNResultsInterface<1>,
NOpdsInterface<2>,
OneResultInterface,
NResultsInterface<1>,
SameOperandsType,
SameResultsType,
SameOperandsAndResultType
],
attributes = (kind: BinOpKind),
verifier = "succ"
)]
pub struct BinOp;
}
Here we show how one can define convenience methods on the typed wrapper (Op):
#![allow(unused)]
fn main() {
impl BinOp {
/// Creates a new `BinOp` of the specified kind with the given operands.
pub fn new(ctx: &mut Context, kind: BinOpKind, lhs: Value, rhs: Value) -> Self {
let result_ty = lhs.get_type(ctx);
let op = Operation::new(
ctx,
Self::get_concrete_op_info(),
vec![result_ty],
vec![lhs, rhs],
vec![],
0,
);
let op = BinOp { op };
op.set_attr_kind(ctx, kind);
op
}
/// Returns the `BinOpKind` of this `BinOp`.
pub fn kind(&self, ctx: &Context) -> BinOpKind {
*self
.get_attr_kind(ctx)
.expect("BinOp must carry kind attribute")
}
/// Returns the left-hand side operand of this `BinOp`.
pub fn lhs(&self, ctx: &Context) -> Value {
self.get_operation().deref(ctx).get_operand(0)
}
/// Returns the right-hand side operand of this `BinOp`.
pub fn rhs(&self, ctx: &Context) -> Value {
self.get_operation().deref(ctx).get_operand(1)
}
}
}
CallOp
The CallOp represents a function call. It has a variable number of operands (the arguments to the function) and one result (the return value of the function). The callee function is captured as an attribute (an IdentifierAttr).
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.call",
format = "`@`attr($callee, $IdentifierAttr) `(` operands(CharSpace(`,`)) `)` : type($0)",
interfaces = [NResultsInterface<1>],
attributes = (callee: IdentifierAttr),
verifier = "succ",
)]
pub struct CallOp;
}
Regions and Blocks
Structured control flow is represented through regions and blocks:
IfOp
The IfOp has two regions: one for the “then” block and one for the “else” block.
Each region here contains a single block (captured by the SingleBlockRegionInterface),
which in turn contains a sequence of operations.
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.if",
format = "$0 ` then ` region($0) ` else ` region($1)",
interfaces = [
NOpdsInterface<1>,
NResultsInterface<0>,
NRegionsInterface<2>,
SingleBlockRegionInterface
],
verifier = "succ",
)]
pub struct IfOp;
}
WhileOp
The WhileOp has a single region capturing the loop body. The loop condition is represented
as an operand of the op. Here we define the semantics of the condition to be a pointer
(to an integer value), captured by the OperandNOfType<0, PointerType> interface specification,
where a non-zero value (of the pointee) is considered true and zero is false.
The operand being a pointer allows the loop condition to be updated within the loop body.
Similar to DeclOp, the pointer type is taken from pliron’s LLVM dialect.
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.while",
format = "`*`$0 ` do ` region($0)",
interfaces = [
OperandNOfType<0, PointerType>,
NResultsInterface<0>,
NRegionsInterface<1>,
SingleBlockRegionInterface
],
verifier = "succ",
)]
pub struct WhileOp;
}
YieldOp
The YieldOp serves as a terminator for the body region of WhileOp and IfOp.
It has no operands or results, and simply indicates the end of the structured region.
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.yield",
format = "",
interfaces = [IsTerminatorInterface, NResultsInterface<0>, NOpdsInterface<0>],
verifier = "succ",
)]
pub struct YieldOp;
}
ReturnOp
The ReturnOp serves as a terminator from a function body region.
It has one operand, which is the value being returned from the function.
To illustrate the use of a custom verifier, ReturnOp implements the
Verify trait to enforce the invariant that it must have exactly one operand
(this could also have been enforced declaratively using NOpdsInterface<1>).
#![allow(unused)]
fn main() {
#[pliron_op(
name = "kaleidoscope.return",
format = "$0",
interfaces = [IsTerminatorInterface],
)]
pub struct ReturnOp;
}
The custom verifier:
#![allow(unused)]
fn main() {
impl Verify for ReturnOp {
fn verify(&self, ctx: &Context) -> Result<()>
where
Self: Sized,
{
let n_operands = self.get_operation().deref(ctx).get_num_operands();
if n_operands != 1 {
return verify_err!(
self.loc(ctx),
"kaleidoscope.return expects exactly 1 operand, found {}",
n_operands
);
}
Ok(())
}
}
}
Construction Example
In this section, we manually construct IR, using pliron APIs and utilities for a specific simple Fibonacci function. This is to illustrate how the IR is built up from the basic entities (operations, blocks, regions) and how the dialect ops are used in practice. The next chapter shows how to do this systematically by translating (lowering) the AST into this IR.
Pseudocode in Kaleidoscope for the function being generated:
#![allow(unused)]
fn main() {
// Kaleidoscope pseudocode for the generated function:
//
// def main() {
// var a = 0;
// var b = 1;
// var i = 0;
// var n = 10;
//
// while i < n {
// var tmp = a + b;
// a = b;
// b = tmp;
// i = i + 1;
// }
//
// return b;
// }
}
Rather than reading the entire construction test as one block, it is easier to follow it in the same order as the IR is assembled.
1. Set up the context, module, and entry block
First we create the Context, define the function type, create a module and a
main function, and position an inserter at the end of the entry block.
#![allow(unused)]
fn main() {
let ctx = &mut Context::new();
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
let module = ModuleOp::new(ctx, "fib_test".try_into().expect("valid module name"));
let main_ty = FunctionType::get(ctx, vec![], vec![i64_ty.into()]);
let main_fn = FuncOp::new(
ctx,
"main".try_into().expect("valid function name"),
main_ty,
);
module.append_operation(ctx, main_fn.get_operation(), 0);
let entry = main_fn.get_entry_block(ctx);
let mut ins = OpInserter::new_at_block_end(entry);
}
At this point we have the outer container structure in place, but the function body is still empty.
2. Allocate mutable slots for the variables
The Fibonacci implementation uses mutable local state (a, b, tmp, i,
n, and the loop-condition slot), so the next step is to create DeclOps for
each one.
#![allow(unused)]
fn main() {
// Mutable slots: a, b, tmp, i, and the iteration limit n.
let slot_a = DeclOp::new(ctx, i64_ty.into());
set_operation_result_name(ctx, slot_a.get_operation(), 0, "a".try_into().ok());
ins.append_op(ctx, &slot_a);
let slot_b = DeclOp::new(ctx, i64_ty.into());
set_operation_result_name(ctx, slot_b.get_operation(), 0, "b".try_into().ok());
ins.append_op(ctx, &slot_b);
let slot_tmp = DeclOp::new(ctx, i64_ty.into());
set_operation_result_name(ctx, slot_tmp.get_operation(), 0, "tmp".try_into().ok());
ins.append_op(ctx, &slot_tmp);
let slot_i = DeclOp::new(ctx, i64_ty.into());
set_operation_result_name(ctx, slot_i.get_operation(), 0, "i".try_into().ok());
ins.append_op(ctx, &slot_i);
let slot_n = DeclOp::new(ctx, i64_ty.into());
set_operation_result_name(ctx, slot_n.get_operation(), 0, "n".try_into().ok());
ins.append_op(ctx, &slot_n);
// A mutable slot for the loop condition (while cond_ptr do ...).
let slot_cond_ptr = DeclOp::new(ctx, i64_ty.into());
set_operation_result_name(
ctx,
slot_cond_ptr.get_operation(),
0,
"cond_ptr".try_into().ok(),
);
ins.append_op(ctx, &slot_cond_ptr);
}
Notice that we also attach result names such as a and b. These names are
not required for correctness, but they make the printed IR much easier to read.
3. Initialize the state and create the loop shell
Next we materialize the constants, store the initial values into the mutable
slots, compute the initial loop condition i < n, and build the WhileOp
itself.
#![allow(unused)]
fn main() {
// Initialize: a=0, b=1, i=0, n=10.
let c0 = ConstantOp::new_i64(ctx, 0);
ins.append_op(ctx, &c0);
let c1 = ConstantOp::new_i64(ctx, 1);
ins.append_op(ctx, &c1);
let c10 = ConstantOp::new_i64(ctx, 10);
ins.append_op(ctx, &c10);
let store_a0 = StoreOp::new(ctx, slot_a.get_result(ctx), c0.get_result(ctx));
ins.append_op(ctx, &store_a0);
let store_b1 = StoreOp::new(ctx, slot_b.get_result(ctx), c1.get_result(ctx));
ins.append_op(ctx, &store_b1);
let store_i0 = StoreOp::new(ctx, slot_i.get_result(ctx), c0.get_result(ctx));
ins.append_op(ctx, &store_i0);
let store_n10 = StoreOp::new(ctx, slot_n.get_result(ctx), c10.get_result(ctx));
ins.append_op(ctx, &store_n10);
// Loop condition i < n.
let i_before = LoadOp::new(ctx, slot_i.get_result(ctx), i64_ty.into());
ins.append_op(ctx, &i_before);
let n_before = LoadOp::new(ctx, slot_n.get_result(ctx), i64_ty.into());
ins.append_op(ctx, &n_before);
let cond = BinOp::new(
ctx,
BinOpKind::Lt,
i_before.get_result(ctx),
n_before.get_result(ctx),
);
ins.append_op(ctx, &cond);
let store_cond_ptr = StoreOp::new(ctx, slot_cond_ptr.get_result(ctx), cond.get_result(ctx));
ins.append_op(ctx, &store_cond_ptr);
let while_op = WhileOp::new(ctx, slot_cond_ptr.get_result(ctx));
ins.append_op(ctx, &while_op);
}
The important detail here is that WhileOp takes a pointer to the condition
slot, not the condition value directly. That lets the loop body recompute and
update the condition on every iteration.
4. Fill in the loop body region
Once the WhileOp exists, we create its body block and populate it with the
actual Fibonacci update logic.
#![allow(unused)]
fn main() {
let while_region = while_op.body_region(ctx);
let while_block = BasicBlock::new(
ctx,
Some("while_body".try_into().expect("valid block name")),
vec![],
);
while_block.insert_at_front(while_region, ctx);
let mut while_ins = OpInserter::new_at_block_end(while_block);
// tmp = a + b; a = b; b = tmp; i = i + 1
let a_val = LoadOp::new(ctx, slot_a.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &a_val);
let b_val = LoadOp::new(ctx, slot_b.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &b_val);
let sum = BinOp::new(
ctx,
BinOpKind::Add,
a_val.get_result(ctx),
b_val.get_result(ctx),
);
while_ins.append_op(ctx, &sum);
let store_tmp = StoreOp::new(ctx, slot_tmp.get_result(ctx), sum.get_result(ctx));
while_ins.append_op(ctx, &store_tmp);
let b_for_a = LoadOp::new(ctx, slot_b.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &b_for_a);
let store_a = StoreOp::new(ctx, slot_a.get_result(ctx), b_for_a.get_result(ctx));
while_ins.append_op(ctx, &store_a);
let tmp_for_b = LoadOp::new(ctx, slot_tmp.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &tmp_for_b);
let store_b = StoreOp::new(ctx, slot_b.get_result(ctx), tmp_for_b.get_result(ctx));
while_ins.append_op(ctx, &store_b);
let i_val = LoadOp::new(ctx, slot_i.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &i_val);
let one_step = ConstantOp::new_i64(ctx, 1);
while_ins.append_op(ctx, &one_step);
let i_next = BinOp::new(
ctx,
BinOpKind::Add,
i_val.get_result(ctx),
one_step.get_result(ctx),
);
while_ins.append_op(ctx, &i_next);
let store_i = StoreOp::new(ctx, slot_i.get_result(ctx), i_next.get_result(ctx));
while_ins.append_op(ctx, &store_i);
// i is updated, now do the comparison again
let i_inc = LoadOp::new(ctx, slot_i.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &i_inc);
let n_for_cond = LoadOp::new(ctx, slot_n.get_result(ctx), i64_ty.into());
while_ins.append_op(ctx, &n_for_cond);
let cond_next = BinOp::new(
ctx,
BinOpKind::Lt,
i_inc.get_result(ctx),
n_for_cond.get_result(ctx),
);
while_ins.append_op(ctx, &cond_next);
let store_cond = StoreOp::new(
ctx,
slot_cond_ptr.get_result(ctx),
cond_next.get_result(ctx),
);
while_ins.append_op(ctx, &store_cond);
let while_yield = YieldOp::new(ctx);
while_ins.append_op(ctx, &while_yield);
}
This block mirrors the source program closely: compute tmp = a + b, rotate
a and b, increment i, recompute the loop condition, then end the region
with YieldOp.
5. Return the result and verify the IR
After the loop, the function loads the final value of b, returns it, and runs
verification on the constructed module.
#![allow(unused)]
fn main() {
let fib_result = LoadOp::new(ctx, slot_b.get_result(ctx), i64_ty.into());
ins.append_op(ctx, &fib_result);
let ret = ReturnOp::new(ctx, fib_result.get_result(ctx));
ins.append_op(ctx, &ret);
verify_op(&module, ctx).expect("constructed fibonacci IR should verify");
println!("{}", module.get_operation().disp(ctx));
}
This last step is worth keeping in mind whenever you construct IR manually: verification is the cheapest way to catch structural mistakes early.
Try it out
cargo test --example kaleidoscope -- build_fib_example --show-output
This prints Kaleidoscope IR for the Fibonacci function shown above. We suggest studying the test and the printed IR together to understand how the dialect ops are used to construct the IR.
Exercise: Try adding a test to build the factorial function similarly and print its IR.
Next step
In this chapter we manually built the IR for a specific Kaleidoscope program. Chapter 3 shows how to lower the AST into this IR, which means we can parse any Kaleidoscope program into an AST and then lower it into IR.
Chapter 3: Lowering the AST to IR
In Chapter 2 we hand-built IR for a specific Fibonacci function. This chapter shows how to do that systematically for any Kaleidoscope program by lowering the AST produced in Chapter 1 into the dialect ops defined in Chapter 2.
The implementation for this chapter lives in examples/kaleidoscope/from_ast.rs
Design
Every Kaleidoscope value is a 64-bit signless integer (i64). The key
design decisions are:
- All variables are memory-backed. Every local variable and every function
parameter is backed by a
DeclOpslot (analogous to LLVM’salloca). Reads becomeLoadOps; writes becomeStoreOps. This avoids having to worry about SSA form. - Control flow uses regions.
IfOpowns two regions (then / else) andWhileOpowns one region (the loop body). Each region gets its own newBasicBlock. WhileOpholds a condition pointer. Rather than threading a boolean value as an argument, the condition is stored in a dedicatedDeclOpslot in the outer block and updated at the end of each loop iteration.
Entry point
The public API is a single function. It takes an AST Function node and
produces a FuncOp inside the provided Context:
#![allow(unused)]
fn main() {
pub fn lower_function(ctx: &mut Context, func: &Function) -> Result<FuncOp> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
// Build the function type: all params are i64, single i64 return.
let param_tys: Vec<_> = func.params.iter().map(|_| i64_ty.into()).collect();
let func_ty = FunctionType::get(ctx, param_tys, vec![i64_ty.into()]);
let func_op = FuncOp::new(
ctx,
func.name
.as_str()
.try_into()
.expect("invalid function name"),
func_ty,
);
let entry = func_op.get_entry_block(ctx);
let mut ins = OpInserter::new_at_block_end(entry);
let mut var_map = VarMap::default();
// Spill each function parameter (block argument) into a mutable DeclOp slot.
for (idx, param_name) in func.params.iter().enumerate() {
let param_val = entry.deref(ctx).get_argument(idx);
let slot = DeclOp::new(ctx, i64_ty.into());
let slot_val = slot.get_result(ctx);
ins.append_op(ctx, &slot);
let store = StoreOp::new(ctx, slot_val, param_val);
ins.append_op(ctx, &store);
var_map.insert(param_name.clone(), slot_val);
}
lower_stmts(ctx, &mut ins, &mut var_map, &func.body)?;
// If no terminator was emitted (e.g., function ends with an `if` where both
// branches return), add a fallback return of 0 to satisfy the verifier.
if entry.deref(ctx).get_terminator(ctx).is_none() {
let zero = ConstantOp::new_i64(ctx, 0);
let zero_val = zero.get_result(ctx);
ins.append_op(ctx, &zero);
let ret = ReturnOp::new(ctx, zero_val);
ins.append_op(ctx, &ret);
}
Ok(func_op)
}
}
Two type aliases keep the rest of the code concise:
#![allow(unused)]
fn main() {
/// Inserter type used throughout this module.
type OpInserter = IRInserter<DummyListener>;
/// Maps variable names to the slot-pointer [`Value`] produced by their [`DeclOp`].
type VarMap = FxHashMap<String, Value>;
}
OpInserter is an IRInserter with a no-op listener. It tracks a cursor
position inside a block and appends ops there via append_op. VarMap maps
source-level variable names to the Value (result of a DeclOp) that holds
the variable’s storage slot.
Creating the FuncOp
lower_function first creates an empty FuncOp with the right type, then
obtains the entry block and initialises the inserter at its end:
#![allow(unused)]
fn main() {
pub fn lower_function(ctx: &mut Context, func: &Function) -> Result<FuncOp> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
// Build the function type: all params are i64, single i64 return.
let param_tys: Vec<_> = func.params.iter().map(|_| i64_ty.into()).collect();
let func_ty = FunctionType::get(ctx, param_tys, vec![i64_ty.into()]);
let func_op = FuncOp::new(
ctx,
func.name
.as_str()
.try_into()
.expect("invalid function name"),
func_ty,
);
let entry = func_op.get_entry_block(ctx);
let mut ins = OpInserter::new_at_block_end(entry);
let mut var_map = VarMap::default();
// Spill each function parameter (block argument) into a mutable DeclOp slot.
for (idx, param_name) in func.params.iter().enumerate() {
let param_val = entry.deref(ctx).get_argument(idx);
let slot = DeclOp::new(ctx, i64_ty.into());
let slot_val = slot.get_result(ctx);
ins.append_op(ctx, &slot);
let store = StoreOp::new(ctx, slot_val, param_val);
ins.append_op(ctx, &store);
var_map.insert(param_name.clone(), slot_val);
}
lower_stmts(ctx, &mut ins, &mut var_map, &func.body)?;
// If no terminator was emitted (e.g., function ends with an `if` where both
// branches return), add a fallback return of 0 to satisfy the verifier.
if entry.deref(ctx).get_terminator(ctx).is_none() {
let zero = ConstantOp::new_i64(ctx, 0);
let zero_val = zero.get_result(ctx);
ins.append_op(ctx, &zero);
let ret = ReturnOp::new(ctx, zero_val);
ins.append_op(ctx, &ret);
}
Ok(func_op)
}
}
(Only the setup and fallback parts are discussed in detail below.)
Spilling parameters
Every function parameter arrives as a basic-block argument. To allow
mutation later, each argument is immediately spilled into a DeclOp slot:
#![allow(unused)]
fn main() {
// Spill each function parameter (block argument) into a mutable DeclOp slot.
for (idx, param_name) in func.params.iter().enumerate() {
let param_val = entry.deref(ctx).get_argument(idx);
let slot = DeclOp::new(ctx, i64_ty.into());
let slot_val = slot.get_result(ctx);
ins.append_op(ctx, &slot);
let store = StoreOp::new(ctx, slot_val, param_val);
ins.append_op(ctx, &store);
var_map.insert(param_name.clone(), slot_val);
}
}
entry.deref(ctx).get_argument(idx) returns the Value of the idx-th block
argument. DeclOp::new_with_type allocates a pointer-typed slot; StoreOp
writes the initial value into it.
Fallback terminator
After lowering the body, the entry block might still lack a terminator. This
can happen when the source function ends with an if statement where both
branches contain a return. In that case a fallback return 0 is appended:
#![allow(unused)]
fn main() {
// If no terminator was emitted (e.g., function ends with an `if` where both
// branches return), add a fallback return of 0 to satisfy the verifier.
if entry.deref(ctx).get_terminator(ctx).is_none() {
let zero = ConstantOp::new_i64(ctx, 0);
let zero_val = zero.get_result(ctx);
ins.append_op(ctx, &zero);
let ret = ReturnOp::new(ctx, zero_val);
ins.append_op(ctx, &ret);
}
}
Lowering statements
Statements are lowered by two helpers. lower_stmts iterates a slice and
delegates each item to lower_stmt, short-circuiting on the first error:
#![allow(unused)]
fn main() {
fn lower_stmts(
ctx: &mut Context,
ins: &mut OpInserter,
var_map: &mut VarMap,
stmts: &[Stmt],
) -> Result<bool> {
let mut terminated = false;
for stmt in stmts {
terminated = lower_stmt(ctx, ins, var_map, stmt)?;
}
Ok(terminated)
}
}
Both functions return Result<bool> where true means the last op emitted
was a block terminator (ReturnOp). This signal is used to suppress
redundant YieldOp terminators in if / while regions.
Variable declaration
var name; or var name = expr; allocates a slot, registers it in the
VarMap, and optionally stores an initial value:
#![allow(unused)]
fn main() {
Stmt::VarDecl { name, init } => {
let slot = DeclOp::new(ctx, i64_ty.into());
let slot_val = slot.get_result(ctx);
ins.append_op(ctx, &slot);
var_map.insert(name.clone(), slot_val);
if let Some(init_expr) = init {
let val = lower_expr(ctx, ins, var_map, init_expr)?;
let store = StoreOp::new(ctx, slot_val, val);
ins.append_op(ctx, &store);
}
Ok(false)
}
}
Assignment
name = expr; looks up the existing slot in VarMap and emits a StoreOp.
If the variable was never declared, input_error! returns a structured error
instead of panicking:
#![allow(unused)]
fn main() {
Stmt::Assign { name, value } => {
let val = lower_expr(ctx, ins, var_map, value)?;
let slot = *var_map.get(name.as_str()).ok_or_else(|| {
input_error!(
Location::Unknown,
"assignment to undeclared variable: {name}"
)
})?;
let store = StoreOp::new(ctx, slot, val);
ins.append_op(ctx, &store);
Ok(false)
}
}
Return
return expr; lowers the expression, emits ReturnOp, and signals that the
block is now terminated:
#![allow(unused)]
fn main() {
Stmt::Return(expr) => {
let val = lower_expr(ctx, ins, var_map, expr)?;
let ret = ReturnOp::new(ctx, val);
ins.append_op(ctx, &ret);
Ok(true) // ReturnOp is a block terminator
}
}
If statement
if cond { then } else { else } emits an IfOp, then builds two new blocks —
one for each region. Each branch is lowered recursively. A YieldOp is
appended only when the branch did not end with a return:
#![allow(unused)]
fn main() {
Stmt::If {
cond,
then_body,
else_body,
} => {
let cond_val = lower_expr(ctx, ins, var_map, cond)?;
let if_op = IfOp::new(ctx, cond_val);
ins.append_op(ctx, &if_op);
// Then region: add YieldOp only if the branch didn't terminate.
let then_block = BasicBlock::new(ctx, None, vec![]);
then_block.insert_at_front(if_op.then_region(ctx), ctx);
let mut then_ins = OpInserter::new_at_block_end(then_block);
let mut then_vars = var_map.clone();
let then_terminated = lower_stmts(ctx, &mut then_ins, &mut then_vars, then_body)?;
if !then_terminated {
let then_yield = YieldOp::new(ctx);
then_ins.append_op(ctx, &then_yield);
}
// Else region: add YieldOp only if the branch didn't terminate.
let else_block = BasicBlock::new(ctx, None, vec![]);
else_block.insert_at_front(if_op.else_region(ctx), ctx);
let mut else_ins = OpInserter::new_at_block_end(else_block);
let mut else_vars = var_map.clone();
let else_terminated = lower_stmts(ctx, &mut else_ins, &mut else_vars, else_body)?;
if !else_terminated {
let else_yield = YieldOp::new(ctx);
else_ins.append_op(ctx, &else_yield);
}
Ok(false) // IfOp itself is not a terminator in the outer block
}
}
Note that IfOp itself is not a block terminator in the outer block, so
lower_stmt_if returns Ok(false).
While loop
while cond { body } uses the memory-backed condition pattern described in
the design section above. Before entering the loop, the condition is evaluated
and stored in a fresh DeclOp slot. At the end of each iteration, the
condition is re-evaluated and the slot is updated. A YieldOp closes the
body region (if it doesn’t already end with a return):
#![allow(unused)]
fn main() {
Stmt::While { cond, body } => {
// Allocate the condition slot in the outer block.
let cond_slot = DeclOp::new(ctx, i64_ty.into());
let cond_slot_val = cond_slot.get_result(ctx);
ins.append_op(ctx, &cond_slot);
// Compute the initial condition and store it.
let init_cond = lower_expr(ctx, ins, var_map, cond)?;
let init_store = StoreOp::new(ctx, cond_slot_val, init_cond);
ins.append_op(ctx, &init_store);
let while_op = WhileOp::new(ctx, cond_slot_val);
ins.append_op(ctx, &while_op);
// Build the loop body.
let body_block = BasicBlock::new(ctx, None, vec![]);
body_block.insert_at_front(while_op.body_region(ctx), ctx);
let mut body_ins = OpInserter::new_at_block_end(body_block);
let mut body_vars = var_map.clone();
let body_terminated = lower_stmts(ctx, &mut body_ins, &mut body_vars, body)?;
if !body_terminated {
// Re-evaluate the condition at the end of the body and update the slot.
let next_cond = lower_expr(ctx, &mut body_ins, &body_vars, cond)?;
let next_store = StoreOp::new(ctx, cond_slot_val, next_cond);
body_ins.append_op(ctx, &next_store);
let body_yield = YieldOp::new(ctx);
body_ins.append_op(ctx, &body_yield);
}
Ok(false) // WhileOp itself is not a terminator in the outer block
}
}
Expression statement
A bare expr; lowers the expression for its side effects and discards the
resulting value:
#![allow(unused)]
fn main() {
Stmt::Expr(expr) => {
lower_expr(ctx, ins, var_map, expr)?;
Ok(false)
}
Lowering expressions
lower_expr matches on the Expr variant and returns the Value that holds
the computed result:
#![allow(unused)]
fn main() {
fn lower_expr(
ctx: &mut Context,
ins: &mut OpInserter,
var_map: &VarMap,
expr: &Expr,
) -> Result<Value> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
match expr {
// ── integer literal ───────────────────────────────────────────────
Expr::Integer(n) => {
let op = ConstantOp::new_i64(ctx, *n);
let val = op.get_result(ctx);
ins.append_op(ctx, &op);
Ok(val)
}
// ── variable reference ────────────────────────────────────────────
Expr::Variable(name) => {
let slot = *var_map.get(name.as_str()).ok_or_else(|| {
input_error!(
Location::Unknown,
"reference to undeclared variable: {name}"
)
})?;
let load = LoadOp::new(ctx, slot, i64_ty.into());
let val = load.get_result(ctx);
ins.append_op(ctx, &load);
Ok(val)
}
// ── binary operation ──────────────────────────────────────────────
Expr::BinOp { op, lhs, rhs } => {
let lhs_val = lower_expr(ctx, ins, var_map, lhs)?;
let rhs_val = lower_expr(ctx, ins, var_map, rhs)?;
let kind = ast_binop_to_kind(op);
let bin_op = BinOp::new(ctx, kind, lhs_val, rhs_val);
let val = bin_op.get_result(ctx);
ins.append_op(ctx, &bin_op);
Ok(val)
}
// ── function call ─────────────────────────────────────────────────
Expr::Call { callee, args } => {
let mut arg_vals = Vec::with_capacity(args.len());
for a in args {
arg_vals.push(lower_expr(ctx, ins, var_map, a)?);
}
let callee_id = callee.as_str().try_into().expect("valid callee identifier");
let call_op = CallOp::new(ctx, IdentifierAttr::new(callee_id), arg_vals, i64_ty.into());
let val = call_op.get_operation().deref(ctx).get_result(0);
let val = { val }; // reborrow to release ctx ref before append
ins.append_op(ctx, &call_op);
Ok(val)
}
}
}
Integer literal
A literal is wrapped in a ConstantOp. The result value is returned:
#![allow(unused)]
fn main() {
Expr::Integer(n) => {
let op = ConstantOp::new_i64(ctx, *n);
let val = op.get_result(ctx);
ins.append_op(ctx, &op);
Ok(val)
}
}
Variable reference
A variable reference looks up the slot in VarMap and emits a LoadOp to
read its current value. An undeclared variable is reported as an
InvalidInput error using the input_error! macro:
#![allow(unused)]
fn main() {
Expr::Variable(name) => {
let slot = *var_map.get(name.as_str()).ok_or_else(|| {
input_error!(
Location::Unknown,
"reference to undeclared variable: {name}"
)
})?;
let load = LoadOp::new(ctx, slot, i64_ty.into());
let val = load.get_result(ctx);
ins.append_op(ctx, &load);
Ok(val)
}
}
Binary operation
Both operands are lowered recursively, then a BinOp is created with the
appropriate BinOpKind attribute:
#![allow(unused)]
fn main() {
Expr::BinOp { op, lhs, rhs } => {
let lhs_val = lower_expr(ctx, ins, var_map, lhs)?;
let rhs_val = lower_expr(ctx, ins, var_map, rhs)?;
let kind = ast_binop_to_kind(op);
let bin_op = BinOp::new(ctx, kind, lhs_val, rhs_val);
let val = bin_op.get_result(ctx);
ins.append_op(ctx, &bin_op);
Ok(val)
}
}
Function call
Arguments are lowered in order and collected into a Vec. A CallOp is then
created with the callee name as an IdentifierAttr:
#![allow(unused)]
fn main() {
Expr::Call { callee, args } => {
let mut arg_vals = Vec::with_capacity(args.len());
for a in args {
arg_vals.push(lower_expr(ctx, ins, var_map, a)?);
}
let callee_id = callee.as_str().try_into().expect("valid callee identifier");
let call_op = CallOp::new(ctx, IdentifierAttr::new(callee_id), arg_vals, i64_ty.into());
let val = call_op.get_operation().deref(ctx).get_result(0);
let val = { val }; // reborrow to release ctx ref before append
ins.append_op(ctx, &call_op);
Ok(val)
}
Note the reborrow trick: call_op.get_operation().deref(ctx) borrows ctx
immutably, so the result val must be captured before passing call_op to
append_op, which borrows ctx mutably.
Note: The general guideline around calling deref or deref_mut on Ptr<T> is to
keep the borrowed value (Ref<T> or RefMut<T>) around for as little time as possible,
and to avoid passing it to any function that might call back into the context.
Error handling
All lowering functions return pliron::result::Result<T>. Errors are
propagated with ?. The input_error! macro creates a
pliron::result::Error with kind ErrorKind::InvalidInput and an optional
source location. Because the Kaleidoscope AST does not carry source positions,
Location::Unknown is used throughout.
Tests
The test module defines a helper that parses a source string, lowers every function into a fresh module, verifies the IR, and prints it:
#![allow(unused)]
fn main() {
fn lower_program(src: &str) -> String {
let funcs = parse_program(src).expect("parse error");
let ctx = &mut Context::new();
let module = ModuleOp::new(ctx, "test".try_into().expect("valid module name"));
for func in &funcs {
let func_op = lower_function(ctx, func).expect("lowering failed");
module.append_operation(ctx, func_op.get_operation(), 0);
}
verify_op(&module, ctx).expect("IR verification failed");
format!("{}", module.get_operation().disp(ctx))
}
}
Try it out
cargo test --example kaleidoscope -- fibonacci_from_ast --show-output
cargo test --example kaleidoscope -- factorial_from_ast --show-output
cargo test --example kaleidoscope -- inline_fibonacci_from_ast --show-output
Each test prints the lowered IR so you can inspect how the AST maps to ops.
Next step
Chapter 4 lowers the Kaleidoscope dialect into a lower-level LLVM dialect.
Chapter 4: Lowering to the LLVM Dialect
In Chapter 3 we produced IR in the Kaleidoscope dialect — operations like
kaleidoscope.if, kaleidoscope.binop, and kaleidoscope.while that still
carry high-level, structured semantics. This chapter converts that IR into
the LLVM dialect, where all control flow is flat (basic blocks + branches) and
every op corresponds closely to an LLVM instruction.
The implementation for this chapter lives in examples/kaleidoscope/to_llvm.rs.
Design: Dialect Conversion
pliron ships a dialect conversion framework in
pliron::irbuild::dialect_conversion. It is deliberately simpler than
MLIR’s equivalent:
- No unrealized conversion casts. Types that do not need conversion are kept as-is.
- Definitions before uses. The framework guarantees that when an op’s
rewritecallback fires, all operands have already been converted. - Each op rewrites itself. Rather than a monolithic pattern-match switch,
each op implements (in this case) the
ToLLVMDialectinterface frompliron_llvm.
The three moving parts are:
| Piece | Responsibility |
|---|---|
DialectConversion trait | Decides which ops to convert and calls the per-op rewrite |
DialectConversionRewriter | Wraps IRRewriter and records all mutations |
apply_dialect_conversion | Drives the worklist, ensures def-before-use order |
The DialectConversion trait
#![allow(unused)]
fn main() {
pub trait DialectConversion {
fn can_convert_op(&self, ctx: &Context, op: Ptr<Operation>) -> bool;
fn rewrite(
&mut self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
op: Ptr<Operation>,
operands_info: &OperandsInfo,
) -> Result<()>;
// optional: can_convert_type, convert_type
}
}
can_convert_op is the filter. rewrite is called for every op that
passes the filter, with the insertion point already positioned before the op.
OperandsInfo
The operands_info parameter gives each rewrite access to the history of
type changes its operands went through during the conversion pass. For the
Kaleidoscope lowering we do not convert types (everything stays i64), so
this is left unused in most rewrites.
apply_dialect_conversion
#![allow(unused)]
fn main() {
pub fn apply_dialect_conversion<C: DialectConversion>(
ctx: &mut Context,
conversion: &mut C,
op: Ptr<Operation>,
) -> Result<()>
}
The algorithm walks the IR tree rooted at op, collecting convertible ops into a worklist.
It then repeatedly pops from the worklist, and ensures to process operand-defining ops first.
After each rewrite, recorded mutations are inspected: erased ops are dropped from the worklist,
newly inserted ops are added (if required), and block-argument types are updated if any successor
references changed.
The conversion driver: KalToLLVM
Our driver is a zero-field struct that implements DialectConversion:
#![allow(unused)]
fn main() {
pub struct KalToLLVM;
impl DialectConversion for KalToLLVM {
fn can_convert_op(&self, ctx: &Context, op: Ptr<Operation>) -> bool {
op_impls::<dyn ToLLVMDialect>(&*Operation::get_op_dyn(op, ctx))
|| Operation::get_op::<builtin::ops::FuncOp>(op, ctx).is_some()
}
fn rewrite(
&mut self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
op: Ptr<Operation>,
operands_info: &OperandsInfo,
) -> Result<()> {
if let Some(func_op) = Operation::get_op::<builtin::ops::FuncOp>(op, ctx) {
// Convert from builtin.func to llvm.func by updating the function type and argument types.
return lower_func_op_to_llvm(&func_op, ctx, rewriter);
}
let op_dyn = Operation::get_op_dyn(op, ctx);
let to_llvm_op = op_cast::<dyn ToLLVMDialect>(&*op_dyn)
.expect("Matched Op must implement ToLLVMDialect");
to_llvm_op.rewrite(ctx, rewriter, operands_info)
}
}
}
op_impls::<dyn ToLLVMDialect> checks whether the runtime type of the op
object implements the ToLLVMDialect interface. Most conversions go through
that path: op_cast downcasts dyn Op to dyn ToLLVMDialect and dispatches
to the op’s own rewrite method.
There is one explicit special case: builtin.func is matched and rewritten to
llvm.func in the driver itself. We do this because builtin.func is not a
Kaleidoscope op and does not implement ToLLVMDialect, but we still need to
convert function signatures and block-argument types before lowering function
bodies.
Entry point
#![allow(unused)]
fn main() {
/// Lower a [`ModuleOp`] containing Kaleidoscope dialect ops in place.
///
/// Uses the [`DialectConversion`] infrastructure: each Kaleidoscope op
/// implements [`ToLLVMDialect`] and knows how to lower itself to LLVM ops.
pub fn lower_module(ctx: &mut Context, module: ModuleOp) -> Result<IRStatus> {
apply_dialect_conversion(ctx, &mut KalToLLVM, module.get_operation())
}
}
The module is modified in place: builtin.module and builtin.func are
kept as-is because they do not implement ToLLVMDialect. Only the
Kaleidoscope ops inside the function bodies are converted.
Lowering simple ops
kaleidoscope.constant -> llvm.constant
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalConstantOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let val_attr = self.value_attr(ctx);
let llvm_const = LlvmConstantOp::new(ctx, Box::new(val_attr));
let new_result = llvm_const.get_result(ctx);
rewriter.insert_op(ctx, &llvm_const);
rewriter.replace_operation_with_values(ctx, self.get_operation(), vec![new_result]);
Ok(())
}
}
}
rewriter.insert_op inserts the new op at the current insertion point (before
the op being replaced). replace_operation_with_values replaces every use of
the original result with the new result and then erases the original op.
kaleidoscope.decl -> llvm.alloca
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalDeclOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let i32_ty = IntegerType::get(ctx, 32, Signedness::Signless);
let elem_ty = self.variable_type(ctx);
let size_attr = IntegerAttr::new(i32_ty, APInt::from_i32(1, bw(32)));
let size_const = LlvmConstantOp::new(ctx, Box::new(size_attr));
let size_val = size_const.get_result(ctx);
rewriter.insert_op(ctx, &size_const);
let alloca = AllocaOp::new(ctx, elem_ty, size_val);
let alloca_ptr = alloca.get_result(ctx);
rewriter.insert_op(ctx, &alloca);
rewriter.replace_operation_with_values(ctx, self.get_operation(), vec![alloca_ptr]);
Ok(())
}
}
}
DeclOp allocates a variable slot. The LLVM equivalent is alloca elem_type, i32 1. The alloca instruction requires an i32 count, so a separate
llvm.constant 1 : i32 is inserted first.
kaleidoscope.load -> llvm.load
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalLoadOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
operands_info: &OperandsInfo,
) -> Result<()> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
// `operands_info` carries the already-converted slot operand.
let slot = operands_info
.lookup_most_recent_type(self.slot(ctx))
.map_or(self.slot(ctx), |_| self.slot(ctx));
// Operand was already updated in-place by the framework.
let load = LlvmLoadOp::new(ctx, slot, i64_ty.into());
let result = load.get_result(ctx);
rewriter.insert_op(ctx, &load);
rewriter.replace_operation_with_values(ctx, self.get_operation(), vec![result]);
Ok(())
}
}
}
kaleidoscope.store -> llvm.store
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalStoreOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let slot = self.slot(ctx);
let value = self.stored_value(ctx);
let store = LlvmStoreOp::new(ctx, value, slot);
rewriter.insert_op(ctx, &store);
rewriter.erase_operation(ctx, self.get_operation());
Ok(())
}
}
}
StoreOp has no result, so we call erase_operation directly instead of
replace_operation_with_values.
kaleidoscope.binop -> llvm.add / sub / mul / icmp + sext
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for BinOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
let lhs = self.lhs(ctx);
let rhs = self.rhs(ctx);
let kind = self.kind(ctx);
let result = match kind {
BinOpKind::Add => {
let op = AddOp::new_with_overflow_flag(
ctx,
lhs,
rhs,
IntegerOverflowFlagsAttr::default(),
);
let r = op.get_result(ctx);
rewriter.insert_op(ctx, &op);
r
}
BinOpKind::Sub => {
let op = SubOp::new_with_overflow_flag(
ctx,
lhs,
rhs,
IntegerOverflowFlagsAttr::default(),
);
let r = op.get_result(ctx);
rewriter.insert_op(ctx, &op);
r
}
BinOpKind::Mul => {
let op = MulOp::new_with_overflow_flag(
ctx,
lhs,
rhs,
IntegerOverflowFlagsAttr::default(),
);
let r = op.get_result(ctx);
rewriter.insert_op(ctx, &op);
r
}
_ => {
// Comparison: ICmpOp yields i1; sign-extend to i64.
let pred = binop_kind_to_icmp_pred(kind);
let icmp = ICmpOp::new(ctx, pred, lhs, rhs);
let cmp_i1 = icmp.get_result(ctx);
rewriter.insert_op(ctx, &icmp);
let sext = SExtOp::new(ctx, cmp_i1, i64_ty.into());
let r = sext.get_result(ctx);
rewriter.insert_op(ctx, &sext);
r
}
};
rewriter.replace_operation_with_values(ctx, self.get_operation(), vec![result]);
Ok(())
}
}
}
Arithmetic ops are straightforward one-for-one translations. Comparison ops
are two-step: llvm.icmp produces an i1 (1-bit boolean), which is then
sign-extended to i64 by llvm.sext so the result type is consistent with
the rest of the Kaleidoscope value universe.
kaleidoscope.call -> llvm.call
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalCallOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
let callee_attr = self
.get_attr_callee(ctx)
.expect("CallOp must have callee attribute")
.clone();
let callee_ident = pliron::identifier::Identifier::from(callee_attr);
let n_args = self.get_operation().deref(ctx).get_num_operands();
let args: Vec<Value> = (0..n_args)
.map(|i| self.get_operation().deref(ctx).get_operand(i))
.collect();
let arg_types: Vec<Ptr<TypeObj>> = (0..n_args).map(|_| i64_ty.into()).collect();
let llvm_func_ty = FuncType::get(ctx, i64_ty.into(), arg_types, false);
let llvm_call = LlvmCallOp::new(
ctx,
CallOpCallable::Direct(callee_ident),
llvm_func_ty,
args,
);
let result = llvm_call.get_result(ctx);
rewriter.insert_op(ctx, &llvm_call);
rewriter.replace_operation_with_values(ctx, self.get_operation(), vec![result]);
Ok(())
}
}
}
llvm.call requires a FuncType at the call site. Because every
Kaleidoscope function takes and returns i64, the callee type is always
(i64, …) -> i64 regardless of which function is being called.
kaleidoscope.return -> llvm.return
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalReturnOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let val = self.value(ctx);
let ret = LlvmReturnOp::new(ctx, Some(val));
rewriter.insert_op(ctx, &ret);
rewriter.erase_operation(ctx, self.get_operation());
Ok(())
}
}
}
Lowering control flow
Control flow ops are the most interesting because they carry nested regions
that must be flattened into LLVM’s flat CFG. pliron’s
DialectConversionRewriter provides the necessary primitives:
| Method | Effect |
|---|---|
split_block(ctx, block, BeforeOperation(op)) | Moves ops from op onwards into a fresh successor block; returns the new block |
inline_region(ctx, region, AfterBlock(block)) | Moves all blocks from region into the parent function, inserting after block |
create_block(ctx, AfterBlock(b), label, arg_types) | Creates a new empty block and inserts it |
set_insertion_point(AtBlockEnd(b)) | Moves the insertion cursor to the end of b |
kaleidoscope.if -> conditional CFG
Before conversion:
^entry:
... (IfOp with then-region and else-region) ...
... (rest of the function) ...
After conversion:
^entry: ; everything up to IfOp + icmp + cond_br
^then_block: ; inlined from then-region, ends with br ^merge
^else_block: ; inlined from else-region, ends with br ^merge
^merge: ; rest of the function (from split_block)
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalIfOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
let cond = self.condition(ctx);
let then_region = self.then_region(ctx);
let else_region = self.else_region(ctx);
// The entry block of each region is the single block in the region.
let then_entry = then_region
.deref(ctx)
.get_head()
.expect("IfOp then_region must have a block");
let else_entry = else_region
.deref(ctx)
.get_head()
.expect("IfOp else_region must have a block");
let then_term = then_entry
.deref(ctx)
.get_terminator(ctx)
.expect("then block must have a terminator");
let else_term = else_entry
.deref(ctx)
.get_terminator(ctx)
.expect("else block must have a terminator");
// Convert the i64 condition to i1 via `icmp ne cond, 0`.
let zero_attr = IntegerAttr::new(i64_ty, APInt::from_i64(0, bw(64)));
let zero_const = LlvmConstantOp::new(ctx, Box::new(zero_attr));
let zero_val = zero_const.get_result(ctx);
rewriter.insert_op(ctx, &zero_const);
let cmp = ICmpOp::new(ctx, ICmpPredicateAttr::NE, cond, zero_val);
let cmp_i1 = cmp.get_result(ctx);
rewriter.insert_op(ctx, &cmp);
// Split the current block at the IfOp position to create the merge block.
let pre_if_block = self
.get_operation()
.deref(ctx)
.get_parent_block()
.expect("IfOp must be in a block");
let merge_block = rewriter.split_block(
ctx,
pre_if_block,
OpInsertionPoint::BeforeOperation(self.get_operation()),
Some("if_merge".try_into().unwrap()),
);
// Emit conditional branch in pre_if_block.
rewriter.set_insertion_point(OpInsertionPoint::AtBlockEnd(pre_if_block));
let cond_br = CondBrOp::new(ctx, cmp_i1, then_entry, vec![], else_entry, vec![]);
rewriter.insert_op(ctx, &cond_br);
// Replace YieldOp in then-branch with branch to merge.
if Operation::is_op::<KalYieldOp>(then_term, ctx) {
rewriter.set_insertion_point(OpInsertionPoint::BeforeOperation(then_term));
let then_br = BrOp::new(ctx, merge_block, vec![]);
rewriter.insert_op(ctx, &then_br);
rewriter.erase_operation(ctx, then_term);
}
// Replace YieldOp in else-branch with branch to merge.
if Operation::is_op::<KalYieldOp>(else_term, ctx) {
rewriter.set_insertion_point(OpInsertionPoint::BeforeOperation(else_term));
let else_br = BrOp::new(ctx, merge_block, vec![]);
rewriter.insert_op(ctx, &else_br);
rewriter.erase_operation(ctx, else_term);
}
// Inline both regions after the pre_if_block.
rewriter.inline_region(
ctx,
then_region,
BlockInsertionPoint::AfterBlock(pre_if_block),
);
rewriter.inline_region(
ctx,
else_region,
BlockInsertionPoint::AfterBlock(then_entry),
);
// The IfOp itself has no results, so just erase it.
rewriter.erase_operation(ctx, self.get_operation());
Ok(())
}
}
}
The key steps:
split_blockcuts the current block at theIfOp, creatingmerge_blockwith everything that came after.icmp ne cond, 0converts thei64condition toi1.cond_br cmp_i1, then_entry, else_entryterminatespre_if_block.- The
YieldOpterminators in both branches are replaced withbr merge_block. inline_regionmoves the then- and else-blocks into the enclosing function.- The
IfOpshell (which now has empty regions) is erased.
kaleidoscope.while -> loop CFG
Before conversion:
^entry:
... (WhileOp with body-region containing cond updates) ...
... (rest of the function) ...
After conversion:
^entry: ; everything up to WhileOp + br ^header
^while_header: ; load cond, icmp ne cond, 0, cond_br ^body / ^exit
^body_block: ; inlined from body-region, ends with br ^header (back-edge)
^exit: ; rest of the function (from split_block)
#![allow(unused)]
fn main() {
#[op_interface_impl]
impl ToLLVMDialect for KalWhileOp {
fn rewrite(
&self,
ctx: &mut Context,
rewriter: &mut DialectConversionRewriter,
_operands_info: &OperandsInfo,
) -> Result<()> {
let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless);
let cond_ptr = self.cond_ptr(ctx);
let body_region = self.body_region(ctx);
let body_entry = body_region
.deref(ctx)
.get_head()
.expect("WhileOp body_region must have a block");
// Erase the YieldOp at the end of the body.
let while_term = body_entry
.deref(ctx)
.get_terminator(ctx)
.expect("body block must have a terminator");
// The block containing the WhileOp is split to create the exit block.
let pre_while_block = self
.get_operation()
.deref(ctx)
.get_parent_block()
.expect("WhileOp must be in a block");
let exit_block = rewriter.split_block(
ctx,
pre_while_block,
OpInsertionPoint::BeforeOperation(self.get_operation()),
Some("while_exit".try_into().unwrap()),
);
// Create the header block.
let header_block = rewriter.create_block(
ctx,
BlockInsertionPoint::AfterBlock(pre_while_block),
Some("while_header".try_into().unwrap()),
vec![],
);
// Emit an unconditional branch into the header from pre_while_block.
rewriter.set_insertion_point(OpInsertionPoint::AtBlockEnd(pre_while_block));
let br_to_header = BrOp::new(ctx, header_block, vec![]);
rewriter.insert_op(ctx, &br_to_header);
// Header: load condition, compare to zero, cond_br to body or exit.
rewriter.set_insertion_point(OpInsertionPoint::AtBlockEnd(header_block));
let cond_load = LlvmLoadOp::new(ctx, cond_ptr, i64_ty.into());
let cond_i64 = cond_load.get_result(ctx);
rewriter.insert_op(ctx, &cond_load);
let zero_attr = IntegerAttr::new(i64_ty, APInt::from_i64(0, bw(64)));
let zero_const = LlvmConstantOp::new(ctx, Box::new(zero_attr));
let zero_val = zero_const.get_result(ctx);
rewriter.insert_op(ctx, &zero_const);
let cmp = ICmpOp::new(ctx, ICmpPredicateAttr::NE, cond_i64, zero_val);
let cmp_i1 = cmp.get_result(ctx);
rewriter.insert_op(ctx, &cmp);
let cond_br = CondBrOp::new(ctx, cmp_i1, body_entry, vec![], exit_block, vec![]);
rewriter.insert_op(ctx, &cond_br);
// Replace YieldOp at end of body with back-edge to header.
if Operation::is_op::<KalYieldOp>(while_term, ctx) {
rewriter.set_insertion_point(OpInsertionPoint::BeforeOperation(while_term));
let back_edge = BrOp::new(ctx, header_block, vec![]);
rewriter.insert_op(ctx, &back_edge);
rewriter.erase_operation(ctx, while_term);
}
// Inline the body region after the header block.
rewriter.inline_region(
ctx,
body_region,
BlockInsertionPoint::AfterBlock(header_block),
);
// Erase the WhileOp.
rewriter.erase_operation(ctx, self.get_operation());
Ok(())
}
}
}
WhileOp uses the memory-backed condition pattern introduced in Chapter 3:
the loop condition variable is a DeclOp slot in the outer block. The header
loads that slot on each iteration and branches accordingly. The body region
updates the slot at the end of every iteration, then its YieldOp is replaced
with a back-edge branch to the header.
Tests
The test helper parses a Kaleidoscope source string, lowers it to the Kaleidoscope dialect, applies the LLVM lowering in place, then prints and returns the resulting IR:
#![allow(unused)]
fn main() {
fn lower_to_llvm(src: &str) -> String {
let funcs = parse_program(src).expect("parse error");
let ctx = &mut Context::new();
let module = ModuleOp::new(ctx, "test".try_into().expect("valid module name"));
for func in &funcs {
let func_op = lower_function(ctx, func).expect("kaleidoscope lowering failed");
module.append_operation(ctx, func_op.get_operation(), 0);
}
let module_op_ptr = module.get_operation();
lower_module(ctx, module).expect("LLVM lowering failed");
verify_operation(module_op_ptr, ctx)
.expect("module verification failed after LLVM lowering");
format!("{}", module_op_ptr.disp(ctx))
}
}
Try it out:
cargo test --example kaleidoscope -- --show-output fibonacci_to_llvm
cargo test --example kaleidoscope -- --show-output factorial_to_llvm
cargo test --example kaleidoscope -- --show-output if_else_to_llvm
Next step
Chapter 5 feeds the LLVM-dialect module into pliron-llvm’s JIT backend to
compile and execute the Kaleidoscope program.
Chapter 5: JIT with LLVM
Chapter 4 lowered Kaleidoscope IR into the LLVM dialect. In this chapter we take the final step: convert that module to LLVM IR, JIT compile it, and invoke the generated machine code directly from Rust.
The implementation for this chapter lives in examples/kaleidoscope/jit.rs.
Design
The flow is intentionally small and linear:
- Parse Kaleidoscope source into AST (
parse_program). - Lower each AST function into Kaleidoscope dialect (
lower_function). - Lower the module to LLVM dialect (
lower_module). - Convert LLVM dialect to LLVM IR (
to_llvm_ir::convert_module). - Build an LLVM ORC JIT instance, add the module, look up a symbol, execute.
The one extra safeguard in this implementation is a runtime signature check.
Before JIT invocation, we verify that the target function really has signature
fn(i64) -> i64. This limitation is intentionally set for simplicity.
Step 1-4: Lower source all the way to LLVM IR
lower_to_llvm_ir is a helper that runs the complete frontend and lowering
pipeline and returns an LLVMModule:
#![allow(unused)]
fn main() {
fn lower_to_llvm_ir(src: &str, llvm_ctx: &LLVMContext) -> Result<LLVMModule> {
let funcs =
parse_program(src).map_err(|e| input_error_noloc!("Failed to parse program: {}", e))?;
let ctx = &mut Context::new();
let module = ModuleOp::new(ctx, "test".try_into().expect("valid module name"));
for func in &funcs {
let func_op = lower_function(ctx, func)?;
module.append_operation(ctx, func_op.get_operation(), 0);
}
lower_module(ctx, module)?;
verify_operation(module.get_operation(), ctx)?;
// Convert from LLVM dialect to LLVM IR
let llvm_module = to_llvm_ir::convert_module(ctx, llvm_ctx, module)?;
llvm_module
.verify()
.map_err(|e| input_error_noloc!("Generated LLVM module is invalid: {}", e))?;
Ok(llvm_module)
}
}
Notes:
- We build a fresh
ContextandModuleOpper call. - Every parsed function is lowered and appended to the module.
lower_modulemutates the module in place from Kaleidoscope dialect to LLVM dialect.to_llvm_ir::convert_moduleproduces real LLVM IR (LLVMModule) consumable by JIT.
Step 5: JIT compile and execute
The public API for execution is exec_fn:
#![allow(unused)]
fn main() {
pub fn exec_fn(src: &str, name: &str, arg: i64) -> Result<i64> {
initialize_native()
.map_err(|e| input_error_noloc!("Failed to initialize native target: {}", e))?;
let llvm_ctx = LLVMContext::default();
let llvm_module = lower_to_llvm_ir(src, &llvm_ctx)?;
let Some(f) = llvm_get_named_function(&llvm_module, name) else {
return Err(input_error_noloc!(
"Function '{}' not found in generated LLVM module",
name
));
};
let f_ty = llvm_global_get_value_type(f);
let param_types = llvm_get_param_types(f_ty);
let ret_type = llvm_get_return_type(f_ty);
let llvm_int64_ty = llvm_int_type_in_context(&llvm_ctx, 64);
if param_types.len() != 1 || param_types[0] != llvm_int64_ty || ret_type != llvm_int64_ty {
return Err(input_error_noloc!(
"Expected function '{}' to have exactly one parameter of type i64 and return type i64, but found different signature",
name
));
}
// println!("Generated LLVM IR:\n{}", llvm_module.to_string());
// JIT compile and execute the main function
let lljit = pliron_llvm::llvm_sys::lljit::LLVMLLJIT::new_with_default_builder()
.map_err(|e| input_error_noloc!("Failed to create JIT execution engine: {}", e))?;
lljit
.add_module(llvm_module)
.map_err(|e| input_error_noloc!("Failed to add module to JIT: {}", e))?;
let main_fn = lljit
.lookup_symbol(name)
.map_err(|e| input_error_noloc!("Failed to find main function in JIT: {}", e))?;
let main_fn: extern "C" fn(i64) -> i64 = unsafe { std::mem::transmute(main_fn) };
Ok(main_fn(arg))
}
}
Key parts of exec_fn:
initialize_native()sets up the host target backend once per process.llvm_get_named_functionchecks that the requested symbol exists in the generated module.llvm_get_param_typesandllvm_get_return_typeare used to validate the function ABI as exactly onei64argument and ani64return value.LLVMLLJIT::new_with_default_builder()creates an ORC JIT instance.add_moduleloads the generated LLVM module into JIT.lookup_symbol(name)resolves the function entry address.std::mem::transmutecasts the raw symbol address toextern "C" fn(i64) -> i64, then calls it.
If the function is missing, or has a different signature, exec_fn returns a
structured pliron::result::Error instead of panicking.
Tests
The file includes three focused tests that exercise the end-to-end JIT path.
Fibonacci from source file:
#![allow(unused)]
fn main() {
#[test]
fn fibonacci_jit() {
let src = std::fs::read_to_string("examples/kaleidoscope/fibonacci.kal")
.expect("failed to read fibonacci.kal");
let result = exec_fn(&src, "main", 5).expect("failed to execute main function");
assert_eq!(result, 5);
}
}
Factorial from source file:
#![allow(unused)]
fn main() {
#[test]
fn factorial_jit() {
let src = std::fs::read_to_string("examples/kaleidoscope/factorial.kal")
.expect("failed to read factorial.kal");
let result = exec_fn(&src, "main", 5).expect("failed to execute main function");
assert_eq!(result, 120);
}
}
Inline if/else program:
#![allow(unused)]
fn main() {
#[test]
fn if_else_jit() {
let src = "
def abs(x) {
var result = 0;
if x < 0 {
result = 0 - x;
} else {
result = x;
}
return result;
}
";
let result = exec_fn(src, "abs", 42).expect("failed to execute main function");
assert_eq!(result, 42);
let result = exec_fn(src, "abs", -42).expect("failed to execute main function");
assert_eq!(result, 42);
let result = exec_fn(src, "abs", 0).expect("failed to execute main function");
assert_eq!(result, 0);
}
}
These tests verify that:
- file-backed programs run correctly through JIT,
- recursive calls work (
fib,factorial), - structured control flow (
if/else) lowers and executes correctly.
Try it out
Run individual JIT tests:
cargo test --example kaleidoscope -- --show-output fibonacci_jit
cargo test --example kaleidoscope -- --show-output factorial_jit
cargo test --example kaleidoscope -- --show-output if_else_jit
You can also run the example binary in this workspace and execute a
chosen function from a .kal source file.
Running via the example CLI
The example binary in examples/kaleidoscope/main.rs is a thin wrapper around
jit::exec_fn: it reads source from --input, selects a function with
--fn, passes an integer argument via --arg, and prints the result.
Run Fibonacci’s main(5):
cargo run --example kaleidoscope -- --input examples/kaleidoscope/fibonacci.kal --fn main --arg 5
Run Factorial’s main(5):
cargo run --example kaleidoscope -- --input examples/kaleidoscope/factorial.kal --fn main --arg 5
If the function name is missing from the module, or does not match the
expected fn(i64) -> i64 signature, the CLI reports the error returned by
exec_fn.
Recap
At this point, the tutorial pipeline is complete:
- Parse source text into AST.
- Lower AST to a custom high-level dialect.
- Lower to LLVM dialect.
- Convert to LLVM IR.
- JIT and execute native code.
This is the minimal but complete compiler path from source language to running
machine code using pliron + pliron-llvm.
Examples Index
This index maps tutorial chapters to runnable examples.
Chapter mapping
| Chapter | Focus | Tests |
|---|---|---|
| Chapter 1 | Parser | ast::tests::test_ast_fibonacciast::tests::test_ast_factorial |
| Chapter 2 | Dialect definitions and manual IR construction | dialect::tests::build_fib_example |
| Chapter 3 | AST to Kaleidoscope dialect lowering | from_ast::tests::fibonacci_from_astfrom_ast::tests::factorial_from_astfrom_ast::tests::inline_fibonacci_from_ast |
| Chapter 4 | Kaleidoscope dialect to LLVM dialect lowering | to_llvm::tests::fibonacci_to_llvmto_llvm::tests::factorial_to_llvmto_llvm::tests::if_else_to_llvm |
| Chapter 5 | JIT execution | jit::tests::fibonacci_jitjit::tests::factorial_jitjit::tests::if_else_jit |
Running chapter tests
cargo test --example kaleidoscope -- --list
cargo test --example kaleidoscope -- --show-output
Run a single test by name:
cargo test --example kaleidoscope -- fibonacci_to_llvm --show-output
Run the end-to-end CLI example:
cargo run --example kaleidoscope -- --input examples/kaleidoscope/fibonacci.kal --fn main --arg 5
Testing strategy
As examples become implementation-heavy, mirror key behaviors in integration tests under tests/ so chapter claims remain verifiable in CI.
Suggested learning paths
- Parser-focused: Start at Chapter 1.
- IR design-focused: Start at Chapter 2, then Chapter 3 and Chapter 4.
- End-to-end pipeline: All chapters, in order.