ctranslate2 num_workers는 가중치를 공유하고 worker별 메모리는 호출 중에만 늘어난다
요약
- ctranslate2(이하 CT2)는
num_workers(=inter_threads)를 N으로 늘려도 모델 가중치를 한 벌만 메모리에 올린다 — N배 중복 안 됨. - worker별 working memory(인코더 출력, KV cache, beam state 등)는 동시 호출 중에만 N에 비례해서 솟고, 호출이 끝나면 (CPU 기준) 반환된다.
- 메모리 측정 시 peak RSS만 보면 "worker당 영구 점유"처럼 보이지만 current RSS까지 같이 봐야 transient인지 persistent인지 구별된다.
- GPU(CUDA)는 caching allocator가 한 번 받은 메모리를 풀로 잡아두는 경향이 있어 같은 코드가 다르게 보일 가능성 — 별도 검증 필요.
본문
두 종류의 메모리
CT2 worker pool에서 메모리는 두 종류로 나뉜다.
| 종류 | 어디에 들어가는가 | 동작 |
|---|---|---|
| 모델 가중치 | encoder/decoder weight 텐서 | 모든 worker가 동일한 메모리를 참조 → 공유 |
| working memory | 호출별 mel feature, encoder output, KV cache, beam state, 스레드 스택, OMP scratch | worker 호출이 동시에 N개 도는 동안만 N개 만큼 추가 → per-call |
num_workers는 worker pool slot 개수만 정한다 — 가중치 인스턴스 개수가 아니라.
실험 (M4 Pro CPU, faster-whisper 1.2.1 / ctranslate2 4.7.1, small int8, 30s 오디오)
각 N마다 fresh subprocess 띄워서 RSS 측정. N개 동시 transcribe를 Barrier-aligned로 3 round 반복 후 측정. N별 3 reps.
load 직후 current RSS — N과 무관하게 일정
| N | after_load (current RSS) |
|---|---|
| 1 | 833 MB |
| 2 | 837 MB |
| 4 | 837 MB |
| 8 | 836 MB |
→ 가중치가 worker마다 복제됐다면 N=8에서 ~6.7 GB여야 함. 실제 836 MB로 88%+ 절감. 공유 확정.
active 단계 — peak는 N에 비례, current는 다시 떨어짐
| N | active peak RSS | active current RSS |
|---|---|---|
| 1 | 1034 MB | 1034 MB |
| 2 | 1406 MB | 1287 MB |
| 4 | 2231 MB | 872 MB ← |
| 8 | 3786 MB | 1066 MB ← |
Peak는 N에 비례해 솟지만(per added worker ~390 MB), current는 호출이 끝나면 load 수준 가까이 복귀.
Peak RSS vs Current RSS — 측정의 함정
비유: 욕조 수위
- peak: 수위가 가장 높았을 때 벽에 남은 자국. 한 번 묻으면 안 지워짐.
- current: 지금 이 순간 욕조에 담긴 물의 양.
worker가 transcribe 도는 동안엔 메모리가 솟았다가 끝나면 빠진다 (욕조에 물 채웠다가 빼는 것처럼). peak만 보면 자국이 남아있어서 "물이 차있다"고 오해. current까지 봐야 "잠깐 차고 빠진 거"라는 걸 알 수 있다.
처음에 peak만 보고 "worker별 activation buffer가 영구적으로 분리됨"이라고 단정했다가, current를 보고 "호출 중에만 솟는 transient working set"으로 정정한 게 이번 검증의 핵심 깨달음.
→ Python 프로세스 메모리 측정 시 resource.getrusage().ru_maxrss는 monotonic peak metric이고, psutil.Process().memory_info().rss는 현재값. 둘 다 같이 기록해야 transient vs persistent가 구별된다.
CPU와 GPU의 메모리 반환 동작 차이
CPU에서 위 실험을 돌리면 호출이 끝났을 때 working memory가 OS로 반환된다 (malloc → free). 그래서 current RSS가 진짜로 떨어진다.
GPU(CUDA)는 다르다. CUDA의 메모리 할당은 비싼 작업이라, 라이브러리(PyTorch, CT2 등)가 한 번 받은 메모리를 OS/드라이버에 안 돌려주고 자기 풀(pool)에 잡아둔다. 다음 호출 때 풀에서 재사용. 이걸 caching allocator라고 부른다.
결과적으로 GPU에서는:
- 호출 끝나도 그 메모리가 드라이버에 안 돌아감
- "current GPU 메모리"도 안 떨어짐
- worker별 메모리가 정말 풀로 계속 잡혀있는 것처럼 보임 (CPU의 transient 패턴과 다름)
즉 CPU에서 측정한 결과를 GPU에 그대로 일반화하면 안 된다. 메커니즘(가중치 공유 + per-call working memory)은 device-agnostic이지만, "메모리가 호출 후 반환되는가"는 device별로 다르다.
적용 — capacity planning
문서가 흔히 쓰는 표현 *"worker당 ~0.5-1.5 GB 추가 VRAM 사용"*은 두 가지로 해석 가능하다.
- 동시 호출 시 peak가 worker당 그만큼 솟는다 → ✅ CPU 실험으로 메커니즘 확정
- 상시 VRAM이 worker당 그만큼 점유된다 → CPU에선 false (transient), GPU에선 caching allocator 때문에 true에 가까울 가능성
fleet sizing 계산 시 이 구별이 중요하다. peak만 솟는다면 동시성 N에 따른 헤드룸만 확보하면 되고, 상시 점유라면 idle 상태에서도 VRAM이 잡혀있는 거라 instance당 메모리 요구가 다르게 산정된다.