Hugo Future Imperfect Slim

L-Lawliet's Blog

记录游戏开发的点点滴滴

lua fulluserdata代替table的尝试

lua fulluserdata代替table的尝试

Lawliet

Colourful

使用fulluserdata来实现复杂的数据结构,然后尝试替代table,以验证是否可以实现更快的框架(Table的Value为Table时,会有内存不连续的情况,缓存不友好)

环境

  • xlua版本:2.1.16
  • lua版本:5.3.5
  • 运行环境:Unity Editor
  • PC配置:i5 10400F

为了方便测试,所有的测试基于Unity Editor(PC)环境,采用Unity Profiler和Lua Profiler。

Array

以fulluserdata为基础,在C实现了一个Array[double]。

luaarray.h:

#ifndef LUAARRAY_H
#define LUAARRAY_H

#include "lua.h"

/*-------------------------------------------------------------------------*\
* Initializes the library.
\*-------------------------------------------------------------------------*/
LUA_API int luaopen_array(lua_State *L);


#endif /* LUAARRAY_H */

luaarray.c:


#include "luaarray.h"

#define LUA_LIB
#include "lua.h"
#include "lauxlib.h"
#include "lualib.h"
#include "limits.h"

#define ARRAY_TAG "LuaArrayMatetable"

typedef struct NumArray {
    int size;
    double values[1];
} NumArray;

static NumArray *checkarray(lua_State *L)
{
    void *ud = luaL_checkudata(L, 1, ARRAY_TAG);
    luaL_argcheck(L, ud != NULL, 1, "'array' expected");

    return (NumArray *)ud;
}

static double *getelement(lua_State *L)
{
    NumArray *a = checkarray(L);
    int index = luaL_checkinteger(L,2) - 1;

    luaL_argcheck(L,a != NULL,1,"'array' expected.");
    luaL_argcheck(L,0 <= index && index < a->size,2,"index out of range.");

    return &a->values[index];
}

static int newarray(lua_State* L)
{
    int i, n;

    n = luaL_checkinteger(L,1);

    luaL_argcheck(L, n >= 1, 1, "invalid size.");

    size_t nbytes = sizeof(NumArray) + (n - 1) * sizeof(double);

    NumArray* a = (NumArray*) lua_newuserdata(L,nbytes);

    a->size = n;

    for (i = 0; i < n - 1; ++i)
        a->values[i] = 0;

    luaL_getmetatable(L, ARRAY_TAG);

    lua_setmetatable(L, -2);

    return 1;
}

static int setarray(lua_State* L)
{
    double value = luaL_checknumber(L, 3);

    *getelement(L) = value;

    return 0;
}

static int getarray(lua_State* L)
{
    lua_pushnumber(L, *getelement(L));

    return 1;
}

static int getsize(lua_State* L)
{
    NumArray *a = checkarray(L);

    lua_pushnumber(L, a->size);

    return 1;
}

static int array2string(lua_State* L)
{
    NumArray* a = checkarray(L);
    lua_pushfstring(L,"array(%d)",a->size);
    return 1;
}

static luaL_Reg arraylib_f [] = {
    {"new", newarray},
    {NULL, NULL}
};

static luaL_Reg arraylib_m [] = {
    {"set", setarray},
    {"get", getarray},
    {"size", getsize},
    {"__tostring", array2string},
    {NULL, NULL}
};

LUA_API int luaopen_array(lua_State* L)
{
    luaL_newmetatable(L, ARRAY_TAG);

    lua_pushstring(L, "__index");
    lua_pushvalue(L, -2);
    lua_settable(L, -3);

    luaL_setfuncs(L, arraylib_m, 0);
    
    luaL_newlib(L, arraylib_f);

    return 1;
}

对比

local array = require "array"

local num = 1000000

function TestTable()
    local time = os.clock()

    local tb = {}

    local value

    time = os.clock()

    local insert = table.insert

    for i=1,num do
        tb[i] = i
    end

    print(string.format("set table: %sms", (os.clock() - time) * 1000))

    time = os.clock()

    for i=1,num do
        value = tb[i]
    end

    print(string.format("get table:%sms", (os.clock() - time) * 1000))
end

function TestUserdata()
    local time = os.clock()

    local arr = array.new(num)

    local value

    time = os.clock()
    
    for i=1,num do
        arr:set(i, i)
    end

    print(string.format("set array: %sms", (os.clock() - time) * 1000))

    time = os.clock()

    for i=1,num do
        value = arr:get(i)
    end

    print(string.format("get array:%sms", (os.clock() - time) * 1000))
end

print(num) 

TestUserdata()

TestTable()

在完成luaarray.c的实现后,就编写基于两者的对比用例,并且在unity中进行测试。原本以为fulluserdata的实现会比table性能好,最差也是相差无几,但实际情况却出人意料:

方案set耗时(ms)get耗时(ms)
Table249
Array8279

优化Array

从上面对比的情况可以看出,Table远远比Array快,特别在get的情况。在经过一番思考过后,排查出一个问题:那就是Array由于需要区分当前userdata类型,防止意外的内存指针传入导致出问题,所以使用了metatable,所以在调用时,会先判断metatable的情况。以此作为优化目标进行优化:


static NumArray *checkarray(lua_State *L)
{
    
    // 不再检查matetable 
    //void *ud = luaL_checkudata(L, 1, ARRAY_TAG);
    void *ud = lua_touserdata(L, 1);
    luaL_argcheck(L, ud != NULL, 1, "'array' expected");

    return (NumArray *)ud;
}

static int newarray(lua_State* L)
{
    int i, n;

    n = luaL_checkinteger(L,1);

    luaL_argcheck(L, n >= 1, 1, "invalid size.");

    size_t nbytes = sizeof(NumArray) + (n - 1) * sizeof(double);

    NumArray* a = (NumArray*) lua_newuserdata(L,nbytes);

    a->size = n;

    for (i = 0; i < n - 1; ++i)
        a->values[i] = 0;

    // 不再设置metatable

    //luaL_getmetatable(L, ARRAY_TAG);

    //lua_setmetatable(L, -2);

    return 1;
}

//将function赋值给array
static luaL_Reg arraylib_f [] = {
    {"new", newarray},
    {"set", setarray},
    {"get", getarray},
    {"size", getsize},
    {"__tostring", array2string}, //print(a)时Lua会调用该元方法。
    {NULL, NULL}
};

function TestUserdata()
    local time = os.clock()

    local arr = array.new(num)

    local value

    time = os.clock()
    
    local set = array.set --改为array的function,并缓存起来

    for i=1,num do
        set(arr, i, i)
    end

    print(string.format("set array: %sms", (os.clock() - time) * 1000))

    time = os.clock()

    local get = array.get --改为array的function,并缓存起来

    for i=1,num do
        value = get(arr, i)
    end

    print(string.format("get array:%sms", (os.clock() - time) * 1000))
end

测试后,耗时有所改善,但仍然不太乐观:

方案set耗时(ms)get耗时(ms)
Table249
Array (no metatable)4438

可以看出,metatable对于代码效率影响巨大。如果需要标记当前的内存指针是否正确,就需要另辟蹊径。

分析

  1. 首先分析table的get耗时为什么比table的set要高?

这是因为table插入数据时存在扩容的情况。

  1. table的set为什么这么快?

这是有两个原因。
一是示例中的table放入的是number值,number值在table的Value中是以连续内存存在的,这是因为TValue是一个联合体,在number的情况下TValue就保存着值,因此内存是连续的,就不存在缓存不友好的情况。
二是table[x]在运行时是使用OP_SETTABLE和OP_GETTABLE两个指令。而示例中的array.set则需要使用多次OP_MOVE,最后还要调用OP_CALL,在指令中不占优,并且需要频繁操作寄存器导致耗时较大。

local array = require "array"

local num = 1000

local a = {}

for i=1,num do
    a[i] = i
end

main <table.lua:0,0> (12 instructions at 0000000000028a70)       
0+ params, 7 slots, 1 upvalue, 7 locals, 4 constants, 0 functions
        1       [1]     GETTABUP        0 0 -1  ; _ENV "require" 
        2       [1]     LOADK           1 -2    ; "array"        
        3       [1]     CALL            0 2 2
        4       [3]     LOADK           1 -3    ; 1000
        5       [5]     NEWTABLE        2 0 0
        6       [7]     LOADK           3 -4    ; 1
        7       [7]     MOVE            4 1
        8       [7]     LOADK           5 -4    ; 1
        9       [7]     FORPREP         3 1     ; to 11
        10      [8]     SETTABLE        2 6 6
        11      [7]     FORLOOP         3 -2    ; to 10
        12      [9]     RETURN          0 1
local array = require "array"

local num = 1000

local arr = array.new(num)

local set = array.set

for i=1,num do
    set(arr, i, i)
end

main <table.lua:0,0> (19 instructions at 00000000001b8a70)
0+ params, 12 slots, 1 upvalue, 8 locals, 6 constants, 0 functions
        1       [1]     GETTABUP        0 0 -1  ; _ENV "require"
        2       [1]     LOADK           1 -2    ; "array"
        3       [1]     CALL            0 2 2
        4       [3]     LOADK           1 -3    ; 1000
        5       [11]    GETTABLE        2 0 -4  ; "new"
        6       [11]    MOVE            3 1
        7       [11]    CALL            2 2 2
        8       [13]    GETTABLE        3 0 -5  ; "set"
        9       [15]    LOADK           4 -6    ; 1
        10      [15]    MOVE            5 1
        11      [15]    LOADK           6 -6    ; 1
        12      [15]    FORPREP         4 5     ; to 18
        13      [16]    MOVE            8 3
        14      [16]    MOVE            9 2
        15      [16]    MOVE            10 7
        16      [16]    MOVE            11 7
        17      [16]    CALL            8 4 1
        18      [15]    FORLOOP         4 -6    ; to 13
        19      [17]    RETURN          0 1

复杂场景

上述的示例只是一个很简单的场景,在实际开发中,往往会比这个更加复杂,例如实体是一个复杂的结构,可能是面向对象(存在元表),管理时也会对实体增删查改。
而访问元表、table的创建删除、table的扩容都是较为耗时的操作。

800个单位的碰撞测试(未优化过算法,碰撞测试次数在800 * 800 * 0.5 = 320000)。测试场景中只是简单模拟了实体的创建和获取,至于隐藏在实体框架中的耗时并没有包含在测试过程(例如框架的继承需要用到元表、C侧的框架逻辑),因此只是比较了Lua Table和Userdata对于复杂数据的读写耗时。
为了快速验证,本次测试只封装了Component读的方法,写的方法还是采用旧的方案

static int getcomponents(lua_State* L)
{
    NumArray *a = checkarray(L);
    int index = luaL_checkinteger(L,2) - 1;
    int entityGroup = luaL_checkinteger(L,3);  //实际情况下,需要告知实体管理需要获取哪些Component,这里模拟了参数传入

    int start = index * 9;
    int end = start + 9 - 1;

    luaL_argcheck(L,a != NULL,1,"'array' expected.");
    luaL_argcheck(L,0 <= index && end < a->size,2,"index out of range.");

    for (size_t i = 0; i < 9; i++)
    {
        lua_pushnumber(L, a->values[start + i]);
    }

    return 9;
}

方案create耗时(ms)碰撞检测耗时(ms)
Table(结构)141
Table(连续内存)141
Component131

可以看出来,Lua Table在复杂情况下是比userdata要更耗时的,这也比较好理解,userdata一次性全返回了需要的Component属性,这样子就可以减少函数调用和参数入栈出栈次数,而table不管是结构和连续内存,都需要重复的压入索引或获取返回值,这样OP_SETTABLE和OP_GETTABLE的优势将荡然无存。
假设再加上实体框架的逻辑,那么userdata方案将会比table更有优势,因为lua侧只能是用table和metatable来进行管理,而userdata将大量的实体管理逻辑隐藏在C侧。

最新文章

分类

关于

记录游戏开发的点点滴滴