魔改TProto优化掉100MB的Lua内存

Posted 游戏蛮牛

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了魔改TProto优化掉100MB的Lua内存相关的知识,希望对你有一定的参考价值。

手机的内存优化几乎是所有手机游戏都会做的事情。像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,是没有被计算进内存里的

魔改TProto优化掉100MB的Lua内存

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

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

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

方案1:

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

魔改TProto优化掉100MB的Lua内存

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

魔改TProto优化掉100MB的Lua内存

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

方案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的数学库就提供了这样的快速函数:

魔改TProto优化掉100MB的Lua内存

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

魔改TProto优化掉100MB的Lua内存

windows上对应的是_mm_popcnt_u64

魔改TProto优化掉100MB的Lua内存

方案4:

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

PS:

魔改TProto优化掉100MB的Lua内存

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

扩展:Lua数据的内存结构


基本类型

Lua中每个数据类型都是一个TValue

魔改TProto优化掉100MB的Lua内存

  • value_:Value是个共用体,一共占8字节,根据实际类型选择具体是哪个字段

  • tt_:是用来表示上面的共用体实际是哪个类型,占4字节

可以看到基本类型(浮点数,整数,布尔值,lightuserdata,C++函数)至少会占用 12字节 (内存对齐后16字节)

gc这个指针指Lua虚拟机托管的对象包括字符串,Table,Userdata,协程,闭包,Proto等,内存由虚拟机额外分配并托管,下面具体说

GC对象(字符串,Userdata,协程,Proto)

魔改TProto优化掉100MB的Lua内存

每个GC对象都有个公有的头,next表示全局gc池的下一个节点的指针,将所有的gc对象都链起来
(PS:对比ue4是使用一个全局Object数组实现的,Lua每个节点就浪费掉8字节)
tt是当前对象的类型,和上面的tt_是一样的
marked是给垃圾回收器用的标记位

因此,GC对象至少会占用10字节的头部内存

String字符串

魔改TProto优化掉100MB的Lua内存
  • extra:是标记长字符串是否做过hash(这个字段短字符串没用到)

  • shrlen:短字符串的长度,由于是1字节,所以这个长度最多不超过256,也就是说短字符串理论最长可以调到256个字符(默认短字符串是40,这个字段长字符串没用到)

  • hash:是这个字符串算出来的hash值

  • u:是一个共用体,分两种情况:
    短字符串用来标记下一个字符串的指针,因为短字符串全局唯一,所以lua内部是通过一个链表把所有字符串连接起来的
    (PS:对比UE4的FName,是通过一个全局数组实现的,Lua每个短字符串就浪费掉8字节)
    长字符串用来标记字符串的长度(这里能表示8字节的长度,因为上面shrlen对于长字符串来说不够用),长字符串在lua中不是唯一的,所以不需要一个指针链起来
    (hash64标准lua没有,无视)
    实际字符串内容是拼接在这个字符串头之后,因此字符串的实际大小是24+字符串长度

Table

魔改TProto优化掉100MB的Lua内存


Lua的Table分为两部分,一个数组段和一个Map段

  • flags:一些标记位

  • lsizenode:Map的长度

  • sizearray:数组的长度

  • array:数组第一个元素的指针

  • node:Map第一个元素的指针

  • lastfree:Map段最后一个空位置的指针

  • metatable:这个Table的元表指针

  • gclist:这个Table内的托管对象

可以看到,一个空Table就至少要56字节的内存

Table中数组一个元素的结构:

魔改TProto优化掉100MB的Lua内存

Table中Map的一个KV元素的结构:

魔改TProto优化掉100MB的Lua内存

Table的实际大小,可以参考Lua垃圾回收时候遍历Table的代码:

魔改TProto优化掉100MB的Lua内存

Userdata

魔改TProto优化掉100MB的Lua内存

Proto

Proto就是Lua的函数原型,Lua函数的字节码都保存在这里,调用函数的地方只需要通过指向Proto的指针调用执行,具体结构很复杂就不细说了,可以看下图

魔改TProto优化掉100MB的Lua内存

内存占用:

魔改TProto优化掉100MB的Lua内存

闭包

魔改TProto优化掉100MB的Lua内存


分为C函数闭包和Lua闭包
C函数闭包:C的函数指针+UpValue数组
Lua闭包: Lua的函数原型指针+UpValue数组

UpValue结构如下:

魔改TProto优化掉100MB的Lua内存

内存占用:

Lua的局部变量

最后

在需要统计lua详细占用内存的时候,可以遍历_G上的allgc对象列表,按上面规则逐一统计,这里简单贴一个UE4+Unlua的内存详细统计并打印到log中的控制台命令,整个统计方法就是根据上面实现的。

struct FLuaGCObjectMemoryInfo
{
// 字符串统计: <size, count>
TMap<uint32, int32> ShortStringInfo;
TMap<uint32, int32> LongStringInfo;
TMap<uint32, int32> UserdataInfo;
TMap<uint32, int32> LuaClosureInfo;
TMap<uint32, int32> CFunctionInfo;
TMap<uint32, int32> CClosureInfo;
TMap<uint32, int32> TableInfo;
TMap<uint32, int32> ThreadInfo;
TMap<uint32, int32> ProtoInfo;

TMap<uint32, int32> LuaClosureExtra;

template <typename FmtEachType, typename FmtTotalType>
static void SingleMapToOutputDevice(FOutputDevice& OutputDevice, TMap<uint32, int32>& Info,
const FmtEachType& FmtEach, const FmtTotalType& FmtTotal)
{
int32 TotalSize = 0;
int32 TotalCount = 0;
for (auto& Pair : Info)
{
uint32 Size = Pair.Key;
int32 Count = Pair.Value;
TotalSize += (Size * Count);
TotalCount += Count;
OutputDevice.Logf(FmtEach, Size, Count);
}
OutputDevice.Logf(FmtTotal, TotalSize, TotalCount);
}

void ToOutputDevice(FOutputDevice& OutputDevice)
{
OutputDevice.Logf(TEXT("Lua Memory Detail Info Start:"));
SingleMapToOutputDevice(OutputDevice, ShortStringInfo,
TEXT("ShortString Each Size:%d Count:%d"),
TEXT("ShortString Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, LongStringInfo,
TEXT("LongString Each Size:%d Count:%d"),
TEXT("LongString Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, UserdataInfo,
TEXT("Userdata Each Size:%d Count:%d"),
TEXT("Userdata Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, LuaClosureInfo,
TEXT("LuaClosure Each Size:%d Count:%d"),
TEXT("LuaClosure Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, CFunctionInfo,
TEXT("CFuntion Each Size:%d Count:%d"),
TEXT("CFuntion Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, CClosureInfo,
TEXT("CClosure Each Size:%d Count:%d"),
TEXT("CClosure Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, TableInfo,
TEXT("Table Each Size:%d Count:%d"),
TEXT("Table Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, ThreadInfo,
TEXT("Thread Each Size:%d Count:%d"),
TEXT("Thread Total Size:%d Count:%d"));
SingleMapToOutputDevice(OutputDevice, ProtoInfo,
TEXT("Proto Each Size:%d Count:%d"),
TEXT("Proto Total Size:%d Count:%d"));
OutputDevice.Logf(TEXT("Lua Memory Detail Info End:"));
}

void Initialize()
{
lua_State* L = UnLua::GetMainState();
if (L == nullptr)
{
return;
}
global_State* _G = G(L);
lu_mem Count = MAX_LUMEM;
const int OtherWhite = otherwhite(_G);
for (GCObject* Iter = _G->allgc; Iter != nullptr && Count-- > 0; Iter = Iter->next)
{
GCObject* Obj = Iter;
const int32 Marked = Obj->marked;
if (isdeadm(OtherWhite, Marked))
{
continue;
}
switch (Obj->tt)
{
case LUA_TSHRSTR:
{
uint32 Size = sizelstring(gco2ts(Obj)->shrlen);
++ShortStringInfo.FindOrAdd(Size);
break;
}
case LUA_TLNGSTR:
{
#ifdef LUA_USE_LONG_STRING_CACHE
uint32 Size = sizelstring(gco2ts(Obj)->hash);
#else
uint32 Size = sizelstring(gco2ts(Obj)->u.lnglen);
#endif
++LongStringInfo.FindOrAdd(Size);
break;
}
case LUA_TUSERDATA:
{
uint32 Size = sizeudata(gco2u(Obj));
++UserdataInfo.FindOrAdd(Size);
break;
}
case LUA_TLCL:
{
uint32 Size = sizeLclosure(gco2lcl(Obj)->nupvalues);
++LuaClosureInfo.FindOrAdd(Size);
break;
}
case LUA_TCCL:
{
uint32 Size = sizeCclosure(gco2ccl(Obj)->nupvalues);
++CFunctionInfo.FindOrAdd(Size);
break;
}
case LUA_TLCF:
{
uint32 Size = sizeLclosure(gco2lcl(Obj)->nupvalues);
++CClosureInfo.FindOrAdd(Size);
break;
}
case LUA_TTABLE:
{
Table* t = gco2t(Obj);
uint32 Size = sizeof(Table) + sizeof(TValue) * t->sizearray +
sizeof(Node) * cast(size_t, allocsizenode(t));
++TableInfo.FindOrAdd(Size);
break;
}
case LUA_TTHREAD:
{
lua_State* th = gco2th(Obj);
uint32 Size = (sizeof(lua_State) + sizeof(TValue) * th->stacksize +
sizeof(CallInfo) * th->nci);
++ThreadInfo.FindOrAdd(Size);
break;
}
case LUA_TPROTO:
{
Proto* f = gco2p(Obj);
uint32 Size = (f->sizecode) * sizeof(*(f->code));
Size += (f->sizep) * sizeof(*(f->p));
Size += (f->sizek) * sizeof(*(f->k));
Size += (f->sizelineinfo) * sizeof(*(f->lineinfo));
Size += (f->sizelocvars) * sizeof(*(f->locvars));
Size += (f->sizeupvalues) * sizeof(*(f->upvalues));
Size += sizeof(*(f));
++ProtoInfo.FindOrAdd(Size);
break;
}
default:
lua_assert(0);
break;
}
}

auto Lambda = [](uint32 L, uint32 R)
{
return L > R;
};
ShortStringInfo.KeySort(Lambda);
LongStringInfo.KeySort(Lambda);
UserdataInfo.KeySort(Lambda);
LuaClosureInfo.KeySort(Lambda);
CFunctionInfo.KeySort(Lambda);
CClosureInfo.KeySort(Lambda);
TableInfo.KeySort(Lambda);
ThreadInfo.KeySort(Lambda);
ProtoInfo.KeySort(Lambda);
}
};

static FAutoConsoleCommandWithOutputDevice CCmdDumpMemory(
TEXT("DumpMemory"),
TEXT("DumpMemory"),
FConsoleCommandWithOutputDeviceDelegate::CreateLambda([](FOutputDevice& OutputDevice)
{
FLuaGCObjectMemoryInfo Info;
Info.Initialize();
Info.ToOutputDevice(OutputDevice);
}));

以上是关于魔改TProto优化掉100MB的Lua内存的主要内容,如果未能解决你的问题,请参考以下文章

python 魔改json导出为lua

SQLServer 2014 内存优化表

Android 逆向整体加固脱壳 ( DEX 优化流程分析 | dvmDexFileOpenPartial | dexFileParse | 脱壳点 | 获取 dex 文件在内存中的首地址 )(代码片

Hive优化

LuaProfiler:Lua内存优化工具教程

CCF201703-5 引水入城(100分题解链接)