Python 3.13의 copy‑and‑patch JIT 기법에서 영감을 받아 PostgreSQL용 새 JIT 엔진(pg-copyjit)을 만든 배경과 원리, 구현 방식, 현재 상태와 간단한 벤치마크, 향후 계획을 소개합니다.
가끔 나는 왜 이런 걸 하는지 모른다. 이번이 바로 그런 때다. 몇 달 전, Python 3.13에 새 JIT 엔진이 들어갔는데, 새로운 JIT 컴파일러 구성 방법론(copy‑and‑patch, 참고: 연구 논문)에 기반해 만들어졌다. 논문을 읽고 바로 꽂혀서 PostgreSQL에 적용해 봐야겠다고 생각했다. 지금까지 정말 재미있는 여정이었다. 이 글에서 모든 걸 다 다루진 않을 것이고, 나는 다른 소통 방법을 더 선호하지만, 일단은 pg-copyjit을 소개하고 싶다. 이는 당신의 PostgreSQL 서버를 부수고 세그폴트 내는 속도를 끌어올리는 가장 최신이고 번쩍이는 방법이다.
더 나아가기 전에 필수 경고 한 줄: 여기서 만든 모든 코드는 실험적이다. 제발. “오 재밌네”, “오 이런 성능 향상이 있었어”, “이건 이렇게도 할 수 있겠는데” 같은 얘기는 듣고 싶지만, “당신 확장 때문에 우리 미션 크리티컬 애플리케이션이 몇 시간 다운됐어요” 같은 얘기는 듣고 싶지 않다. 어쨌든 현재 상태는 전문 해커들을 위한 것이고, 여러분은 프로덕션 서버에 실험 코드를 맡기지 않을 만큼 현명하리라 믿는다.
아주 예전의 어떤 PostgreSQL 릴리스에서, 아주아주 먼 은하의 이야기처럼, Andres Freund가 LLVM을 사용해 JIT 컴파일의 마법을 PostgreSQL 세계에 소개했다. 둘은 결혼했고 모두가 기뻐했다. 하지만 밝은 성에도 어둠이 있었으니, LLVM은 정말정말 요구사항이 많은 남편이었기 때문이다.
LLVM은 훌륭한 컴파일 프레임워크다. 그 최적화기는 매우 좋고 효율적인 코드를 만들어내며, Andres는 JIT 컴파일러에서 마지막 마이크로초의 성능까지 쥐어짜기 위해 누구도 생각하지 못했을 수준으로 멀리 나아갔다. 이건 경이로운 작업이고, 성능에 대한 이런 집념 어린 광기에 얼마나 애정을 표해야 할지 모르겠다. 하지만 LLVM에는 큰 단점이 있다. JIT 컴파일을 위해 만들어지지 않았다는 점이다. 최소한 PostgreSQL이 사용하는 방식에서는 그렇다. LLVM 최적화기는 비용이 아주 크지만, 사용하지 않으면 차라리 컴파일을 안 하느니만 못할 수도 있다. 그래서 정말 좋은 것들, 즉 성능 향상을 누릴 수 있는 쿼리만 컴파일하려면 전형적인 쿼리 비용 추정을 사용하게 된다. 그리고 그게 모든 걸 거의 불가능하게 만드는 PostgreSQL의 단점이다. PostgreSQL의 비용은 무엇을 의미하도록 설계된 것이 아니다. 서로 비교하도록 만들어졌을 뿐, 실제 실행 시간과는 무관하다. 비용이 100인 쿼리가 1초 걸릴 수도 있고, 비용이 1000인 다른 쿼리는 100밀리초 만에 끝날 수도 있다. 버그가 아니라 설계 결정이다. 그래서 많은 사람들(나 포함)이 결국 JIT 컴파일러를 끈다. 내 프로덕션 시스템의 대부분(혹은 전부)의 쿼리는 LLVM 최적화 비용을 상쇄할 만큼의 성능 향상을 얻지 못한다. 쿼리를 10ms 빨리 돌릴 수 있어도 최적화에 50ms가 들면, 순손해다.
LLVM JIT 컴파일러를 더 쓰기 좋게 만들 방법이 하나 있긴 하다. 하지만 구현에는 수년이 걸릴 것이다. 바로 컴파일된 쿼리를 캐시하고 재사용하는 것이다. 이 글에서 더 파고들지는 않겠지만, 믿어달라. 그건 결코 작은 업적이 아니다.
그렇다면 우리는 무엇을 할 수 있을까? 가능한 한 빠르게 생성되는 “충분히 빠른” 코드가 필요하다. 충분히 빠르다는 건 최소한 현재 인터프리터보다는 조금 더 빠르다는 뜻… 그런데 컴파일러를 작성하는 건 괴롭고, 여러 ISA(명령 집합)를 위한 코드 생성기를 여럿 쓰는 건 더더욱 끔찍하다…
여기서 copy‑and‑patch(복사‑패치)의 혁신이 등장해 우리를 구해준다.
copy‑and‑patch에서는 C로 스텐실(stencil)을 작성한다. 스텐실은 구멍이 있는 함수이며, 일반적인 clang 컴파일러로 컴파일된다(gcc 지원은 보류 중이며, 여기서 설명하기엔 너무 복잡하다). 그런 다음 뭔가를 컴파일하고 싶을 때, 스텐실을 꿰매어 붙이고, 빈자리를 채우고, 방금 만든 “컴파일된” 함수로 곧장 점프한다.
이게 전부다. 이것이 copy‑and‑patch의 마법이다. 스텐실을 새 메모리 영역에 복사하고, 구멍을 패치하면, 끝.
물론 더 나아갈 수도 있다. 컴파일 시점에 수행할 수 있는 계산을 골라낼 수도 있고, 루프를 여러 스텐실로 쪼개 언롤할 수도 있고, 여러 스텐실을 한 번에 합쳐 최적화할 수도 있다(일종의 메타‑스텐실 생성…).
이 논문은 Faster‑CPython 팀의 눈길을 끌었고, 그들은 이를 CPython 3.13에 구현했다. 이때 더 많은 사람들이(나 포함) 이를 알게 되었다.
그렇다면, PostgreSQL에 새 JIT 엔진을 만드는 데 무엇이 필요할까? 다행히도 그리 많지 않다. 아니었으면 아마 이렇게 블로그 글을 쓰지 않았을 것이다.
JIT 컴파일이 도입될 때, 해커스 메일링 리스트에서는 LLVM을 플러그인으로 만들어, 향후 확장이 다른 JIT 컴파일러를 들고올 수 있게 하자는 제안이 있었다. 그 당시 나는 이 아이디어에 꽤 회의적이었다(하지만 그 의견을 공개적으로 말하진 않았다. 나중에 틀릴까 봐). 그리고 결국 내 생각이 틀렸다는 걸 내가 증명하게 되었다… 인터페이스는 정말 단순하다. 당신의 .so는 _PG_jit_provider_init 함수 하나만 제공하면 된다. 그리고 이 함수에서 compile_expr, release_context, reset_after_error라는 세 개의 콜백을 초기화한다. 핵심은 당연히 compile_expr다. ExprState* 파라미터 하나를 받는데, 이는 오퍼코드로 이루어진 표현식 포인터다. 그다음은 “그저” 이 오퍼코드들을 원하는 방식으로 컴파일하고, 생성된 코드를 실행 가능(executable)으로 표시한 뒤, PostgreSQL 인터프리터 대신 이 코드가 evalfunc로 실행되게 바꾸면 된다. 쉽다. 아직 구현하지 않은 오퍼코드를 만나면 자동으로 PostgreSQL 인터프리터로 폴백된다.
copy‑and‑patch 알고리즘(지금까지는 소소한 최적화만 적용했다)은 여기서 다 설명할 수 있을 정도로 쉽다. 각 오퍼코드에 대해, 컴파일러는 스텐실 모음에서 해당 오퍼코드의 스텐실을 찾는다. 스텐실이 있으면 “생성 중” 코드에 덧붙인다. 없으면 컴파일을 멈추고 PostgreSQL 인터프리터가 개입한다. 스텐실을 붙인 뒤에는, 그 스텐실의 각 구멍을 필요한 값으로 패치한다.
예를 들어, 오퍼코드 CONST를 위한 다음과 같은 기초적인 비최적화 스텐실을 보자.
Datum stencil_EEOP_CONST (struct ExprState *expression, struct ExprContext *econtext, bool *isNull)
{
*op.resnull = op.d.constval.isnull;
*op.resvalue = op.d.constval.value;
NEXT_OP();
}
op는 extern ExprEvalStep op; 로 선언되어 있다(그리고 NEXT_OP는 설명이 좀 길어진다. 여기서는 파지 않겠다). 이를 단일 .o 파일로 빌드하면, 컴파일러는 어셈블리 코드에서 op의 주소가 들어갈 자리에 구멍을 남긴다(릴로케이션 사용). 스텐실 모음을 빌드할 때 이 정보가 보존되며, JIT 컴파일러는 이를 사용해 현재 오퍼코드 구조체의 주소를 끼워 넣어 동작하는 코드를 만들 수 있다.
스텐실의 빌드 과정은 꽤 재미있다. 복잡하진 않지만 재미있다. 첫 단계는 스텐실들을 단일 .o 파일로 빌드하는 것이다. 그런 다음 이 .o 파일에서 어셈블리 코드와 릴로케이션 정보를 추출해, JIT 컴파일러가 연결(link)할 수 있는 C 구조체로 바꾼다.
그리고 사실상 이게 전부다.
처음엔 어셈블리 코드를 수동으로 추출했다. 그렇게 해서 SELECT 42;에 필요한 세 가지 오퍼코드를 작동시키는 데 성공했다. 그리고 매우 기뻤다. 이 첫 번째 개념 증명 이후(며칠 전 PgDay.Paris에서 사람들이 내가 SELECT 42가 돌아간다고 좋아하는 걸 보고 좀 당황해했을지도… 이상하게 들렸을 수 있다), 어셈블리 코드 추출을 자동화하는 DirtyPython(비공식 변형) 스크립트를 작성했고, 몇 시간 만에 함수 호출, 단일 테이블 쿼리, 더 복잡한 데이터 타입을 구현하고, 몇 가지 최적화도 넣었다…
내 컴퓨터의 PostgreSQL 16에서 동작한다. 더 오래된 릴리스에서도 잘 동작할 것이다. 현재는 AMD64만 지원한다. 내가 가진 게 그거라서 한 번에 모든 걸 타깃팅할 수 없기 때문이다. 나중에는 ARM64를 추가할 것이고, POWER64나 S390x 같은 흥미로운 타깃도 지원하고 싶다(안타깝게도 몇 가지 컴파일러 패치가 필요할 수도 있고, 그런 컴퓨터 접근도 필요하다. 쿨럭쿨럭 윙크윙크)…
성능 면에서, 아직 최적화에 거의 시간을 쓰지 않았다는 점을 염두에 두면 결과는 훌륭하다. 코드 생성은 몇백 마이크로초 안에 끝나기 때문에, 짧은 쿼리에서도 사용할 수 있다. 그런 경우 LLVM은 게임에서 아예 탈락이다. 단순한 SELECT 42; 쿼리에서, JIT 없음은 0.3ms, copyjit은 0.6ms, 최적화 없는 LLVM은 1.6ms, 최적화한 LLVM은 6.6ms가 걸린다. 물론 LLVM은 매우 빠른 코드를 만들어낼 수 있다. 하지만 여기서의 핵심 아이디어는 “충분히 빠른” 코드를 “빨리” 생성하는 것이므로, 두 도구를 정면 비교하는 건 큰 의미가 없다.
그래도 모두가 벤치마크를 기다릴 테니, 가보자. 인덱스 없는 9만 행 테이블에서 두 쿼리를 벤치마크했다. 이 벤치마크는 노트북에서 수행되었고, 이런 환경의 벤치마크 결과에 대한 내 신뢰는 많아야 보통 수준이다. 추후에는 열 제한(thermal envelope) 장난질 없는 데스크톱에서 제대로 된 벤치마크를 하겠다. 그리고 내 컴파일러는 아직 최적화가 거의 안 되어 있고, 해야 할 일도 많다.
**쿼리****최소/최대 (ms)**중앙값 (ms) 및 표준편차 select * from b; — JIT 없음 10.340/14.046 10.652/0.515 select * from b; — JIT 10.326/14.613 10.614/0.780 select i, j from b where i < 10; — JIT 없음 3.348/4.070 3.7333/0.073 select i, j from b where i < 10; — JIT 3.210/4.701 3.519/0.107
노트북에서 비최적화 코드로 돌린 멍청한 벤치라, 너무 믿지는 말자…
보이는 것처럼, 현재 미완성 상태임에도, CPU 작업(여기서는 where 절)이 생기자 인터프리터 대비 성능이 더 좋아졌다. 당연한 결과고, 여기서 중요한 건 JIT이 약간의 시간을 더 먹는 추가 단계임에도, 그 시간이 너무나 짧아서 이런 쿼리들도 몇 퍼센트는 더 빨라질 수 있다는 점이다.
오퍼코드를 몇 개만 구현했음에도, 서버에서 어떤 쿼리든 실행할 수 있다. JIT 엔진은 그냥 크게 불평만 하고 인터프리터가 쿼리를 실행하도록 내버려둔다…
궁금한 분들을 위해, 코드는 깃허브에 여기 덤프해 두었다. 덤프라고 한 이유는, 나는 코드에만 집중하고 깔끔한 git 히스토리나 예쁜 색과 꽃으로 둘러싼 문서 같은 걸 신경 쓰지 않기 때문이다. 그런 건 코드가 끝난 다음에 하는 일이고, 이건 아직 아니다… 빌드하려면 build-stencils.sh 파일을 먼저 수동으로 실행해야 한다. 하지만 다시 말하지만, 현재 상태의 코드에 대해 지원을 제공할 수 없으므로 아직 문서화하지 않았다.
이건 개념 증명이다. 빌드를 쉽게 하거나, 패키징 가능하게 만드는 작업은 하지 않았다… 빌드 스크립트는 Debian과 PostgreSQL 16에 특화되어 있다. 솔직히 말하면, 지금은 별로 신경 쓰지 않는다. 나를 괴롭히지도 않을 거다. 내 관심사는 더 많은 오퍼코드 구현과 최적화 탐색이다.
언젠가 이것을 안전하게 패키징하고 프로덕션 서버에 배포할 수 있는 지점에 도달하길 진심으로 바란다. 그러면 최적화할 가치가 있는 쿼리가 많은 서버(GIS 서버)에는 기존의 LLVM JIT을 계속 쓰고, 짧은 쿼리 시간이 필수인 웹 애플리케이션 데이터베이스에는 이 JIT을 쓰겠다. LLVM 최적화는 그런 곳에서는 오히려 역효과가 나기 때문이다.
그리고 다른 아키텍처로 포팅하는 것도 진지하게 생각하고 있다. Alpha, Itanium, Sparc, M68k 같은 다양한 아키텍처가 존재하던 옛 시절을 사랑한다. 그런 시스템을 당장 쓸 일은 없겠지만, 나는 다양성이 그립고, 여기서 단일 문화(monoculture)의 문제가 생기는 데 일조하고 싶지 않다.
먼저, 내 현재 직장인 Entr’ouvert에 큰 감사를 전한다. 우리는 자유 소프트웨어에 초점을 맞춘 작은 프랑스 SaaS 회사이고, 동료들은 내가 티켓 처리와 DBA/시스앱 작업 사이사이에 이것저것 가지고 놀도록 너그럽게 해준다.
이 일을 지지하고 동기부여해준 DBA 친구들에게도 감사한다(이름은 밝히지 않겠다. 본인들이 안다). 참고로: PoWA 쓰세요. 훌륭한 도구입니다. 주변에도 널리 알려주세요…
그리고 짧은 질문: 이걸 보여주러 PGConf.dev에 가보라는 제안을 받았는데, 이미 일정은 늦었고 나는 프랑스에 살기 때문에 원래 갈 생각이 없었다. 중요하거나 그럴 가치가 있다고 생각한다면, 제발, 제발 알려 달라(아래 댓글이나, 내 이메일은 p@this.domain). 그렇지 않다면, 다음 유럽 PG 행사에서 봅시다🙂