前两天连载了 SimdPaddleOCR 的发布 和 C# 手写向量化的底层逻辑。1.1 版本在 win-x64 上已经基本站稳了脚跟,但短板也很明显:ARM64 环境下虽然能跑,但速度明显慢了一截。
现在 ARM64 早就不是小众平台了------MacBook、手机、Windows on ARM 以及越来越多的 Linux ARM 服务器包括国产Linux服务器都在用。在 1.1 里,ARM 属于「x64 调完顺便能跑」的状态;而这次发布的 1.2 版本,我把 ARM64 当成了一等公民来专门优化。

废话不多说,先看优化结果。为了保证严谨,以下数字全部跑在同一台 GitHub-hosted linux-arm64(Neoverse N2,CPU part 0xd49) 机器上,使用 PP-OCRv6 tiny 模型,且去掉了首次加载(warmup)的耗时。这是中位墙钟时间对比:
| 用例 | 1.1 版本 | 1.2 版本 | 墙钟对比 | 吞吐对比 |
|---|---|---|---|---|
| 4 worker | 472 ms | 273 ms | 快约 42% | 约 1.73× |
| 1 worker | 658 ms | 399 ms | 快约 40% | 约 1.65× |
| 关掉全部硬件加速 (scalar) | 1162 ms | 1159 ms | 基本持平 | --- |
标题里说的「提速 40%」指的就是真实物理机上的这一档表现,不是虚拟机,也不是个别模型的特殊爆发。注意看最后一行 scalar(标量)的对照数据,两者几乎没变,这说明这 40% 的提升纯粹来自于代码层面的 SIMD 优化,而不是因为跑 CI 的机器恰好变快了。
这 40% 是从哪里抠出来的?
我们把推理过程按算子拆开来看(以 N2 机器 tiny 4 worker 为例)。下面是每张图各算子耗时的均值:
| 核心算子 | 1.1 耗时 | 1.2 耗时 |
|---|---|---|
| Conv(合计) | 712 ms | 309 ms |
| - 其中 Conv1x1 | 469 ms | 176 ms |
| - 其中 Conv3x3Stride2 | 79 ms | 19 ms |
| Depthwise3x3 | 30 ms | 27 ms |
| MatMul | 77 ms | 71 ms |
| Erf | 68 ms | 61 ms |
可以看出,大头全在 1×1 卷积 和 stride-2 的 3×3 卷积 上。像 MatMul、Erf 和深度卷积几乎没动。昨天那篇文章提到过,C# 可以用同一份源码覆盖 x64 和 ARM。1.2 所做的事情,就是针对 ARM64 的指令集特性,把 AdvSimd 铺进了这两条热点路径里,让 x64 和 ARM 各走各的最优解,互不拖累。
代码到底改了什么?
这里稍微深入一下实现细节。
1×1 卷积的特点是不看邻域,每个输出通道就是对所有输入通道做加权求和。在检测网络里这种操作极多,所以它占了耗时的半壁江山。
在 ARM64 上,一个 Vector<float> 一次能装 4 个相邻像素 。我们每次同时计算 8 个输出通道。原本用普通 C# 写的逻辑是这样的:
csharp
// value 是 4 个像素,w[k] 是第 k 个输出通道的权重标量
// 每次都要把 w[k] 复制成 [w, w, w, w] 再去乘
a0 = a0 + value * new Vector<float>(w[0]);
a1 = a1 + value * new Vector<float>(w[1]);
// ... 一路加到 a7
这在数学上没问题,但慢在「取权重」这一步:8 次标量读内存,每次还要再广播成向量。考虑到输入通道动辄几十上百,这 8 次操作会被循环放大无数倍。
在 ARM 指令集里,有一条专门干这事的指令,在 C# 里叫 FusedMultiplyAddBySelectedScalar。简单来说,它允许你不用提前广播标量,指令自己会从权重向量里挑出一个特定的位置(lane)去相乘累加。
于是,优化后的代码就变成了这样:一次性加载 8 个权重,然后按需挑拣。
csharp
Vector128<float> v = value.AsVector128();
Vector128<float> low = Vector128.Load(weights); // 一次装入 w0, w1, w2, w3
Vector128<float> high = Vector128.Load(weights + 4); // 一次装入 w4, w5, w6, w7
// 四个像素一起乘同一个 w0,省去了前面频繁读标量和广播的开销
a0 = AdvSimd.Arm64.FusedMultiplyAddBySelectedScalar(a0.AsVector128(), v, low, 0).AsVector();
a1 = AdvSimd.Arm64.FusedMultiplyAddBySelectedScalar(a1.AsVector128(), v, low, 1).AsVector();
// a2..a7 依此类推
同样的算术题,开销大幅下降(详见 SimdOps.VectorAddMulPacked8)。利用 JIT 时常量的特性,不同架构在编译时会自动剥离不需要的分支,做到零运行时开销。
另一个关键点是 stride-2 的 3×3 卷积 。特征图要缩小一半,我们需要的是"隔一个取一个"(p0, p2, p4, p6),而不是连续的。如果硬取,会把不需要的像素(p1, p3)也带进来。这里我用到了 ARM 的 UnzipEven,专门用来从两段向量里抽出偶数位:
csharp
Vector128<float> first = Vector128.LoadUnsafe(ref source); // p0 p1 p2 p3
Vector128<float> second = Vector128.LoadUnsafe(ref Unsafe.Add(ref source, 4)); // p4 p5 p6 p7
// UnzipEven 直接把偶数位置抽出来,拼成 p0 p2 p4 p6
return AdvSimd.Arm64.UnzipEven(first, second).AsVector();
x64 上对应的等价操作是 Sse.Shuffle。下采样全靠这一手,这也就是为什么表里的 Stride2 耗时能从 79 ms 暴跌到 19 ms。不必在评论区对汇编,看懂这两处优化,这 40% 的提升就落到实处了。
内存:比 OpenVINO.NET 更轻量
光看墙钟时间还不够,作为 OpenVINO.NET 的作者,我知道很多人会拿它来做对比。我们来看看在同一台 win-x64 / EPYC 7763 机器上,使用同一套 tiny 模型的表现:
| 引擎 | 4 worker 中位墙钟 | 工作集峰值 |
|---|---|---|
| 本库 (SimdPaddleOCR) | 224 ms | 约 790 MB |
| OpenVINO.NET | 214 ms (约 0.95×) | 约 2600 MB |
| lw.PPOCR.C | 281 ms | 约 586 MB |
OpenVINO.NET 在速度上略快了约 5%,但它的工作集内存是本库的 三倍。SimdPaddleOCR 加载后大约上涨 370--380 MB,峰值非常稳定。如果你在意服务器的单机多实例部署成本,或者觉得内存比这 5% 的时间更贵,这个差距绝对值得考虑。(注:ARM64 上本库的峰值大约 847 MB,量级和 Windows 接近。最瘦的依然是纯 C 引擎,但速度较慢。)
其他变化:基础向量化路径也提速了 11%
除了 ARM64 的专属优化,这次版本更新也惠及了旧设备。在关闭 AVX 的情况下,走通用的 Vector<float> 路径,在同一颗 EPYC 7763 上,耗时从 554 ms 下降到了 494 ms,快了约 11%。
至于正确率,大家放心,完全没动。在 100 张合成图的测试集上,依然保持 757/1022 行精确、CER 3.53%,不管你是 x64 还是 ARM64,哪怕是纯标量路径,结果都是严格一致的。
这次优化过程中,大模型帮了不少忙。我主要利用大模型在热点路径上生成汇编指令参考,辅助理解底层寄存器的运作,最后再自己动手翻译回 C# 向量化代码。模型是非常好的辅助工具,但最终的性能数字,一切以 GitHub Actions 的实测为准。
开源与本地部署
SimdPaddleOCR 采用 Apache-2.0 协议开源。它的核心竞争力依然是:纯托管 C#,无痛跨平台。不绑 Paddle Inference,不绑 ONNX Runtime,不绑原生 OpenCV,极其适合内网和离线环境部署。
开源仓库: https://github.com/sdcb/SimdPaddleOCR
如果这个项目对你有帮助,欢迎去 GitHub 点个 Star。1.2.0 版本会同步推送到 NuGet,搜索 Sdcb.SimdPaddleOCR 即可。已经在用 1.1 的朋友,直接升级核心包就好。
前情回顾:
欢迎关注我的微信公众号 .NET骚操作 ,或者加入 SimdPaddleOCR 微信交流群:
(如果微信群二维码过期,请加入 C# / .NET 计算机视觉交流 QQ 群:579060605)