fix(compiler): keep merged jumpdest while charging exact gas in multipass#345
fix(compiler): keep merged jumpdest while charging exact gas in multipass#345ZR74 wants to merge 3 commits intoDTVMStack:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts multipass EVM compilation so consecutive JUMPDEST opcodes can share a single canonical “body” basic block while still charging the correct gas for any skipped metering points.
Changes:
- Add
EVMMirBuilder::meterOpcodeRange()and use it to account for gas across a PC range. - Rework jump table construction to distinguish per-target entry blocks (
JumpDestTable) from canonical linear-decode execution blocks (JumpDestBodyTable), adding per-target thunks for mergedJUMPDESTruns when gas metering is enabled. - Update multipass bytecode visitor
OP_JUMPDESThandling to skip consecutiveJUMPDESTs and meter the skipped range before entering the shared destination.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/compiler/evm_frontend/evm_mir_compiler.h | Adds range-metering API and replaces consecutive-JUMPDEST tracking with a body-block map. |
| src/compiler/evm_frontend/evm_mir_compiler.cpp | Implements range metering, rebuilds jump table logic with entry thunks + body blocks, and updates handleJumpDest to use canonical bodies. |
| src/action/evm_bytecode_visitor.h | Updates multipass JUMPDEST decoding to merge runs and meter skipped points via the new range API. |
Comments suppressed due to low confidence (1)
src/action/evm_bytecode_visitor.h:592
- This changes control-flow + gas accounting for consecutive JUMPDESTs specifically in multipass mode. There’s no regression test exercising a dynamic jump into the middle of a consecutive-JUMPDEST run with gas metering enabled (and comparing gas left / status vs interpreter). Adding a focused test case would help prevent future gas-metering regressions in this area.
case OP_JUMPDEST: {
// Consecutive JUMPDEST opcodes share one body BB in multipass.
// Charge all skipped metering points before jumping to the shared
// destination at the end of the run.
uint64_t RunStartPC = PC;
while (Ip < IpEnd && static_cast<evmc_opcode>(*Ip) == OP_JUMPDEST) {
Ip++;
PC++;
}
if (PC > RunStartPC) {
Builder.meterOpcodeRange(RunStartPC, PC);
}
handleEndBlock();
Builder.handleJumpDest(PC);
Builder.meterOpcode(Opcode, PC);
handleBeginBlock(Analyzer);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| void EVMMirBuilder::meterOpcodeRange(uint64_t StartPC, | ||
| uint64_t EndPCExclusive) { | ||
| if (!Ctx.isGasMeteringEnabled() || StartPC >= EndPCExclusive) { | ||
| return; | ||
| } | ||
|
|
||
| const auto *EvmCtx = static_cast<const EVMFrontendContext *>(&Ctx); | ||
| const Byte *Bytecode = EvmCtx->getBytecode(); | ||
| if (!Bytecode) { | ||
| return; | ||
| } | ||
|
|
||
| const uint64_t CodeSize = static_cast<uint64_t>(EvmCtx->getBytecodeSize()); | ||
| if (StartPC >= CodeSize) { | ||
| return; | ||
| } | ||
| EndPCExclusive = std::min(EndPCExclusive, CodeSize); | ||
|
|
||
| uint64_t TotalCost = 0; | ||
| for (uint64_t PC = StartPC; PC < EndPCExclusive; ++PC) { | ||
| uint64_t Cost = 0; | ||
| if (GasChunkEnd && GasChunkCost && PC < GasChunkSize && | ||
| GasChunkEnd[PC] > PC) { | ||
| Cost = GasChunkCost[PC]; | ||
| } else { | ||
| const uint8_t Opcode = static_cast<uint8_t>(Bytecode[PC]); | ||
| Cost = static_cast<uint64_t>(InstructionMetrics[Opcode].gas_cost); | ||
| } |
There was a problem hiding this comment.
meterOpcodeRange iterates byte-by-byte and, when gas chunks are not present, treats every byte as an opcode. That will mis-meter ranges that include PUSH immediate data (and is also more expensive than necessary). Consider advancing PC according to opcode length (skip PUSHn immediates), and when gas chunks are present, skipping directly to GasChunkEnd[PC] after charging GasChunkCost[PC] so the range walk stays opcode-aware and efficient.
| for (size_t DestPC = RangeStart; DestPC < RangeEnd; ++DestPC) { | ||
| MBasicBlock *EntryBB = createBasicBlock(); | ||
| EntryBB->setJumpDestBB(true); | ||
| JumpDestTable[DestPC] = EntryBB; | ||
|
|
||
| setInsertBlock(EntryBB); | ||
| meterOpcodeRange(DestPC, RangeEnd); |
There was a problem hiding this comment.
createJumpTable() builds one entry thunk per merged JUMPDEST and each thunk calls meterOpcodeRange(DestPC, RangeEnd). For a run of length N this does O(N^2) work at compile time (summing overlapping suffix ranges repeatedly) and creates O(N) extra blocks, which can become a compile-time DoS vector with crafted bytecode (e.g., long 0x5b runs) when gas metering is enabled. Consider computing a single prefix/suffix cost table for the run once (or using the fact that the run is all JUMPDEST) and then emitting a constant meterGas(precomputed_cost[DestPC]) per thunk.
| for (size_t DestPC = RangeStart; DestPC < RangeEnd; ++DestPC) { | |
| MBasicBlock *EntryBB = createBasicBlock(); | |
| EntryBB->setJumpDestBB(true); | |
| JumpDestTable[DestPC] = EntryBB; | |
| setInsertBlock(EntryBB); | |
| meterOpcodeRange(DestPC, RangeEnd); | |
| // The run [RangeStart, RangeEnd] consists only of JUMPDEST opcodes, each | |
| // with a fixed per-opcode gas cost. Instead of repeatedly metering the | |
| // overlapping suffix [DestPC, RangeEnd] for every DestPC (O(N^2) work), | |
| // we directly charge a constant amount based on the number of skipped | |
| // JUMPDEST instructions. | |
| constexpr uint64_t JumpdestGasCost = 1; | |
| for (size_t DestPC = RangeStart; DestPC < RangeEnd; ++DestPC) { | |
| MBasicBlock *EntryBB = createBasicBlock(); | |
| EntryBB->setJumpDestBB(true); | |
| JumpDestTable[DestPC] = EntryBB; | |
| setInsertBlock(EntryBB); | |
| const uint64_t skippedJumpdests = | |
| static_cast<uint64_t>(RangeEnd - DestPC); | |
| const uint64_t skippedGas = skippedJumpdests * JumpdestGasCost; | |
| meterGas(skippedGas); |
| meterGas(static_cast<uint64_t>(Metrics.gas_cost)); | ||
| } | ||
|
|
||
| void EVMMirBuilder::meterOpcodeRange(uint64_t StartPC, |
There was a problem hiding this comment.
Is this meterOpcodeRange function change enough to fix the gas calculation for jumpdest? Do you have a bytecode to confirm this?
I heard that consecutive JUMPDEST opcodes are generally NOT common in well-optimized EVM bytecode.
Are all other changes in this PRs necessary?
There was a problem hiding this comment.
Previously, pr merged consecutive jumpdest values and assumed that all jumpdest values were equivalent to deducting 1. However, in reality, SPP charges for jumpdest based on a cost of 0, 1, or greater. Therefore, the corresponding GasChunkCost should be deducted.
There was a problem hiding this comment.
I understand, my question is that are other changes like the JumpDestBodyTable related to this fix?
There was a problem hiding this comment.
I understand, my question is that are other changes like the
JumpDestBodyTablerelated to this fix?
Yes, it separates "the shared main block BodyBB that the linear decode should enter when encountering JUMPDEST" from "the compensation entry thunk (JumpDestTable) that might need to be deducted before the jump/jumpi jump" to avoid linear execution entering the thunk by mistake, which could lead to disorder in instruction insertion or repeated deduction.
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