Skip to main content

logicaffeine_compile/vm/
pebble.rs

1//! Pebbling — the reverse-liveness pass that decides which journal entries a recording run actually
2//! NEEDS, driving the Bennett history tape toward the Landauer floor.
3//!
4//! **The idea.** The tape is not fundamental. Most journaled bits are *recomputable, not destroyed*:
5//! a scratch temporary `t = f(a,b)` with its inputs still live is a pure function of live state, and
6//! reverse execution only reads live registers for `Bijection` ops (`RevAdd`'s `place`/`delta`, …) and
7//! collection reverses — every `Journaled` overwrite restores its destination straight from the tape
8//! without reading its operands. So a scratch write that only feeds `Journaled` results has an old
9//! value **no reverse step ever reads**; recording it was a naive-forward artifact. Eliding it is
10//! sound and shrinks the tape.
11//!
12//! **P1 rule (sound set-membership).** A Journaled single-destination overwrite is `Elide`d iff its
13//! register is (a) not program-observable — not named in its frame ([`CompiledProgram::named_regs`] /
14//! [`CompiledFunction::named_regs`](crate::vm::CompiledFunction::named_regs)) — AND (b) never a reverse-read operand
15//! ([`crate::vm::reverse::reverse_reads`]). Both the recorder ([`crate::vm::Vm`]'s `record_op`) and the
16//! reverse executor consult the plan, so an elided entry is neither pushed nor popped.
17//!
18//! Empty `named_regs` (hand-built bytecode / no name info) ⇒ everything is treated observable ⇒
19//! nothing is elided ⇒ the reverse executor's full-fidelity behaviour is preserved byte-for-byte.
20//!
21//! [`CompiledFunction::named_regs`]: crate::vm::CompiledFunction::named_regs
22
23use std::collections::HashSet;
24
25use crate::vm::instruction::{CompiledProgram, Op, Reg};
26use crate::vm::reverse::{self, ReverseMode};
27
28/// What the recorder should do with a Journaled write's tape entry.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30pub enum JournalDecision {
31    /// Journal the destroyed value — the reverse executor will read it back.
32    Keep,
33    /// Skip the entry: the destroyed value is program-invisible scratch that no reverse step reads.
34    Elide,
35    /// (`Adiabatic`, P3) Skip the entry; recompute the value on reverse from its pure defining segment.
36    Recompute,
37}
38
39/// Per-pc journaling decisions for a recording run in `mode`. `FullState` keeps everything (today's
40/// behaviour, made explicit); the pebbling modes elide scratch whose destroyed value no reverse step
41/// reads. Indexed by absolute pc in [`CompiledProgram::code`].
42pub fn plan(prog: &CompiledProgram, mode: ReverseMode) -> Vec<JournalDecision> {
43    plan_budgeted(prog, mode, 0)
44}
45
46/// [`plan`] with an explicit pebble CHECKPOINT budget (corner 2). Under `Adiabatic`, `budget` recomputable
47/// chain intermediates are KEPT on the tape (journaled once — `landauer = 0`, since a recomputable
48/// scratch is first-def so its displaced value is `Nothing`) instead of upgraded to `Recompute`. A
49/// reverse step that reads a node above a checkpoint then re-walks only down to the nearest checkpoint,
50/// not to the chain root — the Bennett space-for-time trade. `budget = 0` is the recompute-everything
51/// corner (`O(1)` tape, chain·reads time); higher budgets bound the re-walk length.
52pub fn plan_budgeted(
53    prog: &CompiledProgram,
54    mode: ReverseMode,
55    budget: usize,
56) -> Vec<JournalDecision> {
57    let n = prog.code.len();
58    if mode == ReverseMode::FullState {
59        return vec![JournalDecision::Keep; n];
60    }
61    // FRAME-OVERLAP SOUNDNESS (Piece 1). A callee runs at `base = args_start`, so its registers ALIAS
62    // the caller's slots `[args_start..]` — a register that reads as "scratch" in a callee frame can be
63    // the SAME absolute slot as a NAMED variable in the caller. We resolve this exactly: compute each
64    // function's possible absolute BASE(s) from the call graph, then elide a write only if NONE of the
65    // absolute slots it can occupy is a Main-observable slot. Recursion / dynamic dispatch give
66    // unbounded / unknown bases → elision is disabled (sound).
67    let bases = match frame_bases(prog) {
68        Some(b) => b,
69        None => return vec![JournalDecision::Keep; n],
70    };
71    // The absolute slots compared at reverse end = Main's named registers. Empty `named_regs` (hand-
72    // built bytecode) ⇒ EVERY Main slot is observable, so nothing touching Main's frame is elided —
73    // preserving the reverse executor's full-fidelity tests.
74    let main_size = prog.register_count;
75    let main_all = prog.named_regs.is_empty();
76    let main_observable = |slot: usize| -> bool {
77        slot < main_size && (main_all || prog.named_regs.get(slot).copied().unwrap_or(false))
78    };
79    // Registers any reverse step reads live (Bijection operands + collection handles) — pinned Keep.
80    let mut reverse_read: HashSet<Reg> = HashSet::new();
81    for op in &prog.code {
82        reverse_read.extend(reverse::reverse_reads(op));
83    }
84    let mut plan = (0..n)
85        .map(|pc| {
86            let op = &prog.code[pc];
87            let Some(r) = reverse::overwritten_reg(op) else {
88                return JournalDecision::Keep;
89            };
90            if !reverse::records(op) || reverse_read.contains(&r) {
91                return JournalDecision::Keep;
92            }
93            let frame = frame_of(prog, pc);
94            // Named in its own frame? (Empty `named_regs` ⇒ observable ⇒ keep.)
95            let f_named = match frame {
96                None => main_all || prog.named_regs.get(r as usize).copied().unwrap_or(true),
97                Some(f) => {
98                    let nm = &prog.functions[f].named_regs;
99                    nm.is_empty() || nm.get(r as usize).copied().unwrap_or(true)
100                }
101            };
102            if f_named {
103                return JournalDecision::Keep;
104            }
105            // Elide only if NONE of the absolute slots this write can occupy (over every possible base)
106            // is a Main-observable slot.
107            let touches_observable = match frame {
108                None => main_observable(r as usize),
109                Some(f) => bases[f].iter().any(|&b| main_observable(b + r as usize)),
110            };
111            if touches_observable {
112                JournalDecision::Keep
113            } else {
114                JournalDecision::Elide
115            }
116        })
117        .collect::<Vec<_>>();
118    // ADIABATIC (Piece 2): upgrade each recomputable reverse-live scratch DEF from `Keep` to
119    // `Recompute` — re-run on reverse from stable live inputs instead of journaled, pushing the tape
120    // BELOW Observable while keeping full fidelity. Corner 2: a `budget` of chain intermediates stays
121    // `Keep` (a CHECKPOINT) so downstream recomputes stop there instead of re-walking to the root.
122    if mode == ReverseMode::Adiabatic {
123        let checkpoints = checkpoint_defs(prog, budget);
124        for (_frame, _r, def_pc, _op) in recomputable(prog) {
125            if !checkpoints.contains(&def_pc) {
126                plan[def_pc] = JournalDecision::Recompute;
127            }
128        }
129    }
130    plan
131}
132
133/// The recomputable-chain intermediates to KEEP as checkpoints (corner 2) — the `budget` nodes that
134/// most shorten the reverse re-walk. An INTERMEDIATE is a recomputable node whose register is an input
135/// to another recomputable def in the same frame (so it sits *inside* a chain, not at its top). We
136/// checkpoint the intermediates CLOSEST to the chain tops (highest `def_pc`) first: keeping a node near
137/// the top caps every above-it read at a re-walk of length 1, the largest marginal saving per pebble
138/// (the Bennett "cache near the read" heuristic). `budget = 0` ⇒ none (recompute everything).
139pub fn checkpoint_defs(prog: &CompiledProgram, budget: usize) -> HashSet<usize> {
140    if budget == 0 {
141        return HashSet::new();
142    }
143    let rec = recomputable(prog);
144    // (frame, input-reg) pairs consumed by some recomputable def — the interior edges of the chains.
145    let mut consumed: HashSet<(Option<usize>, Reg)> = HashSet::new();
146    for (frame, _r, _pc, op) in &rec {
147        for input in def_inputs(op) {
148            consumed.insert((*frame, input));
149        }
150    }
151    let mut intermediates: Vec<usize> = rec
152        .iter()
153        .filter(|(frame, r, _pc, _op)| consumed.contains(&(*frame, *r)))
154        .map(|(_f, _r, pc, _op)| *pc)
155        .collect();
156    // Highest def_pc first (closest to the chain tops), then take `budget` of them.
157    intermediates.sort_unstable_by(|a, b| b.cmp(a));
158    intermediates.into_iter().take(budget).collect()
159}
160
161/// The two input registers of a pure two-operand recomputable def (kept in lockstep with
162/// `recomputable`'s accepted-op set). Empty for anything else.
163fn def_inputs(op: &Op) -> Vec<Reg> {
164    match *op {
165        Op::Add { lhs, rhs, .. }
166        | Op::Sub { lhs, rhs, .. }
167        | Op::Mul { lhs, rhs, .. }
168        | Op::BitXor { lhs, rhs, .. }
169        | Op::Div { lhs, rhs, .. }
170        | Op::ExactDiv { lhs, rhs, .. }
171        | Op::FloorDiv { lhs, rhs, .. }
172        | Op::Mod { lhs, rhs, .. }
173        | Op::Pow { lhs, rhs, .. }
174        | Op::BitAnd { lhs, rhs, .. }
175        | Op::BitOr { lhs, rhs, .. }
176        | Op::Shl { lhs, rhs, .. }
177        | Op::Shr { lhs, rhs, .. } => vec![lhs, rhs],
178        _ => vec![],
179    }
180}
181
182/// The pc range `[lo, hi)` of a frame — Main is `[0, first-function-entry)`, function `i` is
183/// `[entry_pc_i, next-entry)` (bodies are contiguous in `entry_pc` order; see `vm/compiler.rs`).
184fn frame_range(prog: &CompiledProgram, frame: Option<usize>) -> (usize, usize) {
185    match frame {
186        None => (
187            0,
188            prog.functions
189                .iter()
190                .map(|f| f.entry_pc)
191                .min()
192                .unwrap_or(prog.code.len()),
193        ),
194        Some(i) => {
195            let lo = prog.functions[i].entry_pc;
196            let hi = prog
197                .functions
198                .iter()
199                .map(|f| f.entry_pc)
200                .filter(|&e| e > lo)
201                .min()
202                .unwrap_or(prog.code.len());
203            (lo, hi)
204        }
205    }
206}
207
208/// Reverse-live scratch registers that can be RECOMPUTED on reverse instead of journaled — Bennett
209/// uncompute — keyed by the FRAME they live in (register numbers are frame-relative). Each is a
210/// SCRATCH register that some reverse step in its frame reads live (so `Observable`/`FullState` must
211/// journal it), with a SINGLE pure-arithmetic def whose inputs are STABLE within the frame, and that
212/// (via the frame-overlap bases) aliases NO Main-observable slot. Re-running the def during reverse
213/// with `self.base` set to the frame reconstructs it exactly. Returns `(frame, register, def_pc,
214/// def_op)`. Dynamic dispatch (`CallValue`, unknown callee) yields nothing; recursion is handled via
215/// the bounded frame bases (corner 4) — each activation recomputes at its own live base.
216pub fn recomputable(prog: &CompiledProgram) -> Vec<(Option<usize>, Reg, usize, Op)> {
217    if prog
218        .code
219        .iter()
220        .any(|op| matches!(op, Op::CallValue { .. }))
221    {
222        return Vec::new();
223    }
224    let bases = match frame_bases(prog) {
225        Some(b) => b,
226        None => return Vec::new(),
227    };
228    let main_size = prog.register_count;
229    let main_all = prog.named_regs.is_empty();
230    let main_observable = |slot: usize| {
231        slot < main_size && (main_all || prog.named_regs.get(slot).copied().unwrap_or(false))
232    };
233    // The registers an op WRITES (mutates) — a fresh overwrite, a collection mutation, or a Bijection's
234    // in-place `place`/`a`/`b`. NOT a Bijection's `delta` (read-only, surfaced by `reverse_reads`).
235    let writes = |op: &Op| -> Vec<Reg> {
236        match *op {
237            Op::RevAdd { place, .. } | Op::RevSub { place, .. } | Op::RevXor { place, .. } => {
238                vec![place]
239            }
240            Op::RevNeg { place } => vec![place],
241            Op::RevSwap { a, b } => vec![a, b],
242            _ => reverse::overwritten_reg(op)
243                .into_iter()
244                .chain(reverse::mutated_collection_reg(op))
245                .collect(),
246        }
247    };
248    let frames = std::iter::once(None).chain((0..prog.functions.len()).map(Some));
249    let mut out = Vec::new();
250    for frame in frames {
251        let (lo, hi) = frame_range(prog, frame);
252        let named: &[bool] = match frame {
253            None => &prog.named_regs,
254            Some(i) => &prog.functions[i].named_regs,
255        };
256        if named.is_empty() {
257            continue; // no name info for this frame ⇒ do not recompute (safe fallback)
258        }
259        let mut reverse_read: Vec<Reg> = Vec::new();
260        for pc in lo..hi {
261            for r in reverse::reverse_reads(&prog.code[pc]) {
262                if !reverse_read.contains(&r) {
263                    reverse_read.push(r);
264                }
265            }
266        }
267        for r in reverse_read {
268            if named.get(r as usize).copied().unwrap_or(true) {
269                continue; // named — must be journaled to restore its value
270            }
271            // Frame-overlap: skip if any absolute slot this register can occupy is Main-observable.
272            let aliases_observable = match frame {
273                None => main_observable(r as usize),
274                Some(i) => bases[i].iter().any(|&b| main_observable(b + r as usize)),
275            };
276            if aliases_observable {
277                continue;
278            }
279            let defs: Vec<usize> = (lo..hi)
280                .filter(|&pc| reverse::overwritten_reg(&prog.code[pc]) == Some(r))
281                .collect();
282            if defs.len() != 1 {
283                continue;
284            }
285            let def_pc = defs[0];
286            let def_op = prog.code[def_pc];
287            // The whole pure two-operand family is recomputable (corner 3): re-running any of these from
288            // the same live inputs reproduces the exact value. Keep in lockstep with `recompute_into`.
289            let (lhs, rhs) = match def_op {
290                Op::Add { lhs, rhs, .. }
291                | Op::Sub { lhs, rhs, .. }
292                | Op::Mul { lhs, rhs, .. }
293                | Op::BitXor { lhs, rhs, .. }
294                | Op::Div { lhs, rhs, .. }
295                | Op::ExactDiv { lhs, rhs, .. }
296                | Op::FloorDiv { lhs, rhs, .. }
297                | Op::Mod { lhs, rhs, .. }
298                | Op::Pow { lhs, rhs, .. }
299                | Op::BitAnd { lhs, rhs, .. }
300                | Op::BitOr { lhs, rhs, .. }
301                | Op::Shl { lhs, rhs, .. }
302                | Op::Shr { lhs, rhs, .. } => (lhs, rhs),
303                _ => continue,
304            };
305            // Stability WITHIN the frame: neither the register nor its inputs are re-written after the
306            // def, so the reverse-time recompute reproduces the exact forward value.
307            let modified =
308                |reg: Reg| ((def_pc + 1)..hi).any(|pc| writes(&prog.code[pc]).contains(&reg));
309            if modified(r) || modified(lhs) || modified(rhs) {
310                continue;
311            }
312            out.push((frame, r, def_pc, def_op));
313        }
314    }
315    out
316}
317
318/// A static summary of a mode's plan: how many Journaled entries survive vs. are elided.
319#[derive(Debug, Clone)]
320pub struct PebbleReport {
321    pub mode: ReverseMode,
322    pub ops: usize,
323    pub journaled: usize,
324    pub elided: usize,
325}
326
327/// Summarise the plan for `mode` — the count of Journaled writes kept vs. elided (the static
328/// space budget; the dynamic erased-byte floor comes from a recording run's tape).
329pub fn report(prog: &CompiledProgram, mode: ReverseMode) -> PebbleReport {
330    let plan = plan(prog, mode);
331    let (mut journaled, mut elided) = (0usize, 0usize);
332    for (pc, op) in prog.code.iter().enumerate() {
333        if reverse::records(op) {
334            match plan[pc] {
335                JournalDecision::Keep => journaled += 1,
336                JournalDecision::Elide | JournalDecision::Recompute => elided += 1,
337            }
338        }
339    }
340    PebbleReport {
341        mode,
342        ops: prog.code.len(),
343        journaled,
344        elided,
345    }
346}
347
348/// The function that owns `pc` — the one with the greatest `entry_pc ≤ pc` (Main is compiled first,
349/// then each function body appended contiguously; see `vm/compiler.rs` Pass 2a/2b). `None` = Main.
350pub fn frame_of(prog: &CompiledProgram, pc: usize) -> Option<usize> {
351    let mut best: Option<usize> = None;
352    for (i, f) in prog.functions.iter().enumerate() {
353        if f.entry_pc <= pc && best.map_or(true, |b| f.entry_pc > prog.functions[b].entry_pc) {
354            best = Some(i);
355        }
356    }
357    best
358}
359
360/// Each function's set of possible absolute BASE offsets — the register-window origins it can run at,
361/// propagated from Main (base 0) through the call graph (`callee_base = caller_base + args_start`).
362///
363/// RECURSION-SAFE (corner 4). The elision decision only ever asks `main_observable(base + r)`, which
364/// is `false` whenever `base + r ≥ main_size`. So a base `≥ main_size` is USELESS — it can never make
365/// any slot observable — and we drop it. Dropping useless bases truncates a recursion's
366/// arithmetic-progression bases (`b₀, b₀+A, b₀+2A, …`) to a FINITE set inside `[0, main_size)`, so the
367/// monotone-insertion fixpoint TERMINATES even when the call graph has a cycle — no bail on recursion.
368/// This loses nothing: every base that could make a slot observable satisfies `base < main_size` and is
369/// kept; the multi-hop overlap (a grandchild aliasing a grandparent-Main slot) is captured because the
370/// propagated `base` already sums every hop's `args_start`. Only dynamic dispatch (`CallValue` — the
371/// callee, hence its base, is not statically known) still returns `None` and disables elision. An
372/// uncalled (or only-beyond-Main) function keeps an empty set — sound, its scratch aliases no Main slot.
373fn frame_bases(prog: &CompiledProgram) -> Option<Vec<Vec<usize>>> {
374    if prog
375        .code
376        .iter()
377        .any(|op| matches!(op, Op::CallValue { .. }))
378    {
379        return None;
380    }
381    let n = prog.functions.len();
382    let main_size = prog.register_count;
383    // Static call sites: (caller frame, args_start, callee index).
384    let sites: Vec<(Option<usize>, usize, usize)> = prog
385        .code
386        .iter()
387        .enumerate()
388        .filter_map(|(pc, op)| match op {
389            Op::Call {
390                func, args_start, ..
391            } => Some((frame_of(prog, pc), *args_start as usize, *func as usize)),
392            _ => None,
393        })
394        .collect();
395    let mut bases: Vec<std::collections::BTreeSet<usize>> = vec![Default::default(); n];
396    // Monotone insertion into the finite domain `frames × [0, main_size)` converges in at most that
397    // many steps; the cap is a backstop that the bounded domain guarantees is never hit.
398    let cap = n * (main_size + 1) + n + 2;
399    for _ in 0..cap {
400        let mut changed = false;
401        for &(caller, args_start, callee) in &sites {
402            let caller_bases: Vec<usize> = match caller {
403                None => vec![0],
404                Some(c) => bases[c].iter().copied().collect(),
405            };
406            for cb in caller_bases {
407                let b = cb + args_start;
408                // Drop bases ≥ main_size (they can never alias a Main-observable slot) — this is what
409                // makes the recursion fixpoint finite.
410                if b < main_size && bases[callee].insert(b) {
411                    changed = true;
412                }
413            }
414        }
415        if !changed {
416            return Some(bases.into_iter().map(|s| s.into_iter().collect()).collect());
417        }
418    }
419    None
420}
421
422#[cfg(test)]
423#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/vm/pebble/tests.rs"]
424mod tests;