
자막 파이프라인을 만들고 있었다. 화자 분리(pyannote), 음성 인식(vibevoice), 강제 정렬(qwen) 이렇게 GPU 모델 3개가 순차적으로 돌아가는 구조다. 처음엔 당연히 하나의 패키지에 다 넣었다. 모델 코드도 같은 저장소에, venv도 하나.
문제는 의존성에서 터졌다.
pyannote는 특정 버전의 torch를 요구하고 vibevoice는 또 다른 버전을 원한다. transformers 버전도 서로 안 맞는다. pip install 한 번 돌릴 때마다 뭔가 깨지고 하나 고치면 다른 게 망가지는 전형적인 의존성 지옥이었다.
보통 이런 상황에서 Docker 컨테이너를 떠올린다. 모델마다 컨테이너를 만들고 API 서버를 띄우고 HTTP로 통신하는 식이다. 깔끔한 방법이긴 한데 이 프로젝트에선 과했다. CLI 도구인데 Docker Compose를 띄워야 하나? GPU passthrough 설정에 네트워크 레이어까지 붙이면 단순한 파이프라인치곤 인프라가 너무 무거워진다.
더 가벼운 격리 방법을 찾았다. 독립 venv + subprocess.
아이디어는 단순하다. 모델마다 독립 Python 패키지를 만들고 각각 자기만의 venv를 갖게 한다. 메인 파이프라인은 subprocess로 각 worker를 호출하면 끝이다.
실제 디렉토리 구조를 보면 이렇다.
workers/diarize — pyannote 기반 화자 분리. VRAM ~3GBworkers/asr — vibevoice 기반 음성 인식. VRAM ~14GBworkers/align — qwen 기반 강제 정렬. VRAM ~1.2GB각 worker는 자체 pyproject.toml과 .venv를 갖고 있다. pyannote가 torch 2.1을 쓰든 vibevoice가 torch 2.4를 쓰든 서로 간섭할 일이 없다.
worker 간 통신도 단순하게 갔다. 소켓이나 공유 메모리 같은 IPC 메커니즘 없이 Unix 파이프 세 개로 해결한다.
메인 파이프라인에서 worker를 호출하는 코드다.
def run_worker(worker_name: str, input_data: dict) -> dict:
worker_dir = WORKERS_DIR / worker_name
venv_python = worker_dir / ".venv" / "bin" / "python"
proc = subprocess.Popen(
[str(venv_python), "-m", f"{worker_name}_worker"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(worker_dir),
)
# stderr를 별도 스레드로 실시간 로그
def _stream_stderr():
for line in proc.stderr:
logger.info("[%s] %s", worker_name, line.decode().rstrip())
stderr_thread = threading.Thread(target=_stream_stderr, daemon=True)
stderr_thread.start()
stdout, _ = proc.communicate(input=json.dumps(input_data).encode())
return json.loads(stdout)핵심은 venv_python이다. 각 worker의 독립 venv에 있는 Python 바이너리를 직접 호출하기 때문에 메인 프로세스의 환경과 완전히 분리된다.
worker 쪽은 더 단순하다. stdin에서 JSON 읽고 처리하고 stdout으로 JSON 뱉는 게 전부다.
def main() -> None:
input_data = json.loads(sys.stdin.buffer.read())
segments = diarize(input_data["wav_path"])
sys.stdout.buffer.write(json.dumps({"segments": segments}).encode())subprocess 패턴을 쓰면서 얻은 예상치 못한 이점이 있다. GPU 메모리 관리다.
하나의 프로세스에서 모델 3개를 돌리면 총 VRAM 사용량이 3GB + 14GB + 1.2GB = 약 18GB다. 32GB GPU를 쓰고 있어서 물리적으로는 들어가지만 파이토치가 VRAM을 잘 안 놓아주는 문제가 있다. del model 해도 바로 해제가 안 될 때가 많다.
subprocess로 분리하면 각 worker 프로세스가 종료될 때 OS가 GPU 메모리를 확실하게 회수한다. 그래도 만전을 기하기 위해 프로세스 종료 전에 명시적으로 해제하는 코드도 넣었다.
del diarizer
gc.collect()
torch.cuda.empty_cache()이렇게 하면 diarize worker가 끝나고 3GB가 해제된 상태에서 asr worker가 14GB를 할당받는다. 동시에 18GB를 잡지 않아도 되니 GPU 메모리 여유가 생긴다.
전체 파이프라인은 6단계로 구성했다.
여기서 중요한 건 Graceful Degradation이다. 화자 분리가 실패하면? 단일 화자로 간주하고 다음 단계로 넘어간다. 강제 정렬이 실패하면? ASR이 내놓은 타임스탬프를 그대로 쓴다. 각 단계를 try-except로 감싸서 부분 실패를 허용하는 구조다. 완벽한 결과보다는 어떤 결과라도 내는 게 낫다.
이게 제일 골치 아팠던 문제다. pyannote가 내부적으로 torchcodec을 쓰는데 prebuilt 바이너리가 worker의 torch 버전과 심볼 불일치를 일으켰다. 그런데 에러 메시지가 NameError: name 'AudioDecoder' is not defined로 나온다. ABI 불일치랑 NameError가 무슨 상관이지 싶었는데, torchcodec의 import가 실패하면서 AudioDecoder 클래스 자체가 정의되지 않는 거였다.
해결책은 monkey-patch다. _patch_pyannote_io() 함수로 torchcodec이 실패하면 torchaudio로 대체하는 fallback을 넣었다. 우아하지는 않지만 돌아간다.
stderr를 별도 스레드로 읽는 부분도 함정이 있었다. proc.communicate() 이후에 stderr 스레드를 join하지 않으면 로그가 잘리거나 프로세스가 끝나지 않는 문제가 생긴다. timeout=5로 join하는 걸 잊지 말자.
당연한 건데 의외로 자주 걸린다. worker 디렉토리에서 uv sync를 실행하지 않으면 FileNotFoundError가 난다. 에러 메시지에 해결 방법을 포함시켜서 자체 문서화했다. cd {worker_dir} && uv sync 이렇게 에러에 넣어두면 나중에 헤매지 않는다.
ML 모델 여러 개를 하나의 파이프라인에서 조합할 때 모델별 의존성이 충돌한다면 바로 Docker를 꺼내기 전에 subprocess + 독립 venv 패턴을 고려해볼 만하다. 프로세스 경계로 의존성을 격리하면서도 Unix 파이프만으로 통신할 수 있으니 인프라 부담이 적다. VRAM 관리까지 덤으로 따라온다.
물론 이 패턴에도 한계는 있다. worker를 매번 새 프로세스로 띄우니 모델 로딩 오버헤드가 생긴다. 이건 다음 글에서 다룰 persistent worker 패턴으로 해결할 수 있다.