跳到主要内容

创建日期:2026-10-14 | 最近更新:2026-10-14 事实核对基于 sherpa-onnx 1.13.8+cuda12.cudnn9.onnxruntime1.28.2;机器为 32 核 / 125 GB / RTX 3090 24 GB。所有数字均为本机实测,脚本与原始结果留档于 source/sherpa-subtitle-service/。

sherpa-onnx 101(第 8 篇 · 服务化):把转写做成服务,并发到底卡在哪

第 6、7 篇做出来的是一条命令行流水线:给一个视频,吐一个 SRT,17 分钟的视频跑 20 秒。 但真实场景不是"我有一个文件",而是"我有一堆文件,谁提交、什么时候好、好了通知我"。

这一篇把它做成服务:从 MinIO 取视频 → 异步转写 → 回传 SRT → 回调。而真正要回答的只有一句话:

这台机器到底能同时转多少路?瓶颈在哪一步?

答案一半在意料之外:我优化了半天识别,最后发现瓶颈在切分(VAD)——它占了端到端时间的一半。

0. 先给结论​

#结论数据出处
1一个 recognizer 可以被多线程安全共享,但不会被并行使用——结果逐字一致,可是解码被内部锁串行化65 段解码耗时的累加随手并发度线性膨胀(10.9s → 158s),墙钟却停在 10s;显存还从 2.7 GB 涨到 19.7 GB§2
2所以并发的单位只能是进程——每进程一份模型,进程内不要并发同进程内每线程一份模型也只到 1.74×;多进程同样 2 个到头§3
3真正提效的是批处理:decode_streams 一次解 N 条流单进程 75× → 200×(2.67×),文本逐字一致§4
4批处理 × 多进程可以叠乘6 进程 × 批 16 → 437× 实时,显存 14.1 GB(裸模型)§5
5服务级的最优配置不是"裸模型的最优配置":显存是硬约束,用更小的批换更多的 worker批 8 × 8 进程 = 255.4×,比批 16 × 4 进程的 160.5× 高 59%§5.1
6端到端的瓶颈不在识别,而在切分(VAD)——但它会漂移一条视频 VAD 占 50.2%(ASR 32.9%);另一条反过来(VAD 8.4s、ASR 10.9s)§6、§8.1
7有个能快 4.6× 的 VAD 优化,但会改掉 1.2% 的识别文本——不能默认开;另一个"多喂点"的省法会直接打乱切分分块并行 VAD:14.95s → 3.25s,文本相似度 0.9880;喂入粒度 512→16000,切分段数 49→15§7
8服务化没有改变输出:同一视频,服务产出与命令行直跑逐字节一致cmp 通过§8

1. 服务长什么样​

客户端 ──POST /v1/jobs {source:{bucket,object,s3}, callback}──> API
│ 立即返回 job_id
▼
任务表(SQLite/WAL)
│ 抢任务
┌────────────────────────────────────────┘
▼
worker 进程池(每个进程一份模型)
下载 → ffmpeg → VAD → ASR(批处理)→ 标点 → SRT → 上传 → 回调

三个设计决定,都是被实测逼出来的:

  1. API 不做转写,只入队/查询——否则一个长任务就把接口堵死;
  2. worker 是进程不是线程——原因就是下一节;
  3. 任务落 SQLite(不是内存)——重启时把"上次正在跑"的任务退回队列,否则它们永远卡在 running。

2. 先证伪:共享一个模型能并行吗​

最省显存的思路显然是"一个进程加载一份模型,多个线程共用"。翻 sherpa-onnx 的 pybind 源码, 两条事实让人以为可行:

  • decode_stream / decode_streams / accept_waveform / flush 全都带 py::call_guard<py::gil_scoped_release>()——重活不占 GIL;
  • DecodeStream(s) 是 const 方法,底下是 ONNX Runtime 的 Session::Run(线程安全)。

于是先测"对不对",再测"快不快"。

对不对:同一个 recognizer,48 段音频,串行解一遍、8 线程并发解一遍,逐字比对——

文本逐字一致:48/48
时间戳一致 :48/48

安全。 但快了多少?

串行 9.34s(79.4× 实时)
并发 8.11s(91.4× 实时) 8 线程
加速比 1.15×

8 个线程只快 15%。 把并发度扫一遍,再看两个数字就明白为什么了——

并发墙钟吞吐解码耗时的累加显存
113.34s73.2×10.9s2706 MB
28.67s112.7×14.9s13244 MB
410.41s93.8×38.5s14312 MB
810.85s90.0×81.8s16444 MB
1610.52s92.9×158.3s19688 MB
2410.96s89.1×235.3s23954 MB

看第三列:65 段"解码耗时"的累加值,随并发度线性膨胀(10.9 → 235 秒), 而墙钟稳稳停在 10 秒上下。这意味着:

  • 每次 decode_stream 调用被排到了同一把锁后面(否则累加值应该基本不变);
  • 单个调用在并发下反而慢了一个数量级(158/16 ≈ 10 倍);
  • 顺带把显存从 2.7 GB 吃到 19.7 GB——共享权重省下的那点显存,被并发的 arena 吃回去了。

所以"共享一个模型多线程跑"是"串行 + 白占显存"。 结果是对的,但一点不快。

补充一句:这把锁不在 sherpa-onnx 自己身上——它的 recognizer 实现里没有 mutex。 锁在 ONNX Runtime 的会话内部(线程池与 CUDA stream 的争用)。 这也解释了为什么 GIL 释放了、却依然不并行。

3. 那就换:每线程/每进程一份​

既然同进程内争用,就把模型复制几份。每线程一份:

实例数墙钟吞吐加速单实例吞吐
112.95s75.4×1.00×75.4×
28.04s121.4×1.61×60.7×
410.05s97.2×1.29×24.3×
811.00s88.8×1.18×11.1×

2 个实例到 1.61×,之后"单实例吞吐"一路掉到 11×——还是同一个瓶颈,只是这次换了地方排队。

再换成每进程一份(彻底绕开进程内的 ORT 上下文):

进程数墙钟吞吐加速单进程吞吐GPU 均值显存
113.30s73.4×1.00×73.4×14.4%2446 MB
27.64s127.8×1.74×63.9×34.0%3756 MB
49.74s100.3×1.37×25.1×15.5%5350 MB
810.04s97.3×1.32×12.2×25.5%10602 MB

同样的形状:2 个就到头。而且注意 GPU 利用率——最高才 34%,GPU 从来不是瓶颈。

到这一步可以下结论了:加并发这条路上限就在 2 倍左右,问题不在"几个并发单元",而在"单次调用本身"。

4. 换个方向:单次调用贵在哪​

回头算一笔账:单路时 65 段解码花了 10.9 秒,平均每段 168 毫秒。 而一段也就 15 秒音频,在 3090 上算一遍应该是十几毫秒的量级。

每段 168 毫秒里,绝大部分不是计算,是每次调用的固定开销。

翻 offline-recognizer-sense-voice-impl.h:113 的 DecodeStreams 实现,看到一行关键代码:

if (n == 1) {
DecodeOneStream(ss[0]);
return;
}
// n >= 2:把 n 条流的特征 padding 成一个张量,一次 session.Run

SenseVoice 的批处理是真批,不是循环。也就是说:一次解 N 条流,那笔固定开销只付一遍。

实测:

批大小墙钟吞吐加速每段均摊文本一致
112.99s75.2×1.00×199.8 ms65/65
28.57s114.0×1.52×131.8 ms65/65
46.35s153.8×2.05×97.7 ms65/65
85.31s184.1×2.45×81.6 ms65/65
164.87s200.5×2.67×74.9 ms65/65
324.90s199.1×2.65×75.5 ms65/65

批 16:单进程从 75× 提到 200×(2.67 倍),而文本逐字一致——这正是"省固定开销"该有的样子。 再加到 32 反而略降(padding 到最长序列会浪费算力)。

5. 叠乘:批处理 × 多进程​

批处理省的是"固定开销",多进程加的是"并发路数"——两者不冲突,应该能叠:

配置吞吐显存GPU 均值
1 进程 × 批 175×2.4 GB7%
2 进程 × 批 8310.6×4.8 GB10%
4 进程 × 批 8405.9×9.5 GB27%
4 进程 × 批 16397.3×13.5 GB—
6 进程 × 批 16437.0×14.1 GB—
8 进程 × 批 16394.8×20.1 GB—
12 进程 × 批 8364.4×24.0 GB—

6 进程 × 批 16 = 437× 实时,比单路快 5.8 倍,而显存只用了 14.1/24 GB。 再往上(8、12 进程)吞吐反而降——显存打满之后就开始互相挤。

这是裸模型的最优配置。但服务层的默认值不能照搬它——服务里每个 worker 的显存 占用是裸模型的好几倍(多一份标点模型、还有各自的 arena),照搬"批 16 × 6 进程"会把 24 GB 吃满。服务自己的最优配置是另一套,见 §5.1。

5.1 服务级:真实任务的账(MinIO 端到端,稳态)​

上面全是裸模型(把一个视频的段分给多进程)。服务里每个任务还要下载、抽音轨、切分、 标点、上传,所以还得看服务级。用一条真实视频(680 秒音频,从内网 MinIO 取件、 SRT 回传 MinIO、带回调),只改配置各跑一遍:

workersbatch吞吐加速P50 延迟显存单任务中位
11643.2×1.00×80.6s5.5 GB45.0×
21681.1×1.88×51.1s10.9 GB44.2×
416160.5×3.72×66.2s21.7 GB43.4×
68216.8×5.02×52.2s20.2 GB40.7×
88255.4×5.91×44.8s23.9 GB36.6×
84214.8×4.97×54.3s18.7 GB31.1×
104213.5×4.94×57.7s23.4 GB26.6×
124234.0×5.42×52.7s24.1 GB21.0×

两个口径差得很远:裸模型单进程批 16 是 200×,服务级单 worker 只有 43.2×。 差的那些就是下一节要算的账——VAD、ffmpeg、标点、传输加起来,比识别本身还贵。

但真正反直觉的是这张表的后半段:服务级的最优配置,不是"裸模型的最优配置"。

  • 裸模型上,"批 16" 是单进程吞吐的甜点(§4);
  • 到了服务层,显存成了硬约束——每个 worker 要独占一份模型加自己那份 arena。 批 16 时一个 worker 高峰占到 ~5.4 GB,4 个就把 3090 的 24 GB 吃到 21.7 GB (88%),于是"再多加一个 worker"直接没戏;
  • 把批降到 8,单进程是慢了一点(中位 43.4× → 40.7×),但每个 worker 只要 ~3.4 GB, 于是能同时跑起 8 个——乘积反而大了 59%(160.5× → 255.4×)。

换句话说:在显存受限的服务里,batch 的最优值不是"单进程吞吐最优"的那个, 而是让 并发路数 × 单路吞吐 最大的那个。 这是个乘法,不是加法—— 盯着任何一个乘数单独看,都会被误导。

但 batch 也不是越小越好。表里后三行把批压到 4:单路吞吐掉得太快 (36.6× → 21.0×),即使把 worker 堆到 12 个(显存已 98%),吞吐也只回到 234×—— 还是低于"批 8 × 8 进程"的 255×。所以存在一个最优组合,不是单调的: 批太大 → worker 塞不进;批太小 → 单路太慢。这台 3090 上,拐点在批 8 附近。

另外两点:

  • 加 worker 通常要拿延迟换吞吐,这里不用:任务之间没有共享的串行段, 多一个 worker 就是多一条独立流水线,吞吐和延迟能一起变好(P50 80.6s → 44.8s);
  • 单任务速度其实在掉(45.0× → 36.6×):并发的 worker 越多,GPU 上的 arena 与显存带宽越挤,单条反而慢。总吞吐仍涨,是因为"条数"涨得比"单条变慢"快。

一个测量陷阱,值得单独记:上面所有数字都是稳态——队列始终排满(≥3 波)。 一开始我用 8 个任务量 workers=4,得到 144.6×;换成 24 个任务再量,是 160.5×。 差别就来自"波数"和第一波预热:N 个任务、W 个 worker,墙钟 ≈ ceil(N/W) 波 × 单波耗时,N 太小时这个 ceil 会把吞吐算歪(W=6, N=8 那种"6+2"尤其明显—— 它会让人误以为加到 6 个 worker 毫无收益)。量稳态吞吐,队列得比 worker 数深好几倍。

所以服务的默认值定成 WORKERS=6、BATCH=8:吞吐 216.8×、显存 20.2/24 GB, 留 18% 余量。8×8 的 255× 更高,但显存 97%——这台机器的显存是共享的, 不留余量等于把"别人一启动就轮到我 OOM"的概率拉满(§5.2 就是这么发生的)。

5.2 一次真实的显存事故:别人的 ollama 把我的 worker 挤死了​

补记(事后重跑):机器空载时,同一档配置 workers=4 batch=16 重跑 24 个任务, 24/24 全部成功、稳态吞吐 160.5×(见 §5.1)。所以那一轮的 6 个失败纯粹是 环境所致,不是这档配置本身的问题。下面留的是那次事故的现场——它的价值不在于 "workers=4 不行",而在于讲清"这台机器的显存是共享且瞬时的"这件事。

跑到 workers=4 时,12 个任务里 6 个失败,报错是:

RuntimeError: Exception during initialization:
onnxruntime/core/framework/bfc_arena.cc:359
BFCArena::AllocateRawInternal ... Failed to allocate memory for requested buffer of size 4194304

GPU 显存不够了。 但奇怪的是:§5 那张表里 6 进程 × 批 16 只用了 14.1 GB, 4 个 worker 怎么会不够?

查下去发现,GPU 上多了一个不属于我的进程:

$ nvidia-smi --query-compute-apps=pid,used_memory --format=csv
737519, 16088 MiB ← /usr/local/lib/ollama/llama-server

$ ps -o etime= -p 737519
03:05 ← 3 分钟前起来的

别人的 ollama 在扫描开始后启动了,一口气占走 16 GB。 我的 4 个 worker(每个约 2 GB) 加上它就超过了 24 GB,于是有人分配不到显存。

三件事值得记下来:

  1. 这台机器是三张卡共享的(3090 + 两张 V100 常年有别人的任务), 显存不是你能规划的资源——同一份配置,早上跑得通、下午就 OOM。 这也解释了为什么第 6 篇里那些"RTF 忽快忽慢"的数字;

  2. 服务对这件事的反应是对的:OOM 被 worker 的兜底 except 接住, 失败的任务带上原始报错,其余 6 个照常完成,服务没有崩—— 如果当时是"一个异常杀掉整个 worker 进程",损失会大得多。 (同一轮扫到 workers=6 时更明显:12 个里只成功 4 个—— worker 越多,同时抢显存的进程越多,撞上的概率越大。这条路是负向扩展的:)

  3. 所以要把 OOM 和"数据有问题"分开对待。显存是共享且瞬时的——被别人挤一下, 等几十秒就又有空间了,判成 failed 等于把可恢复的故障永久化。服务的做法是重排队:

    if is_oom(msg): # 认 BFC arena / CUDA / out of memory 这几种写法
    store.requeue(jid, msg) # 退回队列,attempts+1
    else:
    store.finish(jid, status="failed", error=msg)

    attempts 到 3 次才真正判失败(实测:第 1~3 次退回队列,第 4 次落 failed 并带上"重试 3 次仍未成功;最后一次:…")。这一条已经实现在 app/jobs.py:requeue。

    还没做的是更前面一步:加载模型时预留显存(给进程设显存上限,超了就排队而不是硬上), 那样连撞都不会撞上。

顺带一个操作教训:清理服务进程时我写了 pkill -f "startup.py", 结果把自己的 shell 也杀了(退出码 144)——pkill -f 匹配完整命令行, 而执行它的那条命令里就含这个字符串。要么写 pkill -f "[s]tartup.py", 要么按 PID 停。这条在本机全局 CLAUDE.md 里记着,我还是踩了一次。

6. 端到端:钱其实花在 VAD 上​

上面全是"裸模型解码"的账。真实任务还要下载、抽音轨、切分、标点、上传。 把一条 9.5 分钟视频(570 秒音频)的服务耗时拆开:

total 18.14s (31.4× 实时)
download 0.24s 1.3%
vad_seconds 9.10s 50.2% ████████████████████
asr_seconds 5.96s 32.9% █████████████
upload 0.01s 0.1%
ffmpeg/标点/断句 2.83s 15.6% ██████

VAD 占了整整一半,比识别还贵。

这有点讽刺:第 6 篇把大量精力花在"选哪个 ASR 模型、CPU 还是 GPU"上, 而到了服务这一步,那块只占三分之一。原因也很直白:

  • VAD 是 CPU 单线程的——segment_audio 逐 512 样本串行喂模型,num_threads 对它没意义;
  • 而它处理的音频和 ASR 一样长(570 秒),RTF 约 0.016;
  • ASR 因为有批处理和 GPU,反倒被压到了 5.96 秒。

推论:多开 worker 时,VAD 也在并行——所以服务级吞吐能随 worker 数涨, 但每个人都得先付自己的那份 VAD。这也意味着优化 VAD 的收益比优化 ASR 更大。

7. 一个能快 4.6 倍、但不能要的优化​

既然 VAD 是瓶颈,最直接的想法:把音频切成 N 块,每块独立跑 VAD,并行。

实测(1025 秒音频,留 1 秒重叠、按"段的中心落在谁负责的区间"归属、再合并相邻段):

块数耗时RTF加速段数与不分块一致
114.95s0.01461.00×51—
27.12s0.00692.10×55否
45.55s0.00542.70×49否
83.25s0.00324.60×48否

4.6 倍,很诱人。但"段数"那列在报警:切出来的段数变了(51 → 48/49/55), 说明切分结果被改动了。切分一变,每一段喂给 ASR 的音频就不一样,识别结果自然会变。

实测下去:

块数=1: 65 段, 识别文本 6352 字
块数=8: 48 段, 识别文本 6338 字
文本相似度 0.9880,差异 93 处
例:'会' → 丢, '啊' → 丢, '哈' → 丢

快了 4.6 倍,代价是 1.2% 的字被丢掉(都是段边界附近的语气词与短音)。

这个交易不划算,所以默认没开。它揭示的是一个更普遍的纪律:

优化之前先定义"什么不能变"。 这里不能变的是识别文本—— 一个让字幕掉字的加速,再快也是负收益。

要真正加速 VAD 又不改结果,得走另一条路:把 VAD 和 ASR 拆成两级流水线, 让"下一条视频的切分"和"上一条视频的识别"重叠起来(VAD 是 CPU、ASR 是 GPU,天然错开)。 这是这个服务下一步该做的事,本篇先把它测出来、没做进去。

7.1 另一个试过的 VAD 提速:一次多喂点——也不行​

分块并行会改文本,那有没有"不改文本"的省法?一个直觉:现在 segment_audio 是 每 512 个样本喂一次 vad.accept_waveform(680 秒音频要跑 ~2.1 万次 Python 循环), 如果能一次喂更大块、靠 VAD 内部缓冲自己切窗,就只是省了 Python 循环开销。

实测(同一段 680 秒音频,只改喂入粒度):

每喂耗时段数
512(现状)8.44s49
20487.24s49(段数一样,但边界已变)
81927.13s33
160007.40s15
3200010.36s4
1600008.04s1

喂大块会把切分整个打乱——680 秒视频塌成 1 段。原因:sherpa 的 VoiceActivityDetector 是一个要求按 window_size 步进的在线状态机,一次喂超过窗口的 数据会让内部循环行为改变(顺带刷一堆 circular-buffer Overflow 警告)。 所以 512 不是"保守写法",而是唯一正确的档位;而它真能省的那点 Python 循环也就 ~15%。 又一次:不改文本就没有白捡的加速。

8. 服务化没有改变输出​

把命令行的流水线搬进服务,最容易出的事是"结果悄悄变了"。所以有一条硬性验证:

# 命令行直跑
bash video2srt.sh media/4MiAZ-5_WnU.mp4 -o /tmp/cli.srt
# 服务跑(从 MinIO 取同一视频,产出 SRT 回传 MinIO)
curl -X POST .../v1/jobs -d '{"source":{"object":"4MiAZ-5_WnU.mp4"}}'
# 逐字节比
cmp /tmp/cli.srt /tmp/from-minio.srt && echo 一致
完全一致 ✅

服务版和命令行版复用同一批函数(VAD、断句、折行、术语替换都是 import 过来的), 唯一改掉的是解码那一步——从逐段 decode_stream 换成批处理 decode_streams, 而 §4 已经证明批处理不改变文本。

(复用不等于复制:05-make-srt.py 那条 CLI 被抽出了一个 run(args) 入口, 服务 import 它;抽的时候跑了一遍回归,产出与抽之前逐行一致。)

8.1 端到端闭环:真实 MinIO 上的取件 → 转写 → 回传 → 回调​

上面验证的是"输出一致"。真实部署还要走完整条链路,所以拿内网的 MinIO (192.168.1.112:10000,桶 video-raw)实测一次:提交 1-经典的海龟交易策略.mkv (35 MB,音频 680 秒),回调指向宿主上一个接收器。

提交 → 202 {"job_id":"…","status":"queued"}
24.5 秒后:
MinIO 出现 1-经典的海龟交易策略.srt(17.8 KB / 888 行)
回调收到 {"job_id":"…","status":"done","srt_object":"1-经典的海龟交易策略.srt",
"elapsed_seconds":24.5,"speedup_x_realtime":27.7,
"timings":{"download":0.376,"vad_seconds":8.432,"asr_seconds":10.914,
"upload":0.06,"n_segments":49,"n_cues":212}}

闭环成立。拆账:这条 680 秒视频里 VAD 8.4 秒、ASR 10.9 秒(其余 ~5 秒是下载、ffmpeg、 标点、断句、上传)。

注意这里 ASR 反而比 VAD 贵——和 §6 那条视频(VAD 9.1s / ASR 5.96s)正好相反。 所以 §6 的"瓶颈在 VAD"不是一条普适定律,它随素材、时长、批大小漂移: 两条视频、同一份代码,谁是大头都能翻过来。这正是 §10 要收的那个尾—— "并发能力"没有一次性答案。

(另外,这次端到端还踩出容器少拼一个 curand 目录的坑——记在 §9.2。)

9. 坑​

9.1 显存是按"并发调用数"涨的,不是按模型数​

第 2 节的表里,一个只加载了 2.4 GB 权重的模型,在 24 线程并发下吃掉了 19.7 GB。 共享权重省的是权重那份,省不掉每次调用要的激活与 arena。

9.2 容器里的 CUDA 运行库:三个目录缺一不可,最容易漏的是 curand​

宿主上我们是"借用"另一个 conda 环境里的 cuDNN 9(前几篇一直这么干),容器里也一样—— 从宿主只读挂载那一份 nvidia/(见 Dockerfile ②:把 766 MB 的 cuDNN wheel 下进镜像,本机到 PyPI 只有 ~190 KB/s,要一个多小时,用国内镜像也改善不大, 干脆复用宿主那份),再在入口脚本里把需要的子目录拼进 LD_LIBRARY_PATH。

一开始只拼了 cudnn 和 cublas 两处,任务打到模型加载就炸:

RuntimeError: OrtSessionOptionsAppendExecutionProvider_Cuda: Failed to load shared library

ldd 一下 provider 才看清真正的依赖:

$ ldd libonnxruntime_providers_cuda.so | grep 'not found'
libcurand.so.10 => not found
$ ldd libonnxruntime_providers_cuda.so | grep /nvidia # 把 curand 补进路径后
libcublasLt.so.12 => /nvidia/cublas/lib/libcublasLt.so.12
libcublas.so.12 => /nvidia/cublas/lib/libcublas.so.12
libcurand.so.10 => /nvidia/curand/lib/libcurand.so.10

CUDA provider 直接依赖 curand,而不是只依赖 cudnn/cublas——照着别人一份 LD_LIBRARY_PATH 抄,最容易漏的就是它,而报错永远是那句没有信息量的 Failed to load shared library。最后入口脚本改成把 /nvidia/*/lib 全部拼进来, 但排在基础镜像的 /usr/local/cuda/lib64 之后:

SONSUB_LIB=$(python3 -c 'import sherpa_onnx,os;print(os.path.dirname(sherpa_onnx.__file__)+"/lib")')
NV=/nvidia
NV_LIBS=$(ls -d "$NV"/*/lib | paste -sd: -) # cudnn / cublas / curand / …
export LD_LIBRARY_PATH="$SONSUB_LIB:/usr/local/cuda/lib64:$NV_LIBS:$LD_LIBRARY_PATH"

顺序不能反:base 镜像自带的 libcudart(12.9)必须优先于宿主那份,反了就是版本串味。 版本也要正好——CUDA 12.9 的 libcudart(cudaLibraryGetKernel 只有 12.9+ 才有)、 cuDNN 9,错一个小版本还是同一句 Failed to load shared library(第 6 篇 §9.2 详述)。

一个操作教训:改这一行只需重打镜像末尾那一层,但当时 docker compose build 触发了 全量重建——本机 buildkit 缓存被清空了(只剩 12 MB),apt-get install ffmpeg 那一层 得从头下 430 MB。最后用"基于旧镜像顶一层 COPY docker-entrypoint.sh"的叠加镜像秒级绕开。 根因是构建上下文是整个 source/(1.6 GB,且没有 .dockerignore)—— 这也解释了为什么这台机器上"改一行 entrypoint"会这么贵。

9.3 worker 崩了,任务会永远卡在 running​

进程被 kill(OOM、docker stop 超时、手滑 pkill)时,那条任务的状态已经写成 running 了。 没有回收逻辑的话,它永远不会被再领走。所以启动时要做一次:

store.recover_orphans() # UPDATE jobs SET status='queued' WHERE status='running'

9.4 SQLite 要开 WAL,不然多进程抢写会 database is locked​

API 进程写、N 个 worker 进程抢着改状态,这是典型的多进程写场景。 PRAGMA journal_mode=WAL + timeout=30 之后才稳。顺带把 synchronous=NORMAL 也开了——本机那块 SSD 的 fsync 延迟很差(13~77 ms, 见全局 CLAUDE.md),跟着每次状态变更同步落盘会让整条流水线莫名卡顿。

9.5 提交时就该查对象在不在​

让任务跑一半才发现"视频不存在",白白占用一个 worker。所以在 POST /v1/jobs 里先 stat_object 探一下——这也是 /healthz 会真去列一次桶的原因: 不真调一次 S3 的健康检查等于没检查。

10. 收尾​

把这一篇和前几篇连起来看,是个挺典型的工程曲线:

  • 第 6 篇在选模型(准确率、CPU/GPU)——那时瓶颈在模型;
  • 第 7 篇在修词表(准确率的最后几个点)——那时瓶颈在数据;
  • 这一篇在做服务——瓶颈换到了 VAD,一个前两篇从没细看过的环节。

每次瓶颈转移,都不是因为前面那步做错了,而是因为前面那步被解决得太好: ASR 用批处理压到 5.96 秒之后,那个一直默默无闻的切分环节自然就成了最大的那块。

所以"并发能力"这个问题没有一次性答案——它是一张会移动的图。 而且移动的不只是"瓶颈在哪个环节",还有"最优配置"本身:同一个 3090, 批 16 时最优是 4 个 worker,批 8 时最优是 8 个(§5.1)—— 最优值是配着工作负载和硬件一起定的,抄不来。

唯一可靠的办法是每次拆账(§6 那张表),看钱现在花在哪。