8.7 KiB
8.7 KiB
步骤 4:ARM 汇编优化
目标:把已经用 C++ 验证过的热点 kernel,迁移为 NEON/SDOT/I8MM/SME2 汇编实现。
前置条件:步骤 0-3 已完成,已经知道热点路径、输入 layout、pack mode、正确性 oracle 和性能 baseline。
原则:不要直接写汇编。先标量、再 C++ SIMD 模拟、再寄存器计划、最后写
.S。
4.0 进入汇编前的门禁
只有同时满足下面条件,才进入 asm:
- 已有 CoreFunctions、Vec4 或 C++ 优化无法覆盖该热点。
- 已有可运行的 C++ 标量 oracle,并能和当前正确实现对齐。
- 已有目标 shape/ISA 的 speed baseline。
- 已明确 pack layout、cell stride、metadata 格式、tail 规则、postprocess 规则。
- 已知道本次要优化的是 E=1 decode、E>1 prefill、block32、block64、per-channel 还是 tail。
如果这些条件不满足,先回步骤 0-3。
4.1 四阶段实现流程
阶段 A:C++ 标量 oracle
写一个最直接、最容易读懂的参考实现:
- 权重读取必须按真实 pack layout 和真实 cell stride,不要用理想化公式代替。
- w2/w3 要显式解 bit,不要隐藏在复杂宏里。
- metadata 读取、scale、zero point、bias、min/max、add-dst 要拆开,方便定位差异。
- 对低 bit GEMV,优先用小 case 对齐:单 block、单 OC group、单 K tile、block64。
对比建议:
| 对比点 | 目的 |
|---|---|
| unpack 后的 int 值 | 确认 bit layout |
| dot accumulator | 确认 sdot/smmla 数学分组 |
| dequant 后 FP32 | 确认 scale/zp |
| postprocess 后 dst | 确认 bias/minmax/add-dst |
阶段 B:C++ SIMD/寄存器模拟
在 C++ 中模拟目标指令和寄存器,而不是直接写 .S:
- 用
std::array<int8_t, 16>、std::array<int32_t, 4>或局部数组模拟 NEON lanes。 - 写小 helper 模拟
sdot、smmla、smlal、zip/uzp/ext/tbl等关键行为。 - 用变量名模拟 asm 寄存器,例如
vAcc0、vW0、vAux、vMin、vMax。 - 每次改变 unroll、pack 顺序或 unpack 方法,都先让模拟版通过 oracle。
低 bit 示例要点:
// 伪代码:表达意图,不要求直接复制
std::array<int8_t, 16> vW0 = unpackW2Plane0(bytes);
std::array<int8_t, 16> vW1 = unpackW2Plane1(bytes);
std::array<int32_t, 4> vAcc = sdot4(vAcc, vInput, combine(vW0, vW1));
这一步的价值是把“数学/pack 错”和“汇编寄存器/ABI 错”分开。
阶段 C:寄存器生命周期表
写 asm 前必须先列寄存器表。建议直接放在 .S 对应 macro 附近的注释中。
| 寄存器 | 用途 | live 范围 | 可复用条件 | 风险 |
|---|---|---|---|---|
x0-x7 |
ABI 参数 | 函数入口到重映射 | 可复制到 temp | 参数被覆盖后难恢复 |
x8-x18 |
临时指针/loop | 当前 loop | loop 结束后 | tail 分支跳转 |
x19-x28 |
长生命周期指针 | 整个函数 | 保存/恢复后 | callee-saved |
v0-v7 |
input/unpack tmp | 当前 K step | dot 后 | 被 postprocess 误用 |
v8-v15 |
长生命周期值 | 整个函数 | 保存低 64 位 | callee-saved |
v16-v25 |
accumulator | compute 到 store | store 后 | unroll 增大压力 |
v26-v31 |
scale/min/max/constants/tmp | postprocess 前后 | 明确分支后 | 最容易被 clobber |
必须显式回答:
- min/max 是什么时候加载的?是否可能被 unpack 覆盖?
- scale/zp/bias 是否跨 K loop 或 tile loop 存活?
- accumulator 和 unpack tmp 是否在所有 tile/tail 分支都不冲突?
- 如果 hoist 常量,所有 postprocess 路径是否仍然能拿到正确值?
- 用了哪些 callee-saved 寄存器?是否保存和恢复?
阶段 D:最小 asm 实现
- 一次只迁移一个 tile 或一个 ISA 路径。
- 先保持和 C++ SIMD 模拟完全同构,再做指令级精简。
- 优先让 block64/default case 正确,保留 block32/per-channel/fallback 的旧安全路径。
- 每加一个 unroll 或新分支,立即跑小测试和目标 op test。
4.2 AArch64 ABI 和文件规范
文件位置
source/backend/cpu/arm/arm64/MNNXxxKernel.S
source/backend/cpu/arm/arm64/MNNXxxKernelFP16.S
source/backend/cpu/arm/arm64/MNNXxxKernel_int8.S
source/backend/cpu/arm/arm64/sme2_asm/MNNXxxKernel_SME2.S
基本规则
- 使用现有
MNNAsmGlobal.h和asm_function风格。 x0-x7是参数,x8-x18caller-saved,x19-x28callee-saved。v0-v7caller-saved,v8-v15的低 64 位 callee-saved,v16-v31caller-saved。- 需要
.arch armv8.2-a+dotprod、.arch armv8.6-a+i8mm或 SME2 特性时,跟随仓库已有 asm 文件写法。 - 不要为了省几条指令破坏可读性。低 bit kernel 的 layout 注释和寄存器注释比普通 kernel 更重要。
最小模板
#ifdef __aarch64__
#include "MNNAsmGlobal.h"
.text
.align 5
// void MNNXxxKernel(...)
asm_function MNNXxxKernel
// Register plan:
// x0: dst
// x1: src / packed weight
// x2: params
// v16-v19: accumulators
// v30-v31: fp32 min/max, only live in postprocess
L_loop:
// load
// unpack / compute
// postprocess
// store
ret
#endif
4.3 SDOT/I8MM/低 bit 特别检查
w2/w3 pack 和 stride
- OC 分线程的
weightPtr偏移必须使用真实 packed cell 字节数。 - block32、block64、per-channel 的 metadata 步进要分别确认。
- 如果每个 cell 有 padding,kernel 和 packer 必须都按 padded stride 前进。
- 验证必须覆盖
tId>0的 OC chunk。
unpack 指令预算
w2/w3 的有效带宽低,常见不是 DRAM 慢,而是 unpack 太重:
- 检查是否有连续
tbl/ext/ushr/shl/and导致 issue bound。 - aux plane 如果需要 64-bit 复制到 128-bit,优先考虑
ld1r {.2d},避免ld1 {.8b}+mov d[1]。 - 不要只靠 prefetch 或加 unroll 解决 unpack-heavy kernel。
- 如果不允许扩大字节,优先优化 bit-plane 方向、常量复用、block64 专用路径。
accumulator 和后处理
sdot每 4 byte 形成一个 int32 lane,C++ 模拟必须和这个分组一致。smmla的 2x8 by 8x2 分组容易因为 B layout 错位而“看起来能跑但质量差”。- min/max、bias、scale、zp 最好在 postprocess 前短生命周期加载;如果提前加载,必须确认 unpack 不会 clobber。
- FP32 后处理和 FP16 后处理要分别验证,不能用 FP16 正确推断 FP32 正确。
4.4 dispatch 和注册
在 C++ 中声明并注册 asm symbol 时,同时检查 pack mode:
extern "C" {
void MNNXxxKernel(...);
}
检查清单:
- arm init 中的函数指针是否只在对应 ISA 支持时注册。
supportI8mm、supportSDot、supportSME2的优先级是否符合预期。- SME2 如果改变
UNIT/SRC_UNIT/DST_XUNIT,packer 和 kernel 必须一起切换。 - mixed/online reorder 不要复用错误 ISA 的低 bit kernel 指针。
4.5 测试标准
编译
cmake --build build -j 8
op 正确性
cd build
./run_test.out op/lowMemory/blockConv 0 1 4
./run_test.out op/lowMemory/HybridConv 0 1 4
./run_test.out op/lowMemory/blockConv 0 1 4 # 在 SDOT 目标设备/构建配置上复跑
./run_test.out op/lowMemory/HybridConv 0 1 4 # 在 SDOT 目标设备/构建配置上复跑
根据算子替换为更精确的 op test;低 bit LLM kernel 至少保留上面两类。
模型级 sanity
./llm_demo /path/to/w3/config.json prompt.txt 64 1
./llm_demo /path/to/w3/config.json prompt.txt 64 1 # 在 SDOT 目标设备/构建配置上复跑
./llm_demo /path/to/w2/config.json prompt.txt 64 1
./llm_demo /path/to/w2/config.json prompt.txt 64 1 # 在 SDOT 目标设备/构建配置上复跑
如果输出重复或异常:
- 先跑 greedy/no-thinking,固定采样变量。
- 比较 FP16 和 FP32。
- 分别比较 I8MM、SDOT、SME2 等目标路径。
- 如果只有 mixed sampling 复读,而 no-thinking/greedy 正常,不要直接判定 kernel 错。
性能
./run_test.out speed/GemvBW 0 2
报告至少包含:
- before/after
us/iter - weight bytes 或 bytes/elem
- effective GB/s 和
%peak - GFLOPS/AI
- ISA、线程数、block size、precision
4.6 失败回退
| 现象 | 处理 |
|---|---|
| C++ SIMD 模拟和标量 oracle 不一致 | 先修 pack/unpack 数学,不写 asm |
| asm op test 过但 LLM 乱码 | 查 E=1、block64、fp32 min/max、multi-block 和 postprocess |
| i8mm 正常但 sdot 错,或反过来 | 分开检查各自 packer、kernel 注册和 runtime flag |
| 性能低于预期 | 看 unpack 指令数、寄存器压力、load/store、postprocess,不先扩大 packed bytes |
| 新 unroll 导致质量变差 | 回退到上一个正确 unroll,重新做 live range 表 |