Skip to content
Open
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
45 changes: 45 additions & 0 deletions docs/changes/2026-07-28-ssa-shared-dynamic-dispatch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Change: Share full dynamic dispatch in stack-SSA builds

- **Status**: Implemented
- **Date**: 2026-07-28
- **Tier**: Light

## Overview

Reuse the existing module-level full-table dynamic jump dispatch CFG when EVM
stack-SSA lifting is enabled. Sources with a proven smaller target set continue
to use a per-source filtered dispatch.

## Motivation

The previous stack-SSA path rebuilt the full dynamic dispatch for every source.
For contracts with many dynamic jump sites and many `JUMPDEST`s, this duplicated
MIR control flow at approximately
`O(dynamic jump sources × dynamic jump targets)`. The duplication increased
frontend time, MIR size, register-allocation work, and emitted code without
changing execution semantics.

Runtime dynamic targets that use the full dispatch table are already forced to
the materialized runtime-stack fallback. They do not consume source-specific
stack-merge phis, so the full-table dispatch can be shared in stack-SSA and
non-SSA builds alike.

## Impact

- `compiler`: remove the stack-SSA exception from full-table dispatch sharing.
- `tests`: assert that many unfiltered dynamic sources produce fewer dispatch
switches than sources.
- `docs`: record the runtime-stack fallback invariant that makes sharing sound.

Filtered target sets remain source-specific so their predecessor registration
and target filtering are unchanged. EVM semantics and determinism are
unaffected.

## Validation

- [x] Implementation complete
- [x] Tests added
- [x] Module specifications updated
- [x] Formatting check passes
- [x] Multipass stack-SSA build passes
- [x] `evmJitFrontendTests` passes (`109/109`)
1 change: 1 addition & 0 deletions docs/changes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Typical triggers:
| 2026-04-14 | [from-raw-pointer-safety-checks](2026-04-14-from-raw-pointer-safety-checks/README.md) | Accepted | Light | Add null/alignment safety checks to `from_raw_pointer` in Rust bindings |
| 2026-05-13 | [evm-ngram-macro-ops](2026-05-13-evm-ngram-macro-ops/README.md) | Implemented | Full | Initial EVM n-gram macro-op lowering and specialized keccak helpers for multipass JIT |
| 2026-07-21 | [evm-memory-alias-and-expansion](2026-07-21-evm-memory-alias-and-expansion/README.md) | Implemented | Full | Stronger memory alias proofs, wider precheck/expansion coverage, DSE, load forwarding, grouping, and MCOPY roadmap |
| 2026-07-28 | [ssa-shared-dynamic-dispatch](2026-07-28-ssa-shared-dynamic-dispatch/README.md) | Implemented | Light | Share unfiltered full-table dynamic dispatch in stack-SSA builds |

Each active proposal lives in its own subdirectory. Browse `docs/changes/*/README.md`
to see all current proposals, or use:
Expand Down
20 changes: 20 additions & 0 deletions docs/modules/compiler/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,26 @@ and destination ends for expansion elision while retaining the original
- Provide bytecode, gas chunk end/cost arrays for chunk-based metering
- Use register to hold gas when `ZEN_ENABLE_EVM_GAS_REGISTER` is enabled

### EVM Stack SSA Lift Safety

- `ZEN_ENABLE_EVM_STACK_SSA_LIFT` permits compatible EVM operand-stack values
to cross basic-block boundaries as SSA state.
- A lifted merge must have one valid incoming for every final MIR predecessor,
and all MIR phi instructions must remain contiguous at block start.
- Dynamic-jump lowering may expand one EVM predecessor PC into multiple MIR
predecessor blocks. Until the merge representation distinguishes those MIR
predecessors, a dynamic-jump target requiring an entry merge is non-lifted
and uses the deterministic runtime-stack fallback.
- An unfiltered full-table dynamic dispatch is shared at module scope in both
stack-SSA and non-SSA builds. This is sound while every runtime
dynamic-dispatch target is non-lifted and consumes the materialized runtime
stack. A source with a proven smaller candidate set retains a per-source
filtered dispatch. Re-enabling lifting for runtime dynamic targets requires
revisiting this predecessor-accounting contract before changing either
invariant.
- Unsupported merge shapes must fall back before MIR phi construction; they
must never emit an incomplete phi, fail module verification, or abort.

### Machine Code and Module Binding

- Machine code is written to `EVMModule::getJITCodeMemPool()` or the corresponding `Module` pool
Expand Down
9 changes: 9 additions & 0 deletions docs/modules/tests/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ This module does not include: EVM interpreter (evm), Host implementation (host),
- **evmFallbackExecutionTests**: Requires `ZEN_ENABLE_LIBEVM`; verify interpreter fallback on JIT exception
- **Dependency**: dtvmapi library

### 9. EVM JIT Frontend Structural Tests

- **evmJitFrontendTests**: Validate analyzer facts, stack-SSA boundaries,
memory-plan consumers, MIR verification, and control-flow lowering.
- Full-table runtime dynamic dispatch must remain shared across multiple
unfiltered dynamic sources. The structural regression counts MIR switch
statements so stack-SSA builds cannot silently return to per-source
`O(dynamic sources × JUMPDESTs)` expansion.

## External Contracts

| Dependent Module | Contract |
Expand Down
18 changes: 7 additions & 11 deletions src/compiler/evm_frontend/evm_mir_compiler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -316,14 +316,6 @@ void EVMMirBuilder::loadEVMInstanceAttr() {
ExceptionReturnBB = CurFunc->createExceptionReturnBB();
}

bool EVMMirBuilder::shouldUseSharedDynamicDispatch() const {
#ifdef ZEN_ENABLE_EVM_STACK_SSA_LIFT
return false;
#else
return true;
#endif
}

MBasicBlock *EVMMirBuilder::getOrCreateIndirectJumpBB(
uint64_t SourceBlockPC, const JumpTargetPCList *CandidateTargets) {
auto ExistingIt = IndirectJumpBBs.find(SourceBlockPC);
Expand Down Expand Up @@ -1576,12 +1568,16 @@ void EVMMirBuilder::implementIndirectJump(
const bool UseFilteredPerSource =
CandidateTargets != nullptr && !CandidateTargets->empty() &&
CandidateTargets->size() < JumpDestTable.size();
// Full-table runtime-dispatch targets are forced onto the materialized
// runtime-stack path by EVMAnalyzer::finalizeLiftability(). They therefore
// have no source-aware stack-merge phi consumer and can share one dispatch
// CFG in both stack-SSA and non-SSA builds. A proven smaller candidate set
// remains per-source so its filtered dispatch and predecessor registration
// stay source-specific.
MBasicBlock *TargetBB =
UseFilteredPerSource
? getOrCreateIndirectJumpBB(CurrentBlockPC, CandidateTargets)
: (shouldUseSharedDynamicDispatch()
? getOrCreateSharedIndirectJumpBB()
: getOrCreateIndirectJumpBB(CurrentBlockPC));
: getOrCreateSharedIndirectJumpBB();
createInstruction<DassignInstruction>(true, &(Ctx.VoidType), JumpTarget,
JumpTargetVar->getVarIdx());
createInstruction<BrInstruction>(true, Ctx, TargetBB);
Expand Down
1 change: 0 additions & 1 deletion src/compiler/evm_frontend/evm_mir_compiler.h
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,6 @@ class EVMMirBuilder final {
MBasicBlock *buildIndirectJumpBB(uint64_t SourceBlockPC,
const JumpTargetPCList *CandidateTargets,
bool RegisterDynamicPhi);
bool shouldUseSharedDynamicDispatch() const;
void linkJumpDestEntryThunkIfNeeded(MBasicBlock *TargetBB);
void registerPhiIncomingBlock(uint64_t TargetBlockPC, uint64_t PredBlockPC,
MBasicBlock *PredBB);
Expand Down
52 changes: 52 additions & 0 deletions src/tests/evm_jit_frontend_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,43 @@ bool verifyMirForBytecode(const std::vector<uint8_t> &Bytecode,
return Ok;
}

struct MirControlFlowStats {
bool Compiled = false;
uint32_t BasicBlocks = 0;
uint32_t Switches = 0;
};

MirControlFlowStats
compileMirControlFlowStats(const std::vector<uint8_t> &Bytecode) {
COMPILER::EVMFrontendContext Ctx;
Ctx.setRevision(EVMC_CANCUN);
Ctx.setGasMeteringEnabled(false);
Ctx.setBytecode(reinterpret_cast<const zen::common::Byte *>(Bytecode.data()),
Bytecode.size());

COMPILER::MModule Mod(Ctx);
std::array<COMPILER::MType *, 1> ParamTypes = {
COMPILER::MPointerType::create(Ctx, Ctx.VoidType)};
COMPILER::MFunctionType *FuncType = COMPILER::MFunctionType::create(
Ctx, Ctx.VoidType, llvm::ArrayRef<COMPILER::MType *>(ParamTypes));
Mod.addFuncType(FuncType);

COMPILER::MFunction Func(Ctx, 0);
Func.setFunctionType(FuncType);
EVMMirBuilder Builder(Ctx, Func);
MirControlFlowStats Stats;
Stats.Compiled = Builder.compile(&Ctx);
Stats.BasicBlocks = Func.getNumBasicBlocks();
for (COMPILER::MBasicBlock *BB : Func) {
for (COMPILER::MInstruction *Inst : *BB) {
if (Inst->getKind() == COMPILER::MInstruction::SWITCH) {
++Stats.Switches;
}
}
}
return Stats;
}

const EVMAnalyzer::BlockInfo *findBlock(const EVMAnalyzer &Analyzer,
uint64_t EntryPC) {
const auto &Blocks = Analyzer.getBlockInfos();
Expand All @@ -93,6 +130,21 @@ const EVMAnalyzer::BlockInfo *findBlock(const EVMAnalyzer &Analyzer,
return &It->second;
}

TEST(EVMMirBuilderControlFlowTest, FullDynamicDispatchIsSharedAcrossSources) {
constexpr uint32_t DynamicSources = 16;
std::vector<uint8_t> Bytecode;
for (uint32_t Index = 0; Index < DynamicSources; ++Index) {
Bytecode.push_back(OP_JUMPDEST);
Bytecode.push_back(OP_PUSH0);
Bytecode.push_back(OP_CALLDATALOAD);
Bytecode.push_back(OP_JUMP);
}

const MirControlFlowStats Stats = compileMirControlFlowStats(Bytecode);
ASSERT_TRUE(Stats.Compiled);
EXPECT_LT(Stats.Switches, DynamicSources);
}

class MirBuilderConstFoldHarness {
public:
MirBuilderConstFoldHarness() : Func(Ctx, 0), Builder(Ctx, Func) {
Expand Down
Loading