JIT 에피소드 III: 워프 스피드 어헤드 - 레지스터 기반 최적화로 인터프리터를 능가하다

PostgreSQL copy-patch JIT, episode III

작성자
HackerNews
발행일
2025년 12월 04일

핵심 요약

  • 1 JIT 컴파일러는 메모리 접근을 줄이고 CPU 레지스터를 적극 활용하여 인터프리터 대비 상당한 성능 향상을 달성합니다.
  • 2 64비트 아키텍처의 레지스터 수 증가가 성능에 미치는 긍정적 영향과 메모리 접근의 높은 비용이 강조됩니다.
  • 3 Copy-patch JIT에서 SysV 호출 규약을 활용하여 레지스터 할당을 제어하고, 오퍼레이션 코드(opcode)를 재작성하여 메모리 대신 레지스터를 사용함으로써 사이클 수를 획기적으로 줄였습니다.

도입

이전 JIT 에피소드에서는 copy-patch 방식을 사용하여 PostgreSQL용 JIT 컴파일러를 구축하고 인터프리터 대비 소폭의 성능 개선을 달성하는 방법을 논의했습니다. 두 번째 에피소드에서는 인터프리터를 뛰어넘는 성능 도약의 어려움을 다루었으나, 긍정적인 전망으로 마무리되었습니다. 본 글은 인터프리터의 성능 장벽을 돌파하기 위한 근본적인 접근 방식, 즉 JIT 컴파일러가 인터프리터가 수행하기 어려운 최적화를 통해 성능을 향상시키는 방법에 초점을 맞춥니다.

인터프리터의 한계와 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에 최적화하는 과정에서 nullFlagsreg0를 사용하는 예시에서 확인할 수 있습니다.

성능 결과

간단한 벤치마크(SELECT * FROM demo WHERE a = 42를 1천만 행 테이블에서 10회 실행) 결과, copyjit은 PostgreSQL 인터프리터 대비 평균 시간 15% 감소, 사이클 수 16% 감소, 분기 수 13% 감소를 달성했습니다. 이는 명령어 수 감소보다는 동일한 명령어가 메모리 접근 대신 레지스터를 사용함으로써 사이클 수가 절약되었기 때문입니다. LLVM JIT도 유사한 실행 시간을 보였으나, 컴파일 시간이 더 길어 copyjit이 더 빠릅니다.

결론

레지스터 기반 최적화는 JIT 컴파일러가 인터프리터의 성능 한계를 극복하는 데 핵심적인 역할을 합니다. 메모리 접근을 최소화하고 CPU 레지스터를 효율적으로 활용함으로써, 동일한 명령어라도 훨씬 적은 사이클로 실행될 수 있음을 확인했습니다. 이는 copy-patch JIT의 주요 이점 중 하나이며, LLVM JIT와 비교하여 컴파일 시간 측면에서도 우위를 점합니다. 향후 더 많은 오퍼레이션 코드를 새로운 메타데이터에 포팅하고, 스필링(spilling) 메커니즘 및 제어 흐름 분석과 같은 추가 최적화를 통해 성능을 더욱 향상시킬 여지가 있습니다.

댓글 0

로그인이 필요합니다

댓글을 작성하거나 대화에 참여하려면 로그인이 필요합니다.

로그인 하러 가기

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!