« July 2021 | Main | September 2021 »

August 24, 2021

内存对齐问题和编译器优化

昨天在公司内部的“不作不死”(程序员)群里,有同学贴了个知乎上的帖子 。表示这个问题居然关闭 gcc 的 builtin-memset 就解决了,感觉很玄学。

我说,这个感觉才是对的。关于文章中表达的 “添加编译选项-no-builtin-memset后,一切就正常了。然后大家都如释重负,不但解决了问题,又学到的新知识。” ,我认为这“如释重负”对于程序员来说才是种不正常的感觉,正常应该是“更加困扰”了才对呀。

到底是怎么回事,文章线索不全,无法判断。不过我直觉上感觉和我前几个月在我们这个“不作不死”群里讨论过的另一个问题非常相似。

我怀疑,问题是内存不对齐造成的。

当时有同事发现了我给 skynet 写的序列化库在 arm 32 下有机会引起崩溃 。由于 skynet 从一开始就有不少基于嵌入式平台的应用,所以实现时特别有注意不同平台的问题。我一度认为使用 memcpy ,而不去直接对地址取值就能回避内存对齐问题。

经过那次后,我仔细阅读了 C 标准,查了许多资料,重新理解了一下 C 语言的内存对齐。在 C1x 标准中,还增加了 stdalign.h 可以用 alignas 去精细控制对齐。

现代 C 编译器会对 C 代码基于标准允许的推断做相当深入的优化。很多看似函数调用的地方都很有可能根据上下文优化为更简单的目标码。比较典型的就是针对 memset memcpy memmove 的优化。

我们知道,当内存非对齐时,即使硬件不报错,也会有很大的性能惩罚,而且一旦引起问题是非常难查的。所以我们要尽量回避对非对齐的内存访问。

https://www.kernel.org/doc/Documentation/unaligned-memory-access.txt

Some architectures are able to perform unaligned memory accesses transparently, but there is usually a significant performance cost.

Some architectures raise processor exceptions when unaligned accesses happen. The exception handler is able to correct the unaligned access, at significant cost to performance.

Some architectures raise processor exceptions when unaligned accesses happen, but the exceptions do not contain enough information for the unaligned access to be corrected.

Some architectures are not capable of unaligned memory access, but will silently perform a different memory access to the one that was requested, resulting in a subtle code bug that is hard to detect!

而如果 memcpy 和 memset 这些的标准库函数,如果以函数的形式提供功能,就必须在运行时再检查所操作的内存地址是否对齐,根据是否对齐实现不同的版本。

这里编译器优化的介入,可以根据上下文尽可能的推断出更多信息:传入地址是否对齐?操作长度是否是常量?根据这些信息则可以减少很多运行时不必要的分支判断。

语言本身的规范可以方便编译器做这些判断。例如,虽然 memcpy 本身的定义是 void * ,但如果上下文中这个指针是从 int64 指针转换而来,就能推断出地址一定是 64bit 对齐的。这是符合语言规范的。所以你不可以随意的将两个不同对齐标准的指针相互强制转换。


话说回来,如果真的遇到了编译器优化导致 bug 怎么办?我认为正确的姿势是尽力搞清楚问题的本源。起码应该让编译器输出汇编对比阅读一下。编译器输出了不是自己预期的结果的话,首先还是要怀疑自己的代码是否不够标准,导致编译器错误理解了你的意图。如果真的是编译器优化问题,应该把 issue 投递到编译器的开发社区,协助编译器的开发团队解决问题。而同时选择绕道只应该是个不应该自己软件发布时间的权宜之计。

btw, 最近一年在 lua 5.4 发布之前,lua 社区就发现过 gcc 的不正确优化导致的 bug ,马上就有人把问题同步到 gcc 社区,进而跟进解决了问题。

现代软件开发,上下游其实是一体的。只有每个环节的开发人员都不放过潜在的问题,整体才可以做的更好。

August 20, 2021

预制件和对象集的管理

最近在用自研引擎开发项目时,发现了一些问题。在解决问题的同时,也逐步对之前的设计做了一些调整。一开始只是一些小修复,慢慢的发展成了大规模的代码重构。

最开始源于我重新设计了 ECS 框架。在新设计下,可以用 C/Lua 混合组织数据。为未来优化热点做好准备。我们借此机会重新思考了 ECS 框架下应该如何组织代码的问题。发现一个关键点就是,要尽量去掉系统中对象之间的引用关系。每类对象最好是成组分批的处理业务,每个模块都只做最简单的事情。但同一件事情尽量处理更多的数据、对象。

比如对象的构建和销毁,通常会随着对象构成的复杂度上升而演变为一件越来越复杂的事务。我们之间在设计 ECS 框架时,就设计了大量的机制来正确执行 Entity 的构建流程。一个 Entity 可以由若干 Component 类型动态组合,并非每个 Component 都能独立初始化。有时它们是相关联的。例如 A B C 组合成一个 Entity ,初始化 A 和 B 后,才能根据 A B 的结果初始化 C 。

加入预制件的概念后,这个过程更为复杂。预制件其实是一个包含很多对象的组。从预制件还原设计好的对象,不光是要逐个初始化对象,还需要重建设计时构建的这些对象之间的关联。例如,整个游戏场景就可以是单个预制件。加载场景就是把预设的构成整个场景的所有对象在运行时重建的过程。

在传统的 OOP 框架中,我们往往顺次构造每个对象,然后再尝试还原它们之间的关系(比如场景树中的层次父子关系);但在 ECS 框架中,我们管理的其实是若干 Components 的集合。按 Component 分类初始化可能更为简单。关联本身很可能局限于同类 Component 之间(例如场景节点),而不涉及 Entity 。

我们在重构之后,取消了之前的那套基于 Entity 为单位的初始化(以及销毁)机制。改为模块自己把构造流程的 System 添加到合适的 Pipeline 中。所以,在新机制下,Entity 看起来是批量同时构建出来的。也就是说,如果一个 Entity 的构造如果分 A B C 三个步骤的话,其实框架是在步骤 A 处理了所有的 Entity 后,再批量做步骤 B 以及步骤 C 。

这样做的代价是,Entity 的构建全部变成了异步过程。我们不再可以直接用代码做这样的操作:

  1. E = CreateEntity()
  2. S = Getstate(E)
  3. CreateEntity(S)

先创建一个 Entity E ,然后取得 E 的某些状态 S ,再用 S 继续创建下一个 Entity 。

无法同步创建一个 Entity 到底会给引擎使用者带来多少麻烦?这个问题我们认真讨论过好几次。首先,在有了预制件后,其实我们很少需要直接从空创建一个 Entity ,而是用 prefab:instance() 从预制件数据中创建出来。这个问题就转换为,预制件的 instance (实例化)方法是否应该是一个异步过程。

目前,我们的答案是,实例化过程应该是异步的。但可以提供有限的同步能力。

对于游戏引擎来说。当我们想把一组预先组织好的数据在运行时重新组织起来。这个过程完全同步完成其实本身就不现实。因为它涉及了大量的资源 IO 和解析工作。如果想等所有配套资源都就绪再返回,让后面的代码可以立即 100% 读写这组对象,那么必然会在单个调用中造成长时间的等待。

可即使在加载场景阶段,我们依然希望这个过程可以分部完成,同时期有机会去画个进度条或是做些更复杂的事情。如果是同步接口,恐怕就必须依赖多线程这样更复杂容易出错的机制了。

当然,我们也可以把资源加载剥离出来,提供消息机制,通知调用者何时真的完成。而资源之外的部分则同步创建。但其实我们很难界定什么属于资源数据。如果纹理模型这些算资源数据,那预制件本身算不算?

我们现在的做法是,预制件的实例化同步返回的只有一个场景上的虚拟空节点。这个空节点的结构简单、是完全确定的。它可以直接在 World 中同步创建出来,只有 SRT 等这些简单的属性。而预制件中需要重建的场景树全部都是异步构建的。它们的构建过程分布在框架的 pipeline 中,最快也要等待下一个渲染帧才能真正构建好。

对外的接口是以回调函数的形式控制从预制件中创建出来的对象集的。也就是在创建对象的调用时,就应该传入 ready (创建完毕)update (每帧更新)message(响应消息)这些函数。

从外面看,这组对象是隐藏在根上的虚拟节点之后的。我们没有提供 API 绕过根节点枚举出内部的具体对象。对对象的操作都必须在回调函数中完成。所以从外部访问具体对象都必须通过“消息”来完成,否则就只能控制根节点。而根节点,也可以在创建完毕后同步进行控制,不必等实际对象组都准备好)。