Lua Profiler 基本原理
学习ELuaProfiler和Miku-LuaProfiler,讨论 lua profiler 的前置能力 希望通过学习主流的 lua profiler 的前置能力,来讨论 webgl 平台 lua profiler 的可行性
学习了两款主流的lua profiler:
Miku-LuaProfiler:unity中常用的lua profiler,不支持webgl,只支持windows,androidELuaProfiler:UE中常用的lua profielr
想要实现一个lua profiler,依赖的核心功能是:
- 内存监控:
- 感知内存分配行为;
- 统计当前内存总量;
- 分别对
Table,Function,UserData,Thread,Proto,String进行精细处理;
- 调用监控:
- 感知
lua的调用行为; - 感知函数退出;
- 感知
依赖这些功能,就能实现一个最简单的lua profiler了。
调用感知
ELuaProfiler
ELuaProfiler使用了lua原生提供的hook能力:
/*
** Event codes
*/
#define LUA_HOOKCALL 0
#define LUA_HOOKRET 1
#define LUA_HOOKLINE 2
#define LUA_HOOKCOUNT 3
#define LUA_HOOKTAILCALL 4
/*
** Event masks
*/
#define LUA_MASKCALL (1 << LUA_HOOKCALL)
#define LUA_MASKRET (1 << LUA_HOOKRET)
#define LUA_MASKLINE (1 << LUA_HOOKLINE)
#define LUA_MASKCOUNT (1 << LUA_HOOKCOUNT)
LUA_API void (lua_sethook) (lua_State *L, lua_Hook func, int mask, int count);
lua提供了api,能通过lua_sethook来获取lua中发生的一些事件(下表来自于《如何利用LuaHook开发一个健壮的Profiler》):
| Lua Hook类型 | 触发时机 |
|---|---|
| LUA_HOOKCALL | 进入新函数后,函数获取参数前 |
| LUA_HOOKRET | 函数返回之前 |
| LUA_HOOKLINE | 解释器准备开始执行新的一行代码时 |
| LUA_HOOKCOUNT | 解释器每执行完count条指令时 |
| LUA_HOOKTAILCALL | 执行尾调用时,具体时机与LUA_HOOKCALL相同 |
刚才提到的lua_sethook函数,可以预定义一个lua_Hook的函数,来接收事件,这个函数的声明如下:
struct lua_Debug {
int event;
const char *name; /* (n) */
const char *namewhat; /* (n) 'global', 'local', 'field', 'method' */
const char *what; /* (S) 'Lua', 'C', 'main', 'tail' */
const char *source; /* (S) */
int currentline; /* (l) */
int linedefined; /* (S) */
int lastlinedefined; /* (S) */
unsigned char nups; /* (u) number of upvalues */
unsigned char nparams;/* (u) number of parameters */
char isvararg; /* (u) */
char istailcall; /* (t) */
char short_src[LUA_IDSIZE]; /* (S) */
/* private part */
struct CallInfo *i_ci; /* active function */
};
/* Functions to be called by the debugger in specific events */
typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar);
传入的lua_Debug中的event字段被设置,用于感知当前发生的事件类型,hook函数可以根据event的类型来进一步获取信息。
接下来就可以调用lua_getinfo来获取当前正在运行中的函数信息:
lua_getinfo(L, "nS", ar);
这样就可以对ar中与"n", "S"相关的字段进行填充,上层就可以获得正在进行调用的函数的名称、文件名、行号等信息。
如果每次都要重新获取nS的话,就会比较慢,ELuaProfiler的作者使用"f"方法获取函数指针:
lua_getinfo(L, "f", ar);
const void* luaPtr = lua_topointer(L, -1);
使用Map缓存luaPtr到已经缓存的ar信息,这样就不用每次都新开数据了。
Miku-LuaProfiler
Miku-LuaProfiler并没有选择依赖lua本身提供的hook机制,而是选择使用了原生的hook能力
public interface NativeUtilInterface
{
IntPtr GetProcAddress(string InPath, string InProcName);
IntPtr GetProcAddressByHandle(IntPtr InModule, string InProcName);
void HookLoadLibrary(Action<IntPtr> callBack);
INativeHooker CreateHook();
}
public interface INativeHooker
{
void Init(IntPtr targetPtr, IntPtr replacementPtr);
Delegate GetProxyFun(Type t);
bool isHooked { get; set; }
void Install();
void Uninstall();
}
针对不同平台,需要实现:
NativeUtilInterface.GetProcAddress:根据方法名,拿到targetPtr,根据targetPtr能拿到INativeHooker;INativeHooker:使用将c#中的函数,替换原有的函数;
利用这套hook能力,hook住了:
luaL_loadbufferxluaL_loadbuffer
lua在加载文件的时候,会使用该方法,将文件内容加载进来,Miku-LuaProfiler将这个过程劫持了,并且在文件内容被加载前,对其进行了修改。
它添加了如下内容:
local MikuSample = {
rawget(_G, 'MikuLuaProfiler').LuaProfiler.BeginSample,
rawget(_G, 'MikuLuaProfiler').LuaProfiler.EndSample,
rawget(_G, 'miku_unpack_return_value')
}
MikuSample[1]("[lua]:require ${filename},${filename}&line:1")
return (function(...)
-- 中间填充原来的函数.
end)(...)
与此同时,Miku-LuaProfiler实现了一个Lua的词法分析器 + 语法分析器,该分析器会遍历lua的代码,执行以下操作:
**函数名分析:**在拿到
function的token时,把函数名分析出来;调用行为分析:能够识别调用,并且识别是否是尾调用;
返回行为分析:能够识别函数的
return;
当我们的lua内容如下的时:
function Sum(l, r)
if l < r then
return l + r
else
return r + l
end
return 1
end
function GetOnePlusOneResult()
return Sum(1, 1)
end
生成的结果如下:
local MikuSample = {rawget(_G, 'MikuLuaProfiler').LuaProfiler.BeginSample, rawget(_G, 'MikuLuaProfiler').LuaProfiler.EndSample, rawget(_G, 'miku_unpack_return_value')} return (function(...) MikuSample[1]("[lua]:require asd,asd&line:1")function Sum(l, r) MikuSample[1]("[lua]:Sum,asd&line:1")
if l < r then
return MikuSample[3]( l + r)
else
return MikuSample[3]( r + l)
end
return MikuSample[3]( 1)
end
function GetOnePlusOneResult()
return Sum(1, 1)
end
MikuSample[2]()
end)(...)
而这里,miku_unpack_return_value就是单纯的调用了一下:
[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static int UnpackReturnValue(IntPtr L)
{
LuaProfiler.EndSample(L);
return LuaDLL.lua_gettop(L);
}
到此为止,LuaProfiler在进入函数的时候,被告知BeginSample,在
总结
ELuaProfiler和Miku-LuaProfiler本质上都是通过hook lua函数入口、出口来实现的
不同点在于:
ELuaProfiler通过lua原生机制实现,能够感知到tostring等lua原生提供的接口的调用,这是优势,但与此同时,也需要定制维护一个函数黑名单,来过滤掉不过多的、不必要的系统函数的调用;Miku-LuaProfiler是通过平台原生的hook机制,劫持了lua_loadbufferx方法,修改了读入的lua代码,这样就可以只hook到用户编写的lua文件,而不hook系统调用,会更灵活一些;
ELuaProfiler的方式可以直接拿过来用,但是Miku-LuaProfiler的方式就需要针对webgl重新开发,来实现它的hook接口。但是方法是不难的,可以引入头文件,将lua中对lua_api的调用修改为可hook的调用,如:
typedef int (*luaL_loadbufferx_func)(lua_State *L, const char *buff, size_t size, const char *name, const char *mode);
static luaL_loadbufferx_func luaL_loadbufferx_ptr = &luaL_loadbufferx;
LUALIB_API int wrap_luaL_loadbufferx(lua_State *L, const char *buff, size_t size,
const char *name, const char *mode) {
*luaL_loadbufferx_ptr(L, buff, size, name, mode);
}
int install_luaL_loadbufferx_hook(void* hook_func) {
luaL_loadbufferx_ptr = (luaL_loadbufferx_func)hook_func;
}
int uinstall_luaL_loadbufferx_hook(void* hook_func) {
luaL_loadbufferx_ptr = &luaL_loadbufferx;
}
动态获取luaL_loadbufferx_ptr进行调用;
#define luaL_loadbufferx wrap_luaL_loadbufferx
对所有调用luaL_loadbufferx的地方,改为wrap_luaL_loadbufferx即可。
内存监控
内存监控的目标有两点:
- 实时的内存使用量感知
- 感知不同类型的对象数量、占用空间
内存使用量统计
ELuaProfiler
lua允许用户自定义内存分配器,可以通过调用lua_setallocf来指明自己的内存分配器,其声明如下:
typedef void * (*lua_Alloc) (void *ud, void *ptr, size_t osize, size_t nsize);
void lua_setallocf (lua_State *L, lua_Alloc f, void *ud);
用户需要自己实现lua_Alloc,这个函数根据参数传入不同需要做出不同行为:
| 参数状态(前提) | 需要做出的行为 |
|---|---|
nsize == 0 | 根据osize释放内存 |
ptr == NULL && nsize != 0 | 根据nsize分配对应大小的内存 |
ptr != NULL && nsize != 0 | 执行realloc逻辑,同时释放osize并且分配nsize |
通过监听lua_setallocf能够实时感知到lua的内存分配情况,ELuaProfiler中实现如下:
void* FELuaMonitor::LuaAllocator(void* ud, void* ptr, size_t osize, size_t nsize)
{
if (nsize == 0)
{
ELuaProfiler::GCSize += osize;
FMemory::Free(ptr);
return nullptr;
}
if (!ptr)
{
ELuaProfiler::AllocSize += nsize;
return FMemory::Malloc(nsize);
}
else
{
ELuaProfiler::GCSize += osize;
ELuaProfiler::AllocSize += nsize;
return FMemory::Realloc(ptr, nsize);
}
}
这上面的表中的内容一模一样,不做过多讲解。
Miku-LuaProfiler
Miku-LuaProfiler的思路相当暴力:直接禁用掉所有lua的gc操作,统计lua的内存增量即可
我们之前提到Miku-LuaProfiler使用了平台的hook能力,基于这个能力,Miku-LuaProfiler也hook了lua_gc这个函数:
[MonoPInvokeCallbackAttribute(typeof(lua_gc_fun))]
public static int lua_gc_replace(IntPtr luaState, LuaGCOptions what, int data)
{
lock (m_Lock)
{
if (!isHook)
{
return lua_gc(luaState, what, data);
}
else if (what == LuaGCOptions.LUA_GCCOUNT)
{
return lua_gc(luaState, what, data);
}
else if (what == LuaGCOptions.LUA_GCCOUNTB)
{
return lua_gc(luaState, what, data);
}
return 0;
}
}
hook之后,只会处理LUA_GCCOUNT, LUA_GCCOUNTB这两轮gc,其他的部分一概不处理。
这样lua的内存就不会缩减了,只需要每次sample结束后,count一遍lua的内存,算一下diff就可以知道不同函数使用了多少内存:
public static long GetLuaMemory(IntPtr luaState)
{
long result = 0;
if (LuaProfiler.m_hasL)
{
result = LuaDLL.lua_gc(luaState, LuaGCOptions.LUA_GCCOUNT, 0);
result = result * 1024 + LuaDLL.lua_gc(luaState, LuaGCOptions.LUA_GCCOUNTB, 0);
}
return result;
}
分类型统计(内存快照)
Miku-LuaProfiler
miku-luaprofiler中的内存快照功能在lua中实现:
function miku_do_record(val, prefix, key, record, history, null_list)
在实际使用的过程中,会从_G全局表,以及_R注册表(debug.getregistry())出发,递归的进行遍历。
null_list会返回所有在c#中已经destory的userdata,其原理是通过调用c#的System.Object.Equals判断是否为nil;record会记录所有对象 到其 所在位置的集合的映射,如下面的函数,在以下路径出现:function: 001FA820 { "function:=[C]&line:-1", "[_G].[package].[loaded].[os].[exit]", "function:@.\temp.lua&line:23.[infoTb].[table:]", "function:@.\temp.lua&line:10.[funAddrTb].[table:]" }history是一张特殊的表,在执行miku_diff时生效,为了生成diff,会在lua中保存一张之前的历史记录,这张表中的内容是不希望被遍历到的;
在递归遍历的过程中,除了对于节点本身外:
function:会遍历upvalue进行记录table:遍历table中的内容,进行记录- 对于所有的对象:取
metatable,进行记录
代码详情,可以点击此处
ELuaProfiler
ELuaProfiler的内存快照功能在c++中实现,核心在于:
void FELuaMemAnalyzer::traverse_object(lua_State* L, const char* desc, int level, const void* parent)
{
int t = lua_type(L, -1); // [object]
switch (t)
{
case LUA_TLIGHTUSERDATA:
traverse_lightuserdata(L, desc, level, parent); // [] pop object
break;
case LUA_TSTRING:
traverse_string(L, desc, level, parent); // [] pop object
break;
case LUA_TTABLE:
traverse_table(L, desc, level, parent); // [] pop object
break;
case LUA_TUSERDATA:
traverse_userdata(L, desc, level, parent); // [] pop object
break;
case LUA_TFUNCTION:
traverse_function(L, desc, level, parent); // [] pop object
break;
case LUA_TTHREAD:
traverse_thread(L, desc, level, parent); // [] pop object
break;
//case LUA_TNUMBER:
// traverse_number(L, desc, level, parent); // [] pop object
// break;
default:
lua_pop(L, 1); // [] pop object
break;
}
}
实现的思路与miku相同,但是看起来,对LUA_TTHREAD进行了更多的处理,在traverse_thread中:
- 对
thread的栈上所有元素,执行traverse_object,进行遍历; - 随后会不停的使用
lua_getstack取调用栈,进行记录;
总结
- 调用感知:
- 需要
hooklua函数调用的行为- 可以学习
EluaProfiler,利用Lua原生的hook来实现,但是在unity中需要从头开始自己实现; - 更好的方法是改写
Miku-LuaProfielr,编写Webgl Hook,对于编写hook:- 可以使用工具,改变
lua源码,添加工具函数; - 可以用
binaryen,注入一个hook方法,直接修改wasm,适配会好适配一些;
- 可以使用工具,改变
- 可以学习
- 需要
- 内存监控:
- 内存量的变动:
- 学习
EluaProfiler看起来更好一些,不会限制用户的gc,可以实时感知到真实的内存使用; - 保持
Miku-LuaProfiler的话,可以看到每个函数的增量,但是会导致lua gc不生效;
- 学习
- 内存快照:
- 实现原理基本一致,都是遍历
_G,Miku-LuaProfiler还多遍历了debug.getregistry
- 实现原理基本一致,都是遍历
- 内存量的变动:
风险
1. hook本身会耗时,影响精度
《Lua Profiler性能分析工具的实现》作者进行了实验,每次hook耗时为14us,在层次较深,但是函数逻辑简单的调用过程中,hook的耗时会随着函数调用深度的增加而累加,可能会导致耗时统计不准。
他们另外开了一条线程来处理hook,但是显然wasm环境中不具备这样的条件。
参考
- 《Lua性能优化(一):Lua内存优化 》
- 《如何利用LuaHook开发一个健壮的Profiler》 —— 未公开
- 《Lua Profiler性能分析工具的实现》 —— 未公开
- Miku-LuaProfiler
- ELuaProfiler