作者:benderzhao
方案常见的内存优化方法有很多,针对不同的场景有不同的解决方案,下面按照由简到繁的顺序一一道来。
字段裁剪显而易见,把没用的字段干掉,就可以省内存。根据前文的内存计算公式,哪怕只存了一个bool值,占用也是16字节。因此,首先考虑是去掉一些完全没用的字段,其次是去掉一些默认值的字段。
比如游戏里常见的物品,有id、数量、各种属性等。如果出于方便或者可读性,亦或者C++良好的编码习惯,为每个字段都设置一个初始值,那么物品结构就大概长这样:
local item = { id = 123123, count = 1, property1 = 0, property2 = 0, property3 = "", property4 = false,}这种写法property1到property4的默认值占用了大量的内存,单个item从2个Key-Value变为了6个,内存也膨胀了3倍。
比较节省内存的做法是无默认值,在使用时or下即可:
local property1 = item.property1 or 0当然,如果使用的地方特别多,比如有上万处地方直接使用了item.property1,这种改动还是太大。这个后面会讲到还有别的方法。
结构调整如果不熟悉Lua的实现,雀食会设计出一些常见于C++,但不友好于Lua内存的结构。
还是用物品举例,假设一个玩家有1000个物品,每个物品有一些属性。比较符合直觉的数据结构是这样:
local items = { [10001] = {count = 1, property1 = 1}, [10002] = {count = 2, property2 = 4}, [10003] = {count = 4, property4 = 10}, ... [11000] = {count = 3, property1 = 2},}使用一个Table来存储所有的物品,每个Key是物品id,Value是具体的属性。
这种结构浅显易懂,但是有个问题,总共有1000+个Table,而Table不同于C++的struct,它是有很大的内存开销的,这种数据结构占用的内存会出乎意料的大,在这里光Table的占用就会有几十KB。
考虑换成另一种结构:
local items = { count = { [10001] = 1, ... [11000] = 3, }, property1 = { [10001] = 1 ... [11000] = 2, } ...}这里把相同的字段放在一起,比如所有的count是一个Table,Key是物品id,Value是数量。这种结构与前面的对比,Key-Value的数量是没差别的,但是只有个位数的Table,对比前面的1000+,有几个数量级的差距。
当然,改动还是比较大的,但是如果对于这个结构的访问都收敛到物品模块内,对外只提供接口,那就还可以接受。
对于其他结构也是一样,主旨就是减少Table的使用。
提取公共表前面字段裁剪提到,如果有一些默认字段不好剔除,比如有上万次使用的地方,挨个去加判断肯定不现实,因此可以考虑提取元表来优化。
还是用物品举例,假设有1000个物品,每个物品有3个属性,绝大部分情况下都是默认值0。
local items = { [10001] = {count = 1, property1 = 0, property2 = 0, property3 = 0}, [10002] = {count = 2, property1 = 0, property2 = 0, property3 = 0}, [10003] = {count = 4, property1 = 0, property2 = 0, property3 = 0}, ... [11000] = {count = 3, property1 = 1, property2 = 0, property3 = 0},}因为每个物品结构的字段都是一样,且大部分都是相同的值为0,因此我们提取一个元表base:
local base = { property1 = 0, property2 = 0, property3 = 0}然后将原始数据里与base内容一样的字段剔除掉,变为:
local items = { [10001] = {count = 1}, [10002] = {count = 2}, [10003] = {count = 4}, ... [11000] = {count = 3, property1 = 1,}再为每个物品设置元表,get不到的字段就去base里找。set则只在自己的Table里设置。所有物品共用一张元表。
显而易见,通过共用base的默认值,很多重复的Key-Value被优化掉了,也就节省了内存。
这种方法适合于结构一致,且有大量相同值的情况。
内存压缩假如结构不一致,或者字段的值都各不相同,又该如何优化呢?例如我们已经把没用的字段剔除了,现在物品结构长这样:
local items = { [10001] = {count = 1}, [10002] = {count = 2}, [10003] = {count = 4}, ... [11000] = {count = 3, property1 = 1,}考虑前面的指导思想,就是减少Table的使用,因此我们可以考虑把Table序列化为字符串,例如变成:
local items = { [10001] = "count=1", [10002] = "count=2", [10003] = "count=4", ... [11000] = "count=3,property1=1",}这样就少了一大堆的二级的Table。当然,这种序列化格式还是比较占内存,这里只是举个例子方便理解。实际可以序列化为紧凑的二进制形式。
改为字符串后,要是想访问里面的count,怎么办?还是设置元表,在使用的时候还原回Table即可。
而既然都序列化为二进制字符串了,那干脆再调用下lz4压缩下,牺牲一点点CPU换来更高的优化效果。比如变为:
local items = { [10001] = "\01\FF", [10002] = "\01\FE", [10003] = "\01\1F", ... [11000] = "\01\3F\24",}到此为止了吗?不,还可以进一步优化,考虑每个结构其实都是长差不多的,比如都有count字段存放整数,那与其挨个结构压缩,不如聚合N个结构一起压缩,那样的压缩比更好。
假设N取10,将原来的id除10聚合,先变为临时结构:
local items = { [1000] = { [10001] = {count = 1}, [10002] = {count = 2}, ... [10009] = {count = 4}, }, [1001] = {...} ... [1100] = {...}}这样10个一组,再分别对每一组序列化后压缩,变为:
local items = { [1000] = "\01\FF\12\22", [1001] = "\01\FE\12\22", ... [1100] = "\01\3F\24\12\22",}这样可节省的内存将会更多。访问的方式也是一样,当访问到某id时,除10找到对应的组,解压缩反序列化即可。
这种优化方式对于一些冷数据的,尤为有效,因为大部分情况下都不会访问它们。
下沉C++在前面的优化方法都尝试之后,还想继续优化内存,怎么办?考虑一个问题,似乎C++很少因为定义几个字段几个Struct就内存炸了的情况,究其原因,有以下几点:
C++的Struct或者Class无额外消耗,一个空Class几乎不消耗内存。而Lua的Table本质是一个很复杂的HashTable与Vector结构,即使是个空Table也消耗了一大坨内存。C++的字段是紧密排列,一个int就是固定的4字节,无额外消耗。而Lua因为是弱类型的解释语言,除了本身的数据存储,还需要类型描述以及GC等信息,单个字段的消耗是16字节+,相比C++膨胀了数倍,虽然实际上Lua已经实现的很精巧了。那么,我们也仿照C++实现一套内存布局,是不是就可以了?
比如某个Table结构有a、b、c三个字段,都为int范围的整数,那我们在C++中开辟一块12字节的内存来存放就行了,干掉Lua中的Table,把对a、b、c的读写操作都映射到C++里的这块内存上。
如何映射呢?当然也是用元表了。也许你会说元表不也会占用空间?是会占用,所以我们要把所有类型相同的结构共用一份元表,比如有1000个Item,只有一份元表。
理论上来说,这种方式就可以达到接近C++的内存使用,从而优化Lua的内存占用,顺便还减轻了GC的压力,因为Lua中已经没有复杂的Table结构了。
将大象装进冰箱的思路有了,下面就讲下具体的几步。
结构定义首先第一步,是要先描述结构的定义,可以采用自定义的格式,比如还是物品的例子,干脆直接用Lua描述它的格式:
local Item = { [id] = {size = 4}, [count] = {size = 4}, [property1] = {size = 4}, ...}Item有3个字段,每个字段4字节。或者也可以使用json、protobuf等格式,比如protobuf:
message Item { int32 id = 1; int32 count = 2; int32 property1 = 3; ...}考虑到生态,使用protobuf来描述最好。各种工具齐全,且大部分人都了解这个语法,无需再重新学习。
内存布局有了protobuf的定义后,接下来就是在内存里把这些字段排列开来,也许你突然想到,既然都用了protobuf,那直接用它的反射库不就好了?
protobuf反射库protobuf的反射库做的事情与我们要做的差不多,解析proto文件,生成一套描述信息Reflection,然后就可以根据Reflection初始化一块内存Message,并动态的读写其中的字段。
比如典型的接口:
最终的实现,也是将内存地址强转成类型指针,直接copy进内存,与我们的思路可以说一模一样。
那看起来底层实现直接用protobuf就好了?然而,protobuf的反射库除了太重,还有个最大的问题,是没法支持热更新。
反射需求Lua天生就支持热更新,因此,在将Lua内存下沉到C++时,也必须考虑这个问题。考虑之前的物品的Lua结构:
local Item = { id = 123123, count = 1, property1 = 0}对应的protobuf定义:
message Item { int32 id = 1; int32 count = 2; int32 property1 = 3;}最开始id写在了分配的C++的内存的偏移0,count写在了偏移4,以此类推。
当我们业务需要,假设需要增加一个字段time,出于可读性考虑,我们加在了中间位置。Lua结构变为:
local Item = { id = 123123, count = 1, time = 1212123123, property1 = 0}对应的新的protobuf定义:
message Item { int32 id = 1; int32 count = 2; int32 time = 3; int32 property1 = 4;}这时候,再使用新的protobuf的偏移,去读写我们之前分配好的内存,会明显错位了,比如现在property1的偏移是12,但是在旧的内存布局里,偏移是8。
怎么办?也许你已经想到了,首先protobuf的定义不能乱来,应该兼容旧版本,比如新增字段后,新的定义应该为:
message Item { int32 id = 1; int32 count = 2; int32 time = 4; // 插在中间可以,tag必须兼容 int32 property1 = 3;}其次,在内存中永久维护一份message对应的layout,当加载新的protobuf定义后,根据tag修补layout的结构。
在这个例子中,旧的layout是(id, 0), (count, 4), (property1, 8),后面的数字是字段开始的偏移。
加载新定义之后,新的tag只会往后补充,layout变为(id, 0), (count, 4), (property1, 8), (time, 12)。
这样,使用新的layout访问旧内存布局,是兼容没问题的。
也许你会问,要是删除字段怎么办?岂不是会留有一个空洞?比如某天property1废弃了,那layout变为了(id, 0), (count, 4), (废弃, 8), (time, 12),有几种办法:新增的同类型字段可复用这个tag;等待重启;当看不见。
另外又因为热更并不频繁,所以这部分兼容的代码,可以做在Lua里,实现更简单,也不会造成性能的困扰。
小结所以考虑热更新需求和代码复杂度,我们并不直接使用protobuf的反射库,改为自己实现一套类似的内存布局管理。
同时protobuf的内存生命周期管理也不是我们期望的,这个下面会讲到。
内存管理熟悉Lua的人都知道,Lua把所有的短字符串都放到一个HashMap存起来,这样内存里只会存一份字符串的拷贝。比如:
local a = "test"local b = "test"a与b都指向了同样的字符串地址,节省了内存,而且这样判断a与b是否相等时,可以直接使用指针比较,而无需调用strcmp,也提高了性能。
而什么时候从hashmap删掉呢?自然是没有使用了,Lua通过GC来删掉。比如:
local a = "test"a = nil其他需要GC的类型比如Table、UserData也是同理。
那既然我们把Lua内存下沉到C++,Lua复杂的结构如何保证既不会内存泄露,又不会野指针呢?要知道,Lua的Table是可以随便相互各种引用的。
是不是也要复刻这套GC呢?其实大可不必,我们使用最简单的引用计数即可。
引用计数引用计数众所周知,引用+1,取消引用-1,为0表示没人引用了,就释放掉。比如std::shared_ptr就是干这个的。
但是引用计数有个问题是循环引用,比如:
local a = {}local b = {}a.b = bb.a = a这样a与b相互引用,就没机会减到0了。Lua为了避免这个问题,采用三色标记法来一波一波的回收。
那为什么Lua内存下沉到C++中,反倒可以使用引用计数,没有循环引用的问题呢?
原因很简单,我们使用了protobuf来描述结构。直接不让这么定义就行了,比如这种是不允许的:
message A { B b = 1;}message B { A a = 1;}实际上也几乎不会有必须这样写的业务需求。大部分是分层的定义:
message A { Base b = 1;}message B { Base a = 1;}message Base { ...}当去掉了循环的边,所有的结构都变成了一颗树,只要使用引用计数管理即可,简单又高效。
又因为Lua都是单线程的,所以使用一个单线程版本的shared_ptr即可,性能更佳。
复杂结构当然,我们的结构不可能总全是int,必须要支持嵌套、repeated、map的定义,比如:
message Player { string name = 1; int32 score = 2; bool is_vip = 3; float experience = 4; repeated Item items = 5; Pet pet = 6; map<string, Friend> friends = 7;}在前面我们知道各个字段是按照偏移来存放在内存里的,那这里的name、items、pet、friends成员应该如何存?毕竟几乎都是编译期未知的大小。
那么很简单,只存放指针即可,固定8字节。指针索引到具体的实例上去,对应的就是String、Array、Map。
字符串池子如前所述,我们也仿照Lua,把所有C++里的字符串用一个hashmap管理起来。
虽然实际上不需要在C++中用到字符串的比对,因为访问a.b时,Lua层已经把b映射到某个偏移了,C++也就无需在用b再做字符串比较查找字段。
这种设计的主要目的还是减少内存的占用,毕竟程序中还是存在大量的相同字符串的。
另外String虽然也使用了引用计数管理,但是在释放时,需要从池子中删掉,并且池子是不能增加计数的,不然永远到不了0,这里要用到weakptr了。
Array实现Array对应的就是Lua中的数组,比如:
local array = {"a", "b", "c"}也是protobuf里的repeated,对应的定义:
repeated string array = 1;虽然repeated看起来和普通的message区别很大,但是在C++里实现是差不多的。
message是在访问a.b时,把b映射到某个偏移读写。
repeated则是在访问a[1]时,把1也映射到某个偏移,逻辑更简单了,乘以单个的元素大小即可。
不过这里需要注意的是,在设置元素时,要确保是符合protobuf的定义的,毕竟Lua是可以随便写,如果上面的例子:
array[1] = 2把整数设置到了字符串数组中,C++层要能够检测并抛出异常出来。
Map实现Map在Lua里虽然也是Table,但是是用来存放未知的KV的,典型的比如一个好友的集合:
local friends = { ["123123"] = {name = "张三"}, ["123124"] = {name = "李四"},}对应到protobuf的定义:
map<string, Friend> friends = 1;这个Map就不能靠偏移来实现了,是放KV的也就只能用HashMap结构。
而既然要节省内存,那自然要用最精确的字段来存储了,比如:
map<int32, int32> map1 = 1;map<int64, int32> map2 = 2;map<int32, int64> map1 = 3;map<int64, int64> map1 = 4;这有4种尺寸的KV排列组合,如果我们想简单实现,定义个union,比如:
union Data { int32_t i32; int32_t i64; ...};然后用一个C++的std::unordered_map<Data, Data>来存放所有类型的KV。这种方式固然简单,但是内存明显并没有做到最优。
怎么办呢?似乎也没什么好方法,罗列下所有的可能性,然后定一个指针union,像这样:
union Map { std::unordered_map<int32_t, int32_t> * i32_32; std::unordered_map<int32_t, int64_t> * i32_64; ...};然后根据protobuf的定义,初始化对应的类型,并按照那个类型来读写。
看起来很蠢,但是却是最简单有效的做法。也许你会说,有字节对齐,i32_32与i32_64占用的实际内存会不会其实是一样的?
是有这个可能,但是我们没法假定std::unordered_map内部实现的结构定义,而且哪怕是一样的,除了代码多一点,CPU和内存均无损失。
而代码方面,可以借助template以及C++17的if constexpr,最大程度的减少冗余。
测试结果废了好大劲,终于正确无误的把Lua内存下沉到C++中,现在是检验优化成果的时候了。
分成了普通结构、Array、Map三种场景。
普通结构就是最普通简单的结构体,定义如下:
message SimpleStruct { int32 a = 1; int32 b = 2; int32 c = 3; ... int32 y = 25; int32 z = 26;}初始化了5000份数据,实测下沉C++后,内存为Lua的1/3左右。
Array也采用了最简单的数组类型,定义如下:
repeated int32 labels = 6;插入1000w条数据后,C++内存为Lua的1/4左右。
Map最后是Map类型,定义为:
map<int32, int32> params = 9;插入1000w条数据后,C++的内存居然和Lua差不多,甚至还稍大些。这就尴尬了。
Map优化分析代码,Map下沉之后,内存不减反增。而我们只是封装了一层std::unordered_map,所以问题必然出现在它与Lua的实现的不同上面。
根据资料,std::unordered_map采用的是拉链法,除了KV本身的存储外,还有桶、链表等消耗,那是会多耗费些内存。
再观察Lua的实现,它其实用的是Coalesced hashing,这种实现虽然Set稍显麻烦,但是Get却很简单,同时因为是一整块内存,内存利用率更高。
既然std::unordered_map比不过它,那么我们自己实现一个类似的coalesced_map即可,同样的算法,不过做了些变种,比如支持Delete。
将coalesced_map与std::unordered_map比较下性能,Set会稍慢点,Get是一致的,符合预期。实际上程序中也是读多写少,Lua这种策略没错。
优化后测试最后,重新跑一遍测试,C++内存为Lua的1/2左右。不得不说,Lua实现真的很精巧。
总体来说,下沉方案效果还可以,但是还有继续扣内存的空间。
总结
Lua确实是嵌入式脚本语言一哥,开发效率高,运行效率也不差。
不过如果一不注意,就容易陷入CPU与内存的陷阱中,大量使用时还是要多加注意。