系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:1-1《为什么有人要"手搓"推理引擎》 | 下一篇:1-3《自检门:没有模型的确定性测试》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 ARM 工具链原生与交叉编译选型:拆开 -mcpu、-march 与 dotprod、fp16 扩展的语义,区分板端原生与主机交叉路线,并给出
-march写窄(编译期报错或静默变慢)、写宽(运行期 SIGILL)、写错(configure 阶段就挂)三类后果的完整对照。关键词:手搓 Qwen 推理引擎、千问大模型推理、ARM 工具链、交叉编译、-march、-mcpu、dotprod、fp16、NEON、Qwen3-VL、零依赖纯 C、RK3588
导语:在 RK3588 上让 Qwen3-VL 这类千问大模型跑起来,第一道坎不是算法,而是工具链:原生编译与交叉编译怎么选、
-mcpu与-march各声明什么、+dotprod与+fp16两个扩展为什么缺一不可。ARM 工具链是《0.8MB 跑通 Qwen》整条零依赖纯 C 推理引擎链路能否被编译出来的地基,也是后面所有 NEON 量化内核(第 6 天)能否生效的前提。
上一篇文章让你抄下了 CMakeLists.txt 第 37 行:
set(CMAKE_C_FLAGS_RELEASE "-O2 -mcpu=cortex-a76 -march=${VLLM_MARCH} -ffast-math -ffp-contract=off -fno-unsafe-math-optimizations -fopenmp -D_GNU_SOURCE")
这一篇把它拆开,讲明白 ARM 交叉开发的三个概念:原生 vs 交叉编译、-march/-mcpu 是什么、dotprod 扩展为什么不能少。顺带带你复现一个编译报错现场——那个错误是引擎在保护你,不是在刁难你。
1. 知识点
1.1 原生编译 vs 交叉编译
| 原生编译 | 交叉编译 | |
|---|---|---|
| 在哪编 | 目标设备上(RK3588 板子本地) | 性能强的主机(x86)上,编出 aarch64 产物 |
| 工具链 | 板子的 gcc(aarch64-linux-gnu) | x86 主机装 gcc-aarch64-linux-gnu |
| 优点 | 环境最真、可直接跑 | 编译快几十倍,适合大工程 |
| 缺点 | 板子 CPU 弱,全量编译慢 | 环境差异偶尔坑你;仍要拷回板子验证 |
本仓库两种都支持:./build_rk3588.sh 是板端原生;./build_rk3588.sh --cross 走
cmake/toolchain-aarch64-rk3588.cmake。
工程教训:交叉编译产物永远要在真机重跑自检——这正是本仓库用 PASS/FAIL 自检门的原因(下一篇)。
1.2 -mcpu vs -march:写给编译器的"这台 CPU 有什么"
-mcpu=cortex-a76:按具体核优化(调度、流水线),RK3588 大核就是 Cortex-A76。注意:cortex-a76 这颗核出厂就带 dotprod + fp16,-mcpu是按"这颗核一定有什么"来声明的;但当-mcpu与-march同时出现时,决定扩展集的是-march(第 3 节实验 A 会看到这一点);-march=armv8.2-a+dotprod+fp16:按指令集版本 + 扩展声明能力。armv8.2-a:ARMv8.2 架构基线;+dotprod:打开 INT8 点积指令扩展——量化点积与手写 GEMM 全靠它(vdotq_s32/vdotq_laneq_s32,第 6 天细讲);+fp16:打开半精度浮点扩展。
你再看 CMakeLists.txt 第 17–27 行:架构名没有写死,允许覆盖:
cmake -B build-rk3588 -DVLLM_MARCH=armv8.2-a+dotprod+fp16
默认值在第 25–27 行定义(armv8.2-a+dotprod+fp16),VLLM_MARCH 是留给换板用的旋钮。注意措辞是"声明能力":你告诉编译器"大胆用 dotprod",编译器就真的会用——如果目标 CPU 没有这个扩展,跑起来会非法指令。所以 -march 永远要诚实。
1.3 单平台纪律:vllm_platform.h 的"一票否决"
include/common/vllm_platform.h 开头就写明定位,并且在非 aarch64 上直接编译报错:
#if defined(__aarch64__) || defined(_M_ARM64)
#define ST_ARCH_ARM64 1
...
#define ST_HAVE_NEON 1
#else
#error "vllm_kestrel targets aarch64 (RK3588) only"
#endif
这是"专注边缘设备、不再维护 x86/Windows 分支"的取舍:宁可编译期拒绝,也不在运行期崩。你后面会看到大量 NEON 代码,它们假定 ST_HAVE_NEON == 1——平台层在源头上保证了这份假设一定成立。
2. 对应代码:构建脚本全流程
build_rk3588.sh 做了四件事(第 64–79 行):
cmake -B build-rk3588 -DCMAKE_BUILD_TYPE=Release(默认原生 + Release);cmake --build ... -j$(nproc)并行编译;- 把
admin.html/chat.html拷进构建目录(服务端内嵌页,第 22 天细讲); - 把产物同步到仓库根
./vllm_kestrel(防陈旧二进制,1-1 讲过)。
顺带一提 -ffast-math:它允许编译器做"不严格符合 IEEE"的浮点优化,是性能档的一部分;代价是极端数值下结果可能与教科书不同——本引擎的确定性红线靠"参考对拍 + 固定编译参数"来兜底(1-3 与第 5 天展开)。
2.1 关键代码逐行:那串 Release 旗标
把第 37 行拆成表,每一段都对应一种"编译器授权":
set(CMAKE_C_FLAGS_RELEASE "-O2 -mcpu=cortex-a76 -march=${VLLM_MARCH} -ffast-math -ffp-contract=off -fno-unsafe-math-optimizations -fopenmp -D_GNU_SOURCE")
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "-s") # 非静态档:strip 符号表
| 旗标 | 含义 | 改掉/去掉的后果 |
|---|---|---|
-O2 |
优化档。CMakeLists 注释里记着一次实测:-O2 比 -Os 快约 5%(2306→2194ms),但体积差异在板端存储下无意义,故选速度 |
换 -O0 → 手写 asm GEMM 不受影响,但 norm/attention 等 C 代码明显变慢 |
-mcpu=cortex-a76 |
按 A76 的流水线/调度优化(RK3588 大核) | 换保守 cpu → 调度不贴合,吞吐小幅回落 |
-march=${VLLM_MARCH} |
指令集能力声明,默认 armv8.2-a+dotprod+fp16 |
去掉 +dotprod:__ARM_FEATURE_DOTPROD 不再定义,dotprod 调用点被宏整体关掉,量化路径静默回退标量。Release 档(-O2)把无人引用的静态内核裁掉 → 编译通过(实验 A);Debug 档(-O0)不裁、逐个展开 → 编译报错(实验 B) |
-ffast-math |
放开 IEEE 严格性(但紧跟其后的两条旗标把它收回大半) | 去掉 → 部分热区变慢;留着本身并不必然破坏确定性,因为下面两条封住了重排 |
-ffp-contract=off |
禁止浮点收缩:不许把 a*b+c 合并成一条 fma |
去掉 → 收缩与否随优化档变化,同一份源码在不同档下算出不同数值,位级一致当场断掉 |
-fno-unsafe-math-optimizations |
收回 -ffast-math 里"不安全"的那半(重排结合律、假设无 NaN/Inf 等) |
去掉 → 浮点重排重新放开,热区数值随优化档漂移 |
-fopenmp |
链接 OpenMP(libgomp 是 gcc 自带的) | 引擎核心并行走自研线程池(第 4 天),OpenMP 只在 NPU 直驱(vllm_npu_direct.c)里服务少量批量并行区 |
-D_GNU_SOURCE |
暴露 GNU/Linux 扩展接口(如 posix_memalign) |
去掉 → 平台头里的 posix_memalign 不可见,编译报错 |
-s(链接档) |
strip 符号表 | 去掉 → 产物多几百 KB(0.8MB 的"瘦"也来自这里) |
注意旗标的配对很关键,不能单看某一条:-ffast-math 后面紧跟着 -ffp-contract=off -fno-unsafe-math-optimizations,等于把刚放开的重排权限又收回大半,只留下数学函数近似与部分向量化的收益。所以"快"与"确定"在这里不是矛盾的——引擎用"固定编译参数 + 参考对拍 + 确定性自检"把不确定性锁在外面(第 1-3 篇与第 5 天会反复出现这条纪律)。
2.2 关键代码:平台头里还有哪些守护
除了你看到的 #error,vllm_platform.h 还集中了所有"跨平台本会散落各处"的东西——把这份文件通读一遍,你就知道"单平台专注"省了多少事:
#if defined(__aarch64__) || defined(_M_ARM64)
#define ST_ARCH_ARM64 1
#define ST_HAVE_NEON 1
#include <arm_neon.h>
#else
#error "vllm_kestrel targets aarch64 (RK3588) only"
#endif
#define ST_PREFETCH(p) __builtin_prefetch((p), 0, 3) /* temporal prefetch */
ST_INLINE void *st_aligned_alloc(size_t size, size_t align) {
void *p = NULL;
if (align < sizeof(void *)) align = sizeof(void *);
if (posix_memalign(&p, align, size ? size : 1) != 0) return NULL;
return p;
}
ST_INLINE double st_now_sec(void) {
... clock_gettime(CLOCK_MONOTONIC, &ts); ... }
解读三个设计意图:
ST_ARCH_X86 / ST_HAVE_AVX512_VNNI保留为 0(头注释:便于逐文件迁移后删除)——团队承认 x86 分支曾是历史,现在不维护,但用宏"软删除"而不是物理删光,方便迁移期对照。读到这类"历史遗迹"注释时,别当成代码坏味道,它是工程演进的脚印。posix_memalign+st_aligned_free:NEON 的vld1q/vst1q对 16B 对齐有要求,跨行访问还涉及 cache line;统一走 64B 对齐分配(第 6 天讲 GEMM 时你会看到为什么对齐是性能下限)。st_now_sec用CLOCK_MONOTONIC:单调时钟不受校时/NTP 跳变影响,测时间才可信。头注释写明纪律:"计时仅用于报告(reporting only),不进入计算路径"——即时间戳永不影响数值结果,这是位级确定性的另一条保障。
3. 改动后果:把 -march 写错会怎样
先别急着复现。下面的实验 A/B/C 针对的是第一类写错(能力声明写窄了),因为这一类的结果最反直觉;第二类写错(声明写宽了)放在实验 D,它是唯一会让你在运行期炸掉的一类。先看总览:
3.0 把 -march 写错,只有三类
| 类别 | 例子 | 编译器行为 | 后果 |
|---|---|---|---|
| 一、写窄了(声明少于 CPU 实际能力) | -march=armv8.2-a(去掉 +dotprod) |
部分拦截 | Release:编译通过但静默变慢;Debug:编译报错(实验 A/B) |
| 二、写宽了(声明多于 CPU 实际能力) | 给 A73/A53 声明 +dotprod |
完全放行 | 编译通过,运行期 SIGILL 非法指令(实验 D) |
| 三、语法/拼写错 | +dotpod;写成 armv8.2-a,dotprod(逗号);漏掉 -a |
立刻报错 | 最便宜,configure 阶段就挂 |
一句话记住:写窄了顶多慢,写错了立刻挂,写宽了会在几小时后于板子上炸——所以 -march 永远要诚实,而且绝不要在一台你不拥有的 CPU 上凭空声明扩展。
第一类为什么最反直觉?因为去掉 +dotprod 后到底报不报错,不取决于 -march 本身,而取决于优化档——-O2 会把残留的静态内核裁掉,于是静默通过;-O0 不会,于是报错。这是我们在板端(RK3588 / gcc 11.4 / 2026-09)逐条实测过的结论,你完全可以亲手重演。
实验 A(你以为会翻,其实没翻):Release 档
# 故意把 +dotprod 去掉,只留 armv8.2-a
cmake -B build-broken -DVLLM_MARCH=armv8.2-a -DCMAKE_BUILD_TYPE=Release
cmake --build build-broken -j8
板端实测结果:编译通过,0 error,日志末尾是 [100%] Built target vllm_kestrel。翻车没有发生。
为什么?真正的原因只有一个,而且全在预处理阶段:去掉 +dotprod 后 __ARM_FEATURE_DOTPROD 并未定义(我们实测确认),引擎里 dotprod 内核的调用点全部包在 #if defined(__ARM_FEATURE_DOTPROD) 里(见 vllm_safetensors.c 的 ST_NEON_DOTPROD)——宏一关,这些调用点整体消失,只剩下"没被调用"的静态函数在 -O2 下又被整体裁掉,于是连报错的机会都没有。
换句话说:Release 档去掉
+dotprod不会翻车,代价是 dotprod 内核被静默关闭,量化路径悄悄回退到标量(widening-mla)版本——编译过了、程序能跑,但性能打了折。这正是 1.2 里说"能力声明要诚实"的另一个理由:不诚实不一定报错,也可能只是静默变慢。
实验 B(真翻车现场):Debug 档
Debug 档没有死代码消除(-O0),残留的静态内核会被逐个展开;同样把能力声明收窄到 armv8.2-a,编译期拦截就出现了:
rm -rf build-broken
cmake -B build-broken -DVLLM_MARCH=armv8.2-a -DCMAKE_BUILD_TYPE=Debug
cmake --build build-broken -j8
板端实测(同机同源,2026-09)真实报错(节选):
/usr/lib/gcc/aarch64-linux-gnu/11/include/arm_neon.h:32193:1:
error: inlining failed in call to 'always_inline' 'vdotq_laneq_s32':
target specific option mismatch
(共 16 处同类报错,均指向 src/model/vllm_safetensors.c 的量化点积内核,如 q8x4_dot1_group、llama_gemm_q8_0_4x4_q8_0_neon。arm_neon.h 的路径与行号会随 gcc 版本不同,认准关键字 target specific option mismatch。)
注意报错形态不是 not declared:arm_neon.h 总是声明这些 dot 内联函数,但当编译目标不含 dotprod 时,这些 always_inline 函数无法被展开,编译器在调用点拒绝内联——这才是 mismatch 的来历。为什么 -O0 下会炸而 -O2 不会?因为 -O2 会先裁掉无人引用的静态函数("死代码消除"),而 -O0 会逐个展开函数体、一个都跑不掉。
这个报错是好事:它把"架构能力与代码需求不匹配"拦在了编译期,而不是让你把二进制拷到板子上跑出 SIGILL。这就叫把错误前移——工程里最便宜的 bug 是编译期就能抓到的 bug。查"能力声明"类问题时,别忘了:Release 档会替你挡雷(代价是静默回退),Debug 档才会把雷亮给你看。
实验 C(第二道闸门):在 x86 主机上裸跑 cmake
在 x86 主机上直接 cmake(不加 toolchain、也不给 -DVLLM_MARCH),会先撞上 CMakeLists.txt 第 19–23 行的配置期守卫,configure 阶段直接报错:
CMake Error: vllm_kestrel targets aarch64 (RK3588) only;
found CMAKE_SYSTEM_PROCESSOR=x86_64
而 vllm_platform.h 里的 #error 是第二道闸门:只有绕过第一道(例如显式 -DVLLM_MARCH=... 强行放行配置)或直接手动编源文件时才轮到它出场。两层守卫同一句话:单平台,编译期拒绝,不给运行期机会。
实验 D(唯一在运行期炸的一类:写宽了)
前面三个实验都是"写窄了"或"写错了",全部在编译期就有反馈。写宽了没有。 原因就在 vllm_safetensors.c 的 NEON 段开头:
#if defined(__ARM_FEATURE_DOTPROD)
#define ST_NEON_DOTPROD 1
#else
#define ST_NEON_DOTPROD 0
#endif
编译器只回答"你声明的目标里有没有这个扩展",从不回答"这台机器上有没有"。所以只要你写上 +dotprod,__ARM_FEATURE_DOTPROD 就被定义,ST_NEON_DOTPROD 就是 1,vdotq_s32 就会被真的生成出来。如果目标核没有这条指令,程序在第一次执行到那个内核时收到 SIGILL。
它的恶劣之处是延迟暴露:编译 0 error,权重加载正常,短对话甚至可能正常——直到某次请求走进那条量化点积内核才崩。而且同一份二进制在 RK3588 上一切正常,你极易把它判成偶发。
诚实标注:这一类我们没有在 RK3588 上实测——RK3588 的 A76/A55 本就带 dotprod,写宽了在这台机器上不会翻车,测不出来。它针对的是"换板"场景(例如换到没有 dotprod 的 ARMv8.0 核)。
不需要一台没有 dotprod 的机器,也能自己验证这一点:
# 1) 看反汇编:二进制里是不是真的含 SDOT 指令(板端把前缀去掉,直接用 objdump)
aarch64-linux-gnu-objdump -d build-rk3588/vllm_kestrel | grep -c sdot
# 2) 用 QEMU 模拟一颗没有 dotprod 的核(cortex-a53 即 ARMv8.0)
qemu-aarch64 -cpu cortex-a53 ./build-rk3588/vllm_kestrel --stream-test --model <权重目录>
第一条非 0,就说明"声明"已经变成了真实的指令;第二条预期在首次进入量化 GEMM 时报非法指令。
工程结论:-march 的诚实性,编译通过是证明不了的。编译期只检查"声明与代码是否自洽",从不检查"声明与机器是否一致"。所以换板时的第一件事,就是把 VLLM_MARCH 收回到目标核真正具备的扩展集——宁可写窄(慢一点,且编译期或性能上会给你提示),绝不要写宽。
4. 学员调试任务
- A 档:板端跑通
./build_rk3588.sh,记录输出末尾arch : ...那一行与产物大小;再做第 3 节实验 B(Debug 档缺 dotprod)并截图报错;顺手把实验 A 也跑一遍,记录"Release 档居然编译通过"这个反直觉结果。最后rm -rf build-broken清理。 - B 档:读 vllm_platform.h 全文,列出它定义/守护了哪些宏(架构、NEON、对齐、栈……),并解释
#error分支为什么是"单平台纪律"的体现。 - C 档(换板预演):做实验 D 的两条验证——用
objdump | grep -c sdot确认二进制里确实含 SDOT 指令,再用 QEMU 模拟cortex-a53跑一次,观察首次进入量化 GEMM 时的非法指令。然后回答:如果要把引擎移植到一颗 ARMv8.0 的核上,VLLM_MARCH该怎么写、为什么。 - 可选加分:做一次全静态构建,用
ldd看"零 .so 依赖"。注意-static只挂在 Release 链接档上,直接跑构建脚本最省心:./build_rk3588.sh --static # 产物在 build-rk3588-static/vllm_kestrel ldd build-rk3588-static/vllm_kestrel # 预期:not a dynamic executable
预期输出:你能用自己的话解释——原生/交叉的取舍、-march 为什么必须诚实、编译期报错为什么是好设计。
收尾
本篇源码点名:CMakeLists.txt(编译旗标与 VLLM_MARCH)、vllm_platform.h(平台一票否决)、build_rk3588.sh(构建+根同步)。
仓库:https://gitee.com/pei-xiaoguang/kestrel-llm (AGPL-3.0-or-later 或商业许可,二选一)
下篇预告:编译过了,怎么知道引擎算得对?下一篇 1-3 介绍本仓库的"自检门"——没有模型也能跑的确定性测试,以及"参考对拍"这种保障位级正确性的工程方法。
更新说明
2026-10-02 修订:初版有两处错误,已修正;并补上 -march 写错的完整分类。
修正 1(第 2 节):初版引用的 CMakeLists.txt 第 37 行漏引了两个旗标。实际是:
set(CMAKE_C_FLAGS_RELEASE "-O2 -mcpu=cortex-a76 -march=${VLLM_MARCH} -ffast-math -ffp-contract=off -fno-unsafe-math-optimizations -fopenmp -D_GNU_SOURCE")
漏掉的 -ffp-contract=off、-fno-unsafe-math-optimizations 不是可有可无:前者禁止浮点收缩(不许把 a*b+c 合并成 fma),后者收回 -ffast-math 里的重排权限。本引擎的位级一致靠的是这两条,而不是"保留 -ffast-math"——初版把这层因果说反了。按初版那行去构建,产物与仓库并不一致。
修正 2(第 3 节):初版把"Release 档编译通过、Debug 档报错"的差异,归因于 -mcpu=cortex-a76 的"兜底"效应。这个归因不成立,而且与文中实测到的"去掉 +dotprod 后 __ARM_FEATURE_DOTPROD 并未定义"自相矛盾——若 -mcpu 真提供了该能力,Debug 档也不该报错。
真实原因只有一个:宏关闭后 dotprod 调用点整体消失,只剩未被引用的静态内核,-O2 把它们裁掉(Release 通过),-O0 逐个展开(Debug 报错)。差别在优化档,不在 -mcpu。已改为单一解释。
新增(第 3 节):§3.0 把 -march 写错分成三类——写窄(编译期或静默变慢)、写宽(运行期 SIGILL)、写错语法(configure 阶段即挂);实验 D 专门覆盖"写宽了",即声明了目标核并不具备的扩展。该实验未在 RK3588 上实测(该板本就有 dotprod,写宽不会在此翻车),文中已如实标注,并给出两条无需换机即可自查的路径。§4 相应增加 C 档调试任务。