Workspace IndexAlgorithms › Day 27

Inside the EVM Interpreter TODO

Algorithms · Day 27 / 100 · B. Compilers, Runtimes & VMs (Day 20-35)

Concept

The EVM interpreter is a loop that reads bytecode one opcode at a time via a program counter and executes it as a stack machine. JUMP and JUMPI destinations must be a JUMPDEST opcode, and a byte position that happens to match that value inside a PUSH instruction's immediate data is not a valid destination. So implementations do a single linear scan of the code, skipping over each PUSH's data length, to build a valid-JUMPDEST bitmap up front, letting every jump be checked in O(1). Memory starts at zero and expands only in 32-byte words, and the expansion cost is the sum of a linear term and a quadratic term in the word count, so costs rise steeply the more you use. Cost is charged cumulatively based on "the highest offset reached so far," so re-writing an already-expanded region incurs no further expansion cost.

The quadratic term in the memory cost makes large calldata copies or big array processing much more expensive than expected, and not knowing the JUMPDEST rule leads to wrong assumptions in assembly or code-inspection logic.

Code & Formula

# EVM 인터프리터 내부 — PUSH 데이터를 건너뛰며 JUMPDEST 비트맵을 만들고, 메모리 확장 비용의 2차 항을 계산한다.

PUSH1, JUMPDEST, JUMP = 0x60, 0x5B, 0x56

# 단순화된 바이트코드: PUSH1 0x5B(=JUMPDEST와 같은 바이트값이지만 데이터!), PUSH1 5, JUMP, JUMPDEST, STOP
bytecode = [PUSH1, 0x5B, PUSH1, 0x05, JUMP, JUMPDEST, 0x00]

def build_jumpdest_bitmap(code):
    valid = [False] * len(code)
    pc = 0
    while pc < len(code):
        op = code[pc]
        if op == JUMPDEST:
            valid[pc] = True
            pc += 1
        elif PUSH1 <= op <= PUSH1 + 31:          # PUSHn: 즉시 데이터 n바이트를 건너뜀
            n = op - PUSH1 + 1
            pc += 1 + n                            # 데이터 안의 0x5B는 목적지로 안 침
        else:
            pc += 1
    return valid

valid = build_jumpdest_bitmap(bytecode)
print("바이트코드:", [hex(b) for b in bytecode])
print("유효 JUMPDEST 위치:", [i for i, v in enumerate(valid) if v])
print("pc=1(0x5B, PUSH1의 데이터 바이트)이 무효인 이유: PUSH1 뒤 1바이트라 건너뜀 ->", not valid[1])

def memory_expansion_cost(words):
    # 옐로페이퍼 근사: 3*words + words^2 / 512  (선형 항 + 2차 항)
    return 3 * words + (words * words) // 512

prev_words = 0
for target_words in (1, 10, 100, 1000, 10000):
    total_now = memory_expansion_cost(target_words)
    marginal = total_now - memory_expansion_cost(prev_words)
    print(f"words={target_words:>6}: 누적비용={total_now:>10}, 이전 대비 한계비용={marginal:>10}")
    prev_words = target_words

Exercise

Compile a simple Solidity function, manually trace the bytecode to build the JUMPDEST bitmap, and measure the actual gas-usage difference between two versions that use different memory-expansion sizes to confirm the quadratic term.

Practical Connection

In Verex's settlement and order-processing contracts, just changing the memory-usage pattern can shift gas significantly, so this cost curve needs to be understood when designing an on-chain cost ceiling.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

EVM 인터프리터 내부 TODO

Algorithms · Day 27 / 100 · B. 컴파일러·런타임·VM (Day 20–35)

JUMPDEST 분석·메모리 확장 비용

개념

EVM 인터프리터는 바이트코드를 프로그램 카운터로 한 옵코드씩 읽어 스택 머신으로 실행하는 루프다. JUMP와 JUMPI의 목적지는 반드시 JUMPDEST 옵코드여야 하고, PUSH 명령의 즉시 데이터(immediate data) 안에 우연히 같은 바이트값이 들어 있는 위치는 유효한 목적지가 아니다. 그래서 구현체는 코드를 한 번 선형 스캔하며 PUSH의 데이터 길이만큼 건너뛰는 방식으로 유효 JUMPDEST 비트맵을 만들어 두고, 점프마다 O(1)로 검사한다. 메모리는 0에서 시작해 32바이트 워드 단위로만 확장되며, 확장 비용은 워드 수에 대해 선형 항과 2차 항의 합이라 크게 쓸수록 한계비용이 가파르게 오른다. 비용은 '지금까지 도달한 최대 오프셋' 기준으로 누적 계산되므로, 이미 확장된 영역을 다시 쓰는 것은 추가 확장 비용이 들지 않는다.

메모리 비용의 2차 항 때문에 큰 calldata 복사나 대형 배열 처리가 예상보다 훨씬 비싸지고, JUMPDEST 규칙을 모르면 어셈블리나 코드 검사 로직에서 잘못된 가정을 하게 된다.

코드 · 수식

# EVM 인터프리터 내부 — PUSH 데이터를 건너뛰며 JUMPDEST 비트맵을 만들고, 메모리 확장 비용의 2차 항을 계산한다.

PUSH1, JUMPDEST, JUMP = 0x60, 0x5B, 0x56

# 단순화된 바이트코드: PUSH1 0x5B(=JUMPDEST와 같은 바이트값이지만 데이터!), PUSH1 5, JUMP, JUMPDEST, STOP
bytecode = [PUSH1, 0x5B, PUSH1, 0x05, JUMP, JUMPDEST, 0x00]

def build_jumpdest_bitmap(code):
    valid = [False] * len(code)
    pc = 0
    while pc < len(code):
        op = code[pc]
        if op == JUMPDEST:
            valid[pc] = True
            pc += 1
        elif PUSH1 <= op <= PUSH1 + 31:          # PUSHn: 즉시 데이터 n바이트를 건너뜀
            n = op - PUSH1 + 1
            pc += 1 + n                            # 데이터 안의 0x5B는 목적지로 안 침
        else:
            pc += 1
    return valid

valid = build_jumpdest_bitmap(bytecode)
print("바이트코드:", [hex(b) for b in bytecode])
print("유효 JUMPDEST 위치:", [i for i, v in enumerate(valid) if v])
print("pc=1(0x5B, PUSH1의 데이터 바이트)이 무효인 이유: PUSH1 뒤 1바이트라 건너뜀 ->", not valid[1])

def memory_expansion_cost(words):
    # 옐로페이퍼 근사: 3*words + words^2 / 512  (선형 항 + 2차 항)
    return 3 * words + (words * words) // 512

prev_words = 0
for target_words in (1, 10, 100, 1000, 10000):
    total_now = memory_expansion_cost(target_words)
    marginal = total_now - memory_expansion_cost(prev_words)
    print(f"words={target_words:>6}: 누적비용={total_now:>10}, 이전 대비 한계비용={marginal:>10}")
    prev_words = target_words

연습

간단한 Solidity 함수를 컴파일해 바이트코드를 손으로 훑으며 JUMPDEST 비트맵을 만들어 보고, 메모리 확장 크기를 바꾼 두 버전의 실제 가스 사용량 차이를 측정해 2차 항을 확인하라.

실무 · Verex 연결

Verex의 정산·주문 처리 컨트랙트에서 메모리 사용 패턴을 바꾸는 것만으로 가스가 크게 달라질 수 있고, 온체인 비용 상한을 설계할 때 이 곡선을 알고 있어야 한다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 26. 가스 회계 설계28. WASM 실행 모델과 샌드박싱 경계 →