
# 바이트코드 VM

기본 실행 경로는 AST 노드를 하나씩 방문해 값을 반환하는 트리 워커입니다. 수백만 번 호출되는 자주 실행되는 함수에서는 노드마다 발생하는 디스패치 오버헤드가 병목입니다. Phasis은 이 오버헤드를 줄이기 위해 추측 기반 바이트코드-PHP 클로저 컴파일러를 추가합니다.

## VM이 작동하는 시점

모든 `JsFunction`에는 자주 호출되는 횟수를 세는 카운터가 있습니다. 임계값(기본값 100)을 넘으면 엔진은 함수 본문을 `src/Bytecode/JsToPhp.php`를 통해 PHP 클로저로 컴파일하려고 시도합니다.

함수 본문이 정해진 셰이프에 해당하면 컴파일에 성공합니다:

- 예측 가능한 타입(숫자, 객체, 함수, 배열, 문자열)의 지역 변수 할당.
- 숫자 산술 연산.
- 객체 타입 지역 변수 및 `this`(`this.v`, `obj.x`)의 멤버 읽기.
- 객체 타입 지역 변수의 메서드 호출(`obj.method(args)`). 리시버를 `$thisValue`로 전달해 디스패치합니다.
- `new Ctor(args)` 생성자 호출 — 왼쪽 변수의 타입을 `object`로 승격하고, 객체가 아닌 반환값은 중단 처리로 보호합니다.
- 단순 조건 셰이프를 사용하는 `for` / `while` 루프.
- 계산 가능한 표현식이 포함된 `return`.
- 타입이 지정된 조건을 사용하는 `if` / `else`.

본문에 다음 항목이 포함되면 컴파일에 실패하고 함수는 트리 워커에 남습니다:

- `with` 문.
- `try / catch / finally`(올바른 완료 의미를 위해 트리 워커에서 처리).
- `eval()` 호출.
- 직접적인 `arguments` 변경.
- 컴파일러가 아직 낮은 수준 표현으로 변환하는 방법을 모르는 항목.

컴파일에 성공하면 엔진은 함수 호출 경로를 컴파일된 클로저로 바꿉니다. 이후 호출은 AST 노드마다 한 번씩이 아니라 함수 호출당 한 번의 오버헤드로 네이티브 PHP을 실행합니다.

## 중단 처리

컴파일된 빠른 경로는 몇 가지 가정(숫자는 계속 숫자이고 속성은 같은 셰이프에 남음)에 의존합니다. 런타임에 가정이 깨지면 클로저가 `Bailout`를 던지고, 해당 호출의 실행은 트리 워커에서 다시 시작됩니다.

사양 준수가 가장 중요한 부분입니다. 트리 워커는 빠른 경로에서 발생한 부작용을 관찰하지 않고 중단된 호출을 처음부터 다시 실행할 수 있어야 합니다. Phasis은 이를 두 가지 방식으로 처리합니다:

1. **부작용 없는 접두 구간** — 컴파일러는 추측 실행 전에 부작용이 있는지 확인합니다. 추측 지점 전에 함수가 I/O(console.log, 호스트 함수)를 수행하면 빠른 경로에서 이를 생략하지 않고, 결과를 확정한 뒤 계속합니다.
2. **컴파일 시 거부** — 관찰 가능한 부작용을 되돌려야 하는 셰이프(`fn()`가 상태를 변경하는 비숫자 `let r = fn()` 등)는 컴파일을 거부합니다. 함수는 트리 워커에서 실행됩니다.

문제가 되는 사례는 다음과 같습니다. 빠른 경로에서 숫자가 아닌 할당이 `fn()`를 실행하고 `Bailout`를 던진 뒤 트리 워커에서 다시 실행되면, 두 차례의 부작용과 함께 `fn()`도 두 번 실행됩니다. 컴파일 시 거부하면 `fn()`가 빠른 경로에서 아예 실행되지 않으므로 트리 워커에서 한 번만 호출됩니다.

## VM 디스패치 (두 번째 계층)

일부 함수는 PHP 클로저로 컴파일되고, 나머지는 `src/Bytecode/VM.php`의 더 단순한 바이트코드 인터프리터로 넘어갑니다. 바이트코드는 opcode(`LOAD_LOCAL`, `STORE_PROP`, `CALL_METHOD`, …)의 일차원 배열이며 `switch` 디스패치에서 처리됩니다.

VM은 PHP 클로저로 컴파일할 가치가 없지만 AST 순회를 건너뛸 만큼 자주 실행되는 함수를 위해 존재합니다. 내장 객체(Array.prototype.map, String.prototype.split 등)의 메서드 호출은 일반 `callFunction` 트램펄린을 우회하는 VM의 빠른 경로를 사용합니다.

## 사용자 정의 호출 스택

`Op::CALL`, `Op::CALL_METHOD`, `Op::NEW_CALL`는 인라인 호출 경로를 사용하여 전체 JS 호출 스택을 `VM::execute` 안에 유지합니다. JS 호출마다 새 PHP 프레임을 만들지 않습니다. 진입 시 호출자의 상태(pc, 코드 스트림, 피연산자 스택, 지역 변수, env, `this`, strict-mode 플래그)를 VM별 `savedFrames` 풀에 있는 `SavedFrame` 인스턴스에 저장하고, 디스패치 루프의 지역 변수가 피호출자에 맞도록 바꿉니다. `Op::RET`은 저장된 프레임을 꺼내 상태를 복구합니다. 예외가 발생하면 트리 워커로 돌아가기 전에 저장된 프레임을 따라 되감습니다.

자격 여부는 `JsFunction`에 저장됩니다. 일반 호출에는 `vmInlineCallCache`(BC 컴파일 본문, `bind()` 바인딩이 아니며 네이티브 함수도 아님), `new` 표현식에는 `vmInlineConstructCache`(추가 조건: `isConstructable`이고 파생되지 않았으며 인스턴스 필드나 비공개 메서드 초기화가 없음)이 적용됩니다. 그 외의 호출은 표준 `callFunction` / `vmNewExpression` 트램펄린을 거칩니다.

아키텍처 측면의 부수 효과로 PHP 스택 한도가 더 이상 JS 재귀 깊이를 제한하지 않습니다. 이제 유일한 제한은 `CallStack::maxDepth`입니다. **4 096**으로 설정되어 정당한 test262 사례(대부분 2 k 프레임 미만)에 충분한 여유가 있으며, 병적인 무한 재귀 테스트는 러너의 시간 초과 한도 안에 빠르게 실패합니다. 트랜스파일된 클로저 경로(`JsToPhp`)에는 인라인되지 않은 대체 경로용 8 192 프레임 가드가 별도로 있습니다.

## 프레임 스냅샷 생성기

단순한 생성기 본문(`yield*` 없음, try/catch/finally 안의 `yield` 없음, `await` 없음)은 바이트코드로 컴파일됩니다. 본문이 중단되는 지점에 새 `Op::YIELD` opcode를 출력합니다. `Op::YIELD`는 디스패치 상태(cf, pc+1, stack, sp, locals, env, thisValue, strict)를 `GeneratorSnapshot` 힙 객체에 캡처하고 `VM::execute`에서 `YieldResult` 센티널을 반환합니다. 구동 `JsGenerator`는 스냅샷을 저장하고 다음 `next()` / `throw()`에서 `VM::execute`로 전달해 피연산자 스택 맨 위의 재개 값을 대체합니다.

지원되는 하위 집합에서는 PHP-Fiber 경로를 대체합니다. 컴파일러가 거부하는 셰이프에는 Fiber 기반 드라이버(`executeGeneratorBody`)가 계속 사용됩니다. 예를 들어 `yield*` 위임, try 블록 안에서 yield하는 생성기, 비동기 생성기, `await`가 있는 비동기 함수가 여기에 해당합니다. `JsGenerator`는 이중 모드로 작동합니다. 각 `next()` / `throw()` / `return()`는 인스턴스에 스냅샷이 있는지 Fiber가 있는지 확인해 알맞은 경로로 디스패치합니다.

벤치마크에서 조정할 설정은 없습니다. 자격 여부는 컴파일 시 결정되어 `JsFunction`에 저장됩니다. 스냅샷 경로에서는 yield마다 PHP 컨텍스트 전환이 한 번 줄며, 그 외의 경우 Fiber 경로는 변하지 않습니다.

## 인라인 캐시

`Op::LOAD_MEMBER`에는 프로토타입 식별자를 키로 사용하는 PC별 인라인 캐시가 있습니다. 이 캐시는 인스턴스의 프로토타입 메서드 패턴(`c.sum(...)`, `arr.push(...)`, 모든 클래스 메서드 디스패치)에 사용됩니다. 적중 조건은 다음과 같습니다. 리시버에 해당 이름의 자체 슬롯이 없고, 캐시된 프로토타입 참조가 리시버의 `getPrototype()`와 일치하며, 프로토타입의 `PropertyMap::$version`가 캡처된 버전과 일치해야 합니다.

캐시 무효화는 `PropertyMap::$version`를 따릅니다. 구조 변경(set / delete)은 항상 값을 증가시킵니다. 데이터 슬롯 값 덮어쓰기는 `PropertyMap::$isUsedAsProto`이 true일 때만 값을 증가시킵니다. 이는 객체가 다른 객체의 `[[Prototype]]`로 처음 관찰될 때 켜지는 쓰기 장벽 플래그입니다. 프로토타입으로 사용되지 않는 인스턴스 객체는 매 `obj.x = v`마다 IC 인프라 비용을 부담하지 않습니다.

Getter 접근자는 매번 읽을 때마다 다시 실행해야 하므로 캐시되지 않습니다.

`PropertyMap`에는 두 번째 카운터인 `$mutationVersion`가 있으며, 모든 쓰기에서 *항상* 증가합니다(쓰기 장벽 없음). `JSON.stringify` 캐시는 방문한 모든 중첩 객체/배열의 이 카운터를 고정해 둡니다. 따라서 `o.a = 99` 같은 인스턴스 전용 변경은 IC의 `$version`가 증가를 건너뛰더라도 캐시된 문자열을 올바르게 무효화합니다.

## JSON.stringify 캐시

`JsObject`에는 `$jsonCacheString`와 `$jsonCacheVersions`(간단한 문자열 변환 중 방문한 모든 중첩 객체의 `[obj, mutationVersion]` 쌍 목록)이 있습니다. 다음 `JSON.stringify(o)` 호출에 대체자나 공백 지정이 없으면, 간단한 빠른 경로는 고정된 모든 버전이 변경되지 않았는지 확인하고 다시 순회하거나 인코딩하지 않고 캐시된 문자열을 반환합니다. `JSON.parse(JSON.stringify(o))` 같은 자주 쓰이는 깊은 복제 패턴은 문자열 변환 부분에서 약 2배 빨라집니다.

캐시 적중에 실패하면(고정된 버전 중 하나라도 다르면) 캐시를 조용히 버리고 다음 순회에서 새로 채웁니다. `toJSON`, getter, 접근자, 심벌 또는 `[[PrimitiveValue]]` 슬롯이 있는 객체는 사양에 따른 직렬화기로 넘어가며 캐시에 저장되지 않습니다. 따라서 캐시 경로에는 구조가 균일한 데이터만 들어옵니다.

## 내장 기능의 빠른 경로

VM에 특정 내장 기능을 적용해 가장 큰 성능 향상을 얻었습니다:

- 협력형 스케줄링을 사용하는 `Atomics.load` / `store` / `compareExchange` 및 `loadSpinHook` / `storeNotifyHook` — Atomics 스핀 루프 테스트 4개를 복구했습니다.
- 직접 해시맵 DST 캐시를 사용하는 `Date.prototype.getTimezoneOffset` — SpiderMonkey DST 스트레스 테스트에서 45 % 빨라졌습니다.
- 인라인 ASCII 빠른 경로를 사용하는 `String.fromCharCode` — 일반적인 실행 한 번당 디스패치를 약 1 M회 절약합니다.
- 벌크 디코딩 `hex2bin`을 사용하는 12자 `%XX%XX%XX%XX` 초고속 경로의 `decodeURI` / `decodeURIComponent`.
- 속성 맵에 직접 접근하는 `Object.keys` / `Object.values` / `Object.entries`.
- 내부 PHP 배열을 네이티브 방식으로 순회하는 `Array.prototype.map` / `filter` / `forEach` / `reduce`.

각 빠른 경로에는 "셰이프 검사"가 적용됩니다. 리시버가 예상한 셰이프와 다르면(e.g. 일반 PHP `string`를 기반으로 하지 않는 TypedArray 뷰 등), VM은 사양을 준수하는 느린 경로로 넘어갑니다.

## 이 기능이 하지 않는 일

바이트코드 VM은 V8 의미의 **JIT가 아닙니다**. 함수별 네이티브 기계어 코드를 생성하지 않으며 IC의 단형 프로토타입 식별자 검사 외에는 관찰된 타입에 기반한 추측을 하지 않습니다. 함수 경계 인라이닝은 피호출자 본문을 호출자의 바이트코드에 낮춰 넣는 대신 사용자 정의 호출 스택으로 근사합니다(JS 호출당 추가 PHP 프레임 없음).

엔진에서 JIT에 가장 가까운 것은 JsToPhp 트랜스파일러입니다. 자주 호출되는 함수 본문을 위한 PHP 코드를 생성하고, 이후 PHP 8.5의 추적 JIT가 이를 네이티브 코드로 컴파일합니다. JsToPhp는 `eval()`를 거치므로 일회성 컴파일 비용이 들고, 추적 JIT는 사용자 코드처럼 해당 함수를 찾아야 합니다. opcache opcode를 직접 출력해 `eval()` 단계를 건너뛰면 격차를 더 줄일 수 있습니다. [벤치마크 § 로드맵](https://phasis.inline0.dev/ko/docs/advanced/benchmarks#roadmap)을 참조하세요.

## 벤치마크

`bench/microbench.js`는 12개 남짓의 소규모 벤치마크(loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure)를 실행합니다. 중앙값 벽시계 시간:

```bash
php bench/run.php
```

현재 수치는 각 `bench` 워크플로 실행 후 `BENCH.md`에 커밋됩니다. `bench` 및 test262 매트릭스 워크플로는 모두 수동으로 시작합니다.
