perf(evm): implement EVM lazy compile (segment-based compilation)#416
perf(evm): implement EVM lazy compile (segment-based compilation)#416ys8888john wants to merge 5 commits intoDTVMStack:mainfrom
Conversation
⚡ Performance Regression Check Results✅ Performance Check Passed (interpreter)Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions ✅ Performance Check Passed (multipass)Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
There was a problem hiding this comment.
Pull request overview
This PR introduces an EVM “lazy compile” mode (implemented as Multipass JIT + a lazy flag) and adds initial infrastructure for segment-based compilation (bytecode segmentation at JUMPDEST boundaries), along with basic unit tests.
Changes:
- Add
lazymode handling and a newDTVM_EVM_LAZY_COMPILEenv var /enable_lazy_compileEVMC option to toggle Multipass lazy compilation. - Implement
LazyEVMJITCompiler+EVMSegmentAnalyzerscaffolding and wire lazy compilation intoperformEVMJITCompile(). - Add a new
evmLazyCompileTeststest target with segment analyzer coverage.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/vm/dt_evmc_vm.cpp | Adds lazy mode parsing and env/option toggles for lazy compilation. |
| src/action/compiler.cpp | Switches Multipass+lazy EVM compilation path to use LazyEVMJITCompiler. |
| src/runtime/evm_module.h | Adds storage/accessors for a per-module LazyEVMJITCompiler. |
| src/runtime/evm_module.cpp | Implements EVMModule::newLazyEVMJITCompiler(). |
| src/compiler/evm_lazy_compiler.h | Declares EVMSegmentAnalyzer and LazyEVMJITCompiler APIs. |
| src/compiler/evm_lazy_compiler.cpp | Implements segment analysis and the initial “lazy” compiler behavior. |
| src/compiler/CMakeLists.txt | Adds evm_lazy_compiler.cpp to the compiler library sources. |
| src/tests/evm_lazy_compile_tests.cpp | Adds unit tests for the segment analyzer. |
| src/tests/CMakeLists.txt | Builds/runs the new evmLazyCompileTests when Multipass JIT is enabled. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/tests/evm_lazy_compile_tests.cpp
Outdated
| // Bytecode: JUMPDEST at PC=3 | ||
| std::vector<uint8_t> Bytecode = { | ||
| 0x60, 0x01, // PUSH1 0x01 (PC 0-1) | ||
| 0x60, 0x02, // PUSH1 0x02 (PC 2-3) | ||
| 0x5B, // JUMPDEST (PC 4) | ||
| 0x00 // STOP (PC 5) |
src/compiler/evm_lazy_compiler.cpp
Outdated
| if (SegmentIdx < SegmentAnalyzer.getNumSegments() && | ||
| CompileStatuses[SegmentIdx] == CompileStatus::Done) { | ||
| return SegmentCodePtrs[SegmentIdx]; | ||
| } | ||
|
|
||
| // Fallback: compile the full bytecode if not yet done | ||
| std::lock_guard<std::mutex> Lock(ForegroundMutex); | ||
|
|
||
| auto Timer = | ||
| Stats.startRecord(zen::utils::StatisticPhase::JITLazyFgCompilation); | ||
|
|
||
| // Re-check after acquiring lock | ||
| if (CompileStatuses[SegmentIdx] == CompileStatus::Done) { | ||
| Stats.stopRecord(Timer); | ||
| return SegmentCodePtrs[SegmentIdx]; |
src/compiler/evm_lazy_compiler.h
Outdated
| /// Instead of compiling the entire EVM bytecode at once, this compiler: | ||
| /// 1. Analyzes the bytecode to identify segments (JUMPDEST boundaries) | ||
| /// 2. Compiles only the entry segment initially | ||
| /// 3. Creates stubs for other segments that trigger on-demand compilation | ||
| /// 4. Optionally dispatches background compilation tasks for remaining segments | ||
| /// | ||
| /// This is analogous to the WASM LazyJITCompiler but adapted for EVM's | ||
| /// single-function, segment-based structure. | ||
| class LazyEVMJITCompiler final : public EVMJITCompiler { | ||
| public: | ||
| LazyEVMJITCompiler(runtime::EVMModule *EVMMod); | ||
| ~LazyEVMJITCompiler() override; | ||
|
|
||
| /// Perform initial precompilation: | ||
| /// - Analyze bytecode into segments | ||
| /// - Compile the entry segment eagerly | ||
| /// - Create stubs for remaining segments | ||
| /// - Optionally dispatch background compilation tasks |
There was a problem hiding this comment.
Pull request overview
This PR introduces an initial implementation of an EVM “lazy compile” mode intended to support segment-based (JUMPDEST-delimited) compilation in the multipass JIT pipeline.
Changes:
- Add
lazymode /EnableMultipassLazytoggles for the EVMC VM (options + env var). - Introduce
EVMSegmentAnalyzerandLazyEVMJITCompiler, and wire it into EVM JIT compilation selection. - Add a new
evmLazyCompileTeststarget with unit tests for the segment analyzer.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/vm/dt_evmc_vm.cpp | Adds EVMC options/env var to enable lazy compilation mode. |
| src/action/compiler.cpp | Switches EVM multipass compilation to use LazyEVMJITCompiler when enabled. |
| src/runtime/evm_module.h | Stores/exports a lazy EVM JIT compiler instance on EVMModule. |
| src/runtime/evm_module.cpp | Implements EVMModule::newLazyEVMJITCompiler(). |
| src/compiler/evm_lazy_compiler.h | Declares segment analyzer + lazy EVM JIT compiler APIs. |
| src/compiler/evm_lazy_compiler.cpp | Implements segment analysis and the lazy compiler (currently eager-full compile). |
| src/compiler/CMakeLists.txt | Adds evm_lazy_compiler.cpp to compiler sources when EVM is enabled. |
| src/tests/evm_lazy_compile_tests.cpp | Adds segment analyzer unit tests. |
| src/tests/CMakeLists.txt | Builds/runs evmLazyCompileTests when ZEN_ENABLE_MULTIPASS_JIT is enabled. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #ifdef ZEN_ENABLE_JIT | ||
| common::CodeMemPool JITCodeMemPool; | ||
| void *JITCode = nullptr; | ||
| size_t JITCodeSize = 0; | ||
| std::unique_ptr<COMPILER::LazyEVMJITCompiler> LazyJITCompiler; | ||
| #endif // ZEN_ENABLE_JIT |
There was a problem hiding this comment.
LazyJITCompiler is declared as std::unique_ptr<COMPILER::LazyEVMJITCompiler> under ZEN_ENABLE_JIT, but ZEN_ENABLE_JIT is defined for both singlepass and multipass builds (see src/CMakeLists.txt). In a singlepass-only build, LazyEVMJITCompiler is only forward-declared here, so EVMModule’s destructor will need a complete type and this will not compile. Guard the lazy-compiler member (and related APIs) with ZEN_ENABLE_MULTIPASS_JIT (or provide a custom deleter / include the full definition whenever ZEN_ENABLE_JIT is set).
| /// Lazy JIT compiler for EVM bytecode. | ||
| /// | ||
| /// Instead of compiling the entire EVM bytecode at once, this compiler: | ||
| /// 1. Analyzes the bytecode to identify segments (JUMPDEST boundaries) | ||
| /// 2. Compiles only the entry segment initially | ||
| /// 3. Creates stubs for other segments that trigger on-demand compilation | ||
| /// 4. Optionally dispatches background compilation tasks for remaining segments | ||
| /// | ||
| /// This is analogous to the WASM LazyJITCompiler but adapted for EVM's | ||
| /// single-function, segment-based structure. | ||
| class LazyEVMJITCompiler final : public EVMJITCompiler { |
There was a problem hiding this comment.
The header comment describes stub-based, segment-level lazy compilation (entry-only compile + on-demand compilation for other segments), but precompile() currently compiles the full bytecode eagerly and marks all segments Done. Either update the documentation to match the current behavior or implement the described stub/on-demand compilation so callers aren’t misled about what “lazy compile” does.
| EVMSegmentAnalyzer Analyzer; | ||
| Analyzer.analyze(reinterpret_cast<const std::byte *>(Bytecode.data()), | ||
| Bytecode.size(), EVMC_OSAKA); | ||
|
|
There was a problem hiding this comment.
These tests pass reinterpret_cast<const std::byte*> into APIs expecting zen::common::Byte*. This will break when ZEN_DISABLE_CXX17_STL is enabled (where zen::common::Byte is libcxx::byte, not std::byte). Prefer using reinterpret_cast<const zen::common::Byte*> (or a helper) so the tests compile in both configurations.
src/compiler/evm_lazy_compiler.cpp
Outdated
| EVMMod->setJITCodeAndSize(CodePtr, MainContext->CodeSize); | ||
|
|
||
| size_t CodeSize = CodeMPool.getMemEnd() - JITCode; | ||
| zen::platform::mprotect(JITCode, TO_MPROTECT_CODE_SIZE_LAZY(CodeSize), | ||
| PROT_READ | PROT_EXEC); |
There was a problem hiding this comment.
In the fallback path of compileSegmentOnRequest(), after mprotect() you never call EVMMod->setJITCodeAndSize(JITCode, CodeSize) (unlike precompile() and EagerEVMJITCompiler). This leaves JITCode/JITCodeSize potentially inconsistent (e.g., trap handler code-range checks) depending on what CodePtr/MainContext->CodeSize are. Consider mirroring the precompile()/eager sequence: set entry pointer/size if needed, then set module JIT code to the pool start + protected size.
| EVMMod->setJITCodeAndSize(CodePtr, MainContext->CodeSize); | |
| size_t CodeSize = CodeMPool.getMemEnd() - JITCode; | |
| zen::platform::mprotect(JITCode, TO_MPROTECT_CODE_SIZE_LAZY(CodeSize), | |
| PROT_READ | PROT_EXEC); | |
| size_t CodeSize = CodeMPool.getMemEnd() - JITCode; | |
| zen::platform::mprotect(JITCode, TO_MPROTECT_CODE_SIZE_LAZY(CodeSize), | |
| PROT_READ | PROT_EXEC); | |
| EVMMod->setJITCodeAndSize(JITCode, CodeSize); |
| COMPILER::LazyEVMJITCompiler *newLazyEVMJITCompiler(); | ||
|
|
||
| COMPILER::LazyEVMJITCompiler *getLazyEVMJITCompiler() const { | ||
| return LazyJITCompiler.get(); | ||
| } | ||
|
|
||
| bool hasLazyJITCompiler() const { return LazyJITCompiler != nullptr; } |
There was a problem hiding this comment.
newLazyEVMJITCompiler() is declared under ZEN_ENABLE_JIT but defined in evm_module.cpp only under ZEN_ENABLE_MULTIPASS_JIT. To avoid mismatched feature-guards (and the incomplete-type issue from the lazy compiler member), put this API under the same ZEN_ENABLE_MULTIPASS_JIT guard as its implementation (or provide a definition for all ZEN_ENABLE_JIT builds).
| } else if (std::strcmp(Value, "multipass") == 0) { | ||
| VM->Config.Mode = RunMode::MultipassMode; | ||
| return EVMC_SET_OPTION_SUCCESS; | ||
| } else if (std::strcmp(Value, "lazy") == 0) { | ||
| VM->Config.Mode = RunMode::MultipassMode; | ||
| VM->Config.EnableMultipassLazy = true; | ||
| return EVMC_SET_OPTION_SUCCESS; |
There was a problem hiding this comment.
Selecting mode=multipass does not clear EnableMultipassLazy. If the VM was previously configured with mode=lazy (or enable_lazy_compile=true), switching back to multipass will still run in lazy mode. Consider explicitly setting Config.EnableMultipassLazy = false in the multipass (and interpreter) branches so the mode option is self-contained and deterministic.
| uint8_t *LazyEVMJITCompiler::compileSegmentOnRequest(uint32_t SegmentIdx) { | ||
| // In the current implementation, all segments are compiled during precompile. | ||
| // This method is a placeholder for future segment-level lazy compilation. | ||
| if (SegmentIdx < SegmentAnalyzer.getNumSegments() && | ||
| CompileStatuses[SegmentIdx] == CompileStatus::Done) { | ||
| return SegmentCodePtrs[SegmentIdx]; | ||
| } | ||
|
|
||
| // Fallback: compile the full bytecode if not yet done | ||
| std::lock_guard<std::mutex> Lock(ForegroundMutex); | ||
|
|
||
| auto Timer = | ||
| Stats.startRecord(zen::utils::StatisticPhase::JITLazyFgCompilation); | ||
|
|
||
| // Re-check after acquiring lock | ||
| if (CompileStatuses[SegmentIdx] == CompileStatus::Done) { | ||
| Stats.stopRecord(Timer); | ||
| return SegmentCodePtrs[SegmentIdx]; | ||
| } |
There was a problem hiding this comment.
compileSegmentOnRequest() can read out of bounds / dereference null: after the initial bounds check, the code unconditionally evaluates CompileStatuses[SegmentIdx] again (and later writes it) even when SegmentIdx >= getNumSegments() or when precompile() was never called (leaving CompileStatuses null). Add an early guard that validates SegmentIdx < getNumSegments() and that per-segment state has been initialized (or make this function unreachable before precompile()).
| // Get instruction metrics for opcode length calculation | ||
| const auto *Metrics = evmc_get_instruction_metrics_table(Rev); | ||
| if (!Metrics) { | ||
| Metrics = evmc_get_instruction_metrics_table(zen::evm::DEFAULT_REVISION); | ||
| } | ||
|
|
There was a problem hiding this comment.
Metrics is computed but never used. If opcode-length calculation only needs PUSH handling, remove the unused metrics lookup; otherwise, use Metrics to advance PC safely/consistently for non-PUSH opcodes.
| // Get instruction metrics for opcode length calculation | |
| const auto *Metrics = evmc_get_instruction_metrics_table(Rev); | |
| if (!Metrics) { | |
| Metrics = evmc_get_instruction_metrics_table(zen::evm::DEFAULT_REVISION); | |
| } |
src/tests/evm_lazy_compile_tests.cpp
Outdated
| // Bytecode: JUMPDEST at PC=3 | ||
| std::vector<uint8_t> Bytecode = { | ||
| 0x60, 0x01, // PUSH1 0x01 (PC 0-1) | ||
| 0x60, 0x02, // PUSH1 0x02 (PC 2-3) | ||
| 0x5B, // JUMPDEST (PC 4) | ||
| 0x00 // STOP (PC 5) | ||
| }; |
There was a problem hiding this comment.
The comment says “JUMPDEST at PC=3” but with two PUSH1 instructions (2 bytes each) the JUMPDEST is actually at PC=4. Please fix the comment to match the bytecode layout to avoid confusion when maintaining these tests.
2c6c17f to
ece08ab
Compare
ece08ab to
1512729
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| EVMMod->setJITCodeAndSize(EntryCodePtr, 0); | ||
|
|
There was a problem hiding this comment.
precompile() sets EVMModule JIT code size to 0. The runtime/trap handlers use getJITCodeSize() to compute the JIT address range (e.g. for backtraces), so a zero size breaks that logic even though the entry stub is callable. Consider setting JITCode to the code pool start (or entry stub, if it is the pool start) and JITCodeSize to CodeMemPool.getMemEnd() - getMemStart() after stub/resolver allocation, and updating it again after the first compilation when the pool grows.
| EVMMod->setJITCodeAndSize(EntryCodePtr, 0); | |
| // Initialize JIT code size based on the code memory pool so that | |
| // runtime/trap handlers see a valid JIT address range (for backtraces, etc.) | |
| size_t JITSize = 0; | |
| if (MainContext && MainContext->CodeMPool) { | |
| void *MemStart = MainContext->CodeMPool->getMemStart(); | |
| void *MemEnd = MainContext->CodeMPool->getMemEnd(); | |
| if (MemStart && MemEnd && MemEnd > MemStart) { | |
| auto *Start = static_cast<uint8_t *>(MemStart); | |
| auto *End = static_cast<uint8_t *>(MemEnd); | |
| JITSize = static_cast<size_t>(End - Start); | |
| } | |
| } | |
| EVMMod->setJITCodeAndSize(EntryCodePtr, JITSize); |
| uint8_t *LazyEVMJITCompiler::compileSegmentOnRequest(uint32_t SegmentIdx) { | ||
| uint32_t NumSegments = SegmentAnalyzer.getNumSegments(); | ||
|
|
||
| // Fast path: already compiled | ||
| if (SegmentIdx < NumSegments && | ||
| CompileStatuses[SegmentIdx] == CompileStatus::Done) { | ||
| return SegmentCodePtrs[SegmentIdx]; | ||
| } | ||
|
|
||
| // Slow path: compile on demand with thread safety | ||
| std::lock_guard<std::mutex> Lock(ForegroundMutex); | ||
|
|
||
| auto Timer = | ||
| Stats.startRecord(zen::utils::StatisticPhase::JITLazyFgCompilation); | ||
|
|
||
| // Double-check after acquiring lock | ||
| if (CompileStatuses[SegmentIdx] == CompileStatus::Done) { | ||
| Stats.stopRecord(Timer); | ||
| return SegmentCodePtrs[SegmentIdx]; |
There was a problem hiding this comment.
compileSegmentOnRequest() uses CompileStatuses[SegmentIdx] without validating SegmentIdx < NumSegments after taking the lock. If an invalid segment index is ever passed (e.g. corrupted stub address), this is out-of-bounds UB. Add a bounds check early in the slow path and return/abort with an error if SegmentIdx is invalid.
| EnableLazy != nullptr) { | ||
| bool ParsedEnableLazy = false; | ||
| if (parseBoolEnvValue(EnableLazy, ParsedEnableLazy)) { | ||
| Config.EnableMultipassLazy = ParsedEnableLazy; |
There was a problem hiding this comment.
DTVM_EVM_LAZY_COMPILE can enable EnableMultipassLazy without switching Config.Mode to MultipassMode, even though the option comment says lazy compile requires multipass. To avoid confusing/partial configurations, consider forcing Config.Mode = RunMode::MultipassMode when the env var parses to true (mirroring set_option(enable_lazy_compile)).
| Config.EnableMultipassLazy = ParsedEnableLazy; | |
| Config.EnableMultipassLazy = ParsedEnableLazy; | |
| if (ParsedEnableLazy) { | |
| Config.Mode = RunMode::MultipassMode; | |
| } |
| static uint64_t | ||
| compileSegmentOnRequestTrampoline([[maybe_unused]] zen::runtime::Instance *Inst, | ||
| uint8_t *NextStubCodePtr) { | ||
| // Validate instance pointer | ||
| if (!Inst) { | ||
| ZEN_LOG_ERROR("EVM lazy compile: Instance pointer is null in trampoline"); | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
In the trampoline error paths you return 0, but the stub resolver uses the return value as the re-entry address (it overwrites the saved RIP). Returning 0 will jump to address 0 and crash. If these conditions are truly impossible, prefer ZEN_ASSERT/ZEN_ABORT; otherwise return a valid failure stub/throw an exception so control flow can’t continue with a null target.
| uint32_t SegmentStartPC = 0; | ||
| uint32_t SegmentEndPC = static_cast<uint32_t>(BytecodeSize); | ||
| const EVMFrontendContext *EvmCtx = | ||
| dynamic_cast<const EVMFrontendContext *>(Ctx); | ||
| if (EvmCtx) { | ||
| SegmentStartPC = EvmCtx->getSegmentStartPC(); | ||
| SegmentEndPC = EvmCtx->getSegmentEndPC(); | ||
| if (SegmentEndPC == UINT32_MAX || | ||
| SegmentEndPC > static_cast<uint32_t>(BytecodeSize)) { | ||
| SegmentEndPC = static_cast<uint32_t>(BytecodeSize); | ||
| } |
There was a problem hiding this comment.
dynamic_cast requires RTTI, but the project is built with -fno-rtti (see top-level CMakeLists). This will fail to compile. Since Ctx in the EVM JIT pipeline is an EVMFrontendContext, use a static_cast (or add an explicit kind/tag on CompileContext) instead of dynamic_cast.
| uint32_t SegmentStartPC = 0; | |
| uint32_t SegmentEndPC = static_cast<uint32_t>(BytecodeSize); | |
| const EVMFrontendContext *EvmCtx = | |
| dynamic_cast<const EVMFrontendContext *>(Ctx); | |
| if (EvmCtx) { | |
| SegmentStartPC = EvmCtx->getSegmentStartPC(); | |
| SegmentEndPC = EvmCtx->getSegmentEndPC(); | |
| if (SegmentEndPC == UINT32_MAX || | |
| SegmentEndPC > static_cast<uint32_t>(BytecodeSize)) { | |
| SegmentEndPC = static_cast<uint32_t>(BytecodeSize); | |
| } | |
| const EVMFrontendContext *EvmCtx = | |
| static_cast<const EVMFrontendContext *>(Ctx); | |
| uint32_t SegmentStartPC = EvmCtx->getSegmentStartPC(); | |
| uint32_t SegmentEndPC = EvmCtx->getSegmentEndPC(); | |
| if (SegmentEndPC == UINT32_MAX || | |
| SegmentEndPC > static_cast<uint32_t>(BytecodeSize)) { | |
| SegmentEndPC = static_cast<uint32_t>(BytecodeSize); |
1. Does this PR affect any open issues?(Y/N) and add issue references (e.g. "fix #123", "re #123".):
2. What is the scope of this PR (e.g. component or file name):
3. Provide a description of the PR(e.g. more details, effects, motivations or doc link):
4. Are there any breaking changes?(Y/N) and describe the breaking changes(e.g. more details, motivations or doc link):
5. Are there test cases for these changes?(Y/N) select and add more details, references or doc links:
6. Release note