Skip to main content

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 都带 provenanceenvironment_fingerprint —— 任何一个数字都能倒查回产生它的那份代码加那台机器。

1.2 2.2 个 GPU 小时花在哪

总账 2.2 GPU-小时 ≈ $0.83。但真正在测量的只有其中一小半:

2.2 GPU-小时 = 132 分钟,按真实比例拆开五层测量 61 min其余 约 40 min0132 min开机、拉镜像、SSH 排障 17 min ← 一半是踩坑装环境、下模型与 3 个数据集 4 min ← 镜像里依赖齐全服务冷启动 10 min ← 几乎全是 CUDA 图捕获并发扫描 35 min + 其余四层 26 min重跑、拉数据、销毁前逐个核对文件测量本身只占 46%。租机器的账单里,另外一半付给了开机、排障和冷启动。这也是不建议中途停机的原因:停一次再开,那 10 分钟冷启动要重付一遍。
怎么挑机器、SSH 为什么连不上、日志十几分钟不动怎么判断,这些在 01 - 在租用 GPU 上执行实测 里单独讲了,这篇只讲测出来的东西。

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
冷启动 569.03 s 的构成,按真实�时间比例0 s569 sdecode CUDA graph capture 562.70 sInit torch distributed 0.08 sLoad weight 2.39 s(权重才 3 GB,读盘不是瓶颈)其余约 3.9 s:进程拉起与就绪上报12 个批次桶要各编译一遍前向图,Inductor 在每个桶内部逐个试 kernel 变体。这笔钱全部花在首次启动。
权重只有 3 GB,加载 2.39 秒就完了;真正贵的是为 12 个批次桶逐个捕获 CUDA 图。打算分段租机器的话,这 9.4 分钟每次重启都要重付,除非编译缓存能持久化并复用。
不加 --warmup,第一轮数据全是假的

冒烟测试忘了加 --warmup,并发 2 的三轮跑成这样:

rep=1  wall=39.821s   ← 首轮
rep=2 wall= 1.181s ← 快 34 倍
rep=3 wall= 1.171s

汇总表被首轮一污染,wall mean 14.058lat 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% 的样本占比
1224548.4%96.8%41.4%
878041.6%89.6%25.6%
3252141.8%86.4%39.3%
6458536.1%78.6%23.1%

看上去很清楚:利用率随并发下降,忙碌比也跟着下滑。 当时就是这么读的,报告也是这么写的。

3.2 这个下降是假的

后来把四个 CSV 掐掉首尾的空转段重算,问题就露出来了:

并发窗口总长首尾空转真正在跑全窗口忙碌比掐掉空转后忙碌比掐掉空转后利用率均值
1224.5 s7.2 s217.3 s96.8%100%50.0%
878.0 s8.1 s69.9 s89.6%100%46.4%
3252.1 s7.1 s45.0 s86.4%100%48.4%
6458.5 s12.5 s46.0 s78.6%100%45.9%

四档的首尾空转都是 7–12 秒 —— 这是手动启停采样器的固定开销,跟并发没关系。但窗口长度从 224 秒缩到 58 秒,同一段空转除以越来越短的窗口,占比自然越来越大。忙碌比那条下降曲线,从头到尾就是这个除法。

两次采样窗口,按真实�秒数画在同一把尺子上并发 1在跑 217.3 s,窗口内没有一个 0 采样忙碌比 96.8%并发 64在跑 46.0 s忙碌比 78.6%灰色是采样器启停带出来的空转:7.2 s 对 12.5 s,两次差不多绿色是真正在跑:217.3 s 对 46.0 s,差了 4.7 倍分子几乎没变,分母缩了 4.7 倍 —— 忙碌比这条「下降曲线」就是这么来的,跟 GPU 干了什么无关。掐掉两头空转,四档窗口内部的 0 采样数都是 0,利用率均值平在 45.9–50.0%,不随并发变。教训:忙碌比只有在窗口紧贴负载时才可比。跨档位比之前,先看看每档的窗口有多长。
扫描 JSON 自带的 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 1 观测利用率 46–50%,堆并发不涨解释 A:kernel 本身就喂不饱decode 每步的活太小,塞不满 SM 阵列要往 kernel 层查,需要时间线解释 B:GPU 在等 CPU调度和同步开销在时间线上撕出空隙要往调度和编排层查怎么分辨:看 Layer 2 的阶段占比CPU 侧那两段(请求构建、交接)占总耗时的比例,随并发是涨还是跌涨 → 是 B;跌 → 不是 B
把两种解释并排写出来的好处是,「下一步查什么」变成一道有判据的选择题,不用凭直觉猜。判据就是 CPU 侧占比的变化方向。

四、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=1c=8c=32c=64
总计95.84251.22722.031357.41
decode58.82186.46581.00580.12
prefill30.7232.0832.8533.55
排队等待2.218.1845.53649.08
请求构建2.893.566.767.17
构建→入队交接0.345.5422.7732.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%。

一个请求在 asr 阶段内经过的五个区间,按真实耗时比例并发 1 总计 95.84 msprefill 30.72decode 58.82并发 64 总计 1357.41 ms排队等待 649.08decode 580.12请求构建 2.89 → 7.17 ms(占比 3.0% → 0.5%)构建→入队交接 0.34 → 32.17 ms排队等待 2.21 → 649.08 ms(2.3% → 47.8%)prefill 30.72 → 33.55 ms(32.1% → 2.5%)并发升高时,唯一按数量级膨胀的是排队等待。prefill 的绝对值几乎不变,占比塌缩只是因为分母变大了。CPU 侧那两段合计始终在 3% 上下,这是排除「GPU 在等 CPU」的直接依据。
换成按比例的横条之后,「并发 64 时近一半时间在排队」这件事不用算占比就能看出来。两条横条的总长度不代表绝对耗时(c=64 的真实总耗时是 c=1 的 14 倍),这里比的是构成

4.3 排除「GPU 在等 CPU」

回到 3.3 的判据 —— CPU 侧占比随并发是涨还是跌:

占总计比例c=1c=8c=32c=64
decode61.4%74.2%80.5%42.7%
prefill32.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_bucketscapture_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 延迟RTFxWER显存稳态功耗峰值
19.740.102 s0.124 s46.10.013722154 MiB153.4 W
215.760.127 s0.161 s74.60.013722154 MiB166.4 W
423.000.174 s0.233 s108.90.013722154 MiB169.2 W
831.320.255 s0.346 s148.30.013822156 MiB172.9 W
1640.370.395 s0.532 s191.20.013722186 MiB181.1 W
3247.680.668 s0.923 s225.80.013722350 MiB192.3 W
6447.151.339 s1.626 s223.30.013722486 MiB192.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 档之间没有差异。并发不损精度。
020.040.000.511.5拐点1248163264并发
悬停查看数值,点击图例可隐藏曲线
吞吐走左轴,两条延迟走右轴。32 之前吞吐几乎线性上升、延迟平缓;越过 32 之后吞吐持平、两条延迟同时陡升 —— 拐点重合,才是判断「饱和」而不是「还能再堆」的依据。点图例可以单独隐藏某条曲线。

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/sRTFx平均延迟p95 延迟WER
12.324145.80.430 s0.530 s0.1062
87.566474.61.036 s1.370 s0.1062
327.029440.84.173 s5.669 s0.1062

meanwhile,60 条结构不同的独白,180/180 全完成:

并发req/sRTFx平均延迟p95 延迟WER
11.24070.90.806 s0.979 s0.0986
84.542259.81.692 s2.135 s0.0984
324.913281.05.592 s7.768 s0.0985

长音频的饱和点是 8,不是 32。 而且到 32 不是持平,是倒退 —— 吞吐从 7.566 掉到 7.029,延迟却是 4 倍。而 meanwhile 到 32 还在慢慢爬。三种音频形状,三种行为。

吞吐随并发的走向,按音频形状分成三类183264短音频:32 饱和后持平60 秒长音频:8 达峰,32 倒退独白类:32 仍在缓慢爬升这条曲线不能只测一种只测短音频会得出「32 最优」,把这个值配到 60 秒长音频上,吞吐反而下降、延迟涨 4 倍任何并发建议都必须声明它适用的音频长度区间
纵轴是各自负载的相对吞吐,曲线之间的绝对高度不可比 —— 长音频单请求处理的音频秒数多得多,req/s 天然低。这里要看的是每条曲线自己的走向。

另外两条:

  • WER 在各并发档之间完全稳定(0.1062 和 0.0984–0.0986)。绝对值比短音频(1.37%)高得多,那是数据集难度差异,不是回归。
  • 长音频的 RTFx 反而更高(474.6 对 225.8)。长音频把每请求的固定开销摊薄了 —— prefill 恒定约 33 毫秒、请求构建、排队,跟 4.2 的拆解对得上。

这是整轮里最能搬走的一条:一个只测了短音频就写进文档的「最优并发 32」,会让长音频用户配出比默认更差的效果。

长音频 benchmark 不传 --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 MiBLayer 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 无关,换个模型也会遇到:

  1. 忙碌比只有在采样窗口紧贴负载时才可比。 手动启停采样器会带出固定几秒的空转,窗口越短占比越大,跨档位一比就得到一条不存在的下降曲线。要么掐掉首尾,要么记下每档的窗口长度(3.2 节)
  2. py-spy 需要 CAP_SYS_PTRACE,标准容器不给。 建实例时加 --cap-add SYS_PTRACE,或者改用不需要特权的请求级 profiler。同一条限制也挡住了 nsys(4.1 节)
  3. 读配置要读完整条解析链,不能只读兜底默认值。 上层的显式覆盖会让下层的分档默认变成死代码。先 grep 启动日志里解析后的实际值(5.1 节)

原始数据(扫描 JSON、100 毫秒采样 CSV、每请求 jsonl、环境指纹)和完整报告在 sglang-omni issue #1888