我们游戏 UI 基于 RmlUI 的 fork,做了大量的改造。它实际上类似目前的 web 前端技术,使用 CSS 来表示 UI 的布局。所以,我们做的底层工作也都是围绕如何高效实现一套基于 CSS 的 UI 引擎来做的。
一年多前,我写过一篇 blog 介绍了一些优化的工作。
最近,在游戏开发的使用中,我们又发现了一些性能热点,最近在着手优化。这一篇 blog 记录一下其中的一个优化点。
按目前引擎的抽象,每个 style 其实是一组 attrib 的列表。而单个 attrib 则是一个 k/v 对。k 虽然使用上是一个字符串,但实际上会被转换为 [0,127] 的数字 id 。也就是说,引擎只支持一百来个不同的 key 。就目前 RmlUI 定义的 css 规范来说,够用了。
而 value ,也是一个字符串。但对于不同的 key 有不同的结构。对 stylecache 模块来说,它并不关心其结构,全部视为字符串。这个字符串根据不同的 key ,可能是布尔量、数字、字符串、也可以是一组数字、甚至是一个字典这类复杂结构。
RmlUI 是用 C++ 编写的,我们把 style 从字符串取出时,转换为一个 C++ 可以方便访问的类。过去,这是通过序列化和反序列化进行的。即,当我们把 attrib 从 style 中取出时,将 attrib 通过反序列化变成一个 C++ 对象。这个对象可以方便的被 C++ 代码使用。
目前,这个反序列化过程发现有潜在的性能问题,值得优化一下。大致有两个方向:
其一,直接把 C++ 对象映射为连续内存,然后将内存视为字符串被 stylecache 管理起来。这样,就省去了反序列化的过程,也无需在每次访问 attrib 的数据时构造新的 C++ 对象(这个对象可以视为数据块的访问器)。
这一步我们已经差不多做好了,的确提升了不少性能。
但是复杂结构,比如字典对象,就很难被映射到连续内存块中。这是因为,复杂数据结构需要额外的索引信息。这些信息在 C++ 语境下,很可能是一些指针。指针是很难被复制和移动的,它难以被视为字符串。
所以,我考虑了第二个方向:不拒绝额外的访问器对象,同时对访问器做缓存。
如果我们把一个复杂的数据结构看成有两种形态,一是一个字符串;二是一个 C++ 对象;两者的信息是完全等价的,当它们都是不变量时,后者就是前者的访问器。
如果 C++ 对象可以通过字符串调用反序列化接口创建出来,又可以调用序列化接口变成一个字符串,那么就可以在两个心态间自由转换。通过这种转换,我们可以简化信息本身的生命期管理。
字符串的优势是可以自由的制作副本,计算 hash ,去重,比较等等;劣势是难以访问内部的数据子结构。
所以,我们对外的接口提到 attrib 时均可以 C++ 对象的形式提供数据,这样可以方便用户数据其内部数据结构。但,当我们需要在内部储存一个 attrib ,则可以以序列化后的字符串形式保存下来,同时 cache 一个对应的 C++ 访问器对象的内部 handle。这个 handle 可以较小,例如,当我们使用一个 16bit handle 时,内部其实最多 cache 64K 个访问器。因为访问器和字符串数据可以自由转换,当 cache 失效时,随时都能重建。
注意,这里没有提到访问器对象的生命期管理。实际上,我们需要调用者提供一个销毁方法,这样才能在 cache 满后销毁掉不用的访问器。但用户完全可以额外给访问器增加引用计数的管理。它的好处是,当通过接口取得一个 attrib 的访问器后,可以通过增加引用来持有它,下次把这个对象再重新传回 stylecache 模块(同时减一次引用)。持有和复用特定 attrib 的成本就是 O(1) 的了。
即:如果从外部传入一个新的 attrib ,stylecache 模块先把它序列化为字符串,持有这个字符串,这样就无需和外部商定其生命期如何管理(信息被复制了一次)。
如果从 stylecache 获取一个 attrib ,得到的是一个生命期仅延续到下一次 api 调用前的访问器,用户不需要关心它的生命期。但接下来如何立刻将访问器指针传回 stylecache 模块,则有可能在内部 cache 中找到,这样就可以跳过序列化流程。零拷贝。
大致是这样的,用户先定义三个接口:
typedef void * accessor_t; // 访问器,可以是一个 C++ 对象 accessor_t (*create)(const char *, size_t); // 从字符串构造访问器 void (*release)(accessor_t); // 销毁访问器 size_t (*serialize)(accessor_t, char buf[], size_t buf_sz); // 将访问器序列化为字符串
style 层面的接口需要交换 attrib 数据时,都采用访问器类型。所有的接口都约定为:对于输入参数,调用者自己负责输入访问器的生命期;对于输出参数,返回的访问器对象的生命期至少可以维持到下一个 api 调用。这样即没有增加生命期管理的成本,又可以额外提供一个 C++ 访问器对象供用户使用。
我写这篇 blog 是因为觉得以上方案有一定的通用性,值得记录一下:
在以往的 C++ 方案中,如果你想将一个 C++ 对象视为一个基础类型,常见的方案是给它定义一大堆接口:增加引用、减少引用、复制、计算 hash 、对象比较……
这样,这个对象才能被传递、复制、储存在容器中,以及作为字典的 key ,做类似字符串 intern 这样的去重(即相同的值在系统中只保留一份)。
我觉得传统方案过于复杂,需要额外编写大量的代码,且在生命期管理上很容易犯错。而这篇提到的方法,只需要为对象实现序列化、反序列化以及销毁三个接口就够了。而且减少了生命期管理的复杂度。