去年有一段时间,我们团队在一颗RISC-V四核SoC上做端侧推理,跑的是YOLOv5检测。板子刚上电时的表现让我印象深刻——推理延迟低,温度也低,跑几分钟后温度上来,系统开始自动降频,然后帧率崩了。那阵子大家习惯性把“能效”理解成降频降温,遇事不决就把最高频率锁低,或者打开thermal限制。折腾了一周,功耗是低了,但产品根本没法用。后来我把思路整体换掉,从被动节流改成主动能效治理,核心就落在调度、量化与空闲态优化这三件事上。这篇分享没什么高深理论,都是我在RISC-V端侧推理落地过程中反复试出来的方法,适合做嵌入式AI部署、实时推理系统和嵌入式Linux优化的朋友参考,有基础的人能直接拿去用,新手也看得懂。
1. 为什么被动节流撑不起端侧推理
1.1 降频只是把问题往后推
被动节流最典型的实现就是DVFS。芯片有动态电压频率调节,频率下降后动态功耗大致按频率线性下降,按电压平方下降,所以只要把频率降下来,温度马上好转。问题在于端侧推理不是恒定负载:推理阶段是计算密集型,前后处理是内存访问密集型,任务队列还有空窗期。固定低频运行会拖慢每个计算阶段,导致推理延迟变高,帧率下降。更麻烦的是,很多平台的DVFS切换不是瞬时完成的,频繁调频会让电压调整器一直在跳变,功耗不降反升。
我拿某款RISC-V开发板实测过,温度触发降频后,主频从1.5GHz掉到1.0GHz,单帧延迟从120毫秒涨到180多毫秒,帧率直接腰斩。这就像夏天电不够,最后拉闸,而不是把电能分配到最需要的地方。被动节流本质上是事后救火,没有从任务怎么跑、数据怎么搬、CPU闲着该干嘛这三个角度去解决问题。端侧推理真正需要的是主动治理,即在任务调度、数据位宽和空闲低功耗这几个维度上同时下手。
1.2 主动能效治理的三个支点
主动能效治理的核心思维,是把“能效”拆成两条曲线来看:一条是性能曲线,一条是功耗曲线,最终目标是让性能不下降或下降有限的前提下,把功耗压下去。我最后收敛到三个抓手。
第一个是调度。调度解决的是任务去哪颗核心、什么时候抢占、空闲时是否要主动让核的问题。RISC-V多核平台如果调度器选核不合理,任务会在核之间反复迁移,缓存不断失效,功耗不被算进任务里,但实实在在地烧在DDR访问和总线竞争上。第二个是量化。量化解决的是数据量问题,FP32换成INT8后,同样一个卷积操作需要搬运的字节数变成原来的四分之一,计算量也降下来。第三个是空闲态。空闲态解决的是无事可做时的浪费。CPU没有任务时如果还在忙等,或者被周期时钟定时唤醒,功耗一样很难看。这三个支点不是孤立的,量化把任务变轻,调度把任务放对位置,空闲态把空出来的时间变成低功耗模式,三者协同才叫主动治理。
2. 调度优化:把算力花在刀刃上
2.1 用 cpuload 数据先给系统“拍个CT”
拿到开发板后我先做的不是改内核,而是采集cpuload数据。cpuload并不等于CPU占用率,它能反映每个CPU核在单位时间内处于运行态、睡眠态、中断处理的时间比例。我喜欢组合用这几条命令:top -d 1看每个线程的CPU占用;mpstat -P ALL 1看每个核心的忙碌和空闲比例;cat /proc/interrupts看中断有没有集中在某个核上。
第一次采集就发现了三个典型问题。一是四个核心负载看起来差不多,但单个推理进程的CPU使用率在不断跳变,说明它每隔几十毫秒就被调度器迁移一次;二是所有网络中断都落在0号核上,而推理主线程也在0号核,导致中断经常抢占推理;三是空闲核并没有真正空闲,而是陷入自旋锁忙等,cpuload看起来低,功耗一样不低。这些问题不看数据是发现不了的。
我后来习惯把采集脚本写到后台,跑十分钟再分析,不要只看一两分钟,因为端侧推理负载会有周期性波动。比如摄像头输入、帧率控制、定时同步都会让任务呈现突发性,短时间采样很容易漏掉关键相位。分析cpuload时还要结合单核时域曲线,而不仅仅是平均值。两个核各自50%负载,和同一个核满负载另一核空闲,功耗和延迟表现完全不同,这一点对推理任务尤其重要。
2.2 从绑核、优先级到智能核心调度
绑核是最简单也最有效的手段。taskset -c 2 ./infer把推理进程绑定到2号核,可以立刻减少进程迁移。迁移听起来只是换个CPU跑,实际上是一个连锁反应:新核的cache是冷的,需要重新从DDR拉数据,旧核留下的cache变成无效状态,总线流量增加,功耗也随之上升。对实时推理来说,绑核还能减少调度延迟,因为调度器不用再考虑把任务放到哪个核上。
如果板子支持实时调度策略,还可以配合优先级使用。chrt -f 50 ./infer把推理主线程切成SCHED_FIFO实时优先级,让它在唤醒后立刻抢占CPU,不被普通线程干扰。这里要小心,实时优先级不是越大越好,优先级过高会影响中断线程、网络协议栈处理,严重时系统输入输出会卡死。我一般把推理线程设为实时优先级40到60之间,其他辅助线程用普通优先级。
真正智能的核心调度要解决的是异构核心和集群调度。RISC-V平台也有不少SoC采用大小核设计,小核能效高、大核吞吐高,所以前后处理线程可以绑到小核,推理主线程绑到大核。内核如果支持能耗模型调度器,它会自动做这种选择,但很多RISC-V平台的调度器还比较粗糙,更多时候需要手动管理。我常用的方式是用cpuset cgroup把进程限制到指定核上,例如把实时推理进程放进只允许大核的cpuset,后台服务放到小核的cpuset,避免两类任务互相抢核。
集群调度也是容易被忽略的维度。同簇的核心共享L2缓存,任务跨簇访问时延迟更高、总线竞争更激烈。调度器会尽量让任务留在同一个簇,但如果内核不知道簇拓扑,就得靠cpuset固定进程能使用的簇范围。我在一个多簇平台上做过对比,同样一个检测线程,允许它跨簇调度时每帧平均延迟多出十几毫秒,绑在单簇后延迟方差明显下降,功耗也低了几百毫瓦。
2.3 实时性监控与调度延迟排查
做实时系统的朋友应该用过QNX Momentics,里面能看到时序调度和系统延时的准确分布,线程在哪个时刻被哪个对象唤醒、等了多久都一目了然。Linux端也有类似思路,用perf sched和ftrace可以取得接近的效果。我通常执行perf sched record -- sleep 10,然后再用perf sched latency看结果,重点看wakeup latency和scheduling delay这两个指标。
有一次我发现推理线程平均唤醒延迟只有几十微秒,但最大延迟超过3毫秒,查了一圈,是内核里的kworker每两毫秒跑一次,优先级还比较高,把推理线程挤掉了。解决办法是把推理线程改成SCHED_FIFO优先级,同时把kworker绑到别的核。还有一个容易忽略的点,RISC-V的PLIC中断控制器默认会把外部中断集中投递到一个CPU上,导致该CPU被频繁打断。这时需要把网卡、存储等中断的smp_affinity手动改到另一个空闲核,例如echo 4 > /proc/irq/45/smp_affinity,这样推理主线程的时序才稳定下来。
排查调度延迟时要有耐心,不要只看平均值。平均值再低,如果尾部延迟很高,推理任务一样会偶发抖动,表现为画面卡顿或超时。我建议用直方图方式观察延迟分布,只要尾部有超过目标帧间隔的点,就要继续优化。
3. 量化优化:用更少的比特跑同样的推理
3.1 为什么端侧推理离不开 INT8 量化
RISC-V核的算力增长很快,但在端侧推理里,真正的瓶颈往往不是MAC阵列,而是数据搬运。以卷积层为例,每产生一个输出点,需要读取输入特征图的窗口和对应的权重值。FP32下,一个较大的卷积核参数一次性读进来就是几千字节,换成INT8后同样数量参数只占四分之一字节,DDR访问量随之降低。对嵌入式板子来说,DDR访问能耗远高于计算能耗,所以量化的收益不只是速度,更是功耗。
这里还要说到位宽对计算效率的影响。如果处理器带向量扩展,比如RVV,一次能装载的数据元素数是按位宽算的,INT8能同时处理的元素数量是FP32的四倍。很多RISC-V核跑向量化INT8算子的性价比非常高,这也是为什么我只要精度能接受,优先上INT8而不是继续在FP32上优化。量化研究方法里那张能量表大家应该也看过,访存的成本比整数加法高一两个数量级,放到端侧嵌入式里更夸张,所以减少内存流量往往比减少计算周期还重要。
3.2 从 YOLOv5 到 ONNX 再到 INT8 的实操路径
我以YOLOv5s为例说一条能落地的路线。第一步,导出ONNX:python export.py --weights yolov5s.pt --include onnx,开启simplify。ONNX是中间格式,各种量化工具链都能识别。第二步,准备校准集。这一步最容易被忽略。校准集不是越多越好,而是要贴合真实推理时遇到的输入分布。
我从训练集里随机抽300张图,做和训练一致的预处理,然后用工具链做训练后量化。如果你手头是RK3568这类带NPU的板子,可以看到很多yolov5量化rk3568的教程,流程基本都是加载ONNX模型、指定量化数据类型、传入校准数据、转换导出。RISC-V平台如果没有NPU,可以直接用ONNX Runtime的静态量化接口,或者按芯片厂商的PTQ工具来走,核心是把每个算子的输入输出范围统计出来,然后映射到INT8。量化后必须回到板子上做精度和性能验证,不要只看PC模拟结果。
我踩过一个典型的坑,量化版CLIP模型最后输出的特征维度和原模型对不上,一个是5120,一个是4096。一开始以为是量化搞坏了,后来定位发现是导出时用了一个不同版本的投影层权重,结构错位了。量化只会忠实反映模型本身,如果模型定义与权重不匹配,它不会帮你纠正。所以做任何量化模型之前,先用脚本比对各层输入的shape,特别是维度、通道数和token长度,不要拿起来就量化。
3.3 量化后的精度修复与混合量化
量化之后最常见的现象是精度下降,尤其当你用SiLU或GELU这类非饱和激活函数时,输出分布不是简单的均匀分布,量化误差会被放大。如果INT8全量化掉点超过一个点,我会考虑混合量化,把敏感层保持FP16或FP32,其他层继续用INT8。很多工具链支持在量化配置里指定算子白名单或黑名单,花点时间做敏感层分析是值得的。
如果混合量化还不够,就要上量化感知训练QAT,在训练阶段模拟量化的舍入误差,让模型权重去适应这种噪声。QAT成本高,但如果目标是长期稳定量产,该做还得做。这里放一组我在某RISC-V四核板上的量化前后对照数据,硬件不同,数值仅供思路参考:
| 方案 | 权重格式 | mAP@0.5 | 推理耗时 | 平均功耗 |
|---|---|---|---|---|
| 原始FP32 | FP32 | 0.582 | 112ms | 3.2W |
| 全INT8 PTQ | INT8 | 0.574 | 73ms | 2.4W |
| 混合量化 | 关键层FP16 | 0.580 | 78ms | 2.5W |
量化不能和调度割裂,它的直接效果是计算量降低、DDR带宽降低,这让CPU可以在更低的频率下完成同样的帧率,也为调度腾出余量。我在做优化时,量化不是最后一步,而是和调度同步调整,否则又把频率拉上去,量化的收益会被抵消掉一部分。
4. 空闲态优化:让无事可做的时刻真正省电
4.1 RISC-V 的 WFI 与 cpuidle 机制
RISC-V有WFI指令,全称Wait-for-Interrupt,CPU执行后暂停流水线,直到外部中断把核唤醒。操作系统在idle线程里反复执行WFI,就是让CPU进入cpuidle状态。问题在于内核要决定CPU睡多深,睡深了唤醒慢,睡浅了省电少。Linux的cpuidle框架通过governor根据最近的idle间隔预测来选state。
我在RISC-V板子上会先确认CPU到底有没有进入idle。看/sys/devices/system/cpu/cpuidle/state*/name,有些state名是C1、C2,如果只有state0,说明firmware或设备树没配置好低功耗状态。还需要检查设备树里idle-states节点的latency-us和min-residency-us参数。这些参数要按SoC手册填,填太大会让governor不敢睡深,填太小又容易睡进去后立刻被唤醒,反而耗电。
WiFi数据上,我建议把governor从menu换成teo试一下,两种算法的预测逻辑不同,在某些工作负载下teo对长时间空闲的预测更准。如果使用的是openSBI固件,还要确认SBI的电源管理扩展是否完整,因为进入更深睡眠最终要通过固件来完成,固件不配合,内核怎么配都白搭。
4.2 动态时钟与唤醒风暴
内核如果一直维持周期性tick中断,空闲CPU也会每隔一两毫秒被叫醒一次,根本睡不踏实。开启CONFIG_NO_HZ_IDLE后,空闲CPU会关闭tick,这是标准的低功耗配置。更进一步,CONFIG_NO_HZ_FULL可以让运行实时任务的CPU也停止tick,把时间精度交给应用自己维护,适合计算密集型推理任务。
唤醒风暴是我最想强调的现象。四个核都在执行周期任务,虽然单核负载不高,但时钟中断把四个核同时叫醒,瞬间电流很高,而且它们都没有进入深睡眠。cpuload看着只有百分之十几,功耗却和跑满差不多。排查方法很简单,看/proc/interrupts里本地时钟中断那一项是否跳得飞快,再用trace-cmd把hrtimer和tick事件拉出来看。
解决思路是减少不必要的定时器、使用动态tick、把实时任务放到isolcpus隔离核上,以及让调度器尽量把负载聚拢到少数核心上。只有整个簇的所有核都空闲下来,电源控制器才能进一步关闭CPU簇电源,这是空闲态优化和集群调度的真正联动。我在实测中发现,只把三个核从负载中释放出来,待机功耗就能降低不少,比任何模型优化都见效快。
5. 几个容易踩的坑和我的避坑习惯
5.1 link.ld 与内存布局对能效的影响
嵌入式的老朋友应该对risc-v link.ld不陌生,它决定代码段、数据段、堆栈放在哪些内存区域。绝大多数教程讲的是怎么把启动代码放到固定地址,但很少有人把它当能效治理工具。实际上,同一个数据放在DDR和内部SRAM,访问功耗差别很大。SRAM在片上,不需要DDR刷新,带宽低,访问一次所需的能量少得多。RISC-V SoC通常有ITCM或紧耦SRAM,如果能塞得下模型权重,就尽量把热数据放进去。
具体做法是在链接脚本里划分一块保留SRAM,然后把权重段的位置定义到SRAM,大概像这样:
MEMORY { DDR (rwx) : ORIGIN = 0x80000000, LENGTH = 512M SRAM (rwx) : ORIGIN = 0x10100000, LENGTH = 2M } SECTIONS { .rodata : { *(.rodata.model_weight) } > SRAM }但要注意,改完链接脚本后要确认MMU页表有没有覆盖到SRAM地址,否则运行时会触发异常。另一个坑是改了段顺序后,向量表被覆盖,系统启动不起来。解决办法是用readelf -S和nm检查符号地址,确认入口点没有被挤掉。模型量化成INT8后体积小很多,放进SRAM的可能性更大,所以量化和内存布局是天然搭档。
5.2 量化与调度协同的实测复盘
我在某颗四核RISC-V板子上做过一组完整对比,把数据列出来,给各位一个直观参考:
| 优化状态 | 推理耗时 | 平均功耗 | 系统温度 | mAP@0.5 |
|---|---|---|---|---|
| 初始状态 | 125ms | 3.5W | 78℃ | 0.582 |
| 只降频 | 192ms | 2.4W | 66℃ | 0.582 |
| 只做调度和空闲态 | 118ms | 3.0W | 72℃ | 0.582 |
| 调度+INT8+空闲态 | 72ms | 1.8W | 55℃ | 0.574 |
降频换来功耗下降,但延迟明显劣化;调度和空闲态微调后功耗降得有限;加上INT8后,数据搬运量下来,CPU能在更低频率完成任务,空闲态也有机会进入,整体效果不是简单叠加,而是乘法关系。这也是我强调“主动治理”的原因,被动降频只是把系统按下去,主动治理则是让系统在更低功耗下自然稳定运行。
我个人做能效治理最深的感受,是数据闭环比任何技巧都重要。每次改动只动一个变量,同时记录cpuload、功耗、帧率、延迟和温度,跑二十分钟再对比。一开始我也喜欢堆技巧,今天绑核明天量化,结果功耗降了精度不行,精度恢复又慢了,反复横跳。后来把习惯改成先采集数据再做决策,效率高了很多。RISC-V生态虽然比ARM稍微折腾一点,但好处是系统软件的可控性高,你能看到调度器怎么选核、idle怎么选态,甚至能改固件。把这些能力用好,端侧推理的能效拐点会比你想的来得快,希望这篇分享能帮你少走一点弯路。