JIT 툴체인: 데이터베이스 개발을 위한 디스어셈블러 및 CPU 에뮬레이터 구축

JIT 툴체인: 데이터베이스 개발을 위한 디스어셈블러 및 CPU 에뮬레이터 구축
Copy-and-Patch JIT 개발과 디버깅을 실용적으로 만드는 필수 인프라
이전 글에서는 Copy-and-Patch JIT 컴파일이 어떻게 마이크로초 단위의 컴파일 시간으로 네이티브 코드 성능을 달성하는지 살펴보았습니다. 하지만 기계어 코드를 생성하는 것은 전체의 일부에 불과합니다. 충돌하는 스텐실(stencil)은 어떻게 디버깅할까요? 패치된 오프셋이 올바른 명령어 경계에 위치하는지 어떻게 검증할까요? 다른 CPU 아키텍처를 사용하는 개발 환경에서 JIT 코드를 어떻게 테스트할까요?
이 글에서는 Cognica 데이터베이스 엔진을 위해 구축한 JIT 툴체인을 자세히 살펴봅니다. 검증을 위한 다중 아키텍처 디스어셈블러와 크로스 플랫폼 테스트 및 디버깅을 위한 소프트웨어 CPU 에뮬레이터가 그 핵심입니다.
문제점: JIT 개발은 어렵습니다
JIT 컴파일은 기존의 AOT(Ahead-of-Time) 컴파일에서는 겪지 않는 디버깅 과제를 안겨줍니다.
- 보이지 않는 코드 (Invisible Code): JIT 컴파일된 코드는 런타임 전까지 존재하지 않습니다. 실행하기 전에 디버거로 미리 검증해 볼 수 없습니다.
- 패치 포인트 검증 (Patch Point Validation): Copy-and-Patch JIT는 특정 바이트 오프셋을 패치하는 방식에 의존합니다. 패치가 명령어 중간에 적용되면 충돌이나 조용한 데이터 손상을 일으킵니다.
- 크로스 플랫폼 개발 (Cross-Platform Development): Apple Silicon을 사용하는 개발자는 x86-64 스텐실을 테스트해야 하고, x86-64 개발자는 ARM64 코드를 검증해야 합니다.
- 성능 격리 (Performance Isolation): 쿼리가 느리게 실행될 때, 그것이 JIT 코드 때문인지, 인터프리터 때문인지, 아니면 쿼리 실행 계획 때문인지 어떻게 알 수 있을까요? JIT 동작을 격리하여 통제된 실행 환경이 필요합니다.
이러한 과제들은 특별한 도구를 요구합니다. 스텐실 형식을 이해하는 디스어셈블러와 스텐실을 격리된 상태로 실행할 수 있는 에뮬레이터가 바로 그것입니다.
아키텍처 개요
우리의 JIT 툴체인은 함께 작동하는 세 가지 주요 구성 요소로 이루어져 있습니다.
디스어셈블러는 검증 및 디버깅 출력을 위해 네이티브 기계어 코드를 디코딩합니다. 변환기(Translators)는 네이티브 코드를 아키텍처 중립적인 중간 표현(IR)으로 변환합니다. 실행 엔진(Execution Engine)은 IR을 해석하여 크로스 플랫폼 실행과 정밀한 디버깅을 가능하게 합니다.
디스어셈블러: 우리가 생성한 것을 이해하기
왜 기존 도구를 사용하지 않았나?
objdump, llvm-objdump, Capstone 같은 도구들은 범용 디스어셈블리에 훌륭합니다. 하지만 우리 스텐실에는 특별한 요구사항이 있습니다.
- 패치 검증: 패치 오프셋이 명령어 경계와 일치하고 올바른 즉시값(immediate) 필드를 대상으로 하는지 확인해야 합니다.
- 최소한의 풋프린트: 약 110개의 명령어 패턴만 사용하는데 디스어셈블리를 위해 50MB짜리 LLVM 의존성을 추가하는 것은 과도합니다.
- 통합: 우리는 디스어셈블리가 외부 도구 호출이 아닌, 내장된 디버깅 기능으로 통합되기를 원했습니다.
우리의 디스어셈블러는 스텐실에서 사용하는 명령어 하위 집합만을 정확히 지원합니다.
x86-64 디스어셈블러
x86-64의 가변 길이 인코딩은 디스어셈블러 구현을 어렵게 만듭니다. 명령어는 1~15바이트가 될 수 있으며 복잡한 접두사 조합을 가집니다.
// x86-64 디스어셈블러 구조 class X86_64Disassembler { public: auto disassemble(const Stencil& s) const -> DisassemblyResult; auto validate_patches(const Stencil& s) const -> std::vector<std::string>; static auto format(const DisassembledInst& inst) -> std::string; private: // REX 접두사 구조체 struct Rex { bool present; bool w; // 64비트 피연산자 크기 bool r; // ModRM.reg 확장 bool x; // SIB.index 확장 bool b; // ModRM.rm 확장 }; auto decode_one_(const uint8_t* code, size_t len, uint32_t offset) const -> DisassembledInst; static auto parse_rex_(uint8_t byte) -> Rex; auto decode_modrm_mem_(const uint8_t* code, size_t len, const Rex& rex, bool is_64bit) const -> std::pair<std::string, size_t>; };
핵심적인 복잡성은 REX 접두사와 ModR/M 바이트 파싱에 있습니다. REX 접두사(0x40-0x4F)는 레지스터 주소 지정 범위를 확장하여 r8-r15에 접근할 수 있게 합니다. ModR/M 바이트는 주소 지정 모드와 레지스터 피연산자를 모두 인코딩합니다.
auto X86_64Disassembler::parse_rex_(uint8_t byte) -> Rex { Rex rex; rex.present = (byte >= 0x40 && byte <= 0x4f); if (rex.present) { rex.w = (byte & 0x08) != 0; // 64비트 피연산자 rex.r = (byte & 0x04) != 0; // ModRM.reg 확장 rex.x = (byte & 0x02) != 0; // SIB.index 확장 rex.b = (byte & 0x01) != 0; // ModRM.rm 확장 } return rex; }
ARM64 디스어셈블러
ARM64의 고정된 32비트 명령어 인코딩은 디코딩하기 더 단순하지만, 미묘한 부분들이 있습니다. 모든 명령어는 4바이트이며, 명령어 카테고리는 고정된 비트 위치에 의해 결정됩니다.
class AArch64Disassembler { public: auto disassemble(const Stencil& s) const -> DisassemblyResult; auto validate_patches(const Stencil& s) const -> std::vector<std::string>; private: auto decode_one_(uint32_t instr, uint32_t offset) const -> DisassembledInst; // 비트 필드 추출기 static auto rd(uint32_t i) -> uint8_t { return i & 0x1f; } static auto rn(uint32_t i) -> uint8_t { return (i >> 5) & 0x1f; } static auto rm(uint32_t i) -> uint8_t { return (i >> 16) & 0x1f; } static auto rt(uint32_t i) -> uint8_t { return i & 0x1f; } static auto rt2(uint32_t i) -> uint8_t { return (i >> 10) & 0x1f; } // 카테고리별 명령어 디코더 auto decode_dp_reg_(uint32_t instr) const -> DisassembledInst; auto decode_dp_imm_(uint32_t instr) const -> DisassembledInst; auto decode_fp_(uint32_t instr) const -> DisassembledInst; auto decode_ldst_(uint32_t instr) const -> DisassembledInst; auto decode_branch_(uint32_t instr) const -> DisassembledInst; };
ARM64는 레지스터 피연산자를 고정된 5비트 필드(32개 레지스터 지원)에 인코딩합니다. rd, rn, rm 추출기는 표준 위치에서 이 필드들을 가져옵니다.
패치 검증 (Patch Validation)
가장 중요한 기능은 패치 포인트가 유효한지 검증하는 것입니다.
auto X86_64Disassembler::validate_patches(const Stencil& s) const -> std::vector<std::string> { std::vector<std::string> errors; auto result = disassemble(s); for (const auto& patch : s.patches) { bool found = false; for (const auto& inst : result.instructions) { if (patch.offset >= inst.offset && patch.offset < inst.offset + inst.length) { found = true; // 패치 타입이 명령어 타입과 일치하는지 검증 if (patch.type == PatchType::kImmediate64 || patch.type == PatchType::kAddress64) { if (!inst.has_immediate) { errors.push_back(fmt::format( "Patch at offset {} is in '{}' which has no immediate", patch.offset, inst.mnemonic)); } } else if (patch.type == PatchType::kRelativeJump) { if (inst.mnemonic != "jmp" && inst.mnemonic != "jnz" && inst.mnemonic != "jz") { errors.push_back(fmt::format( "Patch at offset {} (type=RelativeJump) is in '{}' " "which is not a jump", patch.offset, inst.mnemonic)); } } break; } } if (!found && patch.offset < s.code.size()) { errors.push_back(fmt::format( "Patch at offset {} does not match any instruction", patch.offset)); } } return errors; }
이 검증은 미묘한 버그를 잡아냅니다. 예를 들어, 패치 오프셋이 1바이트만 어긋나도 멀티 바이트 즉시값의 중간에 적용되어 명령어를 손상시킬 수 있습니다.
CPU 에뮬레이터: 하드웨어 없이 실행하기
왜 에뮬레이션을 하는가?
네이티브 실행은 빠르지만 유연하지 않습니다.
- 크로스 플랫폼 테스트: x86-64 개발 환경에서 ARM64 스텐실 테스트 (또는 그 반대)
- 결정론적 디버깅: 명령어 단위로 실행 단계별 추적
- 격리: 시스템 상태에 영향을 주지 않고 스텐실 실행
- 계측 (Instrumentation): 명령어 수 계산, 레지스터 변경 추적, 핫 경로 프로파일링
에뮬레이터는 관찰 가능성 및 이식성을 위해 실행 속도를 희생합니다.
아키텍처 중립적 IR
에뮬레이터의 핵심은 아키텍처의 차이점을 추상화한 중간 표현(IR)입니다. x86-64와 ARM64 모두 동일한 IR 연산코드로 변환됩니다.
enum class EmulatorOp : uint8_t { // 정수 산술 kAddInt64, // dst = src1 + src2 kSubInt64, // dst = src1 - src2 kMulInt64, // dst = src1 * src2 kDivInt64, // dst = src1 / src2 (부호 있음) kModInt64, // dst = src1 % src2 (부호 있음) kNegInt64, // dst = -src1 kMsubInt64, // dst = src2 - src1 * imm_reg (곱셈-뺄셈) // 비트 연산 kAndInt64, // dst = src1 & src2 kOrInt64, // dst = src1 | src2 kXorInt64, // dst = src1 ^ src2 kNotInt64, // dst = ~src1 kMovkInt64, // dst = (dst & ~mask) | (imm << shift) - MOVK 의미론 // 부동 소수점 산술 kAddDouble, // dst = src1 + src2 kSubDouble, // dst = src1 - src2 kMulDouble, // dst = src1 * src2 kDivDouble, // dst = src1 / src2 kNegDouble, // dst = -src1 kXorDouble, // dst = bitwise_xor(src1, src2) - XORPD용 // 비교 (조건 플래그 설정) kCmpInt64, // flags = compare(src1, src2) kCmpDouble, // flags = compare(src1, src2) kTestInt64, // flags = test(src1 & src2) // 조건부 설정 (플래그 읽기, 0 또는 1 쓰기) kSetEq, kSetNe, kSetLt, kSetLe, kSetGt, kSetGe, kSetAbove, kSetAboveEq, kSetBelow, kSetBelowEq, kSetParity, kSetNoParity, // 데이터 이동 kMovInt64, kMovDouble, kCselInt64, kLoadImm64, kLoadImmDouble, kZeroExtend8To64, kZeroExtend16To64, kZeroExtend32To64, kSignExtend8To64, kSignExtend16To64, kSignExtend32To64, // 타입 변환 kInt64ToDouble, // f[dst] = (double)r[src1] kDoubleToInt64, // r[dst] = (int64_t)f[src1] // 메모리 연산 kLoadMem8, kLoadMem16, kLoadMem32, kLoadMem64, kLoadMemDouble, kStoreMem8, kStoreMem16, kStoreMem32, kStoreMem64, kStoreMemDouble, // 스택 연산 kPush64, kPop64, kAllocStack, kDeallocStack, // 제어 흐름 kBranch, // pc = target (무조건) kBranchIfZero, // if (src1 == 0) pc = target kBranchIfNotZero, // if (src1 != 0) pc = target kBranchIfEq, kBranchIfNe, kBranchIfLt, kBranchIfLe, kBranchIfGt, kBranchIfGe, // 함수 호출 kCallHelper, // ID로 헬퍼 함수 호출 kReturn, // 스텐실에서 반환 kNop, kBreakpoint, };
각 IR 명령어는 간결한 구조체입니다.
struct EmulatorInst { EmulatorOp op; // 연산 타입 uint8_t dst; // 대상 레지스터 (0-31) uint8_t src1; // 첫 번째 소스 레지스터 uint8_t src2; // 두 번째 소스 레지스터 union { int64_t imm_i64; // 64비트 정수 즉시값 double imm_f64; // 64비트 double 즉시값 uint32_t target; // 분기 대상 (명령어 인덱스) uint32_t helper_id; // 헬퍼 함수 식별자 int32_t mem_offset; // 메모리 접근 오프셋 }; uint32_t source_offset; // 원본 기계어 코드의 바이트 오프셋 };
변환 파이프라인 (Translation Pipeline)
변환기는 아키텍처별 기계어 코드를 중립적인 IR로 변환합니다.
x86-64 변환기는 가변 길이 인코딩의 복잡성을 처리합니다.
class X86_64Translator { public: auto translate(const Stencil& stencil) -> EmulatorProgram; auto translate(std::span<const uint8_t> code, JITType result_type, StencilOp stencil_op) -> EmulatorProgram; private: struct Rex { bool present = false; bool w = false; // 64비트 피연산자 크기 bool r = false; // ModRM.reg 확장 bool x = false; // SIB.index 확장 bool b = false; // ModRM.rm 확장 }; struct ModRM { uint8_t mod; // 주소 지정 모드 (0-3) uint8_t reg; // 레지스터 피연산자 / opcode 확장 uint8_t rm; // 레지스터/메모리 피연산자 }; // x86-64 레지스터 인코딩에서 에뮬레이터 레지스터 인덱스 가져오기 static auto gpr_to_emu_reg_(uint8_t reg, bool rex_ext) -> uint8_t; // 특정 명령어 타입 변환 void emit_add_(const DecodedInst& inst, const uint8_t* code); void emit_mov_(const DecodedInst& inst, const uint8_t* code); void emit_cmp_(const DecodedInst& inst, const uint8_t* code); void emit_jcc_(uint8_t cc, const DecodedInst& inst, const uint8_t* code); // SSE 명령어 void emit_sse_arith_(uint8_t op, const DecodedInst& inst); void emit_cvtsi2sd_(const DecodedInst& inst, const uint8_t* code); // 첫 번째 패스 후 분기 대상 해결 void resolve_branch_targets_(); std::unordered_map<uint32_t, uint32_t> offset_to_index_; std::vector<std::pair<uint32_t, uint32_t>> pending_branches_; };
ARM64 변환기는 규칙적인 명령어 형식을 활용합니다.
class AArch64Translator { public: auto translate(const Stencil& stencil) -> EmulatorProgram; private: // 비트 필드 추출기 static auto rd_(uint32_t instr) -> uint8_t { return instr & 0x1f; } static auto rn_(uint32_t instr) -> uint8_t { return (instr >> 5) & 0x1f; } static auto rm_(uint32_t instr) -> uint8_t { return (instr >> 16) & 0x1f; } static auto sf_(uint32_t instr) -> bool { return (instr >> 31) & 1; } // 부호 확장을 포함한 즉시값 추출기 static auto imm19_(uint32_t instr) -> int32_t { auto imm = static_cast<int32_t>((instr >> 5) & 0x7ffff); if (imm & 0x40000) { imm |= static_cast<int32_t>(0xfff80000); // 부호 확장 } return imm * 4; // 분기 오프셋을 위해 4배 } // 명령어 카테고리 변환기 void translate_dp_reg_(uint32_t instr); // 데이터 처리 (레지스터) void translate_dp_imm_(uint32_t instr); // 데이터 처리 (즉시값) void translate_fp_(uint32_t instr); // 부동 소수점 void translate_ldst_(uint32_t instr); // 로드/스토어 void translate_branch_(uint32_t instr); // 분기 };
에뮬레이터 상태: 레지스터, 플래그, 메모리
에뮬레이터는 완전한 CPU 상태를 유지합니다.
// 정수 및 부동 소수점 레지스터를 모두 보유하는 레지스터 파일 struct RegisterFile { std::array<int64_t, 32> r; // 정수 레지스터 std::array<double, 32> f; // 부동 소수점 레지스터 uint64_t sp; // 스택 포인터 uint64_t pc; // 프로그램 카운터 (명령어 인덱스) // ARM64 XZR (Zero Register) 처리 auto get_int(uint8_t reg, bool is_arm64 = false) const -> int64_t { if (is_arm64 && reg == 31) return 0; // XZR은 항상 0으로 읽힘 return r[reg]; } void set_int(uint8_t reg, int64_t value, bool is_arm64 = false) { if (is_arm64 && reg == 31) return; // XZR에 대한 쓰기는 무시됨 r[reg] = value; } }; // 조건 플래그 (NZCV - x86-64 및 AArch64 호환) struct ConditionFlags { bool n; // Negative: 결과가 음수 bool z; // Zero: 결과가 0 bool c; // Carry: 부호 없는 오버플로우/빌림(borrow) bool v; // oVerflow: 부호 있는 오버플로우 // 정수 뺄셈(CMP) 후 플래그 업데이트 void update_from_sub(int64_t src1, int64_t src2) { auto result = src1 - src2; n = (result < 0); z = (result == 0); // 뺄셈 캐리 (unsigned src1 >= src2) c = (static_cast<uint64_t>(src1) >= static_cast<uint64_t>(src2)); // 오버플로우: 부호가 다르고 결과 부호가 src1과 다를 때 v = (((src1 ^ src2) & (src1 ^ result)) < 0); } // IEEE 754 부동 소수점 비교 플래그 void update_from_fcmp(double src1, double src2) { if (std::isnan(src1) || std::isnan(src2)) { n = false; z = false; c = true; v = true; // 순서 없음(Unordered) } else if (src1 == src2) { n = false; z = true; c = true; v = false; } else if (src1 < src2) { n = true; z = false; c = false; v = false; } else { n = false; z = false; c = true; v = false; } } // 조건 평가 auto is_equal() const -> bool { return z; } auto is_less_than() const -> bool { return n != v; } auto is_greater_than() const -> bool { return !z && (n == v); } auto is_above() const -> bool { return c && !z; } // Unsigned };
메모리는 스택과 선택적인 상수 영역(Literal Pool)으로 에뮬레이션됩니다.
class EmulatorMemory { public: explicit EmulatorMemory(size_t stack_size = 64 * 1024); // 스택 연산 void push(int64_t value, uint64_t& sp); auto pop(uint64_t& sp) -> int64_t; void alloc(size_t bytes, uint64_t& sp); void dealloc(size_t bytes, uint64_t& sp); // 메모리 접근 auto load64(uint64_t addr) const -> int64_t; void store64(uint64_t addr, int64_t value); auto load_double(uint64_t addr) const -> double; void store_double(uint64_t addr, double value); private: std::vector<uint8_t> stack_; std::vector<uint8_t> literal_pool_; uint64_t stack_base_; };
실행 엔진 (The Execution Engine)
실행 엔진은 직관적인 인터프리터입니다.
class ExecutionEngine { public: auto execute(const EmulatorProgram& program, EmulatorState& state) -> ExecutionResult; void execute_instruction(const EmulatorInst& inst, EmulatorState& state); private: // 정수 산술 void exec_add_int64_(const EmulatorInst& inst, EmulatorState& state) { auto a = state.regs().get_int(inst.src1, state.is_arm64()); auto b = state.regs().get_int(inst.src2, state.is_arm64()); state.regs().set_int(inst.dst, a + b, state.is_arm64()); } void exec_div_int64_(const EmulatorInst& inst, EmulatorState& state) { auto dividend = state.regs().get_int(inst.src1, state.is_arm64()); auto divisor = state.regs().get_int(inst.src2, state.is_arm64()); if (divisor == 0) { throw error::division_by_zero(state.regs().pc, create_snapshot_(state)); } // INT64_MIN / -1 오버플로우 케이스 처리 if (dividend == std::numeric_limits<int64_t>::min() && divisor == -1) { state.regs().set_int(inst.dst, std::numeric_limits<int64_t>::min(), state.is_arm64()); } else { state.regs().set_int(inst.dst, dividend / divisor, state.is_arm64()); } } // 비교 및 플래그 설정 void exec_cmp_int64_(const EmulatorInst& inst, EmulatorState& state) { auto a = state.regs().get_int(inst.src1, state.is_arm64()); auto b = state.regs().get_int(inst.src2, state.is_arm64()); state.flags().update_from_sub(a, b); } // 조건부 분기 void exec_branch_if_lt_(const EmulatorInst& inst, EmulatorState& state) { if (state.flags().is_less_than()) { state.regs().pc = inst.target; ++stats_.branches_taken; } else { ++state.regs().pc; ++stats_.branches_not_taken; } } };
메인 실행 루프입니다.
auto ExecutionEngine::execute(const EmulatorProgram& program, EmulatorState& state) -> ExecutionResult { state.regs().pc = 0; state.set_returned(false); while (state.regs().pc < program.instructions.size() && !state.has_returned()) { // 명령어 제한 확인 if (config_.max_instructions > 0 && stats_.instructions_executed >= config_.max_instructions) { throw error::max_instructions_reached(config_.max_instructions, state.regs().pc); } const auto& inst = program.instructions[state.regs().pc]; execute_instruction(inst, state); ++stats_.instructions_executed; // PC 증가 (분기가 수정하지 않은 경우) if (!is_control_flow(inst.op)) { ++state.regs().pc; } } return ExecutionResult::kSuccess; }
디버거: JIT 코드 단계별 실행
디버거는 에뮬레이터 실행에 대한 정밀한 제어를 제공합니다.
class Debugger { public: // 중단점(Breakpoint) 관리 auto add_breakpoint(uint32_t instruction_index) -> uint32_t; void remove_breakpoint(uint32_t instruction_index); auto has_breakpoint(uint32_t instruction_index) const -> bool; // 실행 제어 void attach(ExecutionEngine* engine, const EmulatorProgram* program, EmulatorState* state); auto run() -> DebugState; // 중단점까지 실행 auto step() -> DebugState; // 한 단계 실행 (Single step) auto continue_execution() -> DebugState; // 상태 검사 auto get_int_register(uint8_t reg) const -> std::optional<int64_t>; auto get_fp_register(uint8_t reg) const -> std::optional<double>; auto get_flags() const -> std::optional<ConditionFlags>; auto get_stack_pointer() const -> std::optional<uint64_t>; // 실행 히스토리 auto history() const -> const std::deque<ExecutionRecord>&; void set_history_enabled(bool enabled); private: ExecutionEngine* engine_; const EmulatorProgram* program_; EmulatorState* emulator_state_; std::vector<Breakpoint> breakpoints_; std::deque<ExecutionRecord> history_; };
실행 히스토리는 각 명령어에 대한 레지스터 변경 사항을 기록합니다.
struct ExecutionRecord { uint32_t instruction_index; EmulatorOp op; uint32_t source_offset; bool is_fp; // 정수 레지스터 값 int64_t dst_before, src1_before, src2_before, dst_after; // FP 레지스터 값 double fp_dst_before, fp_src1_before, fp_src2_before, fp_dst_after; };
이는 강력한 디버깅 워크플로우를 가능하게 합니다. 중단점을 설정하고, 그곳까지 실행한 다음, 레지스터 값이 변하는 것을 지켜보며 단계별로 실행할 수 있습니다.
헬퍼 디스패처: 데이터베이스 API 연결
JIT 스텐실은 문서(Document 혹은 Row) 필드에 접근해야 하지만, 에뮬레이션 중에 실제 포인터 역참조를 실행할 수는 없습니다. 헬퍼 디스패처(HelperDispatcher)는 포인터 역참조 없이 데이터베이스의 실제 데이터에 접근할 수 있는 API 함수들을 제공합니다.
enum class HelperId : uint32_t { // 필드 접근 kGetInt64Field, kGetDoubleField, kGetBoolField, kGetStringField, kIsFieldNull, // 캐시된 필드 접근 kGetInt64FieldCached, kGetDoubleFieldCached, // 문자열 연산 kStringEq, kStringNe, kStringLt, kStringContains, kStringStartsWith, kStringEndsWith, // 배열 연산 kArrayGetInt64, kArrayContainsInt64, kGetArrayLength, }; class HelperDispatcher { public: void dispatch(HelperId id, EmulatorState& state); private: void dispatch_get_int64_field_(EmulatorState& state) { // 호출 규약에서 문서 포인터와 필드 이름 추출 auto doc_ptr = reinterpret_cast<const Document*>( state.is_arm64() ? state.regs().r[arm64_reg::kArg0] : state.regs().r[x86_reg::kArg0]); auto field_name_ptr = reinterpret_cast<const char*>( state.is_arm64() ? state.regs().r[arm64_reg::kArg1] : state.regs().r[x86_reg::kArg1]); // 실제 문서 API 호출 auto result = doc_ptr->get_int64(field_name_ptr); // 결과를 반환 레지스터에 저장 if (state.is_arm64()) { state.regs().r[arm64_reg::kReturnInt] = result; } else { state.regs().r[x86_reg::kReturnInt] = result; } } };
이를 통해 헬퍼 함수를 호출하는 스텐실이 에뮬레이션 중에도 실제 문서 데이터에 접근하며 올바르게 실행될 수 있습니다.
고수준 인터페이스
StencilEmulator는 모든 것을 하나로 통합합니다.
class StencilEmulator { public: explicit StencilEmulator(const EmulatorConfig& config = {}); // 문서를 사용하여 스텐실 실행 auto execute(const Stencil& stencil, const Document& doc) -> TypedValue; // 스텐실 실행 (순수 산술) auto execute(const Stencil& stencil) -> TypedValue; // 디버깅 인터페이스 auto debugger() -> Debugger& { return debugger_; } auto prepare(const Stencil& stencil) -> const EmulatorProgram&; auto step() -> bool; auto run_to_completion() -> TypedValue; // 아키텍처 감지 static auto detect_architecture(const Stencil& stencil) -> ArchType; static auto is_x86_64(const Stencil& stencil) -> bool; static auto is_aarch64(const Stencil& stencil) -> bool; // 변환 캐시 void clear_cache(); auto cache_hits() const -> uint64_t; auto cache_misses() const -> uint64_t; private: auto translate_(const Stencil& stencil) -> const EmulatorProgram&; EmulatorConfig config_; ExecutionEngine engine_; EmulatorState state_; Debugger debugger_; HelperDispatcher dispatcher_; std::unordered_map<uint64_t, EmulatorProgram> cache_; };
아키텍처 감지는 처음 몇 바이트를 확인하여 명령어 집합을 식별합니다.
auto StencilEmulator::detect_architecture(const Stencil& stencil) -> ArchType { if (stencil.code.size() < 4) return ArchType::kUnknown; // ARM64: 고정 32비트 명령어, 종종 특정 패턴으로 시작 // x86-64: 가변 길이, 일반적인 패턴은 REX 접두사(0x40-0x4F) 포함 // 휴리스틱: ARM64 명령어는 구별되는 비트 패턴을 가짐 uint32_t first_word = stencil.code[0] | (stencil.code[1] << 8) | (stencil.code[2] << 16) | (stencil.code[3] << 24); // 일반적인 ARM64 패턴 확인 if ((first_word & 0x9F000000) == 0x91000000 // ADD immediate || (first_word & 0xFF000000) == 0xD6000000 // BR/BLR/RET || (first_word & 0x7F800000) == 0x2A000000) { // ORR shifted register return ArchType::kAArch64; } return ArchType::kX86_64; }
사용 예제
다음은 디버깅을 위해 에뮬레이터를 사용하는 방법입니다.
auto stencil = get_add_int64_stencil(); auto emulator = StencilEmulator {}; // 디버깅 활성화 auto& dbg = emulator.debugger(); dbg.set_history_enabled(true); // 스텐실 준비 (IR로 변환) auto& program = emulator.prepare(stencil); // 명령어 3번에 중단점 설정 dbg.add_breakpoint(3); // 디버거 연결 dbg.attach(&emulator.engine(), &program, &emulator.state()); // 중단점까지 실행 auto result = dbg.run(); if (result == DebugState::kBreakpoint) { // 상태 검사 std::cout << "Stopped at instruction " << dbg.current_instruction() << "\n"; std::cout << "Registers:\n" << dbg.format_registers() << "\n"; // 남은 명령어 단계별 실행 while (dbg.step() != DebugState::kFinished) { std::cout << dbg.format_current_instruction() << "\n"; } } // 실행 히스토리 출력 std::cout << dbg.format_history(20) << "\n";
성능 특성
에뮬레이터는 속도보다는 관찰 가능성을 우선시합니다.
| 지표 | 네이티브 실행 | 에뮬레이터 |
|---|---|---|
| 속도 | ~1 ns/명령어 | ~50-100 ns/명령어 |
| 디버깅 | 외부 도구만 사용 가능 | 내장된 스테핑(Stepping), 중단점 |
| 플랫폼 | 네이티브 아키텍처 전용 | 크로스 플랫폼 |
| 계측 | 샘플링 필요 | 정확한 명령어 수 측정 |
50-100배의 속도 저하는 테스트 및 디버깅 용도로는 감수할 만합니다. JIT 코드 품질 벤치마킹을 위해서는 네이티브 실행을 사용합니다.
결론
JIT 컴파일러를 구축하려면 단순히 코드를 생성하는 것 이상의 작업이 필요합니다. 디스어셈블러는 생성된 코드가 올바른지---패치 포인트가 명령어 경계에 위치하는지, 즉시값이 올바른 위치에 있는지---검증합니다. 에뮬레이터는 크로스 플랫폼 개발, 결정론적 디버깅, 정밀한 성능 분석을 가능하게 합니다.
이러한 도구들은 우리의 JIT 개발 워크플로우를 변화시켰습니다.
- 스텐실 작성자는 코드를 실행하지 않고도 패치를 검증할 수 있습니다.
- 디버거는 컴파일된 표현식을 명령어 단위로 단계별 실행할 수 있습니다.
- CI 시스템은 어떤 아키텍처에서든 전체 테스트 스위트를 실행할 수 있습니다.
- 최적화가 필요할 때는 샘플링 오버헤드 없이 정확한 명령어 수를 얻을 수 있습니다.
툴체인에 대한 투자는 몇 배의 가치로 돌아옵니다. JIT 컴파일은 본질적으로 오류가 발생하기 쉽습니다. 보이지 않는 코드, 플랫폼별 특성, 미묘한 인코딩 버그 등이 그 원인입니다. 좋은 도구는 보이지 않는 것을 보이게 만듭니다.
참고 문헌
- Intel 64 and IA-32 Architectures Software Developer's Manual
- ARM Architecture Reference Manual for A-profile architecture
- Xu, H., & Kjolstad, F. (2021). Copy-and-Patch Compilation: A fast compilation algorithm for high-level languages and bytecode. OOPSLA.
- QEMU: A Fast and Portable Dynamic Translator. Bellard, F. (2005). USENIX Annual Technical Conference.