Skip to content

Commit 2230c82

Browse files
timfennisclaude
andcommitted
Refactor VM tracing into trait-based system with CLI flags
Replace ad-hoc #[cfg(feature = "vm-trace")] blocks with a composable VmTracer trait behind a renamed `trace` feature flag. Add four tracer implementations: PrintTracer, HistogramTracer, TimingTracer, and SpanTracer (source heat map). Tracers are enabled via CLI flags --trace-print, --trace-histogram, --trace-time, and --trace-span. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 5739e84 commit 2230c82

9 files changed

Lines changed: 508 additions & 32 deletions

File tree

ndc_bin/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,6 @@ yansi.workspace = true
2525
rustyline.workspace = true
2626
termimad = "0.34.1"
2727
tokio.workspace = true
28+
29+
[features]
30+
trace = ["ndc_interpreter/trace"]

ndc_bin/src/main.rs

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ mod repl;
1414

1515
mod docs;
1616
mod highlighter;
17+
#[cfg(feature = "trace")]
18+
mod span_tracer;
1719

1820
#[derive(Parser)]
1921
#[command(name = "Andy C++")]
@@ -28,7 +30,25 @@ struct Cli {
2830
#[derive(Subcommand)]
2931
enum Command {
3032
/// Execute an .ndc file or start the repl (this default action may be omitted)
31-
Run { file: Option<PathBuf> },
33+
Run {
34+
file: Option<PathBuf>,
35+
/// Print each instruction as it is dispatched
36+
#[cfg(feature = "trace")]
37+
#[arg(long)]
38+
trace_print: bool,
39+
/// Print a histogram of instruction dispatch counts
40+
#[cfg(feature = "trace")]
41+
#[arg(long)]
42+
trace_histogram: bool,
43+
/// Print cumulative time spent per instruction type
44+
#[cfg(feature = "trace")]
45+
#[arg(long)]
46+
trace_time: bool,
47+
/// Render source as a heat map colored by time spent per span
48+
#[cfg(feature = "trace")]
49+
#[arg(long)]
50+
trace_span: bool,
51+
},
3252
/// Output an .ndc file using the built-in syntax highlighting engine
3353
Highlight { file: PathBuf },
3454

@@ -56,14 +76,32 @@ enum Command {
5676

5777
impl Default for Command {
5878
fn default() -> Self {
59-
Self::Run { file: None }
79+
Self::Run {
80+
file: None,
81+
#[cfg(feature = "trace")]
82+
trace_print: false,
83+
#[cfg(feature = "trace")]
84+
trace_histogram: false,
85+
#[cfg(feature = "trace")]
86+
trace_time: false,
87+
#[cfg(feature = "trace")]
88+
trace_span: false,
89+
}
6090
}
6191
}
6292

6393
enum Action {
6494
RunLsp,
6595
RunFile {
6696
path: PathBuf,
97+
#[cfg(feature = "trace")]
98+
trace_print: bool,
99+
#[cfg(feature = "trace")]
100+
trace_histogram: bool,
101+
#[cfg(feature = "trace")]
102+
trace_time: bool,
103+
#[cfg(feature = "trace")]
104+
trace_span: bool,
67105
},
68106
DisassembleFile(PathBuf),
69107
HighlightFile(PathBuf),
@@ -79,8 +117,28 @@ impl TryFrom<Command> for Action {
79117

80118
fn try_from(value: Command) -> Result<Self, Self::Error> {
81119
let action = match value {
82-
Command::Run { file: Some(file) } => Self::RunFile { path: file },
83-
Command::Run { file: None } => Self::StartRepl,
120+
Command::Run {
121+
file: Some(file),
122+
#[cfg(feature = "trace")]
123+
trace_print,
124+
#[cfg(feature = "trace")]
125+
trace_histogram,
126+
#[cfg(feature = "trace")]
127+
trace_time,
128+
#[cfg(feature = "trace")]
129+
trace_span,
130+
} => Self::RunFile {
131+
path: file,
132+
#[cfg(feature = "trace")]
133+
trace_print,
134+
#[cfg(feature = "trace")]
135+
trace_histogram,
136+
#[cfg(feature = "trace")]
137+
trace_time,
138+
#[cfg(feature = "trace")]
139+
trace_span,
140+
},
141+
Command::Run { file: None, .. } => Self::StartRepl,
84142
Command::Lsp { stdio: _ } => Self::RunLsp,
85143
Command::Disassemble { file } => Self::DisassembleFile(file),
86144
Command::Highlight { file } => Self::HighlightFile(file),
@@ -93,6 +151,14 @@ impl TryFrom<Command> for Action {
93151
}
94152
1 => Self::RunFile {
95153
path: args[0].parse::<PathBuf>().context("invalid path")?,
154+
#[cfg(feature = "trace")]
155+
trace_print: false,
156+
#[cfg(feature = "trace")]
157+
trace_histogram: false,
158+
#[cfg(feature = "trace")]
159+
trace_time: false,
160+
#[cfg(feature = "trace")]
161+
trace_span: false,
96162
},
97163
n => return Err(anyhow!("invalid number of arguments: {n}")),
98164
}
@@ -108,7 +174,17 @@ fn main() -> anyhow::Result<()> {
108174
let action: Action = cli.command.unwrap_or_default().try_into()?;
109175

110176
match action {
111-
Action::RunFile { path } => {
177+
Action::RunFile {
178+
path,
179+
#[cfg(feature = "trace")]
180+
trace_print,
181+
#[cfg(feature = "trace")]
182+
trace_histogram,
183+
#[cfg(feature = "trace")]
184+
trace_time,
185+
#[cfg(feature = "trace")]
186+
trace_span,
187+
} => {
112188
let filename = path
113189
.file_name()
114190
.and_then(|name| name.to_str())
@@ -118,6 +194,28 @@ fn main() -> anyhow::Result<()> {
118194

119195
let mut interpreter = Interpreter::new();
120196
interpreter.configure(ndc_stdlib::register);
197+
198+
#[cfg(feature = "trace")]
199+
{
200+
use ndc_interpreter::tracer;
201+
let mut tracers: Vec<Box<dyn tracer::VmTracer>> = Vec::new();
202+
if trace_print {
203+
tracers.push(Box::new(tracer::PrintTracer));
204+
}
205+
if trace_histogram {
206+
tracers.push(Box::new(tracer::HistogramTracer::new()));
207+
}
208+
if trace_time {
209+
tracers.push(Box::new(tracer::TimingTracer::new()));
210+
}
211+
if trace_span {
212+
tracers.push(Box::new(span_tracer::SpanTracer::new()));
213+
}
214+
if !tracers.is_empty() {
215+
interpreter.set_tracer(Box::new(tracer::CompositeTracer::new(tracers)));
216+
}
217+
}
218+
121219
let name = filename.as_deref().unwrap_or("<input>");
122220
if let Err(err) = interpreter.eval_named(name, &string) {
123221
diagnostic::emit_error(interpreter.source_db(), err);

ndc_bin/src/span_tracer.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
use ndc_interpreter::tracer::{InstructionContext, VmTracer};
2+
use std::collections::HashMap;
3+
use std::io::Write;
4+
use std::time::{Duration, Instant};
5+
use yansi::Paint;
6+
7+
/// Accumulates time per source span, then renders the source code as a heat map
8+
/// where cold regions are green and hot regions are red.
9+
pub struct SpanTracer {
10+
/// Accumulated time per (offset, length) span.
11+
times: HashMap<(usize, usize), Duration>,
12+
last: Option<((usize, usize), Instant)>,
13+
source: Option<String>,
14+
}
15+
16+
impl SpanTracer {
17+
pub fn new() -> Self {
18+
Self {
19+
times: HashMap::new(),
20+
last: None,
21+
source: None,
22+
}
23+
}
24+
}
25+
26+
impl VmTracer for SpanTracer {
27+
fn on_instruction(&mut self, ctx: &InstructionContext<'_>) {
28+
if self.source.is_none()
29+
&& let Some(src) = ctx.source
30+
{
31+
self.source = Some(src.to_owned());
32+
}
33+
34+
let now = Instant::now();
35+
let key = (ctx.span.offset(), ctx.span.end() - ctx.span.offset());
36+
if let Some((prev_key, start)) = self.last.take() {
37+
*self.times.entry(prev_key).or_default() += now - start;
38+
}
39+
self.last = Some((key, now));
40+
}
41+
42+
fn on_complete(&mut self) {
43+
// Finalize the last instruction's timing.
44+
if let Some((key, start)) = self.last.take() {
45+
*self.times.entry(key).or_default() += start.elapsed();
46+
}
47+
48+
let Some(source) = &self.source else {
49+
return;
50+
};
51+
52+
if self.times.is_empty() {
53+
return;
54+
}
55+
56+
// Additive heat: each span's duration is added to every byte it covers.
57+
// This means bytes inside both a hot outer span and an inner span accumulate
58+
// both contributions, correctly showing them as hotter.
59+
let spans: Vec<_> = self.times.drain().collect();
60+
let mut heat = vec![Duration::ZERO; source.len()];
61+
for ((offset, length), dur) in &spans {
62+
let end = (offset + length).min(source.len());
63+
for h in &mut heat[*offset..end] {
64+
*h += *dur;
65+
}
66+
}
67+
68+
// Find max duration for normalization.
69+
let max_dur = heat.iter().max().copied().unwrap_or(Duration::ZERO);
70+
71+
if max_dur.is_zero() {
72+
print!("{source}");
73+
let _ = std::io::stdout().flush();
74+
return;
75+
}
76+
77+
let max_nanos = max_dur.as_nanos() as f64;
78+
79+
// Render: walk the source, coloring each non-whitespace character by heat.
80+
let mut byte_pos = 0;
81+
for ch in source.chars() {
82+
let len = ch.len_utf8();
83+
let s = &source[byte_pos..byte_pos + len];
84+
85+
let color = if ch.is_ascii_whitespace() || byte_pos >= heat.len() {
86+
None
87+
} else {
88+
let d = heat[byte_pos];
89+
if d.is_zero() {
90+
None
91+
} else {
92+
let t = d.as_nanos() as f64 / max_nanos;
93+
Some(heat_color(t))
94+
}
95+
};
96+
97+
match color {
98+
Some((r, g, b)) => print!("{}", s.rgb(r, g, b)),
99+
None => print!("{s}"),
100+
}
101+
102+
byte_pos += len;
103+
}
104+
105+
let _ = std::io::stdout().flush();
106+
}
107+
}
108+
109+
/// Interpolate from soft green (cold, t=0) through warm peach (t=0.5) to soft red (hot, t=1).
110+
fn heat_color(t: f64) -> (u8, u8, u8) {
111+
let t = t.clamp(0.0, 1.0);
112+
// Mix towards white to create pastel tones: lerp between the pure hue and (255,255,255).
113+
let pastel = 0.4; // 0.0 = fully saturated, 1.0 = white
114+
let r = ((t.min(0.5) * 2.0).mul_add(1.0 - pastel, pastel) * 255.0) as u8;
115+
let g = ((t - 0.5).max(0.0).mul_add(-2.0, 1.0)).mul_add(1.0 - pastel, pastel) * 255.0;
116+
let b = (pastel * 255.0) as u8;
117+
(r, g as u8, b)
118+
}

ndc_interpreter/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ edition.workspace = true
55
version.workspace = true
66

77
[features]
8-
vm-trace = ["ndc_vm/vm-trace"]
8+
trace = ["ndc_vm/trace"]
99

1010
[dependencies]
1111
ndc_analyser.workspace = true

ndc_interpreter/src/lib.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use ndc_vm::value::CompiledFunction;
77
use ndc_vm::{OutputSink, Vm};
88
use std::rc::Rc;
99

10+
#[cfg(feature = "trace")]
11+
pub use ndc_vm::tracer;
1012
pub use ndc_vm::{NativeFunction, Value};
1113

1214
pub struct Interpreter {
@@ -18,6 +20,8 @@ pub struct Interpreter {
1820
/// `None` until the first `eval` call; kept alive afterwards so that
1921
/// variables declared on one line are visible on subsequent lines.
2022
repl_state: Option<(Vm, Compiler)>,
23+
#[cfg(feature = "trace")]
24+
tracer: Option<Box<dyn tracer::VmTracer>>,
2125
}
2226

2327
impl Interpreter {
@@ -41,6 +45,8 @@ impl Interpreter {
4145
analyser: Analyser::from_scope_tree(ScopeTree::from_global_scope(vec![])),
4246
source_db: SourceDb::new(),
4347
repl_state: None,
48+
#[cfg(feature = "trace")]
49+
tracer: None,
4450
}
4551
}
4652

@@ -55,6 +61,11 @@ impl Interpreter {
5561
self.analyser = Analyser::from_scope_tree(ScopeTree::from_global_scope(functions));
5662
}
5763

64+
#[cfg(feature = "trace")]
65+
pub fn set_tracer(&mut self, tracer: Box<dyn tracer::VmTracer>) {
66+
self.tracer = Some(tracer);
67+
}
68+
5869
pub fn functions(&self) -> impl Iterator<Item = &Rc<NativeFunction>> {
5970
self.registry.iter()
6071
}
@@ -138,8 +149,8 @@ impl Interpreter {
138149

139150
fn interpret_vm(
140151
&mut self,
141-
#[cfg(feature = "vm-trace")] input: &str,
142-
#[cfg(not(feature = "vm-trace"))] _input: &str,
152+
#[cfg(feature = "trace")] input: &str,
153+
#[cfg(not(feature = "trace"))] _input: &str,
143154
expressions: impl Iterator<Item = ExpressionLocation>,
144155
) -> Result<Value, InterpreterError> {
145156
use ndc_vm::{Function as VmFunction, Object as VmObject, Value as VmValue};
@@ -163,9 +174,12 @@ impl Interpreter {
163174
};
164175
let (code, checkpoint) = Compiler::compile_resumable(expressions)?;
165176
let mut vm = Vm::new(code, globals).with_output(output);
166-
#[cfg(feature = "vm-trace")]
177+
#[cfg(feature = "trace")]
167178
{
168179
vm = vm.with_source(input);
180+
if let Some(tracer) = self.tracer.take() {
181+
vm = vm.with_tracer(tracer);
182+
}
169183
}
170184
vm.run()?;
171185
let result = vm.last_value(checkpoint.num_locals());
@@ -177,7 +191,7 @@ impl Interpreter {
177191
let prev_num_locals = checkpoint.num_locals();
178192
let (code, new_checkpoint) = checkpoint.resume(expressions)?;
179193
vm.resume_from_halt(code, globals, resume_ip, prev_num_locals);
180-
#[cfg(feature = "vm-trace")]
194+
#[cfg(feature = "trace")]
181195
{
182196
vm.set_source(input);
183197
}

ndc_vm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ edition.workspace = true
44
version.workspace = true
55

66
[features]
7-
vm-trace = []
7+
trace = []
88

99
[dependencies]
1010
thiserror.workspace = true

ndc_vm/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ pub mod compiler;
33
pub mod disassemble;
44
pub mod error;
55
pub mod iterator;
6+
#[cfg(feature = "trace")]
7+
#[allow(clippy::print_stderr)]
8+
pub mod tracer;
69
pub mod value;
710
mod vm;
811

0 commit comments

Comments
 (0)