光流这个话题,我之前一直觉得是“算法难、工程更难”的典型代表。直到一个视频实时处理需求摆到面前,CPU上的密集光流一帧算出来要一两百毫秒,所谓实时直接成了笑话。硬着头皮用CUDA把光流重写了一遍,才算彻底扭转局面。这篇东西就把我从原理认知到CUDA实现、再到性能调优和踩坑的完整过程写出来,给也在折腾GPU CUDA optical flow的朋友做个参考。
1. 光流计算为什么绕不开GPU
1.1 应用场景的实时压力
光流这个词听起来很学术,但实际应用已经非常普及:视频插帧、运动目标跟踪、行为识别、视觉里程计、三维重建,底层几乎都会用到光流。它输出的是一张矢量场,给每个像素(或每个特征点)一个运动方向和速度,说白了就是描述“这一帧里物体往哪儿跑了”。
问题在于,很多项目在原型验证阶段用的是CPU版本的OpenCV光流算法,跑个标清视频、分辨率低一点还能凑合,一旦切到1080p或者更高的采集源,性能立刻崩。我做的那次需求就是实时视频流处理,CPU版本跑起来只有几帧每秒,偶尔性能抖动还会丢帧。那时候我就意识到,要在工程里真正用上光流,计算瓶颈必须解决,而GPU几乎是唯一现实的选择。
1.2 从像素数量到浮点运算量
先算一笔账。1080p的分辨率是1920×1080,大约207万个像素。密集光流算法要对每个像素做梯度计算、卷积、多项式拟合、迭代求解,保守估计每个像素也要几百次浮点运算,一帧就是几亿到几十亿次操作。想达到30fps,每秒的浮点运算量就得奔着百亿以上去,这还不算金字塔多分辨率带来的重复计算。
CPU虽然也有多个核心,但一张消费级主板的CPU,满打满算几十个线程,单个核心还要同时支撑采集、显示、编解码这些活,根本吃不消。GPU那边呢,随便一块中端显卡就有几千个流处理器,单精度算力比CPU高一个数量级以上。光流这种“每个像素独立算、只有局部邻域依赖”的任务,正好是GPU最擅长的大规模并行形态。
1.3 图像本身就是天然网格
我刚开始学CUDA的时候,总被各种抽象概念绕晕,后来想明白一个事:图像本质上就是一个二维数组。你给图像划分网格,每个线程处理一个或者几个像素,再把卷积、邻域求和这类操作通过共享内存处理好,大多数图像算法的并行化骨架就出来了。
光流算法比单纯的像素级操作复杂一些,但大框架没变。每个像素的计算主体是独立的,唯一的依赖是它周围一个窗口内的邻居数据,而这种局部性通过CUDA的块划分和共享内存机制解决起来非常成熟。理解了这一层,再看CUDA光流实现就不会觉得玄乎了。
2. 算法选型:先决定稀疏还是密集再谈CUDA优化
2.1 三类主流光流方案的对比
不是所有光流都要用同一种算法实现。动手写CUDA之前,得先明确你的业务到底需要哪种光流,不同方案的并行化方法和优化重心完全不同。
| 算法方案 | 输出结果 | 核心思路 | 计算量 | 适用场景 |
|---|---|---|---|---|
| Lucas-Kanade稀疏光流 | 少数特征点的运动矢量 | 局部窗口灰度恒定假设,最小二乘求解 | 小 | 特征点跟踪、视觉SLAM前端 |
| Farneback密集光流 | 全图每个像素的运动矢量 | 局部多项式展开,对系数场做全局匹配 | 中 | 视频插帧、运动分割、稠密重建 |
| 基于深度学习的光流 | 全图每个像素的运动矢量 | 神经网络端到端回归 | 大 | 高精度离线处理、专用推理卡部署 |
稀疏光流的特点是快,但它只回答“我关心的那些点去哪儿了”,适合跟特征提取配合使用。密集光流输出的是完整的运动场,视觉上更好用,也能直接作为其他算法的输入。深度学习方案精度高,但工程依赖大,推理框架、模型转换、硬件选型一套弄下来很麻烦,而且还有功耗和显存的问题。
2.2 我为什么选择Farneback
我当时的需求需要全图的运动信息,所以稀疏光流直接排除;深度学习方案在接入成本和部署环境上又太重,于是只剩下Farneback。
选择Farneback还有一个很实际的原因:OpenCV里有一套非常成熟的CPU实现,而且参数调节的经验在网上也很多。这意味着我先用CPU版本验证算法效果和参数,再对着同样的逻辑去写CUDA,每一步都能对照验证结果。这对于调试CUDA内核来说太重要了,不然你很难判断是算法理解错了还是CUDA写错了。
另外,Farneback的算法结构对GPU是友好的:每个像素的多项式展开是独立的,空间平滑是卷积操作,最后的位移求解是两两独立的线性方程组,这些天然适合大规模并行。
2.3 金字塔策略是大位移的关键
光流算法的前提假设是“像素亮度随时间保持不变”,但这个假设只在位移比较小的时候近似成立。如果物体运动速度快,帧间位移大,像素的邻域结构早就变了,直接算光流必然出鬼影。
解决办法是金字塔。先把原图逐级缩小,在最粗的尺度上算一次光流,此时原本的大位移被缩放成小位移,满足假设条件。然后用这个结果作为下一层精细尺度的初始值,逐层细化,最终恢复到原始分辨率。这也是OpenCV的Farneback接口里levels和pyr_scale这两个参数的作用。
CUDA实现金字塔光流时要特别注意内存和kernel启动的管理。每一层金字塔都是一个独立的图像尺寸,需要单独分配显存,每一层又要跑一遍从多项式展开到迭代求解的完整流水线。这种多层、多kernel的结构对GPU调度有影响,后续优化时值得专门处理。
3. Farneback的CUDA内核是如何一个模块一个模块搭出来的
3.1 先规划好数据在GPU上的流转
很多人写CUDA光流一上来就写kernel,写到一半发现数据布局不对,返工成本极高。我的建议是先画出完整的数据流图。
Farneback的主流程大致是:灰度图输入之后,先做金字塔下采样;每一层上做多项式展开,得到局部多项式系数;再做空间平滑;然后根据前后两帧的系数构建位移方程;最后迭代求解出光流场。整条链路涉及多个中间结果,每个中间结果在Global Memory里怎么排布、什么时候释放、哪些可以复用,这些都在写代码之前就要想清楚。
比如多项式展开后的系数实际是若干个浮点数组,有的实现会单独拆分出A矩阵系数和b向量系数,有的则打包成多通道纹理。我倾向于用结构体数组而不是数组结构体,虽然GPU对结构体访问有对齐要求,但光流计算中同一个像素的系数往往要一起读,打包在一起反而能减少内存访问次数。
3.2 从核心流程拆解kernel设计
Farneback的CUDA实现可以拆成几个阶段,每个阶段对应一个或一组kernel:
阶段一是金字塔构建。这一步可以用双线性插值实现下采样,CUDA的纹理采样硬件自带插值功能,效率非常高。
阶段二是多项式展开。对每个像素,利用它周围一个窗口内的像素做加权多项式拟合,得到二次多项式的系数。实际计算中,这个环节等价于用一个预设计的高斯卷积核对邻域做卷积,然后组合出系数。由于窗口不大,常见的做法是把窗口数据先搬进共享内存,避免每个线程反复从全局内存读取邻居。
阶段三是根据两个多项式系数场求位移。这段逻辑是Farneback的数学核心:把第一帧的系数和第二帧经过位移后的系数对齐,构建一个线性方程组并求解。这个方程组对每个像素是独立的,每个线程解一个2×2的小方程组,并行度直接拉满。
阶段四是迭代细化。把当前的光流估计作为偏移,把第二帧重新采样到第一帧坐标系下,再重复构建方程和求解,迭代几次让结果收敛。
每个阶段之间用kernel边界分隔,好处是逻辑清晰,坏处是中间结果要写回Global Memory再读取。如果追求极致性能,可以把相邻阶段合并成fused kernel,但这是后期的优化方向,不建议一开始就做。
3.3 一个典型kernel的线程映射逻辑
下面用一个简化的多项式展开kernel示意线程映射方式,关键点在于block内部的邻域共享:
__global__ void polyExpansionKernel( const float* gray, float* coeffB, int width, int height) { // 每个线程处理一个像素 int x = blockIdx.x * blockDim.x + threadIdx.x; int y = blockIdx.y * blockDim.y + threadIdx.y; if (x >= width || y >= height) return; // 邻域半径 const int RADIUS = 2; float sum = 0.0f; // 实际的加权系数由预计算的高斯核决定 // 这里示意累加窗口内的灰度值 for (int dy = -RADIUS; dy <= RADIUS; dy++) { for (int dx = -RADIUS; dx <= RADIUS; dx++) { int nx = x + dx; int ny = y + dy; if (nx >= 0 && nx < width && ny >= 0 && ny < height) { sum += gray[ny * width + nx]; } } } coeffB[y * width + x] = sum; }这个代码为了演示做了大量简化,实际的高斯加权系数和组合逻辑会复杂不少,但线程映射的基本思路就是这样:block内部覆盖一个图像块,每个线程负责其中的一个像素。对于真正的实现,我建议把邻域数据先加载到共享内存,如下面这样。
3.4 共享内存和边界处理的落地
共享内存是CUDA光流里最值钱的优化手段之一。它的原理类似于CPU的L1缓存,但由程序员显式控制。对于卷积和窗口求和这类操作,每个线程都要读取邻居像素,如果直接从Global Memory读,很多数据会被重复读取,导致带宽浪费严重。用共享内存先把整个block覆盖的矩形区域连同边缘(halo区)加载进来,之后所有线程都从共享内存取数,速度能差好几倍。
设block大小为16×16,窗口半径为r,那么共享内存数组需要申请(16+2r)×(16+2r)的大小,边界上的数据需要额外加载。加载的时候可以逐行处理,也可以让线程协同完成,一般用循环让每个线程多加载几个点。
边界处理是另一个容易踩坑的地方。图像顶、底、左、右边缘的像素,窗口会超出图像范围,如果不做处理,内存越界会直接导致程序崩溃或者给出错误结果。常见做法有三种:补零、复制边缘、镜像翻转。我建议在光流算法里用复制边缘或者镜像,因为补零会在边缘引入虚假的梯度,影响光流结果。CUDA的纹理采样也能帮上忙,设置好寻址模式后,越界坐标会被硬件自动处理,省掉一堆条件分支。
4. 调优实录:从勉强实时到性能翻倍的几个关键动作
4.1 先判断是带宽受限还是计算受限
代码能跑通之后,下一步就是调性能。但调优的第一步不是盲目改代码,而是用profiler确认瓶颈。我一般会看两个指标:Global Memory的吞吐量和计算单元的占用率。如果内存吞吐顶到了上限,说明带宽受限;如果计算单元忙不过来,则是计算受限。对策完全不同。
我第一次写出来的CUDA Farneback大概在20毫秒左右一帧,1080p勉强能跑。profile之后发现,大部分时间花在了反复读写Global Memory上,尤其是构建位移方程时,每个像素都要读取多个系数数组,每个数组都存在于Global Memory。这说明带宽是主要矛盾。
带宽受限的解法很直接:减少Global Memory访问次数,把数据复用搬到共享内存里;尽量让线程访问的地址连续,触发合并访问,确保一次内存事务能带出更多有用数据。经过这两步优化,单帧时间直接降到了10毫秒左右。
4.2 占用率与寄存器压力的平衡
另一个常见问题是内核占用了太多寄存器,导致GPU无法在同一个流处理器上驻留足够多的线程块,也就是Occupancy太低。光流kernel里如果每个线程要保存一堆中间系数,很容易把寄存器用满。
解决办法有两个。一是在kernel前用__launch_bounds__声明线程数和寄存器上限,强制编译器做取舍。二是调整block尺寸,找到当前数据规模下最合适的组合。我通常的做法是调一组对比实验:block从8×8、16×16到32×8挨个试,同时用profiler看Occupancy和实际运行时间。实测下来16×16在我的场景里较稳,但换一个算法阶段可能又要重新试,这玩意没有一劳永逸的答案。
4.3 优化前后的实测对比
给一个我环境下的参考数据,分辨率1080p,某中端消费级显卡,Farneback经典参数组合。每个平台结果会有浮动,但优化趋势是类似的。
| 版本 | 单帧耗时 | 每秒帧数 | 备注 |
|---|---|---|---|
| 多线程CPU版本 | 约90ms | 约11fps | 已用OpenMP并行 |
| 朴素CUDA版本 | 约22ms | 约45fps | 每个阶段独立kernel,无共享内存优化 |
| 共享内存优化版本 | 约11ms | 约90fps | 窗口邻域数据复用 |
| 合并访问 + 纹理采样版本 | 约6ms | 约160fps | 金字塔采样和方程构建阶段均优化 |
从90毫秒优化到6毫秒,光这个数据对比就足够说明,CUDA光流的性能空间非常大。后续我还做了CUDA流并行,把金字塔的不同层放到不同流上执行,虽然因为层间依赖效果有限,但在设备支持的情况下依然有收益。
5. 工程落地时最容易翻车的几个地方
5.1 NaN是怎么冒出来的
CUDA光流写完之后,我遇到过一个诡异问题:大部分区域的光流看起来正常,但某些像素位置会突然冒出一个极大的异常值,导致后处理结果全花。排了半天才发现是NaN。
根源在于位移方程的求解过程涉及除法,当局部窗口内的结构张量接近奇异矩阵(比如纯色的墙面、无纹理的区域)时,最小二乘解的分母趋近于零,结果就会溢出成NaN。这个问题在CPU版本里也存在,但CPU上因为工程库有默认保护,没暴露得那么明显;在GPU上数据一多,全面爆发。
解决方案分两层:一是在求解kernel里检测分母的绝对值,小于阈值就直接判该像素为无效光流,置零并标记;二是后处理阶段用中值滤波把无效点修复一遍。千万别指望算法自己在所有场景都稳定,工程上必须有兜底。
5.2 与OpenCV的CUDA实现对比
如果你现在问我,业务集成时到底该不该自己写CUDA光流,我的回答是看情况。
OpenCV自带的CUDA光流模块用起来确实省心,几行代码就能拿到结果,而且在新设备上通常已经做了一定的底层优化。如果项目交付时间紧、算法不需要深度定制,我强烈建议直接用OpenCV的现成实现。自己做的问题在于后续维护成本高,而且CUDA代码一换显卡平台就可能需要重新调优。
但如果你的场景比较特殊,比如光流只是某个大pipeline里的一环,需要跟自研的预处理、深度学习推理融合,或者你需要控制中间结果、做显存复用,那自己写反而更灵活。我这次自己实现,图的就是能把光流计算嵌进统一的GPU内存管理里,避免反复做Host与Device之间的内存拷贝。
5.3 调试与性能定位的工具链
最后说下工具。纯靠printf调试CUDA代码是灾难,我用的最多的组合是:cuda-gdb负责查逻辑错误,官方性能分析工具看内核耗时、带宽、Occupancy这些指标。显示端出现问题的时候,先把数据拷回主机用Python可视化,分别看x方向和y方向的光流图,一眼就能找出异常区域。
还有一个容易被忽略的坑:kernel启动本身是有开销的。如果你的光流流程里有十几个kernel,每个kernel运行时间又都很短,那启动开销占比会变得很离谱。这时候可以考虑kernel融合,把不太大的中间步骤合并成一个kernel,或者用CUDA Graphs把整条指令流预录制好,减少启动次数。
做了这一轮CUDA光流实现,我自己最大的感受是:难的不是把公式翻译成CUDA代码,而是让数据流动的方式贴合GPU的硬件特性。每个像素的计算逻辑用伪代码半天就能写完,但如何让几千个线程高效协作、让邻域数据少读几次,才是决定最终性能的地方。如果你正准备上手这类项目,建议先拿Farneback做一次完整的CUDA实现流程,把金字塔、共享内存、纹理采样、profiler调优都过一遍,之后再碰更复杂的方案都会从容很多。