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}