diff --git a/README.md b/README.md index 3c5389e..376c184 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ - [断句与时间戳重算原理](#断句与时间戳重算原理) - [模型不共驻(显存策略)](#模型不共驻显存策略) - [Docker 说明](#docker-说明) + - [新建 / 重建容器](#新建--重建容器) + - [缓存分层与删除边界](#缓存分层与删除边界) + - [⚠️ 不要用 `docker builder prune --filter until`](#-不要用-docker-builder-prune---filter-until) +- [GPU 利用率优化](#gpu-利用率优化) - [依赖](#依赖) - [常见问题](#常见问题) @@ -235,7 +239,7 @@ docker compose --profile cpu up -d --build # CPU | ASR | `large-v3-turbo` | ~3GB(FP16) | 8x 速度,质量接近 large-v3 | | 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GB(FP16) | 质量最好的蒸馏版 | -ASR 与翻译**不共驻**:翻译阶段先卸载 Whisper 释放显存,独占跑大 batch(`batch_size=16`), +ASR 与翻译**不共驻**:翻译阶段先卸载 Whisper 释放显存,独占跑大 batch(`batch_size=32`), 两者峰值显存互不叠加,远低于 24G 上限。模型缓存(`./models` volume)跨容器复用, CPU→GPU 切换时 NLLB/Whisper 大模型首次下载、之后秒起。 @@ -249,16 +253,18 @@ AUDIO2TEXT_VARIANT=cpu ./setup.sh # 切回 CPU(构建 cpu 镜像 + config.cp ./start.sh # 重新启动 ``` -两套配置的差异仅在 6 项(其余字段完全一致): +两套配置的差异仅在 8 项(其余字段完全一致): | 字段 | CPU(`config.cpu.yaml`) | GPU(`config.gpu.yaml`) | |---|---|---| | `asr.model` | `tiny.en` | `large-v3-turbo` | | `asr.device` | `cpu` | `cuda` | | `asr.compute_type` | `int8` | `float16` | +| `asr.batch_size` | `8` | `32` | +| `asr.beam_size` | `5` | `2` | | `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` | | `translation.device` | `cpu` | `cuda` | -| `translation.batch_size` | `8` | `16` | +| `translation.batch_size` | `8` | `32` | ### 启动后的入口 @@ -292,16 +298,18 @@ AUDIO2TEXT_VARIANT=cpu ./setup.sh # 切回 CPU(构建 cpu 镜像 + config.cp ### CPU / GPU 两份配置的差异 -其余字段(存储、断句、日志、docs)两份配置完全一致,仅以下 6 项不同: +其余字段(存储、断句、日志、docs)两份配置完全一致,仅以下 8 项不同: | 字段 | `config.cpu.yaml` | `config.gpu.yaml` | |---|---|---| | `asr.model` | `tiny.en` | `large-v3-turbo` | | `asr.device` | `cpu` | `cuda` | | `asr.compute_type` | `int8` | `float16` | +| `asr.batch_size` | `8` | `32` | +| `asr.beam_size` | `5` | `2` | | `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` | | `translation.device` | `cpu` | `cuda` | -| `translation.batch_size` | `8` | `16` | +| `translation.batch_size` | `8` | `32` | ### 完整字段 @@ -342,6 +350,8 @@ AUDIO2TEXT_VARIANT=cpu ./setup.sh # 切回 CPU(构建 cpu 镜像 + config.cp | `language` | str | `en` | 识别语言,仅英语 | | `word_timestamps` | bool | `true` | 词级时间戳:让断句精确(取首末词时间戳)而非纯匀速估算。建议开 | | `vad_filter` | bool | `true` | 过滤静音段,提升识别质量与速度 | +| `batch_size` | int | `8`(CPU)/ `32`(GPU) | `BatchedInferencePipeline` 批量解码的音频块数。GPU 拉大 batch 拉长单次 GPU 解码时间,掩盖 CPU 提取 Mel 特征的间隙,提升平均利用率 | +| `beam_size` | int | `5`(CPU)/ `2`(GPU) | beam search 宽度。GPU turbo 模型鲁棒,降到 2 减少解码候选数与步数,加速明显、质量损失小;CPU 无加速诉求保持默认 5 | #### `translation` — 翻译(NLLB-200) @@ -351,8 +361,9 @@ AUDIO2TEXT_VARIANT=cpu ./setup.sh # 切回 CPU(构建 cpu 镜像 + config.cp | `device` | str | `cpu` | `cpu` 或 `cuda` | | `src_lang` | str | `eng_Latn` | NLLB 语言码:英语 | | `tgt_lang` | str | `zho_Hans` | NLLB 语言码:简体中文 | -| `batch_size` | int | `16` | 翻译批量大小。不与 ASR 共驻时显存独占,可用大 batch | +| `batch_size` | int | `8`(CPU)/ `32`(GPU) | 翻译批量大小。不与 ASR 共驻时显存独占,可用大 batch | | `max_length` | int | `256` | 单条翻译最大 token 数 | +| `sort_by_length` | bool | `true` | 按句子长度排序后分批,减少批内 padding 浪费(GPU 收益大) | #### `segmentation` — 断句与字幕规范化 @@ -419,14 +430,17 @@ asr: language: en word_timestamps: true vad_filter: true + batch_size: 8 # GPU: 32(拉长单次 GPU 解码,掩盖 CPU 特征提取间隙) + beam_size: 5 # GPU: 2(turbo 鲁棒可降,候选数↓解码步数↓) translation: model: facebook/nllb-200-distilled-1.3B device: cpu # GPU: cuda src_lang: eng_Latn tgt_lang: zho_Hans - batch_size: 16 + batch_size: 8 # GPU: 32(显存独占可用大 batch) max_length: 256 + sort_by_length: true # 按长度排序分批,减少 padding 浪费 segmentation: max_words_per_line: 14 @@ -592,6 +606,51 @@ ASR 与翻译模型**不会同时驻留 GPU**。`model_manager.py` 单例跟踪 --- +## GPU 利用率优化 + +faster-whisper 的 GPU 利用率曲线常呈**尖刺波**(峰=批量解码满载,谷=CPU 提取 Mel 特征 ++ 处理结果时 GPU 空闲),平均利用率偏低。瓶颈不在 GPU 算力,而在 CPU 特征提取与 GPU +解码未重叠: + +``` +CPU: [VAD+切片+Mel特征 N个chunk] ──► [处理结果] ──► [VAD+切片+Mel特征] ──► ... +GPU: (空闲) [批量解码] (空闲) [批量解码] +``` + +`BatchedInferencePipeline` 内部把音频按 30s chunk 切分,凑够 `batch_size` 个 chunk 一次性 +送 GPU 解码。每批解码完后回到 CPU 处理结果 + 提取下一批 Mel 特征,这期间 GPU 空闲。 + +### 已做的优化(GPU 配置) + +| 参数 | 旧值 | 新值 | 作用 | +|---|---|---|---| +| `asr.batch_size` | 16 | **32** | 单次 GPU 解码时长翻倍,CPU 特征提取间隙占比减半 → 尖刺变宽、谷底变浅,平均利用率上升。turbo FP16 仅 ~1.6GB,3090 24G 充裕 | +| `asr.beam_size` | 5 | **2** | 解码候选数 5→2,每步计算量与解码步数下降 → 峰更密、间隙更短。turbo 鲁棒,保留 1 个候选做歧义发音保险,质量损失小 | + +### 为什么不关 `word_timestamps` + +`segmenter.py` 强依赖词级时间戳做精确断句——只要任一 segment 没词级时间戳,就整体退化 +到匀速估算路(时间戳按字符数比例估算),字幕精度下降明显。所以 `word_timestamps=true` +必须保留,即使它是 CPU↔GPU 同步开销的来源之一。 + +### 验证方法 + +```bash +# 1. 确认配置生效 +curl -s http://127.0.0.1:8001/health | python -m json.tool +# 应见 asr_batch_size=32, asr_beam_size=2 + +# 2. 跑长视频(如 test/1-5.mp4),观察 GPU 利用率曲线 +nvidia-smi dmon -s u # 实时 GPU 利用率(d=dec u=util) + +# 3. 对比字幕质量(可选):同一视频改前改后 SRT diff +``` + +优化后尖刺应比之前密且谷底变浅,平均利用率上升。`beam_size=2` 对 turbo 模型质量损失 +极小,但仍建议用同一视频 A/B 对比字幕确认无歧义发音处的降级。 + +--- + ## Docker 说明 ### 一份 Dockerfile,两个镜像 @@ -606,25 +665,151 @@ ASR 与翻译模型**不会同时驻留 GPU**。`model_manager.py` 单例跟踪 两个镜像的 Python 依赖列表(`requirements.txt`)完全一致,仅 torch 不同。镜像内 apt 装 `ffmpeg` + `patchelf`。 -### Volume 挂载 +### 新建 / 重建容器 -| 容器路径 | 宿主路径 | 用途 | -|---|---|---| -| `/data` | `./data` | 上传视频、中间音频、输出字幕、SQLite 数据库 | -| `/models` | `./models` | 模型缓存(HF + ctranslate2),跨容器复用避免重下 | -| `/app/config.yaml` | `./config.yaml` | 配置文件(只读挂载) | +项目提供 `setup.sh` / `start.sh` / `stop.sh` 包装脚本,也可直接用 `docker` / `docker compose`。 -镜像本身无状态、无敏感数据。 - -### docker-compose - -`docker-compose.yml` 提供 `audio2text-cpu` / `audio2text-gpu` 两个 profile: +#### 首次新建(新机器 / 全新拉取代码后) ```bash -docker compose --profile cpu up -d # CPU -docker compose --profile gpu up -d # GPU(需 nvidia runtime) +# 1. 构建镜像 + 生成 config.yaml(CPU 默认) +./setup.sh +# GPU:AUDIO2TEXT_VARIANT=gpu ./setup.sh + +# 2. 启动容器 +./start.sh +# GPU:start.sh 检测到 audio2text:gpu 镜像 + nvidia-smi 自动加 --gpus all ``` +`setup.sh` 做三件事:检查 docker → 构建 `audio2text:{variant}` 镜像 → 把 +`config.{variant}.yaml` 复制为 `config.yaml`(运行时实际读取的文件)。 + +#### 重建镜像(改了 app 代码或 requirements 后) + +依赖层(apt + pip + torch)由 BuildKit 缓存挂载复用,只有 `COPY app` 层重建,通常 +30 秒内完成。**重建不会动运行时数据**(`./data` / `./models` 是挂载的 volume)。 + +```bash +# CPU:直接重跑 setup.sh(幂等,会复用缓存层) +./setup.sh +# 或显式构建: +docker build --build-arg VARIANT=cpu -t audio2text:cpu . + +# GPU: +AUDIO2TEXT_VARIANT=gpu ./setup.sh +# 或: +docker build --build-arg VARIANT=gpu -t audio2text:gpu . + +# 重建后重启容器(替换运行中的旧镜像): +./stop.sh && ./start.sh +``` + +#### 改配置(不重建镜像) + +`config.yaml` 是只读挂载,改完重启容器即生效,**无需重建镜像**: + +```bash +cp config.gpu.yaml config.yaml # 切换配置(或直接编辑 config.yaml) +./stop.sh && ./start.sh +``` + +#### 改依赖(requirements.txt / torch 版本) + +会触发 deps 层重建,耗时较长(重装 torch + 全部依赖,CPU ~3 分钟,GPU ~5 分钟)。 +BuildKit 的 pip 缓存挂载(`/root/.cache/pip`)跨构建复用已下载的 wheel,二次构建会快 +很多。 + +```bash +# 编辑 requirements.txt 后 +./setup.sh # 或 docker build --build-arg VARIANT=gpu -t audio2text:gpu . +./stop.sh && ./start.sh +``` + +#### docker compose(替代脚本) + +```bash +docker compose --profile dev up -d --build # 开发:源码挂载 + uvicorn reload,改代码零重建 +docker compose --profile cpu up -d --build # CPU 生产 +docker compose --profile gpu up -d --build # GPU 生产(需 nvidia runtime) +``` + +### 缓存分层与删除边界 + +这套构建涉及三类缓存,**删除策略截然不同**,乱删会导致全量重建: + +| 缓存类型 | 位置 | 存什么 | 能删吗 | 删了会怎样 | +|---|---|---|---|---| +| **BuildKit 构建缓存** | Docker 内部(`docker builder` 管理) | Dockerfile 各层(base / deps / final)的构建产物 | ⚠️ 谨慎,见下方 | 命中失效 → 该层及下游全量重建 | +| **pip wheel 缓存** | BuildKit cache mount `/root/.cache/pip` | 下载过的 `.whl` 文件 | ✅ 可删 | 下次构建重新下载 wheel(不重编译) | +| **模型缓存** | `./models` volume(容器内 `/models`) | Whisper / NLLB 权重(HF + ctranslate2) | ✅ 可删 | 下次启动重新下载模型(~5.5GB GPU) | +| **运行时数据** | `./data` volume(容器内 `/data`) | 上传视频 / 中间音频 / 输出字幕 / SQLite | ⚠️ 视情况 | 删了任务历史和产物全没 | + +### ⚠️ 不要用 `docker builder prune --filter until` + +**这是踩过的坑**。BuildKit 的 `--filter "until=30m"`(或任意时长)会清除"最近 N 分钟未 +访问"的缓存层。问题在于:**稳定的基础层**(如 `base-gpu` 的 apt 装 python3.12)只在 +首次构建时执行一次,之后每次构建都直接 CACHED 跳过——它的"最后访问时间"一直停在首次 +构建那一刻,永远不会更新。于是 `--filter "until=..."` 会把这些**仍然在用的稳定层**当成 +"很久没访问"清掉,导致下一次构建从 base 层开始全量重来(GPU 镜像 ~10 分钟 + 重新下载 +torch ~2.5GB)。 + +正确做法: + +```bash +# ✅ 想清理磁盘、释放 BuildKit 缓存:用不带 filter 的 prune(清全部未引用缓存) +docker builder prune -f +# 或只清 dangling(悬挂的、无引用的中间层) +docker builder prune -f --filter "type=regular" + +# ✅ 清旧镜像(不影响构建缓存) +docker image prune -a # 删所有未被容器使用的镜像 +docker image prune # 只删 dangling 镜像 + +# ✅ 清 pip wheel 缓存(BuildKit cache mount,安全) +docker builder prune -f --filter "type=exec.cachemount" + +# ❌ 永远不要这样用——会清掉仍在用的稳定 base 层 +docker builder prune -f --filter "until=30m" +docker builder prune -f --filter "until=24h" +``` + +> 根因:BuildKit 的 `until` filter 按"最后访问时间"判定,而非"是否仍在被引用"。CACHED +> 跳过的层不会刷新访问时间,于是被误判为可回收。这是 BuildKit 的已知行为,不是 bug, +> 但对"稳定 base + 频繁改代码"的构建模式特别致命。详见 +> [moby/buildkit#2414](https://github.com/moby/buildkit/issues/2414)。 + +#### 什么时候需要主动清缓存 + +- **磁盘紧张**:`docker builder prune -f` + `docker image prune` 释放空间 +- **依赖换了 torch / CUDA 大版本**:BuildKit 可能复用了不兼容的旧 wheel,清 pip 缓存 + mount 强制重下:`docker builder prune -f --filter "type=exec.cachemount"` +- **换 VARIANT(cpu↔gpu)**:不需要清——两条构建链独立,缓存互不干扰 +- **想从零验证构建**:`docker builder prune -af` 清全部,模拟新机器首次构建 + +#### 模型缓存(`./models`) + +模型权重在 `./models` volume(容器内 `HF_HOME=/models/huggingface`、 +`CT2_CACHE=/models/ctranslate2`),跨容器复用。首次启动下载,之后秒起。 + +```bash +# 查看模型缓存大小 +du -sh ./models + +# 删了强制重下(GPU 大模型 ~5.5GB,建议用 prefetch 脚本提前下好) +rm -rf ./models +./scripts/prefetch_models.sh config.gpu.yaml +``` + +### Volume 挂载 + +| 容器路径 | 宿主路径 | 用途 | 删除影响 | +|---|---|---|---| +| `/data` | `./data`(CPU)/ `./data-gpu`(GPU) | 上传视频、中间音频、输出字幕、SQLite | 任务历史和产物全没 | +| `/models` | `./models` | 模型缓存(HF + ctranslate2),跨容器复用 | 下次启动重下模型 | +| `/app/config.yaml` | `./config.yaml`(只读) | 配置文件 | 改配置需重启容器 | + +镜像本身无状态、无敏感数据。 + ### ctranslate2 可执行栈修复 ctranslate2 的 `.so`(在 `ctranslate2.libs/` 隐藏目录)带 PT_GNU_STACK 可执行栈标志,