查看原文
其他

LWN大作:Android display pipeline本身以及进程调度!

LinuxEverything Linux News搬运工 2021-11-05

关注了就能看到更多这么棒的文章哦~

Scheduling for the Android display pipeline

January 16, 2020

This article was contributed by Alessio Balsini

原文来自:https://lwn.net/Articles/809545/


Android用户非常依赖设备上的display功能,基本上所有交互都要用到它。display性能非常重要。不过达到好的display性能可不容易,需要在许多方面都配合好,而kernel本身其实对此并没有完美支持。Android团队现在就在考虑能更好的利用和改进kernel中一些功能,从而提供尽量好的display体验。


display是由Android display pipeline来管理的,这是一个非常复杂的系统,其中有许多进程和硬件加速器参与其中,才能让用户程序正常执行并更新屏幕上的用户可见的内容。display pipeline本身负责生成display output,所以它的性能会直接影响用户使用设备时的交互体验。


因为现在移动游戏要求越来越高,因此display pipeline延迟需要尽量低,此外还需要保证稳定的frame rate(帧率),确保不丢帧。Android主要用在移动设备上,因此功耗和散热都非常重要,由此也引出了系统必须达到的一些要求,统称为低功耗需求。这些需求其实互相冲突,需要仔细调整这个pipeline中各位贡献者的workload分布情况,同时也需要能正确使用Linux kernel scheduler和CPU-frequency governor。这些选择最终都会直接影响系统的整体性能表现。


A short journey through the Android display pipeline


Android display pipeline是多个软件硬件配合在一起的功能模块,不仅一起配合生成display output,同时也会影响应用程序的执行进展。参与到display pipeline中的软件主要分为两部分,一部分是在application里面,一部分是在Android framework里面。这些软件模块之间会用ad-hoc(点对点)、zero-copy(避免进行数据复制)方式来传递数据。在这一小节中我们会把Android display pipeline的内部工作流程展示给大家,会随着各个模块的依赖关系来逐个进行简要介绍。


首先,一切都是从display controller(显示控制器)开始的。它不仅仅负责管理display buffer(显示缓冲区内存)和display configuration(显示配置),还要负责管理好display和其他系统的同步信号(synchronization signal)。每当display这边准备好了,可以接收下一帧要显示的数据的时候,display controller就会生成一个VSYNC信号,标志着整个display pipeline要开始动作了。这个信号会按照display的刷新率来的,因此是个周期信号:60HZ刷新率的display会每隔16.6667ms来生成一个VSYNC信号。VSYNC这个名字的由来就是参照了早期CRT显示器上的vertical synchronization的。


SurfaceFlinger里的DispSync线程直接负责把这个周期性的VSYNC信号传递给display pipeline中的其他模块。有些系统上会把这些signal定期跟Display Controller硬件的VSYNC signal重新进行对齐。DispSync也负责把VSYNC广播给application和SurfaceFlinger主线程,这个广播的时候会加上预先定义好的不同时间偏移offset。这里为什么对VSYNC广播加delay或者直接替换,后面会解释。


用户进程的user-interface thread从DispSync收到VSYNC信号的时候,就会从epoll()睡眠中醒过来,进行下列操作:

  • 处理input event(输入事件)

  • 执行应用程序开发者规定好的那些Animation callback。

  • 遍历View tree(译者注:Android的View采用了树状结构管理),排布好各个UI元素,生成一个RenderNode tree,其中包含了各个drawing command。

  • 把更新过的RenderNode tree传递给进程中的另一个名为RenderThread的线程。

  • 完成一些其他任务,包括cleanup清理工作和monitoring监控工作等,然后调用epoll()回到睡眠状态,等待下一次VSYNC。


RenderThread收到RenderNode tree并被唤醒之后,就会:

  • 从跟SurfaceFlinger共享的一个BufferQueue中获取到下一个output buffer,如果这个buffer还不可用,就等待它相应的release fence(释放fence,标志着这个buffer可用了)事件。

  • 优化draw command列表(例如有些被隐藏掉的图像元素的相关操作就可以删掉)。

  • 把这个draw command list翻译成GPU command。

  • 要求GPU根据这些命令来进行render。

  • 把output buffer enqueue(放入队列)到跟SurfaceFlinger共享的BufferQueue里。这个output buffer在enqueue的时候不需要等GPU完成,它包含一个hardware fence,所以GPU在完成这个frame的render工作的时候就可以通知到SurfaceFlinger。

  • 完成其他的一些收尾工作,回到sleep状态,等待下一个来自UI thread的request。

如之前所说,这里进行的所有内存分配以及display相关的数据传递都是zero-copy操作,这是通过一个ad-hoc的数据结构BufferQueue来完成的。user space和kernel space的同步动作——例如通知user space说GPU已经完成了对下一帧的绘图工作——是通过kernel里面实现的fence机制来确保的。


SurfaceFlinger的main thread也会被DispSync线程通过一个delayed VSYNC signal来唤醒。SurfaceFlinger负责把各个来源生成的图像粘合在一起(称之为composing),来源通常包括:

  • 当前正在屏幕上显示的应用程序,

  • navigation bar(导航条),是一些没有实体按键的设备在屏幕下方显示的一条区域,绘制了按键图标,

  • status bar(屏幕上方的状态条),显示了时间、剩余电量、通知信息的图标等等。


composition(就是上面说的图像粘合动作)通过2D compositor硬件来做,可以节省时间,这样就不需要使用GPU了,让它专心供application用来做render操作。2D compositor在这种工作上通常也比GPU更加高效和快速。在composition结束之后,最终生成的frame就可以送给display了。


下图是一个简化的流程,描述了这些模块是如何配合工作的。


What is the benefit of this complexity?


生成一个frame的工作被分配到了pipeline中的多个模块去,主要是为了增加并行性,减少系统瓶颈。在系统繁忙的时候,当display正在显示frame N的时候,SurfaceFlinger已经在compose frame N+1了,同时,在application内部的RenderThread已经在准备frame N+2,而UI thread已经在准备frame N+3了。

在这个场景中,application需要3个display周期才能把某个frame送到display上完全显示出来,这个虽然是个缺点,不过还是可以接受的。通常来说application自己的UI线程和RenderThread都会在同一个周期内完成工作,这样整个pipeline里的总延迟就是2个周期。此外,最理想的情况下,UI thread和RenderThread的工作都非常简单的话,那么application和SurfaceFlinger可以全部在一个周期内完成,这样一来latency就不超过一个frame了。只有SurfaceFlinger在app刚生成frame之后就马上开始工作,并且在VSYNC到来之前就把composited frame返回回去,才有可能出现这个最理想情况。下图描述了这里提到的一些场景:


最理想场景的实现,主要依靠DispSync在广播VSYNC signal给application和SurfaceFlinger的时候替换了VSYNC。如果每个模块都能在自己的一个周期内完成工作,不超出VSYNC周期,那么整个系统就会提供一个平滑的display output,完全依照着display的帧率。假如有任何一个模块没有及时完成,那么最终结果就是会发生丢帧。


Scheduling display pipeline entities


Android为所有SurfaceFlinger和Hardware Composer相关的task都设置了SCHED_FIFO的实时调度策略。因为这些工作都对latency非常敏感。此外,它们都是system services,因此它们的执行流程都是已知的,执行时间也都有限,确保了不会导致其他进程长时间拿不到CPU从而饿死。


application也是display pipeline流程中的一环,因此它的表现也会极大影响用户体验。可惜,app都是由第三方开发的,无法预测和确保它们在执行时是什么脾性。如果贸然把一个app配置成SCHED_RT这种实时的scheduling class的话,搞不好就会导致其他那些SCHED_OTHER级别的系统服务都被饿死,让系统出现不正常的行为。而application scheduler缺省使用的CFS(completely fair scheduler)调度策略则提供了很重要的功能,会确保各个task之间的公平,也跟CPU-frequency governor配合得比较好。


Schedutil, Linux, and Android


Android缺省使用的CPU-frequency governor就是schedutil,它会根据当前的runnable task的CPU占用率来决定当前运行这些task的CPU频率:利用率越高,CPU频率就越高。这个governor非常适合Android device的需求,甚至在Android中,它也会识别SCHED_RT task来处理。通常在mainline Linux kernel里面会在SCHED_RT task执行时使用CPU最高频率。


Schedutil会根据测量出来的系统利用率来选择一个适合当前workload的最低频点。如果各个task都是互不依赖可以完全并行执行,那么这个方案就能工作得非常好。不过在task互相之间有依赖关系的时候(就是某个task会在另一个task未完成时被block住),这种逐个task统计的机制就不适合了,无法代表整个task集合的整体需求。


比如在上面描述的情况下,schedutil看到RenderThread只占用CPU的50%的时间,那么它就会把CPU频率降到最高频点的50%。不过RenderThread需要等UI thread做完工作之后才能开始做事(针对同一帧来说,这两个task不能同时执行),所以RenderThread就无法及时完成任务了。



Android目前实现了一个机制来弥补这个缺点,名为“TouchBoost”。当用户在操作设备的时候,TouchBoost会把CPU频率设定到governor之前预设好的一个较高的频点以上一段时间。这个暴力的解决方案在用户操作设备的时候可以给display pipeline提供足够的计算能力。不过,当display pipeline的workload很低的时候,TouchBoost抬高过的这个CPU频点可能会大大超过display pipeline的所需,从而导致功耗浪费的同时其实并没有改善用户体验,这种情况应该尽量避免。


Some possible solutions


有许多不同方法可以解决这个问题,都需要对user space或者kernel space做一些改动。比如说使用一个不同的scheduling class,或者在Android framework里面实现一个feedback loop(反馈闭环)来帮助kernel里对互相依赖的进程统计CPU占用率时额外乘以一个系数,或者扩展scheduling机制。


CFS and Utilclamp


避免TouchBoost的提频过高的问题,最直接的方案就是实现一个类似的、更小巧的机制来预测这些互相依赖的task要想按时完成所必须的最小CPU占用率。这个机制需要能监控某个进程的所有线程的执行时间(在不同的设备、core、application、application当前状态、系统中其他任务情况下,都各有不同),还要有个API来通知kernel这些task有哪些性能需求。


Utilclamp就是Patrick Bellasi开发的一个机制,可以允许user space来限制kernel所测量的若干个进程的占用率数值,对它设置最小或者最大的极限值(译者注:clamp就是夹子,形象地说明了设置最大最小值的操作)。此机制可以允许user space来改变kernel在处理这些task时候的行为,确保CPU会在这些task执行时把频率限定在我们指定的范围。配置这些utilization clamp参数的话不仅会影响CPU frequency,同时也会影响非对称CPU架构(例如big.LITTLE)下task运行在哪个CPU上。


例如,一个短小并且执行间隔很长的task会表现出非常低的占用率,不过如果user space知道这个task必须要能尽快完成的话,就可以设置clamp来调整这个task的占用率在kernel算出来的数值下限,从而让它运行在高性能、高频点的CPU上。还有个例子,也许有个批量处理的task在kernel看来有接近100%的CPU占用率,但是user space明确知道这个task可以在后台执行,那么就设置clamp来限制它的最大占用率,这样就能让此任务在低频、性能较低但是更加省电的CPU上。


就拿Android display pipeline的具体情况来说,Android framework可以算出一组进程(包括UI thread和RenderThread)的正确的CPU占用率,然后利用Utilclamp来强制kernel做决策时要利用这个已知值,而不是仅仅依赖kernel自己测量出来的单个task的占用率。这个方案可以帮助定义一些互相依赖的task的占用率,不过,UI thread和RenderThread则会一起跟其他不那么重要的CFS task来争抢CPU,并没有标明自己是需要尽快完成的有明确实时性要求(即有deadline)的task。


此外,很难利用主流的CLOCK_MONOTONIC和CLOCK_THREAD_CPUTIME_ID clock来对某个task的执行时间给出一个精确估计,因为这些测量方法都受CPU频率的影响,也受此CPU的capacity的影响。此外,Linux kernel一直没有办法把某个进程归一化之后的(不受capacity和frequency影响的)执行时间提供给user space。


SCHED_RT


如果我们的application所执行的task非常重要,那么为什么不把它们配置为SCHED_FIFO或者SCHED_RR这些realtime class呢?这个方案就可以解决此进程和CFS service的竞争,并且,因为SCHED_RT情况下的运行频率也是由schedutil决定的,跟CFS采用的是同样的非常重视能效的一些考虑因素,并且也能使用上面说到的utilization-clamping机制。


这里的问题在于,虽然我们进程运行的任务确实非常重视性能表现,但是并不是它的每一行代码都需要高优先级完成的,也有可能会导致其他CFS的系统服务被饿死。Real-time thottling机制可以减轻这个问题,但是它会导致此application可用的资源变少,从而碰到一些功能性问题。而Daniel Bristot de Oliveira提供的RT_RUNTIME_GREED方案可以缓解这个“资源变少”的问题,因为它会在没有其他non-realtime task要运行的时候再把已经被throttle控制住的real task再拿回来继续运行。


这个方案里面还是缺少方法来指明task本身的deadline特性。


SCHED_DEADLINE


SCHED_DEADLINE这个全新的scheduling class有许多特性都对Android display pipeline有好处。应用了SCHED_DEADLINE的task不会被其他可以访问user space的scheduling class抢占。并且这个scheduling class会指明某个task的deadline,可以用来动态调整调度决策。SCHED_DEADLINE同时还要求对这些task定义清楚所需的run time,这样它既可以用来进行task throttling,也可以用来预估CPU占用率从而协助选择CPU频率以及使用哪个CPU。


可惜,SCHED_DEADLINE也有若干缺点,无法直接使用。它没有完善的优先级继承机制,无法很好的管理这些互相依赖的task。不过现在有个proxy execution机制(https://lwn.net/ml/linux-kernel/20181009092434.26221-1-juri.lelli@redhat.com/  )正在讨论,应该可以解决此问题。


此外,它的bandwidth-throttling机制有点过头了,如果给某个task设置的run time太少,会导致错过deadline。这样一来,在设置run time的时候需要加上一些额外的margin,从而导致其他SCHED_DEADLINE task本可以使用的一些CPU时间就用不上了。假如使用了schedutil的话,这会导致CPU频率过高,浪费功耗。跟SCHED_RT的时候一样,application thread不是完全time-critical的。它其中不属于time-critical的部分,本应该用CFS方式来调度,而不应该以SCHED_DEADLINE task的身份来占用运行时间。


对那些会self-suspend的task使用SCHED_DEADLINE的话,也会有一些问题。比如,RenderThread可能会被阻塞注,等待某个output buffer从BufferQueue中dequeue出来,这就是一个例子。如果SCHED_DEADLINE task在暂停之后被唤醒,kernel可能会推后它的deadline,降低这个task的优先级,从而如果它被另一个SCHED_DEADLINE抢占的话可能导致错过deadline。


在选择把task放到哪个CPU上运行的时候,SCHED_DEADLINE也没有考虑CPU capacity。在非对称CPU架构上,它可能会把一些高带宽的task放到slow CPU上去,导致错过deadline。目前有个Capacity awareness patch set正在讨论,应该可以解决这个问题。最后,SCHED_DEADLINE实现的scheduling policy无法处理那些有重合部分的cpuset cgroup。所以无法定制CPU affinity配置,要想用cpuset来选择所有CPU的某个子集的时候,必须要明确指定,需要root权限(译者注:这里不是很确定翻译正确,原文是root domain)。


SCHED_DEADLINE with token passing


除了上述介绍的数据传递流程之外,应用程序的UI thread和RenderThread的交互也是一个关键环节,因为这里的操作是顺序执行的。


看待这个问题的时候,除了考虑task的deadline之外,还可以把deadline和数据结合起来看。等application醒来之后,UI thread在把draw command list发给RenderThread之前,所做的事情都非常紧急。接下来RenderThread在把生成的frame发送给BufferQueue之前所做的事情都非常紧急。上述环节的共同deadline时间点实际上是SurfaceFlinger醒来处理BufferQueue进行composition的时间点。这样一来,就可以看做其实只有一个task,而这个task的deadline是SurfaceFlinger的唤醒时间点,此task的任务就是完成UI thread和RenderThread中的critical section(即需要快速执行完成的代码)。


这个方案可以通过扩展scheduler API来实现,就是增加一个token-passing(令牌传递)机制,用来交换两个task的调度特性。UI thread本来是SCHED_DEADLINE类型的,可以在draw command list交给RenderThread之后,也把它自己的调度参数传递给RenderThread,后面RenderThread也会把SCHED_DEADLINE token还给UI thread。



在application中那些不太紧急的部分,本来也会占用deadline的时间。用这个方案之后就可以把这部分排除出去,并且不需要任何优先级继承(priority-inheritance)和deadline-synchronization机制。


不过,在系统负载过重的时候,Android display pipeline则可能让UI thread和RenderThread同时运行,此时就只好按照CFS方式来进行调度,或者需要使用两个SCHED_DEADLINE token在UI thread和RenderThread之间交换,这样两者之间就需要一个更加复杂的同步机制。


Hierarchical scheduling: SCHED_DEADLINE on top of SCHED_RT


如果考虑更广泛的一个场景,系统中如果有许多组需要互相协作的realtime task,那么就需要采用另一种不同的方案了。通过把SCHED_RT里面的realtime throttling机制替换成SCHED_DEADLINE entities(实体),多个互相合作的task可以共用同一个deadline时间点,成为一个特殊的SCHED_DEADLINE entity,统一调度。在这个scheduling entity内部的多个进程可以采用SCHED_RT或其他策略。


这种方案不仅可以简化这个task集合的实时性分析,此外也能够减少SCHED_DEADLINE bandwidth pessimism(译者注:就是不用给deadline留太多margin,见下一句解释)。选择整个task group的run time的时候,多个task都走到最坏情况的可能性非常低,所以给这组task分配的总共的run time,肯定小于把单个task的最坏情况执行时间加起来的总和。


另外,这个方案却也额外增加了scheduling overhead,因为有了两层调度决策,同时也会有许多的task migration(就是task在CPU之间的迁移)。这个方案也会把SCHED_DEADLINE所拥有的那些问题引入其他的scheduling class,例如CPU affinity问题。因此这个方案不太合适。


本节中讲到的hierarchical scheduling algorithm(多等级调度算法)理论最早是由Leontyev和Anderson提出的(https://www.cs.unc.edu/~anderson/papers/ecrts08c.pdf  ),Linux kernel中最早实现出来的版本是Andrea Parri,Mauro Marinoni, Juri Lelli, Giuseppe Lipari实现的(http://retis.sssup.it/~nino/publication/rtlws14bdm.pdf  )。为了能够确保实时性,就需要额外的overhead,包括这两层调度层本身的overhead,加上对task group进行migration(因为它在当前CPU上分配的执行时间已经用完了)的时间。


Conclusions


这里介绍的多种方案每种都有一些缺陷,因此都没能脱颖而出为Android display pipeline所用。每种方案都依赖一些尚未完全合入Linux kernel upstream的工作。而如果不使用mainline Linux代码的话,则会导致这些改动在今后kernel升级的时候变得非常困难。


目前还没有一个明显的胜出者,采用任何一种方案都会有许多工作,冒不少的风险,因此需要非常仔细的审查、计划。不过,所有这些方案都有可能成功,逐步完善这些缺陷之后都可以马上在许多应用场景里面产生好处,从而也非常可能成为未来Android display pipeline的基础。



全文完

LWN文章遵循CC BY-SA 4.0许可协议。

欢迎分享、转载及基于现有协议再创作~


长按下面二维码关注,关注LWN深度文章以及开源社区的各种新近言论~



: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存