Archive for the ‘软件开发’ Category

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/06/22

本科开始学 linear algebra 时,最大的困惑是 matrix, dot product, cross product 这些概念指的到底是什么。书读的遍数多了,忽然间脑子里就隐隐约约明白了。其实还是说不清,只是不再纠缠而是忙着看之后的内容了。

好多年之后开始学习 rendering,工作上也常有绘制 custom UI 的问题,渐渐觉得这些东西理所当然,不再深究。但时常遇到初次接触这些领域的人问同样的问题。我敢打赌,每个接触过这些概念的人,不管是后来以图形学为生,还是早早放弃治疗,一定都有过「这些到底是什么」的挣扎。现在回想起来最让我震撼的是,经过这么一代人一代人的挣扎,教科书就是从来不肯用一句话点破这些东西到底是什么。于是初学者便要自己体会。 「dot product 的『意义』是一个面积吧,但 cross product 看起来就完全没有意义啊?一定是我体会不够!」

其实这东西就像古人写格律诗一样,为什么每句字数要一样还讲究平仄?为了美感吗?当然有这个因素,但如果「美感」就是最终目的,那么限于格律的形式一定程度上也阻挡了人类表达美好事物的能力。「美感」是让人的大脑能够轻松记忆的手段。为了在现代印刷术发明前能大量流传才是「格律」的意义。只是一个记法。

同样,大多数 linear algebra 的概念并没有直接的应用意义,它们只是帮助学者能把公式写成更有「美感」的形式,便于记忆而已。就如同为什么要把成像公式写成 1/a + 1/b = 1/f  一样。到了计算机时代,这些助记符又有了另外一层意义。由于它们最初的广泛应用,硬件上对它们的计算设计了特别的加速电路。软件上也有优化良好的库。反过来促使研究者更加注重把理论表达成这些助记符的形式。

Rendering 和「赌玉」

2017/01/29

大年三十夜里,在 TurboSquid 上买了个价位还算合理的 F-4J model 作为自己的春节礼物。以我的购买经验,刚刚打开的时候不出所料小小的失望了一番。Polygon 的粒度和座舱透明在 OBJ 格式上处理的都不对。紧急对 Nuo Model Viewer 的代码做了些调整,结果发现还是个不错的模型。更好的是起落架收起/放下只要用简单的隐藏/显示 model parts 就能实现。TurboSquid 对我这样用「非主流」renderer 的人不保证最终渲染效果,每次买 model 都像「赌玉」一样,可刺激了。

f-4

以前很喜欢调侃从 CS 专业毕业的同事「浪费」了四年时间。我自己的 CS 知识是工作后自学的,大学专业是通信工程,我一直觉得是更好的投资,因为自认为没信心和动力自己用四年时间建立通信方面的专业知识,。

从十多年前开始打算学 rendering,多次半途而费之后,突然意识到 CS 也渐渐成为了个人无法自己完成的学业。当一个学科越来越复杂的时候,在学习曲线初期能去征服的问题就变得不那么「有趣」。拿 rendering 来说,三十年前也许画个茶壶就是很「有趣」的项目,那段时间现在一去不复返。去年开始我终于靠不断「赌玉」勉强给自己注入学习的动力。如果是在大学里完成一两学年的课程,也许我会安于用圆球和茶壶来集中学习 rendering 知识,而现在我要花费 60% 的时间处理从各种途径获得的真实物体模型。

能够和一门学科共同成长的人是幸运的,因为这门学科最有趣的部分始终在个人能力可以触及的范围内。当一门学科开始成熟,它的热门问题就变的越来越对初学者不友好。这时学校的作用就是用自己的激励制度和环境让初学者能安于一些「枯燥」的初级问题。我庆幸还能在毕业后自学 CS 知识,也终于意识到今后 CS 领域的学习会大大不同于我们这批人的经历。

关于语言消亡

2016/11/17

不是长篇立论,只是突然想到一个有趣的话题。

我们通常会惊讶于旧技术的生命力。《What Technology Wants》里 Kevin Kelly 详实的阐述了旧技术永不消亡的事实。在 IT 领域,所有出现过的编程语言似乎也都还在发挥作用,提供着相当数量的工作机会。但是有一种语言,似乎还很年轻,辉煌的日子也并不久远,却突然的以一种和其历史记录完全不相称的速度从各个领域都消失了。这就是 Pascal。

Pascal 出现的时间并不太早。个人计算机早期被 Macintosh 钦定。IBM PC 上一开始就有 Borland 护航,后有 Delphi 中兴。像 Photoshop 等经典应用的早期版本也完全是 Pascal 写成。但是就消失了。

不要谈「正确性」

2016/09/29

很多程序员自嘲「数学不好」。反过来看就是在憧憬基于数学理论写就的程序一定完全正确。其实并非如此。比如说,简单的 parser 可以严格基于有限态自动机和 LL(n) 理论,但写过的人都知道调试起来并不简单,即便充分测试也不能达到 100% 正确。

mbe

另一个例子是 3D renderer。无论是物理真实的 ray tracing 还是简单的 phong model,renderer 就是数学公式的代码实现。最近用 Metal by Example 的例子作为起点写程序。一开始还运行得不错。直到加上摄像位置的变换之后,near/far 平面会切掉在 view volume 里的模型。一开始也没在意,给 near/far 参数随便加上个余量凑合用。混了两个星期后偶然想到 Metal by Example 用的 projection 和 OpenGL 的一模一样,而 Metal 的 depth 范围不同于 OpenGL [1] 。在 Twitter 上向作者确认之后果然是这个问题。Metal by Example 经过了 15 个月的开发,出版也有一年左右。但是这个基本问题一直没有被发现。

软件开发的现状略带讽刺 —— 严格依据数学理论的程序反而缺乏有效的工具来验证其正确性。但这里其实也没什么讽刺性,因为数学本身的理论正确性也并非自动获得。一个新证明的正确性必需要经过其他数学家的人工验证,有时需要历时几年,甚至在最后才发现证明中的错误。数学的正确性是依靠数学界的「社交活动」达成的 [2] 。正如测试是软件开发的「社交活动」。

那么,数学理论是不是对软件质量没有帮助呢?也对也不对。数学理论并不能直接保证正确性,无法帮助发现错误。但是一旦发现了错误,数学理论可以帮助更快的修复软件。当我们在一个复杂的 ad hoc if-else 分支群里发现错误行为之后,很难立刻理清修复方案。而面对上文的 projection 错误,立刻就可以修正错误的参数。我把这种特性称为「可修复性」。可修复性和「正确性」不是一个层面的问题。其实软件开发的很多 best pratice 并非追求「正确性」,而是去提高「可修复性」。例如提高代码的可读性,以及我以前讨论的 MVC 模式的局限和突破,都是追求「可修复性」。经常听到很多团队讨论开发流程和实践的时候以「正确性」作为争论的立脚点,讨论怎么做才能节约测试成本,这样就走偏了。

「可修复性」不是「正确性」。也没有办法直接降低测试成本。但是「可修复性」是软件的一个隐形 spec。当错误被发现的时候,「可修复性」将一切争论局限在实现层面,避免了在设计层面的争论,更不会出现 feature vs. bug 的可笑争吵。「可修复性」还避免了修改中无意引入新的 bug。就目前的软件开发现状,我认为没有任何编程行为能有效提高「正确性」,那些能产生正确代码的程序员也无非是把代码丢给测试团队之前,自己先系统测试一番。一切编程本身的实践,都应该围绕「可修复性」来讨论。一切针对「正确性」的讨论,都应该交给测试领域。

脚注:

  1. Metal 的 canonical view volume 是 2x2x1,它的 z-buffer 范围不超过 [0, 1] 。OpenGL 为 2x2x2 ,z-buffer 范围为 [-1, 1] 。
  2. 当然,类似 Coq 这样的形式验证工具说明有可能改变这样的现状,但是距离实现仍然有一定距离。

Program by Debug

2016/09/10

老手都知道「debugging」是书本很少涉及但是对生产力影响最大的编程手段。但在不断提高自身修养的过程中也听过大师告诫不要「program by debug」,令人时常前思后想不敢动手 coding 。反复调试时充满负罪感。

如果因为一句「不要 program by debug」就坚持敲代码之前要深思熟虑,那就是和自己过不去了。因为人并不擅长在不同层次进行通盘思考。花费很多精力思考的大计划在细节上必然充满逻辑漏洞。所以编程就是先让粗糙的代码在简单输入下勉强运行起来,然后这里紧一下「螺栓」,那里调一下「杠杆」,最终获得一个稳定的系统。所以恰恰就是要「program by debug」。如果重新思考这个问题,重点不在于是否让 debugger 成为 coding 的线索,而在于如何使用从 debugger 得到的信息,不应该用「头疼医头」的方式去修补问题,而是要从 debugger 暴漏出的现象扩展出一般化的问题,寻找一般化的解决方案。

大师的观点一定程度上和工具的发展程度有关。Debugging 是高度依赖工具的手段。工具永远有覆盖不到的地方,所以脱离 debuging 的慎重思考也总有不可替代的地位。不过工具发展之后,要让我们的头脑从工具成熟的领域中解放出来。

五六年前尝试学过 rendering ,还写过几篇《 OpenGL 随想 》,现在看来十分惭愧。除了熟悉基本概念之外,实际的练习都是浅尝辄止。那个时候我的印象是 GPU pipeline 的编程并不像大多数工程可以大量依靠 debugging 。今年公司给了去 SIGGRAPH 2016 的福利。为了不太辜负这次见闻,回来之后打算再进阶一次自己的 rendering 知识。总结一下前几次进阶夭折的原因,有一点在于只用下面这样的简单过程生成的模型。因为无聊而失去动力。所以我想这次要先花些力气让代码直接用上从网上下载的大量模型。

bqhwpgrcyaeokfr-png-large

具体的目标定为 Wavefront OBJ 格式的 viewer。本着 program by debug 的精神,从网上找了几个 OBJ loader 库,然后给它们分别写些简单的测试代码来 debug ,看它们产生的结果是否好理解。然后选中 tinyobjloader 加到 Metal by Example 的例子里直接开始编码。每次 debugging 暴漏的问题的时候,不能只是把当前出错的这一个 OBJ 文件的情况糊弄过去,而是要考虑类似情况如何反映在所有的 OBJ 文件里。用点合理猜测,偶尔去查相关的 OBJ 标准,程序就一步一步的稳定起来。最后把例子一步步的 refactor 成现在的 Nuo Model Viewer

Parsing model 的构建诠释了 program by debug 的作用,但是这毕竟不算 rendering 本身。和几年前相比,GPU debugging 的工具也丰富多了。例如 rendering order 就不用费力猜了。

2016-0908-modelviewer

Lua 的语言实现难度

2016/06/10

谈到各种语言的实现,Lua 的 single-pass compiler 常被拿来说一番,似乎给人的印象是为性能牺牲了简洁。这个 compiler 的代码我一年前读过,做了些笔记。在复杂度方面,其实大体感觉比采用 explict AST 加单独的 code generating pass 的 compiler 并没高多少。在某些情况下,OP code generator 里晚些运行的步骤要为之前步骤已经生成的代码打 patch。主要集中在表达式计算结果的寄存器分配,以及分支语句跳转的目标地址上。

不过,为了 compiler 的实现简洁,Lua 还是「偷偷」地保留了一棵「语法树」。我并不是说像 FuncState 这样在 compile-time 按需生长的暂态树,而是一棵最终保留到运行时的常青树。它的结点为 struct Proto,表示源代码级别的 lexical scope function 的嵌套关系,也就是通常所说的 closure 定义。

在接触 Lua 实现之前,我最好奇的就是如何设计一个支持 closure 的指令集。看了 Lua 的实现之后,突然觉得有种「受骗」的感觉,因为对 closure 的处理明显不是用一般意义上的「指令」实现的。当时我还发了一条 Twitee 说「Lua 指令集要是做成真的 CPU 应该算是 extremely complex instruction set」。回头再想想,其实就是一个粗节点的 AST。所以 Lua VM 除了运行虚拟指令集,还混合了直接解释 AST 的方式。这点有效的控制了 compiler 的复杂度。

Lua 坚持采用手写的 single-pass compiler 的主要目的是满足作为「数据描述」语言的需求。这里有一个有趣的「迂回」。大多数「数据描述」语言其实并不需要 single-pass compiler 也能满足性能需求,因为它们的应用场景到 AST 为止,根本谈不上后面的 code generation —— AST 就是它们描述的数据,即 declarative 形式。但这也限制了这些语言的描述能力。Lua 通过 compile 舍弃了大多数 AST 信息,再运行 OP code 恢复和 AST 类似的数据结构。看似做了些「无用功」,但是最后的数据并不一定要和源代码的语法完全一致,灵活性远胜 declarative 形式的数据描述语言。

Lua 的 single-pass compiler 算是用一套 imperative language 的方案兼顾了大多数 declarative data expression 的需求。追求性能的同时,仍然保留了粗粒度 AST 降低实现复杂度。是个比较完美的折衷。