Files
ModelTrainEval/tests/test_train_smoke.py
T
Shaun ZhouandClaude Opus 5 141cb907f2 feat: 支持 cuda / mps / cpu 三后端,Mac 上可用 MPS 训练
移除"训练必须有 Nvidia GPU"的硬性守卫,改为按后端能力分派。

- utils/device: resolve_device("auto") 按 cuda > mps > cpu 挑最快的可用后端;
  显式指定不可用的后端时直接报错,不会悄悄退回 CPU——训练慢几十倍却不吭声是最坏的情况
- AMP 随后端取默认值:CUDA 开、MPS 关。MPS 上 autocast 数值正常但实测更慢
  (tiny_conv @128²:fp32 76 ms/步、fp16 90 ms、bf16 108 ms),需要时 amp: true 可强开
- PeakMemory 统一显存峰值统计:CUDA 读内置计数器,MPS 无峰值 API 改用逐 step 采样
  (是下界,日志与产出里已标注)
- profile_inference 支持 MPS,并提示 torch.profiler 没有 MPS 的 ProfilerActivity,
  算子明细只反映 CPU 侧下发耗时
- train.py / eval.py 新增 --device,配置默认改为 auto
- check_env 改为列出全部可用后端与 auto 的选择结果

实测:tiny_conv 训练在 MPS 上比 CPU 快约 32 倍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:43:35 +08:00

153 lines
5.0 KiB
Python

"""train.py / eval.py 的端到端冒烟:YOLO 数据目录 -> 训练 -> 评估。
覆盖入口脚本的接线:classes 解析、label_mode 下发、设备解析。固定用 CPU 跑,
免得测试受机器上有没有 GPU 影响。
"""
import sys
import pytest
pytest.importorskip("torch")
pytest.importorskip("yaml")
pytest.importorskip("PIL")
import numpy as np # noqa: E402
import yaml # noqa: E402
from PIL import Image # noqa: E402
import eval as eval_mod # noqa: E402
import train as train_mod # noqa: E402
from datasets import write_data_yaml # noqa: E402
CLASSES = ["scratch", "bubble"]
TRANSFORMS = [
{"name": "Resize", "size": [32, 32]},
{"name": "ToTensor"},
{"name": "Normalize", "mean": [0.5, 0.5, 0.5], "std": [0.5, 0.5, 0.5]},
]
@pytest.fixture
def project(tmp_path):
"""造一个最小的 YOLO 数据集 + 配置,classes 用 data.yaml 的路径给。"""
for split in ("train", "val"):
images = tmp_path / "images" / split
labels = tmp_path / "labels" / split
images.mkdir(parents=True)
labels.mkdir(parents=True)
for i in range(2):
Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8)).save(images / f"{i}.png")
content = "0 0.5 0.5 0.25 0.25\n" if i else ""
(labels / f"{i}.txt").write_text(content, encoding="utf-8")
data_yaml = write_data_yaml(tmp_path / "data.yaml", CLASSES, root=tmp_path)
def dataset(split):
return {
"dataset": {"name": "film_defect", "img_dir": str(tmp_path / "images" / split),
"transforms": TRANSFORMS},
"loader": {"batch_size": 2, "num_workers": 0, "shuffle": split == "train"},
}
config = {
"classes": str(data_yaml),
"label_mode": "multilabel",
"image_size": [32, 32],
"device": "cpu",
"seed": 0,
"model": {
"name": "defect_net",
"backbone": {"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]},
"neck": {"name": "upsample", "channels": 8, "num_upsamples": 1},
"head": {"hidden_channels": 8},
},
"data": {"train": dataset("train"), "val": dataset("val")},
"train": {
"epochs": 1,
"amp": False,
"log_interval": 10,
"optimizer": {"name": "adamw", "lr": 0.001},
"swanlab": {"enabled": False},
},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
return config_path, tmp_path
def run(module, argv, monkeypatch):
monkeypatch.setattr(sys, "argv", argv)
return module.main()
def test_train_then_eval(project, monkeypatch, tmp_path):
config_path, root = project
work_dir = tmp_path / "runs"
assert run(train_mod, ["train.py", "--config", str(config_path), "--work-dir", str(work_dir)],
monkeypatch) == 0
assert (work_dir / "best.pth").exists()
assert (work_dir / "config.yaml").exists()
metrics_path = tmp_path / "metrics.json"
assert run(
eval_mod,
["eval.py", "--config", str(config_path), "--checkpoint", str(work_dir / "best.pth"),
"--set", "device=cpu", "--out", str(metrics_path)],
monkeypatch,
) == 0
assert metrics_path.exists()
def test_classes_from_data_yaml_sizes_the_heads(project, monkeypatch, tmp_path):
"""classes 写成 data.yaml 路径时,num_classes 必须是 2,而不是路径字符串的长度。"""
config_path, _ = project
work_dir = tmp_path / "runs2"
captured = {}
real_build = train_mod.build_model
def spy(cfg):
captured["cfg"] = dict(cfg)
return real_build(cfg)
monkeypatch.setattr(train_mod, "build_model", spy)
run(train_mod, ["train.py", "--config", str(config_path), "--work-dir", str(work_dir)], monkeypatch)
assert captured["cfg"]["num_classes"] == len(CLASSES)
assert captured["cfg"]["label_mode"] == "multilabel"
def test_unavailable_device_fails_loudly(project, monkeypatch, tmp_path):
"""显式请求机器上没有的后端时必须退出,而不是悄悄退回 CPU。"""
from utils.device import available_backends
missing = next((b for b in ("cuda", "mps") if b not in available_backends()), None)
if missing is None:
pytest.skip("这台机器 cuda 与 mps 都可用")
assert run(
train_mod,
["train.py", "--config", str(config_path_of(project)), "--work-dir", str(tmp_path / "runs3"),
"--device", missing],
monkeypatch,
) == 1
def test_device_flag_overrides_config(project, monkeypatch, tmp_path):
"""--device 优先于配置里的 device。"""
config_path, _ = project
work_dir = tmp_path / "runs4"
assert run(
train_mod,
["train.py", "--config", str(config_path), "--work-dir", str(work_dir), "--device", "cpu"],
monkeypatch,
) == 0
assert (work_dir / "best.pth").exists()
def config_path_of(project):
return project[0]