2023-3-28 21:34 /
以下是 Mike Acton 在 CppCon 2014 所做的著名讲座的内容翻译,为方便以文本形式阅读,结合幻灯片内容进行了轻微编辑。

介绍

我想介绍一个人,他要来介绍我们的主讲人。有请 Alex Rosenberg,索尼 Playstation 的平台架构管理。

---

我被问道:"游戏行业的哪位人士应该来为这里的人演讲?"。

游戏行业在很大程度上……主机游戏行业在很大程度上是基于 C++ 的。所以我只能想到一个名字,也就是我们今天要见到的:Mike Acton,Insomniac Games 的引擎总监。

不清楚你们是否知道他们的作品,但他们已经做了一些如《抵抗》、《瑞奇与叮当》和《导火线》这样的作品,而《日落过载》是他们的新作。

他们每年相当可靠地推出两款主机游戏。如果你没有个参考标准的话,这是项艰巨的工作。有上百人要参与这些事情,让一切都按期发生是非常有挑战性的。这也是 Mike 在那里的工作的核心部分。

他还负责了游戏行业的不少技术共享工作:我想到的第一个是 Cell Performance 网站,这是提供给与我们在 Playstation 3 中用的 Cell 芯片打交道的人,以及使用它们的研究人员的。然后最近 Mike 做了 AltDevBlogADay,他鼓励游戏行业的人每天都在同一个共享网站上写一篇博客文章,这样就总会有东西可读了。此外,在 Insomniac 的网站上还有关于他们所做的各种研究以及取得的成果的论文。这些读起来都相当引人入胜。

那么,很荣幸能为大家介绍 Mike Acton。

正文

大家好。谢了,Alex。我是 Mike Acton,Insomniac Games 的引擎总监。

这里有多少人是游戏开发者?行吧,相对来说这算是你们中的少数。所以我想介绍一点背景情况,以便大家了解我和我们的团队在制作游戏方面是干什么的。

我们团队一般会构建运行时系统,尤其是对性能要求最高,和以变换的数据量来说游戏中使用最多的那些系统。

例如渲染系统、动画和姿态、流式资产传输、影像、视觉特效、后期特效、导航、本地化,基本上所有这些不见得是游戏本身的大型系统。

我们也做所有的开发工具,也就是工作室内部用来做游戏的工具。比如关卡创建工具、照明工具、材质编辑工具、视觉效果创作工具,动画师的动画/状态机工具、视觉脚本、场景绘画、影像制作…… 我们作为一个团队制作和开发数百种工具,以便 Alex 提到的团队内的其他数百人来开发游戏。

我们既在主机上非常关注该机器的特征,也在 PC 开发方面关注我们开发机器的特征。

我想让大家对我们的价值观,以及开发这些东西时对我们重要的事情有个概念:

- 硬性最后期限

    最后期限对我们非常重要。我们知道什么时候要发行一个游戏。我现在就可以告诉你们,《日落过载》将在 10 月 28 日上架。而我们是在游戏发行前一个非常小的窗口内得知这些日期的。

    我们有多个游戏在同时进行开发,我们知道它们什么时候得发行。数百万美元的营销资金取决于发行游戏的流程,所以这是不能错过的。没有任何灵活性。提前几年我们知道它就得在这一天发行上架。因此,你必须适应大规模,你必须确保这能行。

- 软实时性能要求(软意味着 33 毫秒)

    一般来说,作为一个游戏,这意味着一帧 16 毫秒 或 33 毫秒,大致如此。这就是我们所处的时间范围。

    检查任何特定的东西时,我们会以微秒为单位讨论。如果我们以毫秒为单位,讨论的会是相当大的一组变换,非常大的系统。但一般我们是以微秒为单位的。讨论游戏性能时,这大体上是我们的范围。

- 可用性

    我们有这么多人要快速构建一个游戏,一个但愿比开发团队实际人数代表的还要大得多的巨大游戏。因此,我们需要确保我们的工具、系统和所有的东西,无论是对程序员还是对非程序员:美术、设计师、音频工程师等等,都是快捷易用的。

- 性能

    一般意义上的性能非常重要。任何过程或系统在一个硬件上运行越快,你就有越多的空间用于其他事情,这对我们很关键。我们想解决新问题,而为了解决新问题,必须为它们腾出空间。正如我所说,我们一帧中的空间是 16 毫秒或 33 毫秒,如果想放更多东西,必须腾出空间。性能为王。

- 可维护性

    一般意义上的可维护性。我们必须维护,必须继续开发。我们有多个游戏在同时进行开发,得跨时间和游戏继续开发引擎和工具。所以我们必须确保它们可以维护,以我们的角度来看。

- 可调试性

    同样的,我们有硬性的发行日期。我们必须确保能够在一切都快做好、快要发行之前迅速解决问题。因此,能够推断出错的原因非常关键。

我们用什么语言?正如 Alex 所说,这个行业是由 C++ 主导的,我们也不例外。但我们确实混合使用了很多其他语言:一点 C、一点汇编、Perl、JavaScript、C#。显然,不是所有这些都在真正的目标主机上,而是在开发 PC 那边。我们的整个代码库里,C++ 可能占 70%。这只是个粗略的估计,我没有实际计算代码行数或者怎样。

我们最喜欢的目标肯定是汇编,这是我们最想一直用的。但当然,现实意味着我们使用工具来帮助生成汇编,而不是直接自己做所有的事情,特别是在大规模上。

还有一些杂七杂八的东西,如像素着色器、顶点着色器、几何着色器、计算着色器,这些 GPU 的东西。现在有更多工作放到这个领域处理了。

---

与昨天的一个讲座(CppCon 2014: Mark Maimone "C++ on Mars: Incorporating C++ into Mars Rover Flight Software")相联系起来,我们当然没有做能上火星的游戏,但我们所做的,和我理解的 JPL 在火星车的嵌入式系统中所做的,有很多相似之处。

那么,游戏哪里跟火星车相似了?

- 异常

    类似地,我们避免使用异常。如果可以的话,我们把它们关掉。偶尔有必须与之交互的第三方库和代码是围绕异常设计的,我们就把那些代码关到沙盒里。

- 模板

    虽然我们没有硬性规定不能用模板,但如果有人用了,我会给他们个特定的脸色,而那不是个开心的脸色。模板偶尔会有好的运用,但大多数时候是糟糕的运用,而且大多数时候它让事情变慢。任何一个在大规模上使用模板,或使用一个严重依赖模板的代码库的人,都知道它是如何摧残编译时间的。

- iostream

    虽然我们确实使用 stdout 机制,用于主机和开发用 PC 上的 TTY 和调试信息,但我们并不使用 C++ 的具体实现 iostream。

- 多重继承

    直接可以抬下去了,甚至不是个问题。这功能就是蠢。

- 操作符重载

    与 JPL 项目不同的是,我们没有严格规定不允许操作符重载。但是,取决于重载的复杂程度,你会得到个皱眉的脸色或愤怒的脸色。

    经验之谈是,如果你做的事情特别明显,我们倾向于放过。加几个向量这种,变换出来的东西特别明显的会放过。但任何比这复杂得多的东西都会受到严厉的反对。

- RTTI

    运行时类型信息,没戏。

- 不使用 STL

    我们当然不使用 STL,回到模板和编译时间的问题上。但也是因为我们发现,并不是我们有个 STL 替代品,我们觉得它神奇地更好,而是 STL 实际上并没有解决我们想解决的问题,而我们想要解决我们有的问题。

- 定制内存分配器

    同样地,我们不使用通用的动态堆内存管理。我们一开始就分配所有的内存,把它们分成各种层级,每个沙盒都以适合其系统的方式来管理数据。有大量的定制分配器工作在这些沙盒中:有些可能是沙盒区块之内的熟悉的堆内存管理,有些是小的块型分配器,有些是那种线性分配器,用于只需要线性过一遍然后重置的系统。我们有一大堆不同的东西。

- 定制调试工具

    回到可调试性。我们有很多工具可以查看数据,或是将其从主机拉到开发机器上以便检查。我们有实时检查的工具,也有离线检查的工具,可以转储一堆数据来分析。一堆调试工具。

在很多方面,因为火星车项目是非常小的嵌入式系统,资源非常有限,相比之下,一个 PS4、Xbox One 这样的主机的范围显然要大得多。它们有很多类似的属性,对吧?它们都有固定的资源,而你想要利用好这些资源。因此,我们最终做了很多相同的事。

---

回到标题。这个面向数据设计是什么意思?它甚至是个东西吗?它是个流程吗?到底是什么意思呢?

说面向数据设计的时候,我想谈一下我的意思:
所有程序的目的,以及这些程序所有部分的目的,都是把一种形式的数据变换成另一种。
从根本上来说,这就是个事实,对吧?这不是个信念。所有程序和这些程序所有部分的目的,是把数据从一种形式变换为另一种形式。物理事实。这就是其意义所在。但这有点像个提醒原则,提醒我们为什么要做在做的事情。
如果你不理解你正在变换的数据,你就不理解你要解决的问题。
反过来,通过理解你在处理的数据来更好地理解一个问题。
不同的问题需要不同的解决方案。
这应该显而易见,不同的问题,需要不同的解决方案。每个人应该都同意这点。因此,如果你有不同的数据,你就有不同的问题,而需要不同的解决方案。
如果你不理解解决问题的成本,你就不理解这个问题。
如果你不能推断出你所做事情的成本,你就没有真正理解这个问题。
如果你不理解硬件,你就无法推断出解决问题的成本。
每一块硬件,每一块你将要使用的假想的硬件的集合都是有限的,并且有一些性能特征范围。考虑你如何解决问题的唯一方式就是考虑这些特征。
一切问题都是数据问题,包括可用性、维护、可调试性等等。
一切都是数据问题。不是代码问题。
解决你可能并没有的问题肯定会产生更多问题。
避免在问题空间中增加你没有的问题。而你知道有什么问题,因为你已经分析了数据。
延迟和吞吐只在顺序系统中是一样的。
经验之谈:有一个就有很多。试着去时间轴上检查。
一个经验之谈:如果可以的话,你从时间轴上检查,先解决最常见的问题。也就是随着时间,肯定有一堆同样的事情会发生,这是最常见的问题,这就是我应该管理的,而不是试图想象真空中单独存在的一件事情。
经验之谈:你有的上下文越多,你就越能把一个解决方案做好。不要丢弃你需要的数据。
不要丢弃你需要的数据或限制。你想更多地知道某样东西将在哪里使用、如何使用,什么数据可以接受,什么范围可以接受,以便做出适当的解决方案。
经验之谈:NUMA 这样的概念可以扩展到 I/O 和预构建的数据,一直追溯到制作源头。
根本上,对我们来说,这种非统一内存访问的概念,也是随时间延伸的。不仅是指主机和主机上的数据,我们认为这个概念也延伸到了要被读取的硬盘,和存储的蓝光碟片。再回到开发 PC 上,这也能延伸到从源数据中得到的数据,所有这些从 Maya 之类或我们的开发工具中得到的,艺术家用鼠标或键盘创造的数据。因此,随着时间会有这样的一串输入流,我们必须管理整个流水线,并了解整个流水线随时间的数据访问成本,而不是在真空中检查一个时间点上的单个访问。
软件并不跑在一个由计算机科学博士们的狂想驱动的神奇仙境里。
最重要的是,我认为,软件并不跑在一个由计算机科学博士们的狂想驱动的神奇仙境里。软件是真实的工程,跑在真实的硬件上,解决真实的问题。
理性至上。
最后,理性必须占上风。比如,如果我们在做的事因为任何原因或想象的原因是不合理性的,那么就必须停止。

---

希望这能给你一些背景,让你知道我说的面向数据是什么意思。

那么这是个东西吗?当然,这些都不是新的概念,对吧?没有什么是新的,或者能算闻。这真的只是提醒我和其他人那些首要原则,提醒我们什么是最重要的,专注于此,摒弃其他一切。

但很大程度上,我们讨论这个的唯一原因,也是我会讨论这个的唯一原因,是因为它是对 C++ 所产生的广泛文化的回应。最普遍的是我所称的三大谎言:

- 谎言一:软件是个平台。
- 谎言二:代码应该围绕世界建模来设计。
- 谎言三:代码比数据更重要。

谎言一:软件是个平台。

这应该超级超级明显。硬件才是平台。你有机器码,你有数据,塞进 CPU,吐出其他数据。这就是其意义所在。

不同的硬件,不同的解决方案。你有一套不同的硬件,解决方案必然是不同的。如果我在为 6502 编程,对比 Cell,对比 ATI 5870,对比 Arm,对比一房间装满 x64 处理器的服务器农场,解决方案在每种情况下都将是完全不同的。因为它们是不同的硬件平台,它们有不同的限制,有不同的物理上的限制。你不能使一个解决方案独立于你打交道的实际平台,或有限的平台集合。
现实不是个 hack,它不是你为了解决你的抽象理论问题而被迫处理的东西。现实是你试图解决的真正问题。
你不得不在真实的平台上对真实的数据做点什么事让它能正常运转,并不会让你的代码变得“不那么美”。这本来就是其意义,是你该做的事情。

所以,如果你认为正确的方式不是实际背景下有效的方式,而是其他方式,那么你对正确方式的看法就是错误的。

谎言二:代码应该围绕世界建模来设计。

这点是从面向对象那边渗透到 C++ 文化中的。

建立世界的模型并试图将其转为代码,这个过程中隐性地隐藏了数据,对吧?大多数人甚至会认为隐藏数据有其意义。

但这是不好的。它搞混了两件重要的事情:一个是维护,这是好事情。我们希望能够维护对数据改变的访问,没问题。但它把这一点和对数据属性的理解搞混了。数据是什么?它是如何工作的?它有多大?里面有什么?为了真正解决问题,这些是我们需要知道的最重要的事情。而搞混这两件事,或混淆这两件事,这以可能稍好的可维护性为代价,使我们的问题非常非常难解决,或至少是难以解决好。

而这整个世界建模的想法意味着与真实数据或变换的某种关系。就好像我有个世界建模,我把代码写成这样,那就是实际正在发生的事情。

但问题是在现实生活中,一“类”事物在本质上就是相似的,它们确实是一样的。现实生活中,一把椅子就是一把椅子。但就我们所做的数据变换而言,这些类真的只是表面上相似。

在游戏这个背景下,我们有把椅子:一把物理椅子,一把静态椅子或一把可破坏的椅子。这些东西一点也不相似。这些上下文之间几乎没有什么是相同的。它们如何被处理,数据如何被管理和变换,几乎没有任何地方是相同的。然而,人们的倾向会是,因为它们有一些世界建模上的相似性,或者在现实世界中的相似性,它们应该以某种方式在代码层级结构中被连接起来,这是非常荒唐的,说实话。

因此,世界建模一般会导致这些单一的、不相关的数据结构和变换,导致各种其他的问题,而这些问题你本来就不该有。

从我的角度来看,世界建模是试图将问题理想化来解决(回到没有软件跑在神奇仙境那点),而我们真的需要理解数据和我们打交道的硬件。

你不能让一个问题变得比它自己更简单。真正的现实是,你有这一套有限的硬件集合;真正的现实是,你有这一套有限的数据集合。你不能让它变得比这更简单。

我把世界建模看成编程的“励志书籍”。这像是试着通过打比方来做工程,或通过讲故事来做工程,而不是通过做工程来做工程。这使得它,“不那么好”,你懂的。

谎言三:出于某种原因,代码比数据更重要。

我们花了这么多时间关注和讨论代码。我们来这里,实话说都是为了讨论代码,对吧?有这么多开发时间和脑力都花在讨论代码上,而绝大多数时候,代码并不是真正的问题所在。我们处理的所有事情中,代码只是一个很小很小的问题。实际上,我们所处理的数据才是我们要推断的东西,才是真正的问题所在。

正如我之前所说,任何代码的唯一目的都是为了变换数据。它必须把数据从这种形式变成那种形式,不管是在高层上,从磁盘上的数据和玩家输入变成屏幕上和扬声器中的内容,和根据代码层级一直到系统和系统内变换数据的东西。

程序员从根本上对这些数据负责。从根本上确保数据以正确、快速、可维护的方式变换,不是代码。代码是我们用来做这些变换的工具。

程序员的工作不是写代码。人们真的真的不愿意相信这一点。写代码不是你的工作,代码是你用来做你工作的一个工具,你的工作是解决数据变换问题,而解决问题才是工作。

因此,只写那些有直接可证明的价值的代码,以有意义的方式变换你想变换的数据。

而为了做到这一点,你必须理解数据,这样你才能理解问题,才能适当地解决问题。这意味着:并没有理想的抽象的解决问题的方法。不存在。你可以假装有,但事实上没有。

你不能让代码不过时,这就是个人们陷入的陷阱。他们相信这整个想法,即我可以写一点代码,它可以跑在未来所有可能的假想系统中。这根本是不可能的。总有一些有限的范围你没考虑到。例如,我们不会试图把某人为 SNES 或 Atari 2600 开发的引擎放在 PS4 上,并开发同样的游戏。随着时间推移,问题已经从根本上改变了。

回顾一下谎言:

- 谎言一:软件是个平台。
- 谎言二:代码应该围绕世界建模来设计。
- 谎言三:代码比数据更重要。

这些谎言会导致什么问题呢?一般来说,你会看到的,我肯定看到的问题是:
性能差、并发性差、可优化性差、稳定性差、可测试性差。

而我们在这上面建立了一堆其他设施,以试图解决这些我们给自己创造的问题。

解决在平台的限制下变换你有的数据这个问题,其他的都别管。就这么做就好。

---

我们来看个简单的例子,以给上面这些提供一些背景。

词典查询:我有一些键,我有一些值,我有一个所谓的键值对。给定一个键,我想得到一个值。


| key | value |
|     |       |
|     |       |
|     |       |

传统上,如果我首先考虑的是代码,这个表就是内存中或多或少会看到的东西。键和值会相互关联,因为在程序员的心智模式中,它们被关联在一起。这是一个键值,对吧?

但问题是,实际上它们并没有被关联在一起。如果分析一下的话,最常见的操作应该是对键的搜索。我们检查一下,对这个列表进行迭代时,对于任何给定的键,需要值的统计概率是多少?非常低。它只在那一个命中上。大多数情况下,我们不会需要值。大多数情况下,我们将对键进行迭代。这就是数据所告诉我们的,这就是问题的实际情况。而这两个东西实际上并不直接相关。

会出现的情况是,规模越大这个表现越坏。当你有更多的键和更多的值,每个键值对的性能就会越来越低。迭代键的过程中,你会把每个值都加载到数据缓存里,然后马上就扔掉了,因为绝大部分时间你根本就没用它。你在你的机器上浪费了相当多的带宽。因为你的心智模式,你在做一些不必要的事情。


       | key |             | value |
key -> |     |  -> ndx ->  |       |  -> value         
       |     |             |       |
       |     |             |       |

现实情况是,这才更像是实际过程:我们有一些键,我想从中找到一个键,依此得到数组索引,再依此从表里获得值。绝大多数情况下,这才是我们实际想要的实际过程。

这种情况下,键被存储在一起,缓存将加载我们现实中最可能需要的数据,即下一个键。而虽然值的那一次命中很可能是冷访问,会有个缓存未命中,但就只有那一次,在统计上是个罕见的情况。

所以概念上讲,我们要做的是先解决最常见的情况。首先是最常见的真正的、真实的、活生生的情况,不是最泛化的情况。

---

“这些编译器不能做吗?”,是吧?

我可以想象今晚的会议上,大家讨论 C++ 委员会还能添加什么到这他妈的语…… 添加到这语言里面,让它解决我们的问题,对吧?

我们来做个小小的评审,看看编译器能做什么、不能做什么,以及这里我们真正的角色是什么。

为了给些背景,一些指令时间。看看 AMD Piledriver 的时间:


Instruction | Latency
SQRTSS/PS   | 13-15
VSQRTPS     | 14-15
SQRTSD/PD   | 24-26
VSQRTPD     | 24-26

平方根,传统上被认为是一个非常昂贵的指令,延迟在 15 到 20 个周期左右。


Instruction | Latency
FSIN        | 60-146
FCOS        | ~154
FSINCOS     | 86-141
FPTAN       | 86-204
FPATAN      | 60-352

这个特定架构上最昂贵的指令会是这些超越数指令,它们都在 100 周期这个范围内。

我们绝大部分的函数,99% 以上的指令,都不会是这些,这些都是偶尔才会有的指令。但为了给些背景,以延迟而言,这是我们所在的时间范围。

然而,在内存访问方面,来看看我们的时间范围。从寄存器中读取基本上是免费的,从 L1 指令缓存或数据缓存中读取的时间大概都是 3 个周期。(既然说到这个了,图上这些数字来自于 Jason Gregory,他在 Naughty Dog 与 PS4 打交道。)

但一般来说,适用于几乎任何平台的粗略数字,就说 x64 架构吧:L1 缓存是 3 个周期,到 L2 大约是 20 个周期,到主内存大约是 200 多个周期(通常慢得多,但为了我们的估计,就说是 200 吧)。

为了让你了解这意味着什么:

L1 访问是这样,挺快的。

L2 访问是这样的。

而主内存访问则是这样…………………… 按真实比例。

所以现实中,如果检查一下我们的代码,按成分分析,指令流中最显著的将是这些 L2 缓存未命中。绝大部分的时间都是在等待来自内存的极其缓慢的过程。

这还不包括那些其他的问题,对吧?比如说,GPU 和 CPU 之间的共享内存模式,或者同一平台上的不同 CPU 类型,这些模式是什么,是否是可缓存的,或者是否是可合并式写入的,或者其他什么,这些都会改变内存访问的特性。

因此,来看看一个非常简单的例子。希望每个人都读得懂这个:


class GameObject {
  float m_Pos[2];
  float m_Velocity[2];
  char m_Name[32];
  Model* m_Model;
  // ... other members ...
  float m_Foo;

  void updateFoo(float f) {
    float mag = sqrtf(
      m_Velocity[0] * m_Velocity[0] +
      m_Velocity[1] * m_Velocity[1]);
      m_Foo += mag * f;
  }
};

一个小小的想象的例子。我们有这样一种典型的游戏对象,它有二维位置、二维速度、某个固定长度的名字、一个指向模型的指针,和一个 Foo 变量。这不重要,它只是个单一的游戏对象。想象一下,它是以那种典型的面向对象方式从其他东西继承出来的。

我们有这个函数 updateFoo(),它要做的就是取速度分量平方和的平方根,然后乘以你传入的某个参数 f,并加到 Foo 上。差不多就这样。只是个简单的函数。

传统上,我们检查它时会说,那个平方根是问题所在。但我们已经看到,这不是我们讨论的数量级上的真正问题。这比单独一个到主内存的访问至少要少一个数量级,所以这真的真的不是问题。

来看看这个的输出。你把这个过一遍编译器,实际的输出是什么?


GameObjectUpdateFoo:
    .cfi_startproc
# BB#0:
    pushq    %rbx
.Ltmp2:
    .cfi_def_cfa_offset 16
    subq     $16, %rsp
.Ltmp3:
    .cfi_def_cfa_offset 32
.Ltmp4:
    .cfi_offset %rbx, -16
    movss    %xmm0, 12(%rsp)
    movq     %rdi, %rbx
    movss    8(%rbx), %xmm1
    movss    12(%rbx), %xmm0
    mulss    %xmm1, %xmm1
    mulss    %xmm0, %xmm0
    addss    %xmm1, %xmm0
    callq    sqrtf
    mulss    12(%rsp), %xmm0
    addss    184(%rbx), %xmm0
    movss    %xmm0, 184(%rbx)
    addq     $16, %rsp
    popq     %rbx
    ret
.Ltmp5:
    .size GameObjectUpdateFoo, .Ltmp5 - GameObjectUpdateFoo
    .cfi_end_proc

注意一下这里的几件事:


    movss    8(%rbx), %xmm1
    movss    12(%rbx), %xmm0

这里有两个 32 位的读取。你可以看到它们在栈帧中紧挨着,有着 8 和 12 个字节的偏移量,所以这些可能是在同一个缓存行上。我们把这个算 200 周期。


    mulss    %xmm1, %xmm1
    mulss    %xmm0, %xmm0
    addss    %xmm1, %xmm0

这里有些浮点数乘法加法,大约 10 周期。


    callq    sqrtf

假设这里是直接调用平方根指令。为了凑个整,慷慨一点,就当 30 周期吧。


    mulss    12(%rsp), %xmm0

而这里这个乘回去的指令,是同一个地址,所以它已经在 L1 里了。算 3 个周期吧。


    addss    184(%rbx), %xmm0
    movss    %xmm0, 184(%rbx)

而最后这行对 m_Foo 的读取和加法,会是一个新的缓存行,这得有 200 周期。

很明显,这里花时间最多的工作,实际上是最初的读取以及最后的读取和加法。这个简单的函数中花费的时间里,L2 未命中与实际工作相比大约是 10:1。这个比例里的 1 就是编译器所处的空间。我们讨论编译器是干什么的时候,这才是它的空间,这是它能推断你性能的领域,大概 10%。

所以在一个真正的问题里,编译器可以推断你问题空间的 1% 到 10%。绝大多数的问题,绝大部分的问题空间是编译器根本无法推断的。

因为编译器是个工具,不是个魔杖,对吧?它以非常可预测的方式(通常是……非常可预测的方式)做你告诉它要做的事,但它不能解决你遇到的最重要的问题,根本不能。而实话说这也不是它该干的。

今天的主题是我们需要解决而编译器不能解决的那 90% 问题空间,以及我们如何帮助编译器解决它可以解决的 10% 问题空间。

我们可以寻找这些非常简单和明显的东西进行粗略计算,在整体上获得极大好处。这并不是什么复杂的东西,可以只简单看看就在前期做出合理的判断。

任何想说过早优化的人现在可以离场了,因为这是有史以来最被滥用的名句。

因此,每帧的 L2 未命中,我们不想浪费它们。回到我们每帧 16 毫秒和 33 毫秒的时间周期那点。

就我们正在看的这个函数而言,来推断一下浪费的情况:


    movss    8(%rbx), %xmm1
    movss    12(%rbx), %xmm0

这里我们在 64 个字节的缓存行中浪费了大约 56 字节(假设我们有一个 64 字节的缓存行,你的架构或一组架构可能不同,但我们没有使用其中的 56 个字节)。


    addss    184(%rbx), %xmm0
    movss    %xmm0, 184(%rbx)

这里,我们在 64 字节的缓存行中浪费了 60 个字节。我们没有使用它。

这大约浪费了我们 90% 的读取。或者,可以把它看作是使用了主内存和 L2 之间缓存行容量的 10%,剩下的全是干扰信息。使用 10% 与使用好 10% 是不一样的,但我们可以就从这里开始,把这个数字提高一点。

那么,如何更好地利用我们的容量呢?

最简单的方法就是确保得到的一整行都是真正需要的数据。如果对数据做一个非常简单的变换,我们说,我不只是要处理一个,我肯定要处理很多这样的数据(任何情况下,单独一个东西存在于真空中是非常不可能的),最常见的情况是我有多个,所以不妨把它们一起变换。如果要一起变换,不妨把它们打包到同一个缓存行中,这样就可以利用我有的容量。


struct FooUpdateIn {
  float m_Velocity[2];
  float m_Foo;
};

struct FooUpdateOut {
  float m_Foo;
};

void UpdateFoos(const FooUpdateIn* in, size_t count, FooUpdateOut* out, float f) {
  for (size_t i = 0; i < count; ++i) {
    float mag = sqrtf(
      in[i].m_Velocity[0] * in[i].m_Velocity[0] +
      in[i].m_Velocity[1] * in[i].m_Velocity[1]);
      out[i].m_Foo = in[i].m_Foo + mag * f;
  }
}

这种情况下,我们有个结构体 FooUpdateIn,它将速度和 m_Foo 打包到一起,这些是实际要读取的,我们在这个变换中实际关心的东西。还有个结构体 FooUpdateOut,里面有我们的输出 m_Foo,是真正需要的实际结果。我们一共处理 count 对这些数据。


12 bytes x count(5) = 72
4  bytes x count(5) = 20

一个缓存行上这些可以容纳多少个?五个 FooUpdateIn 将超过一个缓存行,72 字节。五个 FooUpdateOut 在一个缓存行以下,也就是 20 字节。


12 bytes x count(32) = 384 = 64 x 6
4  bytes x count(32) = 128 = 64 x 2

那么我们能不能安排得好一点呢?当然,很简单。只需要一次处理 32 个,或 32 的某个倍数。最后效果非常好,因为对于输入是 6 个缓存行,输出则是 2 个缓存行。对于这些特定的请求而言,完全打包好了,填满了主内存和 L2 之间 100% 的容量。


(6/32) = ~5.33 loop/cache line

检查一下我们的输入缓存行,有 6 个缓存行的读取,32 次迭代。这告诉我们,每读一个缓存行,可以做大约 5.33 次循环。

我们要推断一下这是否塞得满。在这些读取下我们还做了什么?一些平方根之类的数学运算。我们已经看过了这些的成本,就说大约 40 个周期吧。


sqrt + math = ~40 x 5.33 = 213.33 cycles/cache line

因此,乘以 5.33,就得到每个缓存行真正的工作大约是 213 个周期。如果我们的估计是每个缓存行读取大约 200 个周期,那这就相当不错了,大致上是相等的。因此,现在我们可能要在这里等待实际指令的几个周期。

然而,在 x64 上,这里做的事情可以得到流式预取的加成,所以可能会更快。我们仍然会受到缓存行容量的限制,但它会相当接近 200,非常接近我们的输入。在这个范围内,以我们的目的来说已经够好了,对吧?希望这说得通。

只是简单的一些变换,加上在高层上推断有多少容量被使用,以及如何被使用,在这个特定的变换中,就会有大约 10 倍的提速。而且这只是缓存行被使用了,还不是说它的使用效率尽可能高了,只是说你真的有在使用它,没有 90% 的浪费。

此外,看一下这段代码,它是可管理的,是可调试的,我们真的可以推断出改变的成本。这样写是有理由的。这样写是因为我们理解在做的事情的成本。因此,当做一个改变时,我们知道成本是多少;而在前面的例子中,要推断出改变的成本是非常困难的,所以成本上升了。

忽视不方便的事实,比如说,我不想知道我的缓存行容量,和数据缓存如何工作,以及我的有限硬件集合实际上是什么(即使做的是表面上可移植的东西,你讨论的仍然是一个有限的硬件集合),我不想对这些进行推断;但现实是,忽略不方便的事实不是工程学,而是教条主义。我们必须考虑现实,以便做好我们的工作,这只是专业素养。

---

一些常见的问题,来看看别的例子:

结构体中的布尔值。你经常能看到,人们在结构体中放布尔值。


class __attribute__((__packed__)) Foo
{
public:
  uint8_t  a0;
  uint16_t b0;
  uint8_t  c0;
  bool     d0;

  uint8_t  a1;
  uint16_t b1;
  uint8_t  c1;
  bool     d1;

  uint8_t  a2;
  uint16_t b2;
  uint8_t  c2;
  bool     d2;
};


Foo
(
  a0: 0
  b0: 1
  c0: 2
  d0: 4
  a1: 5
  b1: 6
  c1: 8
  d1: 9
  a2: 10
  b2: 11
  c2: 13
  d2: 14
)

我们来简单推断一下这样做的成本。假设我们至少已经把它打包了,不会被下一个类型对齐。至少可以说,这样信息密度极低。一个布尔值代表一位信息,而我们却为它占用了一整个字节,最少都浪费了其中的七位。

但是,最糟糕的问题不仅仅是那七位的浪费,而是我们可能把其他的东西推到了缓存行读取之外。


class Foo:
{
public:
  bool   m_NeedParentUpdate;
  bool   m_NeedChildUpdate;
  bool   m_ParentNotNotified;
  Mat4   m_ObjectWorld;
  bool   m_InheritScale;
  bool   m_InheritOrientation;
};


Foo
(
  m_NeedParentUpdate:   0
  m_NeedChildUpdate:    1
  m_ParentNotNotified:  2
  m_ObjectWorld:        4
  m_InheritScale:       68
  m_InheritOrientation: 69
)


  m_ObjectWorld:        4
  m_InheritScale:       68

看看这里,这个值把一些其他的值推了出去,所以现在这个特定结构体中的 m_InheritScale 被推到了第二个缓存行。现在我们的成本是 400 个周期,为了实际上只读取一位数据。这一位数据让我们多花了 200 个周期来读取其中一个值,太荒谬了。

布尔值也是导致“最后一刻决策”的常见问题来源。


int
Foo::Bar( int count )
{
  int value = 0;
  for (int i=0;i<count;i++)
  {
    if ( m_NeedParentUpdate )
    {
      value++;
    }
  }
  return (value);
}

来看看这个非常简单的、显然是生造出来的例子。希望你们能推断这个。想象一下有这个成员变量 m_NeedParentUpdate,如果为真或什么(假设类型是布尔值),那么实际上这会返回 count 的值,对吧?你可以推断出来。这是实现一个简单指令的非常糟糕的方式,但这是我们会看到的,随着时间推移传播到代码中的那种东西。这样的东西(也许其中还会嵌入一些别的)的实际存在并不罕见。

那么,编译器是如何推断的?

MSVC 是怎么处理的?再看看这个代码,我相信你可以脑中优化,对吧?这非常非常简单。


    xor     eax, eax
    test    edx, edx
    jle     SHORT $LN2@Bar
    mov     r8b, BYTE PTR [rcx]
    mov     ecx, edx
    test    r8b, r8b
    je      SHORT, $LN10@Bar
    inc     eax
$LN10@Bar:
    dec     rcx
    jne     SHORT $LL9@Bar
$LN2@Bar:

然而,这里的编译器,MSVC,大概开了 O2 或者相当于 O2 的优化,做的是实际上极其天真的变换,每次循环都要对那个成员变量进行重读和重测,然后递增和循环。这大概是我能想象到的处理这个函数的最糟糕的方式。

那它为什么要这么做呢?不幸的是,闭源代码环境,我们只能猜测。也许这个情景下,有额外的超级保守的混淆规则?尽管看起来也很可能是其他原因。它想象成员变量可能会改变?谁知道呢?事实是这种事发生了。


FooBar:
    .cfi_startproc
# BB#0:
    xorl     %eax, %eax
    testl    %esi, %esi
    jle      .LBB1_2
# BB#1:
    movzbl   (%rdi), %eax
    negl     %eax
    andl     %esi, %eax
.LBB1_2:
    ret
.Ltmp3:
    .size    FooBar, Ltmp3 - FooBar
    .cfi_endproc

我们来看看 Clang。Clang 怎么处理的?(这里用的至少是 3.4。)很好,它更激进一些,给了个更好的结果。它只检测了一次,并实际上返回绝对的值。它做了正确的、我们期望的事情,不错。


int
Foo::Bar( int count )
{
  int value = 0;
  for (int i=0;i<count;i++)
  {
    if ( m_NeedParentUpdate )
    {
      value++;
    }
  }
  return (value);
}

int
Foo::Baz( int count )
{
  int value = 0;
  for (int i=0;i<count;i++)
  {
    if ( Bar(count) > 0 )
    {
      value++;
    }
  }
  return (value);
}

那么,这样稍微复杂一点,但仍然在现实中会看到的范围内的东西呢?实际上我们就是在一个循环中调用了刚创建的那个函数,做了完全相同的事情。所以这整个应该能折叠成跟之前看到的完全一样的东西,对吧?想象一下,这种情况下,对 Baz() 的优化应该和上一张幻灯片中看到的完全一样。这只是用一种稍微复杂的方式来做完全相同的事。

因此,这里重要的教训是理解编译器不是魔法。它们可以做很多非常棒的变换,但也可以非常愚蠢。它们只是工具。


FooBaz:
    .cfi_startproc
# BB#0:
    xorl     %eax, %eax
    testl    %esi, %esi
    jle      .LBB2_2
# BB#1:
    xorl     %eax, %eax
    movl     %esi, %r8d
    andl     $-2, %r8d
    je       .LBB2_2
# BB#3:
    movl     %r8d, %ecx
    xorl     %r9d, %r9d
    .align   16, 0x90
.LBB2_4:
    movzbl   (%rdi), %edx
    negl     %edx
    testl    %esi, %edx
    setg     %dl
    movzbl   %dl, %edx
    addl     %edx, %eax
    addl     %edx, %r9d
    addl     $-2, %ecx
    jne      .LBB2_4
    jmp      .LBB2_5
.LBB2_2:
    xorl     %r8d, %r8d
    xorl     %r9d, %r9d
.LBB2_5:
    addl     %r9d, %eax
    cmpl     %esi, %r8d
    je       .LBB2_2
    .align   16, 0x90
.LBB2_6:
    movzbl   (%rdi), %ecx
    negl     %ecx
    testl    %esi, %ecx
    setg     %el
    movzbl   %el, %ecx
    addl     %ecx, %eax
    incl     %r8d
    cmpl     %r8d, %esi
    jne      .LBB2_6
.LBB2_7:
    ret
.Ltmp4:

来看一下这个情况下 Clang 的输出。这个特定的例子里,Clang 基本上是吐了自己一身,对这个特定的循环做了一个天真的变换。我们最多只能说,它把这个内联了,行吧,太棒了。


    xor     eax, eax
    test    edx, edx
    jle     SHORT $LN2@Baz
    mov     r10d, BYTE PTR [rcx]
    mov     r9d, edx
    mov     r8d, edx
$LL4@Baz:
    xor     ecx, ecx
    mov     rdx, r9
$LL17@Baz:
    test    r10b, r10b
    je      SHORT $LN18@Baz
    inc     ecx
$LN18@Baz:
    dec     rdx
    jne     SHORT $LL17@Baz
    test    ecx, ecx
    je      SHORT $LN3@Baz
    inc     eax
$LN3@Baz:
    dec     r8
    jne     SHORT $LL4@Baz

Visual Studio,一样的,它肯定正吐了擦下巴呢。这个情况下没有得到任何像我们想要的东西。

那么该怎么做?我们这里的流程是什么?如何帮助编译器?


int
Foo::Bar( int count )
{
  int value = 0;
  bool need_update = m_NeedParentUpdate;
  for (int i=0;i<count;i++)
  {
    if ( need_update )
    {
      value++;
    }
  }
  return (value);
}

int
Foo::Baz( int count )
{
  int value = 0;
  bool need_update = Bar(count) > 0;
  for (int i=0;i<count;i++)
  {
    if ( need_update )
    {
      value++;
    }
  }
  return (value);
}

一个规则:消除伪读取和写入。你不希望重新读取已经有的值,当已经有了数据时,你不想再去调用函数。你想尽可能多地告诉编译器这些信息,即我已经有了这些数据,你不需要再获取它们。就从这里开始。我们把对成员变量 m_NeedParentUpdate 的读取提到了循环外,也把对 Bar(count) 的调用提了出来,因为我们知道这是循环外的常量。

现在让我们看看编译器的结果:


FooBaz:
    .cfi_startproc
# BB#0:
    xorl     %eax, %eax
    testl    %esi, %esi
    jle      .LBB2_2
# BB#1:
    movzbl   (%rdi), %ecx
    negl     %ecx
    xorl     %eax, %eax
    testl    %esi, %ecx
    cmovgl   %esi, %eax
.LBB2_2:
    ret

嘭!Clang 可以推断出来这个,把整个东西折叠成我们想要的样子。


    mov    r8b, BTYE PTR [rcx]
    xor    eax, eax
    xor    r8d, r8d
    test   edx, edx
    jle    SHORT $LN8@Baz
    mov    ecx, edx
$LN10@Baz:
    test   r9b, r9b
    je     SHORT $LN9@Baz
    inc    r8d
$LN9@Baz:
    dec    rcx
    jne    SHORT $LN10@Baz
$LN8@Baz:
    test   r8d, r8d
    setg   r8b
    test   edx, edx
    jle    SHORT $LN2@Baz
    mov    ecx, edx
$LL4@Baz:
    test   r8b, r8b
    je     SHORT $LN3@Baz
    inc    eax
$LN3@Baz:
    dec    rcx
    jne    SHORT $LL4@Baz
$LN2@Baz:

Visual Studio,不怎样,还是差不多。

接下来我们能合理地做什么事?


int
Foo::Bar( int count )
{
  int value = 0;
  bool need_update = m_NeedParentUpdate;
  if ( need_update )
  {
    for (int i=0;i<count;i++)
    {
        value++;
    }
  }
  return (value);
}

int
Foo::Baz( int count )
{
  int value = 0;
  bool need_update = Bar(count) > 0;
  if ( need_update )
  {
    for (int i=0;i<count;i++)
    {
        value++;
    }
  }
  return (value);
}

我们可以再次给编译器尽可能多的帮助。把循环里所有不变的读取从这些分支中提出来,甚至是那些超级超级明显的、应该已经在寄存器中的读取。你会说,为什么编译器做不了这个?不管怎样你做就是了。把已经分配了值的 need_update 从整个循环中提出来,看看会发生什么。


    xor     eax, eax
    cmp     BYTE PTR [rcx], al
    je      SHORT $LN3@Baz
    test    edx, edx
    jle     SHORT $LN3@Baz
    cmovg   eax, edx
$LN3Baz:
    ret     0

行吧,Vision Studio 终于明白了,并且基本上可以给出我们要的结果。它事实上是可以对整个函数进行推断,将其优化到我们想要的程度的。这里有个不必要的分支,但或多或少相当于 Clang 的版本。

总的来说,当讨论 C++ 时,这非常适用于任何成员字段,但也适用于一般的函数和其他全局变量之类你可能读取的东西。显然,这不只是专门适用于布尔值的。

---

我们也来看看实际背景下的信息密度。我们如何推断它?怎样帮助编译器?


void
Foo::Update()
{
  float choose   = RandFloat();
  bool  is_spawn = (choose < m_SpawnChance);
  if (is_spawn)
  {
    Spawn();
    m_SpawnChance = 0.0f;
  }
  else
  {
    m_SpawnChance += kFooSpawnChanceIncrease;
  }
}

同样一个生造的简单例子,但也没有超出我们实际会看到的范围。我们有个假想的 Update() 函数,它拿一个随机值与存储的概率值进行比较。如果比较通过了,那么我们就调用一个 Spawn() 函数,并重置概率值。如果没有,就把某个全局常量加到概率值上。非常简单,直截了当,一个我们能在脑中推断的东西。

检查这个函数时,我们会说,这里的核心是那个分支,是那个值 is_spawn,决定是否要去调用 Spawn()。

如何推断这个 is_spawn 值在给定时间内的信息密度呢?


  bool  is_spawn = (choose < m_SpawnChance);
  printf("%d", is_spawn?1:0);

非常简单容易的方法就是直接把它打印出来,然后你把输出压缩成个 zip。压缩的目的是为了减少冗余,所以它很能告诉我们数据的信息密度。


(915 * 8) / 10,000 = 0.732 bits/frame

这种情况下,压缩了 10,000 帧的输出,得到 915 字节。让我们忽略有头信息之类的东西,这已经够接近了,对吧?因此,915 字节乘以 8 位除以 10,000 帧,这告诉我们,每帧实际上传输了大约 0.732 位的信息。

取决于你有什么信息,你已经知道这些东西的一些概率,你也可以自己计算一下信息熵。看你想怎么处理这个了。但快速直接偷懒的方法就是打印出来并压缩成 zip。

这在实际背景中说明什么?如果在这个函数中保守地计算一下,我们一帧有大约两个 L2 缓存未命中,乘以 10,000 帧。假设每个缓存行 64 个字节,这告诉我们,有大约 128 字节乘以 10,000 帧,或大约 1,280,000 字节的读取,以在 10,000 帧里变换这些数据。但我们也知道,我们的平均信息含量约为每帧 0.732 位乘以 10,000 帧,这给了 915 字节的信息,即在这 10,000 帧中读取的实际信息。现在我们可以推断出缓存行上的浪费量。


(1,280,000 - 915) / 1,280,000 = 0.99928515625

从主内存到 L2 的浪费比例,信噪比里大约 99.9% 都是浪费,缓存行上完全是浪费的干扰信息。这有助于我们推断出在这个变换中需要做什么。实际上绝大多数在这里做的工作都是不必要的,对吧?全是干扰信息。

对于这个特定例子,有什么替代方案呢?

如果要在每一帧内就地解决,那么我们可以看到,一帧里的 512 位数据中只需要一位,不管那个布尔值是否是真的。我将读取整个缓存行,那要怎么处理剩下的部分呢?一个简单的方法就是做 512 次同样的决定。如果需要处理 512 个,那我们就把整个东西打包成 512 位就可以了。这通常是一个不错的方法。

另一种方法是把它和其他读取和变换结合起来。我可以把其他同时会需要的东西放在这里,然后把这些变换组合在一起。这通常是最简单的,不是做一件事,而是同时做两件事。但你不能在抽象的泡沫中解决这个问题,对吧?我必须知道,按统计来看,我还会同时做哪些事情,以便将它们合并。这需要背景,需要信息。解决这个问题的唯一方法是考虑你处理的数据的现实。

解决这个问题的另一个方法是跨帧查看。这也是一种相当常见的方法,只在你需要读取时才读取。这种情况下,我们可以说,我们知道绝大部分时间从统计上来说不太可能真的调用 Spawn(),概率是非常低的。所以我们把它推到未来。


void
Foo::Spawn()
{
  ...
  uint32_t next_spawn_frame = NextSpawnFrame();
  InsertCommand( this, CMD_SPAWN, next_spawn_frame );
}

因此,如果我们随着时间处理一个命令缓冲区表或指令流之类的东西(比如长度是 128 帧或 512 帧之类,不管你要往未来看多远),我们可以算出什么时候会调用 Spawn(),然后把它推到未来的指令流中。这样的话,从现在到那时的所有帧之间,我们甚至都不用考虑、处理和关心它,我们只用把这个调用推到未来。这也是个非常不错的做法。

---

我们来评审一些其他的代码。

看看这个。不知道你们有多少人知道 Ogre 引擎,一个开源游戏引擎?不是想特意挑它的茬,只是因为它是开源的,而它是个很好的例子。


namespace Ogre {

    NameGenerator Node::msNameGenerator("Unnamed_");
            Node::QueuedUpdates Node::msQueuedUpdates;
    //-----------------------------------------------------------------------
    Node::Node()
                        :mParent(0),
                        mNeedParentUpdate(false),
                        mNeedChildUpdate(false),
                        mParentNotified(false),
        mQueuedForUpdate(false),
                        mOrientation(Quaternion::IDENTITY),
                        mPosition(Vector3::ZERO),
                        mScale(Vector3::UNIT_SCALE),
        mInheritOrientation(true),
                        mInheritScale(true),
                        mDerivedOrientation(Quaternion::IDENTITY),
                        mDerivedPosition(Vector3::ZERO),
                        mDerivedScale(Vector3::UNIT_SCALE),
                        mInitialPosition(Vector3::ZERO),
                        mInitialOrientation(Quaternion::IDENTITY),
                        mInitialScale(Vector3::UNIT_SCALE),
                        mCachedTransformOutOfDate(true),
                        mListener(0),
                        mDebug(0)
    {
        // Generate a name
        mName = msNameGenerator.generate();

        needUpdate();

    }

取个特定的方面,这个 Node 类。仅仅靠查看这段代码,我们能看出什么?能看到什么问题?

我马上就看到这里填了很多成员变量。但愿这能代表这个类中绝大多数的成员变量。我知道这些值在内存中不能被重新排列。编译器无法对此进行推断,不可能现实地期望它基于访问本地性和我要做的事情重新排列这些值。它很受 ABI 的限制,对吧?编译器必须做一切我们希望它做的事情。

它不能限制未使用的读取。如果我只读其中的一个值,其余的都得在缓存行中。编译器无法推断这一点,它就是这样的。而取决于你如何设置这个结构体,可能有或没有额外的结构体填充。

我们也可以看着这个说,正如我之前提到的,这里有很多布尔值。所以会有很多“最后一刻决策”,很多额外的复杂性。当然,也是靠看到这些结构体中的布尔值,我们知道这大大增加了读取任何东西的成本。

只从这个类的名字也可以看出,它是过度泛化的,对吧?“Node”。它也有个相对复杂的构造器。这将意味着几件事:

一是暗示读取是毫无管理的。这只是用来扔一堆东西的垃圾场,没人想过如何读取、什么时候读取、谁来读取,以及最常见的变换是什么。

我们通过“Node”这个名字还知道,这里的设计是这种一次一个的心智模式。就好像这个节点存在于真空中,而它要做它所有的事情,其他的节点将做其他节点所有的事情。但是我们也知道现实里最常见的情况不是一个节点,这会是非常罕见的。

这种情境下你会在哪里使用节点?对于这个特定的游戏引擎来说,会是在某种动画层级结构中。我有一个节点层级结构,而层级结构才是最常见的情况,对吧?一个层级结构中有一堆在一起的节点,这才是常见的情况,而不是一个单独的节点。

因此,有人在这里表面上优化的是这种泛用性,以保持这些节点独立,而不是现实中最常见的情况,即层级结构。我也很容易猜到,为了清理一堆东西,在析构器中会有很多不必要的读和写。

通过观察我们也肯定可以知道,指令缓存是毫无管理的。当有这样一个非常泛化的名字时,你就知道它的意图是让人可以在不同的情况下覆盖它。所有的那些更新函数之类的函数都会是虚函数,所以缓存是毫无管理的。我们不知道特定的指令集中,对于这些数据有哪些指令被加载。这完全是未经思考的。这样我们就可以不假思索地调用那个更新函数,这样我们就可以忽略正在处理的实际问题,而这让一切都变得更慢。

我们也看得出来,这里有个复杂得不必要的状态机。这里实际上至少有七个布尔值。假设结构体或函数中没有其他东西的话,我们至少有 128 个状态,任何时候都有至少 128 个状态需要我们在这个类中管理。

一个经验之谈:每个状态类型分别存储。我们想分别管理状态,我们想把相同的集合存储在一起,这样就根本不需要保留状态值。我们显然不需要七个。我们只用把不同的状态组合存储在一起,这样可以作为一个组独立地推断它们的概率。


        // Generate a name
        mName = msNameGenerator.generate();

我们还可以看到没定义清楚的约束条件。比如这里,我在调用 Node 的默认构造函数时,分配了一个名字。我就要生成一些名字,因为我就要。虽然现实里实际的常见情况是,我会在某个上下文里给它提供一个名字。很可能我们最终在这里做了一堆工作,而它之后会被覆盖掉。这暗示这里有大量浪费的读取,只因为这个你假装不知道这个类会发生什么的借口。

字符串一般都有这个特性。字符串通常都很糟糕。特别是文件名,人们喜欢假装不知道要跟哪些文件打交道。

而一个经验之谈是,一如既往,就是最好的代码是根本不需要存在的代码。线下处理,只做一次。预编译字符串哈希等等,无论你要做什么,都把它推回到过去。


        needUpdate();

在这里我们还看到过度解决或计算过多的例子。我们已经写完了这个默认构造器,现在我们又说它需要更新。我们可以想象这里会发生什么,层级结构中的一个节点将向下传播,并提示它所有的子节点都需要更新之类的。

尽管在这个默认构造器里,我们可以推断出这个变换的整个结果,我们静态地知道它。但你也知道,有了一定的复杂度后,即使是少量的复杂度,即我们之前看到的两个可以在脑中推断的函数,编译器也很容易迷失在它们的深度中。因此,编译器将永远无法推断这里这个函数,无法将其平展开来。它会不必要地为这个静态的、可知的结果产生实际工作。

但我们是程序员,我们可以制造工具来为我们处理这个。我们不该害怕生成代码来预先计算,然后把代码直接放到那里。对我们来说常见的情况是预先计算矩阵乘法:我们有一组在代码中要做的变换,可以在线下变换它们,生成代码,一次性完成。处理你的实际数据,如果知道你有稀疏或仿射矩阵之类的,不管你想做什么,你可以只解决这些问题,生成只解决这些问题的代码。

那么,我们怎么修复这类情况呢?


    //-----------------------------------------------------------------------
    void Node::translate(const Vector3& d, TransformSpace relativeTo)
    {
        switch(relativeTo)
        {
        case TS_LOCAL:
            // position is relative to parent so transform downwards
            mPosition += mOrientation * d;
                break;
        case TS_WORLD:
            // position is relative to parent so transform upwards
            if (mParent)
            {
                mPosition += (mParent->_getDerivedOrientation().Inverse() * d)
                    / mParent->_getDerivedScale();
            }
            else
            {
                mPosition += d;
            }
                break;
        case TS_PARENT:
            mPosition += d;
            break;
        }
        needUpdate();

    }

我们拿这个函数 Node::translate() 看看。另一个例子,没什么特别和不寻常的地方。我没挑什么特别糟糕的东西,这是很常见的那种函数。

这里有个 switch 语句,这虽然不是布尔值,但符合“最后一刻决策”的相同背景,对吧?它是在一个节点里面,试图单独推断它的上下文。尽管它是一个更大的层级结构的一部分,尽管调用它的环境很可能知道这对整个层级结构来说是什么情况,但它仍然试图在最后一刻进行推断。

它这么做是因为这是我们的世界建模。我们有个心智模式,即节点要一起推断这些问题。只因为这个心智模式,我们付出了沉重的代价。所以,我们要做的第一件事就是把这些状态组织好,让它们分开,这样我们至少能对它们进行推断。

回到这里,我们有本地空间变换、世界空间变换和父节点空间变换这几个“case”。我们把它们分开以进行推断。


void
NodesTranslateLocal( Node* nodes, int count, const Vector3& d )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    node->m_Position += node->m_Orientation * d;
  }
}

void
NodesTranslateWorld( Node* nodes, int count, const Vector3& d )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    if ( node->m_Parent )
    {
      node->m_Position += ( node->m_Parent->_getDerivedOrientation().Inverse() * d)
                         / node->m_Parent->_getDerivedScale();
    }
    else
    {
      node->m_Position += d;
    }
  }
}

void
NodesTranslateParent( Node* nodes, int count, const Vector3& d )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    node->m_Position += d;
  }
}

三个函数或成员函数,或者想用其他方式也行。

其中每一个函数,因为我们知道想推断的是一整个层级的变换,我们就说,我不仅要取一个节点,而是要取一个节点的列表,这样我就可以推断我在做什么。一个节点列表在本地空间进行移位,一个列表在世界空间,一个列表在父节点空间。

当然,一旦把这些分离了,我们接下来需要分配优先度,对吧?我们需要弄清楚时间最好花在哪里,解决什么问题。而这需要基于真实的数据。我们必须知道这些函数被调用的概率和次数大概是多少。我们可以进行粗略估计,也可以在检查真实数据时随时间改进这些估计,但至少要对这种概率和调用次数有一些了解,以便对要集中精力干什么做出合理的决定。

而且这里是有不同的背景的。对我们来说有两个背景:游戏中和编辑器中。这些是非常不同的。

这个特定的变换,在游戏中统计上非常常见的是通过不同的移位来移动很多东西,以及很多东西在不同地方以不同的量移动。而在编辑器中,非常常见的是通过相同的移位来移动大量东西。你在编辑器中抓起一堆东西,然后把它们全部移位。

这是两个不同的背景,两个完全不同的问题,我们需要分别解决。


void
NodesTranslateParent( Node* nodes, int count, const Vector3& d )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    node->m_Position += d;
  }
}

让我们只取其中一个,假设这个就是最重要的一个。我们的第三步将是选择它,并在统计上最常见的情况下减少浪费。这就是我们首先关注的事情,首先集中在统计上最常见的情况。

粗略计算出来的读取成本是,每次循环大约有两个缓存行,约为 400 周期。


struct NodeTranslate
{
  Vec3 m_Position;
  Quat m_Orientation;
};

void
NodesTranslateParent( NodeTranslate* nodes, int count, const Vector3& d )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    node->m_Position += node->m_Orientation * d;
  }
}


2.28 count per 200 cycles = ~88

我们如何改善这个呢?一个非常直接的变换。我们把实际读取的东西:位置、方向,事先准备到这个结构体里面,打包在一起。现在我们可以用实际需要读取的数据填满整个缓存行,或者多个缓存行。这样,我们每个缓存行就有 2.28 个循环,这就大大改善了。


node->m_Orientation * d;


t = 2 * cross(q.xyz, v)
v' = v + q.w * t + cross(q.xyz, t)

现在我们需要推断一下实际的其他指令的成本是多少。这里是每个循环约 88 个周期。我们看看这个操作符重载函数。(这是说明操作符重载混淆了实际发生的事情的一个好例子,很难推断这个。)

这里我们在做一个四元数向量乘法。那这会变换成什么呢?如果每天都干这个,我们可能很熟悉它变换成什么。我们可以直接看出来这至少是在 88 个周期的数量级上,所以差不多是对了。过了这个点,我们需要去进一步测量。但我们知道按粗略估计这是在大概正确的数量级上。

我们专注于最常见的情况,我们也已经进入了正确的数量级。现在,我们可以决定是否真的深入进去做更多显著的优化,或者说这已经足够好了。

我们也可以递归地应用同样的步骤。


void
NodesTranslateWorld( Node* nodes, int count, const Vector3& d )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    if ( node->m_Parent )
    {
      node->m_Position += ( node->m_Parent->_getDerivedOrientation().Inverse() * d)
                         / node->m_Parent->_getDerivedScale();
    }
    else
    {
      node->m_Position += d;
    }
  }
}

看看另一个情况,假设统计上世界空间移位是最常见的。它自己有个最后一刻的读取,即这个节点是否有一个父节点。我们想再把可以分开的状态分开,以便对它们进行推断。这个节点有无父节点直接暗示了它是否是根节点。调用这个的函数肯定知道上下文,可以分辨出这一点,对吧?但是,由于我们对世界的心智模式,我们把这种函数之外明确知道的对数据的检查,推到了这里的代码中,每个节点、每个变换都要付出这个代价。


void
NodesTranslateEachRoot( Node* nodes, int count, const Vector3* t )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    Vec3& d    = t[i];
    node->m_Position += d;
  }
}

void
NodesTranslateWorldEachWithParent( Node* nodes, int count, const Vector3* t )
{
  for (int i=0;i<count;i++)
  {
    Node* node = &nodes[i];
    Vec3& d    = t[i];
    node->m_Position += ( node->m_Parent->_getDerivedOrientation().Inverse() * d )
                        / node->m_Parent->_getDerivedScale();
  }
}

所以我们把它分成了两种情况:

对根节点进行变换,这个我们可以跨不同的层级结构进行。

对有父节点的节点进行变换,除了最上面的那个节点之外,这就是层级结构中的绝大多数节点。现在我们把它看成是统计上最常见的情况。

我们很难推断出这里的成本,因为这里发生了很多函数和方法的调用,不太清楚到底发生了什么。因此,我们需要把这个改清楚一点。


// NodeParent
//    Quat     DerivedOrientationInverse
//    float    DerivedScale
//    uint32_t ChildCount
//    Vector3  ChildPosition[]

void
NodesTranslateWorldEachWithParent( char* nodes, int parentCount, const Vector3* t )
{
  int k=0;
  for (int i=0;i<parentCount;i++)
  {
    Quat*    derivedOrientationInverse = (Quat*)nodes;
    nodes += sizeof(Quat);

    float    derivedScale              = *(float*)nodes;
    nodes += sizeof(float);

    uint32_t childCount                = *(uint32_t*)nodes;
    nodes += sizeof(uint32_t);

    for (int j=0;j<childCount;j++,k++)
    {
      Vector3& d = t[k];
      Vector3& childrenPosition = *(Vector3*)nodes;
      nodes += sizeof(Vector3);

      childPosition += (derivedOrientationInverse * d) / derivedScale;
    }
  }
}

这种情况下,我们可能会对数据进行预处理。如果那些函数造成了混淆或什么的,我们不能得到实际的数据。

我们通过创建一个输入流来预处理它。打包好我们真正想要的数据(这个特定的情况下是 DerivedOrientationInverse、DerivedScale、ChildCount,以及打包好的共 ChildCount 个的 ChildPosition[]),然后对这些进行迭代。这样我们就知道,正在读取的数据实际上是打包好的,我们至少是在合理地使用这个通道。

---

好消息是,通过这些例子可以看到,我们要处理的大多数问题真的很容易看出来。我们可以做这种粗略计算的分析,只看一下代码,花两分钟时间就可以推断。考虑到我们有限的硬件,真的很容易把问题定义出来。解决这 90% 的问题的副作用是,编译器在解决它能解决的那 10% 方面变得越来越好。

其他的好消息是,真正有组织的数据使得维护、调试,尤其是并发,变得更加容易。你知道该期待什么,你知道它在这里,你知道它是如何打包的,你知道它是如何组织的,你知道应该花多长时间,你知道如何改变代码,可以推断出改变的成本。你能把这一切尽收眼底,这让处理事情变得容易太多了。

坏消息是,跟往常一样,好的编程很难,而坏的编程真的真的很容易。

真相则是这些:

- 硬件才是平台,是我们打交道的现实。
- 围绕数据进行设计,而不是围绕一个理想化的世界。
- 作为程序员,我们的主要责任是变换数据。首先解决这个问题,而不是代码设计。

我们来把这一切带回 C++。

好消息是,我们通常有足够的工具来推断问题的最重要部分,即内存和数据。我们可以访问它,可以推断它,可以把东西移来移去。

坏消息是,我们通常也有一种文化,认为忽视真正的问题是一件好事。越来越多的东西被堆积起来,以掩盖内存的访问和排列方式,以掩盖目前为止我们处理的最重要的问题。

谈到 C++ 文化的时候,我想引用 Christopher Erickson 的一句话来结束,他是索尼 Santa Monica 的前任技术总监(如果你还没有读过的话,他也是《实时碰撞检测》的作者):
设计模式是为毫无独立思考能力的无脑程序员准备的填鸭材料,最终他们将生产出与他们使用的设计模式一样平庸的代码。
演讲结束。

问答环节

我们有几分钟的时间可以来提问,扔番茄,等等。
谢谢,很好的讲座。关于开头我有几个问题,你说你不使用 STL,你反对模板和所有这些东西,我不是来向你扔番茄的,我只是想了解一下,如果你不使用模板来复制你将在整个库中使用的功能,如何处理你会碰到的代码重复?第二个问题是,所有关于为缓存行优化的例子都很棒很有意思,我完全同意。但我发现,当我们是在不同的平台上而不是一个平台上时,这个很难做到。我们不知道程序将在哪里运行,也不知道缓存行有多大之类的东西。
抱歉,在我搞不清之前让我打断你。

第一个问题,模板。你怎么消除代码重复?第一,几乎可以肯定这不是人们认为的那么大的问题。人们发明重复的东西,以便他们可以发明模板来解决。但在模板有潜在用途的情况下,你也可以生成代码。模板只是穷人的文本处理器,而我们有大量的工具可以处理文本。实际上,你可以用其他的工具做得非常好,比模板更好。你不需要模板来解决这个问题。

另一个问题是,我有这么多平台,我不知道这些平台的特点。这并不对。也许你确实是不知道,但并不是说没有一套有限的特征和有限的范围。它总会在某个范围内。对于你可能要处理的硬件的范围,你应该知道最小值,你应该知道最大值,你应该知道平均值。你应该知道常见的情况。

你要解决的问题不可能是这样的,比方说,你不会把同样的解决方案放在一个 Z80 和谷歌服务器农场上,对吧?不太可能解决那个范围的问题空间。那么,你要处理的有限空间是什么?你要根据需求处理的芯片设计的有限集合是什么?它甚至需要 MMU 吗?诸如此类的事情。你肯定要有这样一套有限的要求。你需要阐明它们。你需要了解这个范围是什么,因为说实话,这种一般意义上的可移植性是一种愚蠢的想法。
你好,你有没有写过任何工具来帮助你识别这类问题,如果有的话,能和我们分享吗?
我们有很多临时的工具。我们也对我们的性能做分析,可以查看和推断它。但一般来说,当你从基本角度上来看,我是如何使用缓存行的,这是一个相当容易推断的事情。不管你用的是什么分析器,大概都能告诉你缓存未命中率,所以你可以在一个特定的时间段内,在一个特定的函数上采集你的缓存未命中率,数一下你做的读取,然后计算一下,考虑到有这么多的读取和这么多的缓存未命中,我究竟有多少数据是在使用缓存。这不需要一套特别精妙的工具。
我想问,比如说我有一个大型的代码库,它的开发思路完全和面向数据设计相左。你有什么提示或资源可以帮助我们获得正确的心态,以便理解如何修复这个代码库,并使用这样的设计来扩展它?
如何修复一个根本还没有考虑过数据的巨大代码库?

一步一步来吧。这就是你能做的。一次只走一步。选择任何东西都可以。从任何地方开始推断。转储数据,推断实际的信息密度,推断缓存行的使用量和容量,明确清晰地推断,从这里开始。

但任何地方都行,只要开始就行了。不要认为你必须在所有的地方做所有的事情。找到最常见的例子来开始。这就是我们的流程。找出最常见的变换是什么,然后从那里开始。
我只是想提出一个观察。你说,这不是个新问题,不是个新东西。Fred Brooks 很久以前就说过:“给我看看你的代码,否则我不知道你在做什么。”。给我看看你的表格,我会评价你如何组织你的数据,我不需要看你的代码。
当然了。如果我知道输入是什么,知道输出是什么,它们之间我就只能得到这么多方式。这不是什么新闻,对吧?绝对不是。
听你的讲座时,我意识到你的工作环境与我的工作环境非常不同。你是在一个非常受时间和硬件限制的环境中工作。我们都知道,你把硬件指定为平台没问题,因为那是硬件提供给写在芯片内部工作的嵌入式代码的开发者的 API。这是那个层级上的平台。

理想情况下,我认为平台的层级应该是你能以最大效率抽象你问题的层级。我有一个库,比如说,它允许我为 Linux 和 Windows 写代码,我对此没有任何问题。我们公司的主要限制因素是工程资源。我们不怎么担心运行时间,因为这都是用户界面的东西。我们担心的是一个程序员在给定的时间内需要多长时间来开发一段代码。我们并不关心这些东西。如果能在一句代码中写出个哈希映射,并把值拿出来,我们不关心它需要多长时间,只要它是在 O(……
你不关心需要多长时间,没问题。但那些不关心需要多长时间的人也是我必须等 30 秒才能启动 Word 的原因,举个例子。不管怎样,这是你的限制。

我不觉得这与我们的工作是正交关系。我们也担心我们的资源。我们必须把东西弄出来。我们必须按时构建这些东西。我们担心这个。

不过,我们专注于我们能做的最有价值的事情。我们专注于理解问题的实际限制,理解实际上可以把时间花在哪里,使它最终能对我们的玩家或你的用户来说有价值。这就是我们想要带来价值的地方,而性能对我们来说非常重要。

但我想说,任何背景下,对我个人而言,如果我被放到所谓的商业软件开发模式中,我不认为我的心态会有很大的改变,因为性能对用户来说非常重要,不管他们在做什么。这是我的观点。
你对可移植性有什么看法?即使在你的领域,你也必须支持 PlayStation 3、PlayStation 4 和 Xbox,这些都有差异。你基本上是在为单个平台进行优化,对吧?
是也不是。我们经常为一个平台进行优化,但也为有限的一组平台进行优化。再次推断一下这个范围,并不是从 6502 到 Cell,范围比那小。我们可以在这个范围内得到一些大致上工作良好的东西。我们是可以对此进行推断的。
但比如说你为 PS3 写了一个引擎,现在你要把它移到 PS4。听起来,你这种做法需要多得多的精力。
这并没有改变问题。这不是我们的工作方式。我们所做的是解决现实中的问题。你从 PS3 搬到了 PS4,这就是实际的问题。

你可以忽略那个硬件上实际存在的问题,这对你没有任何好处,你只是忽略了它。比较一下,如果我忽视这个问题,我得到的就少,或者如果我对它进行推断,我至少有可能得到更多。我选二,老哥。
你给我们展示了一些变换数据的好例子,我发现,只要你处理的是几乎荒谬的简单数据,比如说数组,那么解决的代码是非常容易做查询或变换的。如果你处理的是一个数组,几乎所有这些问题都非常容易解决,但如果你的数据太大,不能只装到一个大数组里,你就需要开始考虑 B 树和其他更复杂的数据结构来管理对内存的访问。你不能只做一个 for 循环。代码也变得更加复杂,这会是个复杂的问题,对吧?
抱歉,如果我可以总结一下你的意思的话,困难的问题就是困难的,跟你说的一样。随着数据变得越来越复杂,对其进行推断的复杂度也越来越大,因为问题更难。
我的意思是,即使你思考数据了,认为数据是问题了,那么数据的表示方式也是问题的一部分。
当然是了。你在推断如何访问这些数据,推断访问的成本,这就是重点。而更复杂的系统需要更复杂的推断。你必须搞清楚你确实有一堆更复杂的东西要处理。B 树不算是一个特别复杂的例子。但无论你在做什么,无论出于什么原因,你都需要做一个成本效益分析。

你这样做是有理由的,对吧?你试图从中得到一些东西。而你必须对每一个操作的成本进行推断,我如何在这个树上跳跃,我如何分析这个树的部分。无论你在做什么,你都必须能够推断出这些成本,以便知道你是否得到了一开始就把这个结构放在那里而试图得到的好处。是的,这些情况下你也要这么做。我们在游戏引擎中当然是有复杂的数据结构的。
所以你的意思是,如果算法需要依赖于表示方式,如果表示方式改变了,就重写所有的算法。
如果数据变了,你就有不同的问题。如果你有不同的问题,你就需要不同的算法。当然是这样。
这或许更多是个评论而非问题,但我注意到在你讲座的中间部分,当你把大的节点结构切成小的数组,然后你给出了一些优势,其中之一是可维护性,我认为这对我们中一些人来说是反直觉的。但是也许(我把这个理解成这样,不知道你觉得对不对),可维护性的一部分也是对未来糟糕重构的健壮性。如果我有一个大的结构体,有人说,啊哈,我要在这里加个额外的布尔值,就放在这个中间,突然间这毁了你的缓存行,你有一个性能倒退,然后你不得不去寻找原因。
这当然是一部分。可维护性的另一部分,是你快速推断发生了什么的能力。

当这个特定的函数里有 120 个不同的状态在发生,这个函数试图读取所有这些布尔值,管理所有不同的状态时,这是很难观察和推断的。人们常常会在看某个函数时说,我不知道这个函数到底在做什么,因为有这么多的分支和所有这些状态,有成千上万的不同状态和潜在的不同事情发生。我无法对其进行推断,这使得维护难得多。如果你的东西只有很少的状态,它就更容易维护,因为它更容易推断。
对 C++ 有趣的观点。我的问题是,你使用 C++ 的理由是什么?你为什么不坚持用 C 呢?
好吧,你想往那个方向带。我知道我是在 CppCon 上说这个,我的个人偏好当然是使用 C99,但理由真的是文化。在过去的 15 年里,C++ 已经成为文化上的主导,我们接受了这一点。这是事实,所以我们就凑合用了。
当我们与 C++ 一起向更新的标准发展时,你们的行业是否与我们一起使用更新的 C++11 功能,比如,移动语义?右值引用?
某种程度上,是的。但正如你已经看到的,我们避免使用很多甚至是 C++98 里就有的东西,我们仔细评估这些东西的优势和成本。任何东西,只要它是有利的,我们就会使用;如果没有好处或者是有害的,我们就不会使用,因为它只是个工具。只要能在我们实际有的编译器中工作,那么我们和其他任何团队都会去严格评估。它是否带来了表面上应该带来的好处,或者它是否妨碍了我们的工作?我们当然不会为了使用一个语言功能而使用它,或者只为了使用语言功能而使用它。同样的,软件不是平台。
相比于 C 或汇编,你从 C++ 中得到了什么好处?
同样,文化上,人们已经习惯了。呃……你懂的。这个问题不该问我。
你好。我很好奇,在现在的主机和较新的工具链上,使用 C++11 并获得复制消除和其他一些东西,是否对你们的性能有任何具体的影响?
没有,完全没有。

哦,对前面那位我有一个答案该给。C++ 的“优势”。MSVC 在支持 C 方面有非常糟糕的历史,所以这就是了,C++ 的“优势”,它被 MSVC 支持。
我对现代工具链的另一个问题是,你们是否使用链接时优化(LTO)或分析器引导优化(PGO)作为你们一般工作的一部分?
没有,只因为它太垃圾了。慢得难以置信。
LTO 还是 PGO?
真的超级垃圾。但我们也不是没有试过,就是这么垃圾。我们一般不把它放到我们的流水线里。
好吧,哪部分垃圾?是代码生成,还是它使你的链接变慢的事实?
太慢了,难以置信的慢。链接器一般来说已经很慢了,这个起点就很糟糕。现在我们说,为了点边际效益,放一大堆更多东西到那里吧。所以,这并不是我们日常流水线的一部分。
听上去你该在周一听听我的构建加速讲座。
我觉得每个人大概都得关注这个。

还有什么问题吗?好的,谢谢大家。

Tags: 编程 游戏