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:
+239
-44
@@ -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 阶段结束时统一释放本地模型显存,让下一组的
|
||||
# whisper(ASR)拿到 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,
|
||||
|
||||
Reference in New Issue
Block a user