tclrs 0.4.1

Tcl as a fusevm frontend: a parser and compiler to fusevm::Chunk, with no bespoke VM or JIT
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! Which fusevm execution tier a script's bytecode actually reaches.
//!
//! Enabling the JIT is not the same as being compiled by it, and the only
//! honest way to tell the two apart is to ask the VM. This module runs a script
//! and then queries fusevm's own eligibility and cache predicates —
//! `is_block_eligible`, `block_jit_is_compiled`, `trace_is_compiled`,
//! `find_jit_region` — so the answer comes from the compiler that would have
//! done the work rather than from an assumption about it.
//!
//! `tclrs --tiers script.tcl` prints the report; the README quotes it.

use std::collections::BTreeMap;

use fusevm::{Chunk, ChunkBuilder, JitCompiler, Op};

/// A loop header — the target of a backward branch — and what became of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Loop {
    /// Op index of the loop header the backward branch jumps to.
    pub anchor: usize,
    /// Whether fusevm would accept this loop's body as a trace. Asked of
    /// `is_trace_eligible` with the body's ops — the same predicate the
    /// recorder applies to what it recorded, which for a loop whose body has
    /// no early exit is the same op sequence.
    pub trace_eligible: bool,
    /// Whether a compiled trace is installed for this header after the run.
    pub traced: bool,
    /// Whether the tracing JIT gave up on this header.
    pub blacklisted: bool,
}

/// What the tiers did with one script.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
    /// Ops in the compiled chunk.
    pub ops: usize,
    /// Whether every op in the chunk is block-JIT eligible, which is what the
    /// whole-chunk block tier requires.
    pub block_eligible: bool,
    /// Whether the block tier holds compiled native code for this chunk.
    pub block_compiled: bool,
    /// The largest contiguous block-eligible op range, if any is large enough
    /// for fusevm to consider it worth compiling.
    pub largest_eligible_region: Option<(usize, usize)>,
    /// Every loop header, and whether the tracing JIT compiled it.
    pub loops: Vec<Loop>,
    /// Op kinds the **block** tier refuses, by occurrence count — what keeps
    /// the whole chunk from being compiled in one piece.
    ///
    /// Not the same question as whether a loop is traced: the tracing tier
    /// takes `GetVar` / `SetVar` (fusevm 0.15.0 promotes a referenced global to
    /// a register at trace entry and spills it at every exit), so a chunk can
    /// list those here and still reach native code through a trace.
    pub ineligible: BTreeMap<String, usize>,
}

impl Report {
    /// Whether any tier holds compiled native code for this script.
    pub fn reaches_native(&self) -> bool {
        self.block_compiled || self.loops.iter().any(|l| l.traced)
    }
}

impl std::fmt::Display for Report {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "ops                     {}", self.ops)?;
        writeln!(f, "block-JIT eligible      {}", self.block_eligible)?;
        writeln!(f, "block-JIT compiled      {}", self.block_compiled)?;
        match self.largest_eligible_region {
            Some((s, e)) => writeln!(f, "largest eligible region {s}..{e} ({} ops)", e - s)?,
            None => writeln!(f, "largest eligible region none")?,
        }
        if self.loops.is_empty() {
            writeln!(f, "loops                   none")?;
        }
        for l in &self.loops {
            writeln!(
                f,
                "loop @{:<4}             trace-eligible={} traced={} blacklisted={}",
                l.anchor, l.trace_eligible, l.traced, l.blacklisted
            )?;
        }
        if self.ineligible.is_empty() {
            writeln!(f, "block-ineligible ops    none")?;
        } else {
            writeln!(f, "block-ineligible ops")?;
            for (name, count) in &self.ineligible {
                writeln!(f, "  {name:<22}{count}")?;
            }
        }
        write!(f, "reaches native code     {}", self.reaches_native())
    }
}

/// Compile and run `src`, then report which tiers took it.
///
/// The script is run because tier membership is a runtime fact: the block tier
/// compiles after its warmup threshold and the tracing tier only after a loop
/// has gone round enough times to be recorded. Output is discarded.
pub fn report(src: &str) -> Result<Report, String> {
    // The script runs through the ordinary interpreter, so what is measured is
    // what an ordinary run does — including a script that uses `catch` or a
    // coroutine, which needs the driver. The chunk lowered here is the same
    // bytecode, and fusevm keys its compiled code by the chunk's op hash, so
    // asking this copy is asking about the run that just happened.
    let chunk = crate::runtime::compile(src)?;
    let mut interp = crate::Interp::capturing();
    interp.eval(src).map_err(|e| e.to_string())?;
    Ok(inspect(&chunk))
}

/// Report on an already-executed chunk.
pub fn inspect(chunk: &Chunk) -> Report {
    let jit = JitCompiler::new();
    let loops = loop_anchors(&chunk.ops)
        .into_iter()
        .map(|anchor| Loop {
            anchor,
            trace_eligible: body_of(&chunk.ops, anchor)
                .is_some_and(|body| jit.is_trace_eligible(body, anchor)),
            traced: jit.trace_is_compiled(chunk, anchor),
            blacklisted: jit.trace_is_blacklisted(chunk, anchor),
        })
        .collect();

    // The per-op probe below is a one-op chunk, so it cannot see the op that
    // consumes a result. Since fusevm 0.22.0 that matters: a comparison or
    // `LogNot` is block-eligible only when the very next op consumes the
    // boolean as a truth value (`JumpIfTrue`/`JumpIfFalse`) or discards it
    // (`Pop`), because the block tier's register lattice has no boolean kind.
    // Alone in a one-op chunk such an op always looks refused.
    //
    // Whole-chunk eligibility is the conjunction of the per-op decisions, so an
    // eligible chunk has no ineligible op by definition — asking at all would
    // only produce that false positive. The list is therefore a diagnosis of
    // why a chunk was refused, and is gathered only for a refused chunk.
    let block_eligible = jit.is_block_eligible(chunk);
    let mut ineligible: BTreeMap<String, usize> = BTreeMap::new();
    if !block_eligible {
        for op in &chunk.ops {
            if !op_is_eligible(&jit, op) {
                *ineligible.entry(op_name(op)).or_default() += 1;
            }
        }
    }

    Report {
        ops: chunk.ops.len(),
        block_eligible,
        block_compiled: jit.block_jit_is_compiled(chunk),
        largest_eligible_region: jit.find_jit_region(chunk),
        loops,
        ineligible,
    }
}

/// Every op index a backward branch jumps to — fusevm anchors a trace at each.
fn loop_anchors(ops: &[Op]) -> Vec<usize> {
    let mut anchors: Vec<usize> = ops
        .iter()
        .enumerate()
        .filter_map(|(ip, op)| match op {
            Op::Jump(t)
            | Op::JumpIfTrue(t)
            | Op::JumpIfFalse(t)
            | Op::JumpIfTrueKeep(t)
            | Op::JumpIfFalseKeep(t)
                if *t <= ip =>
            {
                Some(*t)
            }
            _ => None,
        })
        .collect();
    anchors.sort_unstable();
    anchors.dedup();
    anchors
}

/// The op sequence one iteration of the loop at `anchor` runs: from the header
/// through the backward branch that closes it. `None` when nothing closes it.
fn body_of(ops: &[Op], anchor: usize) -> Option<&[Op]> {
    let close = ops.iter().enumerate().position(|(ip, op)| {
        ip >= anchor
            && matches!(
                op,
                Op::Jump(t) | Op::JumpIfTrue(t) | Op::JumpIfFalse(t)
                    if *t == anchor
            )
    })?;
    Some(&ops[anchor..=close])
}

/// Whether fusevm's block tier accepts this op on its own, asked by handing the
/// JIT a chunk holding just that op.
///
/// An approximation for an op whose eligibility depends on its neighbours — a
/// comparison or `LogNot` needs the next op to consume the boolean — so
/// [`inspect`] calls this only for a chunk fusevm has already refused, where the
/// list is a diagnosis rather than the decision.
fn op_is_eligible(jit: &JitCompiler, op: &Op) -> bool {
    let mut b = ChunkBuilder::new();
    b.emit(op.clone(), 1);
    jit.is_block_eligible(&b.build())
}

/// An op's variant name, without its operands, so occurrences group.
fn op_name(op: &Op) -> String {
    let text = format!("{op:?}");
    match text.split_once('(') {
        Some((name, _)) => name.to_string(),
        None => text,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A counted loop written against fusevm slots instead of Tcl variables, in
    /// the unrotated shape. Both tiers report it eligible — which is what makes
    /// the refusals below evidence about Tcl's lowering, and about the trace
    /// compiler's own decision, rather than about a report that only ever says
    /// no. Eligibility is not installation: see
    /// [`the_unrotated_shape_of_that_loop_installs_no_trace`].
    #[test]
    fn a_slot_counter_loop_is_accepted_by_both_tiers() {
        let mut b = ChunkBuilder::new();
        b.emit(Op::GetSlot(0), 1); //  0  header
        b.emit(Op::LoadInt(1000), 1); //  1
        b.emit(Op::NumLt, 1); //  2
        b.emit(Op::JumpIfFalse(9), 1); //  3
        b.emit(Op::GetSlot(0), 1); //  4
        b.emit(Op::LoadInt(1), 1); //  5
        b.emit(Op::Add, 1); //  6
        b.emit(Op::SetSlot(0), 1); //  7
        b.emit(Op::Jump(0), 1); //  8  backward branch
        b.emit(Op::GetSlot(0), 1); //  9
        let report = inspect(&b.build());
        assert!(report.block_eligible, "{report}");
        assert!(report.ineligible.is_empty(), "{report}");
        assert_eq!(report.loops.len(), 1, "{report}");
        assert!(report.loops[0].trace_eligible, "{report}");
    }

    /// The Tcl spelling of that same loop. Its counter is a VM global rather
    /// than a frame slot, which the *block* tier still refuses — so the chunk
    /// as a whole is not compiled — but the tracing tier promotes a referenced
    /// global to a register at trace entry and spills it at every exit, so the
    /// loop itself reaches native code.
    #[test]
    fn the_tcl_counter_loop_is_traced_through_its_globals() {
        let report = report("set i 0\nwhile {$i < 1000} {incr i}").expect("runs");
        assert!(!report.block_eligible, "{report}");
        assert!(
            report.ineligible.contains_key("GetVar") && report.ineligible.contains_key("SetVar"),
            "the variable ops are what keep the whole chunk out: {report}"
        );
        assert_eq!(report.loops.len(), 1, "{report}");
        assert!(report.loops[0].trace_eligible, "{report}");
        assert!(report.reaches_native(), "{report}");
    }

    /// The same loop inside a `proc` reaches native code. Two things have to
    /// hold at once. A procedure's locals are frame slots, so the counter is
    /// `GetSlot`/`SetSlot` rather than the globals the tiers refuse; and the
    /// loop is emitted rotated — entered at its test, closed by a conditional
    /// backward branch — which is the shape fusevm's trace compiler accepts.
    /// The unrotated `while` shape, a forward `JumpIfFalse` exit closed by an
    /// unconditional backward `Jump`, records an eligible op sequence that the
    /// trace compiler then declines, so nothing is installed.
    ///
    /// The chunk as a whole stays block-ineligible for a different reason: the
    /// call and the `puts` around the loop.
    #[test]
    fn a_proc_local_counter_loop_reaches_a_compiled_trace() {
        let report =
            report("proc f {} {set i 0; while {$i < 200000} {incr i}; return $i}\nputs [f]")
                .expect("runs");
        assert_eq!(report.loops.len(), 1, "{report}");
        assert!(
            report.loops[0].trace_eligible,
            "slot ops make the body traceable: {report}"
        );
        assert!(
            !report.ineligible.contains_key("GetVar") && !report.ineligible.contains_key("SetVar"),
            "a procedure's locals are slots, not globals: {report}"
        );
        assert!(report.loops[0].traced, "{report}");
        assert!(!report.loops[0].blacklisted, "{report}");
        assert!(report.reaches_native(), "{report}");
    }

    /// The rotation is what installs the trace, not the slot ops alone. The
    /// same body built by hand in the unrotated shape — the one `while` used to
    /// emit — records and is then declined, so no trace exists for it. Without
    /// this the test above could pass for a reason that has nothing to do with
    /// the loop's shape.
    #[test]
    fn the_unrotated_shape_of_that_loop_installs_no_trace() {
        // `i = 0; while (i < N) { i += 1 }` with a forward exit and an
        // unconditional backward close.
        let mut b = ChunkBuilder::new();
        b.emit(Op::LoadInt(0), 1);
        b.emit(Op::SetSlot(0), 1);
        let anchor = b.current_pos();
        b.emit(Op::GetSlot(0), 1);
        b.emit(Op::LoadInt(200_000), 1);
        b.emit(Op::NumLt, 1);
        let exit = b.emit(Op::JumpIfFalse(usize::MAX), 1);
        b.emit(Op::GetSlot(0), 1);
        b.emit(Op::LoadInt(1), 1);
        b.emit(Op::Add, 1);
        b.emit(Op::SetSlot(0), 1);
        b.emit(Op::Jump(anchor), 1);
        let end = b.current_pos();
        b.patch_jump(exit, end);
        b.emit(Op::GetSlot(0), 1);
        let chunk = b.build();

        let mut vm = fusevm::VM::new(chunk.clone());
        vm.enable_tracing_jit();
        vm.run();

        let report = inspect(&chunk);
        assert_eq!(report.loops.len(), 1, "{report}");
        assert!(
            report.loops[0].trace_eligible,
            "the recorded sequence is eligible; it is the compile that declines: {report}"
        );
        assert!(!report.loops[0].traced, "{report}");
    }

    /// And the rotated spelling of that same hand-built loop does install one,
    /// with no Tcl in the picture at all.
    #[test]
    fn the_rotated_shape_of_that_loop_installs_a_trace() {
        let mut b = ChunkBuilder::new();
        b.emit(Op::LoadInt(0), 1);
        b.emit(Op::SetSlot(0), 1);
        let enter = b.emit(Op::Jump(usize::MAX), 1);
        let body = b.current_pos();
        b.emit(Op::GetSlot(0), 1);
        b.emit(Op::LoadInt(1), 1);
        b.emit(Op::Add, 1);
        b.emit(Op::SetSlot(0), 1);
        let cond = b.current_pos();
        b.patch_jump(enter, cond);
        b.emit(Op::GetSlot(0), 1);
        b.emit(Op::LoadInt(200_000), 1);
        b.emit(Op::NumLt, 1);
        b.emit(Op::JumpIfTrue(body), 1);
        b.emit(Op::GetSlot(0), 1);
        let chunk = b.build();

        let mut vm = fusevm::VM::new(chunk.clone());
        vm.enable_tracing_jit();
        vm.run();

        let report = inspect(&chunk);
        assert_eq!(report.loops.len(), 1, "{report}");
        assert!(report.loops[0].traced, "{report}");
        assert!(report.reaches_native(), "{report}");
    }

    /// Tcl arithmetic lowers to ops the JIT accepts — all of them. An `expr`
    /// used to end in an extension op that normalized its result, and that one
    /// op was enough to keep every arithmetic loop out of both the JIT and the
    /// ahead-of-time compiler; Tcl's string form is applied where a string is
    /// asked for instead, so nothing ineligible is left in the expression.
    #[test]
    fn expr_arithmetic_lowers_to_eligible_ops() {
        let report = report("expr {2 + 3 * 4 - 1}").expect("runs");
        assert!(
            report.ineligible.is_empty(),
            "an expression should lower to eligible ops only: {report}"
        );
        assert!(report.block_eligible, "{report}");
    }

    /// A shift is the one arithmetic-looking operator that does *not* lower to a
    /// native op, and this pins the cost rather than leaving it to be
    /// rediscovered.
    ///
    /// fusevm masks a shift distance to six bits and coerces a non-numeric
    /// operand to zero, where Tcl reports `negative shift argument`, promotes an
    /// overflowing left shift, and refuses the operand — so `<<` and `>>` are
    /// extension ops (`ext::SHL` / `ext::SHR`). An extension op in a loop body
    /// costs that loop its trace, which is why `integer_arith`, whose body is
    /// `$sum + $i * $i - ($i >> 3)`, no longer reaches native code. Restoring it
    /// needs the shift to be provably safe from its operands rather than checked
    /// at run time.
    #[test]
    fn a_shift_is_an_extension_op_and_costs_the_native_lowering() {
        let report = report("expr {8 >> 1}").expect("runs");
        assert_eq!(
            report.ineligible.get("Extended").copied(),
            Some(1),
            "a shift should be the frontend's op, not the VM's: {report}"
        );
    }

    /// The same for an assignment whose value is an expression, which is the
    /// shape a counted loop is written in: `set i [expr {$i + 1}]` reaches the
    /// ahead-of-time compiler only if nothing in it is an extension op.
    #[test]
    fn an_expr_assignment_lowers_to_eligible_ops() {
        let report = report("set i 0\nset i [expr {$i + 1}]").expect("runs");
        assert!(
            report
                .ineligible
                .keys()
                .all(|op| op == "GetVar" || op == "SetVar"),
            "only the global-variable ops should be left: {report}"
        );
    }
}