简介:面向Android平台高通相机CamX架构开发者,这份资源展示了HVX node在Hexagon DSP上的算法设计思路,适用于相机图像处理、算法加速与驱动开发场景。HVX node利用向量化计算加速图像识别、增强等复杂任务,同时兼顾移动设备功耗与实时性要求,资源围绕实际节点实现展开,可作为从零理解CamX模块化流程的入门参考。压缩包共10个文件,包含4个so动态库、2个cpp源文件、2个mk构建脚本和2个txt说明文档,整体仅16KB,结构精简;so库提供可加载的DSP执行模块,cpp展示算法逻辑,mk描述构建链路,txt给出设计说明。已有119人学习下载。文件中既有HVX binning、addconstant等典型处理节点的源码示例,也有对应构建配置与说明,方便开发者在sm8150、sm7150等平台上快速编译、移植和调试,掌握算法与硬件交互的关键细节。 这个标题看着挺唬人,但说白了就是一件事:在高通平台上,把相机算法搬进Hexagon DSP里,用HVX向量扩展去算,而不是丢给CPU或GPU硬扛。CamX这套架构早年刚推出来的时候,不少人还在用老一套的mm-camera思路去套,结果被node graph、port buffer这些概念绕得晕头转向。等真正上手做HVX node,又发现这玩意儿跟普通的CPU node完全不是一个玩法——你既要懂CamX的框架,还得摸清HVX的脾气,中间隔着一条很宽的沟。
这篇东西我就按自己实际趟过的路来聊,从CamX架构里HVX node到底是个什么位置开始,讲到HVX算法的设计思路、node代码骨架怎么搭、buffer怎么接,最后把调试阶段踩过的坑和排查手段一并倒出来。适合正在做高通平台camera算法集成、camera HAL开发,或者准备从CPU node转向DSP node的兄弟们参考,不用从头啃几千页文档,跟着这条线走能少走不少弯路。
1. CamX架构与HVX node的整体定位
1.1 CamX不是又一个HAL,而是一张图
高通从骁龙845时代开始在旗舰平台逐步从mm-camera向CamX(Camera eXtension)迁移,到现在新平台基本全面切换。CamX和旧架构最大的差异在于:它把整个拍照/预览流程抽象成了一张节点图(Node Graph),从sensor出帧、IFE处理、IPE处理、统计信息回传,到最终送给HAL的buffer,每个环节都是一个Node,节点之间通过buffer连接,由CamX核心统一调度。
这张图的好处是扩展性极强。OEM想加一个自研算法,不用去改高通闭源的那一坨,只要自定义一个Node,挂到图上合适的位置,数据流就自然接进来了。HVX node就是这种扩展机制的典型产物——它也是一个Node,只不过它的执行体不在CPU上,而是在DSP上。
很多刚从旧架构转过来的工程师容易有一个误区:觉得CamX只是把Pipeline换了个名字。其实不然。CamX里每一个Node都有明确的生命周期管理、buffer Negotiation机制、dependency驱动机制,Node之间是并行触发的关系,而不是旧的“前一Stage干完再叫后一Stage”的串行模型。理解了这条,后面设计HVX node时才能抓住调度上的核心逻辑。
1.2 HVX node在pipeline里的位置和职责
HVX node在CamX pipeline里的位置并没有硬性标准,理论上你可以把它插在任何需要跑自研算法的地方——比如放在IFE和IPE之间做实时增强,也可以放在stats path上做3A相关的辅助计算。但实际工程中,HVX node最常挂的位置是两条:一条是preview/record主链路上,做画质增强类算法;另一条是offline processing链路上,做多帧融合、HDR、降噪这类计算量较大的算法。
如果从数据流的角度看,HVX node的输入输出都是ImageBuffer,它的职责可以概括成三件事:接收上游回传的图像数据、把数据搬运到DSP可访问的内存、执行HVX算法写回输出。听上去简单,但正是“搬运到DSP可访问的内存”这步,让HVX node跟普通CPU node有了本质区别,需要在设计Buffer策略时格外小心。
1.3 为什么非要用HVX而不是CPU或GPU
这个问题几乎每次评审都会被问。我的回答一般分三层:
第一,功耗。DSP做向量运算的能效比远高于CPU,甚至比GPU更划算,因为DSP不需要维护一大堆上下文,数据流更线性。相机是手机上耗电大户,一开相机DSP就得一直转,用CPU扛一会儿发热马上就上来,而HVX做同样的运算,功耗往往只有CPU的一半甚至更低。
第二,延迟与调度确定性。GPU虽然算得快,但在相机这种硬实时链路上并不友好——GPU任务的提交、排队、抢占都是黑盒,帧延迟抖动大。HVX node走的是DSP上的实时线程,配合CamX的dependency调度,延迟可控得多。
第三,与ISP的亲和性。高通ISP(IFE/IPE)的数据本身就在DSP附近流转,HVX能直接访问到这些buffer的物理地址,省掉了CPU搬运这一大块耗时。很多算法在CPU上跑不快,瓶颈根本不是算力,而是内存拷贝,HVX天然避开了这个问题。
2. HVX算法设计的底层逻辑与内存模型
2.1 Hexagon DSP的算力边界
做HVX node设计前,必须先把Hexagon DSP的硬件底牌摸清楚。不同平台的Hexagon版本不同,但共同点是:HVX是128字节/周期的SIMD向量引擎。拿骁龙865的Hexagon 698来说,HVX每个周期能做128字节的向量操作,主频大概在1GHz上下,理论峰值算力超过1 TOPS。但峰值只是宣传数字,实战中你几乎不可能跑满,因为数据要从DDR喂进来,带宽和访存模式往往是真正的瓶颈。
HVX的指令集核心是向量load/store和向量运算,数据处理模式高度规则——非常适合图像这种天然的二维数组结构。如果你要做的算法是逐像素处理、局部窗口滤波、帧间对比之类,HVX能发挥得非常好;但如果算法里有大量分支跳转、稀疏访问、动态索引,HVX的优势就会大打折扣,这时候要重新考虑算法是否适合往DSP上搬。
2.2 VTCM、L2、DDR,三级内存怎么配合
HVX的内存层次是设计算法时最容易踩坑的地方,很多新手把DSP当成一个大号CPU来写,结果性能惨不忍睹。HVX可访问的内存主要有三块:
- VTCM(Vector Tightly Coupled Memory):挂在HVX旁边的紧耦合内存,带宽极高,延迟极低,但容量很小,通常只有几百KB到1MB级别。
- L2 Cache:DSP的二级缓存,容量几MB,带宽也不错,适合放中等大小的中间数据。
- DDR:系统主存,容量大但带宽有限,HVX访问DDR要走系统总线,延迟和带宽都不如前两者。
实际算法设计里,用得最多的策略是“DDR→VTCM”的分块搬运。比如一个1080p的降噪算法,输入帧一整个搬进VTCM肯定放不下,那就按行或者按块切,比如每次处理64行,等HVX处理完这一块,再搬运下一块。VTCM就像厨房的案板,菜都切好放案板上,炒菜(HVX计算)才快;DDR是冰箱,一次性把整头猪(整帧)拿进厨房不现实,只能分批拿。
2.3 数据布局与向量化改造
HVX的向量宽度是128字节,换算下来一次性可以处理128个8-bit像素,或者64个16-bit像素。设计算法时,尽量让内层循环处理的数据宽度跟128字节对齐。所以输入图像每个row的stride必须做对齐处理——比如一个1920宽度的图像,每行字节数是1920,不满足128对齐,就得做padding,补到2048或者按HVX偏好对到1024的倍数,不然处理时会因为跨行访问损失大量性能。
另一个常见的性能杀手是“非连续访问”。比如做图像缩放时,源图像的行跳跃式读取,这种访存模式对HVX很不友好。解决办法是先用HVX做个快速的“重排”操作,把需要的数据先打包到一块连续buffer里,再交给主算法处理。这步看起来多了一次拷贝,实测下来往往比直接乱序访问快不少。
2.4 多context并行与2x模式
现代高通平台上的Hexagon DSP不止一个HVX context。以骁龙8系列为例,DSP上可以同时跑多个HVX线程,如果算法支持数据并行,可以把一帧图像在行方向切分成多个slice,交给不同的HVX context并行处理。
但并行不是免费的。多个context同时访问DDR时,带宽争抢会很厉害,反而可能拖慢整体。我在实际项目中遇到过一个情况:开了双context并行处理降噪算法,帧率没提升,反而DDR带宽打满,导致IFE的帧也受牵连掉帧。后来把输入帧先各自搬到自己的VTCM分区,计算完成后再合并写回,带宽压力才缓解。所以要不要开2x,必须先估算好访存量,别一上来就无脑开满。
3. HVX node的CamX端设计与代码骨架
3.1 Node的创建与生命周期
在CamX里写一个HVX node,本质上是实现一个继承自Node基类的子类。高通在chi-cdk里提供了node扩展机制,你可以在vendor的chi-cdk目录下新增一个node模块,通过usecase XML配置把它挂到pipeline上。
Node的核心生命周期回调包括:
Initialize:初始化node的buffer池、内部资源,读取override settings。CreateBufferRequest/SetBufferDependency:声明这个node需要哪些输入输出buffer。Execute:真正的算力所在地,每帧都会被调用。ExecuteProcessRequest的内部流程:从Request里取输入输出buffer,触发DSP任务,等待完成,再返回。
一个比较标准的HVX node的Execute流程是:先通过GetInputBuffer拿到输入buffer的fd和plane信息,再通过GetOutputBuffer拿到输出buffer,然后调用DSP端封装好的HVX算法入口,把输入输出buffer的物理地址传进去,最后等待HVX任务完成。这里有个关键点:如果把buffer地址直接传给DSP,那必须确保这块内存是从DSP可访问的heap(也就是csl_allocateBuffer之类的接口)分配的,普通ION buffer如果没有做映射,DSP端会直接access fault。
3.2 Buffer Negotiation与dependency设置
Buffer negotiation是CamX里比较烦但又绕不开的一步。写HVX node时,你必须在CreateBufferRequest阶段告诉框架你需要什么样的输入输出格式。比如输入是NV12还是P010,宽高是多少,stride是多少,几个plane。如果上下游格式要求不一致,node内部还要做好格式转换,但这个转换在HVX node里尽量别做——格式转换本身就用HVX能解决,但要另起一个内部buffer,成本不低。
依赖机制上,HVX node通常要设置两类依赖:一个是buffer依赖,框架会保证输入buffer可读时才会触发你的Execute;另一个是事件依赖,比如某些统计信息(AE/AWB结果)需要先从stats node拿到,HVX node才能跑动态调参算法。这两类依赖都需要提前在SetBufferDependency和SetDependency里声明清楚,不然帧调度会乱掉。
3.3 在Node里调用HVX算法
CamX里调用HVX算法有两条路。
一条是直接通过FastRPC调用DSP侧的so库。FastRPC是SDK提供的跨CPU调用DSP的标准通道,你先把HVX算法编译成DSP上的so,然后CPU侧通过FastRPC接口调用。这条路的好处是分库清晰,算法改动不影响CamX侧,适合算法和框架由不同团队维护的情况。
另一条是借助Halide runtime。高通在CamX里集成了Halide运行时,你可以把HVX算法写成Halide的func,由Halide编译器生成HVX target的机器码,运行时直接加载。这套方案在代码可维护性上更舒服,尤其算法里有复杂的数据流变换,手写HVX intrinsics能写到怀疑人生,Halide能省不少事。
我个人建议:如果团队里有熟HVX intrinsics的人,且算法本身不算特别复杂,直接写FastRPC+DSP so更可控,调试也不容易被Halide runtime的封装干扰;如果算法迭代频繁、演算逻辑复杂,就上Halide,它生成的代码在处理边界条件时比手写稳得多。
一个最简单的HVX node核心代码骨架大致长这样:
#include "Node.h" #include "HvxNode.h" static const UINT HvxNodeOutputPortId = 0; static const UINT HvxNodeInputPortId = 1; CamxResult HvxNode::Initialize(const CreateNodeInfo* pCreateNodeInfo) { // 读取usecase里带的custom setting,比如算法强度、开关、分辨率参数 overrideSettings = ChiNodeGetOverrideSettings(pCreateNodeInfo->pNodeName); return CamxResultSuccess; } CamxResult HvxNode::CreateBufferRequest(BufferRequest* pBufferRequest) { // 申请两个port,一个输入一个输出 pBufferRequest[0].numBuffers = 1; pBufferRequest[0].portId = HvxNodeInputPortId; // 设置图像格式、宽高,框架会做格式匹配 pBufferRequest[0].pBufferProperties[0].imgFormat = ChiFormatNV12; pBufferRequest[0].pBufferProperties[0].width = m_width; pBufferRequest[0].pBufferProperties[0].height = m_height; return CamxResultSuccess; } CamxResult HvxNode::Execute(ExecuteRequestData* pExecuteRequestData) { // 拿输入输出buffer ImageBuffer* pInput = GetInputBuffer(HvxNodeInputPortId); ImageBuffer* pOutput = GetOutputBuffer(HvxNodeOutputPortId); // 通过FastRPC调用DSP上的HVX算法 hvx_alg_handle_t handle = hvx_alg_open(HVX_ALG_DENOISE); hvx_alg_set_param(handle, PARAM_STRENGTH, m_strength); hvx_alg_execute(handle, pInput->GetFd(), pOutput->GetFd(), m_width, m_height, stride); hvx_alg_close(handle); return CamxResultSuccess; }如果不需要在DSP上常驻context,可以每次Execute都打开、执行、关闭,但这会带来一定的FastRPC调用开销。一般来说,一帧只有一次调用,几十微秒的开销可以接受。但如果算法本身非常短、执行频率又高,就把handle在Initialize阶段常驻,避免反复open/close。
3.4 通过CHI override挂载node到Usecase
光有node类还不够,你得让CamX在创建pipeline时把你这个node加进去。这一步走的是CHI(Camera Hardware Interface)的override机制。在chi-cdk的usecase目录下,有一个XML描述文件定义了pipeline拓扑,你可以通过ChiNodeGroup和ChiNode标签把自己的node挂到想要的链路上。
比较典型的做法是:定义一个新的ChiNode,指定其NodeName,然后在Port配置里把它插到IFE和IPE之间。编译后,CamX core起pipeline时就会按XML拓扑创建node实例。如果你只是在现有usecase上插入一个节点,可以用override机制——高通在chi-cdk里支持用vendor override替换默认usecase配置,不用改动系统镜像里的原始文件,调试效率高很多。
4. 性能调优与常见问题排查实录
4.1 Buffer格式与对齐引发的血案
我最早调试HVX node时遇到的最莫名其妙的问题,是算法在PC模拟环境里跑得好好的,一到真机上输出图像边缘出现色偏。查了很久,最后发现是row stride对齐的问题:CamX给node的输入buffer stride是1920,而我的HVX算法内部分块时按1024对齐去读,导致每一行末尾都读到了下一行开头的数据,算出来的像素自然就花了。
这个问题如果早期能养成一个习惯就能避开——拿到buffer后先打印一下stride、plane offset,不要假设它就是width乘bytesPerPixel。很多CV工程师在PC上做原型时用的是opencv那种紧凑连续内存,到了嵌入式平台上一切都要重新对齐,这个认知转变越早越好。
4.2 VTCM不够用怎么办
做高分辨率算法时,VTCM不够用是必然遇到的事。以4K30的实时处理为例,一行4K的数据量已经不小,更别说算法如果还要保留多行缓存做垂直方向的滤波。
我的做法是把算法拆成两阶段:第一阶段做水平方向的滤波,在VTCM里按行块处理;第二阶段做垂直方向滤波,输出中间结果放到L2缓存里,等所有行块都处理完再做垂直合并。这样VTCM里同时只保留一个行块的数据和少量中间状态,完全够用。代价是两阶段之间多了一次L2读写,但L2的带宽远好于DDR,比一次性把整帧塞进VTCM的失败方案要强得多。
如果VTCM还是不够,就要考虑“行分块+多次调度”的拆法。比如每一帧被拆成4个slice,每个slice单独走一遍Execute流程,DSP上每次只处理一个slice。这种方式实现稍复杂,但能彻底缓解VTCM压力。关键是要控制好slice之间的重叠区域,避免垂直滤波在slice边界出现接缝。
4.3 DSP访问DDR带宽超限导致掉帧
HVX node接入pipeline后,如果发现相机帧率掉得厉害,而且掉帧规律跟你的Node执行时间对不上,多半是DDR带宽被你的DSP访问打爆了。
这里有个粗略的估算方式:一个1080p NV12帧的大小 = 1920 * 1080 * 3 / 2 ≈ 3.1MB。如果算法每帧要把输入读一遍、中间结果写一遍、最终输出写一遍,那总访存量大概在10MB左右。在30fps下,这就有300MB/s的额外带宽。如果再开多buffer、双context,带宽会翻倍。而平台给整个camera系统的DDR带宽预算通常是有限的,留给DSP的往往只有几百MB/s。一旦超过,不只是你这个node变慢,IFE、IPE的带宽也跟着受影响,整条pipeline一起掉帧。
排查方法很简单:DSP侧有PMU(Performance Monitoring Unit)可以统计HVX的执行周期和stall周期。如果stall周期占比很高,说明是等数据;如果HVX active周期很高但整帧耗时还是大,那就要看卡在哪段访存上。实测中我遇到过一次把一张输入图同时给两个算法分支用,代码里没注意复用buffer,导致DSP把同一份数据读了两遍,白白多了一倍访存。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 图像边缘偏色/噪声异常 | row stride对齐错误 | 检查buffer metadata里的stride,别假设等于width |
| DSP端access fault | 用了普通ION buffer未映射到DSP | 改用CSL分配的buffer,确认fd映射 |
| 帧率下降但不卡顿 | HVX node耗时较长 | PMU统计执行周期,检查是否有大量DDR访问 |
| 整个pipeline一起掉帧 | 带宽超限,拖累IFE/IPE | PMU看stall,估算总访存量 |
| node执行了但输出没变化 | 输出buffer没正确写给下游 | 检查port id、buffer state是否标记完整 |
| override setting读不到 | node name字符串不一致 | 核对CHI XML里的node name和代码里的字符串 |
| 相机启动变慢 | Initialize里做了太多准备 | 把耗时操作挪到第一次Execute时懒加载 |
4.5 调试工具链的一点经验
调试HVX node时,DSP侧打日志不能直接用CDK_LOG,那是CPU侧的。DSP侧要么用HAP_debug之类的接口把日志带回到CPU侧,要么直接挂QDSP(Qualcomm Debug System)用仿真器跑。
我平时的做法是:先在PC上用模拟器(Hexagon SDK带的模拟器)把算法逻辑验证好,纯算法问题绝不带真机。模拟器跑通了,再进真机调buffer和调度相关的问题。带上真机后,大部分时间其实花的不是算法本身,而是排查跨CPU/DSP边界的内存映射、同步、格式转换这类问题。
有一招很实用——做一个“直通模式”。在node里加一个开关,如果打开就直接把输入buffer拷到输出buffer,不跑任何算法。这样能快速判断问题是出在CamX的buffer调度上,还是出在DSP算法本身。以前排查一个偶发黑帧问题,花了三天最后发现是buffer时序问题,跟算法一点关系没有,直通模式一开早就能定位到方向。
5. 从项目整体复盘我个人印象最深的一点
回到HVX node设计这件事本身,我最想分享的一个经验是:不要把HVX node当成“把算法搬进DSP”这么简单。真正影响项目周期和最终效果的,往往是数据路径的规划——buffer怎么分配、怎么对齐、怎么流转,这些在动手写第一行算法代码之前就应该确定下来。算法本身有缺陷可以迭代优化,但数据路径设计错了,代价是大面积返工。
另外一个容易被低估的点是团队协作边界。HVX node开发通常横跨HAL工程师、算法工程师和DSP工程师三个角色,彼此对“接口长什么样”的理解很容易有偏差。我在项目里习惯在启动阶段就定义一份“node接口文档”,里面写清楚输入输出的图片格式、stride要求、buffer归属、调用周期、是否需要常驻context、DSP侧so的导出函数签名。这份文档一开始只有半页纸,后面随着问题出现逐步补充,成了整个项目最重要的沟通工具之一。
最后再分享一个实战小技巧:新平台的HVX规格和旧平台差异不小,迁移HVX node时不要只看编译能不能过,一定要把PMU的cycle、stall、VTCM miss等指标跑一遍对比。有时候同样的算法在旧平台性能很好,新平台因为内存层次变化、DDR带宽不同,性能会突然掉一半。带着指标去迁移,而不是“跑起来就行”,能帮你省掉后续大量返工的时间。
本文还有配套的精品资源,点击获取