Skip to main content

nodejs/
tiers.rs

1//! Which fusevm execution tier a program's bytecode actually reaches.
2//!
3//! Enabling the JIT is not the same as being compiled by it, and the only
4//! honest way to tell the two apart is to ask the VM. This module runs a
5//! program and then queries fusevm's own eligibility and cache predicates —
6//! `is_block_eligible`, `block_jit_is_compiled`, `trace_is_compiled`,
7//! `find_jit_region` — so the answer comes from the compiler that would have
8//! done the work rather than from an assumption about it.
9//!
10//! `node --tiers script.js` prints the report.
11
12use std::collections::BTreeMap;
13
14use fusevm::{Chunk, ChunkBuilder, JitCompiler, Op};
15
16/// A loop header — the target of a backward branch — and what became of it.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Loop {
19    /// Op index of the loop header the backward branch jumps to.
20    pub anchor: usize,
21    /// Whether fusevm would accept this loop's body as a trace. Asked of
22    /// `is_trace_eligible` with the body's ops — the same predicate the
23    /// recorder applies to what it recorded, which for a loop whose body has
24    /// no early exit is the same op sequence.
25    pub trace_eligible: bool,
26    /// Whether a compiled trace is installed for this header after the run.
27    pub traced: bool,
28    /// Whether the tracing JIT gave up on this header.
29    pub blacklisted: bool,
30}
31
32/// What the tiers did with one chunk.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ChunkTiers {
35    /// Which chunk this is — `main` for a whole JavaScript program.
36    pub name: String,
37    /// Ops in the compiled chunk.
38    pub ops: usize,
39    /// Whether every op in the chunk is block-JIT eligible, which is what the
40    /// whole-chunk block tier requires.
41    pub block_eligible: bool,
42    /// Whether the block tier holds compiled native code for this chunk.
43    pub block_compiled: bool,
44    /// The largest contiguous block-eligible op range, if any is large enough
45    /// for fusevm to consider it worth compiling.
46    pub largest_eligible_region: Option<(usize, usize)>,
47    /// Every loop header, and whether the tracing JIT compiled it.
48    pub loops: Vec<Loop>,
49    /// Op kinds the **block** tier refuses, by occurrence count — what keeps
50    /// the whole chunk from being compiled in one piece.
51    ///
52    /// Not the same question as whether a loop is traced: the tracing tier
53    /// takes `GetVar` / `SetVar` (fusevm promotes a referenced global to a
54    /// register at trace entry and spills it at every exit), so a chunk can
55    /// list those here and still reach native code through a trace.
56    pub ineligible: BTreeMap<String, usize>,
57}
58
59impl ChunkTiers {
60    /// Whether any tier holds compiled native code for this chunk.
61    pub fn reaches_native(&self) -> bool {
62        self.block_compiled || self.loops.iter().any(|l| l.traced)
63    }
64}
65
66/// What the tiers did with one program.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Report {
69    /// Every chunk the program compiled to, in the order they were lowered.
70    pub chunks: Vec<ChunkTiers>,
71}
72
73impl Report {
74    /// Whether any tier holds compiled native code for any of the program's
75    /// chunks.
76    pub fn reaches_native(&self) -> bool {
77        self.chunks.iter().any(|c| c.reaches_native())
78    }
79}
80
81impl std::fmt::Display for ChunkTiers {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        writeln!(f, "ops                     {}", self.ops)?;
84        writeln!(f, "block-JIT eligible      {}", self.block_eligible)?;
85        writeln!(f, "block-JIT compiled      {}", self.block_compiled)?;
86        match self.largest_eligible_region {
87            Some((s, e)) => writeln!(f, "largest eligible region {s}..{e} ({} ops)", e - s)?,
88            None => writeln!(f, "largest eligible region none")?,
89        }
90        if self.loops.is_empty() {
91            writeln!(f, "loops                   none")?;
92        }
93        for l in &self.loops {
94            writeln!(
95                f,
96                "loop @{:<4}             trace-eligible={} traced={} blacklisted={}",
97                l.anchor, l.trace_eligible, l.traced, l.blacklisted
98            )?;
99        }
100        if self.ineligible.is_empty() {
101            writeln!(f, "block-ineligible ops    none")?;
102        } else {
103            writeln!(f, "block-ineligible ops")?;
104            for (name, count) in &self.ineligible {
105                writeln!(f, "  {name:<22}{count}")?;
106            }
107        }
108        Ok(())
109    }
110}
111
112impl std::fmt::Display for Report {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        // A single-chunk program needs no section headers; the fleet's
115        // multi-chunk frontends label each one.
116        let label = self.chunks.len() > 1;
117        for c in &self.chunks {
118            if label {
119                writeln!(f, "== {} ==", c.name)?;
120            }
121            write!(f, "{c}")?;
122        }
123        write!(f, "reaches native code     {}", self.reaches_native())
124    }
125}
126
127/// Compile and run `src`, then report which tiers took each of its chunks.
128///
129/// The program is run because tier membership is a runtime fact: the block tier
130/// compiles after its warmup threshold and the tracing tier only after a loop
131/// has gone round enough times to be recorded. node-js writes program output
132/// straight to the process stdout, so the program's own output precedes the
133/// report — what is measured is what an ordinary run does.
134///
135/// The inspection compile happens *before* the run and never loads onto the
136/// host, so both compiles emit byte-identical ops. That is what lets fusevm's
137/// op-hash-keyed caches answer for the run that just happened.
138pub fn report(src: &str) -> Result<Report, String> {
139    let named = program_chunks(&crate::compile(src)?);
140    crate::eval_str(src)?;
141    Ok(inspect_all(&named))
142}
143
144/// Every chunk a compiled program holds, named for the report.
145///
146/// A JavaScript program is not one chunk: the top level, every function and
147/// arrow body, and every arm of a `try` lower to their own chunk. A hot loop
148/// usually lives in one of the latter, so reporting only `main` would answer
149/// the wrong question.
150fn program_chunks(prog: &crate::compiler::Program) -> Vec<(String, Chunk)> {
151    let mut out = vec![("main".to_string(), prog.main.clone())];
152    for (name, f) in &prog.functions {
153        out.push((format!("function {name}"), f.chunk.clone()));
154    }
155    for (i, t) in prog.tries.iter().enumerate() {
156        out.push((format!("try #{i}"), t.block.clone()));
157        if let Some((_, ch)) = &t.handler {
158            out.push((format!("try #{i} catch"), ch.clone()));
159        }
160        if let Some(ch) = &t.finalizer {
161            out.push((format!("try #{i} finally"), ch.clone()));
162        }
163    }
164    out
165}
166
167
168/// Report on one already-executed chunk, as a whole-program report. Used by
169/// tests that build a chunk by hand.
170pub fn inspect(chunk: &Chunk) -> Report {
171    Report {
172        chunks: vec![inspect_chunk("main", chunk)],
173    }
174}
175
176/// Report on every chunk a compiled program holds, in lowering order.
177pub fn inspect_all(named: &[(String, Chunk)]) -> Report {
178    Report {
179        chunks: named.iter().map(|(n, c)| inspect_chunk(n, c)).collect(),
180    }
181}
182
183/// Report on one already-executed chunk.
184pub fn inspect_chunk(name: &str, chunk: &Chunk) -> ChunkTiers {
185    let jit = JitCompiler::new();
186    let loops = loop_anchors(&chunk.ops)
187        .into_iter()
188        .map(|anchor| Loop {
189            anchor,
190            trace_eligible: body_of(&chunk.ops, anchor)
191                .is_some_and(|body| jit.is_trace_eligible(body, anchor)),
192            traced: jit.trace_is_compiled(chunk, anchor),
193            blacklisted: jit.trace_is_blacklisted(chunk, anchor),
194        })
195        .collect();
196
197    let mut ineligible: BTreeMap<String, usize> = BTreeMap::new();
198    for op in &chunk.ops {
199        if !op_is_eligible(&jit, op) {
200            *ineligible.entry(op_name(op)).or_default() += 1;
201        }
202    }
203
204    ChunkTiers {
205        name: name.to_string(),
206        ops: chunk.ops.len(),
207        block_eligible: jit.is_block_eligible(chunk),
208        block_compiled: jit.block_jit_is_compiled(chunk),
209        largest_eligible_region: jit.find_jit_region(chunk),
210        loops,
211        ineligible,
212    }
213}
214
215/// Every op index a backward branch jumps to — fusevm anchors a trace at each.
216fn loop_anchors(ops: &[Op]) -> Vec<usize> {
217    let mut anchors: Vec<usize> = ops
218        .iter()
219        .enumerate()
220        .filter_map(|(ip, op)| match op {
221            Op::Jump(t)
222            | Op::JumpIfTrue(t)
223            | Op::JumpIfFalse(t)
224            | Op::JumpIfTrueKeep(t)
225            | Op::JumpIfFalseKeep(t)
226                if *t <= ip =>
227            {
228                Some(*t)
229            }
230            _ => None,
231        })
232        .collect();
233    anchors.sort_unstable();
234    anchors.dedup();
235    anchors
236}
237
238/// The op sequence one iteration of the loop at `anchor` runs: from the header
239/// through the backward branch that closes it. `None` when nothing closes it.
240fn body_of(ops: &[Op], anchor: usize) -> Option<&[Op]> {
241    let close = ops.iter().enumerate().position(|(ip, op)| {
242        ip >= anchor
243            && matches!(
244                op,
245                Op::Jump(t) | Op::JumpIfTrue(t) | Op::JumpIfFalse(t)
246                    if *t == anchor
247            )
248    })?;
249    Some(&ops[anchor..=close])
250}
251
252/// Whether fusevm's block tier accepts this op, asked by handing the JIT a
253/// chunk holding just that op. Whole-chunk eligibility is the conjunction of
254/// the per-op decision, so a one-op chunk isolates it.
255fn op_is_eligible(jit: &JitCompiler, op: &Op) -> bool {
256    let mut b = ChunkBuilder::new();
257    b.emit(op.clone(), 1);
258    jit.is_block_eligible(&b.build())
259}
260
261/// An op's variant name, without its operands, so occurrences group.
262fn op_name(op: &Op) -> String {
263    let text = format!("{op:?}");
264    match text.split_once('(') {
265        Some((name, _)) => name.to_string(),
266        None => text,
267    }
268}
269
270
271/// The counted loop this module's tests measure, in the frontend's own syntax.
272const PROGRAM: &str = "function f(n) {\n  let t = 0;\n  let i = 0;\n  while (i < n) { t += i; i += 1; }\n  return t;\n}\nf(200000);\n";
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    /// The report can say yes. A counted loop built by hand in the rotated
279    /// shape — entered at its body, closed by a conditional backward branch —
280    /// is the one shape fusevm's trace compiler accepts (`jit.rs` compiles a
281    /// `JumpIfTrue`/`JumpIfFalse` close and bails on an unconditional `Jump`),
282    /// and after a run the report finds the installed trace. Without this,
283    /// every "not traced" below could be a report that only ever says no.
284    #[test]
285    fn a_rotated_slot_loop_reaches_a_compiled_trace() {
286        let mut b = ChunkBuilder::new();
287        b.emit(Op::LoadInt(0), 1);
288        b.emit(Op::SetSlot(0), 1);
289        let enter = b.emit(Op::Jump(usize::MAX), 1);
290        let body = b.current_pos();
291        b.emit(Op::GetSlot(0), 1);
292        b.emit(Op::LoadInt(1), 1);
293        b.emit(Op::Add, 1);
294        b.emit(Op::SetSlot(0), 1);
295        let cond = b.current_pos();
296        b.patch_jump(enter, cond);
297        b.emit(Op::GetSlot(0), 1);
298        b.emit(Op::LoadInt(200_000), 1);
299        b.emit(Op::NumLt, 1);
300        b.emit(Op::JumpIfTrue(body), 1);
301        b.emit(Op::GetSlot(0), 1);
302        let chunk = b.build();
303
304        let mut vm = fusevm::VM::new(chunk.clone());
305        vm.enable_tracing_jit();
306        vm.run();
307
308        let report = inspect(&chunk);
309        assert_eq!(report.chunks[0].loops.len(), 1, "{report}");
310        assert!(report.chunks[0].loops[0].traced, "{report}");
311        assert!(report.reaches_native(), "{report}");
312    }
313
314    /// The top level is not where the loop is — the report has to walk every
315    /// chunk to find it. This pins the multi-chunk walk itself: reporting only
316    /// `main` would say "no loops" for a program that plainly has one.
317    #[test]
318    fn the_loop_lives_in_a_chunk_other_than_main() {
319        let report = report(PROGRAM).expect("runs");
320        assert!(report.chunks.len() > 1, "{report}");
321        assert!(report.chunks[0].loops.is_empty(), "main has no loop: {report}");
322        assert!(
323            report.chunks[1..].iter().any(|c| !c.loops.is_empty()),
324            "{report}"
325        );
326    }
327
328    /// The loop is refused by the tracing tier before branch shape is even
329    /// reached: its body dispatches through ops the trace compiler cannot
330    /// lower, so `is_trace_eligible` says no. The report names them under
331    /// `block-ineligible ops`, which is what makes it a diagnosis rather than
332    /// a verdict — this is the list to shrink to reach native code.
333    #[test]
334    fn the_counted_loop_is_refused_by_the_tracing_tier() {
335        let report = report(PROGRAM).expect("runs");
336        let looped = report
337            .chunks
338            .iter()
339            .find(|c| !c.loops.is_empty())
340            .unwrap_or_else(|| panic!("a chunk with a loop: {report}"));
341        assert!(!looped.loops[0].trace_eligible, "{report}");
342        assert!(!looped.loops[0].traced, "{report}");
343        assert!(!looped.ineligible.is_empty(), "{report}");
344        assert!(!report.reaches_native(), "{report}");
345    }
346}