
做 OCR 的 Java 开发者,大概率都遇到过这样一个问题:
Java 本身写业务很舒服,但一碰到 AI 推理,最后还是绕不开 native。
常见的方案基本都是:
- Java + ONNX Runtime
- Java + Paddle Inference
- Java + OpenCV
- Java + JNI/JNA
- Java 调 Python 服务
这些方案当然都能用,而且很多已经非常成熟。
但我一直在想一个问题:
如果只是为了运行 PP-OCR,有没有可能完全不依赖 ONNX Runtime、不依赖 Paddle Inference、不依赖 OpenCV Native,甚至连 JNI 都不要?
也就是说:
直接用纯 Java 把 PP-OCR 跑起来。
于是就有了这个项目:
lw.PPOCR.Java
GitHub:
它是一个面向 PP-OCRv6 的轻量级纯 Java 推理 Runtime,目前整个核心推理过程没有 native 依赖。
一、为什么要自己写一个 Java 推理引擎?
很多人第一反应可能是:
ONNX Runtime 已经这么成熟了,为什么还要重复造轮子?
其实我的目标从一开始就不是做一个通用 ONNX Runtime。
如果目标是支持几千个 ONNX 算子、各种 Transformer、各种动态模型,那么自己从头写 Runtime 显然没有意义。
但换一个思路。
如果我们只服务于:
PP-OCR。
事情就完全不同了。
PP-OCR 模型真正使用的算子数量并没有想象中那么多,而且模型结构相对固定。
于是可以把问题从:
如何实现一个通用深度学习推理框架?
缩小成:
如何针对 PP-OCR 做一个足够小、足够快、足够容易部署的专用 Runtime?
这也是 lw.PPOCR.Java 的核心设计理念。
二、它不是 Java 调一个推理库
目前项目已经实现了完整的推理链路:
objectivec
图片
↓
DET 文本检测
↓
透视裁剪
↓
CLS 文本方向分类
↓
旋转修正
↓
REC 文本识别
↓
CTC Decode
↓
Reading Order
↓
OCR Result
而下面这些东西也都是自己实现的:
vbnet
模型加载
Tensor 管理
Shape 推导
Memory Planning
Workspace 管理
Operator 执行
Conv
Depthwise Conv
MatMul
Pooling
Resize
Activation
Binary Operator
DB 后处理
CTC Decode
Perspective Crop
OCR Pipeline
也就是说:
这不是对 ONNX Runtime Java API 的二次封装。
而是真的自己执行神经网络计算。
三、不在运行时解析 ONNX
这个项目还有一个比较重要的设计选择。
Runtime 本身不直接加载 .onnx。
而是使用我之前在 lw.PPOCR.C 中设计的一套轻量模型格式:
LWM
大致流程是:
ONNX
↓
离线转换
↓
LWM
↓
lw.PPOCR.Java
这样做有一个很大的好处:
很多原本发生在运行阶段的事情,可以提前完成。
例如:
vbnet
ONNX Graph Parsing
↓
Operator Mapping
↓
Constant Processing
↓
Tensor Metadata
↓
Runtime Execution
可以变成:
离线阶段
ONNX
↓
转换
↓
LWM
运行阶段
LWM
↓
Prepared Model
↓
Execution
Runtime 因此可以做得更简单。
这其实有点类似很多高性能推理框架中的:
Compile Once,Run Many Times。
四、现在的项目结构
目前 lw.PPOCR.Java 已经拆成几个比较明确的模块。
arduino
lw.PPOCR.Java
│
├── lw-ppocr-core
│
├── lw-ppocr-vector
│
├── lw-ppocr-imageio
│
└── lw-ppocr-benchmark
其中:
lw-ppocr-core
这是整个项目的核心。
包含:
objectivec
LWM Loader
Inference Runtime
ScalarBackend
MemoryPlanner
ShapeResolver
DET
CLS
REC
OCR Pipeline
并且尽可能保持较低的 Java 运行环境要求。
lw-ppocr-vector
这是目前主要的高性能实现。
它使用:
Java Vector API
针对卷积、Depthwise Conv、MatMul 等热点算子进行 SIMD 加速。
这个模块目前使用 JDK 25。
核心模块和 Vector 模块是分开的,因此:
普通 Java 环境
↓
Scalar Backend
JDK 25+
↓
Vector Backend
不会因为 SIMD 优化把整个 Runtime 都锁死在高版本 JDK 上。
lw-ppocr-imageio
负责 Java 常见图片接口的适配,例如:
BufferedImage
ImageIO
Runtime Core 本身并不强绑定这些 API。
lw-ppocr-benchmark
这个模块现在越来越重要。
除了完整 OCR Benchmark,我还拆了很多算子级 Benchmark:
sql
MatMul
1×1 Conv
3×3 Conv
Stride-2 Conv
Depthwise Conv
MaxPool
DB PostProcess
PreProcess
Full OCR
因为做到现在以后,优化已经不能靠:
"感觉这个循环应该会快。"
而必须实际测。
五、纯 Java 到底能跑多快?
这是很多人最关心的问题。
最开始写这个项目的时候,我其实也没有把握:
纯 Java 能不能把 CNN 推理做到一个真正可用的速度?
结果比我最初预计的要好。
目前 CI 中有一组固定测试:
图片尺寸:500 × 500
检测尺寸:320
检测文字:16 行
PP-OCRv6 Tiny
在 GitHub Linux Runner 的一次稳态 Benchmark 中:
Scalar Backend
大约:
objectivec
完整 OCR:2906 ms
DET:526 ms
CLS:120 ms
REC:2257 ms
而换成 Vector API Backend 后:
objectivec
完整 OCR:178.5 ms
DET:32.0 ms
CLS:15.5 ms
REC:128.0 ms
这里需要特别说明:
GitHub Hosted Runner 并不是严格的专业 Benchmark 环境,不同 CI 执行之间会存在波动。
所以这些数字更适合看优化方向和数量级,而不是拿来当硬件性能排行榜。
但是有一点已经比较明确:
Java Vector API 对这个项目不是"小修小补",而是决定性能上限的核心能力。
六、一个有意思的例子:Depthwise Conv
PP-OCR 里面有不少 Depthwise Conv。
例如目前 Benchmark 中一个 5×5 Depthwise Conv:
Scalar 版本中位数大约:
0.299 ms
Vector API 版本:
0.077 ms
接近:
4 倍性能提升。
这也让我越来越确定:
纯 Java Runtime 要获得真正有竞争力的性能,不能只依赖 HotSpot 自动优化。
必须主动设计:
数据布局
循环顺序
内存访问
SIMD
缓存友好性
并行策略
到了这个阶段,写 Java 和写 C/C++ 高性能计算其实已经越来越像了。
七、最近开始做的另一件事:减少内存分配
做 Java 推理还有一个非常重要的问题:
GC。
如果每一个 Operator 都不断创建:
arduino
new float[]
new Tensor
new ArrayList
new Object
即使计算本身很快,GC 最终也会把延迟拉下来。
因此最近做了不少这方面的优化。
例如:
arduino
Workspace 复用
OCR staging buffer 复用
Tensor 使用计数预计算
CompiledModel metadata 共享
PreparedExecution
PreparedNode
Prepared Weights
现在 Scalar Backend 的稳定状态下,一次完整 OCR 的临时 Java 分配已经可以控制在几十 KB 量级。
这其实是我认为比单纯"快多少毫秒"更重要的一件事。
因为真正部署到服务器以后:
平均延迟
P95
P99
GC
内存占用
长时间稳定性
往往比一次 Benchmark 的最好成绩更重要。
八、REC 也做了一些专用 Fusion
通用 Runtime 通常会按照模型 Graph 一个节点一个节点执行。
但是专用 Runtime 有一个很大的优势:
可以知道后面究竟要干什么。
例如文字识别最后通常会出现:
objectivec
Feature
↓
Projection
↓
Large Tensor
↓
ArgMax
↓
CTC Decode
如果最终只需要每个时间步的最大类别,那么很多中间结果其实没有必要完整保存。
因此现在已经做了:
REC Projection + ArgMax + Compact CTC
相关 Fusion。
原本可能需要产生大约:
1,104,960 Bytes
的 Dense Output。
融合以后真正需要留下来的结果可以缩小到几百字节。
这里带来的直接计算加速未必特别巨大。
但它会减少:
内存写入
内存读取
临时 Tensor
GC 压力
Cache 压力
这就是专用 Runtime 很有意思的地方。
九、开始从"解释执行"走向 Prepared Execution
早期版本比较接近传统的:
arduino
for node in graph:
判断 operator
查 tensor
解析参数
执行
这样写最简单,也方便验证正确性。
但是随着性能优化深入,现在开始逐步变成:
sql
Model Load
↓
Compile
↓
Shape Resolve
↓
Prepare
↓
Memory Plan
↓
PreparedExecution
↓
Run
最近做的一些优化包括:
预计算 Tensor Use Metadata
Prepared Node
共享 Compiled Model Metadata
Prepared MatMul Weights
节点索引直接执行
Workspace Planning
我的目标是让模型进入稳定运行以后,Runtime 尽量不再:
javascript
查 Map
解析字符串
判断 Shape
创建对象
重复计算 Offset
最终越来越接近一个专用执行计划。
十、下一步:Java Microkernel
目前 VectorBackend 已经越来越大。
继续把所有优化都堆进去,显然不是一个长期方案。
所以接下来我准备参考 BLAS、XNNPACK、KleidiAI 等高性能计算库的一些设计思想,把 Vector Backend 进一步拆成:
Microkernel Architecture。
未来大致可能变成:
vbnet
Operator
↓
Kernel Dispatcher
↓
Kernel Family
↓
Shape Specialized Kernel
↓
Vector API
例如 Conv 不再只是一个:
scss
conv()
而会根据具体 workload 选择:
Conv1x1
Conv3x3S1
Conv3x3S2
Depthwise3x3
Depthwise5x5
ConvTranspose
甚至进一步根据:
css
M
N
K
Channel
Width
Stride
CPU
Vector Species
选择不同 Microkernel。
也就是说:
Java 版本现在才刚刚开始进入真正有意思的阶段。
十一、lw.PPOCR.C 和 lw.PPOCR.Java
这个项目其实还有一个兄弟项目:
lw.PPOCR.C
两个 Runtime 使用相同的 LWM 模型格式。
未来我希望它们形成这样的关系:
objectivec
LWM
│
┌───────┴───────┐
│ │
lw.PPOCR.C lw.PPOCR.Java
│ │
C Microkernel Java Microkernel
│ │
AVX / NEON Vector API
C 版本追求:
原生、极致、小型化。
Java 版本追求:
Zero Native Dependency + Java 生态下尽可能高的性能。
而 C Runtime 还可以反过来作为 Java Runtime 的 Golden Reference。
每一次 Java SIMD 优化以后,都可以和 C 版本的结果进行对照测试。
十二、为什么我觉得这个项目值得继续做?
因为它实际上在验证一个挺有意思的问题:
一个高度专用的 AI Runtime,到底可以有多小?
今天的软件栈越来越重。
运行一个几 MB 的 OCR 模型,有时最终要带上几十 MB、上百 MB 甚至更多的 Runtime。
但如果应用非常明确:
我只需要 OCR。
那么很多通用能力其实是不需要的。
可以直接:
vbnet
删除不需要的 Operator
删除 ONNX Parser
删除 Graph Optimizer
删除通用 Backend
删除 Native Runtime
然后针对真实 workload 优化。
这种思路不一定适合所有 AI 项目。
但是在:
工业视觉
边缘计算
离线程序
桌面软件
嵌入式设备
私有化部署
OCR SDK
这些场景里面,我觉得很值得探索。
十三、目前适合哪些开发者?
如果你正在做下面这些东西:
Java 桌面程序、Java 服务端 OCR、工业视觉、离线 OCR、边缘设备、需要减少 native 依赖的系统,或者单纯对"自己写推理 Runtime"感兴趣,都可以看看这个项目。
项目地址:
lw.PPOCR.Java
如果觉得这个项目有点意思,欢迎:
Star、Issue、PR,以及一起讨论 Runtime、SIMD 和性能优化。
写在最后
最开始做 lw.PPOCR.Java 的时候,我只是想验证:
不借助现成推理 Runtime,纯 Java 能不能把 PP-OCR 跑起来?
现在答案已经比较明确了:
可以。
接下来真正的问题已经变成:
能不能让它跑得足够快?
从目前 Vector API 的表现来看,我觉得这件事比最开始想象的更值得继续做下去。
后面准备继续分享:
- Java Vector API 是怎么优化卷积的
- 1×1 Conv 为什么值得单独写
- Depthwise Conv 如何 SIMD 化
- Java 推理 Runtime 如何减少 GC
- PreparedExecution 是怎么设计的
- 如何参考 KleidiAI 设计 Java Microkernel
- C Runtime 和 Java Runtime 如何做 Golden Test
- PP-OCR 专用 Runtime 如何一步步去掉通用推理框架的开销
如果你也对这些偏底层的 AI 推理优化感兴趣,可以关注后续更新。