Contents

Lua Profiler 基本原理

学习ELuaProfilerMiku-LuaProfiler,讨论 lua profiler 的前置能力 希望通过学习主流的 lua profiler 的前置能力,来讨论 webgl 平台 lua profiler 的可行性

学习了两款主流的lua profiler

想要实现一个lua profiler,依赖的核心功能是:

  • 内存监控:
    1. 感知内存分配行为;
    2. 统计当前内存总量;
    3. 分别对Table, Function, UserData, Thread, Proto, String 进行精细处理;
  • 调用监控:
    1. 感知lua的调用行为;
    2. 感知函数退出;

依赖这些功能,就能实现一个最简单的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_loadbufferx
  • luaL_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的代码,执行以下操作:

  • **函数名分析:**在拿到functiontoken时,把函数名分析出来;

  • 调用行为分析:能够识别调用,并且识别是否是尾调用;

  • 返回行为分析:能够识别函数的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,在

总结

ELuaProfilerMiku-LuaProfiler本质上都是通过hook lua函数入口、出口来实现的

不同点在于:

  • ELuaProfiler通过lua原生机制实现,能够感知到tostringlua原生提供的接口的调用,这是优势,但与此同时,也需要定制维护一个函数黑名单,来过滤掉不过多的、不必要的系统函数的调用;
  • 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即可。

内存监控

内存监控的目标有两点:

  1. 实时的内存使用量感知
  2. 感知不同类型的对象数量、占用空间

内存使用量统计

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-LuaProfilerhooklua_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#中已经destoryuserdata,其原理是通过调用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取调用栈,进行记录;

总结

  • 调用感知:
    • 需要hook lua函数调用的行为
      • 可以学习EluaProfiler,利用Lua原生的hook来实现,但是在unity中需要从头开始自己实现;
      • 更好的方法是改写Miku-LuaProfielr,编写Webgl Hook,对于编写hook
        • 可以使用工具,改变lua源码,添加工具函数;
        • 可以用binaryen,注入一个hook方法,直接修改wasm,适配会好适配一些;
  • 内存监控:
    • 内存量的变动:
      • 学习EluaProfiler看起来更好一些,不会限制用户的gc,可以实时感知到真实的内存使用;
      • 保持Miku-LuaProfiler的话,可以看到每个函数的增量,但是会导致lua gc不生效;
    • 内存快照:
      • 实现原理基本一致,都是遍历_GMiku-LuaProfiler还多遍历了debug.getregistry

风险

1. hook本身会耗时,影响精度

《Lua Profiler性能分析工具的实现》作者进行了实验,每次hook耗时为14us,在层次较深,但是函数逻辑简单的调用过程中,hook的耗时会随着函数调用深度的增加而累加,可能会导致耗时统计不准。

他们另外开了一条线程来处理hook,但是显然wasm环境中不具备这样的条件。

参考

  1. 《Lua性能优化(一):Lua内存优化 》
  2. 《如何利用LuaHook开发一个健壮的Profiler》 —— 未公开
  3. 《Lua Profiler性能分析工具的实现》 —— 未公开
  4. Miku-LuaProfiler
  5. ELuaProfiler