triton 2026-05-13
2026-06-11 优化
rec_pipeline/config.pbtxt
rec_ort_chunk_size: 128 -> 256
目的:减少 rec_pipeline 串行调用 rec_model 的次数。
rec_pipeline 每次最多把 256 个检测出来的文字框裁剪图 组成一个 batch,送给 rec_model 识别。
1 张图片 / 1 页 PDF
-> det 检测出 N 个文字框
-> rec_pipeline 把 N 个框裁成 N 张小文字条图
-> 按宽高比排序分组
-> 每组最多 256 个框
-> 每组调用一次 rec_model
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 解码是什么
时间步数量是跟着输入图宽度变的,不是固定 50。
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/空白符)
50 个时间步不等于 50 个字。 50 是模型能分辨的"最多多少个字符位置",实际识别出来的字数会小于等于 50。
一个字会跨好几个时间步
这就是 CTC 要解决的核心问题。来看个具体例子:
假设这个框是 “你好” 两个字,图宽 200 像素,模型切成 50 个竖条。"你"这个字在图里大概占了 80 像素宽,"好"占了 80 像素,中间还有些空白。
那模型从左到右扫过去,每个时间步看到的东西是:
时间步 1-3: 看到"你"的左边一点点 → 猜"你"
时间步 4-15: 看到"你"的主体 → 猜"你"
时间步 16-20: 看到"你"的右边和空白 → 猜空白
时间步 21-24: 看到"好"的左边 → 猜"好"
时间步 25-40: 看到"好"的主体 → 猜"好"
时间步 41-50: 看到右边的 padding → 猜空白
所以 50 个时间步的原始输出(每个时间步取概率最大的字)大概是这样:
你你你你你你你你你你你你你你你___你____好好好好好好好好好好好好好好好好____________
(_ 表示 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)。
新做法(分块推理再合并)
时间步数量是跟着输入图宽度变的,不是固定 50
设 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,下次直接拿来用,省掉反复跟驱动打交道的开销。 副作用就是显存占用只涨不缩,稳在历史峰值。
"显存超了之后到底发生什么"的链条
⭐️
没申请到想要那么大的显存,这次推理失败
或者cuda内部进入半死不活的状态。推理现成卡住,triton 队列里的请求因为卡住的线程槽位不释放,卡住。timeout
场景设定
假设你的 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_onnx backend 捕获到这个异常,把它包装成 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 error in rec_model
这种情况你能立刻知道是 OOM。
情况 B:卡死 + 超时(你之前遇到的那个)
这个是最讨厌的。OOM 发生的瞬间,有时候会触发:
- CUDA 内部进入半死不活状态,推理线程卡在某个同步点
- 或者后续请求堆积在 Triton 的请求队列里,被卡住的那个请求占着槽位不释放
结果是 Triton 没及时返回失败,客户端 socket 一直等响应头,最后触发你看到的:
TimeoutError: timed out
这就是为什么 OOM 有时候表现为"明确的错误信息",有时候表现为"神秘的超时"——取决于 OOM 发生时 CUDA 和 Triton 的状态有多糟。
更多推荐


所有评论(0)