feat: 批量流水线按 GPU 资源调度(非 GPU 阶段并行、GPU 阶段互斥)

此前组内阶段是串行的(先全部提音、再全部转写、再全部翻译),LLM 走线上端点时
翻译阶段不占显存、GPU 全程空转——实测占整轮挂钟约 40%(19.6W / 272MiB)。

- `_run_job` 改为按组启动在途流水线:每个视频独立推进自己的阶段,最多
  `WOV_BATCH_PIPELINE_WORKERS`(默认 4)个阶段在途。
- 派发只看资源:`stage_gpu_need_mb` 为 0 的阶段(提音、线上翻译、ASS)立刻派发,
  可与其它视频的转写并行;需要 GPU 的阶段由 `GpuGate` 互斥准入,并按"阶段索引
  最小者优先"派发,组内仍是先跑完全部转写再进翻译——本机 Ollama 模型每组只
  加载一次,不需要按"是否云端"写分支。
- 同一阶段只在途一份(派发即标记 running),单视频异常不带走整组;暂停沿用
  run 级 paused.flag,暂停后不再派发新阶段。
- 测试:远端翻译与其它视频转写重叠、本机端点下全部转写先于翻译且翻译互斥、
  提音与转写重叠,以及既有分组/暂停/失败隔离用例。
This commit is contained in:
2026-09-18 22:40:53 +08:00
parent eda37a6ac3
commit 171b088e7c
6 changed files with 437 additions and 62 deletions
+239 -44
View File
@@ -11,15 +11,19 @@
字幕文件(`.srt/.ass/.ssa/.vtt`),说明该视频已有字幕,直接记为 SKIPPED,
不为它触发任何流水线。运行时(BatchWorker)只消费已定位好的明细列表,
**不再重新扫描文件夹**(运行期间新增/删除的视频不会改变本次任务的范围)。
- **分块流水线执行**:视频按 `WOV_BATCH_STAGE_GROUP_SIZE` 分组,组内按节点
顺序跑完全部视频(先全部 extract、再全部 ASR、再全部 LLM 翻译、最后 ASS)
再进入下一组——本地模型每组只加载一次、卸载一次,产物按组增量落地。
- **按资源调度的在途流水线**:视频按 `WOV_BATCH_STAGE_GROUP_SIZE` 分组,组内
每个视频独立推进自己的阶段(最多 `WOV_BATCH_PIPELINE_WORKERS` 个阶段在途)。
阶段是否需要 GPU 由 `wov_app.resources` 判定:提音、线上翻译与 ASS 不需要,
与其它视频的转写并行(GPU 不再空转);需要 GPU 的阶段由进程内门控
`GpuGate` 串行准入,并按"阶段索引最小者优先"派发,于是组内先跑完全部转写
再进翻译——本机 Ollama 模型每组只加载一次(线上端点则完全不受该顺序约束)。
阶段边界用 `execute_run(stop_after=节点)` 停在节点(任务保持 RUNNING),
LLM 阶段靠 `keep_model.flag` 让节点保持模型常驻,阶段结束由引擎统一释放
显存(详见 docs/operations.md#文件夹批量处理)。
LLM 阶段靠 `keep_model.flag` 让节点保持模型常驻,组末由引擎统一释放显存
(详见 docs/operations.md#文件夹批量处理)。
- **产物放在视频旁**:每个视频处理完成后,把工作流 `final_outputs` 对应的
最终产物文件(字幕流水线即中文 `.srt` 与双目 `.ass`**复制一份到视频的
所在目录**,与 .mp4 放在一起;文件名**对齐媒体库既有约定**:中文字幕存为
最终产物文件(字幕流水线即日语转写 `.srt`、中文 `.srt` 与双目 `.ass`
**复制一份到视频的所在目录**,与 .mp4 放在一起;文件名按 `final_outputs`
别名**对齐媒体库既有约定**:日语转写存为 `<视频名>.JA.srt`、中文字幕存为
`<视频名>.CN.srt`、双目字幕存为 `<视频名>.CN_dual_eye.ass`(文件名稳定且
含视频主名,媒体库可自动匹配,下次批量扫描也会命中"已有字幕"规则跳过)。
- **过程文件清理**:视频收尾完成后删除该视频的整个工作空间与 run 记录,
@@ -46,13 +50,20 @@ import shutil
import threading
import time
import uuid
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
from datetime import datetime, timezone
from pathlib import Path
from wov_app import registry
from wov_app.config import BATCH_INTERVAL_SECONDS, BATCH_STAGE_GROUP_SIZE, STORAGE_DIR
from wov_app.config import (
BATCH_INTERVAL_SECONDS,
BATCH_PIPELINE_WORKERS,
BATCH_STAGE_GROUP_SIZE,
STORAGE_DIR,
)
from wov_app.db import Database
from wov_app.logging import get_logger
from wov_app.resources import GpuGate, stage_gpu_need_mb
from wov_app.scheduler import WorkflowScheduler, topological_sort
from wov_app.storage import atomic_copy
from wov_sdk.models import WorkflowDefinition, WorkflowNode
@@ -72,6 +83,14 @@ VIDEO_EXTENSIONS = {
# 最终产物(.srt/.ass)也在该集合内,保证下次扫描能命中同一规则直接跳过。
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt"}
# 组内流水线的轮询间隔(秒):等第一个阶段结束时顺带检查暂停与资源放行。
PIPELINE_POLL_SECONDS = 0.2
def _make_gate() -> GpuGate:
"""创建任务级 GPU 门控(测试通过替换本函数注入假探测结果)。"""
return GpuGate()
# 暂停信号文件名:与节点约定一致,位于 run 根目录(<work_dir>/runs/<run_id>/)。
PAUSE_FLAG = "paused.flag"
@@ -420,44 +439,23 @@ class BatchWorker:
total = int(job["total"] or 0)
group_size = max(1, int(BATCH_STAGE_GROUP_SIZE))
gate = _make_gate()
for start in range(0, len(items), group_size):
group = items[start:start + group_size]
for stage_index, node_id in enumerate(order):
node_spec = node_by_id[node_id]
# 末阶段不传 stop_after:让调度器收尾(final_outputs + COMPLETED)。
is_last_stage = stage_index == len(order) - 1
executed = False
for item in group:
# 暂停检查:批量任务被暂停后停止处理后续视频,等待用户继续。
current = self.db.get_batch_job(job_id)
if current is None or current["status"] == "PAUSED":
# 停下前先把已完成/失败项入账,让暂停中的前端看到真实进度。
self.db.sync_batch_job_progress(job_id)
logger.info("批量任务 %s 已暂停,停止在视频 %s", job_id, item["video_path"])
return
self.db.update_batch_job(job_id, current_video=str(item["video_path"]), updated_at=_now_iso())
try:
outcome = self._run_stage(
job, item, version, definition, node_spec, order, is_last_stage,
)
except Exception as exc: # noqa: BLE001
# 单视频兜底:不中断整个批量任务,记录错误后继续下一个视频。
logger.exception(
"批量任务 %s 视频 %s 阶段 %s 处理异常", job_id, item["video_path"], node_id,
)
self.db.update_batch_video(item["id"], status="FAILED", error=str(exc), updated_at=_now_iso())
outcome = "FAILED"
executed = executed or outcome is not None
# 每个视频每个阶段后实时同步一次汇总,让进度尽快入账。
self.db.sync_batch_job_progress(job_id)
# 阶段内被暂停(节点内的 paused.flag):任务保持 PAUSED 等续跑。
if outcome == "PAUSED":
self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
return
# 阶段收尾:LLM 阶段结束时统一释放本地模型显存,让下一组的
# whisperASR)拿到 GPU,否则下一个视频转写会 CUDA OOM。
if executed and node_spec.node_type.startswith(LLM_NODE_PREFIX):
self._release_llm_model(node_spec.params)
outcome = self._run_group(
job=job,
group=group,
order=order,
node_by_id=node_by_id,
definition=definition,
version=version,
gate=gate,
)
if outcome == "PAUSED":
# 停下前先把已完成/失败项入账,让暂停中的前端看到真实进度。
self.db.sync_batch_job_progress(job_id)
logger.info("批量任务 %s 已暂停,等待用户继续", job_id)
return
# 先按明细实时对齐汇总(done 不计 SKIPPED),再判断能否收尾。
# 仍有未结束视频时不能标 COMPLETED,否则会出现“还有待处理视频却已完成”
@@ -496,6 +494,203 @@ class BatchWorker:
job_id, total, done, failed,
)
def _is_job_paused(self, job_id: str) -> bool:
"""批量任务是否已被暂停(或记录已消失):暂停后不再派发新阶段。"""
job = self.db.get_batch_job(job_id)
return job is None or job["status"] == "PAUSED"
def _next_stage_node(
self,
pipeline: dict,
order: list[str],
node_by_id: dict[str, WorkflowNode],
) -> WorkflowNode | None:
"""返回该视频下一个待执行阶段的节点;已跑完或已失败时返回 None。"""
if pipeline["state"] != "ready" or pipeline["stage"] >= len(order):
return None
return node_by_id[order[pipeline["stage"]]]
def _submit_group_stage(
self,
job: dict,
pipeline: dict,
node_spec: WorkflowNode,
order: list[str],
definition: WorkflowDefinition,
version: dict,
running: dict,
pool: ThreadPoolExecutor,
lease_key: str | None = None,
) -> None:
"""把一个阶段交给线程池执行(异常在池内兜底,不让线程池任务抛出去)。"""
item = pipeline["item"]
is_last_stage = node_spec.id == order[-1]
# 标记在途:同一视频同一阶段只允许有一个执行体,否则会被重复派发。
pipeline["state"] = "running"
if lease_key:
pipeline["lease_key"] = lease_key
if node_spec.node_type.startswith(LLM_NODE_PREFIX):
pipeline["executed_llm"] = True
pipeline["llm_params"] = node_spec.params
self.db.update_batch_job(job["id"], current_video=str(item["video_path"]), updated_at=_now_iso())
logger.info(
"批量任务 %s 视频 %s 进入阶段 %s(在途 %d",
job["id"], Path(item["video_path"]).name, node_spec.id, len(running) + 1,
)
def _worker() -> str:
"""线程内的阶段执行:单视频异常不中断整组。"""
try:
outcome = self._run_stage(
job, item, version, definition, node_spec, order, is_last_stage,
)
except Exception as exc: # noqa: BLE001
logger.exception(
"批量任务 %s 视频 %s 阶段 %s 处理异常",
job["id"], item["video_path"], node_spec.id,
)
self.db.update_batch_video(
item["id"], status="FAILED", error=str(exc), updated_at=_now_iso(),
)
return "FAILED"
return outcome or "RUNNING"
running[pool.submit(_worker)] = pipeline
def _dispatch_group_stages(
self,
job: dict,
pipelines: list[dict],
order: list[str],
node_by_id: dict[str, WorkflowNode],
definition: WorkflowDefinition,
version: dict,
gate: GpuGate,
running: dict,
pool: ThreadPoolExecutor,
workers: int,
) -> None:
"""派发就绪阶段:非 GPU 阶段可并行,GPU 阶段互斥且按上游优先。"""
# 1) 不需要 GPU 的阶段(提音 / 线上翻译 / ASS):立刻派发,与其它视频的
# 转写并行,GPU 不再空转。
for pipeline in pipelines:
if len(running) >= workers:
break
node_spec = self._next_stage_node(pipeline, order, node_by_id)
if node_spec is None:
continue
if stage_gpu_need_mb(node_spec.node_type, node_spec.params) > 0:
continue
self._submit_group_stage(job, pipeline, node_spec, order, definition, version, running, pool)
# 2) 需要 GPU 的阶段:一次只跑一个,且选"阶段索引最小"的视频——组内因此
# 先把转写跑完再进翻译,本机 Ollama 模型仍每组只加载一次。
if len(running) >= workers or gate.holder is not None:
return
candidates = [
(pipeline["stage"], pipeline["index"], pipeline)
for pipeline in pipelines
if self._next_stage_node(pipeline, order, node_by_id) is not None
]
gpu_candidates = [
entry for entry in candidates
if stage_gpu_need_mb(
node_by_id[order[entry[2]["stage"]]].node_type,
node_by_id[order[entry[2]["stage"]]].params,
) > 0
]
if not gpu_candidates:
return
_, _, pipeline = min(gpu_candidates, key=lambda entry: (entry[0], entry[1]))
node_spec = node_by_id[order[pipeline["stage"]]]
need = stage_gpu_need_mb(node_spec.node_type, node_spec.params)
lease_key = f"{pipeline['item']['id']}:{node_spec.id}"
# 显存/在途不满足时本轮跳过,等其它阶段释放后再试(不阻塞派发线程)。
if not gate.try_acquire(lease_key, need):
return
self._submit_group_stage(
job, pipeline, node_spec, order, definition, version, running, pool, lease_key,
)
def _finish_group_stage(
self,
job: dict,
pipeline: dict,
future: Future,
order: list[str],
) -> None:
"""收集一个阶段的执行结果并推进该视频的流水线。"""
try:
outcome = future.result()
except Exception: # noqa: BLE001 - 池内已兜底,这里只保证组不被带崩
logger.exception("批量任务 %s 阶段执行线程异常", job["id"])
pipeline["state"] = "failed"
return
self.db.sync_batch_job_progress(job["id"])
if outcome == "PAUSED":
# 节点在边界(分块/批次/帧)停下:任务保持 PAUSED 等用户继续。
pipeline["state"] = "paused"
self.db.update_batch_job(job["id"], status="PAUSED", updated_at=_now_iso())
return
if outcome == "FAILED":
pipeline["state"] = "failed"
return
pipeline["stage"] += 1
pipeline["state"] = "done" if pipeline["stage"] >= len(order) else "ready"
def _run_group(
self,
job: dict,
group: list[dict],
order: list[str],
node_by_id: dict[str, WorkflowNode],
definition: WorkflowDefinition,
version: dict,
gate: GpuGate,
) -> str:
"""组内在途流水线:每个视频独立推进阶段,GPU 阶段按上游优先串行。
阶段是否需要 GPU 由 resources.stage_gpu_need_mb 判定(提音/线上翻译/ASS
不需要),于是它们与其它视频的转写并行;需要 GPU 的阶段由 GpuGate 准入,
并按"阶段索引最小者优先"派发,组内因此先把转写跑完再进翻译,本机 Ollama
模型仍每组只加载一次。
返回 "PAUSED" 表示组内被暂停(调用方停止任务),其余情况返回 "DONE"
"""
job_id = job["id"]
workers = max(1, int(BATCH_PIPELINE_WORKERS))
pipelines = [
{"item": item, "stage": 0, "index": index, "state": "ready", "executed_llm": False}
for index, item in enumerate(group)
]
with ThreadPoolExecutor(max_workers=workers) as pool:
running: dict[Future, dict] = {}
while True:
paused = self._is_job_paused(job_id)
if not paused:
self._dispatch_group_stages(
job, pipelines, order, node_by_id, definition, version,
gate, running, pool, workers,
)
if not running:
break
done, _ = wait(
list(running), timeout=PIPELINE_POLL_SECONDS,
return_when=FIRST_COMPLETED,
)
for future in done:
pipeline = running.pop(future)
lease_key = pipeline.pop("lease_key", None)
if lease_key:
gate.release(lease_key)
self._finish_group_stage(job, pipeline, future, order)
if paused and not running:
return "PAUSED"
# 组末统一释放本地模型:否则下一组的 whisper 转写会 CUDA OOM。
llm_params = next((p["llm_params"] for p in pipelines if p.get("llm_params")), None)
if llm_params is not None:
self._release_llm_model(llm_params)
return "PAUSED" if self._is_job_paused(job_id) else "DONE"
def _run_stage(
self,
job: dict,