黑科技:魔改TProto优化掉100MB的Lua内存

2021-10-22 16:18:26 浏览数 (1)

手机的内存优化几乎是所有手机游戏都会做的事情。像iphone7,iphone8这样的机器,他的CPU非常强悍,但是内存一共就只有2G,真正能给应用使用的安全内存可能就1.1G左右。内存的限制就直接制约着游戏画面的表现,比如不能用过多的的RT,不能用大分辨率贴图,抗锯齿不能使用TAA等太多的因素。像原神这样的游戏,因为用了延迟渲染,为了保证画质更是任性的直接不支持低内存的手机。

而Lua目前在很多游戏开发尤其是手机游戏的开发中被广泛使用,也是因为这个语言本身的特性,比如逻辑简单易修改,解释执行,支持热更等。虽然一般游戏,轻量使用Lua可能内存的占比不高,但在一些非常重度或全部代码都是写在lua的游戏中,lua的启动内存可能就轻松占用上百MB,什么都不做峰值达到300MB以上,所以对lua做内存优化,就是一个非常重要的事情。在前面有专门写一篇lua是怎样占用内存的: Lua数据的内存结构 - 知乎 (zhihu.com)

如果你的游戏也是一个用lua开发的重度游戏,你可能会观察到其中有个结构TProto占用的内存非常夸张,而且这部分会常驻内存,随着项目代码量的增多而增多。那么TProto到底是什么呢?其实就是程序员写的代码,被lua的解析器编译成字节码在内存中的结构。其中code就是对应的代码,Proto是以函数或闭包为单位的。有多少个Proto就相当于是有多少个函数/闭包被加载了。所以,只要函数写的越长,单个Proto就越大,函数或文件越多Proto的数量就会越多。他的内存计算规则如下:

这里可以看到,lua在计算内存时耍了一个小聪明,只是把他认为需要计算的部分加了起来,而其中有一个占用内存比较大块的字段lineinfo,是没有被计算进内存里的

我们可以通过注释看到,这个lineinfo只是调试的时候当前字节码对应在源码中的行号信息。而Instruction本身就只有4字节,调试信息就同样占了4字节。在调试中可以发现,code的内存有多大这个lineinfo的内存就有多大,这对于游戏来说是不太合适的。当然除此外还有一些其他的调试信息,包括source以及locvars等也会占用一些内存。

所以最简单,最暴力的做法,就是全局搜索这个字段,把所有用到的地方都删掉,因为他只是调试信息不会对正常运行产生任何影响。假如你的代码在内存中有200MB,改完后你就会发现内存轻轻松松少了100MB。。。所以,到此为止,本文就可以这样简单愉快的完结撒花了

但这样做的代价,肯定就是lua代码再也看不到报错堆栈了,遇到了异常完全无法定位原因,就像C 没有符号表一样。所以下面就来提出一些方案,能够很好的解决这个问题。

方案1:

也是最简单的改法。我们注意到这里代码行号使用的是int,int的上限是21亿 ,但其实应该没有人能把单个lua代码文件写到20亿行的,假如我们把int改为short,那么上限是32767,对于大部分程序来说完全足够用了。

当然用到的地方,只需要改一处,就是下面加载字节码的地方,这个函数在lundump.c中。要把加载进来的int转为short,否则是放不下的。

当然这样的方案,减少的内存肯定不如直接去掉,只能减少一半,但好处就是调试信息还在。

方案2:

其实再仔细观察可以发现,这里行号都是绝对行号,但其实正常的函数长度一般都不会很长,在TProto里还有个字段linedefined,记录了这个函数的开始行号,假如我们把这个字段改为相对行号,假如函数的行数都没有超过256,那理论上还可以把这个short改为uint8(unsigned char)的。在报错打堆栈的时候,再用相对行号加上linedefined即可。这样又可以节省4分之一内存,当然代价是肯定比上面更麻烦了,要在打堆栈的地方还原行号。其实理论上不加linedefined也可以正常运行,只是调试信息友好度相对差一些,只要保证所有程序员都清楚的知道规则就好。另外即使少数函数超过了256行,就只保存低位,报错时发现不对,原行号 256再多看1行就好了。

方案3:

因为还剩了4分支1内存,还有没有办法再压缩一下这部分内存呢?再仔细观察,又可以发现,这里是相对行号,那么可以看到这个数组里面值其实是这样的1,1,1,2,3,3,4...要么和前一个值一样,要么是递增1的。这是因为我们写的代码都是连续的,lua在编译后生成的字节码当然也就是连续的。所以我们就可以把这个代码改为一个BitArray,每一位代表一行,如果相比前一个增加了1行,就设为1,否则为0,这样1字节就可以表示8个字节码的行号。最终内存占用就变成了原来的32分支1。当然代价是在报错或打堆栈的时候要把行号还原回去。这里搜一下lineinfo用到的地方,加上linedefined和当前位之前有多少个1就可以,这里就不再具体说怎么修改了。当然统计多少个1还是有一些快速办法的,比如UE4的数学库就提供了这样的快速函数:

如果支持SSE指令的话那会更快,比如clang下__builtin_popcountll

windows上对应的是_mm_popcnt_u64

方案4:

最后,假如还是一点调试信息都不想存,又还想回复出堆栈信息,该怎么办呢?那么也可以像C 那样,把符号信息离线存成一个符号表,不跟着字节码一起打包对外发布。其实符号表完全不需要单独写,因为最终都是从lundump中读取出来的,只要保留原始字节码,对外发布的是strip后的字节码就好。我们知道lineinfo和code是一一对应的,所以报错的时候只要把code下标记录下来,然后程序员需要根据行号,到对应的符号表上找到对应的行号。当然这种方案是最麻烦的,毕竟要写工具,但肯定是效果最好的,而且安全性相对来说也是最高的,即使游戏程序遭到暴力破解后也拿不到lua的调试行号。

PS:

lua5.4这里也修改了,变成了两个字段,但是内存依然占用很多,所以本文的修改方法还是有参考价值的。

0 人点赞