Viscosity
Review
Part X · Drip in Rust · Day 42

166. Optimizing the Rust VM

The profile says an eighth of fib’s instructions are indexing. C would just skip the checks. drip-rs has forbidden itself the keyword that does that, so this chapter measures what the checks really cost, and the safe ways to make them disappear.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Say what get_unchecked requires, and why drip-rs can’t use it.
  • Measure what unchecked reads would buy, in instructions and in time, and say whether it is noise.
  • Explain why the registers were worth far more than removing checks.
  • Remove bounds checks safely with hoisted checks, u8 indexes into fixed arrays, and iterators.

Twelve and a half percent

Chapter 165’s profile of fib put 12.5% of all instructions in slice/index.rs: indexing a slice or a Vec, bounds check included. clox reads bytecode through a raw pointer and checks nothing. It is tempting to do the same. Before touching anything, two rules from chapters 24 and 142: know where the work is, and know how you’ll tell whether a change helped.

Intuition

A bounds check is a guard at a door who looks at every badge. If the same person walks through a million times a second, you can fire the guard and trust everyone. You can also put the guard at the gate, check once, and let people move freely inside. Only the second keeps you safe.

What unchecked would take

The standard library has the unchecked read: xs.get_unchecked(i). It is an unsafe fn. Calling it with i out of bounds is undefined behaviour, “even if the resulting reference is not used”[1]. In read_byte:

// SAFETY: ip < code.len(), because the
// compiler ends every chunk with a return
// and every jump lands inside the chunk.
let byte = unsafe {
    *self.chunk.code.get_unchecked(self.ip)
};

Read that SAFETY comment again. It isn’t about this line at all: it’s a promise about compiler.rs, hundreds of lines away. One wrong jump offset, and instead of a panic with a message, the VM reads whatever is past the chunk, with no telling what happens next[3].

And drip-rs refuses it. In a scratch copy, rustc 1.97 says (trimmed):

error: usage of an `unsafe` block
   --> src/vm.rs:433:20
note: the lint level is defined here
  9 | #![forbid(unsafe_code)]
Try itget_unchecked under #![forbid(unsafe_code)]
#![forbid(unsafe_code)]

fn main() {
    let code = vec![1u8, 2, 3];
    let ip = 1;
    // SAFETY: ip < code.len()
    let b = unsafe {
        *code.get_unchecked(ip)
    };
    println!("{b}");
}
Open in Rust Playground ↗

What it would buy

So measure, in a scratch copy with the forbid deleted. Chapter 141 did this with wall times. Here I added callgrind’s counts. Four builds of drip-rs, each passing the 246 corpus programs and the 95 stress-GC programs: the committed one; one with read_byte unchecked; one with read_constant, peek and the local-slot reads unchecked too; and, for scale, one without chapter 150’s registers, reading every byte through frame, closure, function and chunk. Release builds, rustc 1.97, the book’s shared 4-vCPU VM; 14 fresh runs of fib.drip each, the four builds alternating:

Step throughWhat a check costs on fib
Instructions or wall times for fib under four builds of drip-rsinstructions (callgrind), billionscommitted (safe)20.19
The committed drip-rs runs fib in 20.19 billion instructions, all its indexing checked, no unsafe anywhere.
1/5

The unsafe builds ran 6.2% and 7.9% fewer instructions. In time, their runs (1266–1571 and 1246–1786 ms) landed among the committed build’s (1331–1677 ms). On this VM that is noise. The safe design change, the registers, was worth 1.95× in instructions and about 2× in time, close to chapter 150’s measurement. (drip-rs’s README says “about 1.4×”; its starting point may not have been this one.)

The lesson: the walk to the byte cost far more than the check on it.

Definition

Unchecked indexing reads an element without comparing the index with the length. Rust offers it directly only as unsafe (get_unchecked). Safe code gets the same effect by giving the optimizer a proof that the index is in range, so it can delete the check: a check hoisted out of the loop, an index type that can’t be too big, or an iterator with no index at all.

Checks the compiler already moved

drip-rs’s hash table probes with keys[index], where index = hash & mask and mask = keys.len() - 1. Here is the release build’s machine code for Table::get, trimmed:

sub  $0x1,%rbx      # mask = len - 1
jb   <panic_bounds_check>
loop:
inc  %r8
and  %rbx,%r8       # i = (i + 1) & mask
cmpb $0x0,(%r11,%r15,4)  # keys[i]

One test before the loop, that the length isn’t zero. Inside the loop, none: i & (len - 1) can’t reach len. The optimizer hoisted the check itself. I tried to help by reslicing values to keys.len() before the loop. zoo.drip then ran 7,540,512,526 instructions instead of 7,528,846,449, 0.15% more. The line only added a check.

Simple loops get the same treatment. For xs[i] += ys[i] over 1000 elements, a plain indexed loop, one with ys resliced first, and a zip each ran about 2,770 instructions per call, all vectorized.

Checks that can’t fail

Some indexes can’t be proven in range: a byte read from the bytecode, used to pick a constant. But a u8 is at most 255. Index a [T; 256], whose length is in its type, and there is nothing left to check. A slice becomes an array reference with one check up front, try_into[2]:

Try itOne check up front, none in the loop
// A u8 index into a [T; 256] array can
// never be out of bounds.
fn sum(code: &[u8], k: &[f64; 256])
    -> f64 {
    let mut s = 0.0;
    for &b in code {
        s += k[b as usize];
    }
    s
}

fn main() {
    let consts = vec![0.5; 256];
    // The one check: is the length 256?
    let k: &[f64; 256] = consts
        .as_slice()
        .try_into()
        .expect("256 constants");
    let code: Vec<u8> =
        (0..=255).collect();
    println!("{}", sum(&code, k));

    let short = vec![0.5; 10];
    let r: Result<&[f64; 256], _> =
        short.as_slice().try_into();
    println!("len 10: {}", r.is_ok());
}
Open in Rust Playground ↗

I timed that loop against the same one taking &[f64] (rustc 1.97 -C opt-level=3, 4096 bytes, 20,000 times). callgrind counted 573,560,000 instructions for the slice version and 194,800,000 for the array: 2.9× fewer. Wall time: 53 to 57 ms for both, three runs each. Each s += waits for the one before, so the extra compares ran in the gaps.

drip-rs could use this. Constant operands are u8 and a chunk holds at most 256 constants, so a frozen chunk could keep Box<[Value; 256]>. That is design, not code, and the unsafe build bounds the prize: all four unchecked reads together saved 8% of instructions.

Iterators are the third way. Table::iter zips the keys and values columns, and the collector’s Trace for [T] is a plain for t in self. No index, no check[4].

Under the hood

What else does the profile offer? Table::get is 5.9% of fib, because every call looks fib up by name; caching a global’s slot in the bytecode would remove most of it. close_upvalues runs on every return, 6 instructions each, though fib captures nothing. Neither change is in drip-rs: the VM matches the TypeScript VM and clox one for one. Each would need the corpus run again, and a measurement that beats the noise.

Your turn

ExerciseA speed-up calculator that respects noise

You have wall times from fresh runs, before and after a change. Write min and max of a slice, then speedup(before, after): best before divided by best after. Then write verdict(before, after), which only claims a result when the samples don’t overlap: Faster if every after time is below every before time, Slower if every after time is above every before time, and Noise otherwise. The tests use this chapter’s real fib timings.

#[derive(Debug, PartialEq)]
enum Verdict {
    Faster,
    Slower,
    Noise,
}

fn min(xs: &[f64]) -> f64 {
    todo!()
}

fn max(xs: &[f64]) -> f64 {
    todo!()
}

fn speedup(
    before: &[f64],
    after: &[f64],
) -> f64 {
    todo!()
}

fn verdict(
    before: &[f64],
    after: &[f64],
) -> Verdict {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • get_unchecked is unchecked indexing behind unsafe: its safety rests on promises about distant code, and drip-rs forbids it.
  • Four unchecked reads saved about 8% of fib’s instructions and no time this VM could tell from noise. The safe registers saved half.
  • The optimizer already hoists many checks, such as the hash table’s & mask probe. Adding your own can cost.
  • A u8 into a [T; 256] needs no check; try_into makes the array reference with one check. Iterators carry no index at all.
  • Fewer instructions isn’t less time. Claim a speed-up only when the runs don’t overlap.

Check your understanding

0/6
  1. 1.

    The profile shows 12.5% of fib’s instructions in slice/index.rs. What does that tell you about bounds checks?

  2. 2.

    Why is get_unchecked in read_byte a riskier change than it looks?

  3. 3.

    You add let values = &values[..keys.len()]; before the hash-table probe loop, to hoist a check. zoo.drip then runs 0.15% more instructions. Why?

  4. 4.

    Which index needs no bounds check at all?

  5. 5.

    A change cuts a loop’s instructions by a factor of 2.9 and its time not at all. What is the most likely reason?

  6. 6.

    The fastest unchecked run of fib took 1266 ms, the fastest checked run 1331 ms. What may you claim?

References

  1. [1] The Rust Project Developers. Method slice::get_unchecked (Rust core library documentation). Rust standard library API documentation; source in library/core/src/slice/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/primitive.slice.html#method.get_unchecked (checked 2026-10-31)
  2. [2] The Rust Project Developers. impl TryFrom<&[T]> for &[T; N] (Rust core library documentation). Rust standard library API documentation; source in library/core/src/array/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/primitive.array.html (checked 2026-10-31)
  3. [3] The Rust Reference contributors. Behavior considered undefined. The Rust Reference. doc.rust-lang.org/reference/behavior-considered-undefined.html (checked 2026-10-31)
  4. [4] The Rust Project Developers. Module std::iter (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/iter/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/iter/ (checked 2026-10-31)