资讯动态

30天手搓ARM架构零依赖纯C推理引擎:从张量到NEON优化

发布时间:2026/10/27 20:00:34 来源:尧图企业网站定制
如果让你用 30 天在 ARM 架构上从零手写一个纯 C 推理引擎还不能依赖任何第三方库你愿意尝试吗这不是一个课堂练习而是我最近在打磨的课程《30 天手搓 ARM 架构零依赖纯 C 推理引擎》要带你完成的交付物。这里的“推理引擎”指的是把已训练好的模型权重读进来在前向计算里跑出预测结果的那套运行时不涉及反向传播和训练逻辑。它比训练简单但正因为去掉了自动求导你才有机会把每一层、每个算子、每段内存都彻底看穿。这篇不是招生软文而是把我排课的基本逻辑、30 天大纲、容易劝退的坑以及每天的验收标准摊开给你看。适合那些想在 ARM Linux 设备、树莓派、RK 开发板或 QEMU 虚拟环境里做轻量推理的人也适合已经会用 C 指针和结构体但从来没从零写过算子库的开发者。1. 先别急着写算子为什么“零依赖纯 C 推理引擎”值得手搓1.1 “零依赖”到底指什么课程里说的零依赖是运行时零第三方依赖。标准 C 库和操作系统 API 这一层不算毕竟任何程序都绕不开。真正的要求是不装 TensorFlow不装 PyTorch不用 OpenCV不用 CMSIS-DSP不用 FlatBuffers、Protobuf 这类序列化库。模型格式自己定义算子自己实现内存自己管理启动加载自己写。很多人听到“零依赖”第一反应是“这有什么难不就是不用库吗”。但当你真正开始做就会发现难的不是“不用库”而是“不用库之后你要为每一个基础能力重新做决策”。比如一个简单的MatMul用库一行调用自己写就要考虑数据是行主序还是列主序、要不要拆分成 4x4 微内核、输出张量谁来释放、边界不足时怎么处理。零依赖的价值在于它逼你把每一个抽象层都暴露出来。你写出来的东西没有“黑盒”每一行代码都是你能解释的。部署时也很省心把编译出来的可执行文件拷到目标 ARM 设备上能跑就是能跑不需要处理一堆动态库依赖。1.2 为什么偏偏是 ARM 加 C 的组合ARM 是全球嵌入式、移动端、边缘设备里最常见的架构。你在树莓派上跑在 RK3588、RK3399、全志芯片上跑在海思、瑞萨、ST 的高端 MPU 上跑底层指令集大概率都是 ARM 或 ARM64。学会一套面向 ARM 的推理引擎等于给后面所有边缘设备项目打了底。选择 C 而不是 C 或 Rust不是因为 C 最现代而是因为 C 是这个场景下最透明的语言。推理引擎的性能瓶颈通常在内存布局、缓存友好度、SIMD 向量化这几件事上C 能让你直接控制结构体布局、指针访问和对齐方式。ARM 上成熟的 NEON 加速也以 C intrinsics 为主你不需要引入重抽象就能写出接近手写汇编的性能。当然C 也意味着你必须自己承担内存管理的成本。这恰恰是课程的训练目标先用裸露的方式踩一遍内存分配、释放、越界、对齐的坑再考虑更高层的设计。1.3 适合什么人需要哪些前置知识我排大纲时默认你是个“至少写过几百行 C”的人。结构体、指针、malloc/free、函数指针这些基础必须熟练不需要会 C也不需要会汇编。推理引擎涉及到的卷积、池化、全连接、Softmax我默认你见过概念但不默认你能默写公式。硬件方面有 ARM Linux 开发板最好没有也可以用 QEMU 在 x86 电脑上模拟 AArch64 运行环境。课程前 7 天会花时间把交叉编译和 QEMU 调试链路打通这部分既是开发准备也是整门课最容易卡住的地方我会给一套可直接复制的 Makefile 和命令避免你把精力耗在“为什么编译出来的文件在板子上跑不了”这种环境问题上。所以前置条件就三条C 语言基础够用、认识基本深度学习层结构、愿意每天留出 1 到 2 小时连续写代码。如果你满足这三条这门课的 30 天规划是可行的。2. 30 天路线图四阶段把推理引擎拆到能上手2.1 先拆解最小推理引擎需要哪些零件排大纲前我先列了一个“最小推理引擎必须包含什么”的清单。它不是训练框架那种全量组件而是只做前向推断的最小闭环张量形状、数据类型、数据指针、stride 步长所有算子的输入输出都围绕它转。算子至少包括 Conv2D、MatMul、Add、ReLU、Pooling、Softmax、Reshape、Concat 这些常见前向算子。模型加载定义一种自己的二进制模型格式能从磁盘读出权重和网络结构。内存规划权重加载到一块连续内存激活张量分配到一块 arena 缓冲池推理期间尽量不反复 malloc/free。执行调度按图的拓扑顺序把算子串起来跑当前阶段先做静态图顺序执行。后端优化在纯 C 朴素版本跑通后再用 ARM NEON intrinsics 重写热点算子。这套清单决定了课程节奏。第 1 周解决环境和张量第 2 周攻坚算子第 3 周打通模型和调度第 4 周做 NEON 优化和真机验证。2.2 第 17 天交叉编译、QEMU 与最小可调试骨架第 1 天到第 3 天所有任务都围绕一件事让一行 C 代码在一个 aarch64 环境里顺利编译、链接、运行并正确打印退出码。我建议直接装gcc-aarch64-linux-gnu和qemu-user用 Makefile 管理不要一上来就折腾 IDE。Windows 上很多交叉编译 SDK 的路径问题会吃掉大量精力所以我会给一份 Linux/WSL 下可跑的默认配置。每天验收很简单今天写的代码能编译能用qemu-aarch64运行能通过 GDB 单步能看到日志输出。第一天也不要贪多写一个打印 tensor 元信息的hello_tensor比直接写卷积重要得多。第 4 天到第 7 天进入张量与日志模块。定义基础数据结构typedef struct { float *data; int32_t ndim; int32_t dims[8]; int32_t strides[8]; int32_t size; int32_t dtype; int32_t owns_data; } Tensor;每个字段都必须是显式的。我会让你先把“根据 dims 计算 strides”和“根据 strides 计算某个索引的偏移”这两个功能写出来因为后面 90% 的 bug 都出在这。同时做一个日志系统和断言宏。日志分 debug/info/error 三级断言用于检查张量 shape 是否匹配。这样才能在写算子前就建立“问题可诊断”的开发环境。2.3 第 816 天从朴素算子到可用算子库第 8 天开始写算子。我的强烈建议是不要第一步就写 NEON先把朴素 C 版本跑通再做数学验证最后才谈优化。如果你一上来就对着卷积优化技巧死磕会在第 10 天就因为太枯燥而放弃。算子顺序按依赖关系排先写 ReLU 和 Add这两个最简单可以帮你熟练“读输入、写输出、检查边界”的基本范式。然后写 MatMul这是后面所有线性层的核心。MatMul 朴素实现只要三重循环但不允许直接三重循环跑完就完事必须做单测。第 12 天左右进入 Conv2D。我先不讲 im2col先写一个最直观的直接卷积版本遍历 batch、遍历输出通道、遍历输出位置、遍历输入通道和卷积核窗口。跑通之后再引入 im2col 把卷积转成矩阵乘法最后对照两版结果是否一致。这个过程看着绕却是理解卷积优化最有效的方式。第 14 天到第 16 天补齐 Pooling、Softmax、Reshape、Concat。Softmax 要处理数值稳定性不能直接 exp 后再算先减最大值再 exp。这一个小点很多新手会忽略导致模型输出全是 NaN。到第 16 天结束你应该有一个不依赖任何第三方库的算子集合并且每个算子都有一组固定输入输出的单测用例。测试期望值怎么来我允许你用 Python 在开发期算参考值但运行时完全和 Python 无关。这个区分必须提前讲清楚否则你会误解“零依赖”。2.4 第 1723 天模型格式、图加载与内存规划第 17 天开始做模型加载。我强烈不建议在这个阶段自己写 JSON 解析器那是另一个大坑。课程模型格式是二进制前面放固定字段字段类型说明magicuint32固定魔数用于识别文件类型versionuint32模型格式版本layer_countuint32算子节点数量weight_offsetuint32权重数据在文件中的偏移weight_sizeuint32权重数据总字节数input_shape8 x int32输入张量形状output_shape8 x int32输出张量形状魔数后面是连续的算子记录每条记录包含算子类型、输入张量 id、输出张量 id、权重偏移、权重大小、步长、pad 、激活参数等字段。这个格式不像 JSON 易读但解析代码非常短而且是零依赖的。第 19 天到第 21 天做图执行和内存规划。课程当前版本采用静态图顺序执行也就是按照算子记录顺序逐个跑。中间张量不是每个都 malloc而是在初始化时直接规划好每块激活张量在 arena 缓冲里的偏移推理开始时用session_create分配一块大内存执行完后session_reset直接复用。这才是一个推理引擎“能不能上嵌入式设备”的分水岭频繁 alloc/free 的引擎在长期运行后必然遇到碎片问题。第 23 天验收产物是一个跑通的完整模型。我会给一个很小的图像分类模型比方说 1 个卷积、1 个池化、2 个全连接和 Softmax输入是 28x28 灰度图输出是 10 类概率。这一天你会第一次看到“权重文件 纯 C 引擎 ARM/Linux”跑出结果。那种感觉和调库跑通完全不一样。2.5 第 2430 天NEON 优化、量化与实机验证第 24 天进入 NEON。AArch64 下最常用的是vld1q_f32、vmlaq_f32、vst1q_f32这类 intrinsics。课程不会一上来就搞完整卷积内核而是先从MatMul的 4x4 微内核开始一次计算输出 4 行 4 列的一部分用vld1q连续加载 4 个 float用vfmaq做乘累加。NEON 优化的关键反直觉点是很多时候瓶颈不是计算而是数据搬运。缓存命中率、内存访问连续性和数据对齐比单纯减少指令数更重要。所以第 25 到第 27 天我会让你做 blocking 切分也就是把大矩阵切成适合 L1 cache 的小块再在块内做 NEON 计算。不做 blocking 直接套 intrinsics性能提升会非常有限。第 28 天可以选做 int8 量化。不是做完整的量化训练而是在“已经训好的模型”上做简单的 scale zero_point 后训练量化模拟。int8 在 ARM 上能用到一些定点乘法指令吞吐量明显提升。这部分作为进阶项不要求必须完成但做过的同学通常会更理解为什么产业界推理引擎都往 int8 追。第 29 到第 30 天是性能评测和实机验证。我会给一套基准测试方法用例前跑几轮 warmup然后循环推理多次取中位数用clock_gettime记录耗时分别测朴素 C 版和 NEON 版的单次推理时间。有条件上开发板的同学直接在板子上跑没有板的同学继续用qemu-aarch64虽然性能绝对值不具备参考性但相对优化效果的对比仍然是有效的。3. 手搓过程中最劝退的三个坑编译、内存、调试3.1 交叉编译工具链选错第一晚就全军覆没ARM 这个词在程序员日常里往往指两个完全不同的方向一个是裸机单片机生态一个是运行 Linux 的嵌入式应用生态。很多同学第一次装交叉编译器就把工具链选错了。arm-none-eabi-gcc是给 Cortex-M、Cortex-R 这类裸机目标用的没有 Linux sysroot链接时找不到crt1.o。而我们要跑 Linux 应用要么用arm-linux-gnueabihf-gcc编 32 位 ARM要么用aarch64-linux-gnu-gcc编 64 位 ARM。选错工具链的第一反应往往是“报错我看不懂”其实根因只是目标环境不一样。还有一部分同学之前接触过 Keil MDK看到sarmcm3.dll not found这类 Windows 软件错误提示后误以为 ARM Linux 也要处理 DLL。完全没有关系Keil 的 DLL 只是 MDK 自己 IDE 插件我们生成的产物是 ELF 可执行文件不是 axf也不是 DLL。在 Linux 端你要检查的是file、readelf、ldd的输出file demo readelf -A demo | grep Tag_ABI_VFP_args如果file显示 “ELF 64-bit LSB executable, ARM aarch64”说明交叉编译产物结构正确。如果显示 x86就该回去看 Makefile 里的编译器变量是不是写错了。这类问题 80% 是环境变量或 Makefile 问题不是你的引擎代码 bug。3.2 张量内存布局strides 比 dims 更容易骗人很多人在写张量库时会下意识认为“shape 对了就能算”结果第一个算子就崩。真正决定内存地址的是 strides不是 dims。比如一个[1, 3, 224, 224]的张量如果采用行主序并正确计算 strides访问data[i0*s0 i1*s1 i2*s2 i3*s3]才是安全的。如果你在 ReLU 里直接拿data[i]不用 strides 踩刚好模型又没有转置和广播可能暂时没问题。可一旦进入 Reshape 或 Concat形状和 strides 对不上数据就全乱了。所以课程第 6 天开始就要求你写一个tensor_offset(t, idx, dims)函数并让所有算子统一调用它而不是自己循环。这里要特别提一下布局选择。推理引擎内部我会默认行主序卷积算子可以先按 NCHW 处理后面再讨论是否改成 NHWC 来提升缓存友好度。NEON 优化阶段再讲“通道维和像素维谁放前面”的取舍你会有更直观的感受。这个坑一旦踩过你会对网络上所有“我在 ARM 上提速 3 倍”的帖子有更强的判断力。调试版要开断言。我总是建议在算子和张量操作入口放这样的检查#define TENSOR_ASSERT(cond) \ do { \ if (!(cond)) { \ error_log(assert failed: %s at %s:%d, \ #cond, __FILE__, __LINE__); \ abort(); \ } \ } while (0)不要在 debug 阶段用-O2否则你会在错误地址上绕很久才发现是优化问题。先用-O0 -g把逻辑跑对再用 release 模式验证性能。3.3 调试思路别用 printf 硬猜把 GDB 和 QEMU 用起来纯 C 推理引擎最难受的问题不是算法不会写而是内存被写坏以后运行结果莫名其妙变成 NaNprintf 打印出来的值已经烂了你根本不知道它是什么时候烂的。我的经验是第一个算子和第一个模型跑通阶段必须把断点调试轮子搭好。QEMU 在调试 AArch64 程序时很好用。启动时加-g参数让 QEMU 监听一个端口再用支持 AArch64 的 GDB 连接qemu-aarch64 -L /usr/aarch64-linux-gnu -g 1234 ./demo gdb-multiarch ./demo在 GDB 里打断点、看内存、看寄存器尤其是看 NEON 寄存器的值比盲猜打印高效太多。如果你在 x86 上开发还有一个技巧同一份纯 C 引擎可以编译成 x86 本机版本用-fsanitizeaddress跑单测所有越界写和 use-after-free 都会被直接揪出来。注意这只是开发期验证运行时引擎仍然是零依赖的。还有一个数值问题浮点对不上。两个同样的卷积逻辑一个跑在 x86 上一个跑在 AArch64 上因为浮点中间精度和 FMA 融合方式不同结果可能有几百万分之一的误差。应对方法是单测里用相对误差而不是绝对相等if (fabs(actual - expected) 1e-5f) { error_log(mismatch at index %d, i); }如果你的模型输出层是 Softmax很小的浮点误差会放大到概率分布不同但只要 Top-1 分类一致通常可以容忍。这是所有推理引擎都会遇到的现象不是你的代码一定写错了。4. 课程工程结构、验收标准与后续扩展路线4.1 课程工程目录设计30 天不是靠口嗨堆出来的我准备了一套固定工程模板。课程仓库里的目录大致如下infer/ ├── Makefile ├── README.md ├── src/ │ ├── core/ │ │ ├── tensor.c │ │ ├── tensor.h │ │ ├── memory.c │ │ └── log.c │ ├── ops/ │ │ ├── add.c │ │ ├── relu.c │ │ ├── matmul.c │ │ ├── conv2d.c │ │ ├── pool.c │ │ └── softmax.c │ ├── model/ │ │ ├── parser.c │ │ └── graph.c │ ├── runtime/ │ │ ├── session.c │ │ └── executor.c │ └── backend/ │ └── neon/ │ ├── matmul_neon.c │ └── conv_neon.c ├── tests/ │ ├── unit/ │ │ ├── test_tensor.c │ │ ├── test_matmul.c │ │ └── test_conv.c │ └── bench/ │ └── bench_infer.c └── tools/ └── export_model.pytools/export_model.py只是开发期用来把训练出来的权重转成自定义二进制格式的辅助脚本它不是运行时的一部分。推理端拿到模型文件后完全不依赖 Python 或任何机器学习框架。这一点必须在第一天就明确否则有人会误以为“学完还要装 Python 才叫零依赖”。4.2 每天的验收标准不是“跑通”而是“可证明地跑对”我会在每一周给出明确验收产物而不是让你模糊地感觉“差不多懂了”。阶段天数交付物验收标准环境与张量1-7交叉编译工具链、QEMU 运行示例、Tensor 与日志模块hello 程序在 aarch64 环境退出码为 0Tensor 单测通过算子库8-16ReLU、Add、MatMul、Conv2D、Pooling、Softmax 的朴素实现每个算子在固定随机输入下与参考结果相对误差小于 1e-5模型执行17-23自定义模型格式、解析器、顺序执行调度器、小模型推理能加载模型文件并输出指定类别的预测结果优化与评测24-30NEON 内核、量化可选、基准测试报告NEON 版相比朴素版在相同精度前提下推理耗时下降达到预期我不会让你“凭感觉做完了”。每个测试都要能通过make test一键执行每个基准数据都要能复现。写推理引擎最忌“看不见自己的进度”所以测试框架和基准脚本我会在第 5 天左右就放进工程里后面每一天都在已有框架上继续长代码。4.3 课程之外的延展方向30 天做完之后你可以继续走几条线。最容易想到的是算子融合比如把 Conv 后面的 BatchNorm 和 ReLU 融合进卷积循环减少内存读写。然后是 int8 量化把 float32 的权重和激活压缩到 int8这里会涉及 scale、zero_point 和量化误差分析是工业部署里非常实用的一环。再进一步可以做多线程推理ARM 上通常有多核用 pthread 把不同输出通道分给不同线程可以继续压榨延迟。但要注意多线程的正确性验证比单线程复杂需要先保证单线程版本完全可靠再上锁和并发否则你会被数据竞争折腾到怀疑人生。如果你对架构迁移感兴趣可以尝试把这套纯 C 引擎移植到 RISC-V 或 x86 上。只要张量接口和算子注册表设计得干净换架构时只需要替换 backend 层这也能反过来检验你的代码是否真的“零依赖”。从个人经验讲这一步价值极高因为你会突然意识到原来算子核心和硬件后端是可以完全解耦的。最后还有一个容易被忽略的点不要跳过前 7 天。很多人觉得自己有 C 基础直接跳到算子部分结果到了第 10 天还在解决交叉编译器路径问题。先用 QEMU 把开发调试链路跑顺后面所有写算子的日子才不会被环境问题打断。30 天手搓推理引擎难点从来不只在推理更在“在没有依赖的情况下让整个系统从头到尾都有把握”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑