Lua 性能剖析

栏目: Lua · 发布时间: 6年前

内容简介:Lua 性能剖析

引言

Lua语言在游戏行业大受欢迎,因运行效率高(相比于其他脚本语言),热更方便等原因被广泛应用。在IEG,情况略有不同,C++大行其道。有的小伙伴(包括本文作者)想在现有c++系统中引入lua,被挑战的第一个问题往往是:“Lua性能怎么样?”

一份测试结果 显示,C的性能是 Lua 的30倍,

Lua 性能剖析

我们自己也很容易粗略的构建这样的性能对比例子,比如笔者曾经做过的:

Lua 性能剖析

分别调用1000万次,lua的执行时间在C代码执行时间的20~40倍之间浮动,基本和Lua慢30倍的结论吻合。

问题来了,Lua为什么这么慢,会不会有些使用不当的坑,踩了以后,连慢30倍都是奢望?怎么使用lua,才能尽可能避开性能缺陷,发挥灵活的长处?

笔者研究了lua 5.3.4的代码,分析lua性能比C慢的原因,对Lua使用过程中可能碰到性能问题和解决方案也有部分阐述。

本文所有的测试都是在Intel Xeon E312xx 2.7G/linux的环境下完成。

Lua的基本类型

粗略的说,lua有8种类型,

nil, boolean, number, string,

function, userdata, thread, table

nil是空类型,表示什么都不是,

number在内部实现中区分为整形和浮点型,

function有三个子类: C Function, Lua Function和light C Function

userdata有两个子类:userdata和light userdata

thread就是lua中的协程

table是lua中唯一的聚合类型,不像c++的STL那样,拥有vector、map、set等多种容器,在lua中,只有table。

这8种类型以union的形式定义在TValue中

typedef union Value {
  GCObject *gc;       /* collectable objects */
  void *p;            /* light userdata */
  int b;              /* booleans */
  lua_CFunction f;    /* light C functions */
  lua_Integer i;      /* integer numbers */
  lua_Number n;       /* float numbers */
} Value;
typedef struct lua_TValue {
   Value value_;          //value具体的数值
   int tt_                //value的类型
} TValue;

nil, boolean, number和lua_CFunction直接存储在TValue中,占用至少12个字节。

string, lua function, userdata, thread和table这些可以被垃圾回收管理的类型,TValue只是索引,具体数据存储在堆上,被gc指针索引。

下面重点介绍table的实现和性能。

Table的实现

Table对外的表现是一个Key-Value的Hash容器,除了nil以外的任意lua基本类型都可以做Key, 所有的基本类型都可以做Value。

Table是动态的,随着元素的添加或者回收增长或者缩小。在Lua 4.0之前,Table是严格的按照Hash的方式实现的,后续版本为了提升性能和节省空间, Table内部重构为数组部分和Hash部分两块。

Lua 性能剖析

typedef struct Node {
  TValue i_val;     
  TKey i_key;  //包含next
} Node;

数组部分和Hash部分的长度都是2的指数次方,如果需要扩张,就会调用realloc,内存扩张一倍。Hash部分闭散列,发生冲突的时候会在Node数组中寻找一个空闲节点串起来。

数组部分的key为[1, 2^n -1],,要求有至少一半的利用率。

a={}
a[10000]="hello,lua"

这上述示例代码中, a表不会把"hello,Lua"放在数组部分,因为利用率太低了,而是把它放在hash部分,10000这个数字作为key。如果后面a表逐渐插入了1到9999的元素, "hello,lua"会在rehash的时候被搬移到数组部分。

默认创建出来的的表,都是空的,在插入元素的过程,逐渐翻倍扩大,从0到1, 1到2,2到4,...都会触发realloc,同时把旧元素拷贝到新申请的空间中,对于最终有成千上万个元素的table,扩张的开销可以接受,但是对于大量生成小的table的场景,会明显拖慢性能,可以通过lua的构造函数,让Lua的编译器预分配空间,比如下面的代码:

Lua 性能剖析

Hash部分的扩张也是同样的情形。

Table查找性能

位于数组部分的元素,直接用整数Key做下标到数组中去就可以拿到元素。Hash部分的查找需要经过hash运算和 TValue判等运算,对于lua_number和table/function/userdata, 这都不是问题。对于string,lua做了一点优化。

Lua 性能剖析

所有的短字符串(40字节以内),在Lua内只存储了一份,提前算好了hash值,

Lua内部增加一个string table,这是一个Hash表,所有的短字符串都存储在这里,每次创建一个新的短字符串,都会先到这个表里面查找是否已经存在,如果存在就复用,如果不存在,就在这个表里添加新项。冲突的字符串用链表串起来。

Lua 性能剖析

如果string数量超过Hash桶的1/2就把Hash桶数量加倍,然后rehash。

所以短字符串发生Hash值一致时判等只需要比较指针是否相同,这优化了查找,但是增加了创建和回收字符串的成本。

Table空间占用对比

前面分析提到,lua中的基本类型,至少也要占用12个字节。应用程序把从C切换到Lua,内存占用会如何呢? 通过下面的比较,大概可以有个结论。

在程序中存储一个多边形的所有的顶点,假定这个多边形有100万个顶点,用3种Lua的表达形式和C做对比:

Lua 性能剖析

在上面的例子里,相比于C, Lua消耗的空间增加了5倍也是很正常的事情。很多业务可能对内存增长不敏感,但是在设计时,需要考虑到这个变化。

Lua的基本执行流程

Lua 性能剖析

虚拟机的初始化和编译Lua源码一般发生在系统启动初期,对运行时的性能没有影响。本文把分析重点放在虚拟机的运行上。

Lua 5.3.4包含47条虚拟机指令, 比如创建一个表( OP_NEWTABLE ), 执行一次循环( OP_FORLOOP ),从表中查找一个元素( OP_GETTABUP )。

可以看出,虚拟指令的功能粒度很粗,主要是为了降低编译器负担,把性能优化的工作交给虚拟机做。

虚拟机的主要构造

Lua 性能剖析

typedef struct CallInfo {
  StkId func; 
  StkId top
  struct CallInfo *previous, *next; 
  …
} CallInfo;

global_State 把所有可以垃圾回收的对象都串在一个链表里面管理

Lua用一个数据栈和一个调用双向链表(CallInfo)实现了类似 C语言 中的堆栈的功能。

数据栈是C数组,会动态的增长和回收,不够的时候就realloc, 把栈空间扩大一倍。

Lua函数调用会触发数据栈栈顶的增长和CallInfo增加新节点, 函数return的时候执行相反的操作。那么Lua函数调用的开销性能如何呢?

Lua函数调用的性能

通过下面的测试代码, 对比C和Lua函数调用的开销,可以看出Lua函数调用开销是C的30倍。C代码加O2优化后执行时间不足1ms, gcc编译器已经可以看出测试代码中的调用是没有意义的,自动优化掉了。 Lua编译器远达不到这么好的优化程度。

Lua 性能剖析

Lua频繁函数调用一个典型例子就是网络包的编解码。有两种方案可以供对比:

方案1中,C不需要理解数据的描述信息,只提供解码基本类型的函数,由Lua来调用(Lua调用C会引起Lua数据栈和CallInfo的增长和回收)。

Lua 性能剖析

TDR是互娱研发部发布的数据描述组件,类似google protobuff。

方案2中, C理解网络包的数据描述信息,然后调用Lua C API(不会引发Lua堆栈的变化)构造最终的解码结果。

Lua 性能剖析

两种方案的性能上本质差别在于Lua调用和C调用开销的差别。前面提到,Lua调用性能开销是C的30倍。据某项目的实践,用方案1实现的开销是方案2的30倍左右。

Lua中的全局变量存取

了解了Lua的全局变量存取过程的细节,就会明白为啥全局变量存取性能低下的原因了。

下面的表格对比了全局变量存取和local变量存取的区别:

Lua 性能剖析

全局变量涉及的到表的查询和修改,所以性能要显著差于local变量。简单的性能测试也可以看出来。

Lua 性能剖析

协程切换的性能

Lua的协程是一个lua_state, 有自己的栈和CallInfo,所以协程切换完全没有使用系统相关的调用,如ucontext或者Fiber,实际测试显示,协程的切换cpu消耗和ucontext差不多。测试代码如下:

static ucontext_t uctx_main, uctx_func1;
static void func1(void){
    while(1)   swapcontext(&uctx_func1, &uctx_main);
}  
int main(int argc, char *argv[]){
    char func1_stack[16384];
    getcontext(&uctx_func1);
    uctx_func1.uc_stack.ss_sp = func1_stack;
    uctx_func1.uc_stack.ss_size = sizeof(func1_stack);
    uctx_func1.uc_link = &uctx_main;
    makecontext(&uctx_func1, func1, 0);  
    for(int i = 0; i < 10000000; i++)
         swapcontext(&uctx_main, &uctx_func1);
    exit(EXIT_SUCCESS);
}
c = coroutine.create(function ()
        while true do          
            coroutine.yield()  
        end end)                   
for i = 1, 10000000 do         
    coroutine.resume(c)
end

1000万次的协程切换,ucontext需要4.05秒, lua需要4.33秒,没有显著差异。

垃圾回收

垃圾回收一直默默在后台工作,一般情况下,对使用者是透明的。但是这不意味着垃圾回收的成本是完全可以忽略的。有时候垃圾回收也会严重干扰系统性能。

在Lua 5.0版本中,垃圾回收采用的是双色标记清除算法,

Lua 性能剖析

新生成的可垃圾回收对象(后面简称GCObjct, garbage collectable object)都被标记为白色,垃圾回收启动后,会从全局表和Lua栈出发,把所有可以到达的GCObject全部标记为黑色,标记完成后,把所有保持白色的GCObject释放掉,然后把黑色GCObject全部改成白色。

双色标记清除算法简单、高效,但是垃圾回收的过程必须一次性完成,回收时,业务代码必须等待,在一些实时性要求较高的应用场景,比如游戏,并不适用。所以5.0以后的代码采用了三色标记清理算法。

Lua 性能剖析

新生成的GCObjct都被标记为白色,垃圾收集阶段,先把根节点置成灰色,然后遍历灰色GCObject链表,如果灰色节点的所有1级子节点都被放入了灰色链表,就把这个灰色节点置黑,反复遍历灰色链表,直到灰色链表为空。接下来就和双色算法类似了,清理白色节点,然后把黑色重新变白。

三色算法的优点在于,算法是可以分步慢慢执行的,不需要像二色算法那样一下子占用太多的cpu时间。如果在垃圾回收的过程中,发生了白色节点加入到了黑色节点的操作,要么把白色节点变成灰色,要么把黑色节点变成灰色。

查阅代码可以看到,垃圾回收操作触发时机是在执行虚拟指令OP_NEWTABLE 、OP_CONCAT、 OP_CLOSURE的时候, 简言之,就是系统分配内存的时候可能会触发垃圾回收

collectgarbage函数可以强制Lua单次完成垃圾回收的全过程,通过下面的测试代码,可以窥视一下垃圾回收的消耗。假定1个Player有10个属性,50个物品,每个物品有10个属性,控制player的数量,测试垃圾回收的消耗。

player={}
player.name="abc"..math.random()
player.level = 10  player.faceid=3 player.hp = 1000               
player.mp = 1000 player.headid = 3  player.gender = 3              
player.money = 30000   player.hornor = 300  player.pk = 300                
player.itemlist = {}           
for j=1, 50  do                             
    item={}                    
    item.attr1="red"    item.attr2=5    item.attr3=1
    item.attr4=1    item.attr5=5    item.attr6=5              
    item.attr7=1    item.attr8=1    item.attr9=1  item.attr10=1      
    player.itemlist[j]=item 
end

Lua 性能剖析

垃圾回收的复杂度是O(n),2万个player, 约100万个可垃圾回收对象,1秒钟只能完成3次全量垃圾回收。虚拟机内GCObject的数量越多,垃圾回收的性能负担越大。

关于垃圾回收优化,可以考虑以下几个方向:

(1)根据应用特点,业务自己控制垃圾回收的启动和关闭

(2)回收参数微调

每次回收的步长

再启动清理的间隔

(3)降低垃圾生成速度,尽量复用对象,避免无谓的垃圾产生。

比如把循环中公用的临时变量提到循环体外。

Lua 性能剖析

总结

本文通过分析lua 5.3.4的源码,对笔者感兴趣的一些影响Lua性能的知识点做了分析和评测,主要包括table实现,函数调用,变量存取,协程和垃圾回收。

使用Lua的时候,要小心的避开性能热点,比如频繁的Lua调用和大量的GCObject垃圾回收,扬长避短,即使是比C慢200倍的 python 也一样在游戏业界广泛使用,所以lua没有习惯了c/c++的 程序员 想的那么差。

如果对30倍的性能落差还是觉得不舒服,可以考虑Luajit, 一个比lua官方实现快了5倍的第三方实现。luajit只支持lua 5.1语言,而且现在已经不更新了。

参考资料

http://www.lua.org/ftp/lua-5.3.4.tar.gz

The Implementation of Lua 5.0

Lua Performance Tips

A No-Frill Introduction to Lua 5.1 VM Instructions

Lua源码欣赏


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

金融计算与建模

金融计算与建模

朱世武 / 清华大学 / 2007-8 / 40.00元

《金融计算与建模:理论、算法与SAS程序》全书分为4大模块:1-9章为金融学基础指标计算模块;10-12章为股票定价模块;13-18章为风险度量模块;19-23章为固定收益定价模块。每一模块的内容一般由三部分组成:金融理论与模型、算法实现及计算程序。其中,算法实现与计算程序全部以中国金融市场的实际问题为应用背景而设计。《金融计算与建模:理论、算法与SAS程序》不仅展现了应用SAS软件的技术,同时也......一起来看看 《金融计算与建模》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具