在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
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;
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语言,而且现在已经不更新了。 参考资料 |
请发表评论