오프라인 NLLB 번역 기능을 Python에서 Rust로 옮기려 했습니다. 모델은 int8 양자화된 NLLB-200 600M, ct2rs(ctranslate2-rs)라는 CTranslate2 바인딩을 거칩니다. Linux에서는 모든 게 깔끔했습니다.

그런데 Windows로 옮기자마자, 같은 길 위에서 세 개의 벽에 잇따라 부딪혔습니다: 링크가 안 되고, 종료가 안 되고, GPU 없는 머신에서 크래시납니다.

이 글은 그 세 벽을 하나씩 해체합니다. 앞의 둘은 Windows/MSVC 툴체인의 암초, 셋째는 이 년을 숨어 있던 종료 데드락, 게다가 결국은 제 탓이 아니었던 NVIDIA 드라이버 버그입니다.

두 벌의 protobuf가 충돌하다#

첫 번째 벽은 돌기도 전에 막았습니다——아예 링크가 안 됩니다.

이 데스크톱 프로그램은 번역 말고도 오프라인 ML 컴포넌트를 몇 개 더 정적으로 링크합니다: 하나는 sherpa-onnx(자기 protobuf를 안고 있음), 그 아래에 ONNX Runtime; 그리고 번역 쪽 NLLB tokenizer는 sentencepiece를 거치는데, 이것 protobuf(그 lite 버전)를 내장합니다. 두 벌의 protobuf, MSVC 정적 링크에서는 공존이 안 됩니다. 풀 앱 링크가 이렇게 뱉었습니다:

text
libsherpa_onnx_sys.rlib(parse_context.obj) : error LNK2005:
  "google::protobuf::internal::ReadSizeFallback / InlineGreedyStringParser / ..."
  이(가) 이미 libsentencepiece_sys.rlib(parse_context.obj)에 정의되어 있습니다
fatal error LNK1169: 여러 번 정의된 기호가 하나 이상 발견되었습니다

204개의 LNK2005 + 하나의 LNK1169, 전부 google::protobuf::internal::*. 원인은 단순합니다: sentencepiece 내장 protobuf-lite가 네임스페이스를 바꾸지 않은 namespace google::protobuf를 쓰는데, sherpa/ONNX Runtime의 전체 protobuf와 같은 네임스페이스로 충돌; 같은 이름의 google::protobuf::* 기호 두 벌이 같은 link에서 만나고, 링커는 어느 벌을 취할지 모릅니다——바로 위반입니다.

선택지를 펼쳐 세 갈래를 궁리했습니다:

  • (a) /FORCE:MULTIPLE — 링커에게 따지지 말고 억지로 이으라고. 이건 조용히 한 벌의 protobuf를 남깁니다——하지만 잘못 고르면 끝: ORT는 전체 protobuf의 descriptor가 필요하고 sentencepiece의 lite에는 그게 없어, 만일 lite가 이기면 ORT는 실행 시점에 조용히 죽습니다. ODR 룰렛이죠.
  • (b) 양쪽이 하나의 protobuf를 공유-sys crate의 C++ 빌드를 손봐 모두를 하나의 시스템 protobuf에 잇게. 깨끗하지만 무겁고; 게다가 ORT는 새 버전을 원하고 sentencepiece는 오래된 built-in lite에 묶여, 버전 자체가 비호환.
  • (c) NLLB를 별도 프로세스/sidecar로 분리 — 두 protobuf가 각자의 binary에 있으면 링크 충돌은 사라지고(덤으로 다음 CRT 벽도 통째로 면함), 대가는 in-process를 잃고 IPC 직렬화와 생명주기를 직접 짊어지는 것.

결국 셋 다 안 갔습니다. 진짜 해법은 넷째, 그리고 가장 깨끗한 것: 그 protobuf를 아예 안 갖기. sentencepiece는 tokenize용일 뿐이라, ct2rs의 sentencepiece feature를 통째로 끄고 순수 Rust의 tokenizers crate로 tokenizer.json을 읽게 바꿨습니다. 이 한 번으로 sentencepiece-sys, 그 내장 protobuf-lite, 그리고 prost까지 의존성 그래프에서 사라집니다——충돌이 눌린 게 아니라 존재하지 않게 됐죠. 게다가 모델 디렉터리는 원래 tokenizer.json을 함께 배포하므로 배포 변경 제로. 없애고 나니 LNK2005는 0이 되고, exe가 깔끔히 링크됐습니다.

TIP

기호 중복 충돌에서 /FORCE:MULTIPLE은 가장 유혹적인 한 줄 해법——하지만 그건 "두 정의를 숨기고 링커가 옳게 고르길 도박"하는 것. 먼저 물어야 할 건: 둘 중 하나를 아예 안 들어오게 할 수 있나? 여기선 가능했습니다——그 protobuf의 유일한 출처는 sentencepiece이고, sentencepiece는 순수 Rust tokenizer로 대체할 수 있으니까. 의존성을 통째로 없애는 게 충돌을 누르는 것보다 낫습니다.

/MT냐 /MD냐, 체인 전체가 한쪽에 서야 한다#

protobuf를 치우자 두 번째 벽이 바로 솟습니다——이번엔 CRT(C runtime).

ct2rs는 CTranslate2를 /MT(정적 CRT)로, sentencepiece-sys는 원래 /MD(동적 CRT)로 빌드합니다; MSVC 링커는 /MT/MD 오브젝트를 섞기를 거부하고 LNK2038(RuntimeLibrary 불일치, 강한 거절)을 던집니다. sentencepiece를 없애도 Rust std는 기본이 /MD라 CT2의 /MT와 여전히 안 맞고, LNK4098 + LNK2005(mallocfree__CxxFrameHandler 중복) 한 무더기를 얻습니다.

해법은 체인 전체를 /MT로 통일하는 .cargo/config.toml:

toml
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "target-feature=+crt-static"]

(target 조건으로 감싸서 Linux/macOS CI는 전혀 영향 없음.)

처음엔 이게 무서웠습니다——워크스페이스 전체를 +crt-static로 바꾸면 sherpa-onnx, ORT 같은 C 의존성 링크를 다 건드리는 것 아닌가? 파 보니 오히려 이게 유일한 정답이지, 위험한 타협이 아님을 알았습니다:

  • 제가 쓰는 sherpa 정적 라이브러리는 사전 빌드된 static-MT 버전——새겨진 지시가 RuntimeLibrary=MT_StaticRelease이고, 그 CRT는 바꿀 수 없습니다(내려받아 풀기만, 로컬 컴파일 아님). 그러니 /MT는 그것에 맞출 유일한 값; 반대로 "ct2rs를 /MD로 바꾸기"는 이 프로젝트에서 물리적으로 불가능, sherpa의 /MT를 부술 뿐.
  • ONNX Runtime은 load-dynamic(실행 시 LoadLibrary)——링크에 아예 참여하지 않아 CRT 영향 제로.

바꿔 말해 +crt-static은 모두를 끌어들이는 게 아니라, 원래 /MT였던 sherpa와 같은 편에 서는 것입니다.

그럼 /MT/MD는 대체 뭐가 다를까요? 몇백 KB의 코드 중복이 아니라 상태입니다. 각 /MT 모듈은 자기만의 사적 를 지니고, 그중 heap이 가장 치명적. 고전적 죽음: A 모듈의 heap에서 malloc, B 모듈에 넘겨 free → heap 손상이 몇 분 뒤 전혀 무관한 곳에서 폭발. /MD가 무사한 건 모두가 ucrtbase.dll의 한 heap을 공유하기 때문.

WARNING

위험한 건 /MT가 아니라 혼용입니다. 한 binary 안에서 exe로 컴파일된 정적 라이브러리는 exe의 한 CRT를 공유하고, /MT는 아무 문제 없습니다. 진짜 위험한 상태는 +crt-static를 넣기 전 그것——Rust std의 /MD와 CT2의 /MT같은 binary에 동거, heap이 둘. +crt-static가 하는 일은 그 두 heap을 하나로 되돌리는 것.

내친김에 당시의 오해를 하나 깨겠습니다: /MT는 "sidecar를 둘 수 없다"를 뜻하지 않습니다. CRT는 모듈 경계에서만 맞추면 됩니다——같은 binary는 일치 필수; DLL↔EXE 같은 프로세스는 달라도 되고, 단 CRT 자원(malloc한 포인터, FILE*)은 넘길 수 없음; 그리고 프로세스↔프로세스(sidecar)는 아무것도 공유하지 않아 CRT가 무관. sidecar야말로 CRT가 아예 상관없어지는 층입니다: 두 주소 공간, A에서 malloc하고 B에서 free하는 건 물리적으로 불가능.

이는 sherpa가 끝까지 CRT 고통이 없고 CT2는 지뢰밭이던 이유도 설명합니다: sherpa는 C API(POD, opaque 포인터, 짝지은 Create*Destroy*, 수십 년 안정된 C ABI)라 어떤 CRT로도 DLL로 안전; CT2는 C++ API(cxx——std::stringstd::vectorstd::future가 경계를 직접 넘고, C++엔 안정 ABI가 없음)라 정적으로만 링크할 수 있고 CRT를 맞춰야 함. FFI는 "어떻게 부르나", CRT는 "부른 뒤 그 상태를 누가 소유하나"——둘은 직교라, FFI가 완벽해도 free()에서 죽을 수 있습니다.


벽 하나와 둘을 치우자, 드디어 Windows에서 컴파일되고 돌아가고, 번역도 빠르고 정확했습니다(今天天氣很好The weather is nice today.).

문제는 닫는 그 순간이었습니다.

번역이 끝나고 Translatordrop하자, 프로그램 전체가 거기서 얼어붙습니다. 느린 게 아니라 완전 정지: CPU는 0, 화면은 더 안 바뀌고, Ctrl + C도 못 죽여, 결국 작업 관리자로 강제 종료. 게다가 상대를 가립니다——Windows에서만, CPU에서만 발생; 같은 코드를 CUDA로 바꾸면 깔끔히 종료합니다.

issue를 보니 밟은 건 제가 처음이 아니었습니다. 이 함정은 이 바인딩 구석에 꼬박 이 년을 누워 있었고, 그동안 "돌아가는" 워크어라운드가 있었습니다——다만 그 워크어라운드가 하는 일은 모델 통째로를 leak하는 것이었죠.

NOTE

"종료 시 leak"하는 워크어라운드——왜 그걸로 결론 삼을 수 없나? 이 기능은 오래 도는 데스크톱 프로그램에 들어가야 하니까. 번역하고 끝나는 CLI라면 이 leak을 평생 눈치채지 못합니다——프로세스가 끝나면 OS가 메모리를 다 회수하니까. 하지만 상주 프로그램은 다릅니다, 한 바퀴마다 조금씩 피를 흘립니다.

그래서 이 글의 후반부가 있습니다. 따라가 보면 이건 사실 이 년에 걸쳐 두 개의 데드락을 숨긴 추리 보고서고, 마지막 진범은 두 번 다 같은 이름: 스레드가 끝날 때 Windows의 아래에서 스레드풀을 join하는 thread_local 소멸자입니다.

스레드풀이 몇 층 쌓였는지#

뒤이을 공방을 이해하려면, 먼저 "번역기를 닫기" 아래에 스레드풀이 몇 층 숨었는지 알아야 합니다. 바깥에서부터:

  • CTranslate2 자신의 ThreadPool: 각 Translator 아래에 ReplicaPool이 worker 한 무리를 관리. 모델 소멸 시 ~ThreadPool이 큐를 닫고 각 worker에 worker->join(). 이게 마지막에 데드락 걸리는 층——피해자입니다.
  • CPU 병렬용 스레드풀: OPENMP_RUNTIME=NONE(이 바인딩의 기본) 아래, CT2의 intra-op 병렬은 static thread_localBS::thread_pool을 씁니다.
  • Ruy의 내부 스레드풀: int8 GEMM은 Ruy 백엔드를 거치고, 각 worker가 GEMM을 처음 돌릴 때 thread_localruy::Context를 lazy로 만듭니다. 이 context는 내부에 또 자기 스레드 무리를 둡니다.

Rust 쪽은 cxx가 C++ 객체를 UniquePtr로 감싸고, drop 시 cxx가 C++ 소멸자를 불러줍니다.

그래서 "번역기 닫기"라는 무해해 보이는 동작은 실은 연쇄 소멸: Rust drop → C++ 소멸자 → ~ReplicaPool~ThreadPool → 각 worker에 join(). 그리고 worker는 진짜 끝나기 전에, 몸에 매달린 thread_local 객체들의 소멸자를 다 돌려야 합니다.

데드락은 바로 그 틈——"worker가 끝나려 하면서 아직 thread_local 소멸자를 돌리고 있는"——에 숨어 있습니다.

이 년 전, 이 함정은 "모델 통째 leak"으로 맞바꿔졌다#

이 년 전으로 되감습니다. 최초 보고는 단순: 누군가 공식 nllb.rs 예제로 facebook/nllb-200-distilled-600M을 돌렸고, 번역은 정상, 하지만 끝난 뒤 프로세스가 몇 분 멈추고 Ctrl + C도 무효. 범위를 거듭 좁혀 범인은 한 줄로——drop(t)에서 멈춤, t는 그 translator.

메인테이너는 재현했고 현장도 금세 지목: worker->join()이 영영 안 돌아옴. 하지만 여기 한 문장이 이 년 미제의 열쇠——그 worker 스레드는 분명 정상으로 끝나 보이는데, 그 join은 대체 뭘 기다리나? "이미 끝나 보이는" 스레드가, join하는 이를 천지가 끝날 때까지 기다리게 한다. 말이 안 됩니다.

당시 처리는 우회였습니다. 소멸 때 멈추니, 아예 소멸자를 안 돌리자: Rust 쪽에 #[cfg(windows)] impl Drop을 더해 UniquePtr의 drop을 bypass, C++ 소멸자는 실행 안 되고, 당연히 join에서 안 멈춥니다.

hang은 사라졌습니다. 하지만 대가는 메인테이너 자신의 맺음말에 정직히 적혀 있습니다: Translator가 drop돼도 RAM/VRAM은 해제되지 않고, 프로세스 전체가 끝나 시스템이 회수하길 기다린다. 바꿔 말해——이건 고친 게 아니라, "모델 통째 미해제"로 "안 멈춤"을 맞바꾼 것. 돌고 끝나는 단명 프로그램이면 그 청구서는 평생 안 오고; 상주 데스크톱 프로그램이면 load/unload 한 바퀴마다 한 번 샙니다.

그렇게 사건은 매달린 채였습니다. 중간에 유일하게 는 단서는 메인테이너가 나중에 더한 한 마디: CUDA는 안 멈추고 CPU만 멈춘다, 그리고 "왜 join이 어떤 상황에서 막히는지는 아직 미스터리".

이 CPU/CUDA 비대칭을 기억해 두세요. 뒤에서 그게 범인을 곧장 가리킵니다.

OpenMP는 하나는 우회해도, 다른 하나는 못 한다#

넘겨받고 든 첫 생각은 길 전체를 우회하기: 이 thread_local 소멸자가 멈추는 패턴, 아예 안 건드릴 수 있나?

있습니다. 앞서 말했듯 CT2의 CPU 병렬은 기본 OPENMP_RUNTIME=NONE 아래 그 static thread_local BS::thread_pool을 쓰고, 그 소멸자는 스레드 종료 시 join하고 Windows에서 죽어 보입니다. 하지만 병렬에 OpenMP를 쓰면 이 thread_local 스레드풀 경로가 컴파일 시점에 통째로 제거됩니다——thread_local이 없으면 멈추는 소멸자도 없습니다. 그러니 이론상 CT2를 OpenMP runtime으로 빌드하면(바인딩에선 openmp-runtime-comp feature) 첫 데드락은 사라집니다.

순조로워 보입니다. 그런데 feature를 켜고 컴파일하니 MSVC가 링크 에러를 정면으로 던졌습니다:

text
LINK : fatal error LNK1181: cannot open input file 'gomp.lib'

openmp-runtime-comp은 빌드 스크립트에서 무조건 cargo:rustc-link-lib=gomp, 그리고 gompGNU의 OpenMP runtime, MSVC 툴체인엔 아예 없습니다. MSVC엔 자기 것이 있죠: /openmp가 각 오브젝트에 /DEFAULTLIB:VCOMP 지시를 넣어 runtime이 자동 링크되고, 아무것도 지정할 필요가 없습니다. 그래서 MSVC에선 그 gomp 줄이 잉여일 뿐 아니라 링크를 곧장 실패시킵니다.

해법은——MSVC에선 이 줄을 건너뛰고, 다른 툴체인은 그대로:

rust
// build.rs: gomp는 GNU의 OpenMP runtime, MSVC엔 존재하지 않음(LNK1181).
// MSVC의 /openmp는 /DEFAULTLIB:VCOMP를 자동 발행해 runtime을 자동 링크한다.
if env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("msvc") {
    println!("cargo:rustc-link-lib=gomp");
}

WARNING

여기 무심코 밟는 디테일이 있습니다: 플랫폼 판정에 cfg!(target_env = "msvc")써선 안 됩니다. 빌드 스크립트에서 cfg!빌드 스크립트를 돌리는 host를 반영하지, 빌드 대상 target이 아닙니다; target을 보려면 환경 변수 CARGO_CFG_TARGET_ENV를 읽어야 합니다. 이 "host와 target이 이름을 공유하는" 함정은 크로스 컴파일에서 특히 음험——에러를 안 내고 조용히 틀린 답을 줍니다. (windows-gnu/MinGW엔 VCOMP 지시가 없어 여전히 gomp가 필요; 변경은 MSVC만 대상.)

feature는 링크됐고, OpenMP 경로가 이어졌으며, 그 BS::thread_poolthread_local 소멸자는 컴파일 시점에 제거됐습니다. 확신에 차 load → translate → drop을 다시 돌렸죠.

여전히 멈춥니다.

같은 자리, 같은 worker->join(), 같은 영영 안 돌아옴. OpenMP가 분명 첫 경로를 밀어버렸는데 왜 아직 멈추나?——그제야 깨달았습니다, 뜯어낸 건 그중 하나의 데드락뿐. 이 길엔 둘째가 있고, 그건 CPU 병렬이 어느 runtime을 쓰는지와 전혀 무관; OpenMP는 못 건드립니다. 그리고 그것이야말로 이 사건의 진짜 주인공입니다.

CUDA는 안 멈추고, CPU가 멈춘다#

미뤄둔 단서로 돌아갑니다.

그 문장은 사실 답을 얼굴에 써 놓았습니다. CPU와 CUDA, 이 두 경로는 어디서 다를까요? GEMM입니다. int8의 CPU GEMM은 Ruy 백엔드, CUDA는 cuBLAS——양쪽이 만지는 게 완전히 다릅니다. 그럼 CPU GEMM이 만지고 CUDA가 못 만지는 건? src/cpu/backend.cc의 이것:

cpp
ruy::Context *get_ruy_context() {
  static thread_local ruy::Context context;
  return &context;
}

각 worker가 Ruy GEMM을 처음 돌릴 때 thread_localruy::Context를 lazy로 만듭니다. 그리고 ruy::Context의 소멸자는 한 가지를 합니다: Ruy 내부 스레드 무리를 join. thread_local이라, 이 소멸자가 도는 시점은——worker가 막 끝나는 그 순간.

진실이 여기서 모입니다. CUDA에선 GEMM이 처음부터 끝까지 get_ruy_context()를 안 부르고, worker엔 그런 thread_local이 아예 없어 join할 것도 없고, 스레드는 깔끔히 나갑니다. CPU는 반대: int8 GEMM을 한 번이라도 돌리면 이 thread_local ruy::Context가 worker에 매달려, 최악의 시점에 소멸되길 기다립니다. 이 년의 "CPU/CUDA 미스터리"는 미스터리가 아닙니다——join 자체가 들쭉날쭉한 게 아니라, CPU 경로엔 CUDA 경로에 없는 thread_local 소멸자가 하나 더 있는 것.

그럼 "worker 종료 시 join"이 왜 데드락일까요? 소멸 순서를 펼칩니다:

  1. ~ReplicaPool~ThreadPool이 큐를 닫고 worker에 worker->join()
  2. worker의 run() 루프가 반환, 스레드가 종료 시작
  3. 스레드 종료 시 몸에 매달린 thread_local 소멸자가 돌고, ~ruy::Context가 Ruy 스레드 무리를 join
  4. 이 join이 이미 끝나가는 스레드 위에서 일어남——데드락

핵심은 3번의 실행 환경. Windows에선 스레드 종료가 thread_local/TLS 소멸자 콜백을 촉발하고, 이 전체 흐름은 loader lock을 쥐고 돕니다. 바로 그 순간에 다른 스레드 무리를 join하러 가면——그 무리도 마무리에 loader lock이 필요할 수 있음——교과서급 loader lock 데드락을 얻습니다: 락을 쥔 자는 남을 기다리고, 기다림 당하는 자는 락을 기다립니다. 그래서 ~ThreadPoolworker->join()에서 영영 멈춥니다.

이는 이 년 전 "worker는 분명 정상 종료해 보인다"는 역설에도 답합니다: run()은 확실히 반환했고, 스레드는 보기엔 마무리 중——하지만 thread_local 소멸자 속 그 join에서 멈춰, 마지막 한 마일을 영영 못 걷고, 바깥에서 join하는 이도 영영 못 돌려받습니다.

근본 원인이 보이면, 고칠 방향은 하나뿐. ruy::Context의 파괴를 "스레드 종료 시 자동 발화"에서 "정상 실행 환경에서 손으로"로 옮깁니다.

CAUTION

thread_local 소멸자 안에서 스레드를 join하지 마라. thread-local 소멸자의 시점은 스레드 자신의 생명주기가 정합니다——Windows에선 그게 딱 loader lock을 쥐고, 스레드 자신이 종료 중인 순간; 거기서 똑같이 마무리 중인 다른 스레드 무리를 join하러 가는 건 스스로 자신을 잠그는 셈. join이 필요하면, 당신이 통제할 수 있는 정상 실행 지점에서 하세요.

CT2엔 마침 기성 훅이 있습니다——ReplicaWorker::finalize()는 worker의 run() 안, 스레드가 진짜 나가기 전에 불리고, 원래 이런 per-thread 마무리를 위한 것입니다. 첫걸음, ruy::Context를 "자동 소멸되는 thread_local 객체"에서 "손으로 지울 수 있는 heap 포인터"로 바꾸고 clear_ruy_context()를 더합니다:

cpp
// backend.cc: context를 heap 할당으로 바꿔, 수명을 thread-local 소멸에 묶지 않는다.
static thread_local ruy::Context* ruy_context = nullptr;

ruy::Context *get_ruy_context() {
  if (!ruy_context)
    ruy_context = new ruy::Context();
  return ruy_context;
}

void clear_ruy_context() {
  delete ruy_context;
  ruy_context = nullptr;
}

둘째 걸음, destroy_context() 안에서 CPU 장치를 대상으로 부릅니다——이 함수가 바로 finalize()가 다다르는 정리 지점:

cpp
// devices.cc: 정상 실행 환경에서(스레드 종료 시가 아니라) 이 worker의 ruy::Context를 해제해,
// Windows에서 Ruy 스레드를 join할 때 ThreadPool 종료가 멈추는 걸 피한다.
#ifdef CT2_WITH_RUY
    if (device == Device::CPU) {
        cpu::clear_ruy_context();
    }
#endif

join은 이제 정상 context에서 일어납니다——loader lock 없이, 스레드도 아직 종료를 시작 안 함——매끄럽게 돕니다. 그리고 소멸자가 이제 끝까지 도니, hang을 피하려 모델 통째를 leak하던 Rust 쪽 Drop bypass도 함께 뺄 수 있습니다. 두 데드락, 하나는 OpenMP로 우회(덤으로 MSVC에서 링크 안 되던 문제도 고침), 하나는 뿌리부터 해결.

검시 보고: CPU는 RAM을, CUDA는 VRAM을 샌다#

데드락을 고치는 건 하나의 일, 저는 "이제 leak 안 함"이 진짜인지도 확인하고 싶었습니다. 그래서 Windows/MSVC 14.44/x64, int8 NLLB-200 600M, Ruy 백엔드에서 load → translate → unload를 5바퀴 연속 돌리고, 각 바퀴 후 RSS를 쟀습니다:

방식1–5바퀴 RSS(MB)
model 소멸자 우회(이 년의 workaround)885 → 1755 → 2624 → 3496 → 4371
ruy::Context만 heap-leak268 → 520 → 772 → 1024 → 1276
이 fork(finalize에서 지움)16 → 17 → 18 → 18 → 19

세 줄, 딱 이 사건의 세 단계. 첫 줄은 그 "돌아가는" workaround: 바퀴마다 약 870 MB 샘(모델 통째 미해제), 5바퀴에 4.3 GB, 기울기는 계속 오르고, 상주 프로그램은 못 버팁니다. 둘째 줄은 중계판——그 ruy::Context만 leak하고 모델은 안 leak해도, 바퀴마다 여전히 약 250 MB 샘, 그건 Ruy의 가 context에 끌려 안 놓아서; "조금 덜 새기"가 답이 아님을 보여줍니다, context가 제대로 파괴 안 되는 한 그 뒤 캐시는 계속 눌러앉습니다. 셋째 줄이 정답: 16, 17, 18, 18, 19, 거의 평평, finalize에서 context를 지우고 덤으로 prepacked cache도 해제. 게다가 파괴 후 context 재구축도 전혀 문제없음——여러 바퀴 호출 사이 번역 출력이 한 자 한 자 일치, clear하고 다시 써도 결과 불변.

세 방식의 바퀴별 RSS를 한 그림에 겹치면, 누가 바퀴마다 피 흘리고 누가 바닥에 붙은 평선인지 한눈에 갈립니다:

처음엔 안 재고 코드로만 추리한 한 가지: 이 leak은 장치를 안 가립니다. 데드락을 피하려 소멸자를 skip하는 그 workaround, #[cfg(target_os = "windows")]는 "Windows인가"를 보지, 어느 장치인가를 보지 않습니다——그래서 CPU든 CUDA든 Windows이기만 하면 drop이 스킵되고 모델 통째가 여전히 미해제. CUDA에선 새는 게 VRAM: 미수정판을 5바퀴 돌리면 VRAM이 5516에서 8911 MB까지 오르고(바퀴마다 약 840 MB, 5바퀴에 4.65 GB), fork판은 두 경로 다 깔끔히 해제합니다. CUDA는 더 흉악——8 GB VRAM 노트북은 대략 4바퀴에 넘치고, CPU 쪽은 그나마 가상 메모리가 받쳐 줍니다.

NOTE

공평하게: 지금 제 사용법에선 모델이 한 번 로드되면 상주해 프로세스 종료까지 남고, unload는 사실 죽은 코드라 이 leak은 두 경로 다 나타나지 않습니다. 이 데이터는 미래의 물음에 답하기 위한 것——언젠가 "모델 전환"이나 "유휴 시 언로드로 자원 절약"을 하는 날, fork는 CPU와 CUDA 둘 다에 필요합니다, CPU만이 아니라.

885와 19의 차이는 측정 오차로 설명 안 됩니다. 그건 "안 멈추게"와 "진짜 고치기" 사이의 거리——둘 다 스모크 테스트에선 똑같이 초록, 둘 다 정상 종료; 다섯 바퀴를 채워 메모리를 노려봐야 비로소 하나는 피 흘리고 하나는 안 흘림이 보입니다.

GPU 버전인데, GPU 없을 때 크래시난다#

데드락을 고치고 leak을 막아, ct2rs는 이 관문을 넘었다고 여겼습니다. 그런데 CUDA 버전을 빌드한 뒤, 더 기괴한 게 나왔습니다: 쓸 만한 GPU가 없는 머신에서, 번역 후 무작위로 SIGSEGV(exit 139). 게다가 조건이 말도 안 되게 까다로움——각 조합을 한 표로 돌렸습니다:

터지는 건 한 조합뿐: CUDA로 빌드, 그런데 CPU에서 돌리고, 실제로 번역함——6회 중 4~5회 크래시. 나머지는 전부 0.

이 "수십 초 뒤 터지고, 스택도 범인과 무관"한 유령을 잡으려, VEH 예외 처리 + backtrace 심벌화 + DLL 로드/언로드 기록을 걸어 현장을 찍었습니다:

text
ACCESS_VIOLATION at 0x7ffec97a8190: 실행(DEP) — 그 주소는 state=FREE(매핑 안 됨)
RIP = 0x7ffec97a8190 ; 스택은 ntdll의 thread-pool 프레임뿐: TpSetWaitEx …
[dll] 로드   nvdxgdmal64.dll  0x7ffec97a0000..0x7ffec9834000
[dll] 언로드 nvdxgdmal64.dll  0x7ffec97a0000..0x7ffec9834000   ← 언로드 후, +0x8190에서 크래시

실행 위반——CPU가 이미 언로드되고 매핑조차 안 된 주소를 실행하러 뛰었습니다. 두 번의 크래시 다 같은 RVA +0x8190(base는 ASLR로 다름). 스택엔 CTranslate2/ruy 프레임이 하나도 없고, 전부 ntdll의 thread-pool.

범인은 NVIDIA 드라이버. nvcuda.dll 초기화가 nvdxgdmal64.dll(드라이버의 DXG DMA 할당기)을 끌어들입니다; cudaGetDeviceCount()가 쓸 만한 장치 없음(err=100 cudaErrorNoDevice)을 알면 이 DLL은 FreeLibrary됩니다——하지만 Windows thread-pool에 등록한 wait callback이 해제되지 않습니다. 그 고아 callback이 1829초 뒤 발화해, 이미 언로드된 모듈로 뛰어듭니다 → DEP 위반. 이 한 줄이 모든 관찰을 설명합니다: WITH_CUDA만 nvcuda를 로드; CPU 장치만 "쓸 만한 GPU 없음"에 이름; 번역했을 때만 callback이 발화할 여유가 생길 만큼 오래 삼(load→drop만인 판은 2.9초에 나감, callback보다 빨라서 0/12); 무작위인 건 그게 스케줄링 경쟁이라서.

이건 누구의 버그일까요? 한때 당연하다는 듯 "이건 알려진 드라이버 버그"라 말했다가——제 손에 뺨을 맞았습니다. 12줄, cudaGetDeviceCount()와 대기 루프만인 순수 C 프로그램을 썼습니다: CT2도 ruy도 Rust도 없이, 그래도 3/4~4/6로 크래시. 그러니 이건 확실히 NVIDIA 드라이버 자신의 버그, CT2, ct2rs, 제 fork와 무관.

CAUTION

원래는 공개 issue를 몇 개 인용해 "이 버그는 2024에 보고됐고 미수정"이라 하려 했다가——제대로 뒷조사하고 전부 철회했습니다. 하나(chia-gigahorse #336)는 보고자 자신이 디스크 고장으로 닫아 이 기전과 무관; 진짜 맞는 건 discuss.python.org 한 건뿐(PyTorch + Flask, 같은 nvdxgdmal64.dll_unloaded, 드라이버 재설치·롤백 둘 다 무효, 답 없음). 믿을 만한 공개 기록은 한 건뿐, 게다가 미해결——NVIDIA가 아는지, 고쳤는지 판단할 길이 없습니다. 뒷조사의 결론은 "알려졌고 미수정"이 아니라 "공개 정보가 너무 얇아 판단 불가". 그럴싸한 "알려진 버그"를 지어내느니, 모른다고 하는 게 낫습니다.

드라이버 쪽은 기댈 수 없고(그 PyTorch 보고자는 롤백으로도 못 살렸음), 클라이언트의 드라이버 버전은 제가 통제 못 하니, 결국 제 쪽에서 막아야 합니다. 해법은 외과적: LdrRegisterDllNotification로 DLL 로드를 감시하고, nvdxgdmal64.dll로드된 순간 GetModuleHandleExAGET_MODULE_HANDLE_EX_FLAG_PIN 플래그로 PIN 고정——NVIDIA가 나중에 FreeLibrary해도 언매핑 못 하고, 모듈은 남고, 고아 callback이 발화해도 때리는 건 아직 매핑된 코드——무해. 기준선 4~5/6 → PIN 후 0/8, 언로드 이벤트조차 한 번도 없음, 인과 사슬이 닫힙니다.

발화엔 네 조건이 동시에 성립해야 합니다: NVIDIA 드라이버 설치됨, 프로그램이 실제로 CUDA 초기화, CUDA가 쓸 만한 장치 없음 판명, 그리고 프로세스가 수십 초 더 삶. 현실의 "드라이버 있음, 쓸 만한 GPU 없음"은 생각보다 많습니다: CUDA_VISIBLE_DEVICES 잘못 설정, 하이브리드 GPU 노트북에서 dGPU 비활성(Optimus/MUX), runtime에 안 맞을 만큼 오래된 드라이버, GPU 없는 원격 데스크톱, 다른 세션이 GPU 독점…… 클라이언트 쪽에서 어느 것일지는 못 맞힙니다.

마치며#

소멸 사슬 전체를 그리면, 데드락 지점과 해법이 개입하는 위치가 한눈에 보입니다:

세 벽을 치우고 돌아보면, 그것들은 사실 같은 모양의 함정: 하나하나는 단독으로 옳고, 이 특정 조합에 모여야 비로소 사고 난다. 두 protobuf는 각자 컴파일되고; /MT는 한 binary 안에서 멀쩡하고; thread_local은 다른 플랫폼에선 얌전히 소멸되고; cudaGetDeviceCount()는 완전히 정당한 호출. 모든 고통은 경계조합에서 일어납니다——protobuf는 같은 link에서, CRT는 같은 binary에서, join은 loader lock 아래에서, callback은 언로드된 모듈에서, 각각 충돌했습니다.

그리고 이 길 전체에서 가장 흥미로운 건, 이 년 묵은 데드락이 왜 진짜로 안 풀렸는가: 아무도 이해 못 할 만큼 어려워서가 아니라, 그 workaround가 "충분히 쓸 만해서"——hang을 없앴고, 맞바꿔 얻은 leak이 마침 대다수가 눈치 못 채는 구석에 숨어서: 어차피 프로세스가 끝나면 메모리는 돌아온다. 단명 프로그램은 그 청구서를 평생 안 받고, 문제는 이 년 "해결"돼 있었습니다. 문제를 우회하는 것과 해결하는 건 무척 닮았습니다——특히 우회의 비용이 미래로, 남에게, "어차피 OS가 치운다"로 미뤄질 때는.

thread_local은 편리합니다, 수명을 자동으로 관리해 주죠; /FORCE:MULTIPLE, drop 스킵, "어차피 드라이버가 고치겠지"도 다 편리합니다. 하지만 "편리"의 뜻은 흔히, 비용을 지금 안 보이는 시점, 안 보이는 환경, 안 보이는 누군가에게 옮기는 것. 그리고 이 한 바퀴, 저는 거의, 그 옮겨진 비용을 하나씩 눈앞으로 옮겨 오고 있었습니다.

참고 자료#