triton 2026-05-13
1. “按最宽的比例 padding 成一个大 batch”
先说背景:OCR 的识别模型(rec 模型)一次能吃一批文字图片(这就是 batch),一起推理比一张一张推理快得多。但有个硬性要求:这一批里的图片尺寸必须完全一样,不然没法堆成一个规整的张量喂给 GPU。
问题来了——检测阶段切出来的文字框,形状千奇百怪:
框1: "你好" → 60 × 32 (短) 框2: "今天天气真不错" → 200 × 32 (长) 框3: "Hello World" → 180 × 32 (中等长) 框4: "A" → 20 × 32 (超短)高度一般都 resize 到 32(模型固定的),但宽度差别巨大。怎么办?
做法:找出这批里最"扁最长"的那个的宽高比,记作max_wh_ratio。然后所有图片都按这个比例 resize + padding(不够宽的右边补灰色/黑色像素)到同一个宽度。
举例,假设max_wh_ratio = 200/32 = 6.25,那大家都按 6.25 的宽高比处理,最后全部变成200 × 32:
框1: "你好" → resize 到一定宽度,右边补空白,凑到 200 × 32 框2: "今天天气..." → 它本来就是最长的,直接 200 × 32 框3: "Hello..." → 同理 padding 到 200 × 32 框4: "A" → 大量空白 padding 到 200 × 32现在 4 张图都是200 × 32,可以堆成一个[4, 3, 32, 200]的张量(4 张图、3 个颜色通道、高 32、宽 200)一次性丢给 GPU。
这就是"按最宽的那个 padding 成一个 batch"。
为什么之前会爆显存?想象一下一张图检测出来 200 个框,里面混进一个超长的(比如一整行中文),那max_wh_ratio会被拉得很大,导致所有200 个框都被 padding 成又宽又大的图,最后张量是[200, 3, 32, 800]这种怪物——一次塞进 GPU 就可能 OOM。
2. CTC 解码是什么
CTC 是识别模型输出转成文字的那一步。
识别模型(rec_model)拿到一张32 × 200的文字图,它不会直接吐出字符串,它吐出来的是一个概率矩阵。大概长这样(假设宽度方向切成 50 个时间步,字表有 6000 个字):
时间步1 时间步2 时间步3 时间步4 ... 时间步50 "你" 0.9 0.85 0.1 0.05 ... "好" 0.02 0.05 0.8 0.9 ... "天" 0.01 0.01 0.01 0.01 ... ... " "(空) 0.05 0.08 0.08 0.03 ...意思是:在每一个横向位置(时间步),模型都猜一下"这里可能是哪个字"。
直接取概率最高的字拼起来,会得到类似:
你 你 你 _ 好 好 好 好 _ _ 天 天 气 气 _ ...(_表示 blank/空白符)
CTC 解码要做的事:把这串重复和空白清理掉,变成人看得懂的文字。规则就两条:
- 相邻重复的合并成一个
- 去掉所有空白符
所以上面那串经过 CTC 解码后就变成 “你好天气…”。
为什么要这么搞?因为模型看不懂"字和字的边界在哪",它只能每个横向小切片都猜一下,同一个字可能跨了好几个切片都被猜中。CTC 就是专门处理这种"没对齐的序列输出"的算法。
一句话:CTC 解码 = 把模型吐出来的概率矩阵翻译成人类能读的字符串。
3. “一锤子大 batch” vs “分块推理再合并”
用一个具体场景让你秒懂。
场景:一张文档图,检测出来 100 个文字框。
老做法(一锤子大 batch)
100 个框 → 全部 padding 成同一尺寸 → 堆成 [100, 3, 32, 200] 的大张量 → 一次性送进 GPU 推理 → GPU 瞬间要吃下 100 张图的算力和显存 → 得到 [100, 50, 6000] 的 logits → CTC 解码得到 100 个字符串问题:那一下"一次性送进 GPU"时,显存要同时装下 100 张图的中间计算结果。如果框再多点、图再大点,GPU 直接撑爆,或者忙不过来超时(就是你之前那个TimeoutError)。
新做法(分块推理再合并)
设rec_ort_chunk_size = 32,那 100 个框分成 4 块:32 + 32 + 32 + 4。
块1: 前32个框 → [32, 3, 32, 200] → GPU 推理 → logits_1 [32, 50, 6000] 块2: 中32个框 → [32, 3, 32, 200] → GPU 推理 → logits_2 [32, 50, 6000] 块3: 再32个框 → [32, 3, 32, 200] → GPU 推理 → logits_3 [32, 50, 6000] 块4: 最后4个 → [4, 3, 32, 200] → GPU 推理 → logits_4 [4, 50, 6000] 把 logits_1/2/3/4 在 Python 里拼起来 → [100, 50, 6000] 一次性 CTC 解码 → 100 个字符串好处:
- GPU 每次只需要装得下 32 张图的量,显存峰值固定、可预测。
- 框再多也不怕,无非多跑几次,每次还是 32 张。
- 最终结果和一锤子做法完全一样,因为 CTC 解码是在所有 logits 拼齐后一次做的,和"一次推完 100 个"效果等价。
不释放显存的原因
一句话:申请/释放 CUDA 显存很慢,所以 ONNX Runtime 用内存池复用,用完只标记空闲不还给 OS,下次直接拿来用,省掉反复跟驱动打交道的开销。 副作用就是显存占用只涨不缩,稳在历史峰值。
好,这个问题问到点子上了。我给你画一条完整的"显存超了之后到底发生什么"的链条,从最底层一路讲到你客户端看到什么。
场景设定
假设你的 GPU 有8 GB 显存,现在情况是这样:
已占用: ├── CUDA Context 0.5 GB ├── 模型权重 (det + rec) 2.0 GB ├── ONNX Runtime 内存池 3.5 GB (历史峰值涨到这儿) └── 其他 0.5 GB ───────────────────────── 合计已占: 6.5 GB 剩余可用: 1.5 GB现在来了个大请求,rec_model 要推理一批超大的文字框,中间激活需要2.5 GB显存。
池子里空闲的不够(池子虽然有 3.5 GB 但大部分是已分配未释放的碎片,真正能复用的可能只有 0.5 GB),需要再找 CUDA 额外申请2 GB。
但 GPU 只剩 1.5 GB。
显存超了,链条开始走。
链条第 1 环:CUDA 驱动层
ONNX Runtime 调cudaMalloc(2GB)找驱动要显存。
驱动一看:没这么多了。
驱动返回一个错误码:cudaErrorMemoryAllocation(也叫out of memory,错误码 2)。
关键点:CUDA 不会帮你"想办法挤一挤",没有就是没有,立刻返回失败。
链条第 2 环:ONNX Runtime 层
ONNX Runtime 拿到cudaErrorMemoryAllocation,它会做一次"挣扎":
- 尝试把内存池里所有标记为空闲的块还给 CUDA(这叫 shrink arena),然后再试一次
cudaMalloc。 - 如果还不够(通常确实不够,因为真正在用的块没法释放),彻底放弃。
ONNX Runtime 抛出一个异常,大概长这样:
onnxruntime::BFCArena::AllocateRawInternal Failed to allocate memory for requested buffer of size 2147483648 CUDA failure 2: out of memory这次推理作废,没有输出。
链条第 3 环:Triton 的 ONNX backend
Triton 的onnxruntime_onnxbackend 捕获到这个异常,把它包装成 Triton 内部的推理失败:
Inference request failed: CUDA out of memory error in rec_model**这里有个重要的事:**这次请求失败了,但 Triton服务本身不会崩。它只是把这次 infer 标记为失败,继续处理下一个请求。
但是——如果这个 OOM 发生在非常底层(比如 CUDA Context 本身损坏),有概率导致整个 CUDA 上下文进入错误状态,这种情况下所有后续推理都会失败,必须重启 Triton 进程才能恢复。这是最坏情况,不常见但存在。
链条第 4 环:BLS 调用(你这次重构特有的一环)
因为你的rec_pipeline是通过 BLS 调rec_model的,所以失败信息会按这条路回传:
rec_model 推理失败 ↓ Triton 把失败结果塞回 InferenceResponse ↓ rec_pipeline 里的 inference_request.exec() 返回一个带 error 的 response ↓ 你在 Python 代码里能通过 response.has_error() 检测到这时候rec_pipeline有两个选择:
- 不处理错误:异常往上冒,整个 ocr_ensemble 请求失败。
- 处理错误:比如把 chunk_size 减半重试一次,或者跳过这个 chunk 返回部分结果。
看你的rec_pipeline代码里有没有写容错逻辑,大概率是直接失败。
链条第 5 环:ocr_ensemble 层
ocr_ensemble是个 ensemble,它串了 det + rec 流程。中间某一步失败了,整个 ensemble 的这次请求就失败,返回失败响应。
链条第 6 环:客户端(就是你那个报错的地方)
这里要分两种情况看客户端体验:
情况 A:失败响应及时回来了
客户端收到 HTTP 400 或 500,带着错误信息,类似:
tritonclient.utils.InferenceServerException:[500]CUDA out of memory errorinrec_model这种情况你能立刻知道是 OOM。
情况 B:卡死 + 超时(你之前遇到的那个)
这个是最讨厌的。OOM 发生的瞬间,有时候会触发:
- CUDA 内部进入半死不活状态,推理线程卡在某个同步点
- 或者后续请求堆积在 Triton 的请求队列里,被卡住的那个请求占着槽位不释放
结果是 Triton没及时返回失败,客户端 socket 一直等响应头,最后触发你看到的:
TimeoutError: timed out这就是为什么 OOM 有时候表现为"明确的错误信息",有时候表现为"神秘的超时"——取决于 OOM 发生时 CUDA 和 Triton 的状态有多糟。
完整链条总结
推理需要 2.5 GB,剩余 1.5 GB ↓ [CUDA 层] cudaMalloc 失败,返回 OOM 错误码 ↓ [ONNX Runtime 层] 尝试 shrink arena,还是不够,抛异常 ↓ [Triton backend 层] 这次推理标记失败;Triton 进程一般不崩 ↓ [BLS 层] rec_pipeline 收到带 error 的 response ↓ [Ensemble 层] ocr_ensemble 整个请求失败 ↓ [客户端] ├── 顺利情况:收到明确的 OOM 错误 └── 倒霉情况:请求卡住 → TimeoutError: timed out几个关键认知
1. OOM 不会让 GPU 永久挂掉,但会让这次请求挂掉。下次请求(如果不那么大)通常还能正常跑。
2. 但 OOM 会留下"伤痕"。因为内存池可能在 OOM 过程中被搞乱、产生碎片,导致后面即使总量够,也可能因为没有连续大块而再次失败。所以你有时候会看到"一开始好好的,某次大请求 OOM 后就频繁出错"这种情况。
3. 超时不等于 OOM,但 OOM 可能表现为超时。所以你之前那个TimeoutError,OOM 是嫌疑之一,但也可能是纯粹推理慢、网络问题、队列堆积。要去看 tritonserver 日志里有没有out of memory字样才能确认。
4. 这就是为什么分块推理特别重要。重构后峰值固定在小范围内,每次cudaMalloc要的量不大,几乎不可能 OOM,整条链条根本触发不了。相当于把这颗雷从源头拆了。
建议你做的事
去 tritonserver 的日志里 grep 一下:
grep -i "out of memory\|oom\|cuda.*error\|allocation" triton.log如果能找到,那就坐实了之前的 TimeoutError 是 OOM 引起的;找不到,那超时就是别的原因(推理慢、队列、网络),得另外查。
