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

十年纪念

2017/07/30

十周年了。这个礼物我很喜欢。毕竟 macOS full screen 模式不显示时间。:-)

恢复(到更高的)生产力

2017/07/24

任何一次生产环境的调整都是对生产力的打击。

六月初 WWDC 宣布 MacBook Pro 升级之后,决定照去年末计划升级用了四年多的 MacBook Pro 2012。回想起来,从第一次升级到 MacBook Pro 已经快八年了。每次升级都要磕磕绊绊一两周时间。所以四年应该是最短的更新周期,Apple Care 也是必须要买的。

当年买第一台的时候还特地等到 Snow Leopard 正式发布后才去 Apple Store,希望得到预装新 OS 的机器。结果欢乐地得到了 —— 预装 Leopard 的机器和 Snow Leopard 的光盘。

和以前相比这次升级的特殊性在于 hobby 开发转向了 GPU rendering 方面。从上古时期开始这就是兼容性的「玄学」领域。加上 Apple 刚刚推出 Metal for macOS 没多久,今年又急急忙忙拿出 Metal 2,这些对生产力的平滑输出都没有什么帮助。在新机器上配置好 Xcode 8.3.3 之后,发现我的 renderer 根本跑不起来。真是一个沉重的打击!接下来发现如果注释掉关于 shadow map 的代码可以勉强运行,这给了我虚假的希望,以为可以简单修改代码绕过问题。折腾了两天发现只要 shader 中 texture sampling 和分支组合到一定程度就出问题,根本没有简单的规避方法。时间在郁闷中虚耗了三天。最后我决定试一试 beta 版 macOS 上的 Metal 2。

之前从不在主力机器上安装 beta 软件,这次是被逼无奈。我甚至考虑如果还没有改观就退掉这台机器。好在装完 High Sierra beta 2 和 Xcode 9 beta 之后 renderer 能跑起来了。看来 Apple 人手吃紧,为了 beta 版的进度已经顾不上修复稳定版的问题了。不知道手握 MBP 2017 的人现在有多少像我一样被迫紧跟 beta 版动态。对于 Apple 来说我大概已被划入少数派用户了,macOS 开发就是小众,Metal 开发也是小众,Metal for macOS 就是小众的小众。

好景不长,反复观察一天发现只要系统休眠后,Metal 2 性能就会狂降。害得我不停 reboot 系统。几天后总算找到了基本「恢复」生产力到方法。在系统中始终运行一个 renderer app,休眠就不太会触发性能问题。似乎 High Sierra 的 GPU 管理在休眠状态判断上有问题,而保持一个运行状态的 Metal 2 app 可以让 GPU 管理绕过这个问题。