Viscosity
Review
Part XI · Viscosity in Rust · Day 45

178. Collection discovery in Rust

Chapter 96’s collection pass, as `analysis.rs` has it: the closures become a struct, the object-keyed maps become indices, a worklist pops in the reference’s order, and the borrow checker decides where the uses get copied.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Map the state of analysis.ts’s collection pass onto the Analyzer struct.
  • Explain why collections are referred to by index, and why use lists are copied before the loop.
  • Trace the worklist over real VIR, into a known function’s parameter.
  • Implement the worklist, keeping the reference’s order.

Same pass, new home

Chapter 96 read the first two steps of analysis.ts: find candidate bindings, then follow every load of each one through its uses. The TypeScript keeps everything in local variables of one big analyze function, and the steps are closures that share them.

analysis.rs produces the same collections, the same bailouts and the same messages in the same order. What changes is where the state lives, and how one collection refers to another thing.

Intuition

A desk where several people share one set of drawers can’t let anyone walk off with a drawer. So they pass around drawer numbers instead, and anyone with the number can look inside. analysis.rs does that with its collections: it passes their numbers around.

The Analyzer struct

The TS closures become methods of one struct, trimmed:

struct Analyzer<'m> {
    m: &'m Module,
    uses: Uses,
    stores: HashMap<BindingId,
        Vec<ValueId>>,
    loads: HashMap<BindingId,
        Vec<ValueId>>,
    call_sites: HashMap<FuncId,
        Vec<ValueId>>,
    collections: Vec<Collection>,
    collection_of_load:
        HashMap<ValueId, usize>,
    element_of: HashMap<ValueId, usize>,
    // ...numeric state, a cfg cache
}

HashMap here is FxHashMap from rustc-hash, renamed on import: a faster hasher for small keys[1]. Its iteration order is arbitrary, which is fine, because the analyzer only looks things up in these maps. Anything whose order reaches explain is a Vec or an IndexMap[2].

The analyzer borrows the module for 'm, and inst hands out instructions for that lifetime, not for the borrow of self:

fn inst(&self, v: ValueId) -> &'m Inst {
    self.m.inst(v)
}

So you can hold an instruction and still change self.collections. Chapter 179 shows the error you get without it.

Collections by number

In TypeScript, collectionOfLoad maps a value to the Collection object, and refsOf is keyed by it. Rust’s collection_of_load maps a value to a usize, the collection’s index in collections. A &mut Collection in the map would keep the Vec borrowed for as long as the map exists, and the pass pushes to that Vec and changes its entries all the time. An index borrows nothing.

Finding candidates is otherwise a line-for-line port. Where TS filters every store and tests the length, Rust stops at the first array store, with let-else:

let st = self.stores_of(binding.id);
let Some(&first_array_store) =
    st.iter().find(|&&x| matches!(
        self.inst(self.inst(x).args[0]).op,
        Op::ArrayEmpty | Op::ArrayNew))
else {
    continue;
};

Some details exist only to match the reference’s text. When an array literal holds something other than a record, the TS message interpolates init.src?.line, which prints undefined if there’s no position. The port spells that out: init.src.map_or("undefined".... And every bailout goes through one helper, which keeps the first source position seen for each message, for --frames:

fn bail(&mut self, msg: String,
        at: Option<SourceRef>) {
    self.bailout_sites
        .entry(msg.clone())
        .or_insert(at);
    self.bailouts.push(msg);
}

The worklist and the borrow checker

Step 2, from shapes_and_uses, trimmed:

let mut work: Vec<ValueId> = self
    .loads.get(&binding).cloned()
    .unwrap_or_default();
let mut seen_param = HashSet::default();
while let Some(l) = work.pop() {
    for u in self.uses_of(l) {
        if let Some(next) = self
            .use_of_collection(ci, l, u,
                &mut seen_param)
        {
            work.push(next);
        }
    }
}

Two differences from TS. First, the TS closure pushes a parameter onto work itself; use_of_collection returns it, because it takes &mut self and the stack is a local of the caller. Second, uses_of(l) returns a copy of the use list. Write self.uses.of(l) in the for instead, and the loop holds a shared borrow of self while its body calls a &mut self method. rustc 1.97 on a small copy of this loop (trimmed and rewrapped):

error[E0502]: cannot borrow `*self` as
  mutable because it is also borrowed
  as immutable
  | for &u in self.uses.of(l) {
  |           ---------------
  |           immutable borrow occurs
  |           here
  |     self.check(u);
  |     ^^^^^^^^^^^^^ mutable borrow
  |                   occurs here

The copy costs a few words per use. It is the simplest thing that lets the method mutate freely. Try the error yourself:

Try itClone the uses, then call &mut self
struct Uses(Vec<Vec<usize>>);

impl Uses {
    fn of(&self, v: usize) -> &[usize] {
        &self.0[v]
    }
}

struct A {
    uses: Uses,
    bails: Vec<String>,
}

impl A {
    // Like Analyzer::uses_of: a copy,
    // so self isn't borrowed after.
    fn uses_of(
        &self,
        v: usize,
    ) -> Vec<usize> {
        self.uses.of(v).to_vec()
    }
    fn check(&mut self, u: usize) {
        let m = format!("use {u}");
        self.bails.push(m);
    }
    fn walk(&mut self, l: usize) {
        // Try self.uses.of(l) here.
        for u in self.uses_of(l) {
            self.check(u);
        }
    }
}

fn main() {
    let mut a = A {
        uses: Uses(vec![vec![1, 2]]),
        bails: vec![],
    };
    a.walk(0);
    println!("{:?}", a.bails);
}
Open in Rust Playground ↗

Order is output

work.pop() takes the last load first, as work.pop() does in TypeScript. That order is visible: it decides which push sets the shape and the order of the messages. Here it is on real VIR, printed from the port for a program that pushes into ps and passes it to a function step:

Step throughFollowing ps through its uses
<module>:
%23 = load ps
%25 = record {x, y} %20, %24
%26 = push %23, %25
%30 = load step
%31 = load ps
%32 = call %30, %31
%35 = load ps
%37 = index.get %35, %36
step(%0):
%4 = field.get %0 .length
%6 = index.get %0, %2
%8 = index.get %0, %2
The worklist stack and what the collection pass has recorded so farSTACK%23%31%35INDEX_GETS—LENGTH_READS—PUSHES—FIELDS— (no shape yet)REFER TO ps%23 %31 %35
find_collections made ps a candidate (its first store is array.empty) and mapped each of its loads to collection 0 in collection_of_load. The stack starts as those loads, in instruction order.
1/7

seen_param stops the walk visiting a parameter twice when two call sites pass the collection. Note what it does not do: a function that passes the parameter on to itself, walk(qs, k + 1), isn’t followed, because the recursive call site passes qs, which isn’t yet known to be ps when the sites are checked. Both engines report it the same way (real output, wrapped):

ps (line 1) {x} → kept as array of
  objects
  · unsafe: line 10: passed to walk(),
    which is also called with other
    values

That refusal is safe, just less precise than it could be.

Definition

The collection pass in Rust is steps 1 and 2 of analysis.rs. find_collections files every load and store by binding, finds the known functions, and makes a Collection for each const or let whose first array store is array.empty or array.new. shapes_and_uses then runs a stack-ordered worklist over each collection’s loads, accepting .length, push of a fresh record, indexing, and known calls (followed into the parameter), and records a bailout with its source site for anything else.

Under the hood

The order rules come from the parity guarantee: 190 of 190 fixture pairs match byte for byte, and the fuzzer compares both engines’ explain and explain --frames text on random programs. A queue instead of a stack would find the same collections and the same bailouts, but it could list them in a different order, and the fixtures compare that order too.

Your turn

ExerciseFollow a collection

Write one and discover, a small model of the worklist in shapes_and_uses. Start with the collection’s loads on a stack and pop from the end. For each use of each popped value, one handles it: Len is fine; Push(r) records r in pushes; Index(e) records e in elements; Call(f, k) returns parameter p.params[f][k], which discover pushes on the stack the first time only; Other(why) adds the bailout "the array is {why}". Order matters: it is the order of the explain messages.

use std::collections::HashSet;

// One use of a value that refers to
// the collection.
pub enum Use {
    // .length
    Len,
    // .push(r): the record r
    Push(usize),
    // [i]: the element value
    Index(usize),
    // passed to known function f as
    // argument k (every call site of f
    // passes this collection there)
    Call(usize, usize),
    // anything else, and why
    Other(&'static str),
}

pub struct Prog {
    // uses[v]: the uses of value v
    pub uses: Vec<Vec<Use>>,
    // params[f][k]: the value of f's
    // k-th parameter
    pub params: Vec<Vec<usize>>,
}

#[derive(Debug, Default, PartialEq)]
pub struct Found {
    pub pushes: Vec<usize>,
    pub elements: Vec<usize>,
    pub bails: Vec<String>,
}

fn one(
    u: &Use,
    p: &Prog,
    out: &mut Found,
) -> Option<usize> {
    todo!()
}

pub fn discover(
    p: &Prog,
    loads: &[usize],
) -> Found {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • The collection pass in Rust keeps the TS closures’ state in Analyzer<'m>, with inst returning &'m Inst so instructions don’t borrow the analyzer.
  • Collections are referred to by index (usize), never by reference, so the Vec of collections can keep changing.
  • uses_of copies the use list so that the loop body can call &mut self methods (E0502 otherwise).
  • The worklist is a stack, as in the reference: the last load first. Known functions are followed by returning the parameter; recursion through the parameter isn’t.

Check your understanding

0/6
  1. 1.

    In the TS reference, collectionOfLoad maps a value to a Collection object. What does the Rust port store instead?

  2. 2.

    Iterating for u in self.uses.of(l) and calling self.bail_somewhere() (a &mut self method) in the body gives which rustc 1.97 error?

  3. 3.

    In explain for a program with two pushes of different shapes, the message reads ({x, y} vs {y, x}), the later push’s shape second. Why?

  4. 4.

    When does use_of_collection follow a collection into a function’s parameter?

  5. 5.

    What is seen_param for?

  6. 6.

    A binding is stored twice: first [], later another value. What does the collection pass do?

References

  1. [1] The Rust Project Developers. rustc-hash: a speedy, non-cryptographic hashing algorithm used by rustc. README of the rustc-hash crate (github.com/rust-lang/rustc-hash); also in the published 2.1.3 crate. github.com/rust-lang/rustc-hash (checked 2026-10-31)
  2. [2] The indexmap developers (bluss, Josh Stone and contributors). indexmap 2: IndexMap and IndexSet. Source and API documentation of the indexmap crate, version 2.14.2, on crates.io (github.com/indexmap-rs/indexmap). docs.rs/indexmap/2/indexmap/ (checked 2026-10-31)