Android性能优化:定性和定位Android图形性能问题——以后台录屏进程为例

avatar
@github.com/NasdaqGodzilla

简介

本文记录一次Android图形性能问题的分析过程——发现定性定位图形性能问题,以及探讨的性能优化方案。

环境:Android Q + MTK + ARM Mali-G72。

所分析的性能问题(下称case):打开录屏应用并启动后台录屏,滑动前台应用(滑屏)。性能表现差:CPU、GPU负载显著升高、掉帧、用户明显卡顿感,帧率不足30帧,帧渲染、合成耗时急剧飙升(渲染耗时平均为29ms左右)。

经过优化后,相同环境和条件下,渲染帧率稳定在60帧(提升一倍),渲染耗时平均为8.44ms左右(为优化前的不到三分之一的消耗)。

关键词 Keywords: Screen Recording; Frame rate; FPS; GPU utilization; Jank; MediaProjection; VirtualDisplay; MediaCodec; Perfetto; Inferno; Surface; SurfaceTexture; VSYNC; SurfaceFlinger; HWC; Hardware composer; GPU; OpenGL;

发现、定性与定位

FPS

  • 计算FPS的方法和工具 Android框架层通过hwui配合底层完成渲染。该框架本身提供了逐帧渲染分段耗时记录。通过dumpsys gfxinfo可以获取。
io.microshow.screenrecorder/io.microshow.screenrecorder.activity.MainActivity/android.view.ViewRootImpl@6b9b8a9 (visibility=0)
	Draw	Prepare	Process	Execute
	3.80	0.48	13.88	1.64
	1.08	0.50	14.29	1.59
	1.12	0.51	17.49	1.74
	1.74	0.17	15.56	1.39
...
	1.38	0.45	17.64	1.38
	1.56	0.44	10.85	1.38
	3.09	0.24	18.03	1.73
	1.25	0.30	12.43	1.38

使用工具统计帧率与平均耗时(同时打印GPU负载),在开启后台录屏的情况下滑动屏幕,平均渲染耗时高达~29ms,超出16.67ms一倍,导致帧率仅31帧,显著低于60帧。

Average elapsed 28.96 ms
FPS: 31 │ 9.03 0.73 16.76 2.44

# GPU负载 LOADING BLOCKING IDLE
70 0 30

# case的对比——未开启后台录屏
Average elapsed 9.00 ms
FPS: 60 │ 1.56 0.65 5.62 1.17
  • 通过gfx柱状图直观感受性能数据 直观地感受图形渲染性能,除了帧率感受、触控延时外,还可以通过将gfxinfo的分段耗时通过柱状图展示在屏幕上。

这是case性能问题的gfxinfo柱状图,可以看到红柱和绿柱都非常高,远远超越了流畅标准。 其中,绿柱异常放大表明两个Vsync之间耗时显著增长,红柱异常放大表明应用层应用加速使用的DisplayLists大量增长、或图形层使用GLES调用GPU耗时显著增多导致的GPU执行绘制指令耗时变长。

初步定位问题

本节记录初步的分析思路和定位过程。首先我们完成实验(启停后台录屏并滑动屏幕触发渲染)、观测以及记录,拿到了后台录屏启停情况下的FPS、分阶段耗时以及GPU负载(相关数据位于FPS小节)。

开发的工具输出的统计数据计算结果非常直观,一眼可见,后台录屏为Draw阶段带来额外的8倍或8ms耗时,给Process阶段带来额外的2倍或11ms耗时。帧率从60帧坠落到~30帧。

  • 耗时分析 可以看到,主要的额外耗时来自Draw和Process。接下来重点围绕着两part定位问题问题。
StageDescriptionComp
Draw创建DisplayLists的耗时。Android的View如果支持硬件加速,绘制工作均通过DisplayLists由GPU绘制,可以处理为onDraw的耗时额外8ms或8倍
Prepare准备没有额外耗时
ProcessDisplayLists执行耗时。即硬件加速机制下提交给GPU绘制的工作耗时额外11ms或2倍
ExecuteFramebuffer前后缓冲区flip动作的耗时,上屏耗时额外不到~1ms

60Hz下,上述4个步骤合计耗时小于16.67ms为正常情况。case为~29ms。主要增量来自Draw和Process

经过上述初步分析、观测后,接下来的分析可以围绕Draw和Process开展。由于Android Draw部分涉及较广,包含App 渲染线程(DisplayLists)、UI线程(onDraw方法创建DisplayLists),以及图形栈耗时如SurfaceFlinger、RenderEngine等都可能增加Draw耗时。

这里一个技巧可以初步判断耗时来自App进程(渲染线程和UI线程)还是来自图形栈。如果能判断耗时来自App或图形栈,那么可以缩小分析范围、减少分析工作量。上述四大阶段的耗时统计分类比较宽,实际上还有更详细的分阶段耗时,它呈现在前文描述过的gfx统计信息柱状图上。gfx柱状图会以蓝色(RGB(66,122,249))呈现onDraw方法创建和更新DisplayLists的耗时。如果case与正常情况对比后,这部分耗时(蓝柱大小对比)差异很小,即可说明额外的Draw耗时不是来自App的,极可能来自图形栈。Besides,结合过度绘制分析,判断case与正常情况下是否有更多的额外绘制次数可以协同判断。

——根据上述指导思想,排查出了case的额外Draw耗时与App onDraw无关,多出来的DisplayLists来自App以外的进程,可能是图形栈如SurfaceFlinger

定性问题

本小节介绍问题追踪过程,通过一些方法定位到各阶段的耗时原因,并定性地得出case性能问题的性质。从本小节开始,围绕Perfetto进行分析。这里贴出perfetto的总览,我将关键的信息排序到顶部。前四行分别为SF负责图形的线程、提交到GPU等待完成的工作、Vsync-App、Vsync-sf,最后两行为case中出现卡顿掉帧的App的主线程(UI)和渲染线程(RenderThread)。

跟不上旋律节奏的VSYNC

容易看到,Vsync-sf非常不规律。Vsync-sf是触发SurfaceFlinger一次合成工作的基于Hardware VSYNC虚拟出来的一个信号。它相对于真实硬件信号(HW_VSYNC)一个规律的偏移(在case设备上,Vsync-app与Vsync-sf都被配置为8.3ms,即硬件VSYNC到达后,虚拟的Vsync-app和Vsync-sf延时8.3ms后发出,分别触发App绘制、SurfaceFlinger合成。

而case的Vsync-sf交错、残次、不齐、无规律,显然工况不佳。它将导致SurfaceFlinger不能按照预期的时间间隔将合成的帧提交到Framebuffer(经过Flip后,被提交的Framebuffer将上屏成为显示器的下一帧图像),出现掉帧/丢帧。

As we can see,case的VSYNC-sf出现严重的漂移(见图,第二行的VSYNC-sf残次不齐、跟不上规律、难看且混乱),这导致了丢帧。(但VSYNC-sf的失控仅表示与丢帧的相关性,并不直接表明因果性。)

  • VSYNC-sf为什么会出现偏差? 出于功耗的考虑,VSYNC-sf合VSYNC-app并不是一定会触发的。如果app或sf并没有更新画面的需求,那么死板固定地调度它们进行绘制和合成是不必的。编程上,负责触发VSYNC-sf和VSYNC-app的两个EventThread会在requestNextVsync调用后才会将下一个VSYNC-sf或VSYNC-app发出。因此,当(各自EventThread的)requestNextVsync没有调用时,VSYNC-app和VSYNC-sf也就出现漂移。BufferQueueLayer::onFrameAvailable会在应用提交后调用,该方法通过调用SF的signalLayerUpdate触发产生下一个VSYNC-sf

换而言之,出于功耗,或别的什么原因(比如耗时导致的延期,人家是线程实现的消息队列),SurfaceFlinger的SFEventThread有可能不调用requestNextVsync,这将导致Vsync-sf在窗口期内短暂消失——但是也不会出现参差不齐的情况。结合case的VSYNC信号报告来看,VSYNC-sf信号异常切实地提示了性能问题——它的不规律现象表明前后Vsync之间有异常耗时,而非低功耗机制被激活或无屏幕刷新(case性能问题复现时一直在滑前台应用的屏,它每16ms都有画面更新的需求)。

VSYNC-sf虽然出现了偏差,但是它与卡顿问题仅有相关性(或者说它是性能问题的结果),并非因果关系。猜测是其他卡顿问题导致了SF延缓了对VSYNC的request,导致其信号出现漂移。VSYNC-sf信号偏差实质上指导意义重大,因为它能提示我们,问题发生在比App更底层的地方(前文分析的结论),且比SurfaceFlinger提交到Framebuffer更上层的位置(VSYNC-sf用于触发合成,合成完成后提交到屏幕双缓冲区)

这样,将case性能问题的上下界都确定了,问题分析范围从原先的整个图形栈,有效的缩小到了SurfaceFlinger渲染和合成阶段了。

严重异常耗时的dequeueBuffer

通读Perfetto,可以看到,出了难看的Vsync-sf以外,还可以看到刺眼的超长耗时的draw(App UI线程)以及耗时变态长的dequeueBuffer(App 渲染线程)调用。相对于正常情况,perfetto报告提示的case的draw方法成倍增长的耗时非常容易被误认为耗时“居然来自一开始就排除掉的App进程",这与前文提出的”问题范围“是不能自洽的——它们是相反的结论,肯定哪里不对。仔细分析才能发现,draw方法确实是消耗了更多【墙上】时间(但是不意味着消耗了更多CPU时间,因为等待过程是sleep的),但是draw方法是因为等待渲染线程的dequeueBuffer造成的耗时,dequeueBuffer的严重异常耗时却是被底层的图形栈拖累的

  1. 我们看到,draw严重耗时,渲染线程dequeueBuffer消耗掉~20ms的时间。As we all known,Android的Graphics buffer是生产者消费者模型,当作为消费者的SF来不及处理buffer并释放,渲染线程也就需要额外耗时等待buffer就绪。上面还有一段"Waiting GPU Completion"的trace没有贴上来(下图),这段耗时比不开启后台录屏的case下高得多(3ms对比15ms),说明了一定的GPU性能问题或SF的性能问题,甚至有可能是Display有问题(HWC release耗时过长也会导致SF释放buf、生产者渲染线程dequeueBuffer额外等待)。

    这里的机制比较复杂,不熟悉底层Graphics buffer的流水线模型就不好理解。In one world, dequeueBuffer申请的buffer不是凭空new出来的,而是在App-SurfaceFlinger-Framebuffer这一流水线中循环使用的。流水线中的buffer不是无限的,而是有穷的几个。当底层的伙计,如SF和HWC,使用了buffer但是没有来得及释放时(它们的工作没做完之前不会释放buffer),流水线(可以理解成头尾相接的单向队列(ring buffer))没有可用的buffer,此时dequeueBuffer就不得不进入等待,出现【耗时看上去很长】的问题。实际上,dequeueBuffer耗时的唯一原因几乎仅仅只有一个:底层消费太慢了,流水线没有剩余buffer,因此需要等待。

    这个模型抽象理解非常简单。下图,右边消费者是底层图形栈——它每消费完一个buffer就会释放掉,每释放一个buffer应用层能用的buffer就加1。左边生产者是App渲染线程——它调用dequeueBuffer申请一个buffer以将它的画面绘制到这个buffer上。buffer送入BufferQueue后由右边的消费者(图形栈)进行消费(合成、上屏显示),然后释放buffer。当图形栈来不及release buffer时,dequeueBuffer的调用者(App渲染线程)将由于无可用buffer,就必须挂起等待了,在perfetto上就留下长长的一段”耗时“(实际上是墙上时间,大部分都没有占用CPU)

    以上,这就是为什么说App渲染线程dequeueBuffer严重耗时中的耗时为什么要打引号,为什么要说是被图形层拖累了。

  2. 下图可以看到,刨去dequeueBuffer的严重异常耗时,执行渲染的部分耗时相对于正常的case几乎没有差异,这可以断言渲染线程的惨烈耗时主要就是被dequeueBuffer浪费了。

    GPU Completion来看,此时GPU正在为SF工作,因为在图中看到(不好意思没有截全,下图你是看不出来的),dequeueBuffer总是在SF的GPU Completion结束之后结束的,这就表明SF正在通过GPU消费buffer(调用GPU进行合成后提交,然后标记buffer允许被渲染线程dequeue)。dequeueBuffer获取到就绪的buffer此时此刻取决于SF的消费能力——因为case中它是短板。(当然图形层的buffer可用不止SurfaceFlinger需要释放,因为SF释放后buffer实质上流转到更底层的HWC,等它将Buffer提交到屏幕后才会释放,这里释放后才能给App再次使用(上面哪个模型图把SF和HWC合并为流水线的图形层buffer消费者)。

    从perfetto报告看HWC release非常及时、余量充足,SF的GPU Completion则较紧密地接着dequeueBuffer返回,基本断言是SF太慢了——排除HWC的责任。(下图看不出来,当时没有截图到HWC的release情况。)

    到这里,除了再次确认排除了前台App的问题外,还可以断言问题来自SurfaceFlinger过分耗时。此外将问题范围的下界从整个SF合成流程(上文的Vsync-sf)缩小到了排除HWC的范围。

    结论:渲染耗时一切正常,问题出现在SF消费buffer(合成图形)失速了,导致没有可用的buffer供渲染线程使用。从下图的SF的工况(第三列)来看,情况确实如此。

  3. 既然一口咬定是SF的锅,那就瞧瞧SF。先看SF的INVALIDATE,这没啥好看的,异常case和正常case都是~2.5ms。主要看refresh,正常case ~6.8ms,异常case ~18.8ms。refresh包含SF的合成四件套,包括rebuildLayerStackCalcuateWorkingSetPreparedoComposition Perfetto报告直接表明,case的后台录屏导致的额外一次合成和配套工作是主要的耗时增量。

    之所以会执行两次合成,是因为后台录屏工具编程上通过Android SDK提供的MediaProjection配合VirtualDisplay实现一个虚拟的镜像的屏幕。SurfaceFlinger会将画面输送一份到这个虚拟的Display以实现屏幕图像传送到录屏工具,虚拟的屏幕要求额外的一次合成。从上图可以直接得出结论,case带来的额外工作消耗就是对该录屏用的VirtualDisplay的合成工作(doComposition)带来的

VirtualDisplay合成耗时

由于问题范围已经缩小到了很小的一个范围,在SurfaceFlinger的Refresh过程中,case相对正常应用有巨大的差异耗时,几乎完全来自于对VirtualDisplay的合成耗时(doComposition)。同时也可以看到,两次合成(一次是设备的物理屏幕,一次是case的后台录屏工具创建的虚拟屏幕)中,虚拟屏幕的耗时远远高于物理屏幕(4倍以上)。

通过查看ATRACE的tag(上图,Perfetto中SurfaceFlinger中主线程的各个trace point都是用ATRACE打的tag),结合dumpsys SurfaceFlinger,能直接看到的线索是:

  1. 虚拟屏显著耗时,且合成工作通过GLES调用GPU完成
  2. 物理屏合成耗时很小,它通过HWC合成

结合图中提示的trace tag、耗时,可以得出结论,使用GPU合成的虚拟屏中因GPU合成耗时很长,导致它显著高于物理屏HWC合成耗时。如果GPU合成能够和HWC合成一样快,或者干脆让虚拟屏也使用HWC合成,那么可以预期SurfaceFlinger的合成工作的消耗将显著降低。

结论

本小节综合上述三个小节的分析,对节”定性问题“下一个结论。

耗时的本质已经被看透,录屏工具申请创建的VirtualDisplay没有通过HWC进行合成,而是通过GPU进行合成,它耗时很长导致界面卡顿。In one word,case使用的VirtualDisplay的合成方式不够高效

HWC是Hardward Composer。它接收图形数据,类似于往桌面(真的桌面,不是电脑和手机的桌面)上面叠放照片和纸张——即合成过程。这个工作能将界面上几个窗口叠加在一起后送到屏幕上显示。通过GLES调动GPU也能干这活,不过HWC执行合成的动作是纯硬件的——它很快,比GPU快几倍。

定位问题

前面虽然定性了问题原因是合成方式不够高效,但是没有得出其中的原理——为什么虚拟屏不使用高效的HWC进行合成。本节通过介绍HWC的原理、SurfaceFlinger控制合成方式、虚拟屏Surface特性等来介绍图形栈中合成方式的处理模式。掌握了相关管理后,探讨一些尽量通用的共性的解决方案实现性能优化。最后着重介绍多套优化方案中的一种直面根本原因的解决方法——MediaCodec.MediaFormat创建的支持HWC合成的Surface方案

  • SurfaceFlinger如何决定使用HWC还是GPU合成? SurfaceFlinger合成主要可以依靠两条路径。其中之一是”纯硬“的HWC合成(在dumpsys SurfaceFlinger中可以看到Composition type为DEVICE),另一个是通过OpenGL让GPU进行合成(Composition type为CLIENT)。

除非是功耗上的设计,否则SurfaceFlinger总是会优先检查本次合成是否支持使用HWC。编程上,在合成阶段之一的prepare过程中,SurfaceFlinger通过prepareFrameRenderSurface与Hardware Composer(即HWC)的HIDL服务通信,完成hwc layer的创建。但是,layer能够成功创建不意味着一定支持HWC合成。SurfaceFlinger通过getChangedCompositionTypes向HWC查询不支持HWC合成的Layer。该方法返回的layer如果被标记为CLIENT合成,那么这部分Layer无法由HWC进行合成,而只能通过GPU进行合成——case的VirtualDisplay就是这个情况。

  • 部分layer可能不能由HWC合成的原因(除功耗策略、其他软件策略外):
  1. HWC layer达到上限 Hardware Composer支持的layer数量是有限的。查阅公开资料可知,HWC合成动作属于硬件提供的能力,它们的合成能力受到硬件本身的限制。Google官方资料对Android设备的要求是,HWC最少应该支持4个Layer,分别用于一个常规页面上最常见的4个层:壁纸、状态栏、导航栏和应用窗口。 在case设备中,经过测试,该平台的HWC最多支持7个能进行HWC合成的layer,从第8个layer开始,完完全全只能使用CLIENT合成亦即SurfaceFlinger调用RenderEngine通过OpenGL调动GPU进行合成。 正是由于HWC合成layer有上限,因此在弹出多个弹窗、叠加过于复杂时,即使界面简单也有可能出现比较明显的卡顿。
  2. VirtualDisplay的Surface格式不受HWC支持 HWC的硬件合成能力对buffer(Surface封装)内保存的图像的格式有要求。比如,HWC不能处理缩放,仅支持一部分的格式,大多数都还有其他因素会导致不支持,如旋转、部分Alpha等等。In one word,图像格式的数量是远远多于HWC支持的类型数的。当HWC碰到不支持合成的Surface时,就会在前文提过的getChangedCompositionTypes中通知SurfaceFlinger,由SurfaceFlinger转为使用GPU合成。

结合上述几种情况,设计实验验证。其中通过在物理屏上弹窗来增加Layer以获取HWC Layer上限。确认case无法使用HWC合成不是Layer上限导致的问题后,通过对比来验证Surface格式问题。Surface是对native层的buffer的封装,其类型广泛、实现复杂,一个一个试是不现实的。通过对比性能强劲的类似实现可以一探究竟。Android adb提供一个出厂自带的录屏命令screenrecord、用于测试双屏显示功能的虚拟辅助屏幕(开发者模式-模拟辅助屏)、著名远程窥屏工具scrcpy等三个工具是一系列重要参考。

经过测试,screenrecordscrcpy创建的VirtualDisplay支持HWC合成——这是优化目标。首先看看它们的实现。

编程上,虚拟辅助屏幕采用了与case一模一样的实现——通过创建VirtualDisplay让图形层额外合成一次屏幕到该虚拟屏幕中。虚拟屏幕本质上将画面发送给录屏功能实现,而非进行显示来完成录屏。

通读screenrecord源码,逻辑上,它与虚拟辅助屏、case录屏应用是相同的——VirtualDisplay录屏。但是编程上略有差异:

  1. screenrecord直接通过binder与SurfaceFlinger通信,获取了raw VirtualDisplay,而虚拟屏和case应用均通过SDK提供的MediaProjection包装后的VirtualDisplay。这一块经过源码阅读,确认了经过MediaProjetion包装的VirtualDisplay不会对HWC合成有影响。
  2. VirtualDisplay配置略有差异。case录屏工具自定了一个分辨率和dpi,与物理屏不一致,也和screenrecord不一致。
  3. 用于接收图像数据的Surface不一致。screenrecord通过MediaCodec创建了指定格式的Surface,类型是OMX库中定义的OMX_COLOR_FormatAndroidOpaque

可能影响HWC合成的最大可能来自第3点的Surface格式。做出这个假设的逻辑是,HWC是否有合成能力是由Layer的格式来决定的(Layer是图形层对Graphics Buffer的抽象,Surface是应用层对Layer的再一次抽象,在分析问题时,可以将它们的底层视为一致的,即都是在各自的层指代Graphics Buffer)。

因此,编写demo,模仿screenrecord创建Surface的方式screenrecord::prepareEncoder

  1. 源码中MediaCodec.MediaFormat创建。重点是设置MIME为Video/AVC,并设置颜色空间为OMX_COLOR_FormatAndroidOpaque
format->setString(KEY_MIME, kMimeTypeAvc);  
format->setInt32(KEY_COLOR_FORMAT, OMX_COLOR_FormatAndroidOpaque);
  1. MediaCodec创建后通过getInputSurface获取创建的Surface,传递到VirtualDisplay即可接收到VirtualDisplay上显示的内容——即获取到录屏内容。这一段是常规操作,起HWC合成的决定性作用的是第1步中对MIME类型的设置、对颜色空间的设置。

我们的case是应用层App,因此用Java代码简单表示一下:

final MediaFormat f = MediaFormat.createVideoFormat("Video/AVC", SCREEN_WIDTH, SCREEN_HEIGHT);
f.setInteger(MediaFormat.KEY_COLOR_FORMAT,
		MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
// 其他的MediaCodec的常规设置,如I帧、码率帧率就不再赘述
final MediaCodec c = MediaCodec.createEncoderByType("Video/AVC");
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
// 录屏功能基础操作,这个surface的格式就是前面设置的COLOR_FormatSurface,它作为MediaCodec的输入流进行编码,稍后,它作为VirtualDisplay的参数,传递到VirtualDisplay接收显示数据
final Surface sf = codec.createInputSurface();  
codec.start();  
mScreenRecorder.setupVirtualDisplay(sf, SCREEN_WIDTH, SCREEN_HEIGHT);

Java层使用COLOR_FormatSurface,它等同于OMX的OMX_COLOR_FormatAndroidOpaque

demo如上。简单说明下,核心操作是前两行设置格式。其他的如创建Codec、传递Surface到VirtualDisplay均为常规录屏应用的”死“代码(任谁来写都是这么写的,也是case应用的本来就实现的代码)。case应用之前通过SurfaceTexture创建Surface,它仅支持GPU合成(SurfaceTexture的特性)。

运行该demo,通过dumpsys SurfaceFlinger查看Virtual Display的各个Layer的合成类型,可以看到从原来的CLIENT类型变成了DEVICE类型。用相同的环境、手法测试,发现帧率按照预期从30帧恢复到了60帧! 表明我们的方案取得了彻底的成功——通过这个解决方案,不仅验证了HWC合成受到Surface的格式(底层的Graphics Buffer)的限制,还验证了DEVICE合成的性能优化可行性。

总结

SurfaceFlinger合成中没有利用上HWC可能导致卡顿掉帧。而HWC合成条件比较严格(跟硬件平台本身有关、系统底层实现有关)。应用层尽量使用契合HWC条件的Surface能够尽可能保证使用HWC进行合成——将Surface设置为COLOR_FormatSurfaceOMX_COLOR_FormatAndroidOpaque)。

成果展示

  • 优化后的效果
Average elapsed 8.60 ms
1.45 0.56 5.13 1.46
  • 优化后看看Perfetto

  • 验证是HWC合成 通过dumpsys SurfaceFlinger可以看到,case创建的虚拟屏从CLIENT合成(GPU合成)转成了DEVICE合成(HWC合成)。

  • 通过gfx柱状图非常直观,对比文章开头的一幅图,优化效果极其显著。

注意,屏幕最右边有几个高耸如云的柱子,那个不是case后台录屏干的也不是前台应用干的,不知道这个系统里是不是有什么浮窗什么的干的(不是我没优化好)。

参考

  1. Android图形层垂直同步虚拟VSYNC机制
  2. 显示流水线VSYNC垂直同步