02 - Whisper 在 24G 消费卡上的运行时画像
你想在一张 4090 上跑 whisper-large-v3,第一批问题很实在:24G 显存够不够?并发开到多少之后就不再变快了?
查不到答案。官方文档里 Whisper 的性能数字全部来自 H200,消费级这一档没有公开数据。
所 以租了一张 4090 自己量。这篇是那一轮的完整记录:每一步跑了什么命令、量到什么数、以及两处被原始数据推翻的判断 —— 一处是动手前的预判,一处是发出报告之后重读日志才发现的。
一句话结论:默认参数在 24G 上开箱可用;短音频并发 32 饱和;但换成 60 秒长音频,饱和点掉到 8,开到 32 反而变慢。
会用到的四个词:
| 词 | 意思 |
|---|---|
| 忙碌比 | 采样窗口里 GPU 有活干的时间占比 |
| RTFx | 音频时长 ÷ 处理耗时,越大越快 |
| WER | 词错误率,识别准不准 |
| prefill / decode | 编码器啃输入音频 / 解码器一个字一个字往外吐 |
一、这一轮跑了什么
1.1 机器与版本
机器 Vast.ai 租用,RTX 4090 24564 MiB,SM 8.9,驱动 595.84,450 W 上限
128 核、503 GiB 内存,独 占实例,卡上没有别人
镜像 hongccc/sglang-omni:dev
仓库 sglang-omni 024d099b(当天最新 main,工作区干净)
栈 torch 2.13.0+cu130 / sglang 0.5.18 / sglang-omni 0.1.4 / transformers 5.12.1
模型 openai/whisper-large-v3(约 15 亿参数,FP16 权重约 3 GB)
数据集 SeedTTS EN 1088 条短音频
longlibriheavy-60 100 条 60 秒音频
meanwhile 60 条独白(结构和前两个都不一样)
三个数据集都钉了 revision,依赖做了 freeze 哈希,每个结果 JSON 都带 provenance 和 environment_fingerprint —— 任何一个数字都能倒查回产生它的那份代码加那台机器。
1.2 2.2 个 GPU 小时花在哪
总账 2.2 GPU-小时 ≈ $0.83。但真正在测量的只有其中一小半:
1.3 五层怎么分工
排查按 08 - 运行时性能排查方法论 的五层顺序走:
| 层 | 问什么 | 这轮的结果 |
|---|---|---|
| Layer 1 | 卡在 GPU 还是卡在 CPU? | 利用率只有一半,且堆并发也不涨 |
| Layer 2 | 时间具体花在哪个阶段? | decode 占大头,CPU 侧只占 2.9% |
| Layer 3 | 并发堆上去还能不能更快? | 短音频 32 饱和 |
| Layer 4 | 改一个参数做 A/B | 前提不成立,主动跳过 |
| Layer 5 | 换长音频,精度会不会退? | 精度没退,但饱和点挪了 |
二、开机第一步:默认参数直接跑通
2.1 一条动手前就想错的预判
Whisper 的 mem_fraction_static 默认写死 0.85。这个值是照 H200 调的 —— H200 有 141G,0.85 就是 120G;4090 只有 24G,0.85 是 20G,剩下不到 4G 要装激活值、CUDA 图和碎片。
按这个算术,启动就该 OOM。手册里为此专门准备了降到 0.75 → 0.70 → 0.65 的预案。
一次都没用上。 默认参数直接起来了,显存稳定在 22154 MiB,后面 3264 个请求在 7 档并发下全部完成,零丢弃。
「默认配置在消费级 24G 卡上开箱可用」本身就是一条结论,而且是这轮最有用的一条 —— 它让「24G 卡能不能 serve whisper」这个问题有了答案。
2.2 冷启动 9.5 分钟,98.9% 花在同一件事上
服务从启动到就绪约 9.5 分钟。这个数字在按小时租机器的时候会被反复付,值得拆开:
07:21:42.430 Init torch distributed begin
07:21:42.507 Init torch distributed end 耗时 0.08 s
07:21:42.510 Load weight begin
07:21:44.977 Load weight end 耗时 2.39 s
07:21:45.259 Capture target decode CUDA graph begin
backend=full, bs=[1,2,4,8,12,16,24,32,40,48,56,64]
07:31:07.958 Capture target decode CUDA graph end 耗时 562.70 s
07:31:11.460 Process asr ready
--warmup,第一轮数据全是假的冒烟测试忘了加 --warmup,并发 2 的三轮跑成这样:
rep=1 wall=39.821s ← 首轮
rep=2 wall= 1.181s ← 快 34 倍
rep=3 wall= 1.171s
汇总表被首轮一污染,wall mean 14.058、lat p95 12.789 全是废数。正式扫描必须加 --warmup 跑一轮丢掉。 下面所有数据每档都带一轮被丢弃的预热。
三、Layer 1:GPU 一直在忙,但只用了一半
3.1 原始采样
nvidia-smi 每 100 毫秒采一次,套在一次预热过的压测外面:
# 采样器先起,压测跑完再停 —— 所以窗口两头必然带一段空转,这一点后面会咬人
nvidia-smi --query-gpu=utilization.gpu,utilization.memory,power.draw \
--format=csv,noheader,nounits -lms 100 > util_c$C.csv &
python -u -m benchmarks.eval.benchmark_asr_seedtts \
--port 8000 --model-path openai/whisper-large-v3 \
--concurrencies $C --repeats 1 --warmup --output bench_c$C.json
直接从 CSV 算出来的数:
| 并发 | 样本数 | 利用率均值 | 忙碌比(>0%) | 超过 50% 的样本占比 |
|---|---|---|---|---|
| 1 | 2245 | 48.4% | 96.8% | 41.4% |
| 8 | 780 | 41.6% | 89.6% | 25.6% |
| 32 | 521 | 41.8% | 86.4% | 39.3% |
| 64 | 585 | 36.1% | 78.6% | 23.1% |
看上去很清楚:利用率随并发下降,忙碌比也跟着下滑。 当时就是这么读的,报告也是这么写的。
3.2 这个下降是假的
后来把四个 CSV 掐掉首尾的空转段重算,问题就露出来了:
| 并发 | 窗口总长 | 首尾空转 | 真正在跑 | 全窗口忙碌比 | 掐掉空转后忙碌比 | 掐掉空转后利用率均值 |
|---|---|---|---|---|---|---|
| 1 | 224.5 s | 7.2 s | 217.3 s | 96.8% | 100% | 50.0% |
| 8 | 78.0 s | 8.1 s | 69.9 s | 89.6% | 100% | 46.4% |
| 32 | 52.1 s | 7.1 s | 45.0 s | 86.4% | 100% | 48.4% |
| 64 | 58.5 s | 12.5 s | 46.0 s | 78.6% | 100% | 45.9% |
四档的首尾空转都是 7–12 秒 —— 这是手动启停采样器的固定开销,跟并发没关系。但窗口长度从 224 秒缩到 58 秒,同一段空转除以越来越短的窗口,占比自然越来越大。忙碌比那条下降曲线,从头到尾就是这个除法。
per_repeat 采样器是独立的第二个来源,它给出 47.7 / 47.4 / 48.9 / 45.8%(并发 1 / 8 / 32 / 64),同样是平的 —— 两个来源都说利用率不随并发下降。3.3 修正后的读数 ,和它留下的两种解释
去掉假象之后,Layer 1 说的是两句话:
- 跑起来之后 GPU 一刻没闲着(窗口内 0 采样数为零,最低的一个采样也有 25%)
- 但只用掉一半(45.9–50.0%),并发从 1 堆到 64,这个数纹丝不动
第二句比原来那条下降曲线有用:活多了 64 倍,利用率一点没涨,说明空隙不是「活不够」造成的。但「为什么只有一半」有两种相反的解释,第一层分辨不了:
四、Layer 2:时间到底花在哪个阶段
4.1 py-spy 挂不上,改用仓库自带的 profiler
方法论第二层默认用 py-spy。它在标准容器里跑不起来:yama.ptrace_scope = 1,这个开关在容器内只读,而 Docker 默认能力集不含 CAP_SYS_PTRACE —— 即便你在容器里是 root,也会拿到 Permission denied (os error 13)。缺的是 capability,不是 uid。
替代方案是仓库自带的请求级 profiler。它走 HTTP 端点,不需要任何特权:
# --profile-events 是开关不是数量:每档并发跑完正式轮次后,自动补一轮带事件记录的 pass
# --max-samples 200 是刻意压小的 —— 阶段拆解要的是各区间的相对占比,
# 200 条已经足够稳,跑满 1088 条只是多烧机时
python -u -m benchmarks.eval.benchmark_asr_seedtts \
--port 8000 --model-path openai/whisper-large-v3 \
--concurrencies 1,8,32,64 --repeats 1 --warmup --max-samples 200 \
--profile-events --profile-urls http://127.0.0.1:8000 \
--profile-event-dir layer2/events --output layer2/profiled.json
4.2 五个区间的耗时
单请求各阶段平均耗时,单位毫秒,每档 200 条样本:
| 阶段区间 | c=1 | c=8 | c=32 | c=64 |
|---|---|---|---|---|
| 总计 | 95.84 | 251.22 | 722.03 | 1357.41 |
| decode | 58.82 | 186.46 | 581.00 | 580.12 |
| prefill | 30.72 | 32.08 | 32.85 | 33.55 |
| 排队等待 | 2.21 | 8.18 | 45.53 | 649.08 |
| 请求构建 | 2.89 | 3.56 | 6.76 | 7.17 |
| 构建→入队交接 | 0.34 | 5.54 | 22.77 | 32.17 |
三个数字值得单独拎出来:
decode 在并发 32 就满了。 c=32 是 581.00 毫秒,c=64 是 580.12 毫秒 —— 准入并发翻了一倍,decode 耗时纹丝不动。多出来的请求根本没进入计算。
并发 64 时近一半延迟是纯排队。 排队从 45.53 涨到 649.08 毫秒,占比从 6.3% 跳到 47.8%。多出来的那 32 个请求就在队列里干等槽位。
prefill 是个常数。 30.72 到 33.55,并发翻 64 倍只涨了 9%。因为 Whisper 的编码器处理的是固定 30 秒窗口,单请求的编码成本跟并发无关;它的占比因此从 32.1% 塌到 2.5%。
4.3 排除「GPU 在等 CPU」
回到 3.3 的判据 —— CPU 侧占比随并发是涨还是跌:
| 占总计比例 | c=1 | c=8 | c=32 | c=64 |
|---|---|---|---|---|
| decode | 61.4% | 74.2% | 80.5% | 42.7% |
| prefill | 32.1% | 12.8% | 4.5% | 2.5% |
| 排队等待 | 2.3% | 3.3% | 6.3% | 47.8% |
| CPU 侧(构建 + 交接) | 3.4% | 3.6% | 4.1% | 2.9% |
是跌的,3.4% → 2.9%。解释 B 要求它涨,数据是反的,排除。
所以剩下解释 A:请求在等 decode 槽位,而 decode 本身在 batch ≤32 时就喂不饱这张卡。
但「为什么喂不饱」,阶段拆解答不了 —— 它只能说时间花在哪个阶段,说不了那个阶段的 kernel 是卡在启动开销还是带宽。要看 kernel 时间线,而 nsys 被同一套容器权限挡在门外。所以这条在结论表里标「弱」。
从代码能收窄一点:enable_pre_lm_encoder 默认打开时,_resolve_encoder_graph_buckets 把 capture_limit 设成 pre_lm_max_batch_size(8),而这个参数同时也限制编码器自身的批大小 —— 编码器路径的图覆盖是完整的,缺口不在那儿,指向 decode。代码级证据,没有运行时复验。
五、Layer 3:短音频在并发 32 饱和
SeedTTS EN 1088 条,每档 3 次测量轮次加一轮丢弃的预热,共 3264 个请求:
python -u -m benchmarks.eval.benchmark_asr_seedtts \
--port 8000 --model-path openai/whisper-large-v3 \
--concurrencies 1,2,4,8,16,32,64 --repeats 3 --warmup \
--sample-util --util-gpu-ids 0 --util-interval 0.5 --fingerprint \
--save-raw-dir raw/seedtts_en --output seedtts_en_sweep.json
跑了约 35 分钟,7 档全部 3264/3264 完成,零错误零丢弃:
| 并发 | req/s | 平均延迟 | p95 延迟 | RTFx | WER | 显存稳态 | 功耗峰值 |
|---|---|---|---|---|---|---|---|
| 1 | 9.74 | 0.102 s | 0.124 s | 46.1 | 0.0137 | 22154 MiB | 153.4 W |
| 2 | 15.76 | 0.127 s | 0.161 s | 74.6 | 0.0137 | 22154 MiB | 166.4 W |
| 4 | 23.00 | 0.174 s | 0.233 s | 108.9 | 0.0137 | 22154 MiB | 169.2 W |
| 8 | 31.32 | 0.255 s | 0.346 s | 148.3 | 0.0138 | 22156 MiB | 172.9 W |
| 16 | 40.37 | 0.395 s | 0.532 s | 191.2 | 0.0137 | 22186 MiB | 181.1 W |
| 32 | 47.68 | 0.668 s | 0.923 s | 225.8 | 0.0137 | 22350 MiB | 192.3 W |
| 64 | 47.15 | 1.339 s | 1.626 s | 223.3 | 0.0137 | 22486 MiB | 192.5 W |
三件事:
- 32 是拐点。到 64 吞吐不再涨(47.68 → 47.15,还略降),延迟却翻倍(0.668 → 1.339 秒)。这和 4.2 的排队数据是同一件事的两个视角 —— 多出来的时间全是排队。
- 显存跨 64 倍并发只涨了 332 MiB(22154 → 22486)。功耗峰值 192.5 W,而 4090 的 TDP 是 450 W —— 这张卡远没被吃满。
- WER 全程 0.0137–0.0138,7 档之间没有差异。并发不损精度。
5.1 是什么把它限制在 32?先排除了一个候选
「吞吐在 32 饱和」是测出来的。「什么把它限制在 32」是另一个问题,测量本身回答不了。
自然的怀疑对象是 decode CUDA 图的批次上限 —— 超出已捕获的最大桶就得走 eager,性能会掉。sglang 按显存分档设这个上限,4090 落在「A10 / 4090 / 5090」这一档,tp 小于 4 时默认 24:
# sglang/srt/server_args.py,按 GPU 显存容量分档
elif gpu_mem < 35 * 1024: # A10、4090、5090
if self.tp_size < 4:
decode_cuda_graph_config.max_bs = 24
elif gpu_mem < 90 * 1024: # H100、A100
if self.tp_size < 4:
decode_cuda_graph_config.max_bs = 256 # 十倍差距
按这段代码推,上限该是 24,和实测的 32 很接近 —— 看起来对上了。
但运行时日志把它否掉了:
Capture target decode CUDA graph begin. backend=full, num_tokens_per_req=1,
bs=[1, 2, 4, 8, 12, 16, 24, 32, 40, 48, 56, 64], avail mem=2.96 GB
实际捕获的桶一直到 64。 上层把 max_running_requests(64)当成显式覆盖传了下去,sglang 的分档默认值压根没生效。既然 32 和 64 都在已捕获的桶里,图容量不是拐点的成因。
排除之后还剩几个候选:捕获结束后可用显存只剩 2.61 GB,KV 池容量可能才是真正的约束;也可能是调度器的准入策略。这些都要新的实验,本轮没做。
只读默认值给出了一个 24,和观测值近到足以让人相信,而且是错的。服务真正用的值是解析链上最后一个写入者 —— 上层传了显式 覆盖,下层的分档默认就是死代码。
便宜的兜底:不管代码怎么写,先 grep 启动日志里解析后的实际值。sglang 会把捕获的桶列表打出来,一行就能定论。
六、Layer 5:换成长音频,饱和点整个挪了
这一层本来只是做功能回归 —— 确认长音频下精度不退化。精度确实没退,但顺带看到了更重要的东西。
longlibriheavy-60,100 条 60 秒音频,3 档并发 × 3 重复 + 预热,300/300 全完成:
| 并发 | req/s | RTFx | 平均延迟 | p95 延迟 | WER |
|---|---|---|---|---|---|
| 1 | 2.324 | 145.8 | 0.430 s | 0.530 s | 0.1062 |
| 8 | 7.566 | 474.6 | 1.036 s | 1.370 s | 0.1062 |
| 32 | 7.029 | 440.8 | 4.173 s | 5.669 s | 0.1062 |
meanwhile,60 条结构不同的独白,180/180 全完成:
| 并发 | req/s | RTFx | 平均延迟 | p95 延迟 | WER |
|---|---|---|---|---|---|
| 1 | 1.240 | 70.9 | 0.806 s | 0.979 s | 0.0986 |
| 8 | 4.542 | 259.8 | 1.692 s | 2.135 s | 0.0984 |
| 32 | 4.913 | 281.0 | 5.592 s | 7.768 s | 0.0985 |
长音频的饱和点是 8,不是 32。 而且到 32 不是持平,是倒退 —— 吞吐从 7.566 掉到 7.029,延迟却是 4 倍。而 meanwhile 到 32 还在慢慢爬。三种音频形状,三种行为。
另外两条:
- WER 在各并发档之间完全稳定(0.1062 和 0.0984–0.0986)。绝对值比短音频(1.37%)高得多,那是数据集难度差异,不是回归。
- 长音频的 RTFx 反而更高(474.6 对 225.8)。长音频把每请求的固定开销摊薄了 —— prefill 恒定约 33 毫秒、请求构建、排队,跟 4.2 的拆解对得上。
这是整轮里最能搬走的一条:一个只测了 短音频就写进文档的「最优并发 32」,会让长音频用户配出比默认更差的效果。
--model-path 会静默记错模型名benchmark_asr_longform 不传这个参数时,日志打印的是 against 127.0.0.1:8000 (Qwen/Qwen3-ASR-1.7B)。请求大概率还是打到实际服务的 Whisper 上,但结果 JSON 里记的模型名是错的,数据溯源直接作废。第一次跑漏了,发现后重跑。
每次都显式传 --model-path,并核对日志首行打印的模型名。
七、Layer 4 为什么没做
第四层的前提是「对 Layer 2 找到的每个可疑开销源做单变量 A/B」。Layer 2 没找到 —— CPU 侧占 2.9%,占比还随并发下降。没有候选就硬做 A/B,变量是自己拍脑袋选的,结果不构成证据。这是有条件的主动跳过,不是漏做。
顺带说一个容易犯的错:4.2 显示 decode 在 32 就满了,而准入上限是 64,直觉上该把它压到 32。但整轮实验的服务端 max_running_requests 一直是 64,变的只是客户端并发。「客户端压 64 时请求在排队」和「把 服务端准入设成 32 会更好」是两回事 —— 降准入只是把排队从调度器挪到 HTTP 层,总延迟未必改善。没测就不能写成结论。
八、结论与证据强度
| 结论 | 证据 | 强度 |
|---|---|---|
| 默认配置在 24G 卡上开箱可用,跨 64 倍并发显存只涨 332 MiB | Layer 3,每档 3264/3264 | 强 |
| 短音频吞吐在并发 32 饱和,到 64 延迟翻倍无收益 | Layer 3 | 强 |
| decode 批次在 32 已满(581.00 对 580.12 毫秒) | Layer 2 | 强 |
| c=64 时 47.8% 的延迟是排队等待 | Layer 2 | 强 |
| 图桶容量不是拐点的成因(捕获到 64) | 启动日志 | 强 |
| host 侧编排不是瓶颈,占比 2.9% 且随并发下降 | Layer 2 | 强(负面结论) |
| 跑起来之后 GPU 一刻没闲着,但只用掉一半,且不随并发变 | Layer 1 两个独立采样源 | 强 |
| 饱和点随音频长度移动,长音频 8 饱和、32 倒退 | Layer 5,两个数据集 | 强 |
| 三个数据集上并发都不损精度 | Layer 3 + Layer 5 | 强 |
| 利用率为什么停在一半 | 已排除 host 侧,其余未定 | 弱 |
| 真正把批次限制在 32 的是什么 | 已排除图容量,其余未定 | 无 |
最后两条标弱和无是刻意的。阶段拆解能说明时间花在 decode,但说不了 decode 的 kernel 为什么喂不饱 SM 阵列 —— 前者有数据,后者需要 kernel 时间线,而 nsys 和 py-spy 被同一套容器权限挡住。
九、这轮踩到的三个通用坑
跟 Whisper 无关,换个模型也会遇到:
- 忙碌比只有在采样窗口紧贴负载时才可比。 手动启停采样器会带出固定几秒的空转,窗口越短占比越大,跨档位一比就得到一条不存在的下降曲线。要么掐掉首尾,要么记下每档的窗口长度(3.2 节)
- py-spy 需要
CAP_SYS_PTRACE,标准容器不给。 建实例时加--cap-add SYS_PTRACE,或者改用不需要特权的请求级 profiler。同一条限制也挡住了 nsys(4.1 节) - 读配置要读完整条解析链,不能只读兜底默认值。 上层的显式覆盖会让下层的分档默认变成死代码。先 grep 启动日志里解析后的实际值(5.1 节)
原始数据(扫描 JSON、100 毫秒采样 CSV、每请求 jsonl、环境指纹)和完整报告在 sglang-omni issue #1888。