Imported from alibaba/MNN (
skills/hexagon/SKILL.md). Install upstream withnpx skills add alibaba/MNN --skill hexagon. Copyright stays with the author.
MNN Hexagon 优化 Skill
触发条件:优化/重构 MNN Hexagon DSP 代码路径,分析
DSPOpType性能数据,诊断 cDSP crash,或做 Hexagon 侧内存占用测量。
在这个后端里,正确性、设备稳定性和实测数据的价值远高于凭直觉的微优化。本文所有经验规则都由实测得出,多数是某次错误结论换来的。
硬约束
- 不允许用 CPU fallback 当优化。不要拒绝 Hexagon 支持、把算子改路由到 CPU,也不要保留任何"把工作搬离 Hexagon 换来的耗时下降"。
- 不允许用算子融合当优化,除非用户明确解除该限制。包括图级融合、host 命令融合、DSP 命令组序列融合,以及用融合自定义算子替代多个算子。
- 优化范围限定在 Hexagon/HTP/DSP 的调度、kernel、分块、数据搬运、资源加锁、profiling 清理等不改变后端归属的实现细节内。
- 代码分支必须由设备决定(
info().hvxArch、info().maxThreads、运行时查询的 VTCM 大小),不能由环境变量决定。同一颗芯片必须永远走同一条路径。env 开关只允许作为改动开发期的临时 A/B,上线前必须删除 ——MNN_HEXAGON_Q4_GEMV_I8和MNN_HEXAGON_PATHA就是这类,已按此惯例移除。 - 不要在热 kernel 里留"默认关闭的半成品实现"来"保留未来可能性"。git 历史比散布在热函数里的八处
#if更适合保管它。
工作流
- 改之前先读现有实现:
- host/backend 侧:
source/backend/hexagon - DSP kernel 与派发:
source/backend/hexagon/htp-ops-lib/src/dsp - 复用已有的 HVX/HMX/DMA 辅助函数,不要另起一套平行抽象。
- host/backend 侧:
- 改动先求小而保行为:
- 优先做小的机械重构,再改算法。
- 不要随意加大 worker 栈,除非明确要求。
- 最终代码里不留临时 profiling、调试日志、实验性开关。
- 先测量再优化。 打开对应的 gated profiler(见「相位 Profiler」),让相位分解决定优化目标。这个后端里大部分收益都落在没人会猜到的相位上:那个"矩阵乘"算子曾经只有 4% 的时间在做矩阵乘,49% 花在把结果写回内存。
- 每个有意义的 DSP 改动都要验证:
- 重编 DSP
.so,同时重编 host(见测量纪律第 1 条,无条件)。 - 把生成的
.so拷进project/android/build_64。 - 跑目标模型测试,检查
DSPOpType输出。 - cDSP/qurt crash、设备掉线、profile RPC 失败,都算测试失败。
- 重编 DSP
- 直接比较目标算子:
- 用
DSPOpType <OP>的数字,不要只看 RTF 或总墙钟。 - 多跑几次,报告离散度,尊重该设备的噪声带。
- 只保留"目标算子变快且不损害正确性与稳定性"的改动。
- 用
- 声明收益前必须两个架构都测。 一个改动在某个 arch 上有效、在另一个上无效甚至变差,原因往往与 kernel 本身无关 —— 见「双架构」。
- 记录被否证的尝试,连同否证它的那个数字。 负面结果通常能定位瓶颈(那个"毫无收益"的
:ntstore 恰恰证明了该相位是 issue-bound),并且能避免下一个人重跑一遍。
构建与同步
按项目脚本预期的目录执行命令。
Executor::RuntimeManager::createRuntimeManager会把 backend runtime 加到创建时当前的 Executor 上。要在 benchmark 期间保持 DSP 电源,必须在 runtime manager 存在之后、从同一个 Executor 创建 activation guard,并让它在被测区间内一直存活;不要为了供电另建一个 Hexagon Executor。- Android 构建目录:
cd project/android/build_64 - 重编 host 产物并更新设备:
cd project/android/build_64 && ../build_64.sh -DMNN_HEXAGON=ON -DMNN_GPU_TIME_PROFILE=ON && ../updateTest.shupdateTest.sh对未构建的可选二进制会打印adb: error: cannot stat,属正常。确认libMNN.so、ModuleBasic.out和目标 demo 已推送即可。
- 改了
htp-ops-lib/src/dsp/*之后重编 DSP:cd source/backend/hexagon/htp-ops-lib && source ~/.bash_profile && sh sync_remote_build.sh- 也可以显式传构建机与 SDK,比依赖 profile 更可靠:
REMOTE_SSH=<user@buildhost> HTP_OPS_SDK_ENV=<path/to/setup_sdk_env.source> bash sync_remote_build.sh v81 - 指定架构就显式传,例如
sh sync_remote_build.sh v79。 - 确认脚本报告"无未预期的 undefined symbol"并成功推送 DSP 库。
- 给非当前设备的架构构建是个坑。 脚本会自动推送到唯一连接的设备,而 FastRPC stub
libMNN_htpops.so是按 arch 不同的,所以一次 v79 构建会覆盖 v81 设备的 stub。跨架构构建时屏蔽 adb(PATH=/usr/bin:/bin bash sync_remote_build.sh v79,脚本会打印skip adb push),再自己把产物拷到另一台设备。 - DSP 构建不可复现:远程构建目录的唯一 id 会进入产物,同一份源码每次编出的 md5 都不同。
.so的 md5 只能证明"编过一次",永远不能证明"设备上是哪份源码"。设备状态要用行为验证 —— 见测量纪律。
- 本地测试时把 DSP 库拷进 Android 构建目录:
cp source/backend/hexagon/htp-ops-lib/outputs/libMNN_htpops.so project/android/build_64/libMNN_htpops.socp source/backend/hexagon/htp-ops-lib/outputs/libMNN_htpops_skel.so project/android/build_64/libMNN_htpops_skel.so
测量纪律
下面每一条都是因为违反过一次、得出了一个自信但错误的结论。
- 每个测量点都无条件重编 host。 绝不能以"这个提交只改了 DSP 代码"为理由跳过。host 与 DSP 库是配对的:拿提交 N 的 DSP 库配提交 N-1 的
libMNN.so,会静默选中不同的 kernel。曾因此把某个点测成tg128 57.4(权重路径实际被关闭)而不是89.2。host 增量构建只要几秒,这个错误却要花掉几小时。 - 相信一个数字之前,先确认设备上到底跑的是什么。 md5 做不到这件事(构建不可复现)。用行为来确认:
- 身份行
[MNN::Hexagon] vectorSize=.. vtcmSize=.. maxThreads=.. hvxArch=..确认是哪台设备、哪个 skel 架构; - 一次 greedy 解码与该设备自己的参考文本逐字节比对,确认跑的是哪份代码。
- 身份行
- 只在同一份代码版本内做比较。 不要拿旧提交的数字和当前树的数字比,更不要跨两条不同算法路径比。两者同时犯就会得出"少两个线程反而快 16%"这种结论 —— 同代码扫一遍线程数即被完全否证(8/7/6 线程下平坦)。
- 先确定每台设备的噪声带,带内的差值一律不读。 裸开发板 prefill 可以做到 ±0.3%;跑着完整 Android 系统的真机是 prefill ±4%、decode ±7%,短 prompt 更差。任何
std/mean超过约 2% 的点都要重测。 - 测量周期的首尾都要复测基线,不只是开头测一次。 设备最凉最闲时的第一次读数可能是离群值:曾有这样一个读数让"持平"看起来像 7% 的性能回退,只有复测同一个提交才暴露出来。
- 真机上的噪声是单边的。 后台系统服务会制造单次灾难性掉速 —— 曾观测到某次 prefill 均值的标准差达到均值的 69%。既然只会变慢,这类设备上就该用 best-of-N 或中位数而非均值,并说明用了哪个估计量。
- 基线取"改动前能达到的最好配置",不是"出厂默认配置"。 曾有一个默认路径比同一个 build 里另一条已存在的路径慢 19%,若按默认配置做基线,就会把"修好别人的劣化"算成自己的优化成果。
- 分清每个数字覆盖什么。
llm_bench -p N是纯 prefill、-n N是纯 decode,但llm_demo带 prompt 文件跑的时候会混入尾部的 decode 步,所以那次运行里的单算子时间是两个阶段之和。只有确知是哪个阶段贡献的,才能做算子级归因。 - 相位数字与吞吐数字是两种测量,不能混用。 打开 gated profiler 会扰动被测对象本身,所以永远不要把带 profiler 的相位数字塞进吞吐对比里;要说明每个数字出自哪个 build。
相位 Profiler
编译期开关,默认关闭,关闭时零开销。这些是仪器,动手推测之前先用它们。
| 开关 | 位置 | 报告内容 |
|---|---|---|
HTP_MM_PHASE_PROFILE |
ops/matmul_q4fp16.c + execute_command.cc |
prefill GEMM 各相位 → profile[200..212]:HMX 计算、等权重反量化、DMA 等待、输出写回、激活 shuffle、setup、worker 侧写回/反量化累计时间 |
HTP_QATTN_PHASE_PROFILE |
attention_hmx.cc + execute_command.cc |
attention 队列线程相位 → profile[244..250]:DMA 等待、HMX 计算、score 写回,外加 K/V 字节数、激活字节数和 dmstart 次数 |
HTP_WATTN_PHASE_PROFILE |
attention_sync_process.cc + execute_command.cc |
attention worker 相位 → profile[251..255],按 worker 累加:Q 收集、QK 提交+等待、softmax、SV 提交+等待、O 散出 |
一对文件里的 define 都要改,重编后这些值会作为伪算子出现在 DSPOpType 表里。报告吞吐前记得改回关闭。
怎么读:
- dmstart 计数器的存在意义是回答"这个 DMA 等待是按字节收费还是按次数收费"。用等待时间除以次数:若得到一个稳定的每次耗时、且字节数变化时它不变,就是按次数收费,解法是"更少但更大的传输",而不是"更少的字节"。
- worker 侧数字是跨 worker 累加的。 要除以 worker 数才得到墙钟贡献,再与队列线程总和比较,才知道哪一侧是临界路径。
- DSP 侧的 FARF 不一定能到
adb logcat(在 v79 上就到不了,-b all也不行)。不要把诊断建立在 FARF 探针上,优先用 profile slot —— 它们通过 profile buffer 回传。
双架构(v79 / v81)
info().hvxArch 和 info().maxThreads 在 host 侧可用(HexagonRuntime::info());arch 值源自 DSP(commu.cc 把 __HVX_ARCH__ 写进 info 结构体),因此它报告的是 FastRPC 为这颗芯片加载的那份 skel。查询失败会读到 0 —— 按"更旧/更安全的路径"处理。
- 逐算子的 v79/v81 耗时比集中在 1.1–1.3×,即优于 6 对 8 线程本身预测的 1.33×。某个算子明显偏离这个区间,才是"该 arch 有专属问题"的信号,而不是普遍偏慢。
- 绝不能让工作划分数量与线程数撞上。 某条路径恰好切成
n_kv_heads= 8 个任务:在 8 线程设备上正好一波跑完,在 6 线程设备上变成两波,尾波只有 2/6 的占用率 —— 足以把一个 30% 的算法收益全部吃掉。对每个划分都要问一句:这个数是不是偶然等于某个硬件参数? 任务数要远多于线程数;当任务开销不均时(因果掩码使靠后的块贵得多)必须先派发最贵的。 反过来也要注意:本来就能填满整波的划分,再细拆只会增加开销 —— 无条件细拆曾让某算子变慢 3.5%。 - 一台设备就能验证 arch 假设。 临时把
worker_pool.cc里的g_max_num_workers钉死成更窄设备的线程数。若 N 与 N-1 之间出现悬崖、且 N-1 与 N-2 几乎相同,就是波次量化;若是平滑缩放,才是真正的单线程性能差异。正是这一招把"v79 硬件不同"(错)和"任务数恰好等于 v81 的线程数"(对)区分开。 - 有些 arch 分支是真实硬件约束,不可统一:v79 的 HMX 只支持共享锁,v81 是独占(
hmx_mgr.cc)。另一些则是历史遗留、值得重测 —— 一条被__HEXAGON_ARCH__ >= 81门控的 prefill 路径,曾把五项优化整个从 v79 的构建里编译掉,而 host 侧仍在为它们分配 workspace。 - 权重布局按设计就是 arch 相关的:M=1 整数 GEMV 需要的 vrmpy 布局只在 v81 及以上划算,所以新架构只保留该布局、旧架构只保留 HMX 布局。要在重排之前就决定,让用不到的布局根本不被生成 —— "先生成再释放"曾占掉模型加载时间的 5.4%。
本后端已实测的瓶颈模式
想新理论之前先对照这些,每一条都在这里至少测到过一次。
- DMA 的成本由每次传输的固定延迟主导,而非字节数。 把大量小传输合并成一次 2D 传输。
- 写回/搬运相位可能是 issue-bound 而非带宽受限。 症状:换成非临时存储毫无变化。解法是提高并行发射或减少每字节的指令数,而不是加 cache 提示。
- 不满一个槽位的写入会静默退化成 read-modify-write。 若一次写只覆盖目标 packing 槽位的一半,它就变成"读—改—写",触碰 2 倍内存。攒到能配对再写。
- 搬运修好之后瓶颈会转到计算,反之亦然。 每接受一个改动都要重跑相位分解;相位级大幅收益但墙钟收益很小,说明临界路径已经不在那里了,而这恰好指出下一步该看哪。
- 多趟 DDR 遍历会藏在看起来很朴素的辅助函数里。 某个 softmax 把 score 读两遍、输出也读两遍,合计每元素 10 字节 DDR;把整行暂存进 L1 后降到 4 字节,整个算子 −9.4%,且结果逐位一致。
- 把工作交给 worker 不是免费的。 当这份工作本来就顶在 DDR 带宽上限时,握手就是纯增开销:inline 写回打败了提交给 worker 的同一份写回。
已否证方向(不要重做)
以下全部实现并测量过,别再重新推导一遍。
- prefill attention 的
dmlink连续链 DMA 解耦:实现正确,收益 0 ms。 - 提高因果分组的 Q 行数(64 → 128):两次否证。
- prefill GEMM 用非临时(
:nt)存储:无收益(但由此得出上面的 issue-bound 结论)。 - 激活 shuffle 里加提前一块的显式
l2fetch:慢 1.7 ms。 - 让派发线程自己反量化第一块而不是等 worker:持平。
- prefill 权重 staging 双缓冲:导致设备重启。host 侧已经按"恰好一份权重填满 VTCM"来确定
np,而vtcm_seq_alloc是无边界检查的 bump 分配器,第二份直接越界。任何增加 VTCM 占用的改动,都必须在同一个提交里改 host 侧的 sizing。 - 为队列线程预留一个 HVX 槽位(怀疑超额订阅):否证,耗时在各 worker 数下平坦。
设备风险
- 4096-token 的 prefill 会硬重启设备,在干净的历史版本上同样如此。除非专门排查那个 crash,长 prompt 一律封顶到 2048。
vtcm_seq_alloc不做边界检查。加宽任何 staging buffer 之前,手算一遍 VTCM 占用。- host 上传会落进被回收的设备内存,而 DSP 可能仍持有该内存的脏 cache 行,这些行可能在之后任意时刻被驱逐、盖在刚上传的数据上。必须在 host 写入之前把目标区间刷回 —— 写入之后再刷会让情况严重得多。
- 设备掉线后:
adb kill-server && adb start-server,确认序列号回来之后,才可以对之前那次失败的运行下任何结论。
Profile 与 Crash 检查
- 查失败前先清日志:
adb logcat -c - 运行后取日志:
adb logcat -d - 搜索失败特征:
rg -i "execute_command_group_profile failed|qurt|sysfatal|fatal|crash|tlb|cdsp.*crash|adsp.*crash|segv|signal 11"
- 若运行失败、超时、返回 profile RPC 错误,或设备短暂掉线,继续之前先查 cDSP tombstone/ramdump:
adb shell "ls -lt /data/tombstones /data/vendor/tombstones /data/vendor/ramdump /data/vendor/ssrdump 2>/dev/null | head -80"adb shell "find /data/tombstones /data/vendor/tombstones /data/vendor/ramdump /data/vendor/ssrdump -maxdepth 2 -type f 2>/dev/null | tail -40"- 只把最新的相关文件或小目录拉到本地临时路径检查。
- 在拉取的文件里搜 DSP 失败特征:
rg -i "cdsp|adsp|qurt|sysfatal|fatal|crash|tlb|page fault|protection|signal|MNN|htp|fastrpc" <pulled_path>
- 总结出有用信号后删除临时拉取的 tombstone/ramdump 文件。
- 以下一律算失败:
[Hexagon] execute_command_group_profile failed with code -2147482610- qurt/cDSP fatal 日志。
- 测试窗口内新增或更新的 cDSP tombstone/ramdump。
- 测试期间设备 offline/掉线。
- 改了 DSP 代码后
.so缺失或过期。
- 有用的 profile 字段:
Hexagon DSP ProfileCommand groupsCommand dirtyDSPOpType <name> (<id>): <time> msHexagon onCopyBuffer Profile
HVX/HMX 指引
- HVX 指令细节参考
~/Download/hvx.pdf。 - 引入新的 intrinsic 写法之前,先找项目里已有的例子。
- 只在对齐有保证时用
vmem;不对齐访问用vmemu。 - DMA/HVX/HMX 的改动要落在"内存流量"和"实测算子时间"上。
- HMX 路径在加宽 tile 或 block 之前,先核对 VTCM 分配大小、tile 数和 descriptor 数。
- 不要假设一个微优化能在 v79/v81 之间通用;针对目标架构构建并测试。不通用的原因、以及如何用单台设备验证 arch 假设,见「双架构」。
DSP DMA-BUF 内存测量
用于验证 Hexagon/DSP 内存占用,或对比 CPU 与 forwardtype=10。
- 把模型配置成走 DSP:
- 模型
config.json设forwardtype=10。 - 除非任务明确要求,保持模型原本的 precision。
- 模型
- 用足够多的重复输入让进程活着,便于采样。
- 找到进程 PID:
adb shell ps | grep <process_name> - 进程还活着时,把三个内存视图都采一遍:
- 进程 RSS/HWM:
adb shell "awk '/VmRSS:|VmHWM:/{print}' /proc/<PID>/status" - 进程 DMA-BUF:
adb shell dmabuf_dump <PID> - 完整内存:
adb shell dumpsys meminfo <PID>
- 进程 RSS/HWM:
- 尝试系统级 DMA-BUF 总览,但要明确记录权限/路径失败:
adb shell cat /sys/kernel/debug/dma_buf/bufinfo- 有些设备暴露
/sys/kernel/dmabuf/buffers,可能需要 root。
- 内存数字分开报告:
VmHWM、TOTAL RSS、TOTAL PSS、dmabuf total或PROCESS TOTAL- 需要时用
VmHWM + dmabuf total近似"进程可见的 DSP 压力"。
安全检查清单
- 目标模型通过正确性检查,例如
cos_sim可接受。 - 目标 demo 输出符合预期且不 crash。
- greedy 解码输出与该设备的参考文本逐字节比对过。如果变了,必须已经弄清原因并明确说明 —— 这个后端里大部分 kernel 工作都应当是数值保持的,输出有差异是一个发现,不是一个细节。
- 报告了
DSPOpType目标耗时,并与明确的基线对比,且基线是改动前能达到的最好配置。 - v79 和 v81 都构建并测量过;只覆盖其中一个时,说明理由。
- 报告的差值超出测量设备的噪声带,且均值旁边给出了离散度。
- 每个测量点都重编过 host。
git diff --check干净。- 没有残留的临时日志、计数器、逐算子 tracing、写死的 worker 数或 env 开关,除非明确要求。gated profiler 已恢复默认关闭。
- 报告性能之前,重编的 DSP
.so已拷贝/推送到位。