Radiance Intermediate Language

The Radiance Intermediate Language, or RIL is a minimal, low-level, SSA-based intermediate representation for Radiance.

Why an intermediate language?

Though intermediate languages (ILs) are very common for compiled languages, it’s worth stating some of the reasons we chose to have one for Radiance, given that our constraints are different than most languages. It’s important that the Radiance compiler stays simple. We don’t want to end up with a compiler toolchain in the millions of lines of code like Zig’s or Rust’s, and having ILs in a compiler increases the surface area a lot. However, trying to build a serious compiler without one is challenging and presents trade-offs.

Wirth languages like Pascal and Modula often had single-pass implementations that emitted machine code while parsing source code. This made the compilers very fast and very small in size, at the expense of other things; namely the size of the resulting binary code, the flexibility of the language semantics and the quality of the compiler diagnostics.

For Radiance, we wanted something that would better scale to the demands of the Radiant system, would produce fast machine code, and would have great usability. Intermediate languages are a very useful abstraction that makes compilers easier to write, understand, debug, and build tooling for. We therefore decided it was a worthy trade-off on this basis alone.

However, there is another reason to have an IL that is specific to Radiant: our operating system does not execute machine code directly. This avoids a plethora of security issues that would otherwise require something like CHERI. Since the OS cannot audit machine code, only hardware-level safety is applicable. This has a cost in performance and complexity.

On the other hand, having a verifiable IL allows for native sandboxing capabilities without the costs. This is similar to what is offered by WebAssembly. It means that the OS only loads IL programs and internally compiles them to machine code once it has verified that they do not break any of the safety rules. This is more limiting than allowing arbitrary machine code to run, but the trade-off in this case is worth it.

Radiance’s IL

We’ve tried to keep the IL small, but it’s a balancing act: too small and you give the frontend more work to do; too large and you give the backend more work to do. So we found a middle ground that is still low-level, while having some affordances that give compiler backends enough flexibility in lowering the IL to machine code.

The IL is also designed with RISC-V as the primary target for lowering. Hence, there are instructions that map one to one with RISC-V instructions, such as beq, bge etc. We also took a lot of inspiration from QBE, a minimal, open source intermediate language.

fn w32 $fib(w32 %0) {
  @entry
    br.slt w32 1 %0 @merge @done;
  @done
    ret %0;
  @merge
    sub  w32 %1 %0 1;
    call w32 %2 $fib(%1);
    sub  w32 %3 %0 2;
    call w32 %4 $fib(%3);
    add  w32 %5 %2 %4;
    ret  %5;
}
The Fibonacci function, in RIL

The above RIL code is generated from the following Radiance function:

fn fib(n: i32) -> i32 {
    if n <= 1 {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

The lowering phase that produces RIL takes a typed abstract syntax tree (AST) as input, and generates RIL incrementally. The IL has infinite registers, and instructions don’t have a size limit. The job of instruction selection and register allocation is left to the next phase, which turns RIL into platform dependent machine code.

The SSA form that RIL uses also lends itself to various optimizations, such as constant folding and dead-code elimination.

Instead of phi nodes wich are used in popular ILs such as LLVM and QBE, RIL uses block parameters for SSA construction. This is similar to MLIR and SIL. Each basic block can declare parameters, and jumps/branches pass arguments to their targets. This simplifies reasoning about control flow merges.

If we take the following example code that involves a control flow merge between the if and else branch, we see that the generated RIL has a merge block that takes a block parameter. This is the equivalent of a phi node, which is used to select between between multiple register assignments.

fn mergeExample(flag: bool) -> i32 {
    let mut x: i32 = 0;
    if flag {
        x = 1;
    } else {
        x = 2;
    }
    return x;
}
fn w32 $mergeExample(w8 %0) {
  @entry
    br.ne w32 %0 0 @then @else;
  @then
    jmp @merge(1);
  @else
    jmp @merge(2);
  @merge(w32 %1)
    ret %1;
}
RIL output with a merge block that receives a value from both branches.

Types & Values

The IR supports four word-sized primitive types. Signedness is encoded in operations (e.g. sdiv vs udiv), not in types.

Type Description
w8 8-bit value
w16 16-bit value
w32 32-bit value
w64 64-bit value

Operands in instructions can be registers, immediates, symbols or blocks.

Kind Syntax Description
Register %n SSA register reference
Immediate n Immediate integer value
Symbol $name Symbol address (static data, functions)
Block @label Block label

Instructions

RIL has about 40 instructions currently. As Radiance involves, more instructions may be added.

Memory operations. Stack allocation, loads, stores, and bulk memory copies.

Instruction Syntax Description
reserve reserve %dst <size> <align> Reserve stack space
load load <type> %dst %src <off> Load value from memory
store store <type> <src> %dst <off> Store value to memory
blit blit %dst %src <size> Copy memory region
copy copy %dst <val> Copy a value into a register

Basic arithmetic operations. Signedness is encoded in the operation name.

Instruction Syntax Description
add add <type> %dst <a> <b> Addition
sub sub <type> %dst <a> <b> Subtraction
mul mul <type> %dst <a> <b> Multiplication
sdiv sdiv <type> %dst <a> <b> Signed division
udiv udiv <type> %dst <a> <b> Unsigned division
srem srem <type> %dst <a> <b> Signed remainder
urem urem <type> %dst <a> <b> Unsigned remainder
neg neg <type> %dst <a> Negation

Equality and relational comparisons. Results are stored as W8.

Instruction Syntax Description
eq eq <type> %dst <a> <b> Equal
ne ne <type> %dst <a> <b> Not equal
slt slt <type> %dst <a> <b> Signed less than
sge sge <type> %dst <a> <b> Signed greater or equal than
ult ult <type> %dst <a> <b> Unsigned less than
uge uge <type> %dst <a> <b> Unsigned greater or equal than

Bitwise logic and shift operations.

Instruction Syntax Description
and and <type> %dst <a> <b> Bitwise AND
or or <type> %dst <a> <b> Bitwise OR
xor xor <type> %dst <a> <b> Bitwise XOR
not not <type> %dst <a> Bitwise NOT
shl shl <type> %dst <a> <b> Shift left
sshr sshr <type> %dst <a> <b> Signed shift right
ushr ushr <type> %dst <a> <b> Unsigned shift right
zext zext <type> %dst <val> Zero-extend to W32
sext sext <type> %dst <val> Sign-extend to W32

Control flow. Jumps, calls, returns.

Instruction Syntax Description
call call <type> %dst $<func>(<args>...) Call a function
ret ret [<val>] Return from function
jmp jmp @<label>(<args>...) Unconditional jump
br br.<op> <type> <a> <b> @<then> @<else> Compare-and-branch
switch switch <val> <cases>... Multi-way branch
unreachable unreachable Marks unreachable code

Intrinsics. Low-level operations for interacting with the environment.

Instruction Syntax Description
ecall ecall %dst <num> <a0> <a1> <a2> Environment call
ebreak ebreak Break to the environment

Data directives

RIL supports directives for defining static data and external declarations.

Static data. The data directive defines a named, aligned block of static data.

data $<name> align <n> {
    <values>...
}

Values inside data blocks can be:

Value Description
w8 <val> 8-bit value
w16 <val> 16-bit value
w32 <val> 32-bit value
w64 <val> 64-bit value
<type> <val> * <n> Value repeated n times
undef * <n> Undefined padding of n bytes
str "..." String literal

Example:

// A static string.
data $Greeting align 1 {
    str "hello";
}
// An 8-byte record of {1, 42}.
data $Padded align 4 {
    w8 1;
    undef * 3;
    w32 42;
}

External functions. The extern directive declares an externally-defined function.

extern fn <type> $<name>(<params>...)

Lowering strategy

Since RIL only has scalar types, we need a way to lower aggregates like slices and records.

For records, RIL uses loads and stores with pointer arithmetic to implement field access. For unions, we reserve enough space for the largest variant, and place a numeric tag in front of it.

Slices are lowered as records with a pointer and length field. Optionals use the same strategy as unions.

Arithmetic

In terms of arithmetic, there is no truncation instruction — narrow values use word-sized registers with undefined high bits; truncation happens implicitly via narrow stores.

There’s also no widening on arithmetic — type annotations on arithmetic ops indicate logical width but don’t change code generation; arithmetic is always machine-word sized.

We use zext/sext for loads — widening happens when loading narrow values from memory.