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.
- 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:
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.
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:
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");
}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.
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
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!()
}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,FuncIdandBindingIdareu32newtypes 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.
In the TypeScript VIR,
block.instsandmodule.valuesboth hold instruction objects. Why can’t the Rust port copy that design directly? - 2.
The SSA builder removes a trivial phi
%4. What happens tovalues[4]? - 3.
What does
m.inst(BlockId(1))do in engine/rust? - 4.
A TypeScript
InstforloadBindingkeeps its binding in the optional fieldbinding?. Where is it in Rust? - 5.
Why is
js_number(-0.0)"0"while drip-rs’sformat_number(-0.0)is"-0"? - 6.
What does a
Usehold in the Rust VIR?
References
- [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] 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] 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)