424 lines
17 KiB
Python
424 lines
17 KiB
Python
"""
|
||
真实训练执行器(基于项目内 train.py 调用 LLaMA-Factory 库)
|
||
|
||
- 根据任务配置构建 `python train.py` 命令(非 llamafactory-cli 子命令)
|
||
- 实时捕获训练日志与 trainer_log.jsonl 的 loss,回写到 PlatformStore
|
||
- 支持 SIGSTOP / SIGCONT / SIGKILL 实现暂停 / 恢复 / 取消
|
||
- 训练完成后自动绘制 loss 曲线(matplotlib 可选)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import queue
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from app.db.platform_store import get_platform_store
|
||
|
||
BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||
DATA_DIR = BACKEND_ROOT / "data"
|
||
DATASET_INFO_PATH = DATA_DIR / "dataset_info.json"
|
||
DATASET_STORE_DIR = DATA_DIR / "fine_tune_datasets"
|
||
OUTPUT_ROOT = DATA_DIR / "fine_tune_outputs"
|
||
TRAIN_SCRIPT = BACKEND_ROOT / "train.py"
|
||
|
||
_running_processes: dict[str, "subprocess.Popen[Any]"] = {}
|
||
|
||
_stage_map = {"sft": "sft", "dpo": "dpo", "cpt": "pt", "cot": "sft"}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 工具函数
|
||
# ──────────────────────────────────────────────
|
||
def _log(store: Any, task_id: str, msg: str, log_type: str = "info") -> None:
|
||
try:
|
||
task = store.task(task_id)
|
||
logs = list(task.get("logs") or [])
|
||
logs.append({"time": datetime.now().strftime("%H:%M:%S"), "msg": msg, "type": log_type})
|
||
store.update_task_runtime(task_id, extra={"logs": logs})
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _resolve_model_path(store: Any, name_or_path: str) -> str:
|
||
"""把模型短名解析为本地绝对路径;已是合法路径则原样返回。"""
|
||
if not name_or_path:
|
||
return name_or_path
|
||
p = Path(name_or_path)
|
||
if p.exists() and (p / "config.json").exists():
|
||
return str(p.resolve())
|
||
for m in store.models() or []:
|
||
if m.get("name") == name_or_path or m.get("path") == name_or_path:
|
||
resolved = Path(m.get("path", ""))
|
||
if resolved.exists():
|
||
return str(resolved.resolve())
|
||
return name_or_path
|
||
|
||
|
||
def _materialize_dataset(store: Any, dataset_id: str) -> str:
|
||
"""把数据集管理系统的 UUID 数据集落盘并注册进 dataset_info.json,返回 --dataset key。"""
|
||
if not dataset_id or dataset_id == "identity":
|
||
return dataset_id
|
||
try:
|
||
existing = json.loads(DATASET_INFO_PATH.read_text(encoding="utf-8")) if DATASET_INFO_PATH.exists() else {}
|
||
except Exception:
|
||
existing = {}
|
||
if dataset_id in existing:
|
||
return dataset_id
|
||
try:
|
||
ds = store.dataset(dataset_id)
|
||
except Exception:
|
||
return dataset_id
|
||
files = ds.get("files") or []
|
||
if not files:
|
||
return dataset_id
|
||
file_id = files[0].get("id")
|
||
ext = files[0].get("ext", ".json")
|
||
if ext not in (".json", ".jsonl"):
|
||
ext = ".json"
|
||
if not file_id:
|
||
return dataset_id
|
||
try:
|
||
row = store.dataset_file(file_id)
|
||
except Exception:
|
||
return dataset_id
|
||
content = row.get("content", "")
|
||
DATASET_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||
actual = DATASET_STORE_DIR / f"{dataset_id}{ext}"
|
||
actual.write_text(content, encoding="utf-8")
|
||
rel = actual.relative_to(DATA_DIR)
|
||
entry: dict[str, Any] = {"file_name": str(rel)}
|
||
try:
|
||
text = content.strip()
|
||
if text.startswith("["):
|
||
text = text[text.find("{") : text.find("}") + 1]
|
||
sample = json.loads(text)
|
||
if isinstance(sample, dict) and ("messages" in sample or "conversations" in sample):
|
||
key = "messages" if "messages" in sample else "conversations"
|
||
entry["formatting"] = "sharegpt"
|
||
entry["columns"] = {"messages": key}
|
||
except Exception:
|
||
pass
|
||
existing[dataset_id] = entry
|
||
DATASET_INFO_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
DATASET_INFO_PATH.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return dataset_id
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 训练主流程
|
||
# ──────────────────────────────────────────────
|
||
def run_training(task_id: str) -> None:
|
||
store = get_platform_store()
|
||
try:
|
||
task = store.task(task_id)
|
||
except Exception:
|
||
return
|
||
|
||
cfg = dict(task)
|
||
mode = (cfg.get("train_type") or "sft").lower()
|
||
stage = _stage_map.get(mode, "sft")
|
||
|
||
base_model = _resolve_model_path(store, cfg.get("base_model", ""))
|
||
train_dataset = _materialize_dataset(store, cfg.get("train_dataset_id", ""))
|
||
eval_dataset = _materialize_dataset(store, cfg.get("eval_dataset_id") or cfg.get("eval_dataset", ""))
|
||
|
||
finetuning_type = (cfg.get("train_method") or "lora").lower()
|
||
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||
output_dir = str(OUTPUT_ROOT / task["name"])
|
||
|
||
env = os.environ.copy()
|
||
env.setdefault("HF_HUB_OFFLINE", "1")
|
||
env.setdefault("TRANSFORMERS_OFFLINE", "1")
|
||
env.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||
|
||
gpus = cfg.get("gpus") or [0]
|
||
num_gpus = int(cfg.get("num_gpus", len(gpus)) or 1) or 1
|
||
# 仅当显式指定非默认 GPU 时限制可见设备;多卡统一走 torchrun
|
||
if gpus and str(gpus[0]) not in ("0", "gpu-0"):
|
||
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(g).replace("gpu-", "") for g in gpus)
|
||
num_gpus = len(gpus)
|
||
|
||
if num_gpus > 1:
|
||
_log(store, task_id, f"[INFO] 启用分布式训练: {num_gpus} 个 GPU", "info")
|
||
cmd = [sys.executable, "-m", "torch.distributed.run", "--nproc_per_node", str(num_gpus), str(TRAIN_SCRIPT)]
|
||
else:
|
||
cmd = [sys.executable, str(TRAIN_SCRIPT)]
|
||
cmd += [
|
||
"--stage", stage,
|
||
"--do_train", "True",
|
||
"--model_name_or_path", base_model,
|
||
"--dataset", train_dataset,
|
||
"--dataset_dir", str(DATA_DIR),
|
||
"--template", cfg.get("template", "default"),
|
||
"--finetuning_type", finetuning_type,
|
||
"--output_dir", output_dir,
|
||
"--trust_remote_code", "True",
|
||
"--overwrite_output_dir", "True",
|
||
"--report_to", "none",
|
||
"--learning_rate", str(cfg.get("learning_rate", "1e-5")),
|
||
"--num_train_epochs", str(cfg.get("n_epochs", 3)),
|
||
"--per_device_train_batch_size", str(cfg.get("batch_size", 4)),
|
||
"--cutoff_len", str(cfg.get("max_length", 1024)),
|
||
"--gradient_accumulation_steps", str(cfg.get("gradient_accumulation_steps", 4)),
|
||
"--max_samples", str(cfg.get("max_samples", 100000)),
|
||
"--lr_scheduler_type", cfg.get("lr_scheduler_type", "cosine"),
|
||
"--warmup_ratio", str(cfg.get("warmup_ratio", 0.03)),
|
||
"--max_grad_norm", str(cfg.get("max_grad_norm", "1.0")),
|
||
"--optim", cfg.get("optim", "adamw_torch"),
|
||
"--logging_steps", str(cfg.get("logging_steps", 10)),
|
||
"--save_steps", str(cfg.get("save_steps", 100)),
|
||
"--save_total_limit", str(cfg.get("save_total_limit", 5)),
|
||
"--flash_attn", cfg.get("flash_attn", "auto"),
|
||
]
|
||
dtype = cfg.get("dtype", "bf16")
|
||
if dtype == "bf16":
|
||
cmd += ["--bf16", "True"]
|
||
elif dtype == "fp16":
|
||
cmd += ["--fp16", "True"]
|
||
if finetuning_type == "lora":
|
||
cmd += [
|
||
"--lora_rank", str(cfg.get("lora_rank", 8)),
|
||
"--lora_alpha", str(cfg.get("lora_alpha", 16)),
|
||
"--lora_dropout", str(cfg.get("lora_dropout", "0.05")),
|
||
"--lora_target", cfg.get("lora_target", "all"),
|
||
]
|
||
if cfg.get("do_eval", False):
|
||
cmd += ["--do_eval", "True", "--eval_strategy", "steps",
|
||
"--eval_steps", str(cfg.get("eval_steps", 50)),
|
||
"--per_device_eval_batch_size", "4"]
|
||
if eval_dataset:
|
||
cmd += ["--eval_dataset", eval_dataset]
|
||
else:
|
||
cmd += ["--val_size", str(cfg.get("val_size", "0.1"))]
|
||
if cfg.get("resume_from"):
|
||
cmd += ["--resume_from_checkpoint", str(cfg["resume_from"])]
|
||
|
||
_log(store, task_id, f"[INFO] 训练任务启动: {mode.upper()} 微调")
|
||
_log(store, task_id, f"[INFO] 基座模型: {base_model}")
|
||
_log(store, task_id, f"[INFO] 数据集: {train_dataset}")
|
||
_log(store, task_id, f"[INFO] 输出目录: {output_dir}")
|
||
store.update_task_runtime(task_id, status="running", progress=10, process_id=None)
|
||
|
||
try:
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
cwd=str(BACKEND_ROOT),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
env=env,
|
||
start_new_session=True,
|
||
)
|
||
_running_processes[task_id] = process
|
||
store.update_task_runtime(task_id, process_id=process.pid)
|
||
_log(store, task_id, f"[INFO] 训练进程已启动 (PID: {process.pid})", "info")
|
||
|
||
log_file = Path(output_dir) / "trainer_log.jsonl"
|
||
last_pos = 0
|
||
stop_flag = threading.Event()
|
||
finished = threading.Event()
|
||
|
||
def watch_logs() -> None:
|
||
nonlocal last_pos
|
||
while not stop_flag.is_set():
|
||
time.sleep(1)
|
||
if not log_file.exists():
|
||
continue
|
||
try:
|
||
with open(log_file, "r", encoding="utf-8") as lf:
|
||
lf.seek(last_pos)
|
||
for line in lf:
|
||
try:
|
||
data = json.loads(line)
|
||
if "loss" not in data:
|
||
continue
|
||
loss = float(data["loss"])
|
||
history = list(store.task(task_id).get("loss_history") or [])
|
||
history.append(loss)
|
||
extra: dict[str, Any] = {"current_loss": loss, "loss_history": history}
|
||
if data.get("lr") is not None:
|
||
extra["learning_rate"] = float(str(data["lr"]).replace("'", ""))
|
||
if data.get("epoch") is not None:
|
||
extra["current_epoch"] = float(data["epoch"])
|
||
if data.get("percentage") is not None:
|
||
extra["progress"] = float(data["percentage"])
|
||
if data.get("remaining_time"):
|
||
extra["eta"] = str(data["remaining_time"])
|
||
store.update_task_runtime(task_id, extra=extra)
|
||
step = data.get("current_steps")
|
||
total = data.get("total_steps")
|
||
_log(store, task_id, f"Step {step}/{total} loss={loss:.4f}")
|
||
try:
|
||
if int(step) >= int(total):
|
||
finished.set()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
last_pos = lf.tell()
|
||
except Exception:
|
||
pass
|
||
|
||
watcher = threading.Thread(target=watch_logs, daemon=True)
|
||
watcher.start()
|
||
|
||
q: "queue.Queue[str]" = queue.Queue()
|
||
|
||
def reader() -> None:
|
||
try:
|
||
for line in process.stdout:
|
||
q.put(line.rstrip("\r\n"))
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
q.put("")
|
||
|
||
r = threading.Thread(target=reader, daemon=True)
|
||
r.start()
|
||
|
||
while True:
|
||
try:
|
||
raw = q.get(timeout=1)
|
||
except queue.Empty:
|
||
if finished.is_set() or (process.poll() is not None and q.empty()):
|
||
try:
|
||
raw = q.get(timeout=1)
|
||
except queue.Empty:
|
||
break
|
||
else:
|
||
continue
|
||
if not raw:
|
||
break
|
||
low = raw.lower()
|
||
log_type = "error" if "error" in low else ("warn" if "warn" in low else "info")
|
||
_log(store, task_id, raw, log_type)
|
||
|
||
stop_flag.set()
|
||
try:
|
||
process.stdout.close()
|
||
except Exception:
|
||
pass
|
||
watcher.join(timeout=3)
|
||
try:
|
||
process.wait(timeout=60)
|
||
except subprocess.TimeoutExpired:
|
||
process.kill()
|
||
process.wait()
|
||
|
||
ret = process.returncode
|
||
if ret == 0:
|
||
store.update_task_runtime(task_id, status="completed", progress=100)
|
||
_log(store, task_id, "[INFO] 训练完成!", "info")
|
||
try:
|
||
store.ensure_trained_model_for_task(task_id, cfg, output_dir)
|
||
except Exception as exc:
|
||
_log(store, task_id, f"[WARN] 登记训练产物失败: {exc}", "warn")
|
||
try:
|
||
_plot_loss_curve(task_id, output_dir, bool(cfg.get("do_eval", False)))
|
||
except Exception as exc:
|
||
_log(store, task_id, f"[WARN] Loss 曲线异常: {exc}", "warn")
|
||
else:
|
||
store.update_task_runtime(task_id, status="failed", extra={"error_message": f"训练退出码: {ret}"})
|
||
_log(store, task_id, f"[ERROR] 训练退出码: {ret}", "error")
|
||
except FileNotFoundError:
|
||
store.update_task_runtime(task_id, status="failed", extra={"error_message": "未找到 train.py 或 Python,请确认后端根目录存在 train.py 且 LLaMA-Factory 已安装"})
|
||
_log(store, task_id, "[ERROR] 未找到 train.py / Python,无法启动真实训练", "error")
|
||
except Exception as exc: # noqa: BLE001
|
||
store.update_task_runtime(task_id, status="failed", extra={"error_message": str(exc)})
|
||
_log(store, task_id, f"[ERROR] 训练异常: {exc}", "error")
|
||
finally:
|
||
_running_processes.pop(task_id, None)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 进程信号控制
|
||
# ──────────────────────────────────────────────
|
||
def _signal(task_id: str, sig: int) -> bool:
|
||
process = _running_processes.get(task_id)
|
||
if not process:
|
||
return False
|
||
try:
|
||
try:
|
||
pgid = os.getpgid(process.pid)
|
||
os.killpg(pgid, sig)
|
||
except (ProcessLookupError, PermissionError):
|
||
process.send_signal(sig)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def pause(task_id: str) -> bool:
|
||
ok = _signal(task_id, signal.SIGSTOP)
|
||
if ok:
|
||
get_platform_store().update_task_runtime(task_id, status="paused")
|
||
_log(get_platform_store(), task_id, "[INFO] 训练已暂停", "info")
|
||
return ok
|
||
|
||
|
||
def resume(task_id: str) -> bool:
|
||
ok = _signal(task_id, signal.SIGCONT)
|
||
if ok:
|
||
get_platform_store().update_task_runtime(task_id, status="running")
|
||
_log(get_platform_store(), task_id, "[INFO] 训练已继续", "info")
|
||
return ok
|
||
|
||
|
||
def cancel(task_id: str) -> bool:
|
||
ok = _signal(task_id, signal.SIGKILL)
|
||
if ok:
|
||
get_platform_store().update_task_runtime(task_id, status="failed", extra={"error_message": "用户中断训练"})
|
||
_log(get_platform_store(), task_id, "[WARN] 用户中断了训练", "warn")
|
||
return ok
|
||
|
||
|
||
def _plot_loss_curve(task_id: str, output_dir: str, has_eval: bool = False) -> None:
|
||
try:
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
|
||
state_file = Path(output_dir) / "trainer_state.json"
|
||
if not state_file.exists():
|
||
return
|
||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||
log_history = state.get("log_history", [])
|
||
if not log_history:
|
||
return
|
||
train_steps, train_losses, eval_steps, eval_losses = [], [], [], []
|
||
for entry in log_history:
|
||
if "loss" in entry and "step" in entry:
|
||
train_steps.append(entry["step"])
|
||
train_losses.append(entry["loss"])
|
||
if has_eval and "eval_loss" in entry and "step" in entry:
|
||
eval_steps.append(entry["step"])
|
||
eval_losses.append(entry["eval_loss"])
|
||
if not train_losses:
|
||
return
|
||
fig, ax = plt.subplots(figsize=(10, 5))
|
||
ax.plot(train_steps, train_losses, label="Training Loss", color="#409eff", linewidth=1.5)
|
||
ax.axhline(y=min(train_losses), color="#67c23a", linestyle="--", alpha=0.5,
|
||
label=f"Min: {min(train_losses):.4f}")
|
||
if eval_losses:
|
||
ax.plot(eval_steps, eval_losses, label="Validation Loss", color="#f56c6c",
|
||
linewidth=1.5, marker="o", markersize=3)
|
||
ax.set_xlabel("Step")
|
||
ax.set_ylabel("Loss")
|
||
ax.set_title("Training Loss Curve")
|
||
ax.legend()
|
||
ax.grid(True, alpha=0.3)
|
||
save_path = Path(output_dir) / "loss_curve.png"
|
||
fig.savefig(str(save_path), dpi=150, bbox_inches="tight")
|
||
plt.close(fig)
|
||
_log(get_platform_store(), task_id, f"Loss 曲线已保存: {save_path}")
|
||
except Exception:
|
||
pass
|