Skip to content

Commit d3eab41

Browse files
authored
Merge pull request #69 from refcell/brock/drop-optimizations
feat: codegen drop optimizations & pre-halt cleanup elimination
2 parents 89f62a6 + 2e90789 commit d3eab41

22 files changed

Lines changed: 2756 additions & 346 deletions

File tree

.github/workflows/ci.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ env:
1515

1616
permissions:
1717
contents: read
18+
pull-requests: write
1819

1920
jobs:
2021
build:
@@ -111,8 +112,49 @@ jobs:
111112
runs-on: ubuntu-latest
112113
steps:
113114
- uses: actions/checkout@v4
115+
with:
116+
fetch-depth: 0
114117
- uses: ./.github/actions/setup
115118
with:
116119
tools: cargo-nextest
117120
rust-cache-shared-key: "stable"
121+
- name: Gas snapshot diff
122+
if: github.event_name == 'pull_request'
123+
run: |
124+
git show origin/${{ github.base_ref }}:crates/e2e/.gas-snapshot > /tmp/base-gas-snapshot 2>/dev/null || true
125+
git show HEAD:crates/e2e/.gas-snapshot > /tmp/head-gas-snapshot 2>/dev/null || true
126+
if [ -s /tmp/base-gas-snapshot ] && [ -s /tmp/head-gas-snapshot ]; then
127+
python3 scripts/gas-diff.py /tmp/base-gas-snapshot /tmp/head-gas-snapshot > /tmp/gas-diff.md
128+
else
129+
echo "No gas snapshots to compare." > /tmp/gas-diff.md
130+
fi
118131
- run: just e2e-ci
132+
- name: Post gas diff comment
133+
if: github.event_name == 'pull_request'
134+
uses: actions/github-script@v7
135+
with:
136+
script: |
137+
const fs = require('fs');
138+
const body = fs.readFileSync('/tmp/gas-diff.md', 'utf8');
139+
const { data: comments } = await github.rest.issues.listComments({
140+
owner: context.repo.owner,
141+
repo: context.repo.repo,
142+
issue_number: context.issue.number,
143+
});
144+
const marker = '## Gas Snapshot Diff';
145+
const existing = comments.find(c => c.body.includes(marker));
146+
if (existing) {
147+
await github.rest.issues.updateComment({
148+
owner: context.repo.owner,
149+
repo: context.repo.repo,
150+
comment_id: existing.id,
151+
body: body,
152+
});
153+
} else {
154+
await github.rest.issues.createComment({
155+
owner: context.repo.owner,
156+
repo: context.repo.repo,
157+
issue_number: context.issue.number,
158+
body: body,
159+
});
160+
}

Justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ e2e:
8888
#!/usr/bin/env bash
8989
set -euo pipefail
9090
rm -rf /tmp/edge-gas
91-
cargo test -p edge-e2e
91+
cargo test --lib --tests -p edge-e2e
9292
if [ -f /tmp/edge-gas/e2e.csv ]; then
9393
sort /tmp/edge-gas/e2e.csv > crates/e2e/.gas-snapshot
9494
echo "Gas snapshot written to crates/e2e/.gas-snapshot ($(wc -l < crates/e2e/.gas-snapshot) entries)"

crates/codegen/src/assembler.rs

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,316 @@ impl Assembler {
156156
self.instructions.is_empty()
157157
}
158158

159+
/// Jump threading: if Label(X) is immediately followed by JumpTo(Y)
160+
/// (skipping Comments), rewrite any JumpTo(X)/JumpITo(X)/PushLabel(X) to
161+
/// target Y instead. Iterates to a fixed point for chains.
162+
pub fn thread_jumps(&mut self) {
163+
use std::collections::HashMap;
164+
loop {
165+
// Build redirect map: label X → label Y when Label(X) is followed by JumpTo(Y)
166+
let mut redirects: HashMap<String, String> = HashMap::new();
167+
let mut i = 0;
168+
while i < self.instructions.len() {
169+
if let AsmInstruction::Label(ref label) = self.instructions[i] {
170+
// Find next non-comment instruction after this label
171+
let mut j = i + 1;
172+
while j < self.instructions.len() {
173+
if matches!(self.instructions[j], AsmInstruction::Comment(_)) {
174+
j += 1;
175+
} else {
176+
break;
177+
}
178+
}
179+
if j < self.instructions.len() {
180+
if let AsmInstruction::JumpTo(ref target) = self.instructions[j] {
181+
if target != label {
182+
redirects.insert(label.clone(), target.clone());
183+
}
184+
}
185+
}
186+
}
187+
i += 1;
188+
}
189+
190+
if redirects.is_empty() {
191+
break;
192+
}
193+
194+
// Apply redirects
195+
let mut changed = false;
196+
for inst in &mut self.instructions {
197+
let target = match inst {
198+
AsmInstruction::JumpTo(ref t)
199+
| AsmInstruction::JumpITo(ref t)
200+
| AsmInstruction::PushLabel(ref t) => redirects.get(t).cloned(),
201+
_ => None,
202+
};
203+
if let Some(new_target) = target {
204+
match inst {
205+
AsmInstruction::JumpTo(ref mut t)
206+
| AsmInstruction::JumpITo(ref mut t)
207+
| AsmInstruction::PushLabel(ref mut t) => {
208+
*t = new_target;
209+
changed = true;
210+
}
211+
_ => {}
212+
}
213+
}
214+
}
215+
216+
if !changed {
217+
break;
218+
}
219+
}
220+
221+
// Remove dead labels: Label(X) that no jump/push references anymore.
222+
// Also remove the JumpTo that follows a dead label (and intervening comments).
223+
use std::collections::HashSet;
224+
let referenced: HashSet<String> = self
225+
.instructions
226+
.iter()
227+
.filter_map(|inst| match inst {
228+
AsmInstruction::JumpTo(t)
229+
| AsmInstruction::JumpITo(t)
230+
| AsmInstruction::PushLabel(t) => Some(t.clone()),
231+
_ => None,
232+
})
233+
.collect();
234+
235+
let mut keep = vec![true; self.instructions.len()];
236+
let mut i = 0;
237+
while i < self.instructions.len() {
238+
if let AsmInstruction::Label(ref label) = self.instructions[i] {
239+
if !referenced.contains(label) {
240+
// Mark label and subsequent comments + JumpTo for removal
241+
keep[i] = false;
242+
let mut j = i + 1;
243+
while j < self.instructions.len() {
244+
match &self.instructions[j] {
245+
AsmInstruction::Comment(_) => {
246+
keep[j] = false;
247+
j += 1;
248+
}
249+
AsmInstruction::JumpTo(_) => {
250+
keep[j] = false;
251+
break;
252+
}
253+
_ => break,
254+
}
255+
}
256+
}
257+
}
258+
i += 1;
259+
}
260+
261+
let mut idx = 0;
262+
self.instructions.retain(|_| {
263+
let k = keep[idx];
264+
idx += 1;
265+
k
266+
});
267+
}
268+
269+
/// Eliminate SWAP1+POP cleanup chains that precede a halting sequence.
270+
///
271+
/// Within a basic block (between labels), if a contiguous chain of SWAP1+POP
272+
/// pairs is followed by a "clean terminal" sequence (only Push/MSTORE/RETURN/
273+
/// REVERT/STOP, no DUPs or SWAPs), the chain can be removed. The SWAP1+POP
274+
/// chain preserves TOS while removing elements below it; since the terminal
275+
/// sequence only uses TOS and freshly pushed values, the removed elements
276+
/// are never accessed. RETURN/REVERT/STOP halts execution so leftover stack
277+
/// junk is harmless.
278+
pub fn eliminate_pre_halt_cleanup(&mut self) {
279+
use std::collections::HashSet;
280+
281+
// Phase 1: Identify "halting labels" — labels whose basic block ends
282+
// with RETURN/REVERT/STOP (or JUMP to another halting label).
283+
// Iterate to a fixed point for chains.
284+
let mut halting_labels: HashSet<String> = HashSet::new();
285+
loop {
286+
let mut changed = false;
287+
let mut current_label: Option<String> = None;
288+
for inst in &self.instructions {
289+
match inst {
290+
AsmInstruction::Label(name) => {
291+
current_label = Some(name.clone());
292+
}
293+
AsmInstruction::Op(Opcode::Return)
294+
| AsmInstruction::Op(Opcode::Revert)
295+
| AsmInstruction::Op(Opcode::Stop)
296+
| AsmInstruction::Op(Opcode::Invalid) => {
297+
if let Some(ref label) = current_label {
298+
if halting_labels.insert(label.clone()) {
299+
changed = true;
300+
}
301+
}
302+
}
303+
AsmInstruction::JumpTo(target) => {
304+
if halting_labels.contains(target) {
305+
if let Some(ref label) = current_label {
306+
if halting_labels.insert(label.clone()) {
307+
changed = true;
308+
}
309+
}
310+
}
311+
}
312+
// JumpITo doesn't unconditionally halt — skip
313+
_ => {}
314+
}
315+
}
316+
if !changed {
317+
break;
318+
}
319+
}
320+
321+
// Phase 2: For each basic block, check if it ends in a halt or
322+
// unconditional jump to a halting label. If so, remove SWAP1+POP
323+
// chains that immediately precede the terminal sequence.
324+
let len = self.instructions.len();
325+
let mut keep = vec![true; len];
326+
327+
// Find block boundaries and terminal instructions
328+
let mut i = 0;
329+
while i < len {
330+
// Find the end of this basic block: next Label, or end of instructions
331+
let block_start = i;
332+
let mut block_end = i;
333+
let mut terminal_idx = None;
334+
335+
let mut j = if matches!(&self.instructions[i], AsmInstruction::Label(_)) {
336+
i + 1
337+
} else {
338+
i
339+
};
340+
341+
while j < len {
342+
match &self.instructions[j] {
343+
AsmInstruction::Label(name) => {
344+
// Fallthrough to next block — if the target is halting,
345+
// use the label position as the boundary so the backward
346+
// walk finds the SWAP1+POP chain at the end of this block.
347+
if halting_labels.contains(name) {
348+
terminal_idx = Some(j);
349+
}
350+
block_end = j;
351+
break;
352+
}
353+
AsmInstruction::Op(Opcode::Return)
354+
| AsmInstruction::Op(Opcode::Revert)
355+
| AsmInstruction::Op(Opcode::Stop)
356+
| AsmInstruction::Op(Opcode::Invalid) => {
357+
terminal_idx = Some(j);
358+
// Mark everything after the halt in this block as dead
359+
let mut k = j + 1;
360+
while k < len && !matches!(&self.instructions[k], AsmInstruction::Label(_))
361+
{
362+
keep[k] = false;
363+
k += 1;
364+
}
365+
block_end = k;
366+
break;
367+
}
368+
AsmInstruction::JumpTo(target) => {
369+
if halting_labels.contains(target) {
370+
terminal_idx = Some(j);
371+
}
372+
block_end = j + 1;
373+
break;
374+
}
375+
AsmInstruction::JumpITo(_) => {
376+
// Conditional jump — not a clean terminal
377+
block_end = j + 1;
378+
break;
379+
}
380+
_ => {
381+
j += 1;
382+
}
383+
}
384+
}
385+
if j >= len {
386+
block_end = len;
387+
}
388+
389+
// If we found a terminal, walk backward to find the "clean terminal"
390+
// start, then further backward to find SWAP1+POP chain
391+
if let Some(term) = terminal_idx {
392+
// Walk backward past the clean terminal sequence
393+
// (Push, Push0, MStore, MStore8, Comments — no DUP/SWAP/other)
394+
let mut setup_start = term;
395+
while setup_start > block_start {
396+
let prev = setup_start - 1;
397+
match &self.instructions[prev] {
398+
AsmInstruction::Op(Opcode::MStore | Opcode::MStore8 | Opcode::Push0)
399+
| AsmInstruction::Push(_)
400+
| AsmInstruction::Comment(_) => {
401+
setup_start = prev;
402+
}
403+
_ => break,
404+
}
405+
}
406+
407+
// Check for redundant DUP1 before the clean terminal: DUP1 copies
408+
// TOS for MStore consumption, but if RETURN follows the original
409+
// copy is never used and the DUP1 can be removed.
410+
if setup_start > block_start {
411+
let prev = setup_start - 1;
412+
if matches!(&self.instructions[prev], AsmInstruction::Op(Opcode::Dup1)) {
413+
keep[prev] = false;
414+
setup_start = prev;
415+
}
416+
}
417+
418+
// Walk backward past SWAP1+POP chain
419+
let mut chain_start = setup_start;
420+
while chain_start >= block_start + 2 {
421+
let pop_idx = chain_start - 1;
422+
let swap_idx = chain_start - 2;
423+
// Skip comments between swap and pop
424+
if matches!(&self.instructions[pop_idx], AsmInstruction::Op(Opcode::Pop))
425+
&& matches!(
426+
&self.instructions[swap_idx],
427+
AsmInstruction::Op(Opcode::Swap1)
428+
)
429+
{
430+
chain_start = swap_idx;
431+
} else if matches!(&self.instructions[pop_idx], AsmInstruction::Comment(_)) {
432+
// Skip comment and try again
433+
chain_start = pop_idx;
434+
} else {
435+
break;
436+
}
437+
}
438+
439+
// Mark SWAP1+POP pairs in the chain for removal
440+
if chain_start < setup_start {
441+
let mut k = chain_start;
442+
while k < setup_start {
443+
match &self.instructions[k] {
444+
AsmInstruction::Op(Opcode::Swap1) | AsmInstruction::Op(Opcode::Pop) => {
445+
keep[k] = false;
446+
}
447+
_ => {} // keep comments
448+
}
449+
k += 1;
450+
}
451+
}
452+
}
453+
454+
i = if block_end > block_start {
455+
block_end
456+
} else {
457+
block_start + 1
458+
};
459+
}
460+
461+
let mut idx = 0;
462+
self.instructions.retain(|_| {
463+
let k = keep[idx];
464+
idx += 1;
465+
k
466+
});
467+
}
468+
159469
/// Assemble into final bytecode, resolving all labels to offsets.
160470
///
161471
/// Tries PUSH1 (short) jumps first. If any label offset >= 256,

0 commit comments

Comments
 (0)