병렬 쿼리를 켰는데 빨라지지 않는 경험, 집계 쿼리에서 특히 잦습니다. 이유가 구조에 있고, 그 구조를 바꿔 보려는 실험 결과가 나왔어요.
pgEdge의 Andrei Lepikhov(안드레이 레피호프)가 Do Global Hash Tables Strike Back in PostgreSQL?에서 공유 해시 테이블 기반 병렬 집계를 직접 구현하고 측정했습니다. 결론이 흥미롭습니다. 되기는 되는데, 값이 너무 비쌉니다.
지금 구조: 나눠서 세고, 하나가 합친다
PostgreSQL의 병렬 집계는 두 단계입니다. 각 워커가 자기가 읽은 행을 Partial HashAggregate로 부분 집계합니다. 그 결과를 Gather가 모으고, 리더 프로세스 하나가 Finalize HashAggregate로 합칩니다.
그룹 수가 적으면 이 구조가 잘 돕니다. 워커 넷이 각각 100개 그룹의 부분 합계를 만들면, 리더는 400개 행만 합치면 됩니다.
그룹 수가 많으면 이야기가 달라집니다. 그룹이 500만 개면 각 워커의 부분 집계 결과도 수백만 행이고, 리더가 그걸 다 받아 다시 해시 테이블을 만들어야 합니다. 저자가 든 예시에서 스캔은 252ms인데 Finalize 단계가 전체 25초 중 18초를 썼습니다. 병렬을 끄면 13초였으니, 병렬 계획이 직렬보다 거의 두 배 느렸습니다.
직접 재 봤다
PostgreSQL 18에서 확인했습니다. 워커를 8개까지 쓸 수 있게 두고, 500만 행 테이블 셋을 만들어 그룹 수만 달리했습니다.
-- 그룹 100개
create table agg_few as select (i % 100) as g, i as v
from generate_series(1,5000000) i;
-- 그룹 500만개 (모든 행이 각자 그룹)
create table agg_many (g bigint, v bigint);
insert into agg_many select i, i from generate_series(1,5000000) i;
그룹 100개짜리는 예상대로 병렬 계획이 나왔습니다.
Finalize GroupAggregate (actual time=851.515..855.136 rows=100.00)
-> Gather Merge (actual time=851.509..855.094 rows=400.00)
Workers Planned: 3
Workers Launched: 3
-> Sort (actual time=837.048..837.052 rows=100.00 loops=4)
Gather Merge가 400행을 올리고 Finalize가 4ms 안에 끝납니다. 워커 넷이 각각 100개 그룹을 만들었으니 정확히 400행입니다.
그룹 500만개에서는 계획 자체가 달라졌습니다.
HashAggregate (actual time=3860.148..8242.441 rows=5000000.00)
Group Key: g
Batches: 17 Memory Usage: 135313kB Disk Usage: 161920kB
-> Seq Scan on agg_many (actual time=0.023..569.741 rows=5000000.00)
Execution Time: 8488.126 ms
병렬이 아예 없습니다. planner가 워커를 하나도 쓰지 않기로 결정했습니다. 비용 모형이 이미 “이 경우 병렬 집계는 손해"라고 판단한 것입니다. 저자가 지적한 문제를 planner가 회피하는 방식으로 대응하고 있다는 뜻입니다.
그래서 억지로 병렬을 켜 봤습니다. parallel_setup_cost와 parallel_tuple_cost를 0으로 낮췄습니다.
HashAggregate (actual time=2438.858..6180.438 rows=5000000.00)
Batches: 17 Memory Usage: 135313kB Disk Usage: 161936kB
-> Gather (actual time=0.263..300.906 rows=5000000.00)
Workers Planned: 3
Workers Launched: 3
여기가 이 실험에서 가장 선명한 부분입니다. Gather가 500만 행을 300ms에 올립니다. 읽기는 병렬로 잘 됐습니다. 그런데 그 위의 HashAggregate가 6,180ms를 씁니다. 스캔은 병렬인데 집계는 단일 프로세스입니다.
주목할 것은 Partial HashAggregate가 아예 붙지 않았다는 점입니다. 그룹이 너무 많아 부분 집계가 아무 이득이 없다고 보고, 워커는 스캔만 하고 집계 전부를 리더에게 넘겼습니다. 저자가 지적한 “단일 프로세스 병목"이 계획에 그대로 나타납니다.
flowchart TD
A[Seq Scan 워커 3개] --> B[Gather 300ms]
B --> C[HashAggregate
단일 프로세스 6180ms]
C --> D[결과 500만 행]
E[Disk Spill 161MB] --- C
디스크 spill도 눈에 걸립니다. work_mem을 64MB로 뒀는데 17개 batch로 나뉘고 161MB를 디스크에 썼습니다. 리더 하나가 500만 그룹의 해시 테이블을 메모리에 못 담아서입니다. 워커들이 나눠 담았다면 각자 125만 그룹이니 상황이 달랐을 것입니다.
프로토타입: 공유 해시 테이블
저자가 만든 것은 DSM(Dynamic Shared Memory)에 해시 테이블 하나를 두고, 모든 워커가 lock을 잡고 그 테이블을 갱신하는 방식입니다. 부분 집계와 Finalize 단계가 통째로 사라집니다.
구현 규모가 이 실험의 성격을 보여 줍니다. nodeAgg.c 밖에 약 800줄, planner에 약 600줄(비용 모형과 적용 여부 판단), executor 코어에 일곱 줄이 들어갔습니다. nodeAgg.c에서 가져와 고친 static 함수가 여덟 개입니다.
새로 만들어야 했던 것들이 있습니다. Parallel Hash Join의 해시 테이블 설계를 재사용할 수 없어서 전용 해시 테이블을 만들었습니다. numeric이나 text처럼 가변 타입의 상태를 다루려고 DSM 안팎으로 복사하는 계층을 별도로 짰습니다. 메모리 한도 계산은 로컬에 모았다가 일괄 반영하는 방식으로 처리했고, 집계 함수마다 공유 방식이 안전한지 표시하는 aggsharedsafe 플래그를 새로 넣었습니다.
기존 기계장치도 꽤 썼습니다. spill 처리에 SharedTuplestore, by-value 상태에 32KB 단위 DSA 할당자, 단계 동기화와 임시 파일, 그리고 대기 이벤트 다섯 개와 LWLock tranche 두 개입니다.
균등 분포에서는 통한다
측정은 GCP VM에서 했습니다. 48코어를 주로 쓰고 16코어, 14코어도 함께 봤습니다. 전부 메모리 안에서 돌았고 디스크 spill은 없었습니다.
그룹이 고르게 분포하고 워커가 8개일 때 결과입니다. by-value 집계는 약 4.8배, numeric 같은 by-reference 집계는 약 2.0배 빨라졌습니다. 같은 조건에서 두 배 차이가 나는 이유가 DSM 안팎으로 상태를 복사하는 비용입니다.
집계 함수 개수를 늘려 보면 곡선이 나옵니다. 그룹당 집계 2개면 4.64배, 12개면 5.80배로 정점을 찍고, 32개가 되면 4.40배로 내려갑니다. lock을 한 번 잡고 여러 집계를 갱신하니 개수가 늘면 lock 비용이 분산되고, 너무 늘면 critical section이 길어져 다시 나빠집니다.
skew에서 무너진다
문제는 여기입니다. 한 그룹이 전체를 얼마나 차지하는지에 따라 결과가 이렇게 변합니다.
| 최대 그룹 비중 | 속도 향상 |
|---|---|
| 2% | 4.49배 |
| 5~10% | 거의 동등 |
| 95% | 0.04배 |
95%에서 0.04배는 25배 느려진다는 뜻입니다. 이유가 명확합니다. 모든 워커가 같은 그룹의 상태를 갱신하려고 같은 lock을 두고 줄을 섭니다. 병렬이 아니라 순차가 되고, 여기에 lock 획득 비용이 얹힙니다.
현실 데이터에서 흔한 80대 20 분포에서는 by-value 집계가 대략 동등, by-reference는 통상적인 워커 수에서 이득이 거의 없었습니다. 실무 데이터가 대개 고르지 않다는 점을 생각하면 이 결과가 결정적입니다.
세 가지 장애물
저자는 lock 경합 자체보다 더 근본적인 문제 셋을 지목했습니다.
첫째, LWLock의 입도입니다. “LWLock은 이 크기의 critical section에 맞는 primitive가 아니다"라고 적었습니다. count(*)조차 나노초 단위 증가 연산을 하려고 마이크로초 단위 LWLockAcquire()를 부릅니다. 보호하려는 작업보다 보호 장치가 비쌉니다.
둘째, JIT 컴파일이 불가능해집니다. 지금의 비공유 경로는 ExecBuildAggTrans()가 만든 마이크로프로그램 하나를 JIT으로 함수 하나로 컴파일합니다. 공유 경로에서는 lock을 잡기 전에 인자를 계산하고 lock 안에서 transition 함수를 호출해야 하니, 이 통합 컴파일이 성립하지 않습니다. 병렬로 얻은 이득을 컴파일 손실로 반납하는 구조입니다.
셋째, lock 안에서 임의 코드가 돕니다. enum_cmp_internal() 같은 함수는 카탈로그를 조회하고, 그 과정에서 table_open()과 buffer 읽기가 일어날 수 있습니다. LWLock을 잡은 상태에서 다른 lock을 잡는 상황이 만들어집니다.
프로세스 모델이 청구서를 보낸다
저자의 결론 문장이 셉니다. “프로세스 모델이 공유 모델로 가는 길의 주된 장애물이다.”
스레드 기반 엔진과 비교하면 차이가 구체적입니다. DSM 안에서는 직접 포인터를 쓸 수 없어서 palloc, repalloc, MemoryContext를 쓸 수 없습니다. by-reference 상태는 복사해 넣고 복사해 나와야 합니다. TOAST를 제자리에서 압축 해제할 수 없습니다. 집계 함수 구현이 표준적인 가변 상태 패턴을 쓸 수 없습니다.
정리하면 이렇습니다. “PostgreSQL은 스레드를 가진 엔진보다 공유 가변 상태에 훨씬 많은 값을 치르고, 돌려받는 것은 같다.”
pgrust가 Volcano 모델의 청구서를 뜯어 보여 준 이야기와 같은 종류의 결론입니다. 30년 전에 정한 실행 모델이 지금 어떤 최적화를 막고 있는지 확인하는 작업입니다. Momjian이 짚은 코어의 빈칸들에도 같은 성격의 항목이 여럿 있었습니다.
그럼 어디에 쓸까
저자는 포기하지 않고 적용 범위를 좁히자고 제안합니다.
고정 폭 by-value 상태로 한정합니다. count, sum(integer), sum(float)이 여기 들어갑니다. 이런 상태는 원자적 병합 연산 하나로 처리할 수 있으니 lock 없이 갱신할 수 있습니다. 위 세 장애물 중 첫째와 둘째가 상당히 완화됩니다.
그리고 MCV 통계로 skew를 미리 감지해 병리적인 경우를 피합니다. planner가 pg_stats의 most common values를 보고 “이 그룹 키는 한 값이 지배적이다"라고 판단하면 공유 방식을 고르지 않는 식입니다. 통계가 이미 있는 정보라서 추가 비용이 낮습니다.
더 유망한 쪽으로 두 곳을 꼽았습니다. SetOp 연산자는 현재 병렬화가 아예 없습니다. 그리고 단순 SELECT DISTINCT입니다. 둘 다 by-reference 상태의 복잡성이 걸리지 않는 영역입니다.
남는 생각
이 글의 값은 4.8배라는 숫자가 아니라 그 숫자가 왜 못 남는지에 있습니다. 균등 분포 벤치마크만 보면 도입할 이유가 충분해 보이는데, skew 하나로 25배 역행이 나옵니다. 벤치마크 조건을 고르는 일이 결과 자체보다 중요하다는 사례입니다.
제 실측에서 planner가 500만 그룹에 병렬을 아예 안 붙인 것도 같은 맥락으로 읽힙니다. 비용 모형이 이미 이 지점을 알고 회피하고 있습니다. 새 실행 방식을 넣는다면 planner가 그것을 언제 고를지 판단하는 부분이 구현의 절반이 됩니다. 저자가 planner에 600줄을 쓴 이유겠죠.
참고
- Do Global Hash Tables Strike Back in PostgreSQL? (Andrei Lepikhov, pgEdge, 2026-08-20)
- PostgreSQL 병렬 계획 문서
- PostgreSQL 집계 함수 확장 문서