深挖 WPF 渲染系统

这是一篇2011年的文章,原地址在 A Critical Deep Dive into the WPF Rendering System,个人认为到现在都可以给WPF程序开发人员作为一个参考,里面详细讲述了 WPF 这个号称从底层支持硬件加速的 UI 框架为什么有时候看起来并不是那么回事的原因。以下是正文。


刚开始我并不认为我会发表这篇文章。在被一些我高度重视的人说服后,我决定要发表这篇文章。那些深入投入微软UX平台的程序员应该更加深入了解这个平台内部是怎么工作的,当他们撞到一睹石墙的时候,他们可以清晰了解到问题所在并且更加准确地沟通他们希望平台做出怎样的改变。

我相信 WPF 和 Silverlight 是精心打造的技术,但是…

如果这几个月你有关注我的Twitter,你也许会发现我一直在吐槽WPF(Silverlight也一样)的性能。为什么我会这样做?毕竟这些年我花费了大量的时间为这个平台布道、写库、社区帮助和指导等等。准确来讲,我个人全身投入到这个平台里面,我希望这个平台越来越好。

性能,性能,性能

当在开发沉浸式的、消费者导向的UX的时候,性能是你第一位的特性。性能是你添加其他特性的前提和基础。有多少次因为UI太卡你需要缩小UI的规模?有多少次因为这个技术做不到所以你需要丢弃“开创性的UX模型”?有多少次你告诉客户他们需要一个2.4GHz的四核CPU才能获得全部的体验?我一直以来都被客户问,为什么在PC上拥有4倍于iPad的性能的情况下,WPF程序却做不到像iPad应用那般流畅?

我觉得 WPF 是有应用硬件加速技术的?告诉我为什么你觉得它效率低。

WPF是有硬件加速的,并且其内部工作的某些部分也是很简洁的。不幸的是,它并没有像它应该能够做的的那样来高效率地使用GPU。它的渲染系统是非常暴力的。我希望在这里解释我为什么这样说。

分析一个完整的 WPF 渲染流程

为了分析性能,我们需要知道WPF底层到底在干什么。要做到这个,我使用“PIX”工具,这是DirectX SDK里的一个Direct3D性能分析工具。PIX会加载基于D3D的程序并向所有的Direct3D调用注入钩子来分析和监控程序。

我创建了一个简单的WPF程序,里面从左到右有两个圆形。每个圆形有相同的填充色(#55F4F4F5)和一个黑色的描边。如下图:

WPF 程序是怎样渲染这个程序的?

WPF做的第一件事情是清理出一块需要重新绘制的废弃区域。废弃区域的作用是减少发送给GPU管道中输出合并阶段的像素量。我们甚至可以猜测,这样可以减少需要重新进行曲面细分的多边形数量(稍后会提到)。废弃区域清理后我们的帧看起来像这样:

接着,WPF做了某些我不能理解的事情。它首先填充了一个顶点缓存区,然后这就好像在废弃区域上绘制了一个四边形。所以现在的帧看起来是这样的(兴奋吧?):

下一步,它在CPU上对一个圆形进行曲面细分。曲面细分,你或许已经听过,但是重要的是它将我们100X100的圆形多边形变成了一堆三角形。会这样的原因是1)三角形是GPU渲染的基本单位。2)对一个圆形进行曲面细分可能只是几百个顶点,所有它比在CPU上对10000个反锯齿的像素进行栅格化(Silverlight就是如此)要快得多。
下图是这次曲面细分看起来的样子。对于熟悉3D编程的人来说,你们或许已经注意到这是一个三角形条带。注意,在曲面细分中圆形看起来并不完整。WPF然后将这次曲面细分的结果加载到GPU顶点缓存区,并根据在xaml中定义的画笔颜色,使用像素着色器来发起另外一个绘制命令。

还记得我提到这个圆形开起来是不完整的吗?事实上它的确是。WPF然后生成一种Direct3D程序员知道的“线序列”。GPU像理解三角形一样理解直线。WPF将这些线条填充到一个顶点缓存区中…然后你可以猜到!又发起另外一个绘制调用。这是这些线条集合看起来的样子:

所以,WPF已经完成了对圆形的绘制,对吧?并没有!你忘了它还有一条外描边!外描边同样是一个线条集合。这个集合会被发送到GPU的顶点缓存区并且另外一条绘制命令也会被调用。这是描边看起来的样子:

到目前为止我们完成了一个圆形的绘制,我们的帧看起来是这样的:

这个流程在这个场景下要为每个圆形进行一次。在这里是两个。

我就不明白了,为什么这样对性能有害?

你应该注意到的第一件事情是为了绘制一个圆形它需要调用3次绘制。在这3次绘制调用中,相同的顶点缓存区被使用了两次。为了解释为什么只是低效率的,我需要稍微解释一些GPU是怎样工作的。首先,今日的GPU以非常快的速度处理数据并且异步地和CPU一起运行。并且,在确定的操作中存在耗时的用户态到核心态的切换。在这个例子里,在填充顶点缓存区时一定要锁定它。如果当前GPU正在使用缓存区,这样会导致GPU与CPU同步,引起性能瓶颈。顶点缓存区是通过 D3DUSAGE_WRITEONLY | D3DUSAGE_DYNAMIC创建的,但是当它被锁定时(事实上经常发生),D3DLOCK_DISCARD 并没有被使用。如果缓存区正在被GPU使用可能会导致GPU“失速”(CPU与GPU同步)。在存在很多绘制调用的例子中,我们可能有大量的内核过渡和驱动负载。高性能的目标是发送尽量多的工作给GPU,否则你的CPU会忙的要死而GPU却非常空闲。同样,不要忘了在这个例子中我只谈到一帧。典型的WPF UI会尝试在每秒里处理60帧!如果你曾怀疑过那些渲染线程的高CPU占用是从哪里来的,你会发现许多(大部分)是来自GPU驱动。

那Cached Composition呢?这真的能提升性能

毫无疑问的确如此。Cached Composition,也就是BitmapCache,通过缓存GPU贴图的图像来工作。这意味着你的CPU不用重新对图像进行曲面细分,GPU也不用重新栅格化。在一次渲染当中,WPF通过使用显存中的贴图来进行渲染以提高性能。下图是一个圆形的BitmapCache:

WPF在这方面也是存在缺陷的。对于每一个它遇到的BitmapCache,它发起一次绘制调用。公平来讲,有时候在某些情况下为了渲染图像你必须发起一次渲染调用。这是自然而然的。但是我们举一个例子:在一个Canvas里面填充300个BitmapCache圆形。一个高级系统会知道它有300个依层次排列的圆形。然后向DX9一次处理16个采样输入那样,尽量多地批量处理这些圆形。在这个例子中,可以将300次绘制调用降低到19次并节省可观的CPU负载。在60FPS的情况下,我们可以将每秒18000次的绘制调用较低到1125次。在Direct3D 10中,一次能够处理的采样输出(sampler input)还要高得多。

好的,我读到这里了,告诉我 WPF 是怎样处理像素着色器的!

WPF 有一个可扩展的像素着色器API,一起的还有一些内置的效果。这允许程序员为他们的UI添加一些非常特别的效果。在Direct3D中,当你要为一个贴图添加着色器,典型的做法是使用intermediate rendertarget…毕竟,你不能对你正在写入数据的贴图进行采样。WPF 也是这样的,但不幸的是,它每次都会为每一帧生成一个新的贴图并且在完成后将贴图销毁。在每帧中,生成和销毁GPU资源是其中一件你能做的最慢的事情之一。如果这些中间的资源能够被重复利用,在使用着色器时将可能有一个可观的性能提升。如果你曾因为在使用这些硬件加速驱动的着色器而导致明显的CPU占用问题而搞到困扰时,这就是原因。

或许这才是矢量图形在GPU渲染的方式!

微软花费了大量的努力来解决这些问题,不幸的是这并不是针对WPF。答案是Direct2D。考虑这组在Direct2D中渲染的9个描边的圆形:

还记得WPF渲染一个描边的圆形需要多少次绘制调用吗?以及多少顶点缓存区被锁定?Direct2D只需要1次绘制调用。这是曲面细分看起来的样子:

Direct2D尝试在一次中尽量绘制多地内容,最大化GPU使用并且最小化不必要的CPU负载。在这篇文章“Insights: Direct2D Rendering”底部,Mark Lawrence,使用大量细节解释了Direct2D的工作方式。你深入了解会发现,即使Direct2D运行的非常快,在很多方面它都可以在第二版本中进行改进。相信Direct2D的第二版会支持DX11的硬件曲面细分技术想必也不是那么不合逻辑。

查阅Direct2D的API,说其中很多代码就是从WPF里面拿的,也不是什么疯狂的想法。如果你看这个老视频,Micheal Wallent确实说过从这项技术上去创造一个原生的GDI技术作为替代。它有相似的几何图形API和命名。内部也坐着很多相同的事情,但是却更加优化和现代。

那Silverlight呢?

我会深入讲Silverlight,但可能有点多余。Silverlight的渲染性能是低效的,但是因为另外的方式。它在CPU上进行栅格化(即使是着色器,如果我没记错的话,是使用汇编语言写的),但是CPU起码比GPU慢10-30倍。这导致你只有相当少的能力渲染UI,甚至更少的能力给到程序逻辑。它的硬件加速是非常基础的,并且几乎和WPF的Cache Composition一样,对于每个BitmapCached图像都要调用一次绘制调用。

我们能怎样做?

这是一个我的客户在使用WPF或者Silverlight遇到性能问题时提到的共同问题。不幸的是,我也没有一个答复给到他们。有些可以为了他们特定的需求使用自己的框架。其他的,我尽量倾听,但是他们必须忍受它,毕竟并没有其他丰富的选择来替代WPF和Silverlight。

评论