【译文】id Software的创始人 John Carmack 谈内联代码(Inlined Code)

John D. Carmack id Software的创始人之一,知名于《毁灭战士》,《雷神之锤》,《狂怒炼狱》等游戏。

本文是一封email.


在我写下这篇文章后的几年里,我更加看好纯函数式编程,即使在 C/C++ 中也是如此:https://gamasutra.com/view/news/169296/Indepth_Functional_programming_in_C.php

内联解决的真正问题是意外依赖和状态突变,而函数式编程能更直接、更彻底地解决这个问题。不过,如果你要进行大量的状态变化,让它们全部发生在内联中确实有好处;你应该不断意识到你正在做的事情的全部可怕之处。如果实在无法忍受,就想办法将代码块分解成纯函数(不要让它们再回到不纯的状态!)。

为了进一步证实文章的观点,几年后,当我在开发《毁灭战士 3》BFG 版时,发生了完全已经预料到的一帧延迟输入采样,而且差点就出厂了。那一刻我吓出了一身冷汗。在我反复强调延迟和响应速度之后,我差点就以完全不必要的一帧延迟发布了一款游戏。

让事情变得更复杂的是,.do always, then inhibit or ignore.(始终执行,然后抑制或忽略)策略虽然对高可靠性系统来说是个好主意,但在移动设备等功耗和散热受限的环境中就不那么合适了。

John Carmack
September 26, 2014

—————————————————————————————————————————–

From: John Carmack <johnc@idsoftware.com>
Date: Tue, Mar 13, 2007 at 4:17 PM
Subject: inlining code

这将是一封不同寻常的电子邮件 — 我想谈谈编码风格。我不会发布任何强制规定,但我希望每个人都能认真考虑其中的一些问题。我说的不是操作符周围的空格、括号样式或按类型或变量的指针之类的小事(不过我们也许应该解决这个问题),而是更广泛的代码组织问题。虽然没有什么灵丹妙药,但只要能提高几个百分点的生产率,就能从开发项目中节省几个月的时间。

这封邮件太长了,所以我打算稍后再跟进一些其他想法。我有很多一般性的东西要讨论,但有一个具体的战术方向是我想提倡的。

一年前,我参与了一场关于为航空航天应用编写极其可靠的软件的讨论,多年来我已经参与过好几次了。通常情况下,我会在讨论中抨击那些谈论使用线程和 RTOS 的人,因为简单的轮询循环看起来就像原始的视频游戏,会更加清晰和有效。然而,这次特别的讨论给我带来了一个新的问题:

的确,如果没记错的话(我已经有一段时间没读到这方面的报道了)…

萨博 “鹰狮 “战斗机(一种轻型战斗机)的线控飞行软件更进一步。 它禁止子程序调用和后向分支,但主循环底部的分支除外。控制流只能向前。 有时,一段代码必须为后面的代码留下注释,告诉它该做什么,但这对测试很有效:所有数据都是静态分配的,监控这些变量就能清楚地了解软件正在做的大部分事情。软件只做最基本的事情,当然,他们也非常重视彻底的地面测试。

在 “发布用于飞行 “的代码版本中,从未发现任何错误。

Henry Spencer
henry@spsystems.net

现在,航空航天业的许多做法都不应该被任何人效仿,而且往往是自我毁灭。你们中的大多数人可能都读过关于航天飞机软件开发过程的各种热门文章,虽然有些人可能会认为,如果所有软件开发人员都这么 “小心谨慎”,世界会变得更好,但事实是,如果所有东西都以这种蜗牛般的速度开发出来,我们就会比现在落后几十年,没有个人电脑,没有公共互联网。

这则轶事似乎有一定的实用价值,所以我决定试一试。犰狳火箭的飞行控制代码只有几千行,因此我使用了主 tic 函数,并开始内联所有子程序。虽然我不能说我发现了一个可能导致崩溃的隐藏错误(真的……),但我确实发现了几个被多次设置的变量,几个看起来有点可疑的控制流,而且最终代码变得更小更简洁了。

在使用这种风格的代码一段时间后,我没有发现它有任何缺点,而且我已经开始在我的代码中使用这种方法。在很多地方,我们可以在几种组织代码的方式中进行选择:

------- 风格 A:

void MinorFunction1( void ) {
}

void MinorFunction2( void ) {
}

void MinorFunction3( void ) {
}

void MajorFunction( void ) {
        MinorFunction1();
        MinorFunction2();
        MinorFunction3();
}

--------- 风格 B:

void MajorFunction( void ) {
        MinorFunction1();
        MinorFunction2();
        MinorFunction3();
}

void MinorFunction1( void ) {
}

void MinorFunction2( void ) {
}

void MinorFunction3( void ) {
}

---------- 风格 C:

void MajorFunction( void ) {
        // MinorFunction1

        // MinorFunction2

        // MinorFunction3

}

尽管有些人喜欢 “风格 B”,但我一直使用 “风格 A”,以便在所有情况下都不使用原型。这两者之间的区别并不重要。迈克尔-阿布拉什(Michael Abrash)曾经用 “样式 C “写代码,我记得我还真的把他的代码转换成了 “样式 A”,以提高可读性。

在这一点上,我认为 “C 风格 “有一些明确的优势,但这些优势是以开发过程为导向的,而不是离散的、可量化的东西,它们与相当多公认的传统智慧背道而驰,因此我将尝试为它提出一个明确的理由。这里没有任何教条,但值得考虑的是,它到底在哪些方面合适,哪些方面不合适。

我绝不是在以任何方式、形式或形式来证明避免函数调用就能直接提高性能。

我每隔一段时间都会尝试做一个练习,那就是在游戏中 “走一帧”,从一些重要的点开始,如 common->Frame()、game->Frame() 或 renderer->EndFrame(),然后进入每个函数,尝试走完整的代码覆盖范围。这通常会在你走到帧结束之前就变得相当郁闷。了解所有实际执行的代码非常重要,在调试过程中很容易跳过大量的代码块,即使它们对性能和稳定性有影响。

C++ 中的运算符重载、隐式构造函数等都与这一目标背道而驰。很多以灵活性为名的做法都是错误的,也是很多开发问题的根源所在。游戏具有连续重复的实时 Tic 结构,因此也有一些目标和限制条件,这促使编程风格与内容创建应用程序或事务处理器等有所不同。

如果某些事情需要在每帧完成一次,那么让它发生在帧循环的最外层就有一定的价值,而不是深埋在一连串的函数中,这些函数可能会因为某些原因而被跳过。例如,我们的 usercmd_t 生成代码就埋藏在 asyncServer 之外,而它确实应该放在主公共循环中。与此相关的是硬件设计与软件设计的话题–通常情况下,最好是先执行操作,然后选择抑制或忽略部分或全部结果,而不是尝试有条件地执行操作。usercmd_t 的生成与此(以及其他与游戏绑定集交互相关的混乱)有关,只有在 “需要 “时才会生成 usercmd_t。

传统上,我们衡量性能和优化游戏的方式是鼓励大量的条件操作–认识到某一特定操作不需要在某些运行状态子集中进行,并跳过它。这样可以获得更好的演示时序数据,但却会产生大量的错误,因为跳过昂贵的操作通常也会跳过一些其他地方需要的状态更新。

我们肯定仍有一些性能密集型任务需要优化,但在许多情况下,这种风格的应用是理所当然的,其性能优势可以忽略不计,但我们仍要面对错误。既然我们已经坚定地选择了 60hz 游戏,那么最坏情况下的性能比平均情况下的性能更重要,因此我们应该更加看重性能的高度可变性。

当操作在各种子系统中深度嵌套时,很容易出现操作延迟。这可能隐藏在输入质量几乎无法察觉的下降中,也可能明目张胆地表现为模型在移动过程中拖曳附着点。如果一切都以 2000 行函数的形式运行,那么哪个部分先发生是显而易见的,而且可以肯定的是,后面的部分将在渲染帧之前执行。

除了对实际执行代码的影响,内联函数还有一个好处,就是无法从其他地方调用该函数。这听起来很荒谬,但也有其道理。随着代码库使用年限的增长,会有很多机会让你走捷径,只调用一个函数来完成你认为需要完成的工作。可能有一个 FullUpdate() 函数会调用 PartialUpdateA()PartialUpdateB(),但在某些特殊情况下,你可能会意识到(或认为)只需要执行 PartialUpdateB(),而你避免了其他工作,从而提高了效率。很多错误都是由此产生的。大多数错误都是由于执行状态与你想象的不完全一致造成的。

严格意义上的函数只读取输入参数并返回值,而不检查或修改任何永久状态,因此不会出现这类错误。我并不认为纯函数式编程是一种实用的开发计划,因为它会导致代码晦涩难懂、效率低下,但如果一个函数只引用一两个全局状态,那么考虑将其作为变量传入可能是明智之举。如果 C 语言有一个 “functional“关键字来强制禁止全局引用,那就更好了。

常量参数和常量函数有助于避免与副作用相关的错误,但这些函数仍然容易受到全局执行环境变化的影响。尝试让更多的参数和函数常量化是一项很好的练习,但结果往往是在某一时刻沮丧地将其抛弃。这种挫败感通常是由于发现了各种可以修改状态的地方,而这些地方并不是一眼就能看出来的–也就是错误滋生的地方。

C++ 对象方法几乎可以看作是函数式的,在返回时会隐式覆盖赋值,但对于包含大量变量的大型对象,你并不能意识到方法修改了什么,同样,也不能保证函数不会跑去做一些可怕的全局性的事情,比如解析一个 decl。

最不可能引起问题的函数就是不存在的函数,这就是内联的好处。如果一个函数只在一个地方被调用,那么决定就相当简单了。

几乎在所有情况下,代码重复都比在不同情况下调用函数所产生的任何二阶问题更邪恶,因此我很少主张通过重复代码来避免函数,但在很多情况下,你仍然可以通过标记在适当控制的时间执行的操作来避免函数。例如,在 “玩家思考 “代码中检查一次健康状况 <= 0 && !killed 几乎肯定比在 20 个不同地方调用 KillPlayer() 产生的错误要少。

关于代码重复的问题,我追踪了一段时间我修复的所有 bug(在我认为一切正常后出现的问题),我对复制粘贴修改操作导致的并不明显的细微 bug 的出现频率感到非常惊讶。在对三四个东西进行小矢量操作时,我经常会像这样粘贴和修改几个字符:

 
v[0] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+0));
v[1] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+1));
v[2] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+2));
v[3] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+3));

我现在强烈建议对所有内容都使用显式循环,并希望编译器能正确地展开循环。我的很多 bug 都与此类问题有关,我现在甚至在重新考虑二维情况,我通常使用离散的 _X、_Y 或 _WIDTH、_HEIGHT 变量。我发现这比两个元素的数组更容易读取,但很难用我的数据来证明它让我出错的频率。

一些实际问题

在主要函数内部使用大的注释块来划分次要函数是快速扫描的一个好主意,通常将其封装在一个光秃秃的支撑部分中,以限定局部变量的范围,并允许编辑器折叠该部分。我知道有一些经验法则规定函数的篇幅不能超过一到两页,但我现在特别不同意这一点–如果很多操作都是按顺序进行的,那么它们的代码就应该按顺序排列。

在条件语句或循环语句中包含多页代码确实有可读性和意识上的缺陷,因此将这些代码放在一个单独的函数中可能仍然是合理的,但在某些情况下,仍然可以将代码移到另一个地方,使其执行不带条件性,或者在任何时候都执行这些代码,并用一个很小的条件块以某种方式抑制执行结果。执行和抑制方式通常需要更多的绝对时间,但它减少了帧时间的变化,并消除了一类错误。

内联代码很快就会与模块化和 OOP 保护发生冲突,因此必须运用良好的判断力。模块化的全部意义在于隐藏细节,而我则主张提高对细节的认识。我们需要权衡一些实际因素,比如增加源文件的多重检出,以及在主预编头中包含更多本地数据,从而被迫进行更多的全面重建。目前,我倾向于使用重量级对象作为组合代码的合理突破点,并尝试减少中等大小辅助对象的使用,同时尽可能使任何非常轻量级的对象具有纯粹的功能(如果它们必须存在的话)。

总而言之

如果一个函数只在一个地方被调用,请考虑将其内联。

如果一个函数在多个地方被调用,那么看看是否有可能将工作安排在一个地方完成,也许可以使用标志,并将其内联。

如果一个函数有多个版本,可以考虑制作一个具有更多参数(可能是默认参数)的函数。

如果工作接近于纯粹的功能性,对全局状态的引用很少,则尽量使其完全功能化。

当函数确实必须在多个地方使用时,尽量在参数和函数上使用 const。

尽量减少控制流的复杂性和 “ifs 下的区域”,优先考虑一致的执行路径和时间,而不是 “最优化地 “避免不必要的工作。

讨论?

John Carmack

本文文字及图片出自 John Carmack on Inlined Code

余下全文(1/3)
分享这篇文章:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注