Viscosity
Review
Part XI · Viscosity in Rust · Day 46

181. Access patterns in Rust

The last analysis step writes down three facts about each collection: which fields every loop touches, which element reads are provably in bounds, and how many elements there will be. In Rust that is one function, an IndexMap keyed by loop, and two small proofs over SSA values.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Read the per-loop access table the port builds, and explain the order of its fields.
  • Follow the bounds proof through counting phis, non-negative starts and the loop guard.
  • Say when a size is “about”, when it is “at most”, and when it is unknown.
  • Implement the counting-phi test over a small arena of values, with tests.

Three facts per collection

Here is a small program:

const ps = [];
for (let i = 0; i < 500; i++)
  ps.push({ x: i, y: 0, vx: 1, vy: 2 });
for (let i = 0; i < ps.length; i++) {
  ps[i].x += ps[i].vx;
  ps[i].y += ps[i].vy;
}
let s = 0;
for (let i = 1; i < ps.length; i++)
  s += ps[i - 1].x;
console.log(s, ps[0].y);

and part of what the Rust engine says about it (viscosity explain, release build, rustc 1.97; wrapped):

  · loop at line 4 in <module>() touches 4
    of 4 fields (vy, y, vx, x)
  · loop at line 9 in <module>() touches 1
    of 4 fields (x)
  · about 500 elements
  · 4 of 6 element accesses proven in bounds

All four lines come from one function in crates/core/src/analysis.rs, bounds_loops_and_sizes. Chapter 98 told the story in TypeScript. This chapter reads the Rust, and explains two odd details: the order vy, y, vx, x, and why ps[i - 1] isn’t proven.

Intuition

A building inspector fills in three boxes for each house: which rooms each person uses, whether every door they walk through really exists, and how many people will live there. Nothing is rebuilt yet. The planner reads the form later.

One table per loop

Each field access is filed under the loop it happens in, in a plain struct (comments shortened):

pub struct LoopAccess {
    pub func: String,
    pub line: u32,
    pub depth: u32, // 0: outside loops
    pub reads: IndexSet<String>,
    pub writes: IndexSet<String>,
    /// Iterations, if known.
    pub trips: Option<f64>,
}

An IndexMap groups them. Its iteration order is insertion order, like a JavaScript Map[1]. The key is the function and the header of the innermost loop around the access, or None outside loops (adapted):

for a in &c.accesses {
    let ai = self.inst(a.inst);
    let f = self.func_of[ai.id.index()]
        .expect("live");
    let (loops, _) = &self.cfg[&f];
    let l = loop_of(loops, ai.block);
    let la = by_loop
        .entry((f, l.map(|l| l.header)))
        .or_insert_with(|| /* line, depth,
            trips; empty sets */);
    let set = match a.kind {
        AccessKind::Read => &mut la.reads,
        AccessKind::Write => &mut la.writes,
    };
    set.insert(a.field.clone());
}

or_insert_with builds a table only the first time a key is seen. Option<BlockId> makes “outside any loop” a real key, not a magic block number. Afterwards the tables are sorted by line, stably[3]. Printed with {:?} (wrapped, func removed), the first two are:

LoopAccess { line: 4, depth: 1,
  reads: {"vy", "y", "vx", "x"},
  writes: {"y", "x"}, trips: None }
LoopAccess { line: 9, depth: 1,
  reads: {"x"}, writes: {}, trips: None }

A third table, depth 0, holds ps[0].y. Explain prints only loops.

Now the order. “First-access order” means the order the analysis visits accesses, not source order. The earlier pass that checks each use of the collection finds element reads with a worklist and work.pop(), which takes the last load of ps first. So line 11 is visited before line 10, and line 6 before line 5. The TypeScript reference pops too, so both engines print (vy, y, vx, x).

Try itAccess tables, in visit order
struct Table {
    line: u32,
    depth: u32,
    reads: Vec<&'static str>,
    writes: Vec<&'static str>,
}

// Insert once, keep first-seen order
// (what IndexSet does).
fn add(
    v: &mut Vec<&'static str>,
    f: &'static str,
) {
    if !v.contains(&f) {
        v.push(f);
    }
}

fn main() {
    // (loop line, depth, field, write?)
    // in the order the port visits
    // them in this chapter's program.
    let acc = [
        (11, 0, "y", false),
        (9, 1, "x", false),
        (4, 1, "vy", false),
        (4, 1, "y", false),
        (4, 1, "y", true),
        (4, 1, "vx", false),
        (4, 1, "x", false),
        (4, 1, "x", true),
    ];
    let mut ts: Vec<Table> = vec![];
    for (line, depth, f, w) in acc {
        // (The port keys by loop
        // header, not by line.)
        let i = ts
            .iter()
            .position(|t| {
                t.line == line
            });
        let i = match i {
            Some(i) => i,
            None => {
                ts.push(Table {
                    line,
                    depth,
                    reads: vec![],
                    writes: vec![],
                });
                ts.len() - 1
            }
        };
        let t = &mut ts[i];
        if w {
            add(&mut t.writes, f);
        } else {
            add(&mut t.reads, f);
        }
    }
    // Stable, like the port's sort.
    ts.sort_by_key(|t| t.line);
    for t in &ts {
        if t.depth == 0 {
            continue;
        }
        let mut all = t.reads.clone();
        for &f in &t.writes {
            add(&mut all, f);
        }
        println!(
            "line {}: {} of 4 ({})",
            t.line,
            all.len(),
            all.join(", ")
        );
    }
}
Open in Rust Playground ↗

Proving an index in bounds

Chapter 104 explained why an unproven access needs __vsc_bc. The proof asks four questions about each index.get. Step through them on a program with four reads:

Step throughIs this index in bounds?
A short program with four element reads, and the four questions the bounds proof asks about each 1const ps = []; 2for (let i = 0; i < 300; i++) 3 ps.push({ x: i }); 4for (let i = 0; i < ps.length; i++) 5 for (let j = i + 1; j < ps.length; j++) 6 ps[j].x -= ps[i].x; 7for (let k = -1; k < ps.length; k++) 8 ps[k + 1].x += 1; 9console.log(ps[1].x);four reads of ps·index is a phi in the header of a loop around the access·counting: starts at a non-negative integer, steps by +1·the header tests index < ps.length (one conjunct is enough)·the true edge of that test dominates the access
Four element reads: ps[j] and ps[i] on line 6, ps[k + 1] on line 8, ps[1] on line 9. Each index.get gets the same four questions, and is proven only if all four say yes.
1/8

The second question is is_counting_phi: two operands, a start and a step that is the phi plus 1. SSA doesn’t say which operand comes from the latch, so it tries both orders (adapted):

for (s0, s1) in [(0, 1), (1, 0)] {
    let start = self.inst(phi.args[s0]);
    let step = self.inst(phi.args[s1]);
    // (skip unless step is `phi + 1`)
    let ok = if zero_start {
        is_zero(start)
    } else {
        let seen = &mut HashSet::default();
        self.non_neg_int(start, seen)
    };
    if ok { return true; }
}

Trip counts ask for a start of exactly 0. Bounds only need a non-negative integer, so j = i + 1 counts. non_neg_int accepts whole finite constants from 0 up, + and * of such values, and phis whose operands all are. It begins:

if !seen.insert(v.id) {
    return true;
}
match &v.op {
    Op::Phi => v.args.iter().all(|&a| {
        let a = self.inst(a);
        self.non_neg_int(a, seen)
    }),
    // constants, + and *, unary +

HashSet::insert returns false when the value was already there[2]. That call does two jobs. It stops the recursion going round a loop (i is a phi of 0 and i + 1, which mentions i), and it answers yes for the repeated value: assume i is fine, and let the rest of the cycle decide.

Definition

The access-pattern pass in Rust is bounds_loops_and_sizes in analysis.rs. It sets bounds_proven on each index.get whose index is a counting phi (a non-negative integer start, a +1 step) of a loop around the access, guarded by index < c.length on a true edge that dominates it. It files field accesses into one LoopAccess per loop, keyed by function and loop header. And it estimates the collection’s size.

One Rust detail. The proofs are pushed into a local Vec<bool>, and a second loop zips them into index_gets. The proving loop calls &self methods, which borrow all of self, so it can’t also hold a mutable borrow of the collection. Two phases keep the borrows apart.

About, at most, or unknown

The size is the number of literal elements, plus, for each push, the product of the trip counts of the loops around it. A loop without a constant bound makes it unknown. So does a push inside a function, whose count depends on the calls (f != m.main). The result is known.then_some(size), an Option<f64>.

A push that doesn’t run on every path makes the estimate a maximum. push_runs_on_every_path checks each region from the function down to the innermost loop: the next thing in (an inner header, or the push) must dominate every exit, its return and throw blocks for the function and its latches for a loop.

for (let i = 0; i < 100; i++) {
  if (i % 3 === 0) ps.push({ x: i });
}

Explain says at most 100 elements. The program pushes 34. The next chapter’s planner compares that maximum with its threshold as if it were the size.

Under the hood

crates/core/tests/core.rs mirrors the reference’s unit tests. Nested constant loops of 40 and 5 give Some(200.0), not an upper bound. An if/else with a push on each side gives Some(200.0) with the flag set: each trip pushes exactly once, but neither push dominates the latch, so the proof can’t see it. The port reproduces the reference exactly, including where it is cautious.

Your turn

ExerciseCounting phis

Write the two proofs of the bounds check over a small arena o. non_neg(o, v, seen): if v is already in seen, return true; otherwise insert it, then accept a finite whole Num that is at least 0, an Add whose two operands both are, or a Phi whose operands all are. Anything else is false. counting(o, v): v is a Phi with exactly two operands, and in one of the two orders, the second operand is Add(v, one) where one is Num(1.0), and the first is a non-negative integer (with a fresh seen).

use std::collections::HashSet;

// One SSA value. Operands are indexes
// into the same slice (typed ids, kept
// as usize here).
enum Op {
    Num(f64),
    // a + b
    Add(usize, usize),
    Phi(Vec<usize>),
    Other,
}

fn non_neg(
    o: &[Op],
    v: usize,
    seen: &mut HashSet<usize>,
) -> bool {
    todo!()
}

fn counting(
    o: &[Op],
    v: usize,
) -> bool {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • bounds_loops_and_sizes records access tables, bounds proofs and a size for each collection.
  • Tables are keyed by function and Option of loop header in an IndexMap. Field order is visit order, in both engines.
  • An index is proven when it is a counting phi with a non-negative integer start, guarded by < c.length on a dominating edge.
  • non_neg_int handles cycles with seen.insert: a value seen twice is assumed fine.
  • A size is about N, at most N (a push on some paths only), or unknown.

Check your understanding

0/6
  1. 1.

    An access sits in the inner loop of two nested loops. Which table is it filed under?

  2. 2.

    Why does the port use IndexMap and IndexSet here rather than HashMap and HashSet?

  3. 3.

    for (let j = i + 1; j < ps.length; j++) ps[j].x = 0; sits inside a counting loop over i. Is ps[j] proven?

  4. 4.

    non_neg_int reaches the same value twice in one query. What does it return for it, and why?

  5. 5.

    for (let i = 0; i < 100; i++) if (i % 3 === 0) ps.push({ x: i }); What does explain say about the size?

  6. 6.

    Pushes happen in a function add, called in a constant loop of 50. What size does the analysis record?

References

  1. [1] 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)
  2. [2] The Rust Project Developers. HashSet::insert (Rust standard library). Rust standard library; source in library/std/src/collections/hash/set.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/collections/struct.HashSet.html#method.insert (checked 2026-10-31)
  3. [3] The Rust Project Developers. slice::sort_by_key (Rust standard library). Rust standard library; source in library/alloc/src/slice.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/primitive.slice.html#method.sort_by_key (checked 2026-10-31)