Viscosity
Review
Part XI · Viscosity in Rust · Day 43

170. VIR in Rust

Both engines print the same VIR, line for line. Underneath, the TypeScript blocks hold instruction objects while the Rust blocks hold numbers: four kinds of typed id into arenas that the module owns. Here is why, and what it changes.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Compare the TypeScript VIR with the arena IR in engine/rust’s vir crate, type by type.
  • Explain why a Rust block holds ValueIds instead of instructions, and what happens to a removed instruction’s slot.
  • Read VIR’s Op enum and Use lists as replacements for TypeScript’s optional fields and object references.
  • Explain why the port formats numbers with js_number instead of Rust’s own formatting.

The same listing

Chapter 91 printed VIR with printFunc. The Rust crate has a print_func. I lowered the same three-line particle program with both engines and compared the listings with diff: no difference. Here is the loop body from either one:

  b6:  ; preds b5
    %18 = index.get %13, %15
    %19 = field.get %18 .x
    %20 = field.get %18 .vx
    %21 = binop + %19, %20
    field.set %18, %21 .x
    br b7

Same blocks, same phis, same numbers. The analyses read the same graph in both engines, and that is what makes parity possible. But the data structures behind the text differ, and the difference is ownership.

Who owns an instruction?

In TypeScript, a block holds its instruction objects, and the module keeps a second list of the very same objects, by id:

interface Block {
  insts: Inst[];   // the objects
  term: Terminator;
  preds: BlockId[];
}
interface Module {
  values: Inst[];  // the same objects
}

Two references to one object are nothing in JavaScript. In Rust a value has one owner (chapter 124), so the port picks one: the module.

pub struct Block {
    pub id: BlockId,
    pub insts: Vec<ValueId>,
    pub term: Terminator,
    pub preds: Vec<BlockId>,
}
pub struct Module {
    pub functions: Vec<Func>,
    pub bindings: Vec<Binding>,
    pub values: Vec<Inst>,
    // language, source, main
}

(Adapted from crates/vir/src/lib.rs.) Every instruction of every function lives in values, and its id is its index. A block is a list of ids. Step through the real VIR of a small sum(n) function:

Step throughAn instruction arena with typed ids
The values arena of a VIR function, its blocks as lists of ids, and its use listsModule::valuesfunc sum(%0)%0param%10%20%3φ%4—%5<%6φ%7+%8u+%91%10+11 slots · index = idblocks (lists of ids)b0%0 %1 %2b1%6 %3 %5b2%7b3%8 %9 %10b4ret %6values: Vec<Inst> · %n is index n
The module owns one arena, values: a Vec holding every instruction of every function, in the order they were created. An instruction’s id is its index, so %5 is values[5]. Nothing else owns an Inst.
1/6
Intuition

A library doesn’t let each reading list own its books. The books stay on the shelves, and a reading list is a column of shelf numbers. Ten lists can name the same book, and a book taken out of every list still has its shelf.

Definition

An IR arena stores the nodes of an intermediate representation in Vecs owned by one container, here the Module, and refers to them by typed ids: ValueId, BlockId, FuncId and BindingId, each a u32 newtype. A node’s id is its index in its arena, ids never move, and a reference between nodes is an id rather than a pointer.

Four kinds of id

In the TypeScript VIR, type ValueId = number and type BlockId = number are only names for number. I checked with tsc --strict: a BlockId passes as a ValueId without a word. The Rust crate writes the newtype once, as a macro_rules! template (chapter 153), and stamps it out four times. Simplified:

macro_rules! id_type {
    ($name:ident, $prefix:literal) => {
        #[derive(Copy, Clone, PartialEq,
            Eq, PartialOrd, Ord, Hash)]
        pub struct $name(pub u32);
        // + index(), and Debug that
        // prints "%3", "b1", "f0", ...
    };
}
id_type!(ValueId, "%");
id_type!(BlockId, "b");

Each id is 4 bytes. Run it, then uncomment the marked line:

Try itBlocks of typed ids
use std::fmt;
use std::mem::size_of;

#[derive(Copy, Clone, PartialEq)]
struct ValueId(u32);
#[derive(Copy, Clone, PartialEq)]
struct BlockId(u32);

impl fmt::Debug for ValueId {
    fn fmt(&self,
        f: &mut fmt::Formatter)
        -> fmt::Result {
        write!(f, "%{}", self.0)
    }
}

struct Inst {
    op: &'static str,
    args: Vec<ValueId>,
}

fn mk(op: &'static str, a: &[u32])
    -> Inst {
    let args =
        a.iter().map(|&n| ValueId(n));
    Inst { op, args: args.collect() }
}

struct Module {
    values: Vec<Inst>,
}

impl Module {
    fn inst(&self, v: ValueId)
        -> &Inst {
        &self.values[v.0 as usize]
    }
}

fn main() {
    let m = Module {
        values: vec![
            mk("param", &[]),
            mk("const 1", &[]),
            mk("binop +", &[0, 1]),
        ],
    };
    // block b1 lists ids, not Insts
    let b1 =
        vec![ValueId(1), ValueId(2)];
    for &v in &b1 {
        let i = m.inst(v);
        println!("{:?} = {} {:?}",
            v, i.op, i.args);
    }
    let b = BlockId(1);
    println!("block b{}", b.0);
    // m.inst(b); // try it: E0308
    let n = size_of::<ValueId>();
    println!("a ValueId is {n} bytes");
}
Open in Rust Playground ↗
error[E0308]: mismatched types
   |     m.inst(b);
   |            ^ expected `ValueId`,
   |              found `BlockId`

That is rustc 1.97 on the program above (trimmed). It is chapter 138’s idea, applied to a compiler’s IR instead of a VM’s heap.

Optional fields become variants

A TypeScript Inst carries every possible payload as an optional field, value?, field?, binding?, fields? and more, and code that knows the op asserts the right one is there: m.bindings[i.binding!].name. The Rust Op is an enum whose 22 variants carry their own data:

pub enum Op {
    Const(Const),
    LoadBinding { binding: BindingId },
    FieldGet { field: String },
    IndexGet,
    // ... 22 in all
}

A match on Op::LoadBinding { binding } hands you the binding; there is nothing to assert. op.name() still returns the TypeScript spelling, "index.get", because explain text quotes it. The other types change the same way. Terminator is an enum whose CondBr has an else_ field (else is a keyword). A binding’s owner is Option<FuncId> where TypeScript used -1. And a use is Use::Inst(ValueId) or Use::Term { func, block }, ids again where TypeScript stored object references.

Numbers, the JavaScript way

Explain text puts numbers in sentences, and TypeScript formats them with JavaScript’s String(x). Rust’s {} agrees for everyday numbers and disagrees for large and tiny ones (chapter 149). So the port formats every number in its output with vir::js_number, the same algorithm as drip-rs’s format_number: take the shortest round-trip digits from Rust’s {:e}[2], then place the point as ECMAScript does[3]. One case differs from drip-rs: js_number(-0.0) is "0", as String(-0) is, where Drip prints -0.

To see it matter, I compiled the particle program with a minimum size of 1e21 through both engines’ compile. Both printed this line (wrapped here):

· about 100 elements: below the
  1e+21-element threshold, where
  the benefit is too small

With {}, Rust would have written 1000000000000000000000.

Under the hood

Arenas make the borrow checker’s job easy. The analyses take &Module and pass ids around, so any number of them can read at once. The SSA builder (Braun et al.’s algorithm, chapter 78[1]) does not own the module either: in the words of builder.rs, every method that touches it takes &mut Module, which “lets a front-end keep one module and switch between several function builders (one per nested function) freely”. A removed phi keeps its slot, marked opaque with the note removed-trivial-phi, so no id ever moves, and all_insts() walks blocks, not values, so it skips such slots. The cost is indirection: on this machine an Inst is 112 bytes, and every step from a block to an instruction is an index into values.

Your turn

ExerciseUse lists over an arena

Write uses, a small build_uses. The result has one list per value in m.values (dead ones included), and uses(m)[v] lists every instruction that has v among its args, in program order: block by block, instruction by instruction. An instruction that uses v twice appears twice, as in the real VIR. Instructions that are in no block are dead and use nothing.

#[derive(Copy, Clone, PartialEq, Debug)]
struct V(u32);

impl V {
    fn ix(self) -> usize {
        self.0 as usize
    }
}

struct Inst {
    args: Vec<V>,
}

struct Block {
    insts: Vec<V>,
}

struct Module {
    values: Vec<Inst>,
    blocks: Vec<Block>,
}

// uses(m)[v] = the instructions using
// value v, in program order

fn uses(m: &Module) -> Vec<Vec<V>> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • The TypeScript VIR shares instruction objects between blocks and values. The Rust IR arena gives them one owner, Module::values, and blocks list typed ids.
  • ValueId, BlockId, FuncId and BindingId are u32 newtypes made by one macro; mixing them is E0308, where TypeScript’s number aliases mix silently.
  • Ids never move: a removed phi keeps its slot, and walks go through blocks.
  • Optional fields become enum variants with their data, and use lists hold ids.
  • Numbers in the output go through js_number, so they print as JavaScript prints them.

Check your understanding

0/6
  1. 1.

    In the TypeScript VIR, block.insts and module.values both hold instruction objects. Why can’t the Rust port copy that design directly?

  2. 2.

    The SSA builder removes a trivial phi %4. What happens to values[4]?

  3. 3.

    What does m.inst(BlockId(1)) do in engine/rust?

  4. 4.

    A TypeScript Inst for loadBinding keeps its binding in the optional field binding?. Where is it in Rust?

  5. 5.

    Why is js_number(-0.0) "0" while drip-rs’s format_number(-0.0) is "-0"?

  6. 6.

    What does a Use hold in the Rust VIR?

References

  1. [1] Matthias Braun, Sebastian Buchwald, Sebastian Hack, Roland Leißa, Christoph Mallon, Andreas Zwinkau (2013). Simple and Efficient Construction of Static Single Assignment Form. Compiler Construction (CC 2013), LNCS 7791, Springer, pp. 102–122. doi:10.1007/978-3-642-37051-9_6 (checked 2026-10-31)
  2. [2] The Rust Project Developers. core::num::imp::flt2dec (float-to-decimal conversion) and core::fmt::float. Rust standard library source, rust-lang/rust repository on GitHub. github.com/rust-lang/rust/blob/master/library/core/src/num/imp/flt2dec/mod.rs (checked 2026-10-31)
  3. [3] Ecma International, Technical Committee 39 (TC39). ECMAScript® Language Specification (ECMA-262), current draft. Ecma International; living draft at tc39.es/ecma262, source at github.com/tc39/ecma262. tc39.es/ecma262/ (checked 2026-10-31)