인터프리터의 한계와 JIT의 기회
인터프리터는 각 레코드마다 모든 오퍼레이션 코드를 실행해야 합니다. 이 과정에서 각 레코드에 대해 반복적으로 수행되는 작업 중 한 번만 처리해도 되는 부분이 있다면, 이를 한 번만 수행하는 것이 효율적입니다. JIT 컴파일러는 이러한 기회를 포착하여 인터프리터에게는 성능 저하를 초래할 수 있는 검사를 제거하고 최적화를 적용할 수 있습니다. 예를 들어, 흔히 사용되는 함수 호출(int4eq)을 인라인화하는 것은 인터프리터에서는 비효율적일 수 있으나, JIT에서는 잠재적인 이점을 가집니다.
64비트 아키텍처와 레지스터의 중요성
과거에는 64비트 모드에서 애플리케이션이 느려진다는 오해가 있었으나, 실제로는 64비트 전환이 성능을 크게 향상시켰습니다. 이는 x86 아키텍처의 가장 큰 문제 중 하나였던 레지스터 부족이 해결되었기 때문입니다. AMD가 64비트를 도입하면서 범용 레지스터 수가 8개에서 16개로 두 배 증가했으며, 이는 엄청난 성능 향상으로 이어졌습니다.
메모리 vs. 레지스터: 속도의 차이
CPU 연산에서 메모리는 레지스터보다 훨씬 느립니다. Zen2 CPU 측정에 따르면, 두 레지스터 간의 비교는 1사이클 미만이지만, L1 캐시에서 데이터를 로드하는 데는 4사이클, L2에서는 12사이클, L3에서는 38사이클이 소요됩니다. 이는 레지스터 접근보다 12배에서 115배 느린 수치입니다. 컴파일러는 자동으로 변수를 레지스터로 이동시키고, 필요시 스택에 레지스터를 저장(spill)합니다.
오퍼레이션 코드 최적화: EEOP_SCAN_VAR 사례
가장 기본적인 오퍼레이션 코드 중 하나인 EEOP_SCAN_VAR는 스캔 슬롯에서 값을 가져와 메모리에 씁니다. 인터프리터는 이러한 메모리 쓰기를 제거하기 어렵지만, JIT는 레지스터를 활용하여 메모리 접근을 줄일 수 있습니다. 예를 들어, *op->resvalue = scanslot->tts_values[attnum];와 같은 메모리 쓰기 대신, reg0 = scanslot->tts_values[attnum];와 같이 레지스터에 직접 값을 저장하는 방식으로 변경할 수 있습니다.
Copy-patch JIT 구현 및 레지스터 활용
copy-patch 방식은 어셈블리 코드를 직접 작성할 필요가 거의 없다는 장점이 있지만, 레지스터 할당을 직접 제어하기 어렵다는 단점이 있습니다. 이를 해결하기 위해 SysV 호출 규약을 활용하여 함수의 첫 여섯 개의 정수 또는 포인터 매개변수를 범용 레지스터를 통해 전달하도록 합니다. 이를 통해 nullFlags, reg0, reg1 세 개의 레지스터를 오퍼레이션 코드 호출 전반에 걸쳐 유지할 수 있습니다. 모든 오퍼레이션 코드 구현을 레지스터를 사용하도록 재작성하고, 각 오퍼레이션 코드에 대한 ‘계약(contract)’을 정의하여 어떤 레지스터에 무엇이 기대되고, 쓰여지고, 읽힐지 명시합니다. 이는 FUNCEXPR_STRICT 오퍼레이션 코드를 int4eq에 최적화하는 과정에서 nullFlags와 reg0를 사용하는 예시에서 확인할 수 있습니다.
성능 결과
간단한 벤치마크(SELECT * FROM demo WHERE a = 42를 1천만 행 테이블에서 10회 실행) 결과, copyjit은 PostgreSQL 인터프리터 대비 평균 시간 15% 감소, 사이클 수 16% 감소, 분기 수 13% 감소를 달성했습니다. 이는 명령어 수 감소보다는 동일한 명령어가 메모리 접근 대신 레지스터를 사용함으로써 사이클 수가 절약되었기 때문입니다. LLVM JIT도 유사한 실행 시간을 보였으나, 컴파일 시간이 더 길어 copyjit이 더 빠릅니다.