前阵子我把一块RISC-V八核开发板拿来跑端侧推理,最开始的心态其实有点“佛系”:反正板子功耗低,性能弱一点就弱一点,降降频、关关核、多等一会儿,总能把活干完。结果一测傻了眼——YOLOv5s跑起来不到3FPS,整板平均功耗却飙到快7W,待机功耗也有3W多。这哪里是“低功耗AI”,分明是“低性能电暖器”。
后来我花了两周时间,从调度、量化、空闲态优化三个方向做了一轮系统性的“能效治理”,实测能效比翻了好几倍。这篇就来复盘整个过程,聊聊为什么端侧推理不能靠被动节流,而要走向主动治理。如果你也在RISC-V或类似资源受限的平台上部署视觉模型、LLM,这篇文章里的思路、命令和坑,应该能帮你少走不少弯路。
1. 先把瓶颈找出来:端侧推理到底慢在哪
1.1 算力不是唯一瓶颈,数据搬运才是
做端侧推理的人,第一直觉都是算算力:主频多少、TOPS多少、FPGA/NPU算力多大。但这些纸面数字在真实板上往往对不上帧率。原因很简单:一个模型跑得慢,卡的时间大头通常不在乘加运算,而在访存。
拿我手头这块板子来说,八核RISC-V主频1.8GHz,带RVV向量扩展,理论算力不算难看。但它的L2总共只有2MB上下,内存是单通道DDR4,带宽大约12.8GB/s。跑YOLOv5s时,按FP32口径模型计算量大约4.6 GFLOPs,按算力推算每帧应该几十毫秒搞定;可实际上单帧耗时400多毫秒。差距去哪儿了?全耗在访存上。
一个卷积层,输入Activation、权重、偏置、中间结果都要从DDR搬进L1/L2,算完再写回DDR。每个算子的访存量往往是计算量的好几倍。更别说RISC-V这类平台上的cache miss成本,一次DDR访问的延迟够CPU执行上百条指令。所以我的第一个结论很明确:在端侧推理里,内存带宽和cache miss,比TOPS数字更能决定实际吞吐。借用《计算机体系结构:量化研究方法》里那句经典的话,性能瓶颈一定在你实际测量的地方,而不是你理论推导的地方。
1.2 被动节流和主动能效治理差在哪
想清楚瓶颈之后,我意识到之前“降频等一等”的思路本身就是错的。降频省的是峰值功耗,但它解决不了访存瓶颈,还把性能一起砍了;关核就更粗暴,直接牺牲并行度。我把这种思路叫“被动节流”:出了发热、功耗超限、性能不达标的问题,才用一刀切的手段去压。
真正应该做的是“主动能效治理”:在任务还没开始跑之前,就通过调度、量化、空闲态三件事把能量规划好。调度负责让对的核心在对的时间干对的活;量化负责让模型更小、访存量更低,算同样的活只花一小半能量;空闲态负责让不干活的核心真正睡死,而不是挂着高频空转。
这两个思路的差别,可以整理成一张表:
| 维度 | 被动节流 | 主动能效治理 |
|---|---|---|
| 目标 | 降温、保护硬件、临时降载 | 提升单位能量的有效产出(FPS/W、token/s/W) |
| 手段 | 降频、关核、强制休眠 | 负载感知调度、模型量化、空闲态精细管理 |
| 时机 | 温度超限后 / 系统空闲后被动响应 | 任务启动前预估、运行中动态调整 |
| 副作用 | 性能抖动明显、响应变慢 | 延迟可预测、能效稳定提升 |
后文所有优化动作,本质上都是在回答三个问题:让哪个核心干活,怎么让活变得更轻,怎么让没事的核心快点睡。
2. 调度优化:把每一核算力花在刀刃上
2.1 异构核心怎么分活:别让通用调度器替你拍板
RISC-V端侧SoC的CPU拓扑往往是几簇核心,每簇共用一个频率域和电压域。有的簇偏性能,有的簇偏能效,这跟手机上的大小核结构类似,但Linux通用调度器默认只会根据负载均衡来做决策,它不会主动识别“你这个推理线程放错簇了”。
我实测下来,一个INT8卷积推理线程放在性能簇和能效簇,帧率能差20%到30%。原因不光是主频差异,还有cache共享和内存延迟的不对称。通用调度器不知道这些,它只知道“哪个CPU现在空闲”。
所以第一步就是把关键推理线程绑到正确的核上。我用的方法很直接:
# 把推理进程绑定到CPU2和CPU3 taskset -c 2,3 ./infer_process # 如果有多个线程,可以按线程粒度绑 taskset -p -c 2,3 $PID # 隔离出几个核给实时任务用 # 内核cmdline加: isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7如果你用cgroup管理,也可以用cpuset:
mkdir -p /sys/fs/cgroup/cpuset/infer echo "2-3" > /sys/fs/cgroup/cpuset/infer/cpuset.cpus echo 0 > /sys/fs/cgroup/cpuset/infer/cpuset.mems echo $PID > /sys/fs/cgroup/cpuset/infer/tasks这里有个前提:你最好先摸清板子上的CPU拓扑和频率域划分,再看lscpu -e的输出确认哪些核可以绑在一起。别一上来就绑核,先实测再动手。绑完核之后,记得用perf stat对比一下cache miss率,你会看到立竿见影的变化。
2.2 如何看时序调度和系统延时:从trace到Momentic
说完了核怎么分,下一个问题是怎么验证调度质量。我见过不少朋友优化调度全靠“感觉”:帧率上去了就认为没问题,掉帧了就开始美团式重启。这样不行,你得会看时序和延时。
Linux下最常用的手段是trace-cmd抓内核调度事件。我一般这么抓:
trace-cmd record -e sched_switch -e sched_wakeup -e irq_handler_entry -e power:cpu_idle sleep 10 trace-cmd report > trace.txt这个trace里你能看到每个线程什么时候被唤醒、什么时候真正切换上CPU、中间隔了多少微秒,这个间隔就是调度延迟。端侧推理里,调度延迟超过5ms就会明显影响帧率稳定性。
如果你用的是QNX这类RTOS,或者板子的SDK里自带像Momentic这样的时序分析工具,它会直接给出调度反转、优先级抢占、中断延迟这些指标。一套好的时序分析工具,能帮你快速定位“是调度器在等锁,还是中断风暴在抢CPU”。我自己的习惯是:先看调度延迟的P95,再看中断频次,最后才去看负载。
2.3 cpuload数据分析:先会读数据再谈调优
很多人把cpuload和CPU占用率混为一谈。load average高,不代表CPU跑满了,它统计的是R状态和D状态的进程数,一个进程在等DDR返回数据时也算load。所以只看load会误判。
正确的做法是组合看几组数据:
vmstat 1看r(运行队列)、b(阻塞)、cs(上下文切换)三列;上下文切换每秒超过几千次,说明调度太碎。pidstat -t -p PID 1看进程内各线程的CPU占用分布,找出谁在偷跑。perf stat -e task-clock,context-switches,cache-misses ./infer看推理进程的访存和切换指标。
我遇到过最典型的场景:推理进程CPU占用率50%,但帧率只有预期的一半。看vmstat发现cs列狂跳,再看pidstat发现进程里有个后台线程在周期性做日志刷新,每秒把推理线程打断几十次。这就是典型的“cpuload看起来不高,实际性能被切换吃掉了”。这类问题,不靠数据分析是找不到的。
2.4 关键任务的实时属性:SCHED_FIFO与优先级控制
绑核只能解决“在哪跑”,决定不了“抢不抢得到CPU”。端侧推理如果跟其他业务线程混跑,交互式线程、后台服务都可能抢占CPU。对推理任务,我推荐把它的一部分关键线程设为实时调度。
#include <sched.h> #include <pthread.h> struct sched_param param = { .sched_priority = 80 }; pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);注意,SCHED_FIFO优先级不是越高越好。如果优先级设到90以上,你的中断线程和内核关键线程可能被堵住,反而带来更大的延时抖动。我自己一般控制在70到80之间。另外,设置了实时优先级之后,线程里的循环里如果自旋等锁,很容易把整个核吃死,所以代码里务必避免while(!flag);这种忙等写法。
调度层优化做完之后,我测了一次:帧率从2.4FPS涨到3.4FPS,功耗几乎没有变化,能效比提升了差不多50%。但这还远远不够,因为模型本身还是那个又大又慢的FP32模型,真正的大头在下一层。
3. 量化:把模型体积和访存量一起砍掉
3.1 别把模型量化跟量化交易搞混:端侧量化的真实收益
先说点题外话,常有人一听“量化”就以为是金融那套K线、夏普比率。这里说的量化,是把模型权重和激活从FP32变成INT8甚至更低比特的整数表示,纯粹是端侧部署的事。别搞混。
量化在端侧推理里的收益,不只是模型体积缩小四倍这么简单。它最实在的好处,是让同样的DDR带宽能搬运更多的有效数据。FP32的权重,一次DDR读32位只能读一个参数;INT8的权重,同样32位能读四个参数。访存是端侧推理的命门,量化直接打在命门上。
当然,量化是有代价的。不同数据类型的精度损失和硬件友好度差别很大,对RISC-V这类对工具链要求高的平台尤其明显。我用过的方案整理成一张表:
| 数据类型 | 位宽 | 典型适用层 | 精度损失 | 端侧部署注意点 |
|---|---|---|---|---|
| FP32 | 32 | 各种 | 基线 | 模型大、访存开销最大 |
| FP16/BF16 | 16 | 各种 | 很小 | 硬件没有Zfh扩展时只能软件模拟,反而更慢 |
| INT8 | 8 | Conv、FC、MatMul | 较小 | 部署最成熟,RVV向量化友好 |
| INT4/NF4等 | 4 | LLM线性层 | 中等 | 需要反量化,端侧收益主要在内存带宽 |
| FP8 | 8 | 部分新模型 | 中等 | 工具链不成熟,暂不建议 |
现在连DeepSeek、GLM这些开源模型都有量化版本地部署的玩法,端侧重构的基本路线是:能INT8就不INT16,能极低比特就不普通FP16,前提是精度还能用。
3.2 INT8量化方案怎么选:从PTQ到QAT
对大部分端侧视觉模型,推荐从PTQ(训练后量化)入手。流程不复杂:准备几百张有代表性的输入图片,跑一遍推理,统计每层激活的数值范围,然后算出scale和zero_point。快、不需要重新训练,是首选。
但PTQ翻车也很常见,尤其是小模型或者深层特征图分布不均匀的时候。如果你发现量化后精度掉得离谱,先别急着换QAT,把校准集换一下再试。校准集一定要覆盖真实场景:光照变化、分辨率变化、遮挡,都要有。只拿几张测试图做校准,大概率过拟合到那几张图上。
如果换了校准集还不行,再考虑逐通道(per-channel)量化,尤其对卷积层和FC层,per-channel比per-tensor精度好很多,代价是推理引擎的多一点计算量。最后没办法,才上QAT量化感知训练。QAT要重新训练模型,成本高,但精度确实最稳。
3.3 算子级的坑:为什么量化后反而慢、为什么shape对不上
量化不是导出一个INT8模型就能直接跑,这里坑特别多。我踩得最深的几个:
第一个坑是算子fallback。ONNX里的某些算子,比如动态Shape的Resize、Gather、NonMaxSuppression,量化工具可能没有实现INT8版本,推理引擎就只能把这一块放回FP32执行。如果FP32算子占的推理时间比例高,量化后的加速效果会被严重稀释,甚至出现“量化了但没完全量化”的尴尬情况。所以导出前一定要看算子支持表。
第二个坑是Shape不匹配。社区里常有人问“为什么我量化版的某个模型导出来后在引擎里报错,两个维度对不上”,比如中间特征从5120变成4096。我遇到过类似的情况,十有八九是导出脚本里某处Reshape硬编码了未量化时的维度,或者模型源码和导出工具版本不一致。排查方法很简单:用ONNX Runtime跑一遍原始FP32模型,把每层的输入输出Shape打印出来,再跟量化导出的模型逐层比对。
import onnx model = onnx.load("model.onnx") for node in model.graph.node: for idx, out in enumerate(node.output): print(node.op_type, out)第三个坑是量化后反而变慢。这通常是因为动态反量化被放在了热点路径上,或者INT8算子内部还在做不必要的数据格式转换。RISC-V平台上尤其明显,因为很多推理引擎的RVV向量化路径还没把INT8卷积写透,反而不如FP32标量路径。解决办法是打开引擎的profiling,看到底是哪个算子慢,再针对性替换后端。
3.4 RVV指令与推理引擎适配:最后一步的加速
RISC-V的RVV向量扩展,对INT8推理很友好。数据是8位整型,一条向量指令可以一次搬几十个字节。但编译器自动向量化的效果,取决于循环结构规整不规整。很多量化后的卷积、Depthwise层,循环边界参差不齐,GCC/Clang经常生成很保守的代码。
这时候需要手写RVV intrinsic,或者利用推理引擎已经封装好的RVV后端。比如加载一个INT8向量:
#include <riscv_vector.h> size_t vl = vsetvl_e8m1(n); vint8m1_t vec = vle8_v_i8m1(ptr, vl);实际项目中,我更推荐先用NCNN、TEngine这类原生支持RVV的推理框架,而不是自己一行行写汇编。但框架选型能决定你能不能吃到RVV红利。我实测下来,同一个INT8 YOLOv5s,开启RVV后端的推理时间大概能再降30%以上。像在RK3568这类Arm平台上跑YOLOv5 INT8的朋友,流程其实类似,只是把NEON换成RVV,思路互通。
量化这层做完,我的推理帧率从3.4FPS干到了7.1FPS,模型体积从14MB缩到3.8MB,平均功耗反而从6.5W降到5.2W。能效比已经翻倍还不止。但这还不够,因为我发现板子待机功耗高得离谱,问题出在“不干活的时候”。
4. 空闲态优化:别让CPU假装下班
4.1 为什么推理服务空闲时待机功耗还那么高:WFI与cpuidle
跑推理的板子不可能每时每刻都在满负荷推理,更多时候是“来一帧处理一帧,不来就等着”。我最初测待机功耗,发现一个诡异现象:推理进程完全空闲,板子功耗还有3.1W。这不正常,低功耗板子待机应该不到1W才对。
问题出在CPU没有真正进WFI。RISC-V CPU执行WFI指令后会进入低功耗等待状态,中断来了才会醒来。Linux的cpuidle子系统会根据预测的idle时长选择合适的idle state。查看当前支持的状态:
cat /sys/devices/system/cpu/cpu0/cpuidle/state*/name如果系统频繁被定时器唤醒,比如有线程每秒醒来几十次,cpuidle governor会认为“CPU很快就会有事干”,于是选择浅睡甚至不睡。最终结果就是CPU一直在高频空转,只是没有执行用户代码,功耗自然下不去。
4.2 DVFS与调度联动:别用一套governor走天下
除了idle,频率也是大头。Linux的cpufreq governor有很多,从performance到powersave到schedutil。问题在于很多人一套governor用到底:用performance,推理是快了,但空闲不降频;用powersave,空闲省电了,推理又变慢。
我现在的做法是动态切换:推理任务开始前切成performance,确保前几帧不会因为升频慢而掉帧;推理任务结束后切成schedutil,让调度器根据实时负载自动调频。schedutil的优势是它直接和调度器联动,利用率高才升频,利用率低立即降频,比单纯的按时间轮询的conservative响应快得多。
# 查看当前governor cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # 推理开始前 echo performance > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # 推理结束后 echo schedutil > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor注意,不是所有RISC-V板子的cpufreq驱动都支持每个核独立调频,很多SoC是几个核共享一个频率域,切换governor的时候就是一整簇一起变。实测下来,这种共享频率域的平台上,schedutil比独立调频的平台抖动更明显,更需要在应用层做任务合并。
4.3 从被动到主动的配置实例:一个最小推理服务的能效策略
把上面这些组合起来,我给推理服务写了一个简单的能效管理脚本。逻辑很朴素:有活的时候用performance加绑核,没活的时候用schedutil加WFI,同时强制后台日志进程和大核隔离。
#!/bin/sh # 推理主进程 INFER_PID=$(pidof infer_process) # 有活:切performance + 绑核 echo performance > /sys/devices/system/cpu/cpu2/cpufreq/scaling_governor taskset -p -c 2,3 $INFER_PID # 没活:后台任务绑到小核,主核切schedutil echo schedutil > /sys/devices/system/cpu/cpu2/cpufreq/scaling_governor echo 1 > /sys/devices/system/cpu/cpu6/cpufreq/scaling_governor这套策略的精髓不是“省电”,而是“该快时快,该睡时睡”。推理延迟要从几百毫秒级别降到几十毫秒,你必须在任务到来前把频率拉起来;而任务间隔长的时候,又得让CPU尽快沉到深睡。这就是主动治理和被动节流最大的区别:它知道你什么时候需要性能,什么时候需要省电,而不是一刀切。
做完空闲态优化,待机功耗从3.1W降到了0.8W,推理过程中的平均功耗也降了一点,因为帧间空闲的CPU真正睡过去了。
5. 实测复盘:三轮优化后的能效账单
5.1 实验环境与基线数据
简单交代一下环境,方便你对比自己的板卡。硬件是某八核RISC-V开发板(带RVV 1.0),主频1.8GHz,2GB内存,Linux 6.x内核,推理框架为NCNN自编译+RVV后端。模型是YOLOv5s,先跑FP32基线,再对比INT8量化后的版本。
基线数据(什么优化都没做,FP32模型,默认调度器,cpuidle全开但没做任何策略):
| 场景 | 帧率(FPS) | 平均功耗(W) | 能效(FPS/W) |
|---|---|---|---|
| 连续推理 | 2.4 | 6.8 | 0.35 |
| 空闲待机 | — | 3.1 | — |
这里有个细节要解释一下:能效是帧率除以平均功耗,单位FPS/W,意思是每瓦每秒能处理多少张图。这个指标比只说帧率或者只说功耗都更能反映“主动能效治理”的成效。
5.2 三轮优化后的数据对比
我按顺序做了三轮优化:先调调度,再上量化,最后做空闲态。每轮都在前一轮基础上叠加,最终数据如下:
| 优化阶段 | 帧率(FPS) | 平均功耗(W) | 能效(FPS/W) | 待机功耗(W) |
|---|---|---|---|---|
| 基线 | 2.4 | 6.8 | 0.35 | 3.1 |
| 调度绑核+实时优先级 | 3.4 | 6.5 | 0.52 | 3.0 |
| 加INT8量化+RVV后端 | 7.1 | 5.2 | 1.37 | 2.7 |
| 加空闲态+动态governor | 7.1 | 4.5 | 1.58 | 0.8 |
从2.4FPS到7.1FPS,帧率提升了大约两倍;待机功耗从3.1W降到0.8W;能效比从0.35FPS/W涨到1.58FPS/W,提升接近4.5倍。这个结果比我预想的要好,关键是每一层优化都在“做减法”:调度层减少了无效切换,量化层减少了访存量,空闲态减少了空转能量。
5.3 现场案例:一个后台线程让功耗翻倍
这轮优化过程中,最典型的排查案例是一个后台线程。当时我发现待机功耗在优化后依然偶发跳高,从0.8W跳到2.5W。一开始以为是中断问题,但抓了几次cpuload数据和trace,发现规律很清晰:每200毫秒左右,CPU会从idle状态被唤醒一次,像是某个周期性任务在执行。
后来用trace-cmd抓sched_wakeup事件,追到唤醒源是一个日志组件的flush线程。它每200ms醒一次,每次只跑不到1ms,平时看着CPU占用率很低,但它把CPU从深睡状态硬拉起来,每次都付一次唤醒功耗和升频功耗,累计下来待机功耗直接翻倍。
解决方案很简单:把flush周期从200ms放宽到2秒,同时对日志线程做cpuset隔离,让它只在小核上跑。就这么一处改动,待机功耗又降了1.4W。这类问题不看trace是根本不可能定位到的,只看top会以为CPU很闲,实际能量全耗在“醒来—干活—再睡”的路上了。
我个人在这轮优化中最深的体会是:端侧推理的能效问题,从来不是某一个环节的锅,而是调度、模型、空闲态三个层面共同作用的结果。只调模型不调调度,帧率上去了功耗下不来;只调度不量化,功耗降了性能还在原地;只抠空闲态,待机是省了,但一来推理任务照样卡顿。只有把这三级都打通,才能真正实现从被动节流到主动能效治理的转变。
另外多说一句,类似的方法不只能用在RISC-V上。只要你手里的平台是异构多核、资源受限、还要跑AI推理,这套“先量化减负、再调度分活、最后管空闲”的思路都可以直接搬过去。区别只是指令集和工具链不同,干活的路数是一样的。