4、协同优化策略:CPU与GPU负载均衡、DMA传输优化、缓存预取

双缓冲机制本身并不复杂,说白了就是两个缓冲区轮流干活。但真正让系统跑起来、跑得稳,靠的是CPU、GPU、DMA和缓存这几个模块之间的配合。我见过不少项目,双缓冲架构画得漂漂亮亮,一上真机就掉帧——问题往往就出在协同策略上。

这一章,咱们就聊聊怎么让这些硬件单元协同工作。我会结合自己踩过的坑,把负载均衡、DMA传输和缓存预取这三个优化点讲透。

4.1 CPU与GPU的负载均衡

触摸屏的响应链路里,CPU负责解析触摸事件、更新UI状态、提交绘制命令;GPU负责真正的像素渲染。如果CPU跑得太快,GPU跟不上,缓冲区就会堆积;反过来GPU太快,CPU又可能喂不饱它。

我习惯把这种关系比作「流水线上的两个工位」。CPU是上游,GPU是下游。上游不能一股脑往下塞,下游也不能干等着。

4.1.1 帧同步与异步提交

最常见的做法是垂直同步(VSync)。CPU在VSync信号到来时才开始准备下一帧的数据。这样做的好处是节奏统一,但坏处也很明显——如果CPU这一帧的工作量太大,就会错过VSync,导致掉帧。

我在一个车载仪表盘项目里遇到过这个问题。仪表盘的刷新率要求60fps,但CPU偶尔要处理复杂的导航动画,一帧耗时超过16ms,直接导致画面卡顿。后来我们改成了异步提交:CPU不管VSync,只管把绘制命令塞进一个环形缓冲区,GPU自己按节奏取走执行。

关键思路:异步提交的核心是解耦。CPU和GPU各自维护一个指针,CPU写指针,GPU读指针。只要缓冲区不溢出,两者就能独立工作。

4.1.2 负载感知的动态分配

光异步还不够。如果CPU持续超负荷,缓冲区迟早会满。这时候需要引入负载感知机制。

我常用的方法是:在驱动层统计每一帧CPU和GPU的耗时,然后动态调整CPU的预处理粒度。举个例子:

  • 如果CPU耗时 > GPU耗时,说明CPU是瓶颈。这时候我会减少CPU端的触摸事件预处理,把部分计算推迟到GPU的片段着色器里做。
  • 如果GPU耗时 > CPU耗时,说明GPU是瓶颈。这时候我会让CPU提前做一些像素格式转换或裁剪计算,减轻GPU负担。

一个小技巧:在CPU端维护一个滑动窗口,记录最近10帧的耗时数据。用这个数据做决策,比单帧判断稳定得多。我曾经用这个办法把掉帧率从5%降到了0.3%。

4.2 DMA传输优化

双缓冲里最耗时的操作之一,就是把数据从CPU内存搬到GPU显存。如果每次都用CPU来拷贝,不仅占用CPU时间,还会污染CPU缓存。这时候就该DMA出场了。

4.2.1 为什么DMA比CPU拷贝快

DMA传输是硬件级别的。它不经过CPU核心,直接通过总线在内存和设备之间搬数据。你想想看,CPU拷贝数据时,要经历「读缓存→写缓存→写内存」的路径,而DMA只需要「读内存→写内存」一步到位。

传输方式 CPU占用率 缓存污染 传输延迟
CPU拷贝 高(100%占用) 严重 中等
DMA传输 低(仅初始化) 无 低

4.2.2 链式DMA与描述符

单次DMA传输只能搬一块连续内存。但双缓冲场景下,数据往往分散在不同的内存区域——比如触摸坐标数据、颜色查找表、像素数据。如果每次都要启动一次DMA,开销也不小。

我推荐用链式DMA。它允许你准备一个描述符链表,每个描述符指向一块内存区域。DMA控制器会自动遍历这个链表,依次传输所有数据。

// 链式DMA描述符结构示例
struct dma_descriptor {
    uint32_t src_addr;      // 源地址
    uint32_t dst_addr;      // 目标地址
    uint32_t length;        // 传输长度
    struct dma_descriptor *next;  // 下一个描述符
    uint32_t flags;         // 控制标志
};

// 初始化三个描述符,分别传输触摸数据、调色板、像素
struct dma_descriptor desc[3];
desc[0] = {touch_buf, gpu_touch_buf, 64, &desc[1], DMA_FLAG_INTERRUPT};
desc[1] = {palette, gpu_palette, 256, &desc[2], 0};
desc[2] = {pixel_buf, gpu_pixel_buf, 1024*600*4, NULL, DMA_FLAG_LAST};

注意:描述符链表必须放在非缓存的内存区域(比如DMA专用的SRAM或NC区域)。否则CPU和DMA之间会出现缓存一致性问题。我曾经因为这个bug排查了整整两天,最后发现是描述符被CPU缓存了,DMA读到的却是旧数据。

4.3 缓存预取

缓存预取是个容易被忽视的优化点。很多人觉得缓存是硬件自动管理的,软件不用操心。其实不然——在双缓冲场景下,预取策略能直接影响帧率。

4.3.1 预取时机

什么时候预取?我的经验是:在GPU处理当前帧的时候,CPU就应该开始预取下一帧的数据。这个时机点很关键——太早预取会占用缓存空间,影响当前帧的性能;太晚预取又来不及。

我习惯的做法是:在CPU提交完当前帧的绘制命令后,立即触发一个DMA预取操作,把下一帧需要用到的纹理和顶点数据搬到GPU附近的缓存里。这样当GPU开始处理下一帧时,数据已经在「家门口」了。

4.3.2 预取粒度

预取不是越多越好。你想想看,如果预取的数据量太大,会把缓存里其他有用的数据挤出去。这就是所谓的「缓存污染」。

我一般遵循两个原则:

  • 按行预取:触摸屏的像素数据通常是按行存储的。预取时只预取当前帧需要的那几行,而不是整个缓冲区。
  • 按需预取:只预取那些访问模式可预测的数据。比如触摸轨迹的预测点、动画插值的中间帧。

实战经验:在Cortex-A72平台上,我做过一个对比测试。不预取时,每帧的缓存缺失率是12%;按行预取后,缺失率降到了4%。帧率从55fps提升到了59fps。别小看这几帧,在触摸屏上,59fps和55fps的手感差别很明显。

4.4 协同策略的整体流程

说了这么多,咱们把这三个策略串起来,看看一个完整的协同流程长什么样。

双缓冲协同优化流程 CPU解析触摸事件 负载均衡决策 DMA链式传输 缓存预取启动 GPU处理当前帧 GPU处理下一帧 关键协同点 1. CPU解析完事件后,先做负载均衡决策 2. 决策结果决定:CPU是否要分担GPU计算 3. DMA链式传输在CPU提交命令后立即启动 4. 缓存预取与GPU处理当前帧并行执行

这个流程里,最核心的一点是「并行」。CPU、DMA、GPU三者尽量同时干活,谁也别闲着。我见过很多新手工程师,把这三个步骤做成串行的——先CPU处理,再DMA传输,最后GPU渲染。这样做虽然简单,但性能至少损失30%。

总结一下:协同优化的本质就是「让每个硬件单元都在最合适的时间做最合适的事」。负载均衡解决的是「谁来做」的问题,DMA解决的是「怎么搬」的问题,缓存预取解决的是「什么时候准备」的问题。三者缺一不可。