Lua进程内存优化方案总结

2024-08-20 20:21:52 浏览数 (2)

作者:benderzhao

方案

常见的内存优化方法有很多,针对不同的场景有不同的解决方案,下面按照由简到繁的顺序一一道来。

字段裁剪

显而易见,把没用的字段干掉,就可以省内存。根据前文的内存计算公式,哪怕只存了一个bool值,占用也是16字节。因此,首先考虑是去掉一些完全没用的字段,其次是去掉一些默认值的字段。

比如游戏里常见的物品,有id、数量、各种属性等。如果出于方便或者可读性,亦或者C 良好的编码习惯,为每个字段都设置一个初始值,那么物品结构就大概长这样:

代码语言:javascript复制
local item = {
    id = 123123,
    count = 1,
    property1 = 0,
    property2 = 0,
    property3 = "",
    property4 = false,
}

这种写法property1property4的默认值占用了大量的内存,单个item从2个Key-Value变为了6个,内存也膨胀了3倍。

比较节省内存的做法是无默认值,在使用时or下即可:

代码语言:javascript复制
local property1 = item.property1 or 0

当然,如果使用的地方特别多,比如有上万处地方直接使用了item.property1,这种改动还是太大。这个后面会讲到还有别的方法。

结构调整

如果不熟悉Lua的实现,雀食会设计出一些常见于C ,但不友好于Lua内存的结构。

还是用物品举例,假设一个玩家有1000个物品,每个物品有一些属性。比较符合直觉的数据结构是这样:

代码语言:javascript复制
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。

考虑换成另一种结构:

代码语言:javascript复制
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。

代码语言:javascript复制
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:

代码语言:javascript复制
local base = {
    property1 = 0, property2 = 0, property3 = 0
}

然后将原始数据里与base内容一样的字段剔除掉,变为:

代码语言:javascript复制
local items = {
    [10001] = {count = 1},
    [10002] = {count = 2},
    [10003] = {count = 4},
    ...
    [11000] = {count = 3, property1 = 1,
}

再为每个物品设置元表,get不到的字段就去base里找。set则只在自己的Table里设置。所有物品共用一张元表。

显而易见,通过共用base的默认值,很多重复的Key-Value被优化掉了,也就节省了内存。

这种方法适合于结构一致,且有大量相同值的情况。

内存压缩

假如结构不一致,或者字段的值都各不相同,又该如何优化呢?例如我们已经把没用的字段剔除了,现在物品结构长这样:

代码语言:javascript复制
local items = {
    [10001] = {count = 1},
    [10002] = {count = 2},
    [10003] = {count = 4},
    ...
    [11000] = {count = 3, property1 = 1,
}

考虑前面的指导思想,就是减少Table的使用,因此我们可以考虑把Table序列化为字符串,例如变成:

代码语言:javascript复制
local items = {
    [10001] = "count=1",
    [10002] = "count=2",
    [10003] = "count=4",
    ...
    [11000] = "count=3,property1=1",
}

这样就少了一大堆的二级的Table。当然,这种序列化格式还是比较占内存,这里只是举个例子方便理解。实际可以序列化为紧凑的二进制形式。

改为字符串后,要是想访问里面的count,怎么办?还是设置元表,在使用的时候还原回Table即可。

而既然都序列化为二进制字符串了,那干脆再调用下lz4压缩下,牺牲一点点CPU换来更高的优化效果。比如变为:

代码语言:javascript复制
local items = {
    [10001] = "1FF",
    [10002] = "1FE",
    [10003] = "11F",
    ...
    [11000] = "13F24",
}

到此为止了吗?不,还可以进一步优化,考虑每个结构其实都是长差不多的,比如都有count字段存放整数,那与其挨个结构压缩,不如聚合N个结构一起压缩,那样的压缩比更好。

假设N取10,将原来的id除10聚合,先变为临时结构:

代码语言:javascript复制
local items = {
    [1000] = {
        [10001] = {count = 1},
        [10002] = {count = 2},
        ...
        [10009] = {count = 4},
 },
    [1001] = {...}
    ...
 [1100] = {...}
}

这样10个一组,再分别对每一组序列化后压缩,变为:

代码语言:javascript复制
local items = {
    [1000] = "1FF1222",
    [1001] = "1FE1222",
    ...
    [1100] = "13F241222",
}

这样可节省的内存将会更多。访问的方式也是一样,当访问到某id时,除10找到对应的组,解压缩反序列化即可。

这种优化方式对于一些冷数据的,尤为有效,因为大部分情况下都不会访问它们。

下沉C

在前面的优化方法都尝试之后,还想继续优化内存,怎么办?考虑一个问题,似乎C 很少因为定义几个字段几个Struct就内存炸了的情况,究其原因,有以下几点:

  1. C 的Struct或者Class无额外消耗,一个空Class几乎不消耗内存。而Lua的Table本质是一个很复杂的HashTable与Vector结构,即使是个空Table也消耗了一大坨内存。
  2. C 的字段是紧密排列,一个int就是固定的4字节,无额外消耗。而Lua因为是弱类型的解释语言,除了本身的数据存储,还需要类型描述以及GC等信息,单个字段的消耗是16字节 ,相比C 膨胀了数倍,虽然实际上Lua已经实现的很精巧了。

那么,我们也仿照C 实现一套内存布局,是不是就可以了?

比如某个Table结构有abc三个字段,都为int范围的整数,那我们在C 中开辟一块12字节的内存来存放就行了,干掉Lua中的Table,把对abc的读写操作都映射到C 里的这块内存上。

如何映射呢?当然也是用元表了。也许你会说元表不也会占用空间?是会占用,所以我们要把所有类型相同的结构共用一份元表,比如有1000个Item,只有一份元表。

理论上来说,这种方式就可以达到接近C 的内存使用,从而优化Lua的内存占用,顺便还减轻了GC的压力,因为Lua中已经没有复杂的Table结构了。

将大象装进冰箱的思路有了,下面就讲下具体的几步。

结构定义

首先第一步,是要先描述结构的定义,可以采用自定义的格式,比如还是物品的例子,干脆直接用Lua描述它的格式:

代码语言:javascript复制
local Item = {
    [id] = {size = 4},
    [count] = {size = 4},
    [property1] = {size = 4},
    ...
}

Item有3个字段,每个字段4字节。或者也可以使用json、protobuf等格式,比如protobuf:

代码语言:javascript复制
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结构:

代码语言:javascript复制
local Item = {
    id = 123123,
    count = 1,
    property1 = 0
}

对应的protobuf定义:

代码语言:javascript复制
message Item {
    int32 id = 1;
    int32 count = 2;
    int32 property1 = 3;
}

最开始id写在了分配的C 的内存的偏移0,count写在了偏移4,以此类推。

当我们业务需要,假设需要增加一个字段time,出于可读性考虑,我们加在了中间位置。Lua结构变为:

代码语言:javascript复制
local Item = {
    id = 123123,
    count = 1,
    time = 1212123123,
    property1 = 0
}

对应的新的protobuf定义:

代码语言:javascript复制
message Item {
    int32 id = 1;
    int32 count = 2;
    int32 time = 3;
    int32 property1 = 4;
}

这时候,再使用新的protobuf的偏移,去读写我们之前分配好的内存,会明显错位了,比如现在property1的偏移是12,但是在旧的内存布局里,偏移是8。

怎么办?也许你已经想到了,首先protobuf的定义不能乱来,应该兼容旧版本,比如新增字段后,新的定义应该为:

代码语言:javascript复制
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存起来,这样内存里只会存一份字符串的拷贝。比如:

代码语言:javascript复制
local a = "test"
local b = "test"

ab都指向了同样的字符串地址,节省了内存,而且这样判断ab是否相等时,可以直接使用指针比较,而无需调用strcmp,也提高了性能。

而什么时候从hashmap删掉呢?自然是没有使用了,Lua通过GC来删掉。比如:

代码语言:javascript复制
local a = "test"
a = nil

其他需要GC的类型比如Table、UserData也是同理。

那既然我们把Lua内存下沉到C ,Lua复杂的结构如何保证既不会内存泄露,又不会野指针呢?要知道,Lua的Table是可以随便相互各种引用的。

是不是也要复刻这套GC呢?其实大可不必,我们使用最简单的引用计数即可。

引用计数

引用计数众所周知,引用 1,取消引用-1,为0表示没人引用了,就释放掉。比如std::shared_ptr就是干这个的。

但是引用计数有个问题是循环引用,比如:

代码语言:javascript复制
local a = {}
local b = {}
a.b = b
b.a = a

这样ab相互引用,就没机会减到0了。Lua为了避免这个问题,采用三色标记法来一波一波的回收。

那为什么Lua内存下沉到C 中,反倒可以使用引用计数,没有循环引用的问题呢?

原因很简单,我们使用了protobuf来描述结构。直接不让这么定义就行了,比如这种是不允许的:

代码语言:javascript复制
message A {
  B b = 1;
}

message B {
  A a = 1;
}

实际上也几乎不会有必须这样写的业务需求。大部分是分层的定义:

代码语言:javascript复制
message A {
  Base b = 1;
}

message B {
  Base a = 1;
}

message Base {
  ...
}

当去掉了循环的边,所有的结构都变成了一颗树,只要使用引用计数管理即可,简单又高效。

又因为Lua都是单线程的,所以使用一个单线程版本的shared_ptr即可,性能更佳。

复杂结构

当然,我们的结构不可能总全是int,必须要支持嵌套、repeated、map的定义,比如:

代码语言:javascript复制
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;
}

在前面我们知道各个字段是按照偏移来存放在内存里的,那这里的nameitemspetfriends成员应该如何存?毕竟几乎都是编译期未知的大小。

那么很简单,只存放指针即可,固定8字节。指针索引到具体的实例上去,对应的就是StringArrayMap

字符串池子

如前所述,我们也仿照Lua,把所有C 里的字符串用一个hashmap管理起来。

虽然实际上不需要在C 中用到字符串的比对,因为访问a.b时,Lua层已经把b映射到某个偏移了,C 也就无需在用b再做字符串比较查找字段。

这种设计的主要目的还是减少内存的占用,毕竟程序中还是存在大量的相同字符串的。

另外String虽然也使用了引用计数管理,但是在释放时,需要从池子中删掉,并且池子是不能增加计数的,不然永远到不了0,这里要用到weakptr了。

Array实现

Array对应的就是Lua中的数组,比如:

代码语言:javascript复制
local array = {"a", "b", "c"}

也是protobuf里的repeated,对应的定义:

代码语言:javascript复制
repeated string array = 1;

虽然repeated看起来和普通的message区别很大,但是在C 里实现是差不多的。

message是在访问a.b时,把b映射到某个偏移读写。

repeated则是在访问a[1]时,把1也映射到某个偏移,逻辑更简单了,乘以单个的元素大小即可。

不过这里需要注意的是,在设置元素时,要确保是符合protobuf的定义的,毕竟Lua是可以随便写,如果上面的例子:

代码语言:javascript复制
array[1] = 2

把整数设置到了字符串数组中,C 层要能够检测并抛出异常出来。

Map实现

Map在Lua里虽然也是Table,但是是用来存放未知的KV的,典型的比如一个好友的集合:

代码语言:javascript复制
local friends = {
    ["123123"] = {name = "张三"},
    ["123124"] = {name = "李四"},
}

对应到protobuf的定义:

代码语言:javascript复制
map<string, Friend> friends = 1;

这个Map就不能靠偏移来实现了,是放KV的也就只能用HashMap结构。

而既然要节省内存,那自然要用最精确的字段来存储了,比如:

代码语言:javascript复制
map<int32, int32> map1 = 1;
map<int64, int32> map2 = 2;
map<int32, int64> map1 = 3;
map<int64, int64> map1 = 4;

这有4种尺寸的KV排列组合,如果我们想简单实现,定义个union,比如:

代码语言:javascript复制
union Data {
    int32_t i32;
    int32_t i64;
    ...
};

然后用一个C 的std::unordered_map<Data, Data>来存放所有类型的KV。这种方式固然简单,但是内存明显并没有做到最优。

怎么办呢?似乎也没什么好方法,罗列下所有的可能性,然后定一个指针union,像这样:

代码语言:javascript复制
union Map {
    std::unordered_map<int32_t, int32_t> * i32_32;
    std::unordered_map<int32_t, int64_t> * i32_64;
 ...
};

然后根据protobuf的定义,初始化对应的类型,并按照那个类型来读写。

看起来很蠢,但是却是最简单有效的做法。也许你会说,有字节对齐,i32_32i32_64占用的实际内存会不会其实是一样的?

是有这个可能,但是我们没法假定std::unordered_map内部实现的结构定义,而且哪怕是一样的,除了代码多一点,CPU和内存均无损失。

而代码方面,可以借助template以及C 17的if constexpr,最大程度的减少冗余。

测试结果

废了好大劲,终于正确无误的把Lua内存下沉到C 中,现在是检验优化成果的时候了。

分成了普通结构、ArrayMap三种场景。

普通结构

就是最普通简单的结构体,定义如下:

代码语言:javascript复制
message SimpleStruct {
  int32 a = 1;
  int32 b = 2;
  int32 c = 3;
  ...
  int32 y = 25;
  int32 z = 26;
}

初始化了5000份数据,实测下沉C 后,内存为Lua的1/3左右。

Array

也采用了最简单的数组类型,定义如下:

代码语言:javascript复制
repeated int32 labels = 6;

插入1000w条数据后,C 内存为Lua的1/4左右。

Map

最后是Map类型,定义为:

代码语言:javascript复制
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_mapstd::unordered_map比较下性能,Set会稍慢点,Get是一致的,符合预期。实际上程序中也是读多写少,Lua这种策略没错。

优化后测试

最后,重新跑一遍测试,C 内存为Lua的1/2左右。不得不说,Lua实现真的很精巧。

总体来说,下沉方案效果还可以,但是还有继续扣内存的空间。

总结

Lua确实是嵌入式脚本语言一哥,开发效率高,运行效率也不差。

不过如果一不注意,就容易陷入CPU与内存的陷阱中,大量使用时还是要多加注意。

0 人点赞