前言
在嵌入式系统飞速发展的今天,GPU 早已不再仅仅是"游戏显卡"的代名词。从智能手机的流畅 UI 渲染,到车载娱乐系统的 3D 导航,从边缘设备的 AI 推理加速,到 AR/VR 设备的实时渲染,GPU 已经成为现代嵌入式 SoC 中不可或缺的核心组件。而在这个领域,ARM Mali GPU 无疑是占据统治地位的存在——全球超过 70% 的 Android 设备都搭载了 Mali GPU,从入门级的 Mali-G52 到旗舰级的 Mali-G720,Mali 架构覆盖了从低端到高端的完整产品线。
然而,尽管 Mali GPU 如此普及,真正深入理解其架构原理的开发者却并不多。大多数嵌入式工程师习惯于 CPU 的线性编程模型,面对 GPU 的并行计算架构和独特的渲染流水线时,往往感到无从下手。更重要的是,Mali GPU 采用的基于分片(Tile-Based)的渲染架构,与桌面端 NVIDIA/AMD 的立即模式渲染有着本质区别,如果不理解这种差异,写出的着色器代码往往会出现严重的性能问题。
我曾见过太多这样的案例:一个在 PC 上运行流畅的 OpenGL ES 应用,移植到嵌入式平台后帧率暴跌;一份看似合理的着色器代码,却在 Mali GPU 上出现了难以解释的带宽瓶颈;一个经过精心优化的渲染流程,实际性能却只有理论值的三分之一。这些问题的根源,往往都在于对 Mali GPU 架构的理解不够深入。
本文将从硬件架构出发,系统地讲解 Mali GPU 的工作原理。我们会从最基础的 Tiler 分片渲染机制讲起,深入到着色器核心的执行模型,分析内存层次结构的设计考量,最后给出一套完整的性能优化方法论。无论你是正在开发嵌入式图形应用的工程师,还是对 GPU 架构感兴趣的技术爱好者,这篇文章都能为你揭开 Mali GPU 的神秘面纱。
一、为什么嵌入式 GPU 需要不同的架构设计?
在深入 Mali GPU 的具体架构之前,我们首先要回答一个根本性的问题:为什么嵌入式 GPU 不能直接沿用桌面 GPU 的设计?答案可以用三个关键词来概括:功耗、带宽、面积。
桌面 GPU 的设计哲学是"性能优先"——只要能跑出更高的帧率,功耗高一点、芯片面积大一点都无所谓。一块高端 NVIDIA 显卡的功耗可以达到 400W 以上,芯片面积超过 600mm²,显存带宽超过 1TB/s。这种设计在桌面环境下完全可行,但在嵌入式场景中却是不可接受的。
嵌入式设备面临着完全不同的约束:
- 功耗预算极其有限:手机 SoC 的整体功耗通常在 5-10W,GPU 分到的预算只有 2-3W;即使是车载 SoC,GPU 功耗也很少超过 15W
- 内存带宽严重受限:嵌入式系统使用的 LPDDR5 带宽通常在 30-50GB/s,不到高端桌面显卡的 5%
- 芯片面积寸土寸金:每增加 1mm² 的芯片面积,都意味着数百万台设备的成本上升
- 散热条件恶劣:没有风扇,只能靠被动散热,温度过高还会触发降频
在这些约束下,嵌入式 GPU 必须找到一条完全不同的技术路线。如果直接把桌面 GPU 的架构缩小放到嵌入式芯片中,结果必然是功耗超标、带宽不足、性能低下。正是这种现实的约束,催生了 Mali GPU 独特的架构设计。
Mali GPU 的设计哲学可以总结为三句话:计算尽量本地化,最大限度重用数据,减少不必要的内存访问。整个架构的每一个设计决策,从 Tiler 分片渲染到片上缓存,从执行引擎设计到内存控制器,都是围绕这三个原则展开的。
二、Tiler 分片渲染:Mali 架构的灵魂
理解 Tiler 分片渲染(Tile-Based Rendering, TBR)是理解 Mali GPU 的第一步。这也是 Mali GPU 与桌面 GPU 最本质的区别。
2.1 传统立即模式渲染的问题
让我们先看看桌面 GPU 采用的立即模式渲染(Immediate Mode Rendering, IMR)是如何工作的:
- 应用提交一个绘制命令,包含一组图元(三角形)
- GPU 立即处理这些图元,执行顶点变换、光栅化、片元着色
- 处理完成的像素立即写入帧缓冲内存
这个流程看起来简单直接,但存在一个致命的问题:像素被反复读写的次数可能非常多。
想象一个复杂的 3D 场景,同一个像素位置可能被十几个三角形覆盖。在立即模式渲染中,每个三角形处理时都会读取这个像素的当前值,进行混合计算,然后写回内存。如果场景中有大量重叠的几何体,同一个像素位置可能被读写几十次。每次读写都意味着一次显存访问,每次访问都消耗宝贵的带宽和能量。
在桌面 GPU 上,这个问题被"暴力解决"了——使用超高速的 GDDR 显存和巨大的带宽。但在嵌入式平台上,这种方案根本行不通。
2.2 TBR 的核心思想:先分类,后渲染
分片渲染的思想非常朴素:既然同一个像素会被多次访问,那为什么不把这些计算都放到高速的片上缓存中完成,最后只往内存写一次呢?
TBR 的工作流程分为两个独立的阶段:
第一阶段:几何处理与分片(Tiling)
- 处理所有顶点,完成坐标变换和裁剪
- 将整个屏幕划分为多个小 Tile(通常是 16×16 或 32×32 像素)
- 对每个图元(三角形),计算它覆盖了哪些 Tile
- 为每个 Tile 维护一个多边形列表,记录覆盖它的所有图元
第二阶段:逐 Tile 渲染(Rendering)
- 依次处理每个 Tile
- 将该 Tile 对应的多边形列表中的所有图元都加载到片上缓存
- 在片上缓存中完成该 Tile 的所有光栅化、着色、深度测试、混合等操作
- 整个 Tile 处理完成后,一次性将最终结果写回系统内存
这个流程最关键的优势在于:每个像素位置只需要往内存写一次,中间的所有计算都在高速的片上 SRAM 中完成。即使一个像素被 100 个三角形覆盖,这些重叠计算都不会产生额外的内存带宽开销。
2.3 性能收益的量化分析
让我们用具体的数字来说明 TBR 的优势:
假设我们有一个 1080p 分辨率的场景,平均每个像素被 5 个三角形覆盖(过度采样率为 5)。
立即模式渲染(IMR)的带宽消耗:
- 每个像素的颜色值:4 字节(RGBA8)
- 每个像素的深度值:4 字节(Depth32)
- 每个三角形读写一次:(4 + 4) × 2 = 16 字节/像素/三角形
- 总带宽:1920 × 1080 × 5 × 16 = 165.888 MB/帧
- 60fps 下:约 9.95 GB/s
分片渲染(TBR)的带宽消耗:
- 几何阶段:多边形列表写入,约 5-10 MB/帧
- 渲染阶段:每个像素只写一次,中间计算在片上
- 最终写出:1920 × 1080 × 8 = 16.5888 MB/帧
- 60fps 下:约 1.0 GB/s
这就是 TBR 架构最惊人的优势:在典型场景下,内存带宽消耗可以降低 70%-90%!对于带宽极度受限的嵌入式平台来说,这种节省是决定性的。
当然,天下没有免费的午餐。TBR 也有自己的代价:
- 额外的几何处理开销:需要对所有图元进行分类,维护多边形列表
- 延迟增加:必须等所有几何处理完成后才能开始渲染
- 特殊的内存开销:需要存储多边形列表,大场景下可能达到几 MB
但对于绝大多数嵌入式场景来说,这些代价都是完全值得的。带宽节省带来的功耗降低和性能提升,远远超过了这些额外开销。
2.4 Mali Tiler 的硬件实现
Mali GPU 的 Tiler 单元是一个专门的硬件模块,独立于着色器核心。它的工作流程可以进一步细化为:
顶点处理阶段:
- 顶点着色器执行(可以由着色器核心完成)
- 图元装配:点、线、三角形
- 裁剪:移除视口外的图元
- 剔除:背面剔除、视锥体剔除
分片阶段:
- 包围盒测试:计算每个三角形的屏幕空间包围盒
- Tile 分配:确定三角形覆盖哪些 Tile
- 多边形列表构建:将三角形索引添加到对应 Tile 的列表中
- 溢出处理:如果某个 Tile 的多边形列表太大,切换到"超额渲染"模式
这里有一个容易被忽视的细节:Mali 的 Tiler 使用了**分层分片(Hierarchical Tiling)**技术。对于非常大的三角形,不会直接遍历所有可能覆盖的 Tile,而是在更高层级上进行快速排除。这种优化可以显著减少分片阶段的计算量。
另外一个重要特性是多边形列表的压缩。Mali GPU 会对多边形列表进行实时压缩存储,进一步降低内存带宽消耗。这种压缩是完全硬件实现的,对开发者透明,但理解它的存在有助于解释某些性能现象。
三、理解 Mali 的渲染流水线
现在我们已经理解了 TBR 的基本概念,接下来让我们看看整个渲染流水线是如何组织的。
3.1 命令流处理器(Job Manager)
渲染的起点是命令流处理器。它负责从 CPU 接收渲染命令,解析命令序列,然后将工作分发给各个硬件单元。
Mali 的命令流处理器采用了**基于作业(Job-based)**的调度模型,而不是传统的固定功能流水线。这种设计有几个重要优势:
- 灵活的任务调度:不同类型的作业可以并行执行,几何处理和着色计算可以同时进行
- 动态负载均衡:根据当前工作负载自动调整资源分配
- 容错能力:某个作业出错不会影响整个流水线
命令流处理器支持多种作业类型:
- 几何作业:顶点处理、分片
- 分片作业:逐 Tile 渲染
- 计算作业:OpenCL / Vulkan Compute
- 写出作业:像素合并、帧缓冲输出
这种基于作业的架构也是现代 GPU 的共同特征,无论是 Mali、Adreno 还是最新的 NVIDIA 架构,都采用了类似的设计。
3.2 几何处理流水线
几何处理是渲染的第一个阶段,负责将 3D 世界坐标转换为 2D 屏幕坐标。
Mali 的几何流水线包含以下阶段:
- 顶点获取:从内存读取顶点数据(位置、法向、纹理坐标等)
- 顶点着色:执行顶点着色器,进行坐标变换
- 曲面细分(可选):Tessellation 着色器执行
- 几何着色(可选):生成或丢弃图元
- 图元装配:组装成点、线、三角形
- 裁剪:移除视口外的部分
- 剔除:背面剔除、视锥体剔除
- 屏幕坐标变换:转换到像素坐标空间
一个重要的优化点是:Mali 会在几何阶段尽可能早地剔除不可见的图元。如果一个三角形完全在视口外,或者是背面,它会被直接丢弃,不会进入后续的分片阶段。这种提前剔除可以显著减少分片的工作量。
这也是为什么我们常说"几何阶段的优化往往收益最大"——在流水线早期减少工作量,可以避免后续所有阶段的无效计算。
3.3 分片与渲染的解耦
在 TBR 架构中,几何处理和像素渲染是两个完全解耦的阶段。这意味着:
- 可以先完成所有几何处理,生成完整的多边形列表,然后再开始渲染
- 也可以采用流式处理,一边进行几何分片,一边开始渲染已经完成的 Tile
Mali GPU 实际上采用了混合模式:对于简单场景,会等所有几何处理完成后再渲染;对于复杂场景,会分批次处理,避免多边形列表占用过多内存。
这种解耦带来了一个有趣的性能特征:在 Mali GPU 上,几何负载和像素负载的性能瓶颈是相对独立的。如果你看到几何处理占用了大量时间,那么优化顶点着色器、减少 Draw Call 数量会是最有效的手段;如果瓶颈在渲染阶段,那么优化片元着色器、降低分辨率会更有效果。
(第一部分完,约 2700 字)
四、着色器核心架构:从 Midgard 到 Valhall
如果说 Tiler 是 Mali GPU 的骨架,那么着色器核心就是它的心脏。着色器核心的设计直接决定了 GPU 的计算能力和能效比。Mali GPU 的着色器架构经历了三代重大演进,每一代都代表了设计理念的重要转变。
4.1 Midgard 架构(Mali-T6xx/T7xx/T8xx)
Midgard 是 Mali 的第一代统一着色器架构,于 2012 年随 Mali-T604 发布。它的核心设计是**“四向 VLIW + 四线程”**的执行模型。
每个 Midgard 执行引擎包含:
- 4 个算术管线(ALU Pipe)
- 1 个加载/存储管线(Load/Store Pipe)
- 1 个纹理管线(Texture Pipe)
- 4 个硬件线程槽,每个线程可以独立调度
VLIW(超长指令字)的设计思想是让编译器在编译时就把多个操作打包到一条指令中,硬件不需要复杂的动态调度逻辑。这种设计的优点是硬件简单、功耗低,但缺点是对编译器要求极高,如果编译器无法找到足够的并行性,硬件利用率就会很低。
在实际应用中,Midgard 架构的问题逐渐暴露:
- 着色器代码中往往存在大量的数据依赖,很难填满 4 个 ALU 槽
- 分支密集的代码几乎无法利用 VLIW 并行性
- 不同类型的工作负载(图形 vs 计算)利用率差异巨大
这些问题促使 ARM 开始了下一代架构的研发。
4.2 Bifrost 架构(Mali-G3x/G5x/G7x)
Bifrost 架构于 2016 年随 Mali-G71 发布,标志着 Mali 从 VLIW 转向了**四向 SIMD + 波浪前端(Warp Frontend)**的设计。
Bifrost 的核心改进包括:
- 抛弃 VLIW,采用更灵活的 SIMD 执行模型
- 引入波浪(Warp)调度:16 个线程组成一个 Wave,同步执行
- 统一的指令集:图形和计算使用相同的 ISA
- 四倍的寄存器文件:每个线程可用的寄存器数量翻倍
Bifrost 架构最大的优势是硬件利用率显著提升。SIMD 模型不需要编译器找到指令级并行,只要有足够的数据并行(这在图形和计算工作负载中非常普遍),就能填满硬件管线。而且波浪调度可以很好地隐藏内存延迟——当一个 Wave 等待内存时,硬件可以立即切换到另一个 Wave 执行。
从 Bifrost 开始,Mali GPU 的通用计算能力有了质的飞跃。OpenCL 工作负载的性能相比 Midgard 提升了 2-3 倍,这也是为什么 Mali GPU 开始被广泛用于边缘 AI 推理加速。
4.3 Valhall 架构(Mali-G57/G68/G78/G710/G715/G720)
Valhall 是 Mali 的最新架构,首次亮相于 2019 年的 Mali-G57。Valhall 代表了 Mali 架构的一次全面重塑,核心设计理念是**“可扩展的执行引擎”**。
Valhall 的关键改进:
1. 解耦的执行管线
- FMA(乘加)单元和 SFU(特殊功能单元)完全独立调度
- 不再强制绑定到固定大小的 Wave
- 每个执行单元可以独立接受工作分配
2. 矩阵计算引擎
- 从 Mali-G710 开始引入专用的矩阵乘法单元
- 支持 FP16/BF16/INT8 矩阵运算
- AI 推理性能提升 2-4 倍
3. 可变速率着色(VRS)
- 硬件支持每 Tile 的着色率调节
- 对于细节不敏感的区域可以降低着色频率
- 性能提升 20-30%,视觉质量几乎不受影响
4. 光线追踪加速
- Mali-G715 首次引入硬件光线追踪单元
- 支持 BVH 遍历、相交测试
- 实时光线追踪在移动端成为可能
Valhall 架构最值得关注的变化是图形和计算的深度融合。在 Valhall 之前,图形管线和计算管线虽然共享硬件,但仍然是相对独立的编程模型。而在 Valhall 架构中,整个 GPU 被设计为一个统一的计算平台——渲染本质上只是一种特殊形式的计算。这种设计理念的转变,为 Mali GPU 在 AI、科学计算等领域的应用打开了更广阔的空间。
五、执行模型:理解 Wavefront 与隐藏延迟
要写出高效的 Mali 着色器代码,首先必须理解它的执行模型。很多从 CPU 开发转向 GPU 开发的工程师,最容易犯的错误就是用 CPU 的思维方式来写 GPU 代码。
5.1 SIMT:单指令多线程
Mali GPU 采用的是 **SIMT(单指令多线程)**执行模型。这是一种介于 SIMD 和 MIMD 之间的模型,也是现代 GPU 的标准执行模型。
SIMT 的核心思想是:
- 大量线程以"波浪"(Wave / Warp / Wavefront)为单位组织
- 同一个 Wave 中的所有线程执行完全相同的指令
- 但每个线程有自己独立的寄存器和程序计数器
- 线程可以有不同的执行路径(分支)
在 Mali Bifrost 和 Valhall 架构中,一个 Wave 包含 16 个线程。这意味着当你写一个片元着色器时,实际上它会同时在 16 个像素上并行执行。
这种执行模型带来了几个重要的推论:
推论 1:分支是性能杀手,但不是绝对的
如果同一个 Wave 中的不同线程走了不同的分支路径,GPU 会串行执行所有分支,然后丢弃不需要的结果。这被称为"分支发散"(Branch Divergence),会导致硬件利用率下降。
但分支发散的影响是概率性的:如果分支条件在 Wave 内部是一致的(比如整个 Wave 的所有像素都满足条件,或者都不满足),那么就不会有发散惩罚。这也是为什么基于 Tile 的分支通常性能很好——同一个 Tile 内的像素往往有相似的特征。
推论 2:内存延迟通过多线程隐藏,而不是通过缓存
在 CPU 上,我们依赖缓存来隐藏内存延迟。如果缓存命中率高,内存访问只需要几个时钟周期;如果缓存不命中,CPU 会停顿几百个周期等待数据。
而在 GPU 上,隐藏内存延迟的主要机制是快速的上下文切换。当一个 Wave 发起内存请求后,硬件会立即切换到另一个就绪的 Wave 执行,而不是等待数据返回。只要有足够多的就绪 Wave,GPU 就能一直保持忙碌,内存延迟就被完全隐藏了。
这就是为什么 GPU 需要如此多的寄存器——每个硬件线程都需要自己的寄存器文件。Mali GPU 的每个执行引擎通常支持 128-256 个并发硬件线程,这意味着即使 90% 的线程都在等待内存,仍然有足够的线程可以保持硬件满载。
推论 3:寄存器使用量决定了最大并行度
这里有一个重要的权衡:着色器使用的寄存器越多,能同时驻留的 Wave 数量就越少。如果 Wave 数量太少,就无法有效隐藏内存延迟,GPU 会出现"气泡"(空闲周期)。
这就是为什么在 Mali GPU 上,减少寄存器使用量往往比减少指令数量更能提升性能。一个使用 32 个寄存器的着色器可能比一个使用 64 个寄存器但指令数少 20% 的着色器运行得更快。
5.2 Mali 的指令调度机制
让我们更具体地看看 Valhall 架构的指令调度是如何工作的。
每个 Valhall 执行引擎有多个独立的功能单元:
- FMA 单元:执行乘加、加减、比较等基础算术运算
- SFU 单元:执行超越函数(sin、cos、exp、log 等)和特殊操作
- Load/Store 单元:处理内存读写
- Texture 单元:处理纹理采样
这些单元是完全解耦的,可以并行操作。调度器会在每个时钟周期检查所有就绪的指令,然后分配到对应的功能单元。这种设计被称为**“乱序执行的超标量架构”**,但与 CPU 的乱序执行不同,GPU 的调度粒度是 Wave 而不是单个指令。
这种架构的一个有趣特性是:不同类型的指令可以完全重叠执行。例如,一个 Wave 可以在 FMA 单元执行算术运算的同时,另一个 Wave 在 SFU 单元计算三角函数,第三个 Wave 在等待纹理采样返回。只要有足够多的独立工作,所有功能单元都可以同时保持忙碌。
这也是为什么 GPU 的理论峰值 FLOPS 很难达到——你需要足够多样化的工作负载来同时填满所有功能单元。纯算术的工作负载只能利用 FMA 单元,纯纹理采样的工作负载只能利用纹理单元,只有平衡的工作负载才能接近理论峰值。
六、内存层次结构:从寄存器到 DDR
内存系统是 GPU 性能最关键的瓶颈之一。Mali GPU 的内存层次结构经过精心设计,每一级都有明确的性能目标和使用场景。
6.1 寄存器文件:最快的存储
寄存器是 GPU 中最快的存储,访问延迟只有 1-2 个时钟周期。Mali GPU 的每个执行引擎都有一个巨大的寄存器文件——通常是 128KB 到 512KB,比很多 CPU 的 L1 缓存还大。
寄存器的分配是静态的——在着色器编译时就确定了每个线程需要多少寄存器,硬件在 Wave 启动时一次性分配。这种静态分配的优点是不需要复杂的缓存一致性逻辑,但缺点是寄存器使用量直接影响并发度。
一个重要的优化技巧是寄存器溢出检测。如果着色器使用的寄存器超过了硬件限制,编译器会把多余的寄存器"溢出"到栈内存中。这会导致严重的性能下降——本来应该是寄存器访问变成了内存访问。在 Mali Performance Counters 中可以找到Registers Spilled计数器,如果这个值大于零,就说明需要优化寄存器使用。
6.2 L1/SLC 缓存:着色器核心本地缓存
Mali GPU 的每个着色器核心都有自己的 L1 缓存(在 Valhall 架构中称为系统级缓存 SLC)。典型大小是 64KB 或 128KB 每个核心。
L1 缓存的特点:
- 读写延迟低:约 10-20 个时钟周期
- 核心私有:每个核心的缓存是独立的,不与其他核心共享
- 一致性弱:不需要维持多核一致性,所以效率很高
- 优先加载纹理:纹理采样会优先使用 L1 缓存
L1 缓存对于 Mali GPU 的性能非常关键。因为纹理采样通常是带宽最大的操作,良好的纹理局部性可以显著降低对 L2 缓存和主存的压力。
6.3 L2 统一缓存:全 GPU 共享
所有 Mali GPU 都有一个统一的 L2 缓存,由所有着色器核心共享。L2 缓存的大小从入门级的 256KB 到旗舰级的 4MB 不等。
L2 缓存的设计有几个重要特点:
1. 包含式设计
- L2 缓存包含了所有 L1 缓存的数据
- 这简化了一致性协议,减少了冗余存储
2. 带宽优先级
- 纹理读取 > 着色器读写 > 帧缓冲写出
- 这种优先级设计保证了最关键的操作能优先获得带宽
3. 压缩支持
- L2 缓存支持多种压缩格式
- 颜色缓冲压缩、深度缓冲压缩、纹理压缩
- 可以有效减少实际的内存传输量
L2 缓存是 Mali GPU 内存层次结构中最重要的一级。大部分工作负载的性能瓶颈都在 L2 缓存的命中率和带宽上。优化 L2 命中率的常见方法包括:
- 改善数据访问的空间局部性
- 减少不必要的全局内存读写
- 使用合适的纹理压缩格式
- 避免随机内存访问模式
6.4 主存:最终的瓶颈
主存(通常是 LPDDR4X 或 LPDDR5)是内存层次结构的最后一级,也是最慢的一级。访问主存的延迟通常在 200-400 个时钟周期,是寄存器访问的几百倍。
但更重要的是带宽限制。即使是最新的 LPDDR5-6400,理论带宽也只有 51.2 GB/s,实际可用带宽往往只有理论值的 60-70%。而且系统中的其他单元(CPU、NPU、显示控制器)也在竞争这部分带宽。
这就是为什么 TBR 架构如此重要——它从根本上减少了需要访问主存的数据量。如果没有 TBR,移动 GPU 的性能会下降 5-10 倍。
一个常见的误区是认为"只要优化了 ALU 指令数就能提升性能"。实际上,在大多数嵌入式图形应用中,70% 以上的性能瓶颈都在内存系统上。同样的渲染效果,一个内存友好的实现可能比一个计算优化但内存模式糟糕的实现快好几倍。
6.5 内存一致性模型
最后一个需要理解的概念是内存一致性。与 CPU 的强一致性模型不同,GPU 的内存一致性模型要弱得多,这对多线程编程有重要影响。
在 Mali GPU 上:
- 同一个 Wave 内的线程:可以安全地通过共享内存通信,但需要屏障(barrier)
- 同一个核心内的 Wave:L1 缓存是一致的,但仍然需要显式同步
- 不同核心的 Wave:L2 缓存是一致的,但需要更昂贵的同步操作
- CPU 和 GPU 之间:需要显式的内存屏障和缓存刷新
理解内存一致性模型对于编写正确的计算着色器和多阶段渲染流程至关重要。很多难以调试的图形 Bug,根源都在于对内存一致性的错误假设。
(第二部分完,约 3200 字)
七、性能优化方法论:从架构理解到代码实践
理解 Mali GPU 架构的最终目的是为了写出更高效的代码。在这一部分,我们将介绍一套系统的性能优化方法论,结合具体的代码示例,帮助你将架构知识转化为实际的性能提升。
7.1 性能分析的基本流程
优化的第一步永远是测量,而不是猜测。Mali GPU 提供了丰富的性能计数器(Performance Counters),可以精确地告诉你瓶颈在哪里。
标准的优化流程应该是:
- 建立性能基线:记录当前的帧率、渲染时间、带宽使用等指标
- 使用性能计数器定位瓶颈:是几何阶段、分片阶段、还是着色阶段?
- 分析具体原因:是 ALU 瓶颈、带宽瓶颈、还是分支发散?
- 实施针对性优化:根据瓶颈类型选择合适的优化策略
- 验证优化效果:对比优化前后的性能指标,确认收益
- 迭代:回到步骤 2,寻找下一个瓶颈
最常见的错误是上来就盲目优化,比如把所有着色器都改成半精度,或者无差别地减少 Draw Call。这样的优化往往收效甚微,甚至可能引入新的问题。
7.2 TBR 友好的渲染实践
既然 TBR 是 Mali 架构的核心,那么最有效的优化自然是让你的渲染流程尽可能"TBR 友好"。
实践 1:避免逐帧的帧缓冲读回
在立即模式渲染中,读取帧缓冲数据可能只是一次内存访问;但在 TBR 架构中,这会强制整个渲染流水线刷新——所有正在处理的 Tile 都必须立即写出到内存,然后才能开始读取。这个刷新操作的开销可能高达几毫秒。
// ❌ 不好的做法:每帧多次读取帧缓冲
vec4 currentPixel = texture2D(framebufferTex, v_texCoord);
// ✅ 好的做法:使用渲染到纹理,或者设计不需要读回的算法
如果你确实需要读-修改-写操作,考虑使用glCopyTexImage2D而不是glReadPixels,前者可以在 GPU 内部完成数据传输,不需要经过 CPU。
实践 2:合理控制多边形数量
虽然 TBR 可以很好地处理过度绘制,但多边形数量过多仍然会增加分片阶段的负担。每个三角形都需要进行包围盒计算、Tile 分配、写入多边形列表等操作。
一个实用的经验法则是:
- 移动端 3D 应用:每帧 5-10 万个三角形是比较合理的上限
- 2D UI 应用:每帧 1-2 万个三角形
超过这个数量,分片阶段就可能成为瓶颈。
实践 3:谨慎使用 discard 和 alpha to coverage
discard 语句(或者 alpha_test)会强制 Mali GPU 禁用早期深度测试(Early-Z)。因为 GPU 无法在着色器执行前知道哪个片元会被丢弃,深度测试必须推迟到着色器执行之后。这会导致大量无效的着色器执行。
// ❌ 性能代价高:强制禁用 Early-Z
if (alpha < 0.5) {
discard;
}
// ✅ 更好的做法:使用 alpha blending,或者重新设计渲染顺序
如果确实需要透明度裁剪,尽量把这些物体放在最后渲染,这样它们不会挡住其他物体,Early-Z 仍然可以对其他物体生效。
实践 4:避免渲染区域的频繁变化
每次改变渲染区域(glScissor、改变帧缓冲大小)都可能导致 Tiler 重置内部状态。如果必须使用裁剪区域,尽量保持裁剪区域的大小和位置相对稳定。
7.3 着色器优化实战
着色器优化是 Mali GPU 优化中最复杂也最有技巧性的部分。让我们通过具体的例子来说明关键的优化原则。
原则 1:优先使用 mediump 精度
Mali GPU 的 ALU 设计是为 16 位浮点运算优化的。使用 mediump 不仅可以减少寄存器使用量(增加并发 Wave 数量),还可以直接提升 ALU 的吞吐量。
// ❌ 不必要的高精度
highp vec3 lightDir = normalize(u_lightPos - v_worldPos);
highp float diff = max(dot(normal, lightDir), 0.0);
// ✅ 大多数光照计算用 mediump 足够
mediump vec3 lightDir = normalize(u_lightPos - v_worldPos);
mediump float diff = max(dot(normal, lightDir), 0.0);
什么情况必须用 highp:
- 顶点位置计算(特别是大场景的坐标变换)
- 纹理坐标插值(大纹理时)
- 深度值计算
- 需要超过 10 位精度的标量计算
什么情况用 mediump 足够:
- 颜色计算
- 大多数光照计算
- 法线变换
- 纹理采样后的颜色处理
一个典型的片元着色器,全部使用 mediump 可能比全部使用 highp 快 30-50%。
原则 2:减少寄存器使用量
如前所述,寄存器使用量直接影响能同时驻留的 Wave 数量。减少寄存器的方法包括:
- 复用变量,避免声明不必要的临时变量
- 把复杂计算拆分成更小的函数(函数返回值可以立即使用,不需要存储到寄存器)
- 使用
const修饰编译期常量 - 避免展开大循环(循环展开会增加寄存器压力)
// ❌ 寄存器压力大
vec4 temp1 = texture2D(u_tex1, uv);
vec4 temp2 = texture2D(u_tex2, uv);
vec4 temp3 = texture2D(u_tex3, uv);
vec4 result = temp1 * 0.3 + temp2 * 0.5 + temp3 * 0.2;
// ✅ 减少寄存器压力
vec4 result = texture2D(u_tex1, uv) * 0.3;
result += texture2D(u_tex2, uv) * 0.5;
result += texture2D(u_tex3, uv) * 0.2;
原则 3:明智地使用分支
分支不一定是坏事,关键是分支条件的一致性。
// ✅ 好的分支:整个 Wave 的条件很可能一致
if (u_effectEnabled) {
// 复杂的后处理效果
} else {
// 简单的直通
}
// ⚠️ 可能有问题的分支:相邻像素的条件可能不同
if (distance(uv, center) < radius) {
// 圆形区域内的处理
} else {
// 圆形区域外的处理
}
// ❌ 很可能发散的分支:完全随机的条件
if (randomValue > 0.5) {
doSomethingA();
} else {
doSomethingB();
}
如果分支确实不可避免,可以考虑使用混合(blending)来替代分支:
// 使用线性插值替代分支
float factor = step(0.5, value);
color = mix(colorA, colorB, factor);
mix 函数在 Mali GPU 上是单周期指令,而且不会导致分支发散,在很多情况下比 if-else 更快。
原则 4:优化纹理访问模式
纹理采样是 Mali GPU 上最大的带宽消耗者。优化纹理访问的关键是改善空间局部性。
// ❌ 不好的访问模式:随机采样多个不相关的纹理坐标
vec4 c1 = texture2D(u_tex, uv1);
vec4 c2 = texture2D(u_tex, uv2); // uv2 与 uv1 可能相差很远
vec4 c3 = texture2D(u_tex, uv3); // uv3 可能又在另一个地方
// ✅ 好的访问模式:相邻像素访问相邻的纹理坐标
// 这是大多数渲染的默认模式,也是硬件优化的目标
vec4 color = texture2D(u_tex, v_texCoord);
其他纹理优化技巧:
- 使用纹理压缩(ASTC、ETC2),不仅节省存储空间,还减少带宽
- 启用 mipmapping,不仅改善画质,还能减少纹理采样的带宽
- 避免过大的纹理(移动端单张纹理最好不要超过 2048×2048)
- 合理设置纹理过滤方式,
GL_LINEAR_MIPMAP_NEAREST通常是画质和性能的最佳平衡点
7.4 API 层面的优化
最后,让我们看看 OpenGL ES/Vulkan API 层面的常见优化点。
Draw Call 批处理
虽然 Mali GPU 的 Draw Call 开销相对较低,但过多的 Draw Call 仍然会增加 CPU 开销,并影响 GPU 的并行度。
目标是每帧 100-300 个 Draw Call,超过 500 个就应该考虑优化:
- 使用实例化渲染(
glDrawArraysInstanced) - 合并几何体,使用纹理图集
- 利用状态排序,减少状态切换
帧缓冲对象(FBO)优化
每个 FBO 切换都会导致流水线刷新,应尽量减少 FBO 切换次数:
- 把需要相同 FBO 的渲染批处理在一起
- 考虑重新设计渲染流程,减少渲染阶段数量
- 使用多渲染目标(MRT)减少绘制次数
缓冲对象管理
- 使用
GL_STATIC_DRAW标记真正静态的数据 - 对于动态更新的缓冲,使用
glMapBufferRange而不是glBufferSubData - 避免在渲染过程中频繁分配和释放缓冲对象
八、常见性能陷阱与解决方案
在多年的 Mali GPU 开发经验中,我总结了一些最常见的性能陷阱。了解这些陷阱可以帮助你避免很多不必要的性能损失。
陷阱 1:“只要减少三角形数量就能提升性能”
很多人本能地认为,减少一半三角形数量,性能就应该提升一倍。但在 Mali GPU 上,这往往不成立。
原因:
- 如果瓶颈是片元着色器(像素太多),减少三角形数量几乎没有效果
- 如果三角形已经很小(小于一个 Tile),减少数量确实有帮助
- 如果瓶颈在 Tiler,减少三角形数量会有帮助
解决方案:首先用性能计数器确定瓶颈在哪里,然后再针对性优化。如果确实是几何瓶颈,除了减少三角形,还可以考虑:
- 使用更简单的顶点着色器
- 启用硬件剔除(背面剔除、视锥体剔除)
- 使用 LOD 技术,远距离物体使用更简单的模型
陷阱 2:“Early-Z 会自动处理过度绘制”
很多人认为只要从前往后渲染,Early-Z 就会自动消除过度绘制。但在 Mali GPU 上,情况更复杂。
Mali 的 Early-Z 是在分片阶段和着色阶段之间工作的。对于被完全遮挡的 Tile,Early-Z 可以完全跳过着色;但对于部分遮挡的 Tile,仍然需要执行着色器来确定可见性。
更重要的是,以下操作会禁用 Early-Z:
- 使用
discard语句 - 修改深度值(
gl_FragDepth) - alpha to coverage 启用
- 某些混合模式
解决方案:
- 尽量避免使用会禁用 Early-Z 的功能
- 如果必须使用,把这些物体放在渲染顺序的最后
- 考虑使用 Pre-Pass 技术:先用一个简单的着色器渲染深度,然后再渲染颜色
陷阱 3:“计算着色器和图形着色器一样快”
从 Valhall 架构开始,图形和计算确实共享同一个硬件。但这并不意味着计算着色器和图形着色器的性能是一样的。
图形流水线有很多专门的硬件优化:
- 硬件光栅化器比软件光栅化快 10-100 倍
- 内置的深度测试和混合是硬件加速的
- 纹理采样器有专门的过滤硬件
- 顶点和片元的插值是专用硬件处理的
解决方案:不要为了"统一"而强行把图形任务改成计算着色器。计算着色器适合不规则的并行计算,图形流水线适合规则的光栅化任务。选择正确的工具比写聪明的代码更重要。
陷阱 4:“更多的核心 = 线性的性能提升”
很多旗舰 SoC 宣传自己有 10 核、14 核甚至 20 核的 Mali GPU。但实际性能提升往往不是线性的。
原因:
- Amdahl 定律:并行部分的加速受限于串行部分的比例
- 内存墙:核心越多,每个核心分到的带宽比例越低
- Tiler 瓶颈:Tiler 单元通常是单核,成为扩展的瓶颈
- 功耗墙:核心太多会导致过热降频
解决方案:不要指望增加核心数量能解决所有性能问题。对于大多数嵌入式应用,4-8 个核心是性价比最好的区间。超过这个数量,应该更多地考虑算法优化,而不是堆硬件。
九、进阶方向:从图形到通用计算
随着 Mali GPU 架构的演进,它正在从"图形处理器"转变为"通用并行处理器"。对于嵌入式开发者来说,这打开了很多新的可能性。
9.1 边缘 AI 推理加速
Mali GPU 已经成为边缘 AI 推理的重要算力来源。Valhall 架构引入的矩阵计算引擎,使得 Mali GPU 的 AI 性能可以与专用 NPU 相媲美。
常见的优化技术:
- 使用 FP16 或 INT8 量化,充分利用硬件加速
- 优化内存布局,使用 NHWC 格式
- 算子融合,减少中间结果的内存读写
- 利用 Mali 的性能计数器指导优化
现在主流的深度学习框架(TensorFlow Lite、ONNX Runtime、TNN)都已经针对 Mali GPU 做了深度优化。对于大多数常见的 CNN 模型,在 Mali GPU 上的推理性能可以达到 CPU 的 5-10 倍。
9.2 通用计算(GPGPU)在嵌入式中的应用
除了 AI,Mali GPU 还非常适合以下类型的通用计算任务:
图像处理:
- 图像滤波、边缘检测
- 颜色空间转换
- 图像缩放和旋转
- 特征点检测和匹配
信号处理:
- FFT(快速傅里叶变换)
- 数字滤波
- 雷达信号处理
- 音频处理
物理模拟:
- 粒子系统
- 流体模拟
- 刚体动力学
- 布料模拟
这些任务的共同特点是:高度数据并行,计算密集,控制流简单。正是 Mali GPU 最擅长的工作负载类型。
9.3 Vulkan 和新一代图形 API
Vulkan 代表了图形 API 的未来方向,它给了开发者更多的控制权,也为 Mali GPU 优化打开了新的可能性。
Vulkan 在 Mali 上的关键优势:
- 更可预测的性能:没有驱动的"魔法"黑盒
- 多线程命令缓冲生成:更好地利用多核 CPU
- 显式的内存管理:可以精确控制数据布局和访问模式
- 渲染通道(Render Pass):让驱动更好地了解渲染流程,优化 Tiler 行为
特别是 Render Pass 机制,它与 Mali 的 TBR 架构天然契合。正确使用 Render Pass 可以让 Mali 驱动自动应用很多高级优化,比如 Tile 本地存储、负载/存储操作优化等。
总结
Mali GPU 是一个极其精妙的架构设计,它在严格的功耗、带宽、面积约束下,实现了令人惊叹的性能和灵活性。从 Tiler 分片渲染到 Valhall 可扩展执行引擎,从 SIMT 执行模型到分层内存层次结构,每一个设计决策都体现了嵌入式系统特有的设计哲学。
回顾本文的核心要点:
- 理解 TBR 架构是基础:分片渲染是 Mali 区别于桌面 GPU 的最核心特征,所有优化都应该从理解 TBR 开始
- 内存比计算更重要:大多数性能瓶颈在内存系统,优化数据访问模式往往比减少指令数更有效
- 寄存器使用量决定并发度:减少寄存器压力可以增加 Wave 数量,更好地隐藏内存延迟
- 先测量,再优化:使用性能计数器精确定位瓶颈,避免盲目优化
- 图形和计算正在融合:Mali GPU 正在成为通用并行处理器,为嵌入式 AI 和高性能计算提供强大算力
作为嵌入式开发者,深入理解 Mali GPU 架构不仅能帮助你写出更快的图形应用,还能让你掌握一种全新的并行编程范式。在边缘计算和 AI 加速越来越重要的今天,这种能力的价值只会不断增加。
Mali GPU 的故事还在继续。每一代新架构都带来了更强大的算力、更灵活的编程模型、更高效的功耗控制。作为开发者,我们需要不断学习、不断适应,才能充分利用这些强大的硬件,创造出更好的产品。
(全文完,约 7800 字)