第 11 章
多核的诅咒
性能剖析器的屏幕上,主线程的占用率曲线死死钉在百分之百的位置。其余五个硬件线程的曲线懒散地匍匐在底部,偶尔泛起几道无意义的涟漪。帧率计数器显示着刺眼的红色数字——二十四帧,有时跌至十八帧。渲染管线还有大量余裕,内存带宽远未触及天花板,GPU甚至有时间等待垂直同步信号。瓶颈只有一个:那颗主频高达三点二千兆赫兹的PowerPC核心已经竭尽全力,而它的五个兄弟核心正在旁观它的挣扎。
这个场景出现在Epic Games北卡罗来纳州卡里总部的开发机上,时间是2006年深秋。在随后的两年间,它以不同的变体反复上演——在id Software得克萨斯州梅斯基特的工作站上,在无数第三方授权工作室的性能测试报告里,在GDC演讲者私下交流时交换的苦涩眼神中。
它宣告了一个时代的终结:自1993年id Tech 1确立以来统治游戏引擎架构长达十三年的单线程主循环模型,在第七世代主机面前走到了尽头。
自约翰·卡马克在Doom的源代码中写下那段经典的主循环结构以来,游戏引擎的核心逻辑一直建立在一条清晰的单线程时间轴上。逐帧处理输入,更新游戏世界状态,提交渲染指令,等待垂直同步信号,然后开始下一帧。
这条时间轴根深蒂固到了不再被视为一种技术选择的地步——它已经成为一种不言自明的公理。引擎程序员在架构设计时天然地假设:游戏世界的更新是顺序的、确定的、不可分割的。物理计算必须在AI决策之前完成,因为AI需要知道物体的新位置。AI决策必须在渲染之前完成,因为渲染需要知道角色的最终姿态。这条依赖链构成了一个严格的串行逻辑,任何试图打破它的尝试都被视为对确定性和可调试性的威胁。
Xbox 360的三核六线程PowerPC架构和PlayStation 3的Cell异构多核架构,以一种不容商榷的方式宣告了这一公理的失效。这两台主机分别于2005年11月和2006年11月发售。
它们的CPU设计哲学截然不同——微软选择了三个相同的PowerPC核心,每个核心支持双线程,形成六条对称的硬件线程;索尼则押注在一个主PowerPC核心与八个协同处理单元组成的异构架构上,这些SPU拥有独立的本地存储和指令集,需要显式的DMA传输来与主存交换数据。但无论哪种设计,它们都传递着同一个信息:未来的计算能力不再来自单核主频的提升,而是来自核心数量的增长。
摩尔定律的物理极限正在逼近,登纳德缩放定律已经在2006年左右开始失效,处理器制造商转向了多核扩展的道路。
对于游戏引擎而言,这意味着硬件契约发生了根本性的断裂。自id Tech 1以来,引擎与硬件之间存在着一条不成文的约定:每一代新硬件都会提供更快的单核性能,引擎只需保持其单线程架构,就能自然地获得性能提升。这条约定支撑了从Doom到Half-Life 2的整个黄金时代。但现在,硬件供应商单方面撕毁了这条约定。
他们提供的不是一匹更快的马,而是一个六匹马的战队——而引擎的马车还只设计了一根缰绳。
Epic Games是最早感受到这种断裂的团队之一。作为Xbox 360的首发技术合作伙伴,他们在2005年《战争机器》的开发过程中就已经遭遇了多核调度的问题。但那时他们采取的是一种权宜之计:将渲染线程从主线程中分离出来,形成一个独立的线程负责Direct3D指令的提交。这是一个相对安全的操作——渲染指令的生成与游戏逻辑的更新之间没有严格的数据依赖关系,可以异步执行。
这种双线程模型在《战争机器》中取得了显著成效,帮助这款游戏在2006年11月发售后成为了Xbox 360上画面最出色的作品之一。
但当Epic试图将UE3授权给更广泛的第三方开发商时,问题暴露了。《战争机器》是一款线性掩体射击游戏,其游戏逻辑的复杂度是可控的。
但授权方们想要制作的游戏类型五花八门——大型多人在线游戏、开放世界角色扮演游戏、实时战略游戏——这些类型的游戏逻辑复杂度远超线性射击游戏。如果引擎只能利用两个线程,那么当主线程被复杂的AI决策、物理计算和脚本逻辑占满时,帧率就会不可避免地崩溃。
2007年春天,Epic的引擎架构师们开始在白板上绘制一种新的系统。他们称之为Task Graph——任务图。
其核心思想是将每一帧的游戏逻辑分解为一系列独立的任务节点,每个节点代表一个可以并行执行的计算单元,节点之间的有向边表示数据依赖关系。一个典型的帧可能被分解为数百个这样的任务:物理碰撞检测、AI路径规划、动画骨骼更新、粒子系统模拟、脚本事件触发。这些任务被提交到一个线程池中,由多个工作线程并行执行。当一个任务的所有前置依赖都完成时,它就可以被调度执行。白板上的任务依赖图看起来像一张疯狂的网。线条从物理节点指向AI节点,从AI节点指向动画节点,从动画节点指向渲染节点。
但更多的线条横跨不同的子系统:一个脚本事件可能触发一个物理效果,一个AI决策可能需要查询导航网格的最新状态,一个粒子系统可能需要读取上一个帧的深度缓冲区来进行软粒子混合。这些跨系统的依赖关系是并行调度的噩梦——它们创建了复杂的同步点,迫使工作线程在等待依赖满足时陷入停滞。
更致命的问题在于确定性。在单线程模型中,游戏世界的更新顺序是严格确定的:每一帧都以完全相同的顺序处理输入、更新逻辑、提交渲染。这意味着给定相同的输入序列,游戏的行为是完全可复现的。这对于调试至关重要——当测试人员报告一个bug时,程序员可以精确地重现导致bug的条件。但在任务图模型中,任务的执行顺序取决于线程调度的随机性。同一帧在不同次的运行中可能以不同的顺序执行任务,导致微小的浮点精度差异累积,最终产生不可复现的行为。
Epic的解决方案是在任务图中引入显式的同步屏障。每一帧被划分为若干个阶段,每个阶段内部的任务可以并行执行,但阶段之间必须等待所有任务完成。
这在一定程度上恢复了确定性,但也限制了并行度——如果某个阶段中有一个特别耗时的任务,所有其他工作线程都必须等待它完成才能进入下一阶段。
2007年8月,Epic向其主要授权方发布了UE3的多核更新。结果是一场灾难。那些已经基于单线程模型编写了大量游戏逻辑代码的工作室发现,他们的代码在新的任务图系统中根本无法正常运行。游戏逻辑中充斥着隐式的顺序假设——某个变量在某个函数调用之前必须被初始化,某个对象在某个时刻必须处于某个状态——这些假设在单线程模型中天然成立,但在并行调度下被彻底打破。最严重的问题出现在脚本层。UE3使用UnrealScript作为游戏逻辑的主要编写语言,许多授权方已经在其中积累了数万行代码。UnrealScript虚拟机原本设计为在主线程上串行执行,现在突然被要求支持在多个线程上并行执行多个脚本实例。
竞态条件如同雨后春笋般涌现:两个脚本同时修改同一个对象的状态,一个脚本读取的数据被另一个脚本中途修改,脚本触发的连锁事件在不同线程上以不可预测的顺序展开。
一家大型第三方开发商的技术总监在私下沟通中表达了激烈的反应。他们的项目已经进入全面制作阶段,拥有超过五十万行的UnrealScript代码和数千个相互关联的游戏逻辑系统。多核更新导致他们的游戏在百分之三十的运行时间里产生不可复现的崩溃或逻辑错误。他们的工程师团队花了三个月时间试图修复这些问题,但每修复一批bug,就会有新的一批出现。最终,他们不得不做出一个痛苦的决定:放弃多核更新,回退到双线程模型,接受帧率永远无法稳定在三十帧以上的现实。
Epic并非没有预见到这个问题。引擎总监蒂姆·斯威尼在2006年的一篇内部技术备忘录中就警告过,将现有的单线程代码库迁移到并行模型,其难度不亚于重写整个引擎。
他建议采取一种渐进式的策略:首先在新项目中要求开发者使用一套新的并行编程API,然后在后续版本中逐步废弃旧的单线程API。但市场的压力不允许这种从容的过渡。授权方们正在为第七世代主机开发游戏,他们需要立即获得多核性能,而不是等待一个需要数年时间才能成熟的并行编程模型。
在得克萨斯州梅斯基特,id Software的团队正在走一条完全不同的路。约翰·卡马克对多核并行化一直持怀疑态度。他在2007年QuakeCon大会上的演讲中阐述了他的立场:多核是一个真实的现象,但他不认为游戏逻辑的细粒度并行化是正确的方向。游戏逻辑中的依赖关系太复杂了,试图将它们分解为独立的任务会导致大量的同步开销和不可调试的bug。他认为更好的方法是保持主线程的完整性,将那些天然独立的工作负载——比如渲染、音频、文件IO——放到辅助线程上。卡马克的哲学建立在他对单线程优化的极致信心之上。
在他的职业生涯中,他已经无数次证明了通过手工优化可以在看似已经达到极限的硬件上榨出额外的性能。Doom 3在2004年发布时,其动态光照系统被认为只能在高端PC上运行,但卡马克通过精心的汇编级优化将其塞进了初代Xbox的七百三十三兆赫兹奔腾III处理器中。如果他能在一个处理器上做到这一点,为什么不能在三核处理器上做到?
id Tech 5正是在这一哲学指导下设计的。该引擎最初于2007年6月在苹果全球开发者大会上首次公开亮相,后来被用于《狂怒》的开发。它的架构保持了一个强大的主线程,负责所有的游戏逻辑更新、物理计算和AI决策。渲染被分配到一个独立的渲染线程上,但这两条线程之间的交互被精心设计为最小化同步开销。资源加载和音频处理被分配到后台线程上,但它们与主线程之间的通信是单向的——后台线程将数据推送到主线程可以安全读取的缓冲区中,而不需要主线程等待它们完成。这种架构在受控环境中表现得极为出色。
id Software的内部演示展示了令人惊叹的画面:六十帧每秒的稳定帧率,巨大的户外场景,独特的虚拟纹理技术使得地形表面呈现出前所未有的细节水平。卡马克在2008年GDC上的演讲中展示了这些成果,台下响起了热烈的掌声。
但《狂怒》不是一款受控的演示。它是一款开放世界第一人称射击游戏,设定在一个后末日风格的废土世界中。玩家可以自由地驾驶车辆穿越广阔的地形,遭遇成群结队的敌人,触发复杂的物理交互——爆炸、碎片、可破坏的环境物体。在这种场景下,主线程的工作负载不再是恒定的。当玩家驾车高速穿越密集的城市废墟时,物理碰撞检测的计算量会突然飙升。当多场爆炸同时发生时,粒子系统和破坏模拟会瞬间吞噬大量的CPU周期。
当多个AI角色同时做出复杂的战术决策时——寻找掩体、包抄玩家、投掷手雷——AI系统的计算量会呈指数级增长。在这些负载峰值下,id Tech 5的单线程架构暴露了其致命的弱点。
主线程无法将工作分散到其他核心上,因为所有的游戏逻辑都被设计为在主线程上串行执行。渲染线程只能等待主线程完成更新后才能开始工作,所以当主线程过载时,渲染线程也跟着停滞。后台资源加载线程此时也无能为力——它们只能加载数据,不能帮助计算。
2011年10月《狂怒》发售后,问题以灾难性的方式浮出水面。PC版本的玩家报告称游戏在复杂场景中帧率会从六十帧骤降至十几帧,同时伴随着严重的纹理流式加载延迟——当玩家快速转动视角时,屏幕上的纹理需要几秒钟才能从低分辨率模糊状态过渡到清晰状态。这个问题的根源在于主线程过载导致资源加载请求无法被及时处理:后台线程已经将纹理数据加载到了内存中,但主线程没有时间去更新GPU的纹理引用,导致旧的模糊纹理继续被显示。
媒体和玩家的反应是严厉的。《狂怒》在Metacritic上的PC版评分远低于主机版,用户评分也是惨淡。
论坛上充斥着愤怒的帖子,批评id Software未能充分测试PC版本,指责卡马克过于沉迷于主机优化而忽视了PC平台的特殊性。但真正的问题比这更根本:id Tech 5的架构假设游戏逻辑的计算量可以被控制在单核的处理能力之内,而这个假设在开放世界的高负载场景下被彻底打破。卡马克本人对此做出了回应。
在2012年QuakeCon的问答环节中,他承认id Tech 5的多核利用率不够理想,并表示id Tech 6将采用全新的并行架构。但在同一个回答中,他仍然坚持自己的核心观点:他不认为细粒度的任务并行是正确的方向。他认为更好的方式是将游戏世界划分为空间区域,让不同的核心负责不同的区域。这是一个优雅的折中方案,但它也意味着id Software需要在下一个引擎中重新设计几乎所有的核心系统。
而在哥本哈根,Unity的团队正在以一种完全不同的方式经历多核危机——或者说,他们几乎没有经历这场危机。
Unity的架构从一开始就做出了一个看似有缺陷的设计决策:游戏逻辑使用Mono运行时执行,而渲染引擎使用C++编写,两者运行在不同的线程上。这个决策的初衷并非为了多核性能——实际上,Unity 1.0在2005年发布时,多核处理器还远未普及。这个决策是出于实用主义的考虑:Mono提供了一个安全的沙箱环境,允许开发者编写不会导致引擎崩溃的游戏逻辑;C++渲染引擎则可以提供足够的性能来驱动现代图形API。但这种分离天然地将逻辑线程和渲染线程解耦了。
当2006年多核浪潮袭来时,Unity发现自己的架构已经天然地适应了多核环境。游戏逻辑在Mono线程上运行,渲染指令在C++渲染线程上提交,两者通过一个命令缓冲区进行异步通信。这种设计与Epic费尽心力构建的任务图系统有着相似的最终效果,但Unity是通过一种偶然的架构选择达到这一点的,而非通过痛苦的后期重构。更有趣的是Unity脚本层的解释执行特性。
与UnrealScript不同——后者被编译为原生代码并期望在主线程上运行——Unity的Mono脚本在一个托管运行时环境中执行,天然地接受了一定程度的非确定性。C#语言的垃圾回收机制意味着对象的内存地址和生命周期本身就是不确定的;Mono运行时的即时编译意味着同一段代码在不同次的执行中可能产生不同的机器指令序列。这些特性在传统意义上被视为性能缺陷,但在多核环境下,它们反而成为了优势:Unity的脚本层从来就没有承诺过严格的确定性,因此当多核调度引入额外的非确定性时,没有代码会因此而崩溃。
这并不是说Unity在多核优化方面毫无作为。2008年发布的Unity 2.0引入了对多线程物理计算的支持——PhysX物理引擎可以在独立于Mono线程的物理线程上运行。但这是一个相对平滑的增量改进,而不是Epic经历的那种撕裂式重构。
Unity的开发者不需要重写他们的游戏逻辑来适应新的并行模型,因为他们的游戏逻辑从一开始就没有被绑定到单线程模型上。这种架构上的意外优势在随后的几年中变得越来越明显。当Epic的授权方在任务图重构中挣扎时,当id Software的手工优化在面对开放世界负载时崩溃时,Unity的开发者们正在相对平静地发布他们的游戏。他们可能没有最高端的画面——Unity的渲染能力在2008年时还远不及UE3和id Tech 5——但他们的游戏能够稳定地运行在多核硬件上,利用所有可用的核心来维持可接受的帧率。
历史的讽刺在于此。Unity在2005年做出那个有缺陷的设计决策时,三位创始人——David Helgason、Joachim Ante和Nicholas Francis——并没有预见到多核革命的到来。他们当时只是在解决一个更紧迫的问题:如何让那些不是卡马克级别的程序员的普通开发者也能安全地编写游戏逻辑。
Mono沙箱是一个安全措施,而不是一个并行化策略。但正是这种对安全性而非性能的优先考虑,使得Unity在多核危机中获得了架构上的免疫力。
这揭示了一个更深层的规律:在面对硬件契约断裂时,那些与旧契约耦合最深的系统会受到最严重的冲击,而那些由于各种原因——通常是实用主义或资源限制——保持了架构松耦合的系统反而能够更快地适应新的现实。Epic与Xbox 360硬件的深度耦合在第七世代初期为他们带来了巨大的性能优势,但当多核危机爆发时,这种耦合变成了重构的障碍。id Software对单线程手工优化的极致追求创造了技术奇迹,但当负载超出单核承受极限时,这种追求变成了死胡同。Unity的不够优化——它的脚本层性能开销、它的托管运行时、它的架构松耦合——反而成为了它在新时代生存下来的资本。
2008年秋天,当《战争机器2》即将发售、《狂怒》仍在紧张开发中、Unity 2.0刚刚发布时,整个游戏引擎产业已经清楚地认识到:单线程主循环的时代结束了。这不是一个可以通过更聪明的优化来回避的问题,而是一个必须通过架构层面的根本变革来应对的问题。那些试图在旧范式内寻找解决方案的努力——无论是Epic的渐进式任务图迁移还是id Software的空间分区方案——都只是在延缓不可避免的重构。
但这场危机也揭示了一个更令人不安的问题:如果连从单核到多核的迁移都如此痛苦,那么当硬件契约再次断裂时——当GPU通用计算、云计算、移动端多核架构同时涌现时——引擎产业还能承受多少次这样的重构?这个问题在当时没有人能够回答。但在圣克拉拉的英特尔总部,一个代号为Larrabee的项目正在试图给出自己的答案:如果硬件本身被重新设计为天然支持并行编程模型,那么软件就不需要经历这种撕裂式的重构。
这个想法的雄心令人敬畏,但它所依赖的假设——硬件和软件可以被协同设计来一次性解决并行问题——将在接下来的几年中接受残酷的检验。
《狂怒》在2011年10月的发售成为了这一系列事件的一个阶段性终点。当玩家们在PC上遭遇帧率崩溃和纹理延迟时,他们看到的不仅是一款游戏的优化失败,而是一条技术路线的极限。卡马克的手工优化哲学在Doom、Quake和Doom 3中创造了奇迹,但它在多核时代的开放世界面前到达了边界。
这条边界不是任何个人的失败所划定的——它是硬件契约断裂时,旧软件抽象所能承受的极限所划定的。那些帧率暴跌到十几帧的时刻,那些纹理模糊地停留在屏幕上的秒数,那些愤怒的论坛帖子和失望的媒体评分——它们是单线程信仰的最后丧钟。自1993年以来一直指引着游戏引擎架构的那条清晰的时间轴,在这个时刻彻底断裂了。取而代之的不是一条新的时间轴,而是一张网:一张由任务依赖关系、同步屏障、竞态条件和不确定的执行顺序编织而成的网。
这张网将在接下来的十年中变得越来越复杂,直到它成为每一个引擎程序员都必须面对的新现实。而在那张网的中心,那个曾经被视为不言自明公理的单线程主循环,安静地退出了历史的舞台。它没有被推翻,没有被驳斥,只是在新硬件提供的六条线程面前变得不再够用。这就是硬件契约断裂的本质:不是旧的方法错了,而是旧方法所依赖的条件不复存在了。当条件改变时,所有建立在其上的技术体系都必须重新寻找立足之地——而在这个过程中,有些体系能够适应,有些体系会崩溃,有些体系会因为偶然的设计选择而意外地发现自己已经站在了新世界的门槛上。