ctranslate2의 GIL 해제와 num_workers는 독립적인 두 메커니즘이다
요약
- ctranslate2(이하 CT2)의
transcribe()/generate()는 호출 시 GIL을 푼다 —num_workers값과 무관하게 항상. num_workers(=CT2inter_threads)는 그 위에서 별개 축으로, 동시에 들어온 추론 요청을 진짜로 병렬 실행할 수 있는 워커 큐 개수를 정한다.- GIL 해제만으로는 동시 호출 throughput이 늘지 않는다. 워커가 1개면 두 번째 호출은 큐에서 대기. 두 메커니즘이 합쳐져야 처리량 이득이 난다.
본문
GIL 해제 — CT2가 무조건 하는 일
CT2의 Whisper.generate()는 C++ 바이너리(_ext.so)에서 실행되며, 내부에서 Py_BEGIN_ALLOW_THREADS 패턴으로 호출 직후 GIL을 푼다. 이건 num_workers=1이든 num_workers=8이든 동일하게 일어난다 — 즉 워커 수와 GIL 해제는 인과관계가 없다.
faster-whisper의 transcribe()도 마찬가지로 호출 wall time의 거의 전 구간 동안 GIL을 잡지 않는다. PyAV 디코드, 멜 스펙토그램, encode, generate 모두 native 코드라 GIL을 푼다.
num_workers가 결정하는 것 — 워커 큐 개수
num_workers는 faster-whisper에서 CT2의 inter_threads로 매핑된다 (transcribe.py:695).
CT2
Whisper.__init__docstring: "inter_threads: Number of workers to allow executing multiple batches in parallel."
워커 큐가 1개면, 동시에 두 Python 스레드가 transcribe()를 호출해도 두 번째 호출은 큐에서 대기한다. GIL이 풀려있다는 사실은 두 번째 호출의 대기 시간을 줄여주지 않는다 — 추론 자체가 워커 슬롯을 못 잡고 있어서. GIL은 인터프리터 락이고, 워커 큐는 모델 실행 락이다. 두 락은 별개.
num_workers≥2로 만들면 비로소 두 호출이 같은 모델 가중치를 공유하면서 각자 워커 슬롯에서 병렬로 실행된다.
왜 둘 다 필요한가
| 조건 | 결과 |
|---|---|
| GIL 잡힘 + 워커 1개 | 한 스레드만 실행, 다른 모든 Python 스레드 대기 |
| GIL 풀림 + 워커 1개 | 다른 Python 스레드는 IO/전처리 가능, 그러나 추론 자체는 직렬 |
| GIL 풀림 + 워커 N개 | 추론도 N개까지 병렬, 다른 Python 작업도 동시 진행 |
| GIL 잡힘 + 워커 N개 | (불가능 — CT2가 GIL을 안 풀면 워커 수 늘려도 호출 자체가 직렬화) |
GIL 해제 ↔ 다른 Python 코드(IO, async event loop)와의 동시 진행 워커 수 ↔ 추론 자체의 동시 진행
실험 결과 (M4 Pro CPU, faster-whisper 1.2.1, ctranslate2 4.7.1, tiny int8, 30s 오디오)
Test A — num_workers=1에서도 GIL은 풀린다
워커 1개로 띄운 모델에서 worker 스레드가 transcribe()를 5초 동안 반복 호출. 같은 시간 동안 main 스레드는 단순 카운터 루프(while time.perf_counter() < end: ctr += 1)를 돈다. 비교 baseline은 worker가 sleep만 하는 경우.
baseline median: 17,854,207 iters/s
concurrent median: 17,511,019 iters/s
retention ratio : 98.08%
→ worker가 transcribe를 16번 도는 동안 main 스레드는 단독 실행 대비 98% throughput 유지. GIL이 wall time 대부분 풀려있다는 강한 증거. 그리고 이건 워커 1개 환경에서 측정한 수치다 — 즉 워커 수와 GIL 해제는 별개.
Test B — 워커 1개와 2개의 동시 호출 throughput 차이
같은 모델, 같은 입력, 같은 코드. num_workers만 1 → 2로 변경. 2 스레드가 동시에 transcribe() 호출 (Barrier로 동시 시작), N=5 반복.
num_workers |
sequential 2회 | parallel 2 thread | speedup |
|---|---|---|---|
| 1 | 0.378s | 0.376s | 1.00x |
| 2 | 0.381s | 0.212s | 1.80x (효율 90%) |
→ GIL은 양쪽 다 풀려있다. 그런데도 워커 1개 케이스는 1.00x. 두 번째 호출이 워커 큐에서 대기하기 때문. 워커 2개로 늘리면 1.80x — 두 호출이 진짜로 같이 돈다.
한계와 천장
num_workers를 무한히 올린다고 throughput이 비례 증가하지는 않는다. 천장은 여러 곳에서 발생한다.
- 물리 자원 포화: CPU 코어 수, GPU SM 수, 메모리 대역폭. 모델이 이미 자원을 잘 쓰고 있다면 워커를 추가해도 빈 자원이 없어 idle.
intra_threads × num_workers > 코어 수: 컨텍스트 스위치 비용 발생. CT2의intra_threads는 워커당 OpenMP 스레드 수라 곱셈으로 늘어남.- 메모리: 워커별로 활성화 버퍼/KV cache가 분리되므로 VRAM/RAM 추가 소비. 작은 모델 + 작은 beam이면 무시 가능, 큰 모델은 OOM 위험.
- Python overhead 비중이 큰 짧은 호출: 짧은 오디오일수록 mel/tokenize 같은 Python 단계 비중이 커지고, 이 부분은 GIL을 잡으므로 워커를 늘려도 그만큼 안 빨라짐.
적용 — RunPod serverless handler 같은 곳
GIL 해제만 의지하면 한 핸들러 안에서 다음 요청을 미리 받거나, 전처리/후처리를 동시 진행하는 정도까지만 이득. 추론 자체의 처리량을 N배로 끌어올리고 싶으면 num_workers≥N을 함께 켜야 한다. 단 메모리 + 코어 자원이 받쳐줘야 하고, 작은 모델·짧은 입력에선 GPU saturation 때문에 N에 대한 증가 폭이 sublinear.