一张 WWDC 幻灯片

2019/04/04

之前的 blog 里提到过,尽管早有愿望学 ray tracing,但 2018 前半年总提不起兴趣动手。六月 Apple WWDC 2018 里关于 ray tracing 的 session 成为我第一次真正接触 ray tracing。望着讲台上削减至极的幻灯片和 speaker 超快的解说,我想这似乎并不难,大概下周给同事总结 WWDC 时就可以自己解释这个问题。

实际上到现在过了十个月,才能说大致理解了这张幻灯片的数学原理。一个简单的 demo 固然能推进学习的进度,但是也可能引入误解。

渲染方程

上篇《 Wavefront OBJ 与 Monte Carlo 》第一次尝试总结对 ray tracing 的理解。其中以下面的积分式求 radiance (后面称该积分式为,其中 n 表示积分层数):

但根据渲染方程到达 camera 光线的 radiance 并非,而是:

这就是渲染方程的 light transport equation 形式。只是整个 LTE 中的单个 term,甚至没有明确的物理意义和常用名称。一般将其理解为光源经过 n 次反射/折射后对最终 radiance 的贡献。例如 n 取一时表示光源直接射入 camera,取二表示 direct lighting。

也就是说,上篇 blog 将直接作为 Monte Carlo 方法的求解对象的分析有些粗糙,需要修正为更严密的解释。

随机过程与「物理模拟」

在应用 Monte Carlo 方法分析之前,先说一下 Monte Carlo 方法的随机过程和 ray tracing 物理意义的关系。

当用简单例子 —— 比如说一元函数 —— 来说明 Monte Carlo 方法时,很明显会把它理解为一个抽象数学工具。把它应用在 ray tracing 问题时却容易误认为 Monte Carlo 方法的 sampling path 在模拟物理光线。如果在分析 ray tracing 问题之前没有把「path 不是光线」这个座右铭刻在脑子里,思维很容易走入歧途。

之所以 path 容易被误认为在模拟光线,是因为二者有些似是而非的相似性。最为显著的一点是,在定义渲染方程时,确实是以光线的物理特性为基础。但是最终定义得到的 LTE 的积分形式表明 radiance 的分布达到了 equilibrium 状态。这是统计上的确定状态,单条光线的特性已经被抛弃。之后对这个积分式采用的 Monte Carlo 方法是一种纯粹人造的随机过程,path 是纯粹抽象的随机取样。为了更准确的强调这点,本文描述的这种 Monte Carlo 求解 LTE 的方法不再被称为 ray tracing,而是叫 path tracing。

这里要提到的关于 Monte Carlo 容易被误解的最后一点是 estimator 和积分形式的对应和区别。Estimator 是随机变量,其数学期望等于它所对应的积分式的值。因此 estimator 和积分形式绝不能同时出现在一个公式中。不同随机过程产生的 estimator 也不能在一起运算。看起来很愚蠢,但我不知道反复卡在这个误解上多少次了。就在写这篇 blog 的过程中都曾经把两种形式写在一个式子中。

忽略光源位置的 Path Construction

回到之前的问题:需要通过 Monte Carlo 方法求解的是整个 LTE,而不仅是单个

在 Monte Carlo 方法中只要取样足够多,最终结果的正确性和 path 的构建方法没有关系。不妨先讨论在构建中完全不考虑光源位置,也就是单纯依靠 reflection sampling 的方式。Reflection sampling 可以采用任何随机概率分布,比如 uniform sampling,当然更常见的做法是根据 BRDF 做 importance sampling。

根据上篇 blog 中的 estimator 可以推导出 LTE 的 estimator 为:

根据常识可以假定自发光物体表面的为零 —— 光源本身不反射任何其它来源的光线。所以构建过程遇到第一个自发光物体就结束。并假定在光源之前的遇到的所有表面点的都为零 —— 普通材质不发光。在这些条件下,每条 path 的 LTE estimator 值只剩下一个 term:

其中 c 表示遇到光源时的路径长度。代码实现可以给 c 设置上限,达到上限依然没有遇到光源就认为这条 path 的 LTE estimator 为零。由此可以看到仅用 reflection sampling 构建 path 时上篇 blog 的结论依然成立,仅仅是原来的 n 被意义更加准确的 c 代替。

考虑光源位置的 Path Construction

但是,仅仅依靠 reflection sampling 构建 path 与光源相交的几率很低,结果的收敛速度很慢。因此大多实现在构建时会将最后一段 path 的终点取在光源表面上。这样的方式中 path 不是随机遇到光源终止,而是被「强制」结束,也就是说 LTE 不再作为整体在 path space 中被随机采样,只能为每个执行独立的 Monte Carlo 过程来分别近似积分。因此虽无直观的物理意义,但仍是 path tracing 中的重要概念。

每个的 estimator 如下:

这里的表示物体表面 reflection sampling 的概率分布。表示最后一段向光源表面取方向的概率分布。如果在表面积为 A 的光源上做 uniform sampling,则有:

每个的 estimator 应该由不同而且相互独立的 Monte Carlo 过程取得,各自收敛的数学期望近似值才可以相加。但在实际工程中,通常用下面的近似式作为一个整体的 estimator。

这里的「取巧」之处在于的 estimator 前 n-1 个采样点和的 estimator 的采样点相同,在本该分别采样的多个独立随机过程中引入了 correlation。但是通常认为这对结果的影响可以忽略。其 path 构建过程相当于从 camera 发出的 path 每次遇到物体表面就随机产生两条 path,一条强制射向光源表面上的随机点,另一条通过 reflection sampling 而得。

回顾和比较

经过上面的分析之后,可以看到仅仅依靠 reflection sampling 的方法数学上更简单,实现也更容易。所以有些 ray tracing 的介绍材料 —— 比如《 Ray Tracing in One Weekend 》—— 就采用了这种方式。但这种方式存在一个陷阱,就是容易让人误认为 Monte Carlo 方法是物理模拟。一些材料出于「形象化」考虑也确实如此误导解释。WWDC 的 demo 没有采用这种方式,我读过其代码之后第一件事就是误打误撞把它改成了只有 reflection sampling 方式,也是潜意识里向「物理模拟」靠拢的体现,这个思路可能歪打正着写出基本功能,但要想进一步改进代码就会缺乏理论基础。

考虑光源位置的 path construction 是高级 ray tracer 的通常做法。它的难点在于实现的近似简化。实际代码往往不会真正独立地执行多个随机过程,而是如上所说通过共享随机采样用一个随机过程近似多个 Monte Carlo 过程。本来多重积分用 Monte Carlo 方法近似就是比较难理解的过程,再把多个独立的 Monte Carlo 过程近似成一个随机过程就更增加了难度。WWDC 的 demo 采用的基本是这种方法。但是在材质和光源上都做了进一步简化。特别是光源采样时完全忽略了对的处理。所以将其改成只采用 reflection sampling 方式之后和原来的 demo 并不等价。在理解其中的数学解释之前,渲染结果的差异一直让我困惑。我曾经以为 WWDC 的 demo 起到了带初学者入门 ray tracing 的作用,其实它的用途仅仅是给真正理解 path tracing 的老手展示 GPU 加速功能。

Wavefront OBJ 与 Monte Carlo

2019/02/25

去年十月搬家到现在一直没有对 Nuo Model Viewer 做大改动。年前忙于布置新家。到新年前后稍感安定想增加些新功能,但越想越感觉已有的 ray tracing 代码缺乏数学依据,于是开始温习数学概念。

粗糙的实现

首先,考虑仅支持理想方向光源直射,以及理想纯漫反射材质 (Lambertian) 的 shading 公式 [1]:

这是两年前开始做 Nuo Model Viewer 时最先实现的公式。其实要分析很多细节才能保证完全正确的实现 —— 如何从模型材质描述中得到,如何处理 π 相关的系数,场景的光源描述如何与相对应。对于 Wavefront OBJ 格式来说,几乎找不到直接阐述 Kd 参数和的关系的文档。因为当时只支持直接光照和理想方向光源,对这些细节的分析都偷懒了,在用户界面上放个 slider 解释为,其中 k 为任意系数。如此一来 Kd 参数和的系数关系以及光源描述如何与相对应都无需精确考虑,通过 slider 的取值进行补偿达到最终结果美观即可。

去年实现了几个基于 ray tracing 的功能:area light source 产生的软阴影;ambient 环境光 [2];自发光物体的光照。当时并未多想通过 slider 补偿系数的做法是否还成立。现在发现,要继续做新东西有必要彻底理解这些细节。接下两节先分析 shading 公式中具体系数的意义。然后解释实现 ray tracing 要注意什么。

光源的理解

引入 area light source 后,光线方向不再限于单一角度。直接光照公式变为积分形式(这里只考虑 Lambtian 材质,在所有方向上为常数):

若将理想方向光源应用于积分形式,即为对单一方向上的冲击分布积分:

这与一开始的简单公式非常类似,要让二者等价只需保证。下面就来看看、以及 Kd 参数的关系。

材质的理解

、和 Kd 中,的物理意义最明确,是材质表面反射能量比值的微分形式,其一般形式定义如下:

现在来寻找的物理意义。如果去掉直接光照公式里的,即认为其在各个方向均为 1,就得到了材质的 directional-hemispherical reflectance,对一般材质和 Lambertian 材质分别是:

按照这个定义,就是 Lambertian 材质的。在《Real-Time Rendering, 3ed》里称其为 diffuse color。

现在看来,有两点证据说明 Wavefront OBJ 中的 Kd 就是:第一,Kd 在很多文档中也被称为 diffuse color;第二,对物理真实材质来说,能量守恒定律要求 小于 1,而 Kd 的取值范围一般是 [0, 1] 区间 [3]。这两点都不能说是完全确定的证据,但 Wavefront OBJ 是个老旧的格式,其材质描述方面的正式文档很少,这大概是可以得到的最好解释了。

Monte Carlo 方法

前两节解释了 shading 公式的细节,现在回到原来的问题 —— 加入基于 ray tracing 的功能之后是否还能用 slider 补偿系数关系,需要做出什么改变?首先从 ray tracing 使用的 Monte Carlo 方法说起。用简单的一重积分来说明,若求积分 [4]:

可以在 [a, b] 区间内通过均匀随机分布取一组 sample ,通过它们取得近似的结果:

或者用任意的随机分布来取样,近似结果为

 

这里求和式中的单项被称为 Monte Carlo estimator:

 

把应用扩展到多重积分,如果用均匀随机分布取样求积分:

那么其 estimator 为:

现在可以想到,若 ray tracing 实现里涉及多重积分,且每重都包含一定的系数关系,就不一定能用界面上的 slider 进行补偿。下一节分析在 ray tracing 多重积分中的系数关系。

Monte Carlo 方法与 Ray Tracing

现在把 Monte Carlo 方法应用到光线的路径函数上。表面反射次数为 n 的光路上的传播函数为 [5]:

去年给 Nuo Model Viewer 加入环境光和自发光物体光照时,并没有认真保证代码准确反映上面的多重积分公式。相反,当时还是惯性思维地觉得凭 slider 就能补偿一切系数问题,所以凭感觉写了一个 estimator:

现在来看数学上正确的 Monte Carlo estimator,如果采用均匀随机分布:

如果只考虑 diffuse 效果:

它们都带有含 n 次方的系数。在一个场景的 ray tracing 中,每条路径的 n 可以不同,可能是随机遇到了光源 (自发光物体),也可以是 tracer 主动在某个长度上向光源直接采样。看起来 Nuo Model Viewer 的效果正确性令人担忧。不过 Nuo Model Viewer 并没有采用均匀随机分布,而是以 cosθ 成正比的概率分布做 importance sampling。那么正确的 estimator 应该是:

其中的 k 是保证  归一化的系数,满足:

将 k 带入原式,得到:

居然恰好和最初乱写的一样!几个月没动代码,但总算搞清了其中的意义。

脚注:

  1. 见《Real-Time Rendering, 3ed》,p110。
  2. 不是基于 occlusion 计算的阴影,而是基于 ray tracing 直接计算 ambient lighting。
  3. 如果加入对 specular term 的考虑,并且类似的考虑 Ks 参数,则需要满足 Kd + Ks < 1。这在绝大多数 OBJ 材质中也是满足的。
  4. 本节中的 ) 表示任意函数,注意不要和下文中的 BRDF 分布函数混淆。
  5. 见《Physically-Based Rendering, 3ed》,p867。这里把公式改写为 solid angle 形式,并且用嵌套括号强调了其递归模式。简单的乘法结合交换就可以变成单个多元函数的多重积分。

从 Metal 看 Vulkan —— 重用还是重建

2018/06/04

Apple 给自己的图形系统取名 Metal 之意在于强调其开销很低,图形应用程序如同直接运行在「金属硬件」( bare metal ) 上。但对 Metal 和 Vulkan 都有了解的人直观上会感觉后者更复杂也更直接反映硬件细节。「Metal 并不能像 Vulkan 那样程度地直接操作硬件」是 Khronos 和 Vulkan 拥趸的一贯观点。

Metal 和 Vulkan 的差异有很多值得讨论之处。绝大部分是前者相对后者在概念上的合并和简化。本来想写篇长文逐一说明,后来决定先拿出 commnad buffer 重用这个问题在一篇 blog 里详谈。

Metal 和 Vulkan 的 command buffer 作用基本相同,用来记录一组 command [1]。记录完成后通过 commit/submit [2] 发送给 GPU 执行。Metal command buffer 在 commit 之后从 CPU 看来已成为「废弃」状态,除了等待其在 GPU 执行完毕之外不能再进行任何操作。而 Vulkan 可以在一个 command buffer 被 submit 并执行之后,再次 submit。这就是 Metal 缺乏的 command buffer 重用功能。

Command buffer 重用的作用如何?下面对比普通数据 buffer 的用法来衡量一下。这里不谈体积较大且很少变化的 vertex/image buffer 等等,只谈体积较小而且每个 frame 都会变化的参数 buffer。不论 Metal 还是 Vulkan,数据 buffer 都可被重用。实际上,低开销图形系统都建议在开始渲染前预分配所有数据 buffer。但是这里有一个概念被偷换的问题,数据 buffer 的「重用」并不等同于 command buffer「重用」。前者只是表明省略了「分配」步骤,但是 buffer 的内容依然可以,而且在绝大多数情况下确实每个 frame 都被 CPU 更新。已经记录完毕的 command buffer 是一个 immutable buffer,重用 command buffer 是原封不动的重用整个 buffer 在上一个 frame 用过的内容。所以通常意义上的数据 buffer 重用是指「预分配重用」,而 Vulkan 所谓的 command buffer 重用是指「内容重用」。之后的讨论中会避免不严格的「数据 buffer 重用」和「command buffer 重用」的说法,而是在不同 buffer 类型的使用场景中讨论「预分配重用」和「内容重用」。

不论 Metal 还是 Vulkan,command buffer 的「预分配重用」都不是一个问题。因为任一时刻内同时被使用的 command buffer 数量不多,不存在数据 buffer 那种动态分配的复杂性,所以 command buffer 总是从一个 pool 中进行分配,并不存在分配开销很大的问题。虽然在 Metal 中 command buffer 被设计成一种生命周期通常限于单个 frame 的 transient object,但是从「预分配重用」方面看和数据 buffer 并没有区别。

另一方面设想一下能否把「内容重用」应用到数据 buffer 上。这其实不太容易。渲染中每个 frame 的资源需求基本相同,「预分配重用」只需要用简单逻辑估计所有 frame 需要的资源之和进行分配即可。但是渲染中数据内容的变化是复杂的,判断数据是否在 frame 之间发生了变化需要复杂得多的逻辑。判断数据变化的逻辑本身就会很复杂,而且由于涉及 triple buffer 或者 swap chain 机制,衡量数据变化的时间段并非两个直接相邻的 frames,而是要基于整个 in-flight frame 的周期,且不同 in-flight frame 周期之间是相互重叠的。所以在实际工程中,通常在每个 frame 对数据 buffer 进行「暴力更新」而不考虑内容重用。这是软件复杂度的合理取舍。数据 buffer 如此,command buffer 的重用自然也面对同样难以绕开的困难。

上面的讨论围绕在不同 frame 间的资源重用。那么 command buffer 重用是否在这个场景之外有更好的用途?实际上,command buffer 和固定的 render attachments 绑定 [3] 决定了其在不同的 render pass 或者 frame 之间进行重用比数据 buffer 更加受限。同一 render pass 内又有 instance draw 这种更好的重用的方式。

Command buffer 重用看来是一个 API 设计欠考虑,实际适用场景罕见的功能。给人的感觉是因为 GPU 有了命令队列 replay 的能力,所以不加考虑就在 API 里放出来,「only because we can」。虽然现代 GPU 给了图形系统的开发者存储和重用更多类型数据的机会,但是如何设计对应的上层抽象是值得慎重考虑的问题。

脚注:

  1. Metal command buffer 并不直接记录 command,而是通过创建 encoder 对象来完成这个工作。Vulkan 虽然是用 command buffer 来记录,但在开始记录前会将其设置为一个或者多个 render pass,其开始和结束对应了 Metal encoder 的 life cycle。
  2. Metal 称为「commit」;Vulkan 叫做「submit」。
  3. 其绑定通过上面提到的 Metal encoder 和 Vulkan render pass 体现。

在谁的模式里思考

2018/05/08

CORBA 和 Java 还都很重要的时候,设计 CORBA 的 OMG 鼓吹所有编程语言都应该围绕 CORBA 的接口定义语言 (IDL) 进行面向对象设计;而 Sun Microsystems 则宣扬所有系统都要映射为 Java 的对象模式,数据库要用 Enterprise JavaBean,CORBA 也该被封装为 RMI over IIOP。这是本质一样而方向相反的两支传教队伍抢夺同一群开发者的口角。值得注意的是,这场博弈的每一方依然需要对方去做自己无法胜任的工作(尽管不是唯一的选择),基于 CORBA 的开发不能离开具体编程语言,Java 也需要远程调用实现,但都坚持宣称对方应该照自己的思维模式行事。

CORBA, EJB, RMI 都已被开发者忘记,但类似的博弈永远存在。 主流的硬件图形接口中,Direct3D 继承了 Windows 95 时代一切封装为 COM 的文化遗产(尽管 Microsoft 自己现在也对这个文化有点心虚),macOS/iOS 秉承采用动态语言的传统实现 Metal(尽管 Apple 在 Objective-C 和 Swift 之间如何分配资源还略难取舍),Vulkan 为了跨平台采用 plain-C 接口。

现在的游戏或者专业图形软件很少直接用硬件图形接口开发,而是通过封装抽象层支持多个具体的硬件图形接口。如此一来避免了选择支持何种接口的非此即彼的问题。所以「思维模式」的博弈远离了关于选择操作系统特有接口还是 Vulkan 的问题。影响开发者思维模式主要体现在抽象层设计上。硬件图形接口本身的影响力决定了大多数抽象层接口要如何设计,是否会向某个具体接口的设计靠拢。每个抽象层的设计和传播都会影响具体图形接口在开发者中的口碑和初学者的选择。

暂时不谈如何影响抽象层设计,Vulkan 在 macOS/iOS 上面临的首要问题是 Apple 并不支持它,压根儿不能用也就谈不上思维方式的传播了。但有热心的第三方做出 MoltenVK,用 Metal 实现 Vulkan APIs,绕过了 Apple 不允许硬件厂商直接发布驱动程序的限制。但作为 Metal 的 thin-wrapper,它没法实现完整的 Vulkan。其开发者解释为了性能和实现简单只实现 Vulkan 中可以比较直接地对应到 Metal 的部分,即便如此移植个 Dota 级别的游戏并不在话下。这样终于把 Vulkan 推广到了 macOS/iOS 上。

但操纵思维模式并不是件直截了当的事情,其作用和反作用往往难以预料。MoltenVK 恰恰证明 Metal 已经直接体现了 Vulkan 中大部分概念,至于剩下的那些不能被直接体现的部分有多重要也许见仁见智。但 MoltenVK 本身就在向开发者发出不建议使用这部分的信号。你想想,只要 Vulkan-based 代码能忍住不去用几个少数 MoltenVK 不支持的 Vulkan 独门概念,就能无痛移植到 macOS/iOS 上,岂不美哉!如此反而给 Metal 的思维方式加了一道吸引开发者的筹码。推广跨平台思维方式的努力,经常还是被操作系统本身的优势消散于无形。

当然还有一种可能:除了 Unity 和 Unreal 引擎的维护者,压根没有别人再会去用 Metal/Vulkan 这样的硬件图形接口。就像 CORBA, Java, RMI,它们试图影响开发者的思维方式,最终被另一个层次的替代者掩埋在记忆中。

显卡的今天和往事(续)

2018/03/07

上篇《显卡的今天和往事》提到买了 Radeon RX Vega 56。因为显卡的缘故对 AMD 的图形技术发展梳理了一下。以前一直以为 Apple 抛弃 OpenGL 去设计 Metal 是对 Driect3D 长久以来领跑的追赶,了解之后发现并非如此。在 DirectX 9 到 11 期间,AMD 的 PC 显卡驱动程序性能一直大幅落后于 nVidia,尽管其硬件理论性能并不差。与此同时 AMD 占据了 game console 图形处理的绝大部分份额,与 Microsoft 一起积累了不少在 console 上提高游戏性能的经验。这些经验变成了 Mantle。从某种意义上说,Mantle 是 AMD 硬件部门推动的对软件部门的一次压制 —— 把驱动程序的重要性大幅降低。也是 Microsoft 乐于看到的,因为从前驱动程序的任务被挪到 app code,强化了游戏/应用开发者对图形性能的控制权。

Mantle 推出之后,Microsoft 和 Apple 开始几乎同时推出自己的类似技术,D3D12 和 Metal。当然有说法 D3D12 是和 Mantle 同时开发而且期间有一定程度的交流和技术共享。

再回到这次给 MacBook pro 配置 eGPU 过程本身,上图是完成后的桌面。新设备购置顺序为:LG 显示器,Akitio 盒子,最后拿到 RX Vega 56 显卡。

说来好笑,本以为外接显示器是利用 eGPU 的前提,因为很多论坛甚至 Apple 官网一度宣称 eGPU 只对外接显示器加速。考虑到即使 eGPU 到货之前也能发挥作用,所以早早买来。搭好整个系统后调起代码才发现,不管 app window 在哪个显示器,渲染代码可以任意指定用任何 GPU 加速。甚至系统启动时 eGPU 不连显示器也没问题。究其原因,窗口系统中 app 自己的 rendering 的实际处理都是 offscreen,由 OS window server 把结果搬到与 app window 所在显示器直连的 GPU 上进行最终 composition。所谓 eGPU 只能加速外接显示器的说法可能只是指 D3D fullscreen 这种绕过窗口 composition 的加速方式。Windows Vista 后普通窗口系统方式并不比 fullscreen 方式增加多少开销。macOS 则根本没有绕过 composition 的方式。在窗口系统的「调度」下,GPU 更加接近通用的「pipeline/shader 运行引擎」,传统的显示器 adaptor 的角色则大大淡化了。

显卡的今天和往事

2018/03/07

从 2012 年起看到市面上的显卡越做越漂亮。颇有混合了 cyberpunk 和 steampunk 的感觉。无奈自 2005 年就已是纯粹的 laptop 用户,再无机会和精力搞台式机。我也安慰自己说反正也不常玩游戏。不过从 SIGGRAPH 2017 回来开始玩了一年多 real-time rendering,此种欲望越发强烈起来。业界似乎体会到我的心情。Intel 提出 Thunderbolt 3/USB Type-C 接口支持 PCI 协议。从 2017 年开始,可以配合 laptop 的外置显卡和通用 PCIe 外接盒流行起来。macOS 的支持到 10.13.4 beta 也成熟起来。上个月发现了 egpu.io 对我的计划一锤定音 —— 要用最短的时间配置好 eGPU 系统。

话说回来,这么多年来看着种种「外置」变成「内置标配」,专用硬件被基本软件系统取代。尽管配置 eGPU 这件事是抱着「发烧」的娱乐心态来放松,但也值得花点时间想想到底自己在干什么。是纯粹休闲娱乐,还是顺便能为未来做点准备和过渡。

说到这里想起以前看 Alex St. John 的 blog [1],谈到现代 graphics API 的现状是 Intel 故意限制 CPU-GPU 间的带宽造成的。这种限制激发了显卡厂商开发高速专用显存的动力,也激发了 Microsoft 和硬件设计者合作通过 shader 方式把 GPU 变得越来越通用。GPU 和计算机系统的其它部分形成了微型的高速 client-server 结构。把这个结构和普通意义上的 client-server 系统的软件方面比较,前者的 OpenGL 演进到 Mantle/Metal/D3D12,类似于后者从当年的 CORBA/RPC 想把网络通信伪装成本地调用,到今天的 RESTful API 不再掩饰网络通信的复杂性。

今天看来,这种半无意形成的架构具备了完美的伸缩性,使计算资源可以像存储一样根据性能、价格和灵活性的进行分层取舍。无论存储设备的价格如何降低,如何小型化,外置 storage 甚至家用 NAS 永远是存储系统中活跃的一种类型。以此推测 eGPU 应该有长期发展的潜力,一些 laptop 用户不介意用稍高的价格换取平滑的性能伸缩;避免像「性能本」那样既损失部分移动性,又因为不彻底的散热损失部分性能的妥协。

前景固然分析得很好,不过「用最短的时间配置好 eGPU 系统」这个计划在当下「挖矿」狂潮中确实发烧程度有点儿高。本来打算俭朴一点配块非公版的超频 Radeon RX 580,结果发货时间很长而且送到之后又严重的损坏。退掉之后心情不能平静,决定来个 RX Vega 56 吧!这个时候买高端 AMD 显卡 [2] 价格确实比较坑,说不是为了挖矿大概不会有人信。但是我真的只是为了跑自己写的 renderer,感觉有点冤。关于这块卡的使用,有时间会再写一篇详谈。

脚注:

  1. 这位仁兄的 blog 现在已经挂了(domain suspended)。似乎和前段发表有争议的言论的麻烦有关。也是令人唏嘘。所以想直接引用原文已不可能,只能凭我半吊子的记忆。
  2. macOS 只支持 AMD 显卡。而且 AMD 当前的「高端」卡 …… 凑合用吧。

TRIPLE is More Than DOUBLE Plus One

2017/12/29

TRIPLE 与 DOUBLE 的问题

远在硬件加速的图形系统 ( graphics APIs ) 出现前,double-buffer 已经是流行的动画防闪烁技术,这个名称一直沿用到 OpenGL 之类硬件加速系统上的相似技术。而 Metal 之类低开销图形系统 ( low-overhead graphics APIs ) 的标准运行模式是 triple-buffer。同时看到这两个名称会引出几个问题:

  • 为什么低开销系统采用「triple」,而不是「double」或者「quad」?
  • 在代码里「triple」是如何体现的?
  • 和「double-buffer」相比,「triple」是仅仅多了一个 frame buffer 吗?

深入讨论前先统一几个术语,因为各个图形系统常用不同名称指代相近的概念。CPU-GPU 工作在 client-server 模式。图形系统的驱动 ( driver ) 负责发送 CPU 的 request 给 GPU。发送前 CPU 准备 request 数据的过程叫「encoding」。GPU 执行 request 的过程叫「rendering」。每个 request 携带少量的参数,如各种变换矩阵,叫 parameter buffer。存储 rendering 结果的区域叫 frame buffer。[1] [2] [3] 一个 frame encoding 开始到 rendering 结束这段时间称为「in-flight」状态。图 1 显示了 OpenGL 系统各个过程执行的时间顺序。

图 1  Single In-Flight Frame

为了简化同步机制,OpenGL 系统里一个 frame 的 encoding 要等前一个 frame 的 rendering 结束后才开始。任意时刻最多存在一个 in-flight。这样 CPU 和 GPU 都无法避免空闲等待的状态。如上图所示调用 glFlush() 或者 glutSwapBuffers() 导致 CPU 的 idle 时段。从 encoding 到 rendering 的延时会导致 GPU 的 idle 时段。以这张图为基础,我们讨论下面这个问题:

  • 如果利用 multi-buffer 机制,至少需要几个 buffer 才能缩短空闲时间?Double-buffer 可以吗?特别的,OpenGL 风格的 double-buffer 可以吗?更多的 buffer 有帮助吗,还是反而起负面作用? ( n-buffer 中的 n 取什么值合适?)

BUFFER 与空闲时间

设想一个「naïve solution」:如果有许多个 frame buffer,GPU rendering 时把不同的 frame 写到不同的 frame buffer 里,似乎可以按照图 2 的方式同时执行多个 in-flight 过程。

图 2  Naïve Multiple In-flight Frames

那么来看看这个方案「naïve」在何处。如果在 GPU-bound 应用 [4] 中放任 CPU 无限制地 encoding 下去,CPU 和 GPU 间的处理延时会越来越大。这种延时导致需要暂存的 rendering 结果没有上限。所以一个可行的方案必须有办法让 CPU 适度地停下来等 GPU,这样延时可以控制在常数范围,有限的 buffer 也可以被循环利用。低开销图形系统希望最终达到图 3 的状态,这时系统的处理延时为 d frames,最多有 d+1 个 in-flight。GPU 达到满负荷,同时与 CPU 的延时始终保持在合理范围。

图 3  低开销图形系统的稳定状态

要做到这点,低开销图形系统需要跟踪有多少 frames 处于 in-flight 状态。Metal 利用 command buffer 实现这个机制,其它低开销系统也有类似概念。OpenGL 系统没有类似概念,只能通过调用 glFlush() 或 glutSwapBuffers() 让 CPU 等待 GPU 运行完所有的 requests (图 1)。即使有 multi-buffer,仅支持单个 in-flight 的图形系统只能消除闪烁,并不能减少等待时间。

第二个问题是,parameter buffer 在 encoding 过程中被 CPU 写入,在 rendering 过程中被 GPU 读出。当多个 frame 处于 in-flight 状态时,必须为它们分别分配 parameter buffer,不能重用。否则后来的 encoding 会破坏前面 rendering 过程读出数据。OpenGL 采用的 uniform 方式限制了由同一 shader 处理的所有 frames 共享同一个 parameter buffer。回到之前的问题:「OpenGL 风格的 double-buffer 可以吗?」—— 一般意义上的 double-buffer「有可能」降低空闲等待时间,但是 OpenGL 风格的并不能,因为缺乏跟踪 in-flight 机制和相应的 parameter buffer 分配机制。

SHOW ME THE CODE

现在具体看一下「跟踪 in-flight 状态」的机制在基于 Metal 的代码里的具体实现。下面的两段 code 从 Nuo Model Viewer 的 in-flight 处理简化而来。


// setup n-buffer, n is 3 in most cases

const unsigned int kInFlightBufferCount = n;

...

// in app initialization, _displaySemaphore was
// initialized with re-entry maximal number
// "kInFlightCount"

_displaySemaphore =
    dispatch_semaphore_create(kInFlightBufferCount);

代码段 1  初始化

代码段 1 是应用在初始化时设置变量。常量 kInFlightBufferCount 作为  _displaySemaphore 的初始化参数决定了 in-flight 的最大数,也就是 n-buffer 运行模式的 n。


dispatch_semaphore_wait(_displaySemaphore,
                        DISPATCH_TIME_FOREVER);
id<MTLCommandBuffer> commandBuffer = ...

_inFlightIndex = (_inFlightIndex + 1) % kInFlightCount;

// encoding on the command buffer on the
// "_inFlightIndex"th buffer
...

[commandBuffer commit];
[commandBuffer addCompletedHandler:^
    (id<MTLCommandBuffer> commandBuffer)
    {
        ...
        dispatch_semaphore_signal(_displaySemaphore);
    }];

代码段 2  In-flight 处理

代码段 2 是每个 frame 处理 in-flight 的逻辑。其中 semaphore_wait 和 semaphore_signal 定义的 critical region 正好符合图 3 所示的 in-flight 过程。和传统教科书基于 PV 操作的 critical region 相比,这个 region 有两个特殊性。第一,它不是严格的互斥访问,而是由 _displaySemaphore 指定重入的最大次数。第二,它的起始点和结束点不在同一段 sequential code 中,而是分别在 main thread 与 command buffer complete-handler 中。所以它不是控制不同 thread 的并发访问,而是用 GPU 通知来控制 main thread 的等待,以达到图 3 的效果。绝大多数情况下,Metal 系统的 _displaySemaphore 初始化参数为 3,即 triple-buffer。

其中第 5 行计算当前选择的 buffer 序号。因为运行在 n-buffer 模式,所以用 % 在 kInFlightBufferCount 个 buffer 里依次循环重用。第 11 行调用 commit,表示一个 frame 的所有 encoding 完全结束后才会发出 request 让 GPU 开始 rendering。图 2 和图 3 里那种 GPU 在一个 frame 的 CPU encoding 进行中就开始 rendering 的情况并不会在 Metal 系统中出现 [5]。如图 4 显示了允许三个 in-flight 时 Metal 系统的时序。可以看到由于 commit,延时比图 2 要长。

图 4  Metal 的初始时序

从这段代码也可以看出,Metal 的 n-buffer 中 n 可以任意取值。这是低开销图形系统的一般特点 —— 并不在 APIs 定义中硬性规定 n 的具体值。下面讨论 什么取值能最大地释放系统性能。

TRIPLE 和最优性能

上面的讨论中可以看到,_displaySemaphore 定义的重入次数决定了整个系统运行在 n-buffer 模式。现在讨论 n 的取值对性能的影响。当 n 设置为 5 时 ( quintuple-buffer ) 系统运行如图 5 所示。

图 5  Quintuple-Buffer 的时序

系统在第 10 个 frame 达到稳定状态,延时为四个 frame。除了延时增加之外,还必须准备五套 parameter buffer 和 frame buffer。因此 in-flight 过多有弊无利。如果采取相反的措施,降低 in-flight 个数是否可以减少延时?这时要注意到,在图 4 和图 5 中第一个 frame 的 encoding 和 rendering 之间的延时是 CPU-GPU 作为 client-server 系统的固有延时。从 Apple 文档中摘抄的图 6 大致描述了固有延时的构成。这里「Complete Frame …」可以粗略的看作上文的 rendering 过程。

 

图 6  CPU-GPU 固有延时

虽然 CPU-GPU 的固有延时并不能通过本篇讨论精确得出,但从图 6 和图 5 来考虑,不妨大致假设为每个 frame encoding 时长的 3/4。如果把 in-flight 最大数设置为 2,系统的时序如图 7 所示。

图 7  Metal 风格的 double-buffer 时序

由此可以看出,相对于 OpenGL 系统,Metal 风格的 double-buffer 可以减少一部分空闲等待时间,但是 CPU-GPU 的固有延时决定了 double-buffer 并不能完全消除所有的空闲等待。只有 kInFlightBufferCount 为 3 的 triple-buffer 模式才能达到图 3 和图 4 中 GPU 没有空闲等待的情况。

结论

最后总结一下篇头提出的所有问题。

为什么低开销系统采用「triple」,而不是「double」或者「quad」?

本篇并不能精确证明 triple 在任何情况下都是最优解。但可以分析出,过高的 in-flight 最大数会增加 rendering 延时,过低会导致 GPU 空闲等待。Triple 是针对一般情况的最佳设定。

在代码里「triple」是如何体现的?

通过规定可以重入三次的 semaphore。

和「double-buffer」相比,「triple」是仅仅多了一个 frame buffer 吗?

OpenGL 风格的 double-buffer 只涉及 frame buffer。 低开销图形系统的 triple-buffer 则涉及 frame buffer,parameter buffer 以及基于 semaphore 的 CPU-GPU 同步方式。更确切的说,triple-buffer 应该被称作 triple-in-flight

如果利用 multi-buffer 机制,至少需要几个 buffer 才能缩短空闲时间? Double-buffer 可以吗?特别的,OpenGL 风格的 double-buffer 可以吗?更多的 buffer 有帮助吗,还是反而起负面作用? ( n-buffer 中的 n 取什么值合适?)

采用基于 semaphore 的同步方式,多于一个 in-flight 的系统就可以缩短空闲时间。对于 GPU-bound 应用,通常三个 in-flight 能完全消除等待时间。OpenGL 没有跟踪 in-flight 的能力,其 double-buffer 只能消除闪烁。过多和过少的 in-flight 数目都对性能起负面作用。

脚注:

  1. 术语「encoding」来自于 Metal 系统。OpenGL 里并没有对应的名字。
  2. 术语「parameter buffer」在 OpenGL 里对应 uniform,在 Metal 里是作为 shader function parameter 的 buffer。
  3. 术语「frame buffer」来自于 OpenGL。在 Metal 里对应于用作 rendering target 的 texture。
  4. GPU rendering 的时间大于 CPU encoding 时间。本文只讨论这种情况,对于 CPU-bound 应用请作为读后思考。
  5. OpenGL 系统的 encoding 可以看作每个 request 立即被 commit,而不是一个 frame 的所有 requests 被一次 commit。

GPU 时代的 C-style 字符串 —— 再度绊倒

2017/10/14

写上一篇《GPU 时代的 C-style 字符串》时尽管反复求证,有一点还是搞错了。

Metal 的 fixed-function 部分缺省行为即执行 premultiplication。也就是说,在关闭 blending 时下面的 shader 代码,

会写入:float4(vert.rgb * vert.a, vert.a)

上面描述是错误的。事实是 Metal 的 fixed-function 缺省行为不执行 premultiplication,上面的 shader 代码例子原样写入 float4(vert.r, vert.g, vert.b, vert.a)

说来好笑,上篇 blog 写到一半时的草稿对 fixed-function 的解释本来是正确的。当时我还跑到隔壁的同事办公室大谈了一番。然后晚上 blog 结稿之前做实验晕了头,发布了错误结论,第二天早上又到同事办公室宏论一番。今天再次确认之后,只好第三次去同事那边订正。

所谓「结稿之前做实验」是这样的:Nuo Viewer Model 里有个新加的 screen-space render pass 在关闭 blending 的设置下产生一个 texture,之后其它 render pass 会用到它。我发现如果把它作为 un-premultiplied 形式处理(也就是说再「手工」乘一次 alpha 变成 premultiplied 形式)就会出现「黑边」。而且在 Metal debugger 的 texture viewer 里也看到 RGB 值似乎和 alpha 成正比。于是当晚在 blog 里宣布「Metal 的 fixed-function 部分缺省行为即执行 premultiplication」。

事实上我忽略了一件事:screen-space render pass 打开了 MSAA,在 fragment 边缘进行针对 fragment coverage 的 blending。这时的输出是:fragment 内部区域是 un-premultiplied 形式,边缘是针对 vert.a 和 coverage 的混合计算结果(前者 un-premultiplied,后者 premultiplied)。简而言之,错误的垃圾结果。经过这次教训,「GPU 强制采用 premultiplied 形式情况」的列表补全为:

  • On-screen 显示;
  • 会被 resample;
  • 开启 multi-sample anti-alias (MSAA);
  • (其它未知因素)……

总的来说,尽管 Metal 的 fixed-function 缺省行为不执行 premultiplication,程序员还是要老老实实的保证每一个 fragment shader 都输出 premultiplied 形式 —— 要么在 shader 代码里直接做,要么用 fixed-function blending 去做。否则碰到上面列表的任何一项就会产生垃圾结果。该乘法必须在写入 texture 之前做 —— 是为 pre-multiplication,而不能在 sample texture 的时候做,原因嘛,再看看上面关于 MSAA 的实验就清楚了。

虽然这是一篇更正,但是丝毫无损于上一篇的结论 ——「GPU 时代的 C-style 字符串」。

GPU 时代的 C-style 字符串

2017/10/12

更正 (2017-10-13):如果你依赖本文提供的关于 Metal 的信息,请务必阅读《GPU 时代的 C-style 字符串 —— 再度绊倒》对本文的更正。

由来

曾经有个问题征求答案 ——「计算机系统早期发展的先驱影响最大的决策失误是什么?」很多人赞同以 '\0' 结束的 C-style 字符串。随着计算机解决问题领域的扩展,新领域也会面对各自「早期发展先驱」带来的问题。或许每个时代都有自己的「C-style 字符串」问题。GPU 是过去二十年里「先驱」辈出的领域。从六七年前我的 team 开始接触基于 OpenGL 的产品,到今天自己写 renderer 一年有余,自己和身边的同事反复的被同一个问题绊倒。这个问题 —— alpha premultiplication 应该有资格被称为 GPU 时代的 C-style 字符串。

Alpha 的概念很容易理解。首先,有红绿蓝三原色 (R, G, B channels) 组成颜色。然后加上透明度 (alpha-channel)。透明度本身不会直接显示出来,因为显示器不是透明的,通常的场景也不会在无限远处「透明」。Alpha 是通过对「底色」的修改体现出来的 [1]。如果一个 pixel 颜色为 (r, g, b, a),底色为 (r’, g’, b’) (注意底色没有 alpha),最终结果是:

(r, g, b) * a + (r', g', b') * (1 - a)   [2]

一切概念都非常清晰完美!有那么好的事?C-style 字符串要登场了。

因为「先驱们」发现大多数应用的计算中 (r, g, b) * a 这个式子总是固定出现,并不会出现单独的 (r, g, b) 因子。于是先驱们决定原则上图片(特别是 GPU video memory 中的图片,即 texture)中不再存储最初的 (r, g, b, a),而是存储 (r*a, b*a, g*a, a),以预存储的方式节省乘法运算。这就是 alpha-premultiplied 形式。

到此为止,premultiplication 还只是某些优化情形下的推荐方式,并未上升到 C-style 字符串的影响力。但先驱们又决定,即然在众多时候都需要这个优化,今后 GPU 的 texture sampler,以及显卡的最终 on-screen 显示都假定 pixel 必须是 alpha-premultiplied 形式。

从此混乱开始了。当一个 render pass 的结果有可能被 resample 或者 on-screen 显示时,就必须储存成 premultiplied 形式。否则这个结果经过 GPU 的硬件处理就会产生错误。这导致了很多 shader 中不得不夹杂 premulplied 和 un-premultiplied 两种形式的数据。一份数据是不是 premultiplied 形式没有任何编译期或者运行期的类型信息说明,由于图形编程本身的特点,通过 debugging 来研究难度也很大。开发者只能从静态代码上下文推测。而且 premulitplication 操作可以或者在 fixed-function 部分设置,或者在 shader 中编写代码执行,更增大了通过上下文识别数据形式的难度。

Metal 的实现

说到这里具体谈谈 Metal 的实现。虽然从六七年前开始就被 alpha 相关问题不停绊倒,直到最近才在 Metal 上具体总结了一下。

Metal 的 fixed-function 部分缺省行为即执行 premultiplication。也就是说,在关闭 blending 时下面的 shader 代码,

会写入:float4(vert.rgb * vert.a, vert.a) 。这个行为并不对称,其逆操作 —— texture sampling 并不会自动 divided by alpha [3]。 这导致很多 shader 代码的操作不对称,必须查看 pipeline 的 fixed-function 参数才能理解,是降低 shader 代码清晰度的因素之一。

如果打开 fixed-function blending,写入的数据与 render target 的原有颜色有关。如果用纯黑色 (0, 0, 0, 0) clear 整个 color attachment,并如下设置 color attachment descriptor:

其结果和关闭 blending 的行为一致。上面的 MTLBlendFactorSourceAlpha 决定了对 render 结果执行 premultiplication。如果将其替换为 MTLBlendFactorOne,render 结果就是 un-premultiplied [4]。

未来

这么多年来,只要是显示或存储带透明度的图片,几乎没人能杜绝「黑边」的 bug [5]。而那些中间步骤的还藏有多少看上去不明显的透明度问题,不会有人知道。Premultiplication 绝对当之无愧作为「GPU 时代的 C-style 字符串」。如今在代码里看到越来越多的 std::string,也希望有一天 premultiplication 能从图形图像处理中完全消失。只是不知道当硬件性能充允的时候,已经积累的代码和习惯是否能允许剔除这个遗迹。

脚注:

  1. 如果你听过很多关于 Photoshop 的笑话,那么应该知道「透明底色」是棋盘格的颜色。
  2. 最终结果并没有 alpha-channel,因为为了描述简单没有考虑类似 Photoshop 中 blending group 的概念。归根结底,blending group 只是中间结果而不是最终显示。
  3. 相比之下,Metal 对 sRGB gamma encoded 数据会在 shader 输入输出时进行对称的 linear/delinear 变换。
  4. 当 texture 底色为 (0, 0, 0, 0) 的时候,图中最后两行对 color attachment 的设置并不会有什么作用。
  5. 通常是黑色高透明 pixel 忘记 premultiplication,或是浅色中等透明 pixel 过多进行 premultiplication。

Hackathon 和代码规范

2017/08/24

但凡经手的代码,我尽量令其严格遵守代码规范。看到写的里出外进的代码,比如操作符和括号两侧随机缺掉或者多出空格,连续七八行的代码各行之间都空行…… 都不禁感慨背后的作者到底是有怎样的心情和素养。随着经历的增长,这种感受也会发生变化。

几个月前进行了一次不算剧烈的 hackathon。说「不太剧烈」是因为这次实际上和正式做产品 feature 没有太大区别。做产品 feature 的第一步也是用最快速度写出来一个能运行的基本逻辑,然后再一点点通过 refactor 把代码变换成更清晰的逻辑和结构,逐步加上对 edge case 的处理。Hackathon 无非是多少省去了第一步之后产品化的步骤。如果实现正式 feature 的第一步不是 hackathon-like,那么多半后面要走更多弯路浪费工作。因为编程语言在一个 feature 实现的初期和后期作为工具的作用是不同的。在初期,对 feature 的设想处于探索阶段。编程语言的作用是验证头脑中的想法,揭露其中的逻辑漏洞,起 proof 的作用。后期的作用则是用清晰的代码为其它开发者(包括未来的作者本人)固化知识。前者像草稿纸,而后者的产物如同不必再次 peer-review 就可以被引用的正式论文。

在高强度的 hackathon-like 步骤中,经常发现自己也生产出「里出外进」的代码。临场感受和事后分析都告诉我,在这个时候去整理这种代码的危害大于收益。当然,在整个逻辑被证明基本稳定,开始 refactor 之后,就应该严格执行代码规范。不过我开始同情和原谅那些不规整代码的作者,可能对于某些人来说拥有 refactor 步骤确实是种奢望。

这次 hackathon 中基本功能实现之后还富余了一些时间。这时我面临一个选择。是像实现正式 feature 那样开始 refactor,还是加进更多的 hacky code 让结果 demo 起来更酷更炫。既然暂时没有正式发布 feature 的计划,我决定选择后者。结果我发现脑子好像带着铅球跑步,写出来的东西不断撞到墙上,不得不废弃掉。此时我意识到 hackathon 这个名字确实在很多方面非常贴切。马拉松不光耗时长,跑完了也是需要休息的。这种休息不是体力上的回家睡一觉,而是通过整理工作进行脑力休息。

也就是说,代码冲刺之后的 refactor,非但不是一种奢望,而且还是正常脑力健康的保证。经过一两天的代码长跑之后,花上一段时间把代码整理干净,大脑得到放松,也是静下心来对这段工作的一个内心总结。当你看到一个经常产出「里出外进」的程序员或者团队,就如同看到一个运动生涯中只会冲刺不会休整的运动员,可以想见其未来的长期健康情况。