创建日期: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 → 上传 → 回调
三个设计决定,都是被实测逼出来的:
- API 不做转写,只入队/查询——否则一个长任务就把接口堵死;
- worker 是进程不是线程——原因就是下一节;
- 任务落 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%。 把并发度扫一遍,再看两个数字就明白为什么了——
| 并发 | 墙钟 | 吞吐 | 解码耗时的累加 | 显存 |
|---|---|---|---|---|
| 1 | 13.34s | 73.2× | 10.9s | 2706 MB |
| 2 | 8.67s | 112.7× | 14.9s | 13244 MB |
| 4 | 10.41s | 93.8× | 38.5s | 14312 MB |
| 8 | 10.85s | 90.0× | 81.8s | 16444 MB |
| 16 | 10.52s | 92.9× | 158.3s | 19688 MB |
| 24 | 10.96s | 89.1× | 235.3s | 23954 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. 那就换:每线程/每进程一份
既然同进程内争用,就把模型复制几份。每线程一份:
| 实例数 | 墙钟 | 吞吐 | 加速 | 单实例吞吐 |
|---|---|---|---|---|
| 1 | 12.95s | 75.4× | 1.00× | 75.4× |
| 2 | 8.04s | 121.4× | 1.61× | 60.7× |
| 4 | 10.05s | 97.2× | 1.29× | 24.3× |
| 8 | 11.00s | 88.8× | 1.18× | 11.1× |
2 个实例到 1.61×,之后"单实例吞吐"一路掉到 11×——还是同一个瓶颈,只是这次换了地方排队。
再换成每进程一份(彻底绕开进程内的 ORT 上下文):
| 进程数 | 墙钟 | 吞吐 | 加速 | 单进程吞吐 | GPU 均值 | 显存 |
|---|---|---|---|---|---|---|
| 1 | 13.30s | 73.4× | 1.00× | 73.4× | 14.4% | 2446 MB |
| 2 | 7.64s | 127.8× | 1.74× | 63.9× | 34.0% | 3756 MB |
| 4 | 9.74s | 100.3× | 1.37× | 25.1× | 15.5% | 5350 MB |
| 8 | 10.04s | 97.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 条流,那笔固定开销只付一遍。
实测:
| 批大小 | 墙钟 | 吞吐 | 加速 | 每段均摊 | 文本一致 |
|---|---|---|---|---|---|
| 1 | 12.99s | 75.2× | 1.00× | 199.8 ms | 65/65 |
| 2 | 8.57s | 114.0× | 1.52× | 131.8 ms | 65/65 |
| 4 | 6.35s | 153.8× | 2.05× | 97.7 ms | 65/65 |
| 8 | 5.31s | 184.1× | 2.45× | 81.6 ms | 65/65 |
| 16 | 4.87s | 200.5× | 2.67× | 74.9 ms | 65/65 |
| 32 | 4.90s | 199.1× | 2.65× | 75.5 ms | 65/65 |
批 16:单进程从 75× 提到 200×(2.67 倍),而文本逐字一致——这正是"省固定开销"该有的样子。 再加到 32 反而略降(padding 到最长序列会浪费算力)。
5. 叠乘:批处理 × 多进程
批处理省的是"固定开销",多进程加的是"并发路数"——两者不冲突,应该能叠:
| 配置 | 吞吐 | 显存 | GPU 均值 |
|---|---|---|---|
| 1 进程 × 批 1 | 75× | 2.4 GB | 7% |
| 2 进程 × 批 8 | 310.6× | 4.8 GB | 10% |
| 4 进程 × 批 8 | 405.9× | 9.5 GB | 27% |
| 4 进程 × 批 16 | 397.3× | 13.5 GB | — |
| 6 进程 × 批 16 | 437.0× | 14.1 GB | — |
| 8 进程 × 批 16 | 394.8× | 20.1 GB | — |
| 12 进程 × 批 8 | 364.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、带回调),只改配置各跑一遍:
| workers | batch | 吞吐 | 加速 | P50 延迟 | 显存 | 单任务中位 |
|---|---|---|---|---|---|---|
| 1 | 16 | 43.2× | 1.00× | 80.6s | 5.5 GB | 45.0× |
| 2 | 16 | 81.1× | 1.88× | 51.1s | 10.9 GB | 44.2× |
| 4 | 16 | 160.5× | 3.72× | 66.2s | 21.7 GB | 43.4× |
| 6 | 8 | 216.8× | 5.02× | 52.2s | 20.2 GB | 40.7× |
| 8 | 8 | 255.4× | 5.91× | 44.8s | 23.9 GB | 36.6× |
| 8 | 4 | 214.8× | 4.97× | 54.3s | 18.7 GB | 31.1× |
| 10 | 4 | 213.5× | 4.94× | 57.7s | 23.4 GB | 26.6× |
| 12 | 4 | 234.0× | 5.42× | 52.7s | 24.1 GB | 21.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,于是有人分配不到显存。
三件事值得记下来:
-
这台机器是三张卡共享的(3090 + 两张 V100 常年有别人的任务), 显存不是你能规划的资源——同一份配置,早上跑得通、下午就 OOM。 这也解释了为什么第 6 篇里那些"RTF 忽快忽慢"的数字;
-
服务对这件事的反应是对的:OOM 被 worker 的兜底
except接住, 失败的任务带上原始报错,其余 6 个照常完成,服务没有崩—— 如果当时是"一个异常杀掉整个 worker 进程",损失会大得多。 (同一轮扫到workers=6时更明显:12 个里只成功 4 个—— worker 越多,同时抢显存的进程越多,撞上的概率越大。这条路是负向扩展的:) -
所以要把 OOM 和"数据有问题"分开对待。显存是共享且瞬时的——被别人挤一下, 等几十秒就又有空间了,判成
failed等于把可恢复的故障永久化。服务的做法是重排队:if is_oom(msg): # 认 BFC arena / CUDA / out of memory 这几种写法store.requeue(jid, msg) # 退回队列,attempts+1else: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 | 加速 | 段数 | 与不分块一致 |
|---|---|---|---|---|---|
| 1 | 14.95s | 0.0146 | 1.00× | 51 | — |
| 2 | 7.12s | 0.0069 | 2.10× | 55 | 否 |
| 4 | 5.55s | 0.0054 | 2.70× | 49 | 否 |
| 8 | 3.25s | 0.0032 | 4.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.44s | 49 |
| 2048 | 7.24s | 49(段数一样,但边界已变) |
| 8192 | 7.13s | 33 |
| 16000 | 7.40s | 15 |
| 32000 | 10.36s | 4 |
| 160000 | 8.04s | 1 |
喂大块会把切分整个打乱——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 那张表),看钱现在花在哪。