黑科技:用UE4的FName优化掉100MB的Lua内存

2021-11-04 10:51:56 浏览数 (1)

FName

FName是UE4提供的一种特殊的字符串类型。FName和FString不一样的地方是,他的对象内部并不直接存储字符串,而是把字符串存储在一个全局的NamePool之中,而FName的内部存储着字符串在NamePool中的索引。他的容量非常小,当游戏逻辑在用来传递参数,比较等操作时,只传递或比较索引,而不需要对字符串本身的内容做操作,就可以显著的提升游戏性能。如果你的游戏也用到了Lua并且清楚Lua的字符串内部细节,在看到了这样简短的FName介绍和这个唬人的标题后,相信你这是一定已经有了想法,我会在后面介绍Lua的改造细节。可以略过前面FName部分,直接跳到后面看。

FName的成员变量

FNameEntryId的结构

第一张图可以看到FName本身只有3个变量,而其中一个只在定义了宏WITH_CASE_PRESERVING_NAME的情况下有效(引擎默认是在编辑器中会开启,游戏环境中会关闭),其中FNameEntrtyId在第二张图中可以看到内部只是一个uint32,因此FName本质上的成员变量就只有3个uint32变量12字节,在不开启区分FName大小写的环境中只有8字节,相当于一个指针的大小。

其中,ComparisonIndex是当前字符串在全局NamePool的索引,而Number是字符串的数字部分。有了全局的NamePool,当创建相同的FName时,只要让他们的ComparisonIndex相同就可以共用内存,起到节省内存的目的,因为UE4内部UObject习惯用字符串 数字来存储对象的名字,将同样的字符串合并存储,而不同的数字放在单独的变量里,又能节省掉大量的内存。

当需要访问FName其中内部内容时,可以使用ToString函数来将字符串转成FString,从而获取到实际的字符串。如上图所示,这个函数的内部就是直接用Index到NamePool中获取,如果有数字后缀,就拼接上最后的"_" 数字。

可以看到FName有很多构造函数可以方便用户去创建,包括直接用已经有的Index创建,用字符串来创建等。其中有个参数FindType会填充默认值FNAME_Add。当使用Add时,内部会把传入的字符串调用Store存入NamePool中,而使用Find就只会查找,在没有的情况下不会新增,如下图所示。

这里需要注意的几个细节:

  1. FName传入的字符串,无论是宽字符还是普通的字符,会统一按照ANSICHAR来存储,因此内部内存一定是最小的版本,无需担心把宽字符存入了FName浪费内存
  2. FName默认在游戏中不区分大小写,但使用ToString时得到的字符串本身是有大小写的,这时字符串的内容是第一次存入的内容,因此要避免业务逻辑使用大小写敏感的代码。如果有多处代码同时存入不同大小写的FName时,这里一定要特别注意
  3. 字符串计算Hash时,使用的是CityHash函数,和FString的GetTypeHash用的函数不同,得到的Hash值也是不同的。另外CityHash在计算小于64字节的Buffer时速度非常快,而大于64字节时会稍微慢一些,因此尽量在FName中存短一些的字符串。
  4. FName的存储之后就不会再释放,因此不要存大量不会用到的字符串。

Lua中的字符串

lua中分为普通的值和gc对象,而字符串就是一种gc对象,如下图所示:

字符串对象在内存上保存的实际是一个字符串头 实际的字符串内容(上图的contents)。字符串头中保存了字符串的Hash,长度等信息。

普通的变量在lua内部结构如上图所示,由Value 类型组成,其中Value是一个union共用体,当不是gc对象时,Value内部就直接存值,而如果是gc对象,Value会存储对象的指针(和UE4的UObject非常像)。因为字符串本身是gc对象,所以Lua内部是通过一个字符串指针间接存储的。

真正的对象,实际是存储在Lua的global_state上一个全局字符串表里。这里可以看到和UE4的FName做法非常相似。

lua在创建字符串的时候,如果是小于40字节的字符串,就会调用上图的函数,先计算hash,并到全局的字符串表中查找,找到了就直接返回,没找到就新创建字符串,并保存在全局字符串表中。这里也可以看到连字符串插入的逻辑都和UE4的FName做法非常相似。

在短字符串做比较的时候,就直接比较字符串的指针,只要指针相等就认为字符串相同。这样的实现和FName直接比较Index做法非常相似。

看到这里,你肯定会想到,lua中的字符串就是一个FName的C语言版实现。游戏中的大量字符串,比如路径,对象名,在lua中和在NamePool中如果大量被使用到,就会在两边的字符串池中重复存储,这就造成了严重的内存浪费。lua的字符串池和UE4的NamePool,唯一不同的是lua的字符串会在没被引用时被GC销毁,且区分大小写。如果不在乎这两点区别的话,那么就完全可以使用FName来代替lua中的字符串,这样就可以让整个游戏只使用一份字符串内存(在乎大小写和GC销毁也有办法解决,就是会更麻烦一些,省下来的内存会少一些),相信很多项目,一定会加载大量的策划配置表中的字符串到内存中,最后又传入UE4被再保存一遍,如果砍掉lua的字符串存储,相信很容易就省下来大量内存(这些内存拿来多画几张贴图他不香吗?)。同时因为FName是UE4管理的对象,不需要lua参与gc,能够大幅度减少lua需要gc的对象数量,因此改造后也能显著提升lua的性能。

修改方法:

1 lstring.h,lstring.c中,修改下图的API对应实现,luaS_new创建时将FName的ComparisonIndex填入Value中直接返回,不要创建新的TString对象,将字符串当作值类型来使用,luaS_hash也可以改为CityHash

2 lstrlib.h lstrlib.c中的字符串函数,需要改为FName的对应版本,注意内部中间字符串不要创建新的FName,只在得到最终结果时再创建,否则因为NamePool很难清理,会出现大量临时FName污染NamePool。

3 全局搜LUA_VSHRSTR,通过Value获取TString的地方,根据情况修改对应实现,比如table的getkey和mainposition函数。加载字节码的lundump和保存字节码的ldump中保存字符串的地方等

最后,如果不想忽略大小写,可以打开UE4的宏,使用12字节的FName,这时因为lua的Value只能存8字节放不下,可以考虑做一个间接数组保存FName,将数组的index存到Value中。如果还需要让普通字符串参与gc,只让特殊字符串使用FName,可以在lua中,除了短字符串和长字符串外,再增加一种字符串类型,可以用特殊前缀(比如前面加一个@字符)来区分。

Unity3d理论上也可以用类似的做法,C#的string内部实现也差不多。

0 人点赞