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/// Report on one already-executed chunk, as a whole-program report. Used by
168/// tests that build a chunk by hand.
169pub fn inspect(chunk: &Chunk) -> Report {
170    Report {
171        chunks: vec![inspect_chunk("main", chunk)],
172    }
173}
174
175/// Report on every chunk a compiled program holds, in lowering order.
176pub fn inspect_all(named: &[(String, Chunk)]) -> Report {
177    Report {
178        chunks: named.iter().map(|(n, c)| inspect_chunk(n, c)).collect(),
179    }
180}
181
182/// Report on one already-executed chunk.
183pub fn inspect_chunk(name: &str, chunk: &Chunk) -> ChunkTiers {
184    let jit = JitCompiler::new();
185    let loops = loop_anchors(&chunk.ops)
186        .into_iter()
187        .map(|anchor| Loop {
188            anchor,
189            trace_eligible: body_of(&chunk.ops, anchor)
190                .is_some_and(|body| jit.is_trace_eligible(body, anchor)),
191            traced: jit.trace_is_compiled(chunk, anchor),
192            blacklisted: jit.trace_is_blacklisted(chunk, anchor),
193        })
194        .collect();
195
196    let mut ineligible: BTreeMap<String, usize> = BTreeMap::new();
197    for op in &chunk.ops {
198        if !op_is_eligible(&jit, op) {
199            *ineligible.entry(op_name(op)).or_default() += 1;
200        }
201    }
202
203    ChunkTiers {
204        name: name.to_string(),
205        ops: chunk.ops.len(),
206        block_eligible: jit.is_block_eligible(chunk),
207        block_compiled: jit.block_jit_is_compiled(chunk),
208        largest_eligible_region: jit.find_jit_region(chunk),
209        loops,
210        ineligible,
211    }
212}
213
214/// Every op index a backward branch jumps to — fusevm anchors a trace at each.
215fn loop_anchors(ops: &[Op]) -> Vec<usize> {
216    let mut anchors: Vec<usize> = ops
217        .iter()
218        .enumerate()
219        .filter_map(|(ip, op)| match op {
220            Op::Jump(t)
221            | Op::JumpIfTrue(t)
222            | Op::JumpIfFalse(t)
223            | Op::JumpIfTrueKeep(t)
224            | Op::JumpIfFalseKeep(t)
225                if *t <= ip =>
226            {
227                Some(*t)
228            }
229            _ => None,
230        })
231        .collect();
232    anchors.sort_unstable();
233    anchors.dedup();
234    anchors
235}
236
237/// The op sequence one iteration of the loop at `anchor` runs: from the header
238/// through the backward branch that closes it. `None` when nothing closes it.
239fn body_of(ops: &[Op], anchor: usize) -> Option<&[Op]> {
240    let close = ops.iter().enumerate().position(|(ip, op)| {
241        ip >= anchor
242            && matches!(
243                op,
244                Op::Jump(t) | Op::JumpIfTrue(t) | Op::JumpIfFalse(t)
245                    if *t == anchor
246            )
247    })?;
248    Some(&ops[anchor..=close])
249}
250
251/// Whether fusevm's block tier accepts this op, asked by handing the JIT a
252/// chunk holding just that op. Whole-chunk eligibility is the conjunction of
253/// the per-op decision, so a one-op chunk isolates it.
254fn op_is_eligible(jit: &JitCompiler, op: &Op) -> bool {
255    let mut b = ChunkBuilder::new();
256    b.emit(op.clone(), 1);
257    jit.is_block_eligible(&b.build())
258}
259
260/// An op's variant name, without its operands, so occurrences group.
261fn op_name(op: &Op) -> String {
262    let text = format!("{op:?}");
263    match text.split_once('(') {
264        Some((name, _)) => name.to_string(),
265        None => text,
266    }
267}
268
269/// The counted loop this module's tests measure, in the frontend's own syntax.
270#[cfg(test)]
271const 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";
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    /// The report can say yes. A counted loop built by hand in the rotated
278    /// shape — entered at its body, closed by a conditional backward branch —
279    /// is the one shape fusevm's trace compiler accepts (`jit.rs` compiles a
280    /// `JumpIfTrue`/`JumpIfFalse` close and bails on an unconditional `Jump`),
281    /// and after a run the report finds the installed trace. Without this,
282    /// every "not traced" below could be a report that only ever says no.
283    #[test]
284    fn a_rotated_slot_loop_reaches_a_compiled_trace() {
285        let mut b = ChunkBuilder::new();
286        b.emit(Op::LoadInt(0), 1);
287        b.emit(Op::SetSlot(0), 1);
288        let enter = b.emit(Op::Jump(usize::MAX), 1);
289        let body = b.current_pos();
290        b.emit(Op::GetSlot(0), 1);
291        b.emit(Op::LoadInt(1), 1);
292        b.emit(Op::Add, 1);
293        b.emit(Op::SetSlot(0), 1);
294        let cond = b.current_pos();
295        b.patch_jump(enter, cond);
296        b.emit(Op::GetSlot(0), 1);
297        b.emit(Op::LoadInt(200_000), 1);
298        b.emit(Op::NumLt, 1);
299        b.emit(Op::JumpIfTrue(body), 1);
300        b.emit(Op::GetSlot(0), 1);
301        let chunk = b.build();
302
303        let mut vm = fusevm::VM::new(chunk.clone());
304        vm.enable_tracing_jit();
305        vm.run();
306
307        let report = inspect(&chunk);
308        assert_eq!(report.chunks[0].loops.len(), 1, "{report}");
309        assert!(report.chunks[0].loops[0].traced, "{report}");
310        assert!(report.reaches_native(), "{report}");
311    }
312
313    /// The top level is not where the loop is — the report has to walk every
314    /// chunk to find it. This pins the multi-chunk walk itself: reporting only
315    /// `main` would say "no loops" for a program that plainly has one.
316    #[test]
317    fn the_loop_lives_in_a_chunk_other_than_main() {
318        let report = report(PROGRAM).expect("runs");
319        assert!(report.chunks.len() > 1, "{report}");
320        assert!(
321            report.chunks[0].loops.is_empty(),
322            "main has no loop: {report}"
323        );
324        assert!(
325            report.chunks[1..].iter().any(|c| !c.loops.is_empty()),
326            "{report}"
327        );
328    }
329
330    /// A counted loop written in the frontend's own syntax reaches a compiled
331    /// trace.
332    ///
333    /// This used to assert the opposite, and said why: the frontend closed a
334    /// `while` with an unconditional `Jump` back to a header test, and fusevm's
335    /// trace compiler bails on that close (it compiles a
336    /// `JumpIfTrue`/`JumpIfFalse` one, as
337    /// [`a_rotated_slot_loop_reaches_a_compiled_trace`] pins), so the hottest
338    /// shape a JavaScript program has stayed in the interpreter however hot it
339    /// got. `Compiler::compile_while`/`compile_for` now emit every `for` and
340    /// `while` ROTATED — the test duplicated as an entry guard and a conditional
341    /// backward branch — which is that shape.
342    ///
343    /// Keeping it as an assertion rather than deleting it is the point: rotate
344    /// the lowering back and this fails.
345    #[test]
346    fn the_counted_loop_reaches_a_compiled_trace() {
347        let report = report(PROGRAM).expect("runs");
348        let looped = report
349            .chunks
350            .iter()
351            .find(|c| !c.loops.is_empty())
352            .unwrap_or_else(|| panic!("a chunk with a loop: {report}"));
353        assert!(looped.loops[0].trace_eligible, "{report}");
354        assert!(looped.loops[0].traced, "{report}");
355        assert!(report.reaches_native(), "{report}");
356
357        // Name the reason rather than just the verdict: the close is the
358        // CONDITIONAL backward branch the trace compiler accepts.
359        let anchor = looped.loops[0].anchor;
360        let chunks = program_chunks(&crate::compile(PROGRAM).expect("compiles"));
361        let (_, chunk) = chunks
362            .iter()
363            .find(|(name, _)| name == &looped.name)
364            .expect("the looped chunk is one of the program's chunks");
365        let close = body_of(&chunk.ops, anchor)
366            .and_then(<[Op]>::last)
367            .expect("the loop is closed");
368        assert!(
369            matches!(close, Op::JumpIfTrue(t) if *t == anchor),
370            "the loop closes with a conditional JumpIfTrue({anchor}), got {close:?}"
371        );
372    }
373
374    /// `for (;;)` reaches a compiled trace too. It has no test to branch on, so
375    /// its back edge is a constant-true CONDITIONAL branch (`LoadTrue;
376    /// JumpIfTrue`) rather than an unconditional `Jump`: fusevm's trace compiler
377    /// only ever installs a trace closed by `JumpIfTrue`/`JumpIfFalse` and
378    /// silently declines a `Jump` close, so emitting the honest unconditional
379    /// edge left `for (;;)` interpreted while the identical `while (true)`
380    /// reached native code. This used to assert that gap.
381    #[test]
382    fn an_untested_for_reaches_a_compiled_trace() {
383        const SRC: &str =
384            "function f(n) {\n  let i = 0;\n  for (;;) { i += 1; if (i >= n) break; }\n  return i;\n}\nf(200000);\n";
385        let report = report(SRC).expect("runs");
386        let looped = report
387            .chunks
388            .iter()
389            .find(|c| !c.loops.is_empty())
390            .unwrap_or_else(|| panic!("a chunk with a loop: {report}"));
391        assert!(looped.loops[0].traced, "{report}");
392        assert!(report.reaches_native(), "{report}");
393    }
394}