test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、 不保留全局 conftest.py、测试过程只调用真实生产代码。 结构(73 个文件、30 个模块目录、477 用例): - tests/nodes/ 15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/ subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/ proper_nouns/adaptive_pool/vad_profiler/echo); - tests/app/ 11 个模块目录(db/scheduler/batch/maintenance/registry/seed/ storage/config/logging/main/routers 三组 API); - tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。 测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore 的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。 顺带发现并修复三个真实缺陷: - nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位), 改为正文行遇时间戳行即报错; - src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI 抛 ValueError 导致任务误判失败,改为同时捕获; - nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT, 改用生产模块 nodes/srt.py。 真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py (运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py (无 Key / 余额 / 限流转跳过)。全量 477 passed。
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""GPU 显存探测与不足时跳过(供真实模型集成测试使用)。
|
||||
|
||||
真实模型(faster-whisper + CUDA)需要足够的显存;显存被其他进程占用或本身
|
||||
偏小时,推理会在中途抛 "CUDA failed with error out of memory"。这属于**外部
|
||||
运行环境状态**,不是被测代码的行为,按测试规则应跳过而不是判失败。
|
||||
|
||||
两层防护:
|
||||
|
||||
1. `require_gpu_memory()`:运行前按模型体量估算所需显存,不足则跳过;
|
||||
2. `skip_on_cuda_oom()`:运行中若仍发生显存不足(被其他进程动态抢占),
|
||||
也会被识别并转为跳过,而不是让整个套件判红。
|
||||
|
||||
探测不到 NVIDIA GPU(无 `nvidia-smi`,例如纯 CPU 机器)时**不做显存检查**:
|
||||
faster-whisper 的 `device=auto` 会回退 CPU,此时测试应当照常执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 推理时的额外开销系数:CTranslate2 除权重外还需推理工作区、激活与 CUDA 上下文。
|
||||
# 实测标定(V2 权重 2.87GB,6GB 卡):推理前占用约 1120 MiB,峰值约 5217 MiB
|
||||
# → 峰值增量约 4097 MiB ≈ 权重 × 1.39。系数取 1.45 覆盖 5% 分配余量,
|
||||
# 使 6GB 卡(可用约 4.6GB)刚好放行、而更小的卡会被拦下。
|
||||
#
|
||||
# 注意:这只给出"是否可能跑完"的预估。实测在临界卡上(余量仅数百 MB)
|
||||
# 即使预估通过仍可能因分配碎片化抛 CUDA OOM,所以还需要
|
||||
# `skip_on_cuda_oom` / `require_node_result` 作为运行中的兼底。
|
||||
_MEMORY_FACTOR = 1.45
|
||||
|
||||
|
||||
def available_gpu_memory_mb() -> int | None:
|
||||
"""返回当前可用的最大显存(MB);无 NVIDIA GPU 或探测失败时返回 None。
|
||||
|
||||
多卡时取可用显存最大的那张卡(推理默认只用一张卡)。
|
||||
"""
|
||||
if shutil.which("nvidia-smi") is None:
|
||||
return None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=memory.free",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
values: list[int] = []
|
||||
for line in completed.stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.isdigit():
|
||||
values.append(int(stripped))
|
||||
return max(values) if values else None
|
||||
|
||||
|
||||
def required_memory_mb(model_dir: Path) -> int:
|
||||
"""按模型权重体量估算推理所需显存(MB)。"""
|
||||
weights = model_dir / "model.bin"
|
||||
size_mb = weights.stat().st_size / (1024 * 1024) if weights.is_file() else 0.0
|
||||
return int(size_mb * _MEMORY_FACTOR)
|
||||
|
||||
|
||||
def require_gpu_memory(model_dir: Path) -> None:
|
||||
"""显存不足以跑完该模型时跳过测试;无 GPU 时不检查(回退 CPU 执行)。"""
|
||||
free = available_gpu_memory_mb()
|
||||
if free is None:
|
||||
return # 无 NVIDIA GPU:device=auto 会走 CPU,无需显存检查
|
||||
needed = required_memory_mb(model_dir)
|
||||
if free < needed:
|
||||
pytest.skip(
|
||||
f"可用显存不足(需约 {needed} MB,当前可用 {free} MB),"
|
||||
f"跳过真实模型集成测试以避免 OOM"
|
||||
)
|
||||
|
||||
|
||||
def is_cuda_oom(text: str) -> bool:
|
||||
"""判断错误文本是否为显存不足(CUDA OOM)。"""
|
||||
lowered = text.lower()
|
||||
return "out of memory" in lowered or "cuda failed" in lowered
|
||||
|
||||
|
||||
@contextmanager
|
||||
def skip_on_cuda_oom():
|
||||
"""运行真实推理;中途发生 CUDA OOM 时转为跳过(显存被动态抢占的场景)。"""
|
||||
try:
|
||||
yield
|
||||
except Exception as exc: # noqa: BLE001 - 需要按消息识别 OOM
|
||||
if is_cuda_oom(str(exc)):
|
||||
pytest.skip(f"GPU 显存不足({exc}),跳过真实模型集成测试")
|
||||
raise
|
||||
|
||||
|
||||
def require_node_result(response, model_dir: Path) -> None:
|
||||
"""校验节点响应:显存不足转为跳过,其它失败照常抛出由断言处理。
|
||||
|
||||
节点(如 whisper)会把推理异常包装成 `status="failed"` 的响应,因此
|
||||
OOM 不会以异常形式冒泡;这里统一识别并跳过。
|
||||
"""
|
||||
if getattr(response, "status", "") != "completed" and is_cuda_oom(
|
||||
str(getattr(response, "error", ""))
|
||||
):
|
||||
pytest.skip(
|
||||
f"GPU 显存不足({getattr(response, 'error', '')}),跳过真实模型集成测试"
|
||||
)
|
||||
|
||||
|
||||
def fits_with_margin(model_dir: Path, safety_mb: int = 512) -> bool:
|
||||
"""显存是否充裕到可承受分块/多次推理(预留 safety_mb 余量)。
|
||||
|
||||
用于区分两种用例写法:
|
||||
- 显存充裕:按生产默认走分块路径(更接近线上配置);
|
||||
- 显存临界:退化为整段单次推理,或直接跳过——分块会产生更多分配峰值,
|
||||
在临界卡上易因碎片化触发 CUDA OOM(实测同一峰值下 chunk 失败而单次成功)。
|
||||
|
||||
无 NVIDIA GPU 时返回 False(此时应改用 CPU 友好配置,而非依赖显存)。
|
||||
"""
|
||||
free = available_gpu_memory_mb()
|
||||
if free is None:
|
||||
return False
|
||||
return free >= required_memory_mb(model_dir) + safety_mb
|
||||
Reference in New Issue
Block a user