一个简单的Lua (Memory) Profiler

1.6k 词

Lua没有内置的Profiler,但是提供了一些相关的接口,可以用来实现一个简单的Lua Profiler

一个Profiler至少需要统计以下信息, 用函数名+调用位置(保留一层堆栈信息)作为key:

  • 执行次数
  • 总时间
  • 单次最大时间
  • 尚未gc的内存数量
  • 分配内存的最大值

二、基础

出发点是LuaProfiler,结构比较合理,但是有一些小问题:

  • 统计数据应该驻留在内存中,不能写log,太卡。
  • time()的精度太低,换成PerformanceCounter(windows)。
  • lua5.35.1lua_Hook处理tail call的接口不一样,需要转换一下。
  • coroutine相关的处理。

三、Memory

通过以下方式可以获取每个函数分配内存的数据:

  1. 启动Profiler时执行一次full gc
  2. 重载lua_Alloc,按下列三种情况统计数据:
    1. 分配新的内存:建立内存指针与当前函数数据的对应关系,如果新内存大小为size, 当前函数的内存数量+=size,同时检查更新内存最大值。
    2. 释放内存:获得对应的函数数据,如果释放的内存大小为size, 当前函数的内存数量-=size
    3. realloc: 按照释放旧内存,分配新内存处理,但是这样可能会出现一些问题。如果旧内存是在函数A中分配,新内存在另一个函数B中分配,旧内存对应的数据会被计入B的统计数据中。不过这个问题应该影响不大。
  3. 停止Profiler时执行一次full gc

四、应用

这个Profiler统计的数据虽然简单,但是已经足以用来进行一些精细的优化,其中值得一提的有:

4.1 内存泄漏

以下面这段代码为例:

local function alloc()
    return {}
end

–分配
local Cache = {}
for i = 1, 100 do
Cache[i] = alloc()
end
– 释放
for i = 1, 100 do
Cache[i] = nil
end

下面是函数alloc在内存方面的数据,可以看到alloc分配的内存都释放掉了:

尚未gc的内存数量(Byte) 分配内存的最大值(Byte)
0 5600

把释放内存的代码注释掉,函数alloc在内存方面的数据变成:

尚未gc的内存数量(Byte) 分配内存的最大值(Byte)
5600 5600

如果代码中存在(持续地)内存泄漏,表现在profile数据中,是相关函数的尚未gc的内存数量(Byte)项不但不为0,还可能持续的变大。

4.2 不必要的临时内存

..拼接字符串是最典型的例子,下面的函数ConcatStringsSubStrList中的字符串拼接成一个字符串:

local SubStrList = {}
for i = 1, 10000 do
    table.insert(SubStrList, tostring(i))
end

local function ConcatStrings(SubStrList)
local Result = ""
for _, SubStr in ipairs(SubStrList) do
Result = Result SubStr
end
return Result
end

调用一次ConcatStringsprofile数据如下,从中可以看出产生了大量临时的内存,虽然可以gc掉:

函数名 尚未gc的内存数量(Byte) 分配内存的最大值(Byte)
ConcatStrings 38919 1641422

拼接字符串的正确姿势应该是:

local function ConcatStrings(SubStrList)
    return table.concat(SubStrList)
end

调用这个版本ConcatStrings的数据如下:

函数名 尚未gc的内存数量(Byte) 分配内存的最大值(Byte)
ConcatStrings 0 0
table.concat 39582 137942

比较两个版本的数据可以看出,最终拼接好的字符串占用的内存是相似的,但是拼接过程中产生的临时内存差别非常大。