不用 ONNX Runtime,不用 JNI:我用纯 Java 写了一个 PP-OCRv6 推理引擎

做 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:

github.com/lxw112190/l...

它是一个面向 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

github.com/lxw112190/l...

如果觉得这个项目有点意思,欢迎:

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 推理优化感兴趣,可以关注后续更新。

相关推荐
宝桥南山1 小时前
DeepSeek - 尝试安装和使用一下DeepSeek Harness
人工智能·ai·aigc·ai编程
小海豚儿1 小时前
不是所有 Workflow,都值得升级成 Graph
人工智能·ai编程
X54先生(人文科技)1 小时前
Yuri演唱会碳硅协同思维推演备忘录
人工智能·深度学习·知识图谱·零知识证明
网易云信1 小时前
更强、更快、更普惠!网易智企帝王蟹率先接入 DeepSeek V4.1 Flash
人工智能·agent·deepseek
Coffeeee1 小时前
claude-video 一个让你的Agent拥有看视频能力的Skill
android·人工智能·aigc
知几蜗牛1 小时前
AI会聊天却不会改3D模型?从Pascal 1.0看懂MCP工具调用
人工智能
知几蜗牛2 小时前
AI写代码开始像带团队:Qwen Code补上工作流控制台
人工智能
知几蜗牛2 小时前
AI任务一多就堵车:问题可能不在模型速度
人工智能
啾啾Fun2 小时前
【AI Coding】5-Pi Agent Harness:一个极简终端编码Harness的解构
人工智能·microsoft·ai agent·claude code·ai coding