순수 파이썬 jq가 C 바인딩보다 40배 빠른 이유
Source: Dev.to
어제 나는 purejq라는 jq 패키지를 PyPI에 올리는 작업을 했습니다. (C 바인딩 버전과 비교)
벤치마크
| 워크로드 | purejq | jq (C 바인딩) |
|---|---|---|
| 필드 접근 (스트림) | 9 ms | 368 ms |
| 필터 + 카운트 | 55 ms | 442 ms |
| 맵 + 집계 | 18 ms | 444 ms |
group_by | 112 ms | 704 ms |
| 변환 + 정렬 | 136 ms | 899 ms |
Pure Python 구현이 C 확장보다 7~40배 빠릅니다. 처음엔 이 수치가 이상하게 보였지만 tools/bench.py --verify 로 확인했습니다.
C 바인딩은 실제 jq를 감싸고 있으며, jq는 오직 JSON만을 다룹니다. 따라서 매 호출마다 다음과 같은 과정을 거칩니다.
dicts → JSON 텍스트 → C 파서 → jq 평가 → JSON 텍스트 → dicts
이 왕복은 10만 개의 작은 객체에 대해 약 350~450 ms가 소요됩니다.
purejq는 이 과정을 완전히 생략합니다. jq 프로그램을 한 번 Python 코드로 컴파일해 두고, 이후에는 직렬화 없이 바로 객체에 작동합니다.
import purejq
prog = purejq.compile(
"group_by(.team) | map({team: .[0].team, n: length})"
)
prog.first(data) # 객체에 직접 작동, 직렬화 없음
이 교훈은 jq에만 국한되지 않습니다. C 라이브러리를 임베드할 때, 언어 경계를 건너는 비용을 피할 수 있다면 큰 성능 향상을 기대할 수 있습니다.
예를 들어 93 MB 파일(1 백만 객체) 전체를 처리했을 때의 결과는 다음과 같습니다.
| 워크로드 | purejq CLI | jq 1.8.1 (바이너리) |
|---|---|---|
| 단일 조회 | 0.51 s | 1.68 s |
| 필터 + 카운트 | 1.08 s | 1.96 s |
group_by | 2.32 s | 3.89 s |
여기에도 트릭은 없습니다. 단순히 연산량 차이일 뿐입니다. 큰 파일에서는 대부분의 시간이 JSON 파싱에 소비됩니다.
예상보다 더 큰 영향을 준 요소들
-
한 번 컴파일, 여러 번 실행
프로그램이 중첩된 Python 클로저가 되면서, 평가 단계에서 AST를 다시 탐색하지 않습니다. -
정적 바인딩
프로그램이select같은 함수를 재정의하지 않으면, 호출이 런타임이 아니라 컴파일 타임에 해결됩니다. -
단일 출력 전용 빠른 경로
.score * 2 + 1처럼 정확히 하나의 값을 반환하는 식은 제너레이터가 아닌 일반 함수 호출로 컴파일됩니다. 키가 고정된 객체 리터럴도 제너레이터를 건너뛰죠. -
C에 정렬을 맡기기
정렬 키가 모두 문자열이거나 숫자이면sort_by / group_by / unique가 Python 내장sort로 바로 넘어갑니다. 이 최적화만으로도 정렬 중심 워크로드에서 5배 정도 빨라집니다. -
PyPy와의 시너지
Pure Python 구현이므로 PyPy에서도 바로 동작합니다. 무거운 워크로드에서는 2~9배 추가 가속을 얻을 수 있습니다(예:map+aggregate가 18 ms → 2 ms).
“jq라서 빠른 거다”라고 말하기는 쉽지만, 실제로는 jq 공식 테스트 스위트를 vendoring하고 있다는 점을 강조하고 싶습니다. --verify 출력 결과를 보면 확인할 수 있습니다.
pip install purejq
- 레포지토리: https://github.com/adam2go/purejq — 이슈와 PR을 언제든 환영합니다. 특히 Pyodide나 PyPy에서 테스트해 보시면 좋습니다.