Some checks failed
Windows / verify (push) Has been cancelled
실제 GPU 로 검증을 돌리다 발견한 버그다. tier_availability() 가 torch 유무만
봐서, RTX 5050(7.5GB)에서도 정밀·극한 티어를 "사용 가능"이라고 답했다.
정밀(AWQ Int4) autoawq 가 있어야 로드된다. requirements.txt 에 없는
선택 의존성이라 대개 없다.
극한(Qwen3-8B) min_vram_gb=16. 7.5GB 카드에서는 영영 불가능하다.
둘 다 "고를 수 있다"고 해놓고 로드에서 죽는다. 사용자는 원인을 알 수 없다.
UI 에 min_vram_gb 경고 배지는 있었지만 경고만 하고 선택은 막지 않았다.
- tier_availability(tier, gpu=None) 에 autoawq·VRAM 검사 추가.
새 필드를 만들지 않고 이미 있던 min_vram_gb 를 쓴다.
free 가 아니라 total 로 본다 — free 로 막으면 다른 프로그램 때문에
"아까는 되던 티어가 지금은 안 보인다"가 된다.
CT2 티어(1~3)는 조기 반환이라 detect_gpu 를 아예 부르지 않는다.
(포터블에서 torch 도 GPU 도 없이 돌아야 하므로)
- GpuInfo 를 인자로 받아 루프에서 nvidia-smi 반복 호출을 막았다.
models_page 는 티어 카드 5개를 만들며 매번 조회하던 것을 __init__ 에서
한 번만 하도록 바꿨다. 빌더 함수의 부수효과에 의존하던 것도 제거.
- 점검기의 판정 기준도 고쳤다. 기존엔 "4·5티어가 전부 열려야 통과"였는데
VRAM 작은 GPU 에서 막히는 건 정상이라 오탐이었다. 이제 게이트는 판정
없이 보고만 하고, "게이트가 된다고 한 티어가 실제로 올라가는지" 가
판정한다. 게이트가 전부 막았으면 검증 대상이 없다는 사실을 남기고
통과시킨다 — 하드웨어 한계이지 회귀가 아니다.
- scripts/gpu-check.sh 추가. CTranslate2 가 libcublas.so.12 를 직접 찾는데
torch 의 nvidia 패키지 안에 있어 LD_LIBRARY_PATH 가 필요하다. 없으면
GPU 번역만 조용히 실패한다. 스크립트가 경로를 자동으로 잡는다.
검증: pytest 190 passed (신규 4개 중 3개는 torch 필요 -> gpu-venv 에서
실제로 실행해 9 passed 확인), ruff clean,
RTX 5050 에서 scripts/gpu-check.sh 6/6 통과 (종료코드 0)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
"""포터블 빌드(= torch 없는 환경) 검증.
|
|
|
|
포터블 exe 는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라 onefile 로
|
|
묶으면 실행할 때마다 임시폴더에 푸느라 못 쓸 만큼 느려지기 때문이다.
|
|
|
|
그 대신 **1~3티어(CTranslate2)는 torch 없이 전부 돌아야 한다.** 이 파일은
|
|
그 전제가 깨지지 않았는지 지킨다. 특히 `CTranslate2Translator.load()` 가
|
|
torch 없이 통과하는지 — 한 번 이걸 놓쳐서 포터블이 실행 즉시 죽을 뻔했다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from livesub.models.manager import first_available_tier, has_torch, tier_availability
|
|
from livesub.models.tiers import TIERS, MTBackend, ordered_tiers
|
|
|
|
SPEC = Path(__file__).resolve().parents[1] / "packaging" / "livesub.spec"
|
|
PORTABLE_REQS = Path(__file__).resolve().parents[1] / "packaging" / "requirements-portable.txt"
|
|
|
|
#: CTranslate2 경로가 실제로 import 하는 것들. 포터블에 반드시 들어가야 한다.
|
|
CT2_RUNTIME_IMPORTS = ["ctranslate2", "transformers", "huggingface_hub", "faster_whisper"]
|
|
|
|
|
|
def _spec_list(name: str) -> list[str]:
|
|
"""spec 파일에서 excludes / hiddenimports 리스트를 정적으로 읽는다."""
|
|
tree = ast.parse(SPEC.read_text(encoding="utf-8"))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Assign):
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name) and target.id == name:
|
|
return [
|
|
e.value for e in node.value.elts if isinstance(e, ast.Constant)
|
|
]
|
|
raise AssertionError(f"spec 에 {name} 가 없습니다")
|
|
|
|
|
|
def test_spec_excludes_torch():
|
|
"""torch 가 들어가면 exe 가 3GB 로 불어나 포터블의 의미가 사라진다."""
|
|
assert "torch" in _spec_list("excludes")
|
|
|
|
|
|
def test_spec_does_not_exclude_transformers():
|
|
"""NLLB 토크나이저가 transformers 를 쓰므로 제외하면 안 된다.
|
|
|
|
한 번 제외했다가 포터블에서 번역 모델 로드가 통째로 실패할 뻔했다.
|
|
"""
|
|
assert "transformers" not in _spec_list("excludes")
|
|
|
|
|
|
@pytest.mark.parametrize("module", CT2_RUNTIME_IMPORTS)
|
|
def test_ct2_runtime_deps_are_in_portable_requirements(module):
|
|
text = PORTABLE_REQS.read_text(encoding="utf-8")
|
|
name = module.replace("_", "-")
|
|
assert name in text or module in text, f"{module} 가 포터블 의존성에 없습니다"
|
|
|
|
|
|
def test_portable_requirements_have_no_torch():
|
|
text = PORTABLE_REQS.read_text(encoding="utf-8").lower()
|
|
for line in text.splitlines():
|
|
line = line.split("#")[0].strip()
|
|
assert not line.startswith("torch"), f"포터블에 torch 가 있습니다: {line}"
|
|
|
|
|
|
# --- torch 없는 환경에서의 실제 동작 -----------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
|
|
def test_ct2_translator_loads_without_torch():
|
|
"""리뷰에서 지적된 지점 — 포터블에서 load() 가 죽으면 앱을 못 쓴다.
|
|
|
|
모델 가중치는 받지 않고, load() 가 실제로 부르는 import 들이 torch 없이
|
|
통과하는지만 본다.
|
|
"""
|
|
for module in CT2_RUNTIME_IMPORTS:
|
|
assert importlib.util.find_spec(module) is not None, f"{module} 없음"
|
|
from transformers import AutoTokenizer # noqa: F401 - import 자체가 검증
|
|
|
|
importlib.import_module("ctranslate2")
|
|
|
|
|
|
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
|
|
def test_ct2_tiers_are_usable_without_torch():
|
|
for tier in ordered_tiers():
|
|
if tier.mt.backend is MTBackend.CTRANSLATE2:
|
|
assert tier_availability(tier)[0], f"{tier.key} 가 포터블에서 막혔습니다"
|
|
|
|
|
|
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
|
|
def test_llm_tiers_are_blocked_without_torch():
|
|
"""쓸 수 없는 티어는 '사용 불가'로 막고 이유를 알려줘야 한다."""
|
|
for tier in TIERS.values():
|
|
if tier.mt.backend is MTBackend.TRANSFORMERS:
|
|
ok, reason = tier_availability(tier)
|
|
assert not ok
|
|
assert "PyTorch" in reason
|
|
|
|
|
|
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
|
|
def test_default_tier_is_usable_in_portable():
|
|
"""기본 티어가 포터블에서 못 쓰는 것이면 첫 실행부터 막힌다."""
|
|
from livesub.config import AppConfig
|
|
|
|
assert tier_availability(TIERS[AppConfig().models.tier])[0]
|
|
|
|
|
|
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
|
|
def test_unavailable_tier_falls_back_to_best_usable():
|
|
assert first_available_tier("precision") == "balance"
|
|
assert first_available_tier("ultimate") == "balance"
|
|
|
|
|
|
@pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님")
|
|
def test_app_window_builds_without_torch(tmp_path, monkeypatch):
|
|
"""포터블에서 앱 전체가 뜨는지 (오디오 장치도 GPU도 없이)."""
|
|
pytest.importorskip("PySide6")
|
|
import os
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
from livesub.config import AppConfig
|
|
from livesub.ui.main_window import MainWindow
|
|
|
|
app = QApplication.instance() or QApplication([])
|
|
cfg = AppConfig()
|
|
cfg.models.preload_on_start = False
|
|
cfg.glossary.path = str(tmp_path / "g.json")
|
|
win = MainWindow(cfg)
|
|
try:
|
|
assert win.stack.count() == 5
|
|
finally:
|
|
win.engine.shutdown()
|
|
win.overlay.close()
|
|
win.close()
|
|
_ = app
|
|
|
|
|
|
# --- 티어 게이트가 실제 제약을 보는지 -------------------------------------
|
|
#
|
|
# 예전에는 torch 유무만 봤다. 그래서 8GB 카드에서도 "극한(16GB 필요) 사용
|
|
# 가능"이라고 답했고, 사용자가 고르면 로드에서 죽었다. 실제 GPU 로 검증하다
|
|
# 발견한 버그다.
|
|
|
|
|
|
def _gpu(total_gb: float):
|
|
from livesub.models.manager import GpuInfo
|
|
|
|
return GpuInfo(available=True, name="test", total_vram_mb=int(total_gb * 1024))
|
|
|
|
|
|
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
|
|
@pytest.mark.parametrize("key", ["precision", "ultimate"])
|
|
def test_vram_이_모자라면_티어를_막는다(key, monkeypatch):
|
|
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True)
|
|
ok, reason = tier_availability(TIERS[key], _gpu(7.5))
|
|
assert not ok
|
|
assert "VRAM" in reason and "7.5GB" in reason
|
|
|
|
|
|
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
|
|
def test_vram_이_충분하면_통과한다(monkeypatch):
|
|
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True)
|
|
assert tier_availability(TIERS["ultimate"], _gpu(24))[0]
|
|
|
|
|
|
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
|
|
def test_autoawq_가_없으면_int4_티어를_막는다(monkeypatch):
|
|
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: False)
|
|
ok, reason = tier_availability(TIERS["precision"], _gpu(24))
|
|
assert not ok
|
|
assert "autoawq" in reason
|
|
|
|
|
|
def test_ct2_티어는_gpu_조회_없이도_통과한다():
|
|
"""1~3티어는 torch 도 GPU 도 안 따진다 — 포터블에서 돌아야 하므로."""
|
|
def _boom():
|
|
raise AssertionError("CT2 티어는 detect_gpu 를 부르면 안 된다")
|
|
|
|
import livesub.models.manager as mgr
|
|
|
|
original, mgr.detect_gpu = mgr.detect_gpu, _boom
|
|
try:
|
|
for key in ("lightning", "swift", "balance"):
|
|
assert tier_availability(TIERS[key])[0]
|
|
finally:
|
|
mgr.detect_gpu = original
|