Viscosity
Review
Part XI · Viscosity in Rust · Day 45

177. A generic dataflow framework

Chapter 79 said most of a compiler’s middle is one framework with many instances. engine/rust doesn’t have that framework. It has one hand-written fixpoint. Here you design the framework as Rust traits, build its solver, and see that Viscosity’s real loop is one instance of it, with two differences that matter.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Say what engine/rust really has for dataflow, and why it has no framework.
  • Design a lattice and a problem as Rust traits, with the fact type as an associated type.
  • Write a worklist solver generic over the lattice, and say why it must stop.
  • Map Viscosity’s numeric fixpoint onto the design, including its round cap and how it fails closed.

The framework that isn’t there

Chapter 79 ended with a promise: facts on a lattice, a rule per instruction, a merge, and a worklist are the machinery under most of an optimizing compiler, one framework with many instances[1]. So you might expect the Rust port to have a trait Lattice somewhere. Search engine/rust/crates for trait and you find exactly one: LanguagePlugin (chapter 171).

What it has instead is one fixpoint, written directly over its own state: numeric_inference in crates/core/src/analysis.rs, a port of the same loop in analysis.ts. The other analyses in that file (collections, escapes, bounds) are single passes over use lists, not lattice problems. One instance doesn’t justify a framework, and the parity guarantee (chapter 175) pins the exact order the loop visits things.

So this chapter does two things. It designs a generic framework in Rust: everything in the next three sections is this book’s design, not code in the repo. Then it lines the design up against the real loop.

A lattice is a trait

Intuition

A tax form works for every household because the form never knows what a household is. It only asks for a few numbers and says how to combine them. A generic solver is the form: it never looks inside a fact, it only asks for the top, the bottom and how two facts merge.

A lattice needs a starting point, a safe answer and a merge:

pub trait Lattice: Clone + PartialEq {
    // the optimistic start
    fn top() -> Self;
    // claims nothing: always safe
    fn bottom() -> Self;
    // the highest fact below both
    fn meet(&self, o: &Self) -> Self;
}

A problem says how many nodes there are, which nodes each one reads, and its rule. The fact type is an associated type, as Extra is in LanguagePlugin, because each problem has exactly one:

pub trait Problem {
    type Fact: Lattice;
    fn len(&self) -> usize;
    fn deps(&self, n: usize)
        -> Vec<usize>;
    fn transfer(&self, n: usize,
        f: &[Self::Fact]) -> Self::Fact;
}

Nodes are plain usizes, like the typed ids of chapter 170 without the newtype. For “is it a number?”, Fact = bool, top is true, and meet is &&. For chapter 80’s constants, Fact is an enum Top, K(i64) or Varies.

Definition

A generic dataflow framework is a solver written once, generic over the lattice: any analysis that supplies a fact type with top, bottom and meet, and a transfer function per node, gets a fixpoint from the same code. In Rust, the lattice and the problem are traits, the fact is an associated type, and the solver is a generic function bounded by P: Problem.

The solver

The worklist solver, from the design (the exercise asks you to write its loop):

pub fn solve<P: Problem>(p: &P)
    -> Vec<P::Fact>
{
    let us = users(p); // reverse deps
    let n = p.len();
    let mut f = vec![P::Fact::top(); n];
    let mut work: Vec<usize> =
        (0..n).rev().collect();
    let mut queued = vec![true; n];
    while let Some(v) = work.pop() {
        queued[v] = false;
        let new = p.transfer(v, &f)
            .meet(&f[v]);
        // if new != f[v]: store it, and
        // push v's users not yet queued
    }
    f
}

Two choices are worth naming. The solver stores transfer(..) met with the old fact, which clamps: a fact can only move down. With a lattice of finite height, each node changes a bounded number of times, so the stack must empty, whatever the transfer does. And it is generic, not &dyn Problem: it needs to build a Vec<P::Fact>, and monomorphization makes every transfer and meet a direct call that rustc can inline[2].

Viscosity’s loop, as an instance

The real loop, trimmed from analysis.rs:

const MAX_ROUNDS: usize = 1000;
// ...in numeric_inference:
for v in &m.values {
    let optimistic = matches!(v.op,
        Op::Phi | Op::LoadBinding { .. }
        | Op::FieldGet { .. }
        | Op::Param { .. });
    self.num[v.id.index()] =
        optimistic || self.compute(v);
}
let mut converged = false;
for _round in 0..self.max_rounds {
    let mut changed = false;
    // every value, in id order, in
    // place: int, then num; then every
    // binding; then every field
    if !changed {
        converged = true;
        break;
    }
}
if !converged {
    self.num.fill(false);
    self.int.fill(false);
    // ...and every field fact
}

(Chapter 79’s TypeScript sketch was simplified: both engines cap at 1000 rounds and fail closed like this.) Read against the design:

designanalysis.rs
nodesvalues, bindings, collection fields
Factbool; Python: two Vec<bool>s
top / bottomtrue / false
transfercompute, compute_int, stores, field_stays_numeric
strategyrounds in id order, capped

The Python pair is a product lattice: “is a float” and “is an int”, side by side (the optional exercise builds it). The nodes are of three kinds, not one: a binding is numeric if every store to it is, and a field if every record and every write puts a number there.

Step through the same small problem both ways:

Step throughOne problem, two strategies
Four SSA values with their facts, the worklist stack, and a count of transfer functions runVALUEFACTdesign: worklist%0= 0number%1= phi %0, %3number%2= "!"number%3= %1 + %2numberTRANSFERS0STACK%3%2%1%0pop() takes the right-hand end
The design solver: every fact starts at top (“number”) and every node is on the stack. pop() takes the right-hand end, so %0 comes first.
1/10

Two differences that matter

No clamp on values. Binding and field facts are old && ..., so once false they stay false: that part clamps. Value facts are recomputed from their rules every round, and the first pass reads values not yet computed as false (the code says so). So the loop counts changes in both directions and doesn’t lean on a height argument to stop. It caps the rounds instead.

It fails closed. If round 1000 still changes something, nothing is proven: every value, int and field fact is reset to false. A unit test in analysis.rs pins this with a budget of zero rounds:

let (m, s) = sum_module(); // 1 + 2
let a = Analyzer::new(&m, 0).run();
assert!(!a.is_number(s));

The runnable version below has the same three outcomes: a budget of 1000 is quiet in round 3; a budget of 2 reaches the right facts but never sees a quiet round, so it fails closed; a budget of 0 proves nothing.

Try itRounds, a budget, and failing closed
trait Lattice: Clone + PartialEq {
    fn top() -> Self;
    fn bottom() -> Self;
    fn meet(&self, o: &Self) -> Self;
}

trait Problem {
    type Fact: Lattice;
    fn len(&self) -> usize;
    fn transfer(
        &self,
        n: usize,
        f: &[Self::Fact],
    ) -> Self::Fact;
}

impl Lattice for bool {
    fn top() -> Self {
        true
    }
    fn bottom() -> Self {
        false
    }
    fn meet(&self, o: &Self) -> Self {
        *self && *o
    }
}

// Viscosity's strategy: whole rounds
// in id order, updating in place.
// Returns the facts and the number of
// the first round that changed nothing.
fn rounds<P: Problem>(
    p: &P,
    max_rounds: usize,
) -> Option<(Vec<P::Fact>, usize)> {
    let n = p.len();
    let mut f = vec![P::Fact::top(); n];
    for r in 1..=max_rounds {
        let mut changed = false;
        for v in 0..n {
            let new = p.transfer(v, &f);
            if new != f[v] {
                f[v] = new;
                changed = true;
            }
        }
        if !changed {
            return Some((f, r));
        }
    }
    None
}

// Fail closed: no fixpoint, no facts.
fn facts<P: Problem>(
    p: &P,
    max_rounds: usize,
) -> Vec<P::Fact> {
    match rounds(p, max_rounds) {
        Some((f, r)) => {
            println!("  quiet: {r}");
            f
        }
        None => {
            println!("  out of rounds");
            let b = P::Fact::bottom();
            vec![b; p.len()]
        }
    }
}

enum N {
    Num,
    Str,
    Add(usize, usize),
    Phi(usize, usize),
}

struct G(Vec<N>);

impl Problem for G {
    type Fact = bool;
    fn len(&self) -> usize {
        self.0.len()
    }
    fn transfer(
        &self,
        n: usize,
        f: &[bool],
    ) -> bool {
        match self.0[n] {
            N::Num => true,
            N::Str => false,
            N::Add(a, b) => {
                f[a] && f[b]
            }
            N::Phi(a, b) => {
                f[a].meet(&f[b])
            }
        }
    }
}

fn main() {
    // %0 = 0    %1 = phi %0, %3
    // %2 = "!"  %3 = %1 + %2
    let g = G(vec![
        N::Num,
        N::Phi(0, 3),
        N::Str,
        N::Add(1, 2),
    ]);
    for budget in [1000, 2, 0] {
        println!("budget {budget}:");
        let f = facts(&g, budget);
        println!("  {f:?}");
    }
}
Open in Rust Playground ↗
Under the hood

Could the port swap its rounds for the design’s worklist? For a problem whose rules only move facts down, any fair order reaches the same fixpoint, so the answers would match. Viscosity’s start isn’t quite that, and its facts decide typed columns and index checks, so the port keeps the reference’s rounds and order exactly, and the parity fixtures check the result. A framework would earn its place when a second analysis needs a lattice. Until then, one honest loop with a cap is less code to keep identical.

Your turn

ExerciseA worklist solver, generic over the lattice

Finish solve, the design’s generic worklist solver. Every fact starts at top() and every node starts on the stack work (so pop takes node 0 first). Pop a node, mark it not queued, and compute transfer(v, &f).meet(&f[v]), so a fact can only go down. If that differs from f[v], store it and push each user of v that isn’t already queued. Return the facts when the stack is empty. The tests run the same solver on two lattices: “is it a number?” and “which constant is it?”.

// Facts, ordered. top() is the
// optimistic start, bottom() claims
// nothing, and meet() is the highest
// fact below both.
pub trait Lattice: Clone + PartialEq {
    fn top() -> Self;
    fn bottom() -> Self;
    fn meet(&self, o: &Self) -> Self;
}

// A sparse problem: one fact per node.
pub trait Problem {
    type Fact: Lattice;
    fn len(&self) -> usize;
    // The nodes whose facts n reads.
    fn deps(&self, n: usize)
        -> Vec<usize>;
    // n's fact, from the facts so far.
    fn transfer(
        &self,
        n: usize,
        f: &[Self::Fact],
    ) -> Self::Fact;
}

// The users of each node: who reads it.
fn users<P: Problem>(
    p: &P,
) -> Vec<Vec<usize>> {
    let mut us = vec![vec![]; p.len()];
    for v in 0..p.len() {
        for d in p.deps(v) {
            us[d].push(v);
        }
    }
    us
}

pub fn solve<P: Problem>(
    p: &P,
) -> Vec<P::Fact> {
    let us = users(p);
    let n = p.len();
    let mut f = vec![P::Fact::top(); n];
    // A stack: pop takes node 0 first.
    let mut work: Vec<usize> =
        (0..n).rev().collect();
    let mut queued = vec![true; n];
    todo!()
}
Open in Rust Playground ↗
Extra practiceA product lattice for Python

Python’s numeric fixpoint keeps two facts per value: “is a float” and “is an int”. Implement Lattice for any pair (A, B) of lattices, component by component: top is both tops, bottom both bottoms, and meet meets each side.

// Facts, ordered. top() is the
// optimistic start, bottom() claims
// nothing, and meet() is the highest
// fact below both.
pub trait Lattice: Clone + PartialEq {
    fn top() -> Self;
    fn bottom() -> Self;
    fn meet(&self, o: &Self) -> Self;
}

// A sparse problem: one fact per node.
pub trait Problem {
    type Fact: Lattice;
    fn len(&self) -> usize;
    // The nodes whose facts n reads.
    fn deps(&self, n: usize)
        -> Vec<usize>;
    // n's fact, from the facts so far.
    fn transfer(
        &self,
        n: usize,
        f: &[Self::Fact],
    ) -> Self::Fact;
}

impl<A: Lattice, B: Lattice> Lattice
    for (A, B)
{
    fn top() -> Self {
        todo!()
    }
    fn bottom() -> Self {
        todo!()
    }
    fn meet(&self, o: &Self) -> Self {
        todo!()
    }
}
Open in Rust Playground ↗

What you learned

  • engine/rust has no generic dataflow framework: one fixpoint, numeric_inference, and one trait in the whole engine, LanguagePlugin.
  • The design: Lattice (top, bottom, meet) and Problem (an associated Fact, deps, transfer) as traits, and a solver generic over P: Problem, monomorphized per problem.
  • A clamped worklist only moves facts down, so it stops on a finite lattice.
  • The real loop is an instance: nodes are values, bindings and fields; facts are bools (a pair for Python); the strategy is rounds in id order, capped at MAX_ROUNDS = 1000, and it fails closed.

Check your understanding

0/6
  1. 1.

    What does engine/rust’s analysis.rs use to compute numeric kinds?

  2. 2.

    The fixpoint runs out of rounds without a quiet round. What does it report?

  3. 3.

    In the design, why is solve generic (solve<P: Problem>) rather than taking &dyn Problem?

  4. 4.

    Your design solver clamps: it stores transfer(..).meet(&old). What does that guarantee?

  5. 5.

    Which part of the real fixpoint already behaves like the clamp?

  6. 6.

    With a budget of 2 rounds, the example’s rounds solver has the right facts after round 2, yet fails closed. Why?

References

  1. [1] Keith D. Cooper, Linda Torczon (2022). Engineering a Compiler (3rd edition). Morgan Kaufmann (Elsevier). shop.elsevier.com/books/engineering-a-compiler/cooper/978-0-12-815412-0 (checked 2026-10-31)
  2. [2] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)