From 88b9d931eb03b0e72934ee97ab9b4be0578f98f6 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 23 Sep 2026 03:47:15 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20GPU=20=EB=9F=AC=EB=84=88=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=8B=A4=ED=96=89=20=EC=B0=A8=EB=8B=A8=20+=20?= =?UTF-8?q?=ED=8B=B0=EC=96=B4=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EB=93=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 러너를 여러 프로젝트가 공유한다는 전제에서 두 가지 구멍이 있었다. 1) 동시 실행 — GPU 는 나눠 쓸 수 없다. 두 작업이 같이 VRAM 을 잡으면 둘 다 OOM 이거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다. concurrency(같은 저장소) + 러너 capacity:1(저장소 무관, 진짜 보장)을 두 겹으로 두고, 문서에 capacity 확인을 필수 절차로 넣었다. 한 대에 러너 프로세스를 여러 개 띄우면 안 된다는 것도 명시. 2) 티어 검증이 주장보다 얕았다 — "4·5티어 검증"이라 해놓고 실제로는 tier_availability() 만 봤고, 그 함수는 torch 유무만 본다. 즉 - 4티어(Seed-X AWQ Int4)는 autoawq 가 없어도 "사용 가능" - 5티어(Qwen3-8B)는 VRAM 11GB 가 필요한데 8GB 카드에서도 "사용 가능" 게이트가 통과인데 실제로는 못 올리는 상태를 CI 가 못 잡고 있었다. 그래서 게이트 검사는 얕은 검사임을 이름과 주석에 명시하고, 그 옆에 **진짜로 모델을 올려 번역까지 하는** 항목을 새로 뒀다. 러너 GPU 가 무엇일지 모르므로 여유 VRAM 과 autoawq 유무로 후보를 거른 뒤 올릴 수 있는 것 중 가장 무거운 티어를 고르고, 건너뛴 티어와 그 이유를 결과에 남긴다. 하나도 못 올리면 실패다 (조용히 통과하지 않는다). 게이트는 통과인데 실제 로드가 실패하면 그게 찾아야 할 버그다 — 앱이 사용자에게 "쓸 수 있다"고 하고선 못 올리는 상황. 러너가 붙으면 실제 증거를 보고 tier_availability() 를 고칠지 판단한다. - autoawq 설치 단계 추가(4티어 전용, Windows 휠이 없을 때가 있어 선택) - 1~3티어 CUDA 경로 번역 검사는 유지하고 unload 를 finally 로 보장 검증: pytest 189개 통과, ruff clean, 워크플로 파싱·concurrency 확인, --gpu 를 torch 없는 환경에서 실행해 6개 중 5개가 의도대로 실패 Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/windows-gpu.yml | 16 +++++++ docs/WINDOWS-TESTING.md | 31 ++++++++++++++ packaging/windows_smoke.py | 71 +++++++++++++++++++++++++++++--- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/windows-gpu.yml b/.gitea/workflows/windows-gpu.yml index 662b84f..90a2bba 100644 --- a/.gitea/workflows/windows-gpu.yml +++ b/.gitea/workflows/windows-gpu.yml @@ -16,6 +16,14 @@ name: Windows GPU on: workflow_dispatch: +# GPU 는 나눠 쓸 수 없다. 두 작업이 동시에 VRAM 을 잡으면 둘 다 OOM 으로 +# 죽거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다. +# 이건 같은 저장소 안에서만 막아준다. **다른 저장소까지 포함한 진짜 보장은 +# 러너의 capacity: 1** 이다 (docs/WINDOWS-TESTING.md 참고). +concurrency: + group: windows-gpu + cancel-in-progress: false + jobs: gpu: runs-on: windows-gpu @@ -57,6 +65,14 @@ jobs: .\.venv-gpu\Scripts\python -m pip install -r requirements.txt .\.venv-gpu\Scripts\python -c "import torch; assert torch.cuda.is_available(), 'CUDA 안 잡힘 — CPU 빌드가 깔렸는지 확인'; print('CUDA OK:', torch.cuda.get_device_name(0))" + # 4티어(Seed-X AWQ Int4)에만 필요하고 Windows 휠이 없을 때가 있다. + # 실패해도 멈추지 않는다 — 점검기가 autoawq 유무를 보고 4티어를 + # 건너뛰면서 "건너뜀: 정밀(autoawq 없음)" 이라고 분명히 남긴다. + - name: autoawq (4티어 전용, 선택) + shell: pwsh + continue-on-error: true + run: .\.venv-gpu\Scripts\python -m pip install autoawq + - name: GPU 전용 점검 shell: pwsh env: diff --git a/docs/WINDOWS-TESTING.md b/docs/WINDOWS-TESTING.md index 2f5f64e..52740db 100644 --- a/docs/WINDOWS-TESTING.md +++ b/docs/WINDOWS-TESTING.md @@ -43,6 +43,37 @@ Actions 탭에서 `Windows GPU` → `Run workflow` 하면 됩니다. 러너가 GPU 워크플로는 GPU가 없으면 **분명히 실패합니다.** GPU를 확인하겠다고 부른 작업이 조용히 통과하면 안 되니까요. `nvidia-smi`가 없으면 그 자리에서 멈춥니다. +#### 여러 프로젝트가 GPU 러너를 같이 쓸 때 — 동시에 돌리면 안 됩니다 + +**이게 가장 중요합니다.** GPU는 나눠 쓸 수 없어서, 두 작업이 동시에 VRAM을 +잡으면 둘 다 OOM으로 죽거나 하나가 조용히 느려져 원인을 못 찾습니다. +CPU 러너와 달리 GPU 러너는 **반드시 한 번에 한 작업만** 돌아야 합니다. + +보장 장치가 두 겹입니다. + +1. **러너 `capacity: 1`** — 진짜 보장입니다. 저장소가 달라도 막힙니다. + `act_runner` 설정 파일(`config.yaml`)에서 확인하세요. 기본값이 1이지만 + GPU 러너에서는 반드시 확인하고 넘어가세요. + + ```yaml + runner: + capacity: 1 # GPU 러너는 절대 올리지 말 것 + ``` + + 설정 파일이 없으면 만들고 `--config config.yaml` 로 띄웁니다. + + ```powershell + .\act_runner.exe generate-config > config.yaml + .\act_runner.exe daemon --config config.yaml + ``` + +2. **워크플로 `concurrency`** — 같은 저장소 안에서 겹치는 것만 막습니다. + `windows-gpu.yml` 에 이미 들어 있습니다. 다른 저장소에는 효력이 없으니 + 1번을 대신하지 못합니다. + +한 대에 러너 프로세스를 **여러 개 띄우지도 마세요.** capacity 1짜리 러너가 +둘이면 결국 동시에 두 작업이 돕니다. + #### GPU 머신을 어디서 구하나 다른 프로젝트도 이 러너에서 GPU 테스트를 돌리실 거라면 셋 중 하나입니다. diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py index 6ee0f38..91d3235 100644 --- a/packaging/windows_smoke.py +++ b/packaging/windows_smoke.py @@ -273,8 +273,14 @@ def run_gpu_checks() -> None: raise RuntimeError("상한 적용 실패 — 게임과 같이 쓸 때 VRAM 을 못 막습니다") return "35% 상한 적용됨" - @check("torch 필요 티어(4·5)가 열리는지") - def _tiers(): + @check("torch 필요 티어(4·5) 게이트") + def _gate(): + """앱이 '이 티어 쓸 수 있다'고 답하는지. + + 주의: tier_availability() 는 torch 유무만 본다. autoawq 유무나 VRAM + 용량은 보지 않으므로, 여기서 통과해도 실제로 올라간다는 뜻은 아니다. + 그건 아래 항목에서 실제로 올려서 확인한다. + """ from livesub.models.manager import has_torch, tier_availability from livesub.models.tiers import TIERS, MTBackend @@ -286,9 +292,61 @@ def run_gpu_checks() -> None: ] if blocked: raise RuntimeError(f"막힌 티어: {', '.join(blocked)}") - return "정밀·극한 사용 가능" + return "정밀·극한 게이트 통과 (얕은 검사)" - @check("GPU 로 실제 번역") + @check("torch 티어 실제 로드 + 번역") + def _real_load(): + """게이트가 아니라 **진짜로 모델을 올려서** 번역까지 해본다. + + 티어마다 요구 VRAM 이 다르고(정밀 5.6GB / 극한 11GB) 정밀 티어는 + autoawq 까지 필요하다. 러너 GPU 가 무엇일지 모르므로, 올릴 수 있는 + 것 중 가장 무거운 티어를 골라서 검증한다. + + 게이트는 통과인데 여기서 실패하면 그게 바로 찾아야 할 버그다 — + 앱이 사용자에게 "쓸 수 있다"고 하고선 실제로는 못 올리는 상황이다. + """ + import importlib.util + + import torch + + from livesub.models.glossary import Glossary + from livesub.models.tiers import MTBackend, ordered_tiers + from livesub.models.translator import create_translator + + free_mb = torch.cuda.mem_get_info()[0] / 1024**2 + has_awq = importlib.util.find_spec("awq") is not None + + fits, skipped = [], [] + for t in ordered_tiers(): + if t.mt.backend is not MTBackend.TRANSFORMERS: + continue + if t.mt.compute_type == "int4" and not has_awq: + skipped.append(f"{t.name}(autoawq 없음)") + elif t.mt.vram_mb > free_mb * 0.9: + skipped.append(f"{t.name}({t.mt.vram_mb}MB 필요)") + else: + fits.append(t) + + if not fits: + raise RuntimeError( + f"여유 VRAM {free_mb:.0f}MB 로 올릴 수 있는 torch 티어가 없습니다. " + f"건너뜀: {', '.join(skipped)}" + ) + + tier = fits[-1] # 올릴 수 있는 것 중 가장 무거운 것 + translator = create_translator(tier.mt, device="cuda") + translator.load() + try: + out = translator.translate("Enemy coming from the left.", "en", "ko", Glossary()) + finally: + translator.unload() + if not any("\uac00" <= ch <= "\ud7a3" for ch in out): + raise RuntimeError(f"{tier.name}: 한국어가 아닙니다 — {out!r}") + + note = f" (건너뜀: {', '.join(skipped)})" if skipped else "" + return f"{tier.name} 로드·번역 성공 {out!r}{note}" + + @check("GPU 로 실제 번역 (1~3티어 CUDA 경로)") def _translate(): from livesub.models.glossary import Glossary from livesub.models.packs import build_glossary @@ -299,7 +357,10 @@ def run_gpu_checks() -> None: translator = create_translator(tier.mt, device="cuda") translator.load() glossary = build_glossary(Glossary(), ["fps-common", "pubg"]) - out = translator.translate("Enemy coming from the left.", "en", "ko", glossary) + try: + out = translator.translate("Enemy coming from the left.", "en", "ko", glossary) + finally: + translator.unload() if not any("\uac00" <= ch <= "\ud7a3" for ch in out): raise RuntimeError(f"한국어가 아닙니다: {out!r}") ARTIFACTS.mkdir(exist_ok=True)