From bb385c1d7b0b1484b27d94fe2815435720caefdc Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 23 Sep 2026 03:40:01 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20GPU=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=EB=9F=AC=EB=84=88=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(windows-gpu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPU 는 한 VM 만 독점한다 — 컨슈머 NVIDIA 는 vGPU/SR-IOV 를 지원하지 않아 쪼개 쓸 방법이 없고, 지금 RTX 5050 은 .9(리눅스)에 물려 있다. 따라서 GPU 없는 상시 CI VM 에서는 CUDA·VRAM·4~5티어를 영영 확인할 수 없다. 코드로 우회할 수 없는 제약이므로, 대신 **나중에 GPU 머신이 생겼을 때 그대로 붙도록** 구조를 지금 잡아뒀다. 이 러너를 다른 프로젝트 GPU 테스트에도 쓸 계획이라면 이 구분이 그쪽에도 그대로 적용된다. windows:host GPU 없는 서버 VM main push 마다 자동 테스트·Windows 경로·오디오→자막·포터블 exe windows-gpu:host GPU 달린 머신 수동 실행만 CUDA 인식·VRAM 상한·4·5티어·GPU 번역 - windows_smoke.py --gpu GPU 있는 머신에서만 의미 있는 항목만 모았다. CUDA 가용성, 앱의 GPU 인식, VRAM 상한이 실제로 걸리는지, torch 필요 티어(4·5)가 열리는지, GPU 로 실제 번역까지. 다른 모드와 달리 **GPU 부재를 치명적 실패로 처리**한다 — GPU 를 확인하겠다고 부른 작업이 조용히 통과하면 안 되기 때문이다. - .gitea/workflows/windows-gpu.yml 수동 실행 전용. 러너가 없으면 큐에 쌓이기만 하고 아무 일도 없다. 포터블 CI 와 달리 torch CUDA 빌드를 설치하고, 설치 직후 CPU 빌드가 깔리지 않았는지 확인한다. nvidia-smi 가 없으면 그 자리에서 멈춘다. - 문서에 두 러너 구조와 GPU 머신 확보 3가지 방안(서버에 GPU 추가 / .9 에서 옮기기 / 필요할 때만 실물 PC) + 슬롯 확인 명령 추가. 검증: pytest 189개 통과, ruff clean, 두 워크플로 파싱·라벨·트리거 확인, --gpu 를 torch 없는 환경에서 실행해 의도대로 실패(종료코드 1)하는지 확인 Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/windows-gpu.yml | 72 +++++++++++++++++++++++++ docs/WINDOWS-TESTING.md | 42 +++++++++++++-- packaging/windows_smoke.py | 91 ++++++++++++++++++++++++++++++-- 3 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 .gitea/workflows/windows-gpu.yml diff --git a/.gitea/workflows/windows-gpu.yml b/.gitea/workflows/windows-gpu.yml new file mode 100644 index 0000000..662b84f --- /dev/null +++ b/.gitea/workflows/windows-gpu.yml @@ -0,0 +1,72 @@ +# Windows GPU 검증 — GPU 가 달린 러너에서만 +# +# GPU 는 한 VM 만 독점한다(컨슈머 NVIDIA 는 vGPU 미지원). 그래서 GPU 없는 +# 상시 CI VM(runs-on: windows)에서는 CUDA·VRAM·4~5티어를 확인할 수 없다. +# 그 항목만 떼어내 별도 라벨로 돌린다. +# +# 러너 라벨: windows-gpu:host +# - GPU 가 달린 Windows 머신에 등록한다 (두 번째 GPU를 꽂은 VM, 또는 실물 PC) +# - 상시로 띄울 필요 없다. 필요할 때만 daemon 을 켜면 된다 +# +# 그래서 트리거도 **수동 실행만** 이다. 러너가 없으면 큐에 쌓이기만 하고 +# 아무 일도 일어나지 않는다. 자동으로 돌리고 싶으면 push 트리거를 켜면 된다. + +name: Windows GPU + +on: + workflow_dispatch: + +jobs: + gpu: + runs-on: windows-gpu + timeout-minutes: 90 + + steps: + # 라벨이 안 맞으면 Gitea 가 기본 ubuntu 컨테이너로 떨어뜨린다. + # GPU 를 확인하겠다고 부른 작업이 엉뚱한 데서 도는 일은 막아야 한다. + - name: Windows 인지 확인 + shell: pwsh + run: | + Write-Host "RUNNER_OS=$env:RUNNER_OS" + if ($env:RUNNER_OS -ne "Windows") { + Write-Host "::error::Windows 러너가 아닙니다 ($env:RUNNER_OS)." + Write-Host "::error::러너를 'windows-gpu:host' 라벨로 등록했는지 확인하세요." + exit 1 + } + + - name: NVIDIA GPU 가 실제로 보이는지 + shell: pwsh + run: | + nvidia-smi --query-gpu=name,memory.total --format=csv,noheader + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::nvidia-smi 실패 — 이 러너에는 GPU 가 없습니다." + exit 1 + } + + - name: 소스 받기 + uses: actions/checkout@v4 + + # 포터블 CI 와 달리 여기서는 torch CUDA 빌드가 필요하다. + # 4~5티어(Seed-X / Qwen3)가 torch 위에서 돌기 때문이다. + - name: 의존성 설치 (torch CUDA 포함) + shell: pwsh + run: | + python -m venv .venv-gpu + .\.venv-gpu\Scripts\python -m pip install --upgrade pip --quiet + .\.venv-gpu\Scripts\python -m pip install torch --index-url https://download.pytorch.org/whl/cu128 + .\.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))" + + - name: GPU 전용 점검 + shell: pwsh + env: + PYTHONPATH: src + run: .\.venv-gpu\Scripts\python packaging\windows_smoke.py --gpu + + - name: 결과물 업로드 + uses: actions/upload-artifact@v4 + if: always() + with: + name: LiveSub-gpu-report + path: artifacts/*.txt + retention-days: 14 diff --git a/docs/WINDOWS-TESTING.md b/docs/WINDOWS-TESTING.md index 2c83e0b..2f5f64e 100644 --- a/docs/WINDOWS-TESTING.md +++ b/docs/WINDOWS-TESTING.md @@ -22,9 +22,45 @@ Windows 러너를 하나 붙이면 `main` 에 push할 때(또는 Actions 탭에 | **오디오 → 한국어 자막 (전 과정)** | ❌ CPU로 됨 | ✅ | | CUDA 인식·VRAM 상한 | ✅ | ⚠️ 경고만 (빌드 통과) | -GPU가 필요한 건 마지막 한 줄뿐이고, 그건 **이 서버(.9, 리눅스)에 RTX 5050이 -물려 있어 이미 거기서 검증**하고 있습니다. GPU는 한 VM만 독점할 수 있으니 -Windows VM에는 안 주는 게 맞습니다. +### GPU가 필요한 테스트는 어떻게 하나 + +**GPU는 한 VM만 독점합니다.** 컨슈머 NVIDIA는 vGPU/SR-IOV를 지원하지 않아서 +쪼개 쓸 방법이 없습니다. 지금 RTX 5050은 `.9`(리눅스)에 물려 있습니다. + +그래서 러너를 **라벨로 둘로 나눠뒀습니다.** 지금은 앞의 것만 있으면 되고, +나중에 GPU 머신이 생기면 뒤의 것을 붙이기만 하면 됩니다. 워크플로는 이미 +그렇게 짜여 있습니다. + +| 러너 라벨 | 어디에 | 언제 도나 | 무엇을 | +|---|---|---|---| +| `windows:host` | GPU 없는 서버 VM | `main` push마다 자동 | 테스트·Windows 경로·오디오→자막·exe 빌드 | +| `windows-gpu:host` | GPU 달린 머신 | **수동 실행만** | CUDA 인식·VRAM 상한·4·5티어·GPU 번역 | + +GPU 러너를 상시로 띄울 필요는 없습니다. 필요할 때만 `daemon`을 켜고 +Actions 탭에서 `Windows GPU` → `Run workflow` 하면 됩니다. 러너가 없으면 +큐에 쌓이기만 하고 아무 일도 일어나지 않습니다. + +GPU 워크플로는 GPU가 없으면 **분명히 실패합니다.** GPU를 확인하겠다고 부른 +작업이 조용히 통과하면 안 되니까요. `nvidia-smi`가 없으면 그 자리에서 멈춥니다. + +#### GPU 머신을 어디서 구하나 + +다른 프로젝트도 이 러너에서 GPU 테스트를 돌리실 거라면 셋 중 하나입니다. + +1. **서버에 GPU 한 장 더** — 가장 깔끔합니다. PCIe 슬롯과 파워 여유만 있으면 + 그 GPU를 Windows VM에 패스스루하고 `windows-gpu` 라벨로 등록하면 끝입니다. + 이후로는 두 러너가 각자 계속 돕니다. +2. **RTX 5050을 Windows VM으로 옮기기** — `.9`가 GPU를 포기해야 합니다. + `.9`에서 하던 GPU 작업(이미지 생성 등)을 못 하게 되니 득실을 따져보세요. +3. **필요할 때만 실물 PC에 GPU 러너** — 평소엔 꺼두고 GPU 검증이 필요할 때만 + 켭니다. 수동 실행이라 게임 중에 갑자기 도는 일은 없습니다. + +1번이 가능한지는 서버에서 이걸로 확인할 수 있습니다. + +```bash +lspci | grep -i vga # GPU가 몇 장 꽂혀 있나 +dmidecode -t slot | grep -i "in use" # 빈 PCIe 슬롯이 있나 +``` ### Proxmox에 Windows VM 만들기 diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py index 251c5ab..6ee0f38 100644 --- a/packaging/windows_smoke.py +++ b/packaging/windows_smoke.py @@ -230,6 +230,83 @@ def run_pipeline_check(timeout_s: int = 900) -> None: return f"{line.source_text!r} -> {line.translated_text!r}" +# --- GPU 전용 점검 ---------------------------------------------------------- + + +def run_gpu_checks() -> None: + """GPU 가 있는 머신에서만 의미가 있는 것들. + + GPU 는 한 VM 만 독점하므로(컨슈머 NVIDIA 는 vGPU 미지원) GPU 없는 CI VM + 에서는 확인할 수 없다. 그래서 별도 러너 라벨(windows-gpu)로 떼어놨다. + 여기서는 GPU 가 없으면 **분명히 실패**해야 한다 — GPU 를 확인하겠다고 + 부른 작업이 조용히 통과하면 안 된다. + """ + print(f"\n=== GPU 전용 (플랫폼: {sys.platform}) ===") + + @check("CUDA 사용 가능") + def _cuda(): + import torch + + if not torch.cuda.is_available(): + raise RuntimeError( + "torch 가 CUDA 를 못 씁니다. CPU 빌드가 깔렸는지 확인하세요 " + "(pip install torch --index-url https://download.pytorch.org/whl/cu128)" + ) + idx = torch.cuda.current_device() + free, total = torch.cuda.mem_get_info(idx) + return f"{torch.cuda.get_device_name(idx)} · {total / 1024**3:.1f}GB (여유 {free / 1024**3:.1f}GB)" + + @check("앱의 GPU 인식") + def _detect(): + from livesub.models import detect_gpu + + gpu = detect_gpu() + if not gpu.available: + raise RuntimeError(gpu.reason) + return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB" + + @check("VRAM 상한이 실제로 걸리는지") + def _vram(): + from livesub.models.manager import apply_vram_limit + + if not apply_vram_limit(0.35): + raise RuntimeError("상한 적용 실패 — 게임과 같이 쓸 때 VRAM 을 못 막습니다") + return "35% 상한 적용됨" + + @check("torch 필요 티어(4·5)가 열리는지") + def _tiers(): + from livesub.models.manager import has_torch, tier_availability + from livesub.models.tiers import TIERS, MTBackend + + if not has_torch(): + raise RuntimeError("torch 없음 — GPU 러너에는 일반 설치가 필요합니다") + blocked = [ + t.name for t in TIERS.values() + if t.mt.backend is MTBackend.TRANSFORMERS and not tier_availability(t)[0] + ] + if blocked: + raise RuntimeError(f"막힌 티어: {', '.join(blocked)}") + return "정밀·극한 사용 가능" + + @check("GPU 로 실제 번역") + def _translate(): + from livesub.models.glossary import Glossary + from livesub.models.packs import build_glossary + from livesub.models.tiers import get_tier + from livesub.models.translator import create_translator + + tier = get_tier("swift") # CT2 지만 device=cuda 로 GPU 경로를 탄다 + 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) + if not any("\uac00" <= ch <= "\ud7a3" for ch in out): + raise RuntimeError(f"한국어가 아닙니다: {out!r}") + ARTIFACTS.mkdir(exist_ok=True) + (ARTIFACTS / "gpu-translate.txt").write_text(out + "\n", encoding="utf-8") + return repr(out) + + # --- 빌드된 exe 점검 -------------------------------------------------------- @@ -269,10 +346,16 @@ def main() -> int: "--pipeline", action="store_true", help="오디오 파일로 전 파이프라인 점검 (모델을 내려받으므로 느립니다)", ) + parser.add_argument( + "--gpu", action="store_true", + help="GPU 가 있는 머신에서만 되는 항목 점검 (CUDA·VRAM 상한·4/5티어)", + ) args = parser.parse_args() if args.exe: run_exe_check(args.exe) + elif args.gpu: + run_gpu_checks() elif args.pipeline: run_pipeline_check() else: @@ -288,13 +371,15 @@ def main() -> int: print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과") if failed: print("실패:", ", ".join(failed)) - # 오디오 장치·보조 프로그램은 러너 환경에 따라 없을 수 있어 경고로만 둔다. - fatal = [n for n in failed if n not in { + # 오디오 장치·보조 프로그램·GPU 는 러너 환경에 따라 없을 수 있어 경고로만 둔다. + # 다만 --gpu 로 부른 경우엔 GPU 가 없는 것 자체가 실패다. + lenient = { "프로그램별 캡처 보조 프로그램", "소리 내는 프로그램 열거 (pycaw)", "WASAPI 루프백 장치 열거", "GPU 인식", - }] + } + fatal = failed if args.gpu else [n for n in failed if n not in lenient] return 1 if fatal else 0