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>
239 lines
8.2 KiB
Python
239 lines
8.2 KiB
Python
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QButtonGroup,
|
|
QComboBox,
|
|
QFrame,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QPushButton,
|
|
QRadioButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from ...config import AppConfig
|
|
from ...models import Tier, detect_gpu, ordered_tiers
|
|
from ...models.manager import GpuInfo, tier_availability
|
|
from ..theme import SPACING, palette
|
|
from ..widgets import Card, PageHeader
|
|
|
|
|
|
class TierCard(QFrame):
|
|
"""티어 하나를 라디오 버튼 카드로."""
|
|
|
|
def __init__(self, tier: Tier, gpu: GpuInfo, parent: QWidget | None = None):
|
|
super().__init__(parent)
|
|
self.tier = tier
|
|
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
|
self.available, self.unavailable_reason = tier_availability(tier, gpu)
|
|
self.setObjectName("Card")
|
|
p = palette("dark")
|
|
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(16, 14, 16, 14)
|
|
layout.setSpacing(14)
|
|
|
|
self.radio = QRadioButton()
|
|
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
|
|
|
|
text = QVBoxLayout()
|
|
text.setSpacing(3)
|
|
|
|
head = QHBoxLayout()
|
|
head.setSpacing(8)
|
|
name = QLabel(f"{tier.order}. {tier.name}")
|
|
name.setStyleSheet("font-size: 16px; font-weight: 700;")
|
|
head.addWidget(name)
|
|
if tier.recommended:
|
|
head.addWidget(_badge("추천", p.accent))
|
|
if tier.best_after_finetune:
|
|
head.addWidget(_badge("추가학습 최적", p.success))
|
|
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
|
|
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
|
|
if not self.available:
|
|
head.addWidget(_badge("사용 불가", p.danger))
|
|
head.addStretch(1)
|
|
text.addLayout(head)
|
|
|
|
tagline = QLabel(tier.tagline)
|
|
tagline.setObjectName("Subtitle")
|
|
text.addWidget(tagline)
|
|
|
|
spec = QLabel(
|
|
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
|
|
f"· 내려받기 약 {tier.download_gb:g}GB"
|
|
)
|
|
spec.setObjectName("Hint")
|
|
text.addWidget(spec)
|
|
|
|
if tier.notes:
|
|
note = QLabel(tier.notes)
|
|
note.setObjectName("Hint")
|
|
note.setWordWrap(True)
|
|
text.addWidget(note)
|
|
|
|
if not self.available:
|
|
# 포터블 exe 에는 torch 가 없어 LLM 티어를 못 쓴다. 눌러도 안 되는
|
|
# 이유를 카드에 적어둬야 사용자가 헤매지 않는다.
|
|
blocked = QLabel(self.unavailable_reason)
|
|
blocked.setStyleSheet(f"color: {p.warning};")
|
|
blocked.setWordWrap(True)
|
|
text.addWidget(blocked)
|
|
self.radio.setEnabled(False)
|
|
self.setEnabled(True)
|
|
|
|
layout.addLayout(text, 1)
|
|
|
|
def set_selected(self, selected: bool) -> None:
|
|
self.setObjectName("CardSelected" if selected else "Card")
|
|
self.style().unpolish(self)
|
|
self.style().polish(self)
|
|
|
|
|
|
class ModelsPage(QWidget):
|
|
tier_changed = Signal(str)
|
|
config_changed = Signal()
|
|
|
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
|
super().__init__(parent)
|
|
self.config = config
|
|
self._cards: list[TierCard] = []
|
|
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
|
|
self._gpu = detect_gpu()
|
|
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
|
|
|
|
root = QVBoxLayout(self)
|
|
root.setContentsMargins(0, 0, 0, 0)
|
|
root.setSpacing(SPACING + 4)
|
|
root.addWidget(
|
|
PageHeader(
|
|
"모델",
|
|
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
|
|
"모델은 처음 사용할 때 자동으로 내려받습니다.",
|
|
)
|
|
)
|
|
|
|
root.addWidget(self._build_gpu_card())
|
|
|
|
self._group = QButtonGroup(self)
|
|
self._group.setExclusive(True)
|
|
for tier in ordered_tiers():
|
|
card = TierCard(tier, self._gpu)
|
|
self._group.addButton(card.radio, tier.order)
|
|
card.radio.toggled.connect(
|
|
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
|
)
|
|
self._cards.append(card)
|
|
root.addWidget(card)
|
|
|
|
root.addWidget(self._build_lora_card())
|
|
root.addStretch(1)
|
|
self._apply_selection(config.models.tier)
|
|
|
|
# --- 구성 -----------------------------------------------------------
|
|
def _build_gpu_card(self) -> Card:
|
|
gpu = self._gpu
|
|
p = palette("dark")
|
|
|
|
card = Card("실행 환경")
|
|
row = QWidget()
|
|
layout = QHBoxLayout(row)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(12)
|
|
|
|
if gpu.available:
|
|
text = (
|
|
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
|
|
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
|
|
)
|
|
color = p.success
|
|
else:
|
|
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
|
|
color = p.warning
|
|
info = QLabel(text)
|
|
info.setStyleSheet(f"color: {color}; font-weight: 600;")
|
|
info.setWordWrap(True)
|
|
layout.addWidget(info, 1)
|
|
|
|
self.device_combo = QComboBox()
|
|
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
|
self.device_combo.addItem("CPU", "cpu")
|
|
self.device_combo.setCurrentIndex(
|
|
max(0, self.device_combo.findData(self.config.models.device))
|
|
)
|
|
self.device_combo.setEnabled(gpu.available)
|
|
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
|
|
layout.addWidget(self.device_combo)
|
|
|
|
card.add(row)
|
|
return card
|
|
|
|
def _build_lora_card(self) -> Card:
|
|
card = Card(
|
|
"추가학습 어댑터 (선택)",
|
|
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
|
|
"scripts/finetune_mt.py 로 만들 수 있습니다.",
|
|
)
|
|
row = QWidget()
|
|
layout = QHBoxLayout(row)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(10)
|
|
|
|
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
|
|
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
|
|
self.lora_edit.editingFinished.connect(self._on_lora_changed)
|
|
layout.addWidget(self.lora_edit, 1)
|
|
|
|
browse = QPushButton("폴더 선택")
|
|
browse.clicked.connect(self._browse_lora)
|
|
layout.addWidget(browse)
|
|
|
|
card.add(row)
|
|
return card
|
|
|
|
# --- 동작 -----------------------------------------------------------
|
|
def _apply_selection(self, tier_key: str) -> None:
|
|
for card in self._cards:
|
|
selected = card.tier.key == tier_key
|
|
card.radio.setChecked(selected)
|
|
card.set_selected(selected)
|
|
|
|
def _on_tier_selected(self, tier: Tier) -> None:
|
|
if self.config.models.tier == tier.key:
|
|
return
|
|
self.config.models.tier = tier.key
|
|
for card in self._cards:
|
|
card.set_selected(card.tier.key == tier.key)
|
|
self.tier_changed.emit(tier.key)
|
|
self.config_changed.emit()
|
|
|
|
def _on_device_changed(self) -> None:
|
|
self.config.models.device = self.device_combo.currentData()
|
|
self.config_changed.emit()
|
|
|
|
def _on_lora_changed(self) -> None:
|
|
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
|
|
self.config_changed.emit()
|
|
|
|
def _browse_lora(self) -> None:
|
|
from PySide6.QtWidgets import QFileDialog
|
|
|
|
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
|
|
if path:
|
|
self.lora_edit.setText(path)
|
|
self._on_lora_changed()
|
|
|
|
|
|
def _badge(text: str, color: str) -> QLabel:
|
|
label = QLabel(text)
|
|
label.setStyleSheet(
|
|
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
|
|
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
|
|
)
|
|
return label
|