Lua 性能剖析

引言

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

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

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

分别调用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部分两块。

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的编译器预分配空间,比如下面的代码:

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

Table查找性能

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

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

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

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

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

Table空间占用对比

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

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

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

Lua的基本执行流程

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

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

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

虚拟机的主要构造

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频繁函数调用一个典型例子就是网络包的编解码。有两种方案可以供对比:

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

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

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

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

Lua中的全局变量存取

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

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

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

协程切换的性能

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版本中,垃圾回收采用的是双色标记清除算法,

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

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

新生成的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

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

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

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

(2)回收参数微调

每次回收的步长

再启动清理的间隔

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

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

总结

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

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

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

参考资料

  1. http://www.lua.org/ftp/lua-5.3.4.tar.gz
  2. The Implementation of Lua 5.0
  3. Lua Performance Tips
  4. A No-Frill Introduction to Lua 5.1 VM Instructions
  5. Lua源码欣赏

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

种道伟的专栏

1 篇文章1 人订阅

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏非典型技术宅

Swift多线程:使用Thread进行多线程间通讯,协调子线程任务1. Thread的三种建立方式2. Thread的基本使用3. 使用NSCondition实现线程间通讯4. pthread

1052
来自专栏Jackson0714

PHP内核之旅-3.变量

2706
来自专栏青枫的专栏

51单片机数据传送指令

903
来自专栏Golang语言社区

设计Go API的管道使用原则

管道是并发安全的队列,用于在Go的轻量级线程(Go协程)之间安全地传递消息。总的来讲,这些原语是Go语言中最为称道的特色功能之一。这种消息传递范式使得开发者可以...

3506
来自专栏me的随笔

【译】单元测试最佳实践

原文地址:Unit testing best practices PS:本文未翻译原文的全部内容,以下为译文。

883
来自专栏大史住在大前端

javascript基础修炼(7)——Promise,异步,可靠性

Promise技术是【javascript异步编程】这个话题中非常重要的,它一度让我感到熟悉又陌生,我熟悉其所有的API并能够在编程中相对熟练地运用,却对其中原...

845
来自专栏java达人

java与ruby的直观比较

以前花过一段时间学习ruby,还顺便写了个股票爬虫,搭了数据展示网站,这次回顾一下以前的知识点,将ruby与java作个简单而直观的比较,这样有助于更深入地理解...

1877
来自专栏木可大大

编写优雅代码的最佳实践

Robert Martin曾说过"在代码阅读中说脏话的频率是衡量代码质量额唯一标准"。同时,代码的写法应当使别人理解它所需的时间最小化,也就是说我们写的代码是给...

41920
来自专栏Java帮帮-微信公众号-技术文章全总结

【选择题】Java基础测试题一(10道)

【选择题】Java基础测试题一(10道) 1.下面哪些是合法的变量名? (DEG) A.2variable //不能以数字开头 ...

3938
来自专栏阿杜的世界

使用SA分析内存溢出问题背景例子程序方式方法实践参考资料

在阅读《Java性能调优指南》一书的最后,书中介绍了Serviceability Agent,并给出了一些排查问题的示例,我感觉看书不够深刻,因此自己在macO...

812

扫码关注云+社区