본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 20. 23:28

opencode.ai를 사용하여 Rust로 QuickJS를 능가하는 레지스터 VM 기반 JavaScript 엔진을 구축했습니다

요약

opencode.ai와 DeepSeek-v4-Flash를 활용하여 3주 만에 Rust 기반의 JavaScript 엔진인 'pipa'를 구축했습니다. pipa는 레지스터 VM 방식을 채택하여 QuickJS보다 높은 V8 벤치마크 점수를 기록했으며, 외부 의존성 없이 NaN-boxing 기술을 통해 효율적인 메모리 관리를 구현했습니다.

핵심 포인트

  • opencode.ai를 통해 22일간 85회의 세션을 거쳐 약 75,000줄의 소스 코드를 생성 및 최적화함
  • 스택 VM 대신 레지스터 VM 방식을 사용하여 명령어 수를 줄이고 최적화 범위를 확장함
  • NaN-boxing 기법을 사용하여 모든 JS 값을 64비트 내에 인코딩하고 타입 디스패치 성능을 극대화함
  • 외부 의존성 없이 Regex, JSON, WebSocket 등을 포함한 약 5.2MB 크기의 바이너리 구현
  • 피프홀 최적화(Peephole optimization) 및 인라인 캐시 튜닝을 통한 성능 개선

3주 전, 저는 opencode.ai의 월 5달러 플랜에 가입했습니다. 저에게는 미친 아이디어가 하나 있었습니다. 바로 Rust로 ES2023을 준수하는 JavaScript 엔진을 만드는 것이었습니다. 그 결과는 무엇이었을까요? 바로 pipa (枇杷)입니다. V8 벤치마크에서 1256점을 기록하며 QuickJS(1219점)를 근소하게 앞지른 레지스터 기반 VM (Register-based VM)입니다.

기술 스택 (The Stack):

  • AI: opencode.ai (첫 달 $5/mo) × DeepSeek-v4-Flash
  • 언어 (Language): Rust (edition 2024)
  • 바이너리 크기 (Binary size): ~5.2 MB (REPL, HTTP 클라이언트, WebSocket 포함)
  • 외부 의존성(External deps) 제로: Regex, JSON, Base64, BigInt, Fetch, WebSocket, SSE

3주 만에 구축 완료
opencode 세션 로그를 되돌아보면, pipa에 대한 초기 작업은 4월 28일에 시작되었습니다. 이후 22일 동안 약 85회의 세션을 진행했습니다. opencode는 초기 스켈레톤(Lexer, Parser, NaN-boxed value system, Register VM, Opcodes)부터 점진적인 개선에 이르기까지 모든 차이점(Diff)을 추적했습니다. 첫 번째 커밋에서 205개의 소스 파일에 걸쳐 약 75,000줄의 전체 기반을 생성했습니다. 그 후 피프홀 최적화 패스 (Peephole optimizer passes), 인라인 캐시 튜닝 (Inline cache tuning), GC 피닝 로직 (GC pinning logic), WebSocket 핸드셰이크 수정, 벤치마크 하네스 (Benchmark harnesses), 그리고 O0부터 O3까지의 최적화 레벨(Optimization levels) 등 반복적인 개선이 이루어졌습니다.

왜 레지스터 VM (Register VM)인가?
스택 VM (Stack VM)은 지속적인 셔플/복제 (Shuffle/dup)가 필요합니다. 반면 레지스터 VM은 피연산자 (Operands)를 명시적으로 지정하므로, 명령어 수는 더 적고 최적화할 수 있는 범위는 더 넓습니다. pipa의 명령어는 u8 opcode + u16 register operands로 구성되며, 가변 길이(1/3/5/7/9/11 바이트)를 가집니다. Fused EqJumpIf는 비교(Compare)와 분기(Branch)를 결합합니다. LoadInt8 r, imm8은 3바이트 내에 작은 정수를 로드합니다. Compact Jump8은 근거리 점프(Near jumps)에 2바이트를 사용합니다. 피프홀 최적화 도구는 3번의 패스를 실행하여 Move rX, rX를 제거하고 0/1 패턴을 폴딩(Folding)합니다.

NaN-Boxing: 모든 것을 64비트로
0 x7FF8_?TTT_?PPP_PPPP_PPPP_PPPP
^^^^ tag ^^^^^^^^^^^^^ payload

모든 JS 값은 하나의 u64에 들어갑니다. 표준 NaN 접두사(Canonical NaN prefix) + 4비트 태그(Tag) + 47비트 페이로드(Payload)를 통해 10가지 모든 타입을 인코딩합니다. 타입 디스패치 (Type dispatch)는 단일 시프트+마스크 (Shift+mask) 연산으로 이루어집니다. 작은 정수(Small integers)는 박싱 (Boxing)을 완전히 피합니다. 핫 패스 (Hot-path)인 both_int()는 하나의 비트마스크로 양쪽 피연산자를 확인하여 즉시 i64 연산으로 연결합니다.

의존성 없는 내장 정규표현식 (src/regexp/): 계층적 실행(Layered execution) 구조를 가집니다 — LiteralFast → FastClass → DFA → OptimizedNFA → FullNFA 순으로 진행됩니다. 분석기(Analyzer)가 패턴별로 가장 빠른 엔진을 선택합니다. 별도의 regex crate를 사용하지 않습니다. JSON: 직접 작성한 재귀 하강 파서(Recursive descent parser) 및 직렬화기(Serializer)를 사용합니다. Fetch/WebSocket/SSE (src/http/): Rustls 기반의 TLS, 청크 전송 인코딩(Chunked transfer encoding), 압축, WebSocket 프레임 파싱 등을 모두 처음부터 직접 구현했습니다. BigInt, Base64, Unicode: 직접 구현(Hand-rolled)했습니다. 세대별 가비지 컬렉션 (Generational GC): 16MB의 네서리(Nursery), 범프 할당(Bump allocation), 마이너 GC(Minor GC)를 통해 죽은 객체를 정리합니다. 10회의 마이너 GC가 연속으로 쓰레기를 발견하지 못하면, 증분 마킹(Incremental marking) 및 쓰기 장벽(Write barriers)을 사용하는 풀 GC(Full GC)로 승격(Promote)됩니다. 이는 16K 할당마다 트리거됩니다. 성능 최적화: 실제로 효과가 있었던 것들. 베이스라인 컴파일러(Baseline compiler)를 넘어, pipa는 QuickJS보다 빠르게 만드는 일련의 최적화 스택을 보유하고 있습니다:

  1. 피프홀 최적화 (Peephole Optimizer, 3회 패스) src/compiler/peephole.rs는 바이트코드(Bytecode)에 대해 최대 3회의 패스를 실행합니다. 각 패스는 로컬 패턴을 다음과 같이 변환합니다:
  • Self-move: Move rX, rX → NOP
  • Zero/one folding: LoadInt rX, 0; Add rX, rY, rX → Move rX, rY
  • Divide-by-one: LoadInt rX, 1; Div rX, rY, rX → Move rX, rY
  • Jump threading: Jump → Jump 체인을 최종 타겟으로 해결
  • 이중 부정 (Double negation): Not; Not → Move, Neg; Neg 및 BitNot; BitNot도 동일하게 적용
  • 상수 폴딩 (Constant folding): LoadInt 3; LoadInt 4; Add → 단일 LoadInt 7 (add/sub/mul/and/or/xor 지원)
  • 상수에 따른 분기 (Branch on constant): LoadTrue; JumpIf rX → Jump (항상 실행), LoadFalse; JumpIf rX → NOP
  • LoadInt → LoadInt8 축소 (shrink): Move로 이어지는 [-128, 127] 범위의 정수는 3바이트 LoadInt8로 단축됩니다.
  1. 융합된 비교-점프 명령어 (Fused Compare-Jump Instructions): 불리언(Boolean) 값을 레지스터에 계산한 후 분기하는 대신, EqJumpIf, LtJumpIfNot, StrictEqJumpIf 등(16개의 융합된 쌍)이 두 값을 직접 비교하고 단일 오피코드(Opcode) 내에서 조건부 점프를 수행합니다. 이를 통해 중간 레지스터 압박(Register pressure)을 제거하고 모든 비교 연산에서의 명령어 수를 줄입니다.

NaN-Boxing Fast Paths src/value.rs:194에 있는 both_int()는 단일 비트마스크 연산을 사용하여 두 피연산자가 태그된 정수(Tagged integers)인지 확인합니다: const TAG_MASK : u64 = QNAN_BASE | ( 0xF << TAG_SHIFT ); ( a .0 & TAG_MASK ) == INT_TAG_BITS && ( b .0 & TAG_MASK ) == INT_TAG_BITS 이 연산은 vm.rs 내의 모든 산술 및 비교 연산 코드(Opcodes)에서 총 29회 사용됩니다. 두 피연산자가 모두 정수인 경우, VM은 타입 디스패치(Type dispatch)나 ToNumber 형변환(Coercion) 없이 즉시 i64 산술 연산을 수행합니다. 마찬가지로, both_raw_float()는 가공되지 않은 f64 쌍에 대해 태깅(Tagging) 과정을 건너뜁니다. new_float()에 있는 is_fast_int()는 47비트 부호 있는 정수(Signed bits) 범위 내에 들어오는 f64 값을 자동으로 태그된 정수로 다시 변환하여, 향후 연산이 패스트 패스(Fast path)를 탈 확률을 극대화합니다.

  1. Shape + 인라인 캐시 (Inline Cache)
    src/object/shape.rs는 V8 스타일의 숨겨진 클래스(Hidden class) 트리를 구현합니다. 각 객체는 전이 체인(Transition chains, 속성 추가 → 새로운 자식 Shape 생성)을 통해 속성 이름-오프셋 매핑을 기록하는 Shape를 가리킵니다. src/compiler/ic.rs는 양방향 다형성 인라인 캐시(Polymorphic inline caches, IC_POLY=2)를 구현합니다. 각 캐시 슬롯은 (shape_id, property_offset)을 저장합니다. 속성에 접근할 때, VM은 객체의 Shape ID를 캐시된 슬롯과 비교합니다. 캐시 히트(Hit) 시, 해시 조회(Hash lookup)를 완전히 우회하여 캐시된 오프셋에서 직접 값을 읽습니다. 단형성(Monomorphic) 핫스팟(하나의 Shape만 존재)의 경우, 이는 단일 Shape 비교와 포인터 오프셋 로드만으로 이루어집니다.

  2. VM 내 캐시된 로우 포인터 (Cached Raw Pointers)
    VM 구조체는 각 호출 프레임(Call frame) 푸시 시 업데이트되는 7개의 로우 포인터(Raw pointers)를 캐싱합니다: cached_code_ptr, cached_const_ptr, cached_registers_base, cached_registers_ptr, cached_ic_table_ptr, cached_upvalue_slot_ptr, cached_upvalues_len. 핫패스(Hot-path) 명령어 핸들러는 매 명령어마다 frames[frame_index]를 추적하는 대신, 이러한 캐시된 포인터를 통해 바이트코드(Bytecode), 상수(Constants), 레지스터(Registers)에 접근합니다. 이를 통해 내부 루프(execute_inner 약 9,000라인)에서의 포인터 간접 참조(Pointer indirection)를 제거합니다.

Opcode-Level Shortcuts (Opcode 수준의 지름길)

  • LoadInt8 r, imm8: 7바이트 대신 3바이트를 사용하여 [-128, 127] 범위를 로드합니다.
  • AddImm8 / SubImm8 / LteImm8: 작은 정수 상수를 명령어에 직접 인코딩하여 산술 연산을 수행합니다.
  • Call0 / Call1 / Call2 / Call3: 인자 개수 계산을 건너뛰는 특화된 호출(Call) Opcode입니다.
  • Jump8 / JumpIf8: 가까운 타겟을 위한 2바이트 점프를 사용하여 점프당 3바이트를 절약합니다.
  • Micro function inlining (O2+): 코드 생성(Codegen) 단계에서 x => x.prop 또는 x => x.a.b를 감지하여 GetNamedProp 시퀀스로 인라이닝(Inlining)함으로써 프레임 할당(Frame allocation)을 제거합니다.
  1. 증분 마킹(Incremental Marking)을 사용하는 세대별 GC (Generational GC)
    GC (src/runtime/gc.rs, 약 1,550라인)는 Bump allocation을 사용하는 16MB 크기의 Nursery(유아기 세대)를 사용합니다. Minor GC는 루트를 스캔하고 화이트(White) 객체를 스윕(Sweep)하는 것만으로 수행되므로 비용이 거의 들지 않습니다. 10회 연속으로 Minor GC에서 수집할 객체가 발견되지 않으면 Full GC로 승격됩니다. Full GC는 단계별로 설정 가능한 예산(Budget)을 가진 증분 마킹(Incremental marking)을 사용하여 긴 Stop-the-world 일시 정지를 방지합니다. Write barrier(쓰기 장벽)는 증분 마킹 중에 Black→White 참조를 보호합니다.

  2. 계층형 정규표현식 엔진 (Layered Regex Engine)
    정규표현식 엔진 (src/regexp/)은 가장 큰 성과 중 하나로, V8 RegExp 벤치마크에서 QuickJS보다 3배 더 빠릅니다. 이 엔진은 다음과 같은 5단계 실행 전략을 사용합니다:

모드 (Mode)대상 (When)방식 (How)
LiteralFast순수 리터럴 문자열 (메타 문자 없음)memcmp
FastClass단순 문자 클래스만 포함Byte-by-byte
FastClassMatcherLiteralDFA/ClassDFA로 컴파일 가능한 단순 패턴선형 시간(Linear-time) DFA 실행
OptimizedNFA최적화가 적용된 중간 복잡도최적화된 NFA 바이트코드
FullNFA역참조(Backreferences)가 포함된 복잡한 패턴풀 백트래킹(Full backtracking) NFA (풀링된 컨텍스트 사용)

패턴 분석기(analyze_pattern)는 컴파일 타임에 각 정규표현식을 분류하고, 이를 처리할 수 있는 가장 비용이 적은 엔진을 선택합니다. 대부분의 실제 정규표현식은 DFA 또는 그보다 빠른 단계에 해당합니다.

test262 커버리지: 우리가 시도한 것
공식 ECMAScript 테스트 스위트(test262)를 실행하는 것은 지속적인 노력이 필요한 작업입니다. 현재 구축된 사항은 다음과 같습니다:

  • test262 Runner: examples/test262_runner.rs (약 500라인)는 다음과 같은 기능을 갖춘 독립형 테스트 하네스(Test harness)입니다:
    • 테스트 파일의 YAML 프론트매터(Frontmatter)를 파싱합니다 (/*--- ...

---*/)

  • 테스트 플래그(test flags)를 처리합니다 (onlyStrict, noStrict, raw, async, generated)
  • 테스트 포함 사항(test includes)을 해결합니다 (assert.js, sta.js와 같은 하네스 헬퍼 파일)
  • 네거티브 테스트(negative tests)를 감지합니다 (특정 에러 유형을 동반한 예상된 실패)
  • 콘솔 출력을 통해 파일별 통과/실패 통계를 추적합니다
    test262.sh 스크립트는 전체 흐름을 제어합니다: tc39에서 test262를 클론한 다음 러너(runner)를 호출합니다.

현재 커버리지: 약 45%
엔진은 test262 스위트의 약 45%를 통과합니다. 주요 작동 영역은 다음과 같습니다:

  • 어휘 문법 (Lexical grammar): 식별자 (Identifiers), 키워드 (Keywords), 유니코드 이스케이프 시퀀스 (Unicode escape sequences), 줄 바꿈 문자 (Line terminators)
  • 표현식 (Expressions): 기본 (Primary), 좌측 값 (Left-hand-side), 단항 (Unary), 이항 (Binary), 조건부 (Conditional), 할당 (Assignment)
  • 문 (Statements): 블록 (Block), 변수/함수 선언 (Variable/function declarations), if/switch/while/for/for-in/for-of, try/catch/finally, return/throw/break/continue
  • 내장 객체 (Built-in objects): Object, Array, String, Number, Boolean, Symbol, BigInt, Math, Date, RegExp, JSON, Map, Set, Promise, Proxy, Reflect, TypedArrays, Intl
  • 제어 흐름 (Control flow): 비정상 동작 감지 (Aberrant behavior detection), 조기 에러 처리 (Early error handling)
  • 모듈 (Modules): import/export, 네임스페이스 객체 (Namespace objects)

누락된 사항
남은 55%는 주로 다음 항목에 해당합니다:

  • 부속서 B (Annex B, 브라우저 확장 기능): proto, Object.prototype.defineGetter, RegExp.$1 등
  • TDZ (Temporal Dead Zone) 및 클래스 필드 초기화의 엣지 케이스 (Edge cases)
  • 비동기 제너레이터 (Async generator) 종료 관련 코너 케이스
  • Atomics/SharedArrayBuffer: 현재 싱글 스레드 모델에서는 이를 지원하지 않음
  • Intl.NumberFormat v3: Intl 명세의 최근 추가 사항

MIR 레이어 (src/compiler/mir.rs, 약 460라인)는 설계되었으나 아직 메인 파이프라인에 연결되지 않았습니다. 연결되면 더 나은 데드 코드 제거 (Dead code elimination), 타입 추론 (Type inference), 그리고 잠재적으로 SSA (Static Single Assignment) 기반 최적화를 가능하게 할 것입니다.

opencode가 도움을 준 방식
AI 페어 프로그래밍 (AI pair programming) 없이는 절대 이것을 구축하지 못했을 것입니다. 원하는 것을 설명하고, 작동하는 코드를 얻고, 테스트하고, 개선하는 워크플로우를 통해 수개월의 작업을 3주로 단축했습니다.

세션 타임라인이 그 과정을 보여줍니다:

기간집중 분야
1주차 (4월 28일~5월 4일)핵심 엔진: 렉서 (lexer), 파서 (parser), 레지스터 VM (register VM), NaN-boxing, 연산 코드 (opcode) 인코딩, 기본 내장 함수 (builtins)
2주차 (5월 5일~5월 11일)최적화: 피프홀 패스 (peephole passes), 셰이프 시스템 (shape system), 인라인 캐시 (inline caches), 정규표현식 (regex) 엔진, 바이트코드 직렬화 (bytecode serialization)
3주차 (5월 13일~5월 20일)HTTP 스택: fetch, WebSocket, SSE, EventSource; 프로세스 (process) API; 벤치마크 (benchmarks); REPL 다듬기

DeepSeek-v4-Flash는 Rust의 소유권 의미론 (borrow semantics)을 이해했고, 정확한 unsafe NaN-boxing 코드를 생성했으며, TLS 핸드셰이크 (TLS handshakes)를 연결하고 GC (Garbage Collection)의 예외 케이스들을 디버깅했습니다. 이것이 없었다면, 이 프로젝트는 주말 동안의 단순한 사고 실험으로 남았을 것입니다.

직접 시도해 보세요

cargo install pipa-js

pipa script.js

바이트코드로 사전 컴파일

pipa -compile input.js output.jsc

최대 최적화

pipa -O3 script.js

GitHub에서 MIT 라이선스로 제공됩니다. PR (Pull Request)과 Star를 환영합니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0