Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ env:

permissions:
contents: read
pull-requests: write

jobs:
build:
Expand Down Expand Up @@ -111,8 +112,49 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.github/actions/setup
with:
tools: cargo-nextest
rust-cache-shared-key: "stable"
- name: Gas snapshot diff
if: github.event_name == 'pull_request'
run: |
git show origin/${{ github.base_ref }}:crates/e2e/.gas-snapshot > /tmp/base-gas-snapshot 2>/dev/null || true
git show HEAD:crates/e2e/.gas-snapshot > /tmp/head-gas-snapshot 2>/dev/null || true
if [ -s /tmp/base-gas-snapshot ] && [ -s /tmp/head-gas-snapshot ]; then
python3 scripts/gas-diff.py /tmp/base-gas-snapshot /tmp/head-gas-snapshot > /tmp/gas-diff.md
else
echo "No gas snapshots to compare." > /tmp/gas-diff.md
fi
- run: just e2e-ci
- name: Post gas diff comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('/tmp/gas-diff.md', 'utf8');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = '## Gas Snapshot Diff';
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ e2e:
#!/usr/bin/env bash
set -euo pipefail
rm -rf /tmp/edge-gas
cargo test -p edge-e2e
cargo test --lib --tests -p edge-e2e
if [ -f /tmp/edge-gas/e2e.csv ]; then
sort /tmp/edge-gas/e2e.csv > crates/e2e/.gas-snapshot
echo "Gas snapshot written to crates/e2e/.gas-snapshot ($(wc -l < crates/e2e/.gas-snapshot) entries)"
Expand Down
310 changes: 310 additions & 0 deletions crates/codegen/src/assembler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,316 @@ impl Assembler {
self.instructions.is_empty()
}

/// Jump threading: if Label(X) is immediately followed by JumpTo(Y)
/// (skipping Comments), rewrite any JumpTo(X)/JumpITo(X)/PushLabel(X) to
/// target Y instead. Iterates to a fixed point for chains.
pub fn thread_jumps(&mut self) {
use std::collections::HashMap;
loop {
// Build redirect map: label X → label Y when Label(X) is followed by JumpTo(Y)
let mut redirects: HashMap<String, String> = HashMap::new();
let mut i = 0;
while i < self.instructions.len() {
if let AsmInstruction::Label(ref label) = self.instructions[i] {
// Find next non-comment instruction after this label
let mut j = i + 1;
while j < self.instructions.len() {
if matches!(self.instructions[j], AsmInstruction::Comment(_)) {
j += 1;
} else {
break;
}
}
if j < self.instructions.len() {
if let AsmInstruction::JumpTo(ref target) = self.instructions[j] {
if target != label {
redirects.insert(label.clone(), target.clone());
}
}
}
}
i += 1;
}

if redirects.is_empty() {
break;
}

// Apply redirects
let mut changed = false;
for inst in &mut self.instructions {
let target = match inst {
AsmInstruction::JumpTo(ref t)
| AsmInstruction::JumpITo(ref t)
| AsmInstruction::PushLabel(ref t) => redirects.get(t).cloned(),
_ => None,
};
if let Some(new_target) = target {
match inst {
AsmInstruction::JumpTo(ref mut t)
| AsmInstruction::JumpITo(ref mut t)
| AsmInstruction::PushLabel(ref mut t) => {
*t = new_target;
changed = true;
}
_ => {}
}
}
}

if !changed {
break;
}
}

// Remove dead labels: Label(X) that no jump/push references anymore.
// Also remove the JumpTo that follows a dead label (and intervening comments).
use std::collections::HashSet;
let referenced: HashSet<String> = self
.instructions
.iter()
.filter_map(|inst| match inst {
AsmInstruction::JumpTo(t)
| AsmInstruction::JumpITo(t)
| AsmInstruction::PushLabel(t) => Some(t.clone()),
_ => None,
})
.collect();

let mut keep = vec![true; self.instructions.len()];
let mut i = 0;
while i < self.instructions.len() {
if let AsmInstruction::Label(ref label) = self.instructions[i] {
if !referenced.contains(label) {
// Mark label and subsequent comments + JumpTo for removal
keep[i] = false;
let mut j = i + 1;
while j < self.instructions.len() {
match &self.instructions[j] {
AsmInstruction::Comment(_) => {
keep[j] = false;
j += 1;
}
AsmInstruction::JumpTo(_) => {
keep[j] = false;
break;
}
_ => break,
}
}
}
}
i += 1;
}

let mut idx = 0;
self.instructions.retain(|_| {
let k = keep[idx];
idx += 1;
k
});
}

/// Eliminate SWAP1+POP cleanup chains that precede a halting sequence.
///
/// Within a basic block (between labels), if a contiguous chain of SWAP1+POP
/// pairs is followed by a "clean terminal" sequence (only Push/MSTORE/RETURN/
/// REVERT/STOP, no DUPs or SWAPs), the chain can be removed. The SWAP1+POP
/// chain preserves TOS while removing elements below it; since the terminal
/// sequence only uses TOS and freshly pushed values, the removed elements
/// are never accessed. RETURN/REVERT/STOP halts execution so leftover stack
/// junk is harmless.
pub fn eliminate_pre_halt_cleanup(&mut self) {
use std::collections::HashSet;

// Phase 1: Identify "halting labels" — labels whose basic block ends
// with RETURN/REVERT/STOP (or JUMP to another halting label).
// Iterate to a fixed point for chains.
let mut halting_labels: HashSet<String> = HashSet::new();
loop {
let mut changed = false;
let mut current_label: Option<String> = None;
for inst in &self.instructions {
match inst {
AsmInstruction::Label(name) => {
current_label = Some(name.clone());
}
AsmInstruction::Op(Opcode::Return)
| AsmInstruction::Op(Opcode::Revert)
| AsmInstruction::Op(Opcode::Stop)
| AsmInstruction::Op(Opcode::Invalid) => {
if let Some(ref label) = current_label {
if halting_labels.insert(label.clone()) {
changed = true;
}
}
}
AsmInstruction::JumpTo(target) => {
if halting_labels.contains(target) {
if let Some(ref label) = current_label {
if halting_labels.insert(label.clone()) {
changed = true;
}
}
}
}
// JumpITo doesn't unconditionally halt — skip
_ => {}
}
}
if !changed {
break;
}
}

// Phase 2: For each basic block, check if it ends in a halt or
// unconditional jump to a halting label. If so, remove SWAP1+POP
// chains that immediately precede the terminal sequence.
let len = self.instructions.len();
let mut keep = vec![true; len];

// Find block boundaries and terminal instructions
let mut i = 0;
while i < len {
// Find the end of this basic block: next Label, or end of instructions
let block_start = i;
let mut block_end = i;
let mut terminal_idx = None;

let mut j = if matches!(&self.instructions[i], AsmInstruction::Label(_)) {
i + 1
} else {
i
};

while j < len {
match &self.instructions[j] {
AsmInstruction::Label(name) => {
// Fallthrough to next block — if the target is halting,
// use the label position as the boundary so the backward
// walk finds the SWAP1+POP chain at the end of this block.
if halting_labels.contains(name) {
terminal_idx = Some(j);
}
block_end = j;
break;
}
AsmInstruction::Op(Opcode::Return)
| AsmInstruction::Op(Opcode::Revert)
| AsmInstruction::Op(Opcode::Stop)
| AsmInstruction::Op(Opcode::Invalid) => {
terminal_idx = Some(j);
// Mark everything after the halt in this block as dead
let mut k = j + 1;
while k < len && !matches!(&self.instructions[k], AsmInstruction::Label(_))
{
keep[k] = false;
k += 1;
}
block_end = k;
break;
}
AsmInstruction::JumpTo(target) => {
if halting_labels.contains(target) {
terminal_idx = Some(j);
}
block_end = j + 1;
break;
}
AsmInstruction::JumpITo(_) => {
// Conditional jump — not a clean terminal
block_end = j + 1;
break;
}
_ => {
j += 1;
}
}
}
if j >= len {
block_end = len;
}

// If we found a terminal, walk backward to find the "clean terminal"
// start, then further backward to find SWAP1+POP chain
if let Some(term) = terminal_idx {
// Walk backward past the clean terminal sequence
// (Push, Push0, MStore, MStore8, Comments — no DUP/SWAP/other)
let mut setup_start = term;
while setup_start > block_start {
let prev = setup_start - 1;
match &self.instructions[prev] {
AsmInstruction::Op(Opcode::MStore | Opcode::MStore8 | Opcode::Push0)
| AsmInstruction::Push(_)
| AsmInstruction::Comment(_) => {
setup_start = prev;
}
_ => break,
}
}

// Check for redundant DUP1 before the clean terminal: DUP1 copies
// TOS for MStore consumption, but if RETURN follows the original
// copy is never used and the DUP1 can be removed.
if setup_start > block_start {
let prev = setup_start - 1;
if matches!(&self.instructions[prev], AsmInstruction::Op(Opcode::Dup1)) {
keep[prev] = false;
setup_start = prev;
}
}

// Walk backward past SWAP1+POP chain
let mut chain_start = setup_start;
while chain_start >= block_start + 2 {
let pop_idx = chain_start - 1;
let swap_idx = chain_start - 2;
// Skip comments between swap and pop
if matches!(&self.instructions[pop_idx], AsmInstruction::Op(Opcode::Pop))
&& matches!(
&self.instructions[swap_idx],
AsmInstruction::Op(Opcode::Swap1)
)
{
chain_start = swap_idx;
} else if matches!(&self.instructions[pop_idx], AsmInstruction::Comment(_)) {
// Skip comment and try again
chain_start = pop_idx;
} else {
break;
}
}

// Mark SWAP1+POP pairs in the chain for removal
if chain_start < setup_start {
let mut k = chain_start;
while k < setup_start {
match &self.instructions[k] {
AsmInstruction::Op(Opcode::Swap1) | AsmInstruction::Op(Opcode::Pop) => {
keep[k] = false;
}
_ => {} // keep comments
}
k += 1;
}
}
}

i = if block_end > block_start {
block_end
} else {
block_start + 1
};
}

let mut idx = 0;
self.instructions.retain(|_| {
let k = keep[idx];
idx += 1;
k
});
}

/// Assemble into final bytecode, resolving all labels to offsets.
///
/// Tries PUSH1 (short) jumps first. If any label offset >= 256,
Expand Down
Loading
Loading