Skip to content

5.3 인터프리터와 JIT — 실행 비용을 언제 지불할 것인가

트리 워커, 바이트코드 VM, JIT는 같은 프로그램을 실행하지만 비용을 지불하는 시점이 다르다 — 준비에 쓸 것인가, 매 실행에서 낼 것인가, 실행 중에 관찰해 놓고 베팅할 것인가. 이 문서는 Toy 언어의 두 실행기를 실측으로 비교해 "준비 비용과 회수 시점"의 구조를 세우고, V8의 계층화·추측 최적화·역최적화를 실제 로그로 관찰해 워크로드의 수명과 입력의 안정성이 실행 전략의 성패를 결정하는 방식을 확인한다.

측정 환경: Node.js v24.14.0(V8 13.6), Apple M5 Pro, macOS 26.5. 측정 규칙(워밍업, 반복, 중앙값, 환경 명시)은 1.1의 벤치마크 방법론을 그대로 따른다.

학습 목표

  • 트리 워커와 바이트코드 VM의 실행 경로를 비교하고, 각 전략이 준비 시간·실행 시간·메모리 중 어디에 비용을 지불하는지 설명한다.
  • 준비(파싱·컴파일) 비용과 반복 실행 이득을 분리 측정해, 한 전략이 다른 전략을 역전하는 교차점을 계산한다.
  • JIT의 계층화(tiering)·추측 최적화·inline cache·역최적화가 각각 어떤 문제를 풀고 어떤 실패 모드를 갖는지 설명한다.
  • 최적화·역최적화 로그를 읽고, 값 형태의 불안정이 성능 변동으로 이어지는 경로를 진단한다.
  • 워크로드의 수명(단발 CLI·서버리스·장수명 서버)에 따라 유리한 실행 전략을 판단한다.

배경: 왜 이것이 존재하는가

5.0에서 컴파일과 인터프리트가 언어의 속성이 아니라 조합 가능한 전략임을 세웠다. 왜 조합이 필요한가? 번역이라는 투자의 회수 구조 때문이다.

번역을 많이 할수록 실행은 빨라진다 — 파싱과 의미 확인을 미리 끝내고, 값을 확인하는 검사를 미리 제거하고, 대상 CPU에 맞는 기계어까지 만들면 실행 시점에 할 일이 최소가 된다. 그러나 번역 자체가 시간과 메모리를 쓰고, 그 비용은 실행 횟수로 나눠서만 회수된다. 한 번 실행하고 버릴 코드에 비싼 최적화를 하는 것은 순손실이다. 반대로 수백만 번 실행될 핫 루프를 매번 번역 없이 해석하는 것도 순손실이다. 어느 쪽 손실이 큰지는 코드가 아니라 워크로드가 결정하므로, 하나의 고정 전략은 항상 어딘가에서 진다. 실행 엔진들이 전략의 스펙트럼 위에 있고, 현대 런타임이 그 스펙트럼을 실행 중에 옮겨 다니는(tiering) 이유다.

핵심 개념

트리 워커 — AST를 그대로 실행한다

트리 워커(tree-walking interpreter)는 파서가 만든 AST를 노드 타입별로 재귀 순회하며 즉시 평가한다. 준비 비용이 파싱뿐이라 시작이 가장 빠르고, 구현이 언어 의미의 직역이라 가장 단순하다 — 기준 구현(reference implementation)으로 자주 쓰이는 이유다.

ts
// 트리 워커의 핵심 구조 (발췌 — 전체 언어 처리와 오류 처리는 생략)
function evalExpr(node: Expr, env: Env): Value {
  switch (node.kind) {
    case 'Num':    return node.value;
    case 'Var':    return lookup(env, node.name);       // 환경 체인 탐색
    case 'Binary': {
      const l = evalExpr(node.left, env);
      const r = evalExpr(node.right, env);              // 자식 노드로 재귀
      switch (node.op) { case '+': return l + r; /* … */ }
    }
    case 'Call':   /* 인자 평가 → 새 환경 → 본문 블록 실행 */
  }
}

실행 비용의 구조를 보라. 노드 하나를 처리할 때마다 (1) 노드 종류로 분기하고(디스패치), (2) 자식 노드 포인터를 따라가고(힙에 흩어진 객체들 — 7.2의 포인터 추적 비용 그대로다), (3) 변수는 환경 체인을 탐색한다. 2 * 3을 백만 번 평가하면 "이 노드는 Binary이고 op는 *"라는 변하지 않는 사실을 백만 번 다시 발견한다. 이 중복 발견이 트리 워커가 반복 실행에서 지불하는 비용이다.

바이트코드 VM — 발견을 한 번만 하고 명령으로 굳힌다

바이트코드 컴파일러는 AST를 한 번 순회하면서 그 발견을 선형 명령열로 굳힌다. 이후 실행은 트리가 아니라 평평한 배열 위를 달린다. Toy의 fib를 컴파일한 실제 출력이다.

text
fn fib(n) { if (n < 2) { return n; } return fib(n - 1) + fib(n - 2); }

0000 LOAD_LOCAL 0        ; n            0010 LOAD_LOCAL 0
0002 CONST 0             ; 2            0012 CONST 1     ; 1
0004 LT                                 0014 SUB
0005 JUMP_IF_FALSE 10    ; else로       0015 CALL 0 argc=1
0007 LOAD_LOCAL 0                       0018 LOAD_LOCAL 0
0009 RETURN                             0020 CONST 0     ; 2
                                        0022 SUB
     상수 풀: [2, 1]                     0023 CALL 0 argc=1
     지역 슬롯: 1개 (n)                  0026 ADD
                                        0027 RETURN

읽는 법: n < 2라는 트리가 LOAD_LOCAL 0, CONST 0, LT라는 세 명령이 됐다. 피연산자는 operand stack에 쌓이고 연산은 스택 위 두 값을 소비한다(LT). 변수는 이름 탐색 대신 슬롯 번호가 됐고(컴파일 시점에 해소), 분기는 명령 주소로의 점프가 됐다(JUMP_IF_FALSE 10 — 컴파일러가 then 블록 길이를 안 뒤에 채워 넣는 forward-jump patching). 함수 호출은 call frame(복귀 주소와 슬롯 기준점)을 쌓는다. 실행기는 이제 단일 루프다.

ts
// VM 디스패치 루프의 핵심 구조 (발췌)
while (true) {
  switch (code[ip++]) {                       // opcode 디스패치
    case OP.CONST:      stack.push(consts[code[ip++]]); break;
    case OP.LOAD_LOCAL: stack.push(stack[base + code[ip++]]); break;
    case OP.LT:  { const r = stack.pop(), l = stack.pop(); stack.push(l < r); break; }
    case OP.JUMP_IF_FALSE: { const t = code[ip++]; if (!stack.pop()) ip = t; break; }
    /* … CALL은 frame을 쌓고, RETURN은 frame을 정리하고 값을 남긴다 */
  }
}

트리 워커와 비교하면: 디스패치는 남아 있다(opcode마다 분기). 사라진 것은 트리 포인터 추적, 이름 탐색, 그리고 "이 노드가 무엇인지"의 반복 발견이다. 지불한 것은 컴파일 시간과, 바이트코드·상수 풀이라는 추가 메모리다.

스택 기반 대신 레지스터 기반 VM(Lua가 대표)도 있다 — 명령이 스택 대신 가상 레지스터 번호를 피연산자로 직접 지정한다. 명령 수는 줄지만(push/pop 없음) 명령 하나가 커지고 컴파일러가 복잡해지는 교환이다. 이 챕터의 실습은 스택 VM으로 고정한다.

관찰 1 — 두 실행기의 비용 실측

같은 Toy 프로그램을 두 실행기로 돌린 실측이다(위 환경, 여러 번 반복의 중앙값). 전체 구현과 벤치마크는 챕터 실습(exercises/ch-5/)의 산출물과 같은 구조이고, 두 실행기가 같은 값을 내는지 먼저 검증한 뒤 측정했다.

측정트리 워커컴파일+VMVM만 (컴파일 재사용)
fib(24) 한 번 (재귀 15만 호출)6.40 ms6.85 ms
산술 연산 6개짜리 함수 10만 회 호출10.52 ms28.77 ms6.02 ms
위를 호출 1회당으로 환산105 ns288 ns60 ns

세 가지를 읽을 수 있다.

  1. 준비 비용은 반복으로만 회수된다. 작은 함수를 매번 새로 컴파일하면(288ns) 트리 워커(105ns)에게 진다. 컴파일 결과를 재사용하면(60ns) 이긴다. 이 수치에서 컴파일 비용은 약 228ns, 실행당 이득은 약 45ns — 다섯 번쯤 실행해야 본전이다. 실제 엔진의 교차점은 코드 크기와 최적화 수준에 따라 수십~수천 회가 되지만, "몇 회 실행에서 회수되는가"라는 질문의 구조는 같다.
  2. "바이트코드가 항상 빠르다"는 틀렸다. fib에서는 VM(6.85ms)이 트리 워커(6.40ms)를 이기지 못했다. fib(20) 기준 두 실행기의 단계 수를 세면 방문 노드 21.9만 대 실행 명령 21.9만으로 거의 같다 — 차이는 단계 수가 아니라 단계당 비용에서 나는데, 이 구현은 둘 다 JavaScript 위에서 돌고 V8이 트리 워커의 재귀 순회도 최적화해 주므로 단계당 비용까지 비슷해졌다. C로 짠 VM이라면 평평한 배열 순회가 트리 포인터 추적보다 캐시 친화적이어서 구도가 달라진다(7.2의 메커니즘).
  3. 수치가 아니라 구조를 가져가라. 이 표의 절대값은 이 구현·이 호스트에 한정된다. 이식 가능한 결론은 비용의 위치다 — 트리 워커는 준비가 싸고 실행이 비싸며, 바이트코드는 준비를 지불해 실행을 줄이고, 회수 여부는 실행 횟수가 정한다.

JIT — 실행하면서 관찰하고, 관찰 위에 베팅한다

바이트코드 해석도 여전히 명령당 디스패치를 지불한다. 다음 단계는 뜨거운 코드를 실행 중에 기계어로 컴파일하는 JIT(just-in-time compilation)이다. 사전(AOT, ahead-of-time) 컴파일과의 차이는 시점이 아니라 정보다 — AOT는 모든 가능한 입력에 맞는 일반적 코드를 만들어야 하지만, JIT은 "지금까지 이 함수의 인자는 전부 작은 정수였다" 같은 실행 프로파일을 갖고 있다. 그 대가로 컴파일 시간이 사용자의 실행 시간에 청구되고, 시작 직후에는 아직 느리다(워밍업).

**계층화(tiering)**가 이 긴장의 해법이다. V8(Node.js 24의 13.6 기준)은 4계층이다: Ignition 인터프리터가 바이트코드를 즉시 실행하며 타입 피드백을 수집하고, 뜨거워진 함수를 Sparkplug(베이스라인 컴파일러), Maglev(중간 최적화), TurboFan(최상위 최적화)이 차례로 승격시킨다. 낮은 계층은 싸고 빠르게 나오고, 높은 계층은 비싸지만 좋은 코드를 만든다 — 어떤 함수가 어디까지 올라갈지를 실행 빈도(hotness)가 정하므로, 컴파일 예산이 뜨거운 곳에만 쓰인다. 오래 도는 루프는 함수 반환을 기다리지 않고 실행 중인 프레임을 최적화 코드로 갈아 끼우는 OSR(on-stack replacement)로 승격된다.

상위 계층의 속도는 추측 최적화(speculative optimization)에서 나온다. "인자는 항상 작은 정수였다"는 관찰을 "앞으로도 그렇다"는 가정으로 바꾸고, 그 가정 하에서만 성립하는 빠른 코드(타입 검사 없는 정수 덧셈, 고정 오프셋 필드 접근)를 만들되, 가정을 검증하는 가드(guard)를 심는다. 프로퍼티 접근·호출처럼 반복되는 지점의 탐색 결과는 inline cache(IC)가 호출 지점별로 캐시한다 — 한 지점에서 관찰된 객체 형태(shape)가 하나면 monomorphic(가장 빠른 코드), 몇 개면 polymorphic, 많아지면 megamorphic으로 전이하며 캐시 효과와 최적화 가능성이 단계적으로 떨어진다. 같은 코드라도 호출 지점에 흘러드는 값의 형태 다양성이 성능을 바꾸는 것이다.

가드가 깨지면? 최적화 코드는 그 지점에서 실행을 중단하고 바이트코드 해석으로 탈출해야 한다 — 이것이 역최적화(deoptimization)다. 탈출이 가능하려면 최적화 코드가 임의 지점에서 "바이트코드 세계의 상태(가상 레지스터, 스택, 실행 위치)"를 복원할 수 있는 사상 정보를 유지해야 한다. 최적화가 코드만 빠르게 만드는 것이 아니라 되돌아갈 지도까지 보존해야 하는 이유이고, 이 지도가 JIT 메모리 비용의 일부다.

관찰 2 — 최적화와 역최적화를 로그로 본다

다음 예제를 Node.js 24에서 실행하면 승격과 역최적화가 로그로 보인다.

js
// deopt-observe.mjs — node --trace-opt --trace-deopt deopt-observe.mjs 2>&1 | grep add
function add(a, b) { return a + b; }

let sink = 0;
for (let i = 0; i < 100_000; i++) sink = add(i, i + 1); // 숫자로 워밍업
sink = add('a', 'b');                                   // 형태가 다른 입력 한 번
for (let i = 0; i < 100_000; i++) sink = add(i, i + 1); // 다시 숫자

이 환경의 실제 출력에서 발췌한 것이다(주소 생략).

text
[marking <JSFunction add> for optimization to MAGLEV, reason: hot and stable]
[completed compiling <JSFunction add> (target MAGLEV) - took 0.000, 0.041, 0.000 ms]
[marking <JSFunction add> for optimization to TURBOFAN_JS, reason: hot and stable]
[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing <JSFunction add>,
  <Code MAGLEV>, opt id 2, bytecode offset 2]
[marking <JSFunction add> for optimization to MAGLEV, reason: hot and stable]
[completed compiling <JSFunction add> (target TURBOFAN_JS)]

읽는 법: 워밍업이 add를 Maglev로, 이어 TurboFan으로 승격시킨다(reason: hot and stable — 뜨겁고 피드백이 안정적이라는 두 조건). 문자열 호출이 들어오는 순간 reason: not a Smi — "피연산자는 Smi(V8의 작은 정수 표현)"라는 가드가 bytecode offset 2(덧셈 지점)에서 깨졌고, 그 자리에서 바이트코드로 탈출했다. 이후 숫자 호출이 다시 쌓이자 재최적화된다. 티어 이름과 로그 형식은 V8 버전에 따라 바뀔 수 있다 — 재현할 때는 자기 환경의 출력으로 확인하라. 플래그 없이도 성능 그래프에는 같은 현상이 "수만 번째 호출 근처에서 빨라졌다가, 특정 입력 직후 일시적으로 느려지는" 모양으로 나타난다.

실무 관점

워크로드의 수명이 전략을 고른다

  • 단발 CLI·짧은 서버리스 호출: 총 실행 시간이 짧아 상위 계층까지 갈 일이 없거나, 가더라도 컴파일 비용을 회수하지 못한다. 시작 지연(파싱 포함)이 지배하므로 유리한 것은 빠른 시작 쪽이다 — 바이트코드 캐시, 스냅숏, AOT 컴파일된 바이너리, 또는 아예 가벼운 런타임.
  • 장수명 서버: 워밍업 비용은 가동 시간으로 상각되고 peak 성능이 지배한다. JIT의 프로파일 기반 최적화가 가장 유리한 지형이다. 단, 배포 직후의 워밍업 구간이 있으므로 트래픽을 서서히 올리는(웜업 후 투입) 운영이 성능 관찰과 맞물린다.
  • 판단 기준은 "번역 비용을 몇 번의 실행으로 나누는가" — 관찰 1의 교차점 계산과 같은 산수가 시스템 규모에서 반복된다.

마이크로벤치마크는 워밍업 상태를 통제해야 한다

관찰 2를 뒤집어 읽으면: 같은 함수가 실행 이력에 따라 인터프리터 코드일 수도, Maglev 코드일 수도, TurboFan 코드일 수도 있다. 워밍업 없이 잰 수치는 어느 계층을 쟀는지 알 수 없는 값이다. 1.1의 방법론이 워밍업과 반복을 요구하는 근본 이유가 이 문서에서 완성된다 — 측정 대상이 "함수"가 아니라 "특정 계층으로 컴파일된 함수"이기 때문이다. 하나 더: 벤치마크에서 단일 형태의 입력만 주면 monomorphic IC라는 최상 조건을 측정하는 것이다. 실서비스 호출 지점이 polymorphic이라면 벤치마크는 체계적으로 낙관적이다.

p99 뒤에 형태 불안정이 숨어 있을 수 있다

실서비스 요청 데이터는 형태가 균일하지 않다 — 필드가 있다가 없는 객체, 정수였다가 소수가 되는 값, 드문 요청 타입. 이런 입력이 뜨거운 경로의 가드를 깨면 역최적화가 발생하고, 해당 요청(과 재최적화가 끝나기 전의 이웃 요청들)의 지연이 튄다. 평균은 멀쩡한데 꼬리 지연이 나쁜 서비스에서 의심할 후보 중 하나다. 진단은 추측이 아니라 관찰로 — --trace-deopt를 스테이징에서 켜거나, 역최적화가 반복되는 함수(V8은 반복 deopt 함수의 재최적화를 점점 보수적으로 만든다)를 찾는다. 수리는 형태 안정화다: 객체를 같은 순서·같은 필드로 초기화하고, 옵션 필드는 undefined로라도 항상 존재하게 하고, 뜨거운 경로의 값 타입을 좁게 유지한다.

통념: "컴파일 언어는 인터프리터 언어보다 항상 빠르다"

이 문장은 두 번 틀린다. 첫째, 5.0에서 봤듯 언어와 실행 전략은 일대일이 아니다. 둘째, "빠르다"가 어느 국면인지 말하지 않는다 — AOT 바이너리는 시작이 빠르고 예측 가능하지만, 장수명 핫 루프에서는 프로파일을 가진 JIT이 AOT가 증명 못 하는 추측(실제 타입, 실제 호출 대상)으로 이길 수 있다. 반대로 짧은 프로세스에서는 어떤 JIT도 워밍업 비용을 회수하지 못한다. 유효한 질문은 "어느 언어가 빠른가"가 아니라 "이 워크로드의 수명·입력 안정성에서 어느 전략이 비용을 회수하는가"다.

더 깊이

디스패치 자체의 비용 — 인터프리터 루프와 분기 예측

VM 루프의 switch는 CPU 관점에서 하나의 간접 분기이고, 그 분기의 목적지는 opcode 순서를 따라 사실상 무작위로 바뀐다 — 7.1에서 본 "예측기가 학습하지 못하는 분기"의 고전 사례다. 그래서 인터프리터 구현들은 디스패치 지점을 opcode 처리 코드마다 분산시키는 direct threading(GCC의 computed goto)을 써서 "이 opcode 다음엔 대개 저 opcode"라는 상관관계를 예측기에 노출한다. CPython이 이 기법을 쓰고, 명령 수 자체를 줄이는 방향(레지스터 VM, superinstruction)과 함께 인터프리터 성능 튜닝의 두 축을 이룬다. 하드웨어 지식이 소프트웨어 설계(디스패치 코드 배치!)를 바꾸는 좋은 사례다.

역최적화가 상태를 복원할 수 있는 이유

최적화 코드는 바이트코드의 변수들을 레지스터에 흩어 놓고, 일부 계산은 제거하거나 재배치했다. 그런데도 임의 가드 지점에서 인터프리터 상태로 돌아갈 수 있는 것은, 컴파일러가 각 탈출 지점마다 "이 시점의 바이트코드 상태를 물리 상태에서 재구성하는 사상"(V8·JVM 계열에서 deopt/frame state로 불리는 메타데이터)을 함께 생성해 두기 때문이다. 이 지도는 관찰 가능한 의미를 보존해야 한다는 5.2의 계약의 극단적 형태다 — 최적화 세계와 해석 세계 사이의 의미 동등성을 임의 지점에서 증명 가능하게 유지하는 것. escape analysis로 할당을 제거한 객체조차 탈출 시점에 재물질화(rematerialization)된다.

가정을 어렵게 만드는 언어 기능

추측 최적화는 "관찰이 미래를 예측한다"에 베팅한다. 이 베팅을 구조적으로 어렵게 만드는 기능들이 있다 — evalwith(스코프 내용을 실행 전에 알 수 없다), 객체 형태의 동적 변경(프로퍼티 추가·삭제, prototype 교체), 임의 시점의 코드 로딩. 엔진들은 이런 기능이 등장하는 순간 주변 코드의 최적화를 포기하거나 보수적 경로로 떨어진다. "동적 기능은 쓰는 곳만 느려지는 게 아니라, 그 주변의 최적화 가능성을 함께 낮춘다"는 것이 성능 관점의 비용 계산이다. 한편 phase change — 프로그램이 국면을 바꿔 과거 프로파일이 대표성을 잃는 것(예: 로딩 국면과 정상 상태의 타입이 다름) — 은 기능이 아니라 워크로드 쪽에서 같은 문제를 만든다.

정리

  • 트리 워커는 준비가 싸고 실행이 비싸다(노드 디스패치·포인터 추적·이름 탐색의 반복). 바이트코드 VM은 컴파일과 메모리를 지불해 그 반복 발견을 제거하지만, 명령 디스패치는 남는다.
  • 준비 비용은 실행 횟수로만 회수된다. 실측에서 작은 함수의 컴파일은 대여섯 번의 실행에서 본전이었고, 이 교차점 산수가 모든 실행 전략 선택의 뼈대다.
  • JIT은 실행 프로파일이라는 AOT가 갖지 못한 정보로 추측 최적화를 하고, 가드·inline cache·역최적화가 그 베팅의 안전장치다. 계층화는 컴파일 예산을 뜨거운 코드에만 쓰는 구조다.
  • 입력 형태의 안정성은 성능 특성이다. monomorphic 호출 지점은 빠르고, 형태 불안정은 역최적화와 꼬리 지연으로 나타난다.
  • 벤치마크는 워밍업 상태와 입력 형태 다양성을 통제해야 하고, 전략 선택은 워크로드의 수명이 정한다.

확인 문제

1. 평균 실행 시간 200ms의 서버리스 함수가 있다. 팀이 "JavaScript는 JIT 덕에 오래 돌수록 빨라지니 문제없다"며 성능 개선을 미룬다. 이 판단의 문제를 이 문서의 비용 구조로 지적하고, 확인해야 할 측정 두 가지를 제시하라.

정답과 해설

JIT의 이득은 워밍업 비용을 회수할 만큼의 반복 실행을 전제한다. 서버리스 인스턴스가 짧게 살고 자주 새로 뜬다면 매 콜드 스타트마다 파싱·해석·낮은 계층 실행을 반복하고 상위 계층의 이득은 거의 받지 못한다 — "오래 돌수록 빨라진다"의 전제가 성립하지 않는다. 측정할 것: (1) 인스턴스 수명 분포와 인스턴스당 처리 요청 수 — 번역 비용이 몇 번의 실행으로 나눠지는지의 분모다. (2) 콜드 스타트와 웜 상태의 지연 분해 — 시작 비용(초기화·파싱)이 지배적이면 개선 방향은 JIT 의존이 아니라 시작 비용 축소(번들 축소, 스냅숏, 인스턴스 유지)다.

2. 관찰 2의 예제에서 add('a', 'b')한 번만 호출했는데도 역최적화가 발생했다. 그런데 실서비스에서는 같은 함수에 문자열과 숫자가 계속 섞여 들어온다고 하자. 엔진이 이 상황에 어떻게 적응할지 예측하고, 개발자가 코드 수준에서 할 수 있는 대응과 그 근거를 서술하라.

정답과 해설

섞인 입력이 계속되면 타입 피드백이 "Smi 또는 문자열"로 넓어지고, 엔진은 두 경우를 모두 처리하는 더 일반적인(더 느린) 코드를 만들거나, 역최적화가 반복되면 해당 함수의 재최적화를 보수화한다. 즉 한 번의 deopt는 일시 비용이지만, 지속적 형태 혼합은 정상 상태 성능을 낮춘다. 코드 수준 대응: 뜨거운 경로에서 값 형태를 분리한다 — 호출 지점을 타입별로 나누거나(숫자 경로와 문자열 경로를 다른 함수로), 경계에서 정규화해 내부로는 단일 형태만 흘리거나, 혼합이 본질적이면 그 지점이 polymorphic 비용을 내는 것을 받아들이고 더 뜨거운 안쪽 루프만 단형으로 지킨다. 근거는 IC와 추측 최적화가 "호출 지점별 형태 관찰" 위에 서 있다는 것 — 형태 분리는 각 지점의 관찰을 다시 안정시킨다.

3. Toy 언어에 바이트코드 VM을 도입하는 리뷰에서 "인터프리터보다 무조건 빠르니 트리 워커를 지우자"는 의견이 나왔다. 관찰 1의 실측을 근거로 반박하고, 트리 워커를 유지할 실용적 이유를 한 가지 더 제시하라.

정답과 해설

실측에서 (1) 매번 컴파일하는 단발 실행은 트리 워커(105ns)가 컴파일+VM(288ns)보다 빨랐고 — 준비 비용은 반복으로만 회수된다, (2) 호출 재귀가 지배하는 fib에서는 재사용하더라도 VM이 트리 워커를 이기지 못했다 — 단계 수가 비슷하고 단계당 비용 차이가 호스트 최적화로 좁혀졌기 때문이다. "무조건 빠르다"는 워크로드(실행 횟수, 프로그램 모양)와 구현 기반을 무시한 명제다. 유지할 이유: 트리 워커는 언어 의미의 직역이라 기준 구현으로 가치가 있다 — 컴파일러·VM의 버그를 "두 실행기의 결과 불일치"로 잡는 차등 테스트의 기준점이 되고, 새 언어 기능의 의미를 먼저 확정하는 실험대가 된다.

참고 자료