Copy-and-Patch JIT: 마이크로초 단위 컴파일로 네이티브 코드 성능 달성하기

Posted on January 17, 2026
Copy-and-Patch JIT: 마이크로초 단위 컴파일로 네이티브 코드 성능 달성하기

Copy-and-Patch JIT: 마이크로초 단위 컴파일로 네이티브 코드 성능 달성하기

Cognica 데이터베이스 엔진이 JIT 컴파일의 지연 시간 장벽을 허무는 방법

SQL 쿼리를 실행할 때 데이터베이스 엔진은 근본적인 선택에 직면합니다. 인터프리터(Interpretation) 방식은 즉시 시작되지만 속도가 느리고, 컴파일(Compilation) 방식은 빠른 코드를 생성하지만 시간이 걸립니다. 밀리초(ms) 단위로 완료되는 쿼리를 위해 LLVM 최적화에 수백 밀리초를 소비하는 것은 마치 출퇴근 시간보다 자동차 예열을 더 오래 하는 것과 같습니다.

이 글에서는 우리가 Cognica 데이터베이스 엔진에 구현한 Copy-and-Patch(복사-붙여넣기) JIT 컴파일 기술을 살펴봅니다. 스탠포드 대학의 Haoran Xu와 Fredrik Kjolstad가 발표한 이 방식은 JIT 컴파일이란 무엇인가에 대한 근본적인 재해석을 통해, 킬로바이트(KB)당 1밀리초 미만의 컴파일 시간을 유지하면서 인터프리터 대비 2-10배의 속도 향상을 달성할 수 있는 기반을 제공했습니다.

JIT 컴파일의 딜레마

LLVM 기반과 같은 전통적인 JIT 컴파일러는 명령어 선택(instruction selection), 레지스터 할당(register allocation), 죽은 코드 제거(dead code elimination), 루프 풀기(loop unrolling) 등 정교한 최적화를 수행합니다. 이러한 변환은 미리 컴파일된(AOT) C++ 코드 성능의 몇 퍼센트 이내에 근접하는 우수한 품질의 코드를 생성하지만, 이를 위해 큰 비용이 따릅니다.

컴파일 시간을 살펴보겠습니다:

구현 방식컴파일 시간코드 품질최적 적용 대상 (Sweet Spot)
인터프리터 (Interpretation)0기준점 (Baseline)모든 쿼리
Copy-and-Patch~1ms/KB최적의 80-90%핫 루프 (Hot loops)
메서드 JIT (Method JIT)~10ms/KB최적의 90-95%웜 메서드 (Warm methods)
LLVM JIT~100ms+/KB최적 (Optimal)장기 실행 작업

테이블을 스캔하고 집계를 수행하는 데 5밀리초가 걸리는 데이터베이스 쿼리의 경우, LLVM 컴파일에 200밀리초를 쓰는 것은 터무니없는 일입니다. 컴파일 오버헤드를 만회하려면 해당 쿼리를 40번이나 실행해야 합니다. 그러나 JIT 컴파일 없이는 표현식 평가나 집계와 같은 연산 집약적인 작업이 병목 구간이 됩니다.

Copy-and-Patch는 마이크로초 단위로 측정되는 컴파일 속도를 달성하면서도, 각종 고급 최적화 알고리즘이 적용된 컴파일러 성능의 10-20% 이내에 들어오는 코드 품질을 제공함으로써 이러한 트레이드오프를 깨뜨립니다.

핵심 통찰: 대부분의 명령어는 '템플릿'이다

Copy-and-Patch의 배경이 되는 핵심 관찰은 놀라울 정도로 간단합니다: 대부분의 바이트코드 명령어는 작고 예측 가능한 기계어 패턴 집합에 매핑됩니다.

Cognica 가상 머신(VM)의 정수 덧셈 명령어를 예로 들어보겠습니다:

ADD_I64 R3, R1, R2 ; R3 = R1 + R2

이에 해당하는 x86-64 기계어 코드는 항상 다음 패턴을 따릅니다:

mov rax, [rdi + R1_OFFSET] ; VMContext에서 R1 로드 add rax, [rdi + R2_OFFSET] ; R2 더하기 mov [rdi + R3_OFFSET], rax ; R3에 저장

이 명령어의 인스턴스마다 바뀌는 유일한 값은 레지스터 오프셋뿐입니다. 연산 코드(opcode) 선택, 주소 지정 방식, 명령어 인코딩은 항상 동일합니다.

전통적인 JIT 컴파일러는 ADD 명령어를 컴파일할 때마다 이 패턴을 매번 다시 찾아냅니다. 명령어 선택 알고리즘을 실행하고, 레지스터 할당 제약 조건을 고려한 뒤, 동일한 패턴의 코드를 생성합니다. Copy-and-Patch는 이러한 패턴을 스텐실(Stencils)—지정된 패치 지점(patch points)이 있는 기계어 템플릿—로 미리 컴파일하여 중복 작업을 제거합니다.

스텐실 (Stencils): 미리 컴파일된 코드 템플릿

스텐실은 JIT 시점에 채워질 자리 표시자(placeholder) 값이 있는, 미리 컴파일된 기계어 코드일 뿐입니다.

struct Stencil { const uint8_t* code; // 미리 컴파일된 기계어 코드를 가리키는 포인터 size_t code_size; // 기계어 코드 크기 std::vector<PatchSite> patches; // 런타임 패치가 필요한 위치들 size_t alignment; // 필요한 정렬 (예: 8 또는 16 바이트) const char* name; // 디버그 이름 (예: "add_i64") Opcode opcode; // 해당하는 VM 연산 코드 };

각 패치 지점(PatchSite)은 수정해야 할 내용을 정확히 설명합니다:

struct PatchSite { uint32_t offset; // 스텐실 내의 바이트 오프셋 PatchType type; // 패치할 값의 종류 uint8_t size; // 패치 크기: 1, 2, 4, 또는 8 바이트 int8_t operand_index; // 어떤 명령어 피연산자인지 (-1은 특수 목적) };

패치 타입은 스텐실이 필요로 할 수 있는 모든 종류의 런타임 값을 나열합니다:

enum class PatchType : uint8_t { kRegisterOffset, // VMContext::registers_ 로부터의 오프셋 kImmediate32, // 32비트 즉시 상수 kImmediate64, // 64비트 즉시 상수 kRelativeJump, // PC 상대 점프 오프셋 kAbsoluteAddress, // 호출을 위한 절대 주소 kConstantPoolPtr, // 상수 풀 포인터 kCallbackPtr, // 외부 함수 포인터 };

Cognica는 대상 아키텍처(x86-64 및 ARM64)당 약 110개의 스텐실을 유지 관리하며, 산술 연산, 비교, 제어 흐름, 메모리 접근, 타입 연산, 그리고 함수 프롤로그 및 에필로그와 같은 특수 케이스를 포함합니다.

컴파일 알고리즘

스텐실이 준비되면, JIT 컴파일 과정은 매우 간단해집니다:

function compile(bytecode_module): output = new CodeBuffer() emit_prologue(output) for each instruction in bytecode_module: stencil = lookup_stencil(instruction.opcode) current_offset = output.size() // 1단계: 스텐실의 기계어 코드 복사 output.append(stencil.code, stencil.code_size) // 2단계: 나중에 해결할 패치 기록 for each patch in stencil.patches: pending_patches.add(current_offset + patch.offset, patch, instruction) emit_epilogue(output) // 3단계: 모든 패치 적용 for each (offset, patch, instruction) in pending_patches: value = compute_patch_value(patch, instruction) output.patch(offset, value, patch.size) return finalize(output)

여기서 중요한 점은 무엇을 하지 않는가입니다. 명령어 선택 과정이 없습니다(스텐실에 이미 올바른 명령어가 들어있음). 레지스터 할당 과정이 없습니다(스텐실에 이미 레지스터 사용이 인코딩되어 있음). 코드 생성(code emission) 로직도 없습니다(단지 바이트를 복사할 뿐). 전체 컴파일 과정은 메모리 복사와 패치 값을 계산하기 위한 간단한 산술 연산으로 축소됩니다.

패치 값 계산

패치 값을 계산하는 것은 단순한 산술입니다. 레지스터 오프셋의 경우:

auto compute_register_offset(uint8_t reg) -> uint32_t { // VMContext는 레지스터를 고정된 오프셋에 저장함 // 각 VMValue는 24바이트 (값 + 타입 태그 + 플래그) constexpr uint32_t kRegistersOffset = offsetof(VMContext, registers_); constexpr uint32_t kVMValueSize = 24; return kRegistersOffset + reg * kVMValueSize; }

x86-64에서의 상대 점프(Relative jumps)의 경우:

auto compute_relative_jump(uint32_t from_offset, uint32_t to_offset) -> int32_t { // x86-64 상대 점프는 명령어의 끝에서부터 계산됨 // JMP rel32는 5바이트 길이 return static_cast<int32_t>(to_offset - (from_offset + 5)); }

이러한 계산은 나노초 단위로 실행되며, 전체 컴파일 시간에서 차지하는 비중은 무시할 수 있는 수준입니다.

계층적 컴파일(Tiered Compilation): 웜업과 최고 성능의 균형

모든 코드가 JIT 컴파일될 필요는 없습니다. 한 번 호출되는 함수는 인터프리터에서 실행되어야 하고, 수백만 번 실행되는 루프는 공격적인 최적화의 대상이 될 자격이 있습니다. Cognica는 관찰된 실행 빈도에 따라 코드의 최적화 단계를 점진적으로 높여가는 3계층 시스템을 구현합니다.

계층이름설명컴파일 시간속도 향상
0인터프리터모든 코드는 여기서 시작. 실행 프로필을 수집하고, 기본 성능을 위해 computed-goto 디스패치를 사용.0기준점
1Baseline JIT (Copy-and-Patch)최적화 패스 없이 바이트코드를 스텐실로 직접 변환.~1ms/KB인터프리터 대비 2-5배
2Optimizing JITIR(중간 표현)을 구축하고 상수 전파, CSE, LICM, 타입 특수화 등의 최적화 패스 적용.~10ms/KBBaseline 대비 2배 추가 향상

코드는 100회 호출되거나 1000번의 루프 반복에 도달하면 0단계에서 1단계로 전환됩니다. 1단계에서 2단계로의 전환은 1000회 호출 또는 10000번의 루프 반복 후에 발생합니다. 이러한 임계값은 컴파일 비용을 회수할 만큼 충분히 자주 실행될 코드에만 추가 CPU 자원을 사용하도록 보장합니다.

계층 전환 임계값은 비용-편익 분석에서 도출됩니다. CcompileC_{\text{compile}}을 일회성 컴파일 비용, TinterpT_{\text{interp}}를 호출당 인터프리터 시간, TjitT_{\text{jit}}를 호출당 JIT 실행 시간, NN을 남은 예상 호출 횟수라고 할 때, 컴파일이 이득이 되는 시점은 다음과 같습니다:

(TinterpTjit)N>Ccompile(T_{\text{interp}} - T_{\text{jit}}) \cdot N > C_{\text{compile}}

NN에 대해 정리하면:

N>CcompileTinterpTjitN > \frac{C_{\text{compile}}}{T_{\text{interp}} - T_{\text{jit}}}

일반적인 값(Ccompile=1msC_{\text{compile}} = 1\text{ms}, Tinterp=12μsT_{\text{interp}} = 12\mu\text{s}, Tjit=4μsT_{\text{jit}} = 4\mu\text{s})을 대입하면 손익분기점은 약 125회 호출입니다. 기본 임계값 100회는 다소 공격적인 설정인데, 이는 100번 호출된 함수는 앞으로 훨씬 더 많이 호출될 경향이 있다는 관찰에 근거합니다.

SQL 쿼리를 가속하는 과정

이제 Copy-and-Patch JIT가 구체적으로 어떻게 SQL 쿼리 실행을 가속하는지 살펴보겠습니다. 성능 향상은 몇 가지 상호 보완적인 메커니즘에서 비롯됩니다.

표현식 평가 (Expression Evaluation)

복잡한 WHERE 절이 있는 쿼리를 생각해 봅시다:

SELECT * FROM orders WHERE total_amount > 1000 AND EXTRACT(YEAR FROM order_date) = 2026 AND customer_id IN (SELECT id FROM premium_customers);

total_amount > 1000 AND EXTRACT(YEAR FROM order_date) = 2026 표현식은 다음과 같은 바이트코드로 컴파일됩니다:

LOAD_FIELD R1, row, "total_amount" LOAD_CONST R2, 1000 CMP_GT_I64 R3, R1, R2 JZ skip_second_condition, R3 LOAD_FIELD R4, row, "order_date" CALL R5, extract_year, R4 LOAD_CONST R6, 2026 CMP_EQ_I64 R7, R5, R6 AND R3, R3, R7 skip_second_condition: ; R3 contains the final boolean result

인터프리터에서는 각 명령어마다 메모리에서 연산 코드를 가져오고, 핸들러로 디스패치(간접 점프 또는 스위치)하고, 피연산자를 디코딩하고, 연산을 수행한 뒤, 명령어 포인터를 재설정하는 과정이 필요합니다. 간단한 연산의 경우 이러한 오버헤드가 절대적 비중을 차지하게 됩니다. 1-2 CPU 사이클이면 실행되는 비교 연산이 15-20 사이클 이상이 소요되는 현상이 발생하는 것입니다.

JIT 컴파일된 버전은 모든 디스패치 오버헤드를 제거합니다. 스텐실들이 연속적인 기계어 배열로 연결됩니다:

; LOAD_FIELD R1, row, "total_amount" mov rax, [rsi + 0x40] ; 오프셋 0x40에서 필드 로드 mov [rdi + 0x20], rax ; R1에 저장 ; LOAD_CONST R2, 1000 mov QWORD PTR [rdi + 0x38], 1000 ; R2에 상수 저장 ; CMP_GT_I64 R3, R1, R2 mov rax, [rdi + 0x20] ; R1 로드 cmp rax, [rdi + 0x38] ; R2와 비교 setg al ; 더 크면 AL 설정 movzx eax, al mov [rdi + 0x50], rax ; R3에 저장 ; JZ skip_second_condition, R3 mov rax, [rdi + 0x50] test rax, rax jz skip_second_condition ; ... 나머지 명령어들 ...

수백만 개의 행을 처리하는 테이블 스캔의 경우, 행당 오버헤드가 3-5배 감소하면 이는 곧장 전체 실행 시간 단축으로 이어집니다.

집계 함수 (Aggregate Functions)

SUM, AVG, COUNT, MIN, MAX와 같은 집계 함수는 JIT 컴파일의 이점을 크게 누리는 무거운 루프를 실행합니다:

SELECT region, SUM(amount) as total, AVG(amount) as average, COUNT(*) as count FROM sales GROUP BY region;

집계 루프는 값을 누적하는 바이트코드로 컴파일됩니다:

aggregate_loop: LOAD_FIELD R1, row, "amount" ADD_F64 R_sum, R_sum, R1 ; 합계 누적 ADD_I64 R_count, R_count, 1 ; 카운트 증가 NEXT_ROW row JNZ aggregate_loop, row DIV_F64 R_avg, R_sum, R_count ; 평균 계산

인터프리터는 이 루프를 반복당 약 180 사이클로 실행합니다. Baseline JIT는 이를 약 40 사이클로 줄입니다. 루프 불변 코드 이동(LICM)과 레지스터 피닝(register pinning)이 적용된 Optimizing JIT는 반복당 약 16 사이클을 달성하여 무려 11배의 성능 향상을 이룹니다.

조인 처리 (Join Processing)

해시 조인과 중첩 루프 조인은 성능에 치명적인 내부 루프를 포함합니다:

SELECT o.*, c.name FROM orders o JOIN customers c ON o.customer_id = c.id WHERE c.country = 'USA';

해시 조인에서의 해시 프로브(probe) 루프:

probe_loop: LOAD_FIELD R1, probe_row, "customer_id" HASH R2, R1 AND R2, R2, hash_mask ; R2 = 버킷 인덱스 LOAD R3, hash_table, R2 ; R3 = 버킷 헤드 bucket_scan: JZ no_match, R3 LOAD_FIELD R4, R3, "id" CMP_EQ R5, R1, R4 JZ next_entry, R5 ; 일치 발견 - 조인된 행 생성 및 반환 CALL emit_joined_row, probe_row, R3 next_entry: LOAD_FIELD R3, R3, "next" ; 다음 행으로 넘어가기 JMP bucket_scan no_match: NEXT_ROW probe_row JNZ probe_loop, probe_row

JIT 컴파일은 해싱과 버킷 체인 순회 모두를 가속합니다. 해시 분포가 좋은(체인이 짧은) 해시 조인의 경우 속도가 2-3배 향상됩니다. 해시 충돌로 인해 긴 체인 순회가 필요한 조인의 경우, 컴파일된 코드의 더 나은 분기 예측 덕분에 속도가 4-5배까지 빨라질 수 있습니다.

저장 프로시저 및 PL/pgSQL

가장 극적인 성능 향상은 절차적 코드(procedural code)에서 나타납니다. 데이터를 처리하는 PL/pgSQL 함수를 고려해 봅시다:

CREATE FUNCTION calculate_bonuses() RETURNS void AS $$ DECLARE emp RECORD; bonus NUMERIC; BEGIN FOR emp IN SELECT * FROM employees WHERE department = 'Sales' LOOP IF emp.sales_total > 100000 THEN bonus := emp.sales_total * 0.05; ELSIF emp.sales_total > 50000 THEN bonus := emp.sales_total * 0.03; ELSE bonus := emp.sales_total * 0.01; END IF; UPDATE employees SET bonus_amount = bonus WHERE id = emp.id; END LOOP; END; $$ LANGUAGE plpgsql;

이 함수는 커서(Cursor) 루프(많은 반복), 조건 분기(분기 예측에 유리함), 산술 연산(순수 계산), 중첩 SQL 문(별도 실행)을 포함합니다. UPDATE 문을 제외한 루프 본문의 모든 내용은 JIT 컴파일의 혜택을 크게 받습니다. 영업 부서 직원이 10,000명일 때, 인터프리터 버전은 1.8초가 걸릴 수 있지만, JIT 컴파일 버전은 160밀리초 만에 완료됩니다.

윈도우 함수 (Window Functions)

윈도우 함수는 윈도우 프레임 전체에 걸쳐 상태를 유지하면서 행을 처리합니다:

SELECT date, amount, SUM(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) as week_total, AVG(amount) OVER (ORDER BY date ROWS BETWEEN 29 PRECEDING AND CURRENT ROW) as month_avg FROM daily_sales;

슬라이딩 윈도우 집계:

window_loop: ; 윈도우에서 오래된 값 제거 LOAD R1, window_start CMP_LT R2, R1, current_row - window_size JZ skip_remove, R2 SUB_F64 R_sum, R_sum, [R1 + amount_offset] SUB_I64 R_count, R_count, 1 ADD R1, R1, row_size STORE window_start, R1 skip_remove: ; 윈도우에 새 값 추가 LOAD_FIELD R3, current_row, "amount" ADD_F64 R_sum, R_sum, R3 ADD_I64 R_count, R_count, 1 ; 윈도우 결과 계산 DIV_F64 R_avg, R_sum, R_count EMIT current_row, R_avg NEXT_ROW current_row JNZ window_loop, current_row

포인터 연산과 누산기 업데이트가 있는 무거운 루프는 Copy-and-Patch가 빛을 발하는 패턴입니다. 윈도우 함수 쿼리는 일반적으로 3-4배의 속도 향상을 보입니다.

레지스터 매핑: VM과 하드웨어의 연결

바이트코드 실행 오버헤드의 한 가지 원인은 (메모리에 있는) VM 레지스터와 (반도체 실리콘에 있는) CPU 레지스터 간의 불일치입니다. Cognica의 JIT는 **레지스터 피닝(register pinning)**을 통해 이 간극을 메웁니다.

x86-64의 경우:

struct X64RegisterMap { // 예약됨 (다른 값을 기록할 수 없음) static constexpr auto kContext = RDI; // 항상 VMContext* static constexpr auto kStackPtr = RSP; // 하드웨어 스택 static constexpr auto kFramePtr = RBP; // 프레임 포인터 // 고정된(Pinned) VM 레지스터 (피호출자 저장, 호출 간 유지됨) static constexpr auto kR0 = RBX; // VM R0는 RBX에 상주 static constexpr auto kR1 = R12; // VM R1은 R12에 상주 static constexpr auto kR2 = R13; // VM R2는 R13에 상주 static constexpr auto kR3 = R14; // VM R3는 R14에 상주 // 스크래치 레지스터 (호출자 저장, 임시) static constexpr auto kScratch[] = {RAX, RCX, RDX, RSI, R8, R9, R10, R11}; };

VM 레지스터 R0-R3는 피호출자 저장(callee-saved) CPU 레지스터에 영구적으로 매핑됩니다. JIT가 이 레지스터들을 사용하는 코드를 컴파일할 때, 메모리 로드나 저장 없이 RBX, R12, R13, R14를 직접 조작하는 명령어를 생성합니다.

ARM64의 넉넉한 피호출자 저장 레지스터 세트는 4개가 아닌 8개의 VM 레지스터를 고정(pinning)할 수 있게 하여, Apple Silicon 및 기타 ARM64 플랫폼에서 메모리 트래픽을 더욱 줄여줍니다.

메모리 관리: 보안 환경에서의 실행 코드

현대 운영체제는 W^X (Write XOR Execute) 정책을 시행합니다. 메모리는 쓰기 가능하거나 실행 가능할 수 있지만, 동시에 둘 다일 수는 없습니다. JIT는 이 제약을 해결해야 합니다:

class CodeRegion { public: static auto allocate(size_t size) -> std::unique_ptr<CodeRegion> { #ifdef __linux__ void* ptr = mmap(nullptr, size, PROT_READ | PROT_WRITE, // 쓰기 가능으로 시작 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); #elif defined(__APPLE__) void* ptr = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_JIT, -1, 0); #endif return std::make_unique<CodeRegion>(ptr, size); } void make_executable() { #ifdef __linux__ mprotect(base_, size_, PROT_READ | PROT_EXEC); #elif defined(__APPLE__) pthread_jit_write_protect_np(true); // 실행 모드로 전환 #endif } };

Apple Silicon은 MAP_JIT 플래그와 쓰기/실행 모드 전환을 위한 pthread_jit_write_protect_np() API라는 특별한 처리가 필요합니다.

코드 캐시는 메모리 사용을 제한하기 위해 LRU(Least Recently Used) 정책을 사용하며(기본값: 64MB), 단편화 관리를 위해 주기적 압축(compaction)과 범프 할당(bump allocation)을 사용합니다.

2단계(Tier 2)에서의 최적화 패스

Optimizing JIT는 단순한 스텐실 연결을 넘어, IR을 구축하고 일반적인 컴파일러 최적화를 적용합니다.

**상수 전파 (Constant Propagation)**는 알려진 상수에 대한 연산을 대체합니다:

Before: After: LOAD_CONST R1, 10 LOAD_CONST R3, 30 LOAD_CONST R2, 20 ADD_I64 R3, R1, R2

**공통 부분식 제거 (CSE, Common Subexpression Elimination)**는 이미 계산된 값을 재사용합니다:

Before: After: MUL_I64 R1, R0, R0 MUL_I64 R1, R0, R0 MUL_I64 R2, R0, R0 ; R2 제거됨 ADD_I64 R3, R1, R2 ADD_I64 R3, R1, R1

**루프 불변 코드 이동 (LICM, Loop-Invariant Code Motion)**은 계산을 루프 밖으로 끌어올립니다:

Before: After: loop: LOAD R1, base_ptr LOAD R1, base_ptr ADD R2, R1, offset ADD R2, R1, offset loop: ; use R2 ; use R2 JMP loop JMP loop

**타입 특수화 (Type Specialization)**는 프로파일링 데이터를 사용하여 타입 검사를 제거합니다:

; Before (다형성 - int, float, string 처리) CALL type_check, R1 CMP result, kInt64 JNE slow_path ; int64 fast path... ; After (단형성 - int64만 관찰됨) ; 타입 검사 제거됨 ; int64 경로 직접 실행

이러한 최적화는 Baseline JIT 대비 2배의 속도 향상을 추가하여, 연산 집약적인 워크로드에서 총 5-10배의 개선을 가져옵니다.

성능 특성

컴파일 시간

컴파일 속도의 이점은 극적입니다:

구현 방식KB당 시간10KB 함수
LLVM -O0~50ms500ms
LLVM -O2~200ms2000ms
Baseline JIT~1ms10ms
Optimized JIT~10ms100ms

Copy-and-Patch는 LLVM보다 50-200배 빠르므로, 한 자릿수 밀리초 단위로 실행되는 쿼리에서도 JIT 컴파일을 실용적으로 만듭니다.

워크로드별 실행 속도 향상

워크로드 유형Baseline JITOptimized JIT
타이트한 산술 루프3-5x5-10x
표현식 평가2-3x3-4x
집계 연산2-3x3-5x
필드 접근 위주1.5-2x2-3x
외부 호출 위주1.0-1.2x1.2-1.5x

구체적인 예시: PL/pgSQL 루프

DO $$ DECLARE i INTEGER := 0; sum INTEGER := 0; BEGIN WHILE i < 1000000 LOOP sum := sum + i; i := i + 1; END LOOP; END $$;
실행 모드반복당 사이클총 시간
인터프리터~180180ms
Baseline JIT~4040ms
Optimized JIT~1616ms

총 속도 향상: 11배 (인터프리터 대비 Optimized JIT).

JIT가 도움이 되는 경우 (그리고 그렇지 않은 경우)

JIT 컴파일은 높은 반복 횟수(수천 번 실행되는 루프가 컴파일 비용을 상환), 연산 집약적 작업(산술, 비교, 타입 변환 등), 레지스터 사용이 많은 코드(VM 레지스터를 많이 사용하여 CPU 레지스터 피닝 혜택을 봄), 예측 가능한 타입(단형성 연산이 공격적인 타입 특수화를 가능하게 함)을 포함하는 워크로드에서 빛을 발합니다.

반면, I/O 바운드 작업(디스크 읽기나 네트워크 지연이 지배적임), 외부 호출 위주(복잡한 C 함수나 UDF에서 시간을 보냄), 짧은 실행 시간(마이크로초 단위로 완료되는 쿼리는 컴파일 시간을 정당화할 수 없음), 콜드 코드 경로(한두 번 실행되는 함수)가 포함된 경우에는 JIT의 이점이 미미합니다.

결론

Copy-and-Patch JIT 컴파일은 데이터베이스 쿼리 실행의 패러다임 전환을 나타냅니다. 대부분의 바이트코드 명령어가 고정된 기계어 패턴에 매핑된다는 점을 인식함으로써, 우리는 전통적인 JIT 컴파일의 값비싼 부분인 명령어 선택, 레지스터 할당, 코드 방출을 핵심 경로에서 제거했습니다.

Cognica가 얻은 결과는 강력합니다. 밀리초가 아닌 마이크로초 단위로 측정되는 컴파일 속도는 데이터베이스 워크로드의 대다수를 차지하는 짧은 실행 쿼리에도 JIT를 적용할 수 있게 해주며, 동시에 고급 최적화와 결합된 컴파일러가 생성하는 코드 품질의 80-90%를 달성합니다.

구체적으로 SQL 쿼리의 경우, 이는 WHERE 절 및 SELECT 목록의 표현식 평가에서 3-5배, SUM, AVG, COUNT, MIN, MAX와 같은 집계에서 3-5배, 해시 및 중첩 루프 조인 처리에서 2-4배, 저장 프로시저 및 PL/pgSQL 함수에서 5-10배, 그리고 슬라이딩 윈도우 루프를 사용하는 윈도우 함수에서 3-4배 더 빠른 속도로 이어집니다.

1밀리초 미만의 컴파일 시간과 상당한 실행 속도 향상의 조합은 Copy-and-Patch JIT를 마이크로초 단위로 완료되는 OLTP 쿼리부터 수백만 개의 행을 스캔하는 OLAP 쿼리에 이르기까지, 데이터베이스 워크로드의 전체 범위에 대해 이상적인 기술로 만듭니다.

참고 문헌

  1. Xu, H., & Kjolstad, F. (2021). Copy-and-Patch Compilation: A fast compilation algorithm for high-level languages and bytecode. OOPSLA.
  2. Neumann, T. (2011). Efficiently Compiling Efficient Query Plans for Modern Hardware. VLDB.
  3. Kersten, T., et al. (2018). Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask. VLDB.
  4. Lattner, C., & Adve, V. (2004). LLVM: A Compilation Framework for Lifelong Program Analysis and Transformation. CGO.

Copyright © 2024 Cognica, Inc.

Made with ☕️ and 😽 in San Francisco, CA.