内存优化!Lua进程内存优化方案总结

技术最新也最全 2024-08-19 22:52:32

作者: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与内存的陷阱中,大量使用时还是要多加注意。

0 阅读:25

技术最新也最全

简介:感谢大家的关注