🔑 핵심 요약
✦ AI·GEN이 글은 오프라인 NLLB 번역 기능을 Python에서 Rust로, ct2rs를 거쳐 CTranslate2에 붙여 옮기는 과정에서 Windows/MSVC 위에서 잇따라 부딪힌 세 개의 벽과 각각의 근본 원인·해법을 기록한다. 첫째는 "링크가 안 됨": sentencepiece와 sherpa-onnx가 각각 protobuf를 정적으로 내장해 MSVC에서 204개의 LNK2005로 충돌한다——가장 깨끗한 해법은 /FORCE:MULTIPLE도 sidecar도 아니라, sentencepiece feature를 끄고 순수 Rust의 tokenizers로 바꿔 그 protobuf를 의존성 그래프에서 없애는 것. 둘째는 CRT 전쟁: ct2rs는 CTranslate2를 /MT로, Rust std는 /MD로 빌드하는데 .cargo/config.toml의 +crt-static로 통일하며, 이것이 사전 빌드된 /MT sherpa와 맞추는 유일한 정답이지 위험한 회귀가 아님을 정리한다. 셋째는 이 년을 묵은 종료 데드락——thread_local의 ruy::Context가 Windows loader lock 아래, 스레드가 끝날 때 Ruy 스레드풀을 join하며 멈춘다——결국 파괴를 finalize()에서 손으로 앞당겨 데드락과 누수를 한 번에 해결(RSS 4371에서 19 MB로, CUDA의 VRAM 누수도 막음). 마지막으로 NVIDIA 드라이버 버그에도 이른다: CUDA 빌드가 쓸 만한 GPU 없는 머신에서 번역 후 무작위로 크래시, 원인은 nvdxgdmal64.dll 언로드 후 남은 스레드풀 callback이 매핑 해제된 주소로 뛰는 것. 12줄짜리 순수 C로 재현해 드라이버 문제로 확정하고, DLL을 PIN으로 고정해 막았다. 글은 가정을 검증하는 일과, 뒷조사가 부족한 "알려진 버그" 주장을 정직하게 철회한 일도 돌아본다.
오프라인 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 정적 링크에서는 공존이 안 됩니다. 풀 앱 링크가 이렇게 뱉었습니다:
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를 공유 —
-syscrate의 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(malloc/free/__CxxFrameHandler 중복) 한 무더기를 얻습니다.
해법은 체인 전체를 /MT로 통일하는 .cargo/config.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::string/std::vector/std::future가 경계를 직접 넘고, C++엔 안정 ABI가 없음)라 정적으로만 링크할 수 있고 CRT를 맞춰야 함. FFI는 "어떻게 부르나", CRT는 "부른 뒤 그 상태를 누가 소유하나"——둘은 직교라, FFI가 완벽해도 free()에서 죽을 수 있습니다.
벽 하나와 둘을 치우자, 드디어 Windows에서 컴파일되고 돌아가고, 번역도 빠르고 정확했습니다(今天天氣很好 → The weather is nice today.).
문제는 닫는 그 순간이었습니다.
번역이 끝나고 Translator를 drop하자, 프로그램 전체가 거기서 얼어붙습니다. 느린 게 아니라 완전 정지: 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_local의BS::thread_pool을 씁니다. - Ruy의 내부 스레드풀: int8 GEMM은 Ruy 백엔드를 거치고, 각 worker가 GEMM을 처음 돌릴 때
thread_local의ruy::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가 링크 에러를 정면으로 던졌습니다:
LINK : fatal error LNK1181: cannot open input file 'gomp.lib'openmp-runtime-comp은 빌드 스크립트에서 무조건 cargo:rustc-link-lib=gomp, 그리고 gomp는 GNU의 OpenMP runtime, MSVC 툴체인엔 아예 없습니다. MSVC엔 자기 것이 있죠: /openmp가 각 오브젝트에 /DEFAULTLIB:VCOMP 지시를 넣어 runtime이 자동 링크되고, 아무것도 지정할 필요가 없습니다. 그래서 MSVC에선 그 gomp 줄이 잉여일 뿐 아니라 링크를 곧장 실패시킵니다.
해법은——MSVC에선 이 줄을 건너뛰고, 다른 툴체인은 그대로:
// 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_pool의 thread_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의 이것:
ruy::Context *get_ruy_context() {
static thread_local ruy::Context context;
return &context;
}각 worker가 Ruy GEMM을 처음 돌릴 때 thread_local의 ruy::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"이 왜 데드락일까요? 소멸 순서를 펼칩니다:
~ReplicaPool→~ThreadPool이 큐를 닫고 worker에worker->join()- worker의
run()루프가 반환, 스레드가 종료 시작 - 스레드 종료 시 몸에 매달린
thread_local소멸자가 돌고,~ruy::Context가 Ruy 스레드 무리를 join - 이 join이 이미 끝나가는 스레드 위에서 일어남——데드락
핵심은 3번의 실행 환경. Windows에선 스레드 종료가 thread_local/TLS 소멸자 콜백을 촉발하고, 이 전체 흐름은 loader lock을 쥐고 돕니다. 바로 그 순간에 다른 스레드 무리를 join하러 가면——그 무리도 마무리에 loader lock이 필요할 수 있음——교과서급 loader lock 데드락을 얻습니다: 락을 쥔 자는 남을 기다리고, 기다림 당하는 자는 락을 기다립니다. 그래서 ~ThreadPool은 worker->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()를 더합니다:
// 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()가 다다르는 정리 지점:
// devices.cc: 정상 실행 환경에서(스레드 종료 시가 아니라) 이 worker의 ruy::Context를 해제해,
// Windows에서 Ruy 스레드를 join할 때 ThreadPool 종료가 멈추는 걸 피한다.
#ifdef CT2_WITH_RUY
if (device == Device::CPU) {
cpu::clear_ruy_context();
}
#endifjoin은 이제 정상 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-leak | 268 → 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 로드/언로드 기록을 걸어 현장을 찍었습니다:
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이 로드된 순간 GetModuleHandleExA의 GET_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 스킵, "어차피 드라이버가 고치겠지"도 다 편리합니다. 하지만 "편리"의 뜻은 흔히, 비용을 지금 안 보이는 시점, 안 보이는 환경, 안 보이는 누군가에게 옮기는 것. 그리고 이 한 바퀴, 저는 거의, 그 옮겨진 비용을 하나씩 눈앞으로 옮겨 오고 있었습니다.
참고 자료#
- CTranslate2 #2076 — Fix Windows shutdown deadlock and leak with the Ruy backend
- ctranslate2-rs #144 — Don't link gomp on MSVC
- ctranslate2-rs #74 — Windows에서 Drop을 skip하는 그 leak workaround
- ctranslate2-rs #64 — Process doesn't exit / hangs at the end on Windows(최초 issue)
- discuss.python.org —
nvdxgdmal64.dll_unloaded공개 보고
아직 댓글이 없어요
✨ 첫 댓글을 남겨보세요