Skip to content

Commit 4a5c951

Browse files
committed
opt: Collapse consecutive Up instructions of the same mode
1 parent 88961de commit 4a5c951

6 files changed

Lines changed: 376 additions & 7 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//! Up-collapse optimization: merge consecutive Up instructions of the same mode.
2+
//!
3+
//! Transforms: Up(1) → Up(1) → Up(2) into Up(4)
4+
//!
5+
//! Constraints:
6+
//! - Same mode only (Up, UpSkipTrivia, UpExact can't mix)
7+
//! - Effectless only (no pre_effects, post_effects, neg_fields)
8+
//! - Max 63 (6-bit payload limit)
9+
//! - Single successor (can't merge branching instructions)
10+
11+
use std::collections::{HashMap, HashSet};
12+
13+
use plotnik_bytecode::Nav;
14+
15+
use crate::bytecode::{InstructionIR, Label, MatchIR, NodeTypeIR};
16+
use crate::compile::CompileResult;
17+
18+
const MAX_UP_LEVEL: u8 = 63;
19+
20+
/// Collapse consecutive Up instructions of the same mode.
21+
pub fn collapse_up(result: &mut CompileResult) {
22+
let label_to_idx: HashMap<Label, usize> = result
23+
.instructions
24+
.iter()
25+
.enumerate()
26+
.map(|(i, instr)| (instr.label(), i))
27+
.collect();
28+
29+
let mut removed: HashSet<Label> = HashSet::new();
30+
31+
for i in 0..result.instructions.len() {
32+
let InstructionIR::Match(m) = &result.instructions[i] else {
33+
continue;
34+
};
35+
36+
let Some(up_level) = get_up_level(m.nav) else {
37+
continue;
38+
};
39+
40+
if m.successors.len() != 1 {
41+
continue;
42+
}
43+
44+
let mut current_level = up_level;
45+
let mut current_nav = m.nav;
46+
let mut final_successors = m.successors.clone();
47+
48+
// Absorb chain of effectless Up instructions with same mode
49+
while current_level < MAX_UP_LEVEL {
50+
let &[succ_label] = final_successors.as_slice() else {
51+
break;
52+
};
53+
54+
if removed.contains(&succ_label) {
55+
break;
56+
}
57+
58+
let Some(&succ_idx) = label_to_idx.get(&succ_label) else {
59+
break;
60+
};
61+
62+
let InstructionIR::Match(succ) = &result.instructions[succ_idx] else {
63+
break;
64+
};
65+
66+
let Some(succ_level) = get_up_level(succ.nav) else {
67+
break;
68+
};
69+
70+
if !same_up_mode(current_nav, succ.nav) || !is_effectless(succ) {
71+
break;
72+
}
73+
74+
// Merge: add levels (capped at 63)
75+
let new_level = current_level.saturating_add(succ_level).min(MAX_UP_LEVEL);
76+
current_nav = set_up_level(current_nav, new_level);
77+
current_level = new_level;
78+
final_successors = succ.successors.clone();
79+
removed.insert(succ_label);
80+
}
81+
82+
// Update the instruction if we merged anything
83+
if current_level != up_level {
84+
let InstructionIR::Match(m) = &mut result.instructions[i] else {
85+
unreachable!()
86+
};
87+
m.nav = current_nav;
88+
m.successors = final_successors;
89+
}
90+
}
91+
92+
// Remove absorbed instructions
93+
result
94+
.instructions
95+
.retain(|instr| !removed.contains(&instr.label()));
96+
}
97+
98+
/// Extract Up level from Nav, if it's an Up variant.
99+
fn get_up_level(nav: Nav) -> Option<u8> {
100+
match nav {
101+
Nav::Up(n) | Nav::UpSkipTrivia(n) | Nav::UpExact(n) => Some(n),
102+
_ => None,
103+
}
104+
}
105+
106+
/// Set the level on an Up Nav variant.
107+
fn set_up_level(nav: Nav, level: u8) -> Nav {
108+
match nav {
109+
Nav::Up(_) => Nav::Up(level),
110+
Nav::UpSkipTrivia(_) => Nav::UpSkipTrivia(level),
111+
Nav::UpExact(_) => Nav::UpExact(level),
112+
_ => nav,
113+
}
114+
}
115+
116+
/// Check if two Nav values are the same Up mode (ignoring level).
117+
fn same_up_mode(a: Nav, b: Nav) -> bool {
118+
matches!(
119+
(a, b),
120+
(Nav::Up(_), Nav::Up(_))
121+
| (Nav::UpSkipTrivia(_), Nav::UpSkipTrivia(_))
122+
| (Nav::UpExact(_), Nav::UpExact(_))
123+
)
124+
}
125+
126+
/// Check if a MatchIR has no effects or constraints (pure navigation).
127+
fn is_effectless(m: &MatchIR) -> bool {
128+
m.node_type == NodeTypeIR::Any
129+
&& m.node_field.is_none()
130+
&& m.pre_effects.is_empty()
131+
&& m.neg_fields.is_empty()
132+
&& m.post_effects.is_empty()
133+
&& m.predicate.is_none()
134+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
//! Unit tests for the Up-collapse optimization pass.
2+
3+
use plotnik_bytecode::Nav;
4+
5+
use super::collapse_up::collapse_up;
6+
use super::CompileResult;
7+
use crate::bytecode::{InstructionIR, Label, MatchIR};
8+
9+
#[test]
10+
fn collapse_up_single_mode() {
11+
// Up(1) → Up(1) → exit should become Up(2) → exit
12+
let mut result = CompileResult {
13+
instructions: vec![
14+
MatchIR::at(Label(0)).nav(Nav::Up(1)).next(Label(1)).into(),
15+
MatchIR::at(Label(1)).nav(Nav::Up(1)).next(Label(2)).into(),
16+
MatchIR::terminal(Label(2)).into(),
17+
],
18+
def_entries: Default::default(),
19+
preamble_entry: Label(0),
20+
};
21+
22+
collapse_up(&mut result);
23+
24+
// Should collapse to 2 instructions: Up(2) and terminal
25+
assert_eq!(result.instructions.len(), 2);
26+
27+
let InstructionIR::Match(m) = &result.instructions[0] else {
28+
panic!("expected Match");
29+
};
30+
assert_eq!(m.nav, Nav::Up(2));
31+
assert_eq!(m.successors, vec![Label(2)]);
32+
}
33+
34+
#[test]
35+
fn collapse_up_chain_of_three() {
36+
// Up(1) → Up(2) → Up(3) should become Up(6)
37+
let mut result = CompileResult {
38+
instructions: vec![
39+
MatchIR::at(Label(0)).nav(Nav::Up(1)).next(Label(1)).into(),
40+
MatchIR::at(Label(1)).nav(Nav::Up(2)).next(Label(2)).into(),
41+
MatchIR::at(Label(2)).nav(Nav::Up(3)).next(Label(3)).into(),
42+
MatchIR::terminal(Label(3)).into(),
43+
],
44+
def_entries: Default::default(),
45+
preamble_entry: Label(0),
46+
};
47+
48+
collapse_up(&mut result);
49+
50+
assert_eq!(result.instructions.len(), 2);
51+
52+
let InstructionIR::Match(m) = &result.instructions[0] else {
53+
panic!("expected Match");
54+
};
55+
assert_eq!(m.nav, Nav::Up(6));
56+
}
57+
58+
#[test]
59+
fn collapse_up_mixed_modes_no_merge() {
60+
// Up(1) → UpSkipTrivia(1) should NOT merge (different modes)
61+
let mut result = CompileResult {
62+
instructions: vec![
63+
MatchIR::at(Label(0)).nav(Nav::Up(1)).next(Label(1)).into(),
64+
MatchIR::at(Label(1))
65+
.nav(Nav::UpSkipTrivia(1))
66+
.next(Label(2))
67+
.into(),
68+
MatchIR::terminal(Label(2)).into(),
69+
],
70+
def_entries: Default::default(),
71+
preamble_entry: Label(0),
72+
};
73+
74+
collapse_up(&mut result);
75+
76+
// Should stay 3 instructions
77+
assert_eq!(result.instructions.len(), 3);
78+
}
79+
80+
#[test]
81+
fn collapse_up_skip_trivia_same_mode() {
82+
// UpSkipTrivia(1) → UpSkipTrivia(1) should merge
83+
let mut result = CompileResult {
84+
instructions: vec![
85+
MatchIR::at(Label(0))
86+
.nav(Nav::UpSkipTrivia(1))
87+
.next(Label(1))
88+
.into(),
89+
MatchIR::at(Label(1))
90+
.nav(Nav::UpSkipTrivia(1))
91+
.next(Label(2))
92+
.into(),
93+
MatchIR::terminal(Label(2)).into(),
94+
],
95+
def_entries: Default::default(),
96+
preamble_entry: Label(0),
97+
};
98+
99+
collapse_up(&mut result);
100+
101+
assert_eq!(result.instructions.len(), 2);
102+
103+
let InstructionIR::Match(m) = &result.instructions[0] else {
104+
panic!("expected Match");
105+
};
106+
assert_eq!(m.nav, Nav::UpSkipTrivia(2));
107+
}
108+
109+
#[test]
110+
fn collapse_up_exact_same_mode() {
111+
// UpExact(1) → UpExact(1) should merge
112+
let mut result = CompileResult {
113+
instructions: vec![
114+
MatchIR::at(Label(0))
115+
.nav(Nav::UpExact(1))
116+
.next(Label(1))
117+
.into(),
118+
MatchIR::at(Label(1))
119+
.nav(Nav::UpExact(1))
120+
.next(Label(2))
121+
.into(),
122+
MatchIR::terminal(Label(2)).into(),
123+
],
124+
def_entries: Default::default(),
125+
preamble_entry: Label(0),
126+
};
127+
128+
collapse_up(&mut result);
129+
130+
assert_eq!(result.instructions.len(), 2);
131+
132+
let InstructionIR::Match(m) = &result.instructions[0] else {
133+
panic!("expected Match");
134+
};
135+
assert_eq!(m.nav, Nav::UpExact(2));
136+
}
137+
138+
#[test]
139+
fn collapse_up_with_effects_no_merge() {
140+
// Up(1) with post_effects → Up(1) should NOT merge
141+
use crate::bytecode::EffectIR;
142+
use plotnik_bytecode::EffectOpcode;
143+
144+
let mut result = CompileResult {
145+
instructions: vec![
146+
MatchIR::at(Label(0)).nav(Nav::Up(1)).next(Label(1)).into(),
147+
MatchIR::at(Label(1))
148+
.nav(Nav::Up(1))
149+
.post_effects(vec![EffectIR::simple(EffectOpcode::Null, 0)])
150+
.next(Label(2))
151+
.into(),
152+
MatchIR::terminal(Label(2)).into(),
153+
],
154+
def_entries: Default::default(),
155+
preamble_entry: Label(0),
156+
};
157+
158+
collapse_up(&mut result);
159+
160+
// Should stay 3 instructions (effectful Up can't be absorbed)
161+
assert_eq!(result.instructions.len(), 3);
162+
}
163+
164+
#[test]
165+
fn collapse_up_max_63() {
166+
// Up(60) → Up(10) should become Up(63) (capped)
167+
let mut result = CompileResult {
168+
instructions: vec![
169+
MatchIR::at(Label(0)).nav(Nav::Up(60)).next(Label(1)).into(),
170+
MatchIR::at(Label(1)).nav(Nav::Up(10)).next(Label(2)).into(),
171+
MatchIR::terminal(Label(2)).into(),
172+
],
173+
def_entries: Default::default(),
174+
preamble_entry: Label(0),
175+
};
176+
177+
collapse_up(&mut result);
178+
179+
// Capped at 63, remaining Up(7) stays separate
180+
assert_eq!(result.instructions.len(), 2);
181+
182+
let InstructionIR::Match(m) = &result.instructions[0] else {
183+
panic!("expected Match");
184+
};
185+
assert_eq!(m.nav, Nav::Up(63));
186+
}
187+
188+
#[test]
189+
fn collapse_up_branching_no_merge() {
190+
// Up(1) with multiple successors should NOT merge
191+
let mut result = CompileResult {
192+
instructions: vec![
193+
MatchIR::at(Label(0))
194+
.nav(Nav::Up(1))
195+
.next_many(vec![Label(1), Label(2)])
196+
.into(),
197+
MatchIR::at(Label(1)).nav(Nav::Up(1)).next(Label(3)).into(),
198+
MatchIR::at(Label(2)).nav(Nav::Up(1)).next(Label(3)).into(),
199+
MatchIR::terminal(Label(3)).into(),
200+
],
201+
def_entries: Default::default(),
202+
preamble_entry: Label(0),
203+
};
204+
205+
collapse_up(&mut result);
206+
207+
// Branching instruction can't merge, but its successors can be processed
208+
// Label(0) has 2 successors, so it stays as Up(1)
209+
let InstructionIR::Match(m) = &result.instructions[0] else {
210+
panic!("expected Match");
211+
};
212+
assert_eq!(m.nav, Nav::Up(1));
213+
}
214+
215+
#[test]
216+
fn collapse_up_no_up_unchanged() {
217+
// Non-Up instructions should pass through unchanged
218+
let mut result = CompileResult {
219+
instructions: vec![
220+
MatchIR::at(Label(0)).nav(Nav::Down).next(Label(1)).into(),
221+
MatchIR::at(Label(1)).nav(Nav::Next).next(Label(2)).into(),
222+
MatchIR::terminal(Label(2)).into(),
223+
],
224+
def_entries: Default::default(),
225+
preamble_entry: Label(0),
226+
};
227+
228+
collapse_up(&mut result);
229+
230+
assert_eq!(result.instructions.len(), 3);
231+
}

crates/plotnik-compiler/src/compile/compiler.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::parser::Expr;
1313
use plotnik_bytecode::Nav;
1414

1515
use super::capture::CaptureEffects;
16+
use super::collapse_up::collapse_up;
1617
use super::dce::remove_unreachable;
1718
use super::epsilon_elim::eliminate_epsilons;
1819
use super::lower::lower;
@@ -87,6 +88,9 @@ impl<'a> Compiler<'a> {
8788
// Remove unreachable instructions (bypassed epsilons, etc.)
8889
remove_unreachable(&mut result);
8990

91+
// Collapse consecutive Up instructions of the same mode
92+
collapse_up(&mut result);
93+
9094
// Lower to bytecode-compatible form (cascade overflows)
9195
lower(&mut result);
9296

0 commit comments

Comments
 (0)