Zettelkasten

ctranslate2 num_workers는 가중치를 공유하고 worker별 메모리는 호출 중에만 늘어난다

·수정 2026.05.01·수정 2

요약

  • 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 사용"*은 두 가지로 해석 가능하다.

  1. 동시 호출 시 peak가 worker당 그만큼 솟는다 → ✅ CPU 실험으로 메커니즘 확정
  2. 상시 VRAM이 worker당 그만큼 점유된다 → CPU에선 false (transient), GPU에선 caching allocator 때문에 true에 가까울 가능성

fleet sizing 계산 시 이 구별이 중요하다. peak만 솟는다면 동시성 N에 따른 헤드룸만 확보하면 되고, 상시 점유라면 idle 상태에서도 VRAM이 잡혀있는 거라 instance당 메모리 요구가 다르게 산정된다.

참고