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 解码要做的事:把这串重复和空白清理掉,变成人看得懂的文字。规则就两条:

  1. 相邻重复的合并成一个
  2. 去掉所有空白符

所以上面那串经过 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,它会做一次"挣扎":

  1. 尝试把内存池里所有标记为空闲的块还给 CUDA(这叫 shrink arena),然后再试一次 cudaMalloc
  2. 如果还不够(通常确实不够,因为真正在用的块没法释放),彻底放弃。

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 的状态有多糟。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐