Skip to content

Commit 05963a7

Browse files
timfennisclaude
andcommitted
perf(vm): add peephole pass to elide dead Load; Pop pairs ✂️
Runs to a fixed point over the IR, removing consecutive `Constant|GetGlobal|GetLocal` followed by `Pop` when neither is a jump target. Skipped on the REPL's resumable path so `halt_ip` stays stable across resume-from-halt. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 52784ff commit 05963a7

3 files changed

Lines changed: 118 additions & 68 deletions

File tree

ndc_vm/src/chunk.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::Value;
22
use ndc_core::hash_map::HashMap;
33
use ndc_lexer::Span;
44
use ndc_parser::CaptureSource;
5-
use std::rc::Rc;
5+
use std::{collections::HashSet, rc::Rc};
66

77
/// A signed displacement applied to the instruction pointer to perform a jump.
88
///
@@ -267,6 +267,49 @@ impl OptimizerIr {
267267
}
268268
}
269269

270+
pub(crate) fn peephole(&mut self) {
271+
let targets: HashSet<LabelId> = self // todo replace with ahash
272+
.code
273+
.iter()
274+
.filter_map(|(_, op, _)| match op {
275+
OpCode::Jump(JumpTarget::Label(l))
276+
| OpCode::JumpIfFalse(JumpTarget::Label(l))
277+
| OpCode::JumpIfTrue(JumpTarget::Label(l))
278+
| OpCode::IterNext(JumpTarget::Label(l)) => Some(*l),
279+
_ => None,
280+
})
281+
.collect();
282+
283+
loop {
284+
let mut out = Vec::with_capacity(self.code.len());
285+
let mut i = 0;
286+
let mut removed = 0;
287+
while i < self.code.len() {
288+
let elide = i + 1 < self.code.len()
289+
&& matches!(
290+
self.code[i].1,
291+
OpCode::Constant(_) | OpCode::GetGlobal(_) | OpCode::GetLocal(_)
292+
)
293+
&& matches!(self.code[i + 1].1, OpCode::Pop)
294+
&& !targets.contains(&self.code[i].0)
295+
&& !targets.contains(&self.code[i + 1].0);
296+
if elide {
297+
i += 2;
298+
removed += 2;
299+
} else {
300+
out.push(self.code[i].clone());
301+
i += 1;
302+
}
303+
}
304+
305+
self.code = out;
306+
307+
if removed == 0 {
308+
break;
309+
}
310+
}
311+
}
312+
270313
pub(crate) fn add_constant(&mut self, value: Value) -> usize {
271314
self.constants.push(value);
272315
self.constants.len() - 1

ndc_vm/src/compiler.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,55 @@ use ndc_parser::{
99
};
1010
use std::rc::Rc;
1111

12-
#[derive(Default, Clone)]
12+
#[derive(Clone)]
1313
pub struct Compiler {
1414
ir: OptimizerIr,
1515
num_locals: usize,
1616
loop_stack: Vec<LoopContext>,
1717
allow_return: bool,
18+
/// Whether to run the peephole pass on this chunk in `finish()`.
19+
/// Disabled for the REPL's resumable compilation: peephole shrinks
20+
/// instruction positions, which would invalidate the `halt_ip` captured
21+
/// in the checkpoint and break resume-from-halt.
22+
optimize: bool,
23+
}
24+
25+
impl Default for Compiler {
26+
fn default() -> Self {
27+
Self {
28+
ir: OptimizerIr::default(),
29+
num_locals: 0,
30+
loop_stack: Vec::new(),
31+
allow_return: false,
32+
optimize: true,
33+
}
34+
}
1835
}
1936

2037
impl Compiler {
2138
pub fn compile(
2239
expressions: impl Iterator<Item = ExpressionLocation>,
2340
) -> Result<CompiledFunction, CompileError> {
24-
Ok(Self::compile_resumable(expressions)?.0)
41+
let mut compiler = Self::default();
42+
for expr_loc in expressions {
43+
compiler.compile_expr(expr_loc)?;
44+
}
45+
Ok(compiler.finish()?.0)
2546
}
2647

2748
/// Compile expressions and return both the finished function and a
2849
/// checkpoint that can be passed to `resume` to append more code later.
2950
/// The checkpoint is the compiler state *before* the `Halt` instruction,
3051
/// so `resume` can extend the bytecode without re-running old instructions.
52+
///
53+
/// Peephole optimization is disabled on this path: the REPL's resume-from-
54+
/// halt machinery relies on `halt_ip` matching the position of `Halt` in
55+
/// the emitted chunk, and shifting instructions invalidates that.
3156
pub fn compile_resumable(
3257
expressions: impl Iterator<Item = ExpressionLocation>,
3358
) -> Result<(CompiledFunction, Self), CompileError> {
3459
let mut compiler = Self::default();
60+
compiler.optimize = false;
3561
for expr_loc in expressions {
3662
compiler.compile_expr(expr_loc)?;
3763
}
@@ -71,6 +97,10 @@ impl Compiler {
7197
fn finish(mut self) -> Result<(CompiledFunction, Self), CompileError> {
7298
let checkpoint = self.clone();
7399
self.ir.write(OpCode::Halt, Span::synthetic());
100+
if self.optimize {
101+
self.ir.peephole();
102+
}
103+
74104
let function = CompiledFunction {
75105
name: None,
76106
static_type: StaticType::Function {
@@ -723,6 +753,8 @@ impl Compiler {
723753
fn_compiler.compile_expr(body)?;
724754
fn_compiler.ir.write(OpCode::Return, Span::synthetic());
725755

756+
fn_compiler.ir.peephole();
757+
726758
let compiled = CompiledFunction {
727759
name,
728760
static_type,

tests/compiler/tests/compiler.rs

Lines changed: 40 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,11 @@ fn test_or() {
140140

141141
// 5;
142142
//
143-
// 0: Constant(0) push `5`
144-
// 1: Pop discard value (it's a statement)
145-
// 2: Halt
143+
// Pre-peephole: `Constant(0), Pop, Halt`. The peephole pass elides the dead
144+
// `Constant; Pop` pair, leaving just `Halt`.
146145
#[test]
147146
fn test_statement() {
148-
assert_eq!(compile("5;"), [Constant(0), Pop, Halt]);
147+
assert_eq!(compile("5;"), [Halt]);
149148
}
150149

151150
// { 5 }
@@ -159,40 +158,35 @@ fn test_block_with_expression() {
159158

160159
// { 5; }
161160
//
162-
// 0: Constant(0) push `5`
163-
// 1: Pop discard (trailing semicolon)
164-
// 2: Constant(1) push `()` (block result is unit)
165-
// 3: Halt
161+
// Pre-peephole: `Constant(0), Pop, Constant(1), Halt`. The peephole pass
162+
// elides the dead `Constant(0); Pop`, leaving the unit result.
166163
#[test]
167164
fn test_block_with_trailing_statement() {
168-
assert_eq!(compile("{ 5; }"), [Constant(0), Pop, Constant(1), Halt]);
165+
assert_eq!(compile("{ 5; }"), [Constant(1), Halt]);
169166
}
170167

171168
// { 5; 6 }
172169
//
173-
// 0: Constant(0) push `5`
174-
// 1: Pop discard intermediate statement
175-
// 2: Constant(1) push `6` (block result)
176-
// 3: Halt
170+
// Pre-peephole: `Constant(0), Pop, Constant(1), Halt`. The peephole elides
171+
// the intermediate `Constant(0); Pop`, leaving just the block result.
177172
#[test]
178173
fn test_block_multiple_statements() {
179-
assert_eq!(compile("{ 5; 6 }"), [Constant(0), Pop, Constant(1), Halt]);
174+
assert_eq!(compile("{ 5; 6 }"), [Constant(1), Halt]);
180175
}
181176

182177
// if true { 3 } else { 3; }
183178
//
184-
// true branch returns 3, false branch returns ()
179+
// true branch returns 3, false branch returns (). The peephole pass elides
180+
// the false branch's `Constant; Pop` (pushing `3` only to discard it).
185181
//
186182
// 0: Constant(0) push `true`
187183
// 1: JumpIfFalse(3) jump to false branch (index 5)
188184
// 2: Pop pop condition (true path)
189185
// 3: Constant(1) push `3`
190-
// 4: Jump(4) jump to Halt (index 9)
186+
// 4: Jump(2) jump to Halt (index 7)
191187
// 5: Pop pop condition (false path)
192-
// 6: Constant(2) push `3` (inner of `3;`)
193-
// 7: Pop discard (trailing semicolon)
194-
// 8: Constant(3) push `()` (block result)
195-
// 9: Halt
188+
// 6: Constant(3) push `()` (block result; constant 2 still in table but unreferenced)
189+
// 7: Halt
196190
#[test]
197191
fn test_if_with_statement_else() {
198192
assert_eq!(
@@ -202,9 +196,7 @@ fn test_if_with_statement_else() {
202196
JumpIfFalse(JumpTarget::Offset(3)),
203197
Pop,
204198
Constant(1),
205-
Jump(JumpTarget::Offset(4)),
206-
Pop,
207-
Constant(2),
199+
Jump(JumpTarget::Offset(2)),
208200
Pop,
209201
Constant(3),
210202
Halt
@@ -214,34 +206,27 @@ fn test_if_with_statement_else() {
214206

215207
// if true { 3; } else { 3; }
216208
//
217-
// Both branches return () — result is unit regardless of condition
209+
// Both branches return () — the peephole elides the `Constant; Pop` in each
210+
// arm (each branch's `3;` becomes nothing).
218211
//
219212
// 0: Constant(0) push `true`
220-
// 1: JumpIfFalse(5) jump to false branch (index 7)
213+
// 1: JumpIfFalse(3) jump to false branch (index 5)
221214
// 2: Pop pop condition (true path)
222-
// 3: Constant(1) push `3`
223-
// 4: Pop discard
224-
// 5: Constant(2) push `()`
225-
// 6: Jump(4) jump to Halt (index 11)
226-
// 7: Pop pop condition (false path)
227-
// 8: Constant(3) push `3`
228-
// 9: Pop discard
229-
// 10: Constant(4) push `()`
230-
// 11: Halt
215+
// 3: Constant(2) push `()` (true-branch result; constant 1 unreferenced)
216+
// 4: Jump(2) jump to Halt (index 7)
217+
// 5: Pop pop condition (false path)
218+
// 6: Constant(4) push `()` (false-branch result; constant 3 unreferenced)
219+
// 7: Halt
231220
#[test]
232221
fn test_if_with_statement_branches() {
233222
assert_eq!(
234223
compile("if true { 3; } else { 3; }"),
235224
[
236225
Constant(0),
237-
JumpIfFalse(JumpTarget::Offset(5)),
238-
Pop,
239-
Constant(1),
226+
JumpIfFalse(JumpTarget::Offset(3)),
240227
Pop,
241228
Constant(2),
242-
Jump(JumpTarget::Offset(4)),
243-
Pop,
244-
Constant(3),
229+
Jump(JumpTarget::Offset(2)),
245230
Pop,
246231
Constant(4),
247232
Halt
@@ -251,25 +236,24 @@ fn test_if_with_statement_branches() {
251236

252237
// while true { 1 }
253238
//
239+
// The peephole elides the body's `Constant(1); Pop` (the loop body's value
240+
// is discarded). The backward jump offset shrinks accordingly.
241+
//
254242
// 0: Constant(0) push `true` ← loop_start
255-
// 1: JumpIfFalse(4) if false, jump past body to exit Pop (index 6)
243+
// 1: JumpIfFalse(2) if false, jump past body to exit Pop (index 4)
256244
// 2: Pop pop condition (true path)
257-
// 3: Constant(1) body: push `1`
258-
// 4: Pop discard body value (loops produce no value)
259-
// 5: Jump(-6) jump back to loop_start (index 0)
260-
// 6: Pop pop condition (false path, loop exit)
261-
// 7: Halt
245+
// 3: Jump(-4) jump back to loop_start (index 0)
246+
// 4: Pop pop condition (false path, loop exit)
247+
// 5: Halt
262248
#[test]
263249
fn test_while() {
264250
assert_eq!(
265251
compile("while true { 1 }"),
266252
[
267253
Constant(0),
268-
JumpIfFalse(JumpTarget::Offset(4)),
269-
Pop,
270-
Constant(1),
254+
JumpIfFalse(JumpTarget::Offset(2)),
271255
Pop,
272-
Jump(JumpTarget::Offset(-6)),
256+
Jump(JumpTarget::Offset(-4)),
273257
Pop,
274258
Halt
275259
]
@@ -294,30 +278,21 @@ fn test_declaration() {
294278
// let a = 1;
295279
// a = 5;
296280
//
297-
// Declaration stores 1 into pre-allocated slot 0.
298-
// Assignment pushes new value, SetLocal overwrites,
299-
// push unit as the expression result, Pop discards it.
281+
// Declaration stores 1 into pre-allocated slot 0. Assignment pushes new
282+
// value, SetLocal overwrites. The peephole elides the trailing
283+
// `Constant; Pop` (the assignment expression's unit result, discarded as a
284+
// statement).
300285
//
301286
// 0: Constant(0) push `1`
302287
// 1: SetLocal(0) store in slot 0 (declaration)
303288
// 2: Constant(1) push `5`
304289
// 3: SetLocal(0) overwrite slot 0 (assignment)
305-
// 4: Constant(2) push `()` (assignment result)
306-
// 5: Pop discard (statement)
307-
// 6: Halt
290+
// 4: Halt
308291
#[test]
309292
fn test_assignment() {
310293
assert_eq!(
311294
compile_with_analysis("let a = 1;\na = 5;"),
312-
[
313-
Constant(0),
314-
SetLocal(0),
315-
Constant(1),
316-
SetLocal(0),
317-
Constant(2),
318-
Pop,
319-
Halt
320-
]
295+
[Constant(0), SetLocal(0), Constant(1), SetLocal(0), Halt]
321296
);
322297
}
323298

0 commit comments

Comments
 (0)