« February 2019 | Main | April 2019 »

March 19, 2019

数学运算模块的改进

我为我们正在研发的 3d 引擎设计了一个非常规的数学运算模块

它和很多为 Lua 封装的数学运算模块不同,并没有用 userdata 或 table 来实现矩阵向量对象,而是采用了 64bit 的整数 id 。其生命期管理是模拟了一个堆栈,我称之为数学栈,和程序运行时存放临时变量的堆栈以示区分。数学栈上存在过的矩阵向量对象的生命期都会维持一个固定周期,通常是一个渲染帧。每帧主动调用重置指令刷新。这个设计减少了在 lua 层面使用矩阵向量对象的心智负担。你不必担心运算产生的临时结果会增加过多 gc 的负担。构造新的矩阵向量对象的成本也非常的小。

在计算方面,我们将运算指令也压入数学栈,而不是单独设计计算函数。例如 A * B 的操作,写作 B A "*" ,字符串 "*" 作为乘法操作指令压栈。如果是 A * B * C 则写作 C B A "**" ,两次乘法可以拼接在一个字符串中。如果你还想对 A * B * C 的结果取逆 (Inversed),就可以写 C B A "**i" ,i 表示取逆的操作。

这么设计纯粹是性能上的需求。因为 Lua 调用 C 函数的开销在做小操作时无法完全忽略。为了减少密集运算中,多次调用 C 函数的调用开销,这种逆波兰表示法可以把多次计算操作合并到单次函数调用中。

当然这么设计会增加使用者的学习成本。我也想过做一些改进 但到目前为止还没有多余的精力去实施。好在克服初期的使用不适后,用起来也能接受。


之前一段时间我的工作重心在其它方面,数学运算库是同事使用和维护较多。在使用过程中持续为其增加所需的功能。最大的改变是去掉了最早我实现的一个简陋的数学运算模块,而换成了更加专业的 glm 。对外框架改动不多。

最近,我自己使用了几周后,发现了一些小问题,所以又持续做了一些改进。

首先是需要的运算操作越来越多,只用一个字符表示一种操作,固然性能上很好,但对使用者有很大的记忆负担。一开始我们想用驼峰命名法去命名新的操作指令,但之后换了另一个方法:

我设计了一种新的运算子,直接用 lua 函数表示,然后把这个运算子压入数学栈。这样 B A "*" 就可以写作 B A mul ,其中 mul 是一个 Lua 函数。但是,如果直接这么实现,就有回到了原点,我们想减少的函数调用又回来了。只不过从 mul(A , B) 转移到了数学栈内部处理时。

但我在实现时使用了一点技巧。我们约定,运算子只可以是一个 light C function ,即不含 upvalue 的用 C 实现的函数。它只能有一个参数,数学栈对象。符合这个约定的函数,我称之为 FASTMATH 运算子。

在模块内部,会绕过 lua_call ,直接对运算子进行调用,并不会产生新的 Lua 栈帧。这样,运算子的运算的额外成本就被减少到几乎为零,从一次 Lua 到 C 的函数调用减少为一次普通的 C 函数调用。

这里运用的一点小技巧是,这个运算子真的可以在 Lua 层面作为普通函数调用。这个特性可以方便调试。

为了实现这个特性,我们的 FASTMATH 函数其实是定义成这样的:

int func (lua_State *L, struct lastack *LS, struct ref_stack *RS);

因为 C 调用规则是调用者负责参数退栈,这个函数原型可以被视为和 lua 约定的 C 函数相同:

int func (lua_State *L);

在我们内部直接调用 FASTMATH 函数时,我们直接把数学栈需要的额外对象传递过去;而如果当作普通 lua 函数调用,则从 L 中获取(性能略低)。我们用 L 是否为 NULL 来区分两种调用方式,正常的 Lua 调用 L 一定不为 NULL ;而我们在内部快速调用时,故意把第一个参数传 NULL ,表示后两个参数是有效的。


第 2 个改进关乎模块间的交互。

我们的上层框架是用 Lua 实现,但底层许多内聚性很高,性能相关的模块是用 C/C++ 实现成一个可供 Lua 调用的模块。例如渲染 API 是对 bgfx 的封装;物理系统是对 Bullet 的封装;动画模块是对 Ozz 的封装。

但是,这样的模块一定需要输入或输出矩阵向量对象。这是模块间交互必然面临的问题:如何在模块间交换矩阵向量对象?

常规的方法是让物理/动画模块都按数学模块的标准使用相同的矩阵向量对象类型。但我认为这样就增加了模块间的依赖性,增加了系统间的耦合度。我认为解耦是更高的设计原则,用 C/C++ 实现高类聚的 Lua 模块,把魔鬼(脏活)装进瓶子里,这样才可以跟放心的用 Lua 做设计。所以在一开始,我就决定让这些需要和外部交换矩阵向量对象的接口全部把向量表达为 lightuserdata ,也就是普通的 C 指针。

以渲染模块为例,如果它需要输入一个矩阵,那么接口上就要求输入一个 lightuserdata ,从 C 层面看就是一个 float * ,指向 16 个浮点数;如果需要输入一个 vector4 , 也是一个 float * ,指向 4 个浮点数。渲染模块完全不关心使用者是用怎样的数学运算模块产生出这样的指针的。

我们同时也为数学运算库设计了一个运算子,可以把数学栈上的矩阵向量对象以指针的形式弹出。在运算过程中,一律使用 64bit id 来表示一个矩阵向量对象,在需要和渲染 API 交互时则转换为 lightuserdata 。


但在实现使用中,我发现这给使用者造成了很大的困扰。你需要关心什么时用 id ,什么时候用指针。而且转换为指针后,就丢失了原本 id 承载的类型信息,你不再能知道这个对象到底是矩阵,还是向量,或是一个四元数?一旦从 id 转换为指针后,也就无法再转回原本的 id 。

指针作为模块间的数据交换类型固然解耦了不同的 Lua 模块,但失去了类型检查,也增加了出错的机率。

解决这个问题的方法是增加一个中间层,把不同的模块桥接起来。最容易想到的方法是用 table 来做这个桥接,避免直接使用无类型的指针。但 table 的额外开销会使得之前在减少运算开销上所作的努力都打了水漂。我们设计这么一个非常规的数学运算模块,全部都是因为常规的(用 table 来表达矩阵向量对象)方法开销不可接受。

最终我还是选择了用一点点奇技淫巧来把桥接中间层的代价减到最小。

我们只针对 light C function 来做桥接。这些 C/C++ 开发的模块都符合这一点。这个桥接模块是数学库的一部分,它并不需要了解具体需要和怎样的库做桥接,而只用知道我们要转换怎样的函数:这个需要桥接的函数的哪些参数需要接收矩阵或向量,桥接层生成一个代理函数,把数学库的 id 转换为对应的指针,再去调用它。因为最终的函数是 light C function ,我们就可以直接调用,而不需要通过 lua_call 产生额外的 lua 栈帧。

然后,需要接收矩阵向量对象的库自己再调用桥接库,为自身产生代理函数,覆盖原本的版本。以 bgfx 的封装库为例,就需要替换掉 set_uniformset_transform 等等。这样,bgfx 的封装库就不需要依赖特定的数学库实现就可以使用。我大概花了一周时间,成功把独立维护的 bgfx lua binding 中 examples 用到的数学库,从之前临时开发的一个简单数学库迁移到目前引擎开发中维护的数学库上。如果你愿意,也可以把它桥接到别的你需要的数学库实现,这真正做到了 bgfx 的 lua binding 和数学库解耦。


下一个问题是,像动画库这样的模块,除了输入参数中要矩阵向量对象外,它还需要输出一些矩阵。之前我们在实现的时候,需要输出时就采用 userdata 来解决矩阵的生命期管理问题。动画库的 lua binding 为构建一个临时的 userdata 用于输出计算结果。使用者再将这个结果压入数学栈,转换为 id 。

这次我们的桥接层也要解决这类问题。

这次采取的方案是,模块统一采用传入指针来接收输出。即,由调用者分配好接收输出数据的内存,如果想接收一个矩阵,就分配 16 个浮点数的空间,把地址作为参数传入。

桥接层会统一对输出做一个转换,使用者不需要再传入指针接收输出。再代理函数中,会在堆栈上预留一块空间,调用完真正的函数后,把堆栈上接收到的矩阵向量对象直接压入数学栈。


如果你对我们实现的数学库有兴趣,可以看看我从还在开发的 3d 引擎项目中摘取出来的仓库。使用的范例可以在 bgfx 的 lua binding 中找到。需要注意的是,bgfx 的 examples 是从 C++ 版本逐行翻译到 lua 的,它并不是对数学库的最佳使用形式。如果从头实现功能的话,这个数学库会有更好的使用模式。

March 14, 2019

Lua 虚拟机间函数原型共享的改进

在我们的服务器框架 skynet 中,往往会在同一进程启动非常多的 lua 虚拟机,而大多数几乎运行着相同的代码(为不同的用户服务)。

因为 Lua 的代码也是数据,和 Erlang 这种天生设计成多服务的语言不通,Lua 并没有为多虚拟机同时运行做内存上的优化。所以,我在 5 年前给 Lua 制作了一个 patch ,可以把不同虚拟机间的函数原型数据提取出来共享一份,节省了不少内存。

不过,这个方案只能功能函数原型中的字节码以及调试信息,无法共享函数原型用到的常量表。这是因为,字符串常量是一个对象,尤其是短字符串,Lua 在虚拟机内部做了 interning 已加快字符串比较速度,使得它们很难在虚拟机间共享。

导致 Lua 和 Erlang 在处理多虚拟机共享代码这个问题上不同的本质原因,我认为是 Erlang 在语言层面区分了 atom 和 string 。atom 是虚拟机间共享的,维护在一个大的 atom 池中,只增不减。而 Erlang 的代码本身引用都是 atom ,这样就很方便做共享。而 Lua 不分 atom 和 string ,string 是无法承受只增不减策略带来的内存开销的。

为了解决构成 Lua 函数中大量短字符串常量的问题,我后来又给了一个方案 。它模拟了一个类似 Erlang atom 管理的机制,把所有虚拟机间共享的短字符串维护在同一张 hash 表内。为了解决内存无限增长问题,我给这个 hash 表设定了一个阙值上线,让它只在进程启动时期工作。


昨天,同事在分析我们最近的线上服务器时发现,即使使用了代码共享机制,一个刚加载完代码的虚拟机,还是占用了接近 6M 内存。其中,有大约 1.6M 内存是函数原型使用的常量表。如果我们能节省下这块内存,整体可以有很大的收益。(因为我们大约会在一个进程内启动 2000~3000 个这样的虚拟机)

我仔细考虑一下,其实基于过去做的工作,还能做如下的改进:

在共享函数原型的数据结构中,增加常量表项。过去这一项是放在外面的。

常量表中的长字符串其实可以直接用指针引用,而不必拷贝。

当常量表中是短字符串时,尽可能克隆这个字符串,它有可能在全局短字符串表中,这样,指针是唯一的,也可以被直接共享。

当整个常量表都可以被共享时,直接引用共享数据,而不需要额外建立复制表。

不过针对这些改进,还需要修改 gc 的标记和回收阶段的流程。如果函数原型并非共享,就要正常做标记和删除;如果是共享版本,就需要跳过它。

在修改过后,我们非常完美了减少了 1.6M 内存的使用,整个进程总内存开销减少了 4G 左右。


那么为什么不能强制把所有常量表中的短字符串都放到全局表中共享呢?因为在机器罕见的情况下,加载代码之前,虚拟机内已经构建出来了本地的短字符串,并未置入全局表,之后,克隆函数原型阶段,我们就无法使用全局表中的相同字符串。因为那会导致 lua 的 string interning 机制失效。

如果一个函数原型内包含的所有子函数都被共享,那么为何不干脆复用 Lua 的函数原型对象呢?这是因为 Lua 5.3.5 在每个函数原型内还放置了一个闭包指针,用于 Cache 最后创建出来的闭包。这个是和虚拟机相关的。不过在 Lua 5.4 的开发版中,这个 Cache 已经被取消,未来 Lua 5.4 在这方面应该可以做的更好。


有在使用 Skynet 的同学,可以去开发分支上取下代码 ,试一下这次的新改进能否减少你的项目的内存开销。