一个170倍内存的优化
某一天,光子的一位童鞋突然拉了个小群,发了一段代码,然后发了几个测试数据,说测试结果和预期严重不符。大有一副“兴师问罪”的样子。
他们的测试代码是这样的:
测试数据是
- 10w次内存27.9MB
- 100w次内存135.5MB
- 1000w次内存1027.8MB
我刚好当年测试过类似的用例,当时1000w当时800MB左右,我当时用的三个字段都是数字,光子童鞋其中一个字段用的是变长的字符串,多个200MB不过分吧?
我当时进入了思维误区,以为是过来兴师问罪,自然而然的认为内存占用过多了,继续交流下来才搞清楚是内存占用比预计低太多了,10W次的用例,光是字符串预计就占用4.65G。作为对比测试的unlua,在10W次的用例就崩溃了。预计指数级增长的用例,puerts测试到1000w内存占用还不到1个G。他们严重怀疑是测试代码写错或者获取内存占用数据的方式有问题。
很快地排除了测试代码本身的问题。于是数据获取方式也可以排除了,因为按那种估算,测试到1000w的时候世界任何一台电脑都会OOM。
所以基本上可以确定v8内部实现不是我们想象那样:字符串链接后的新字符串会保存所有字符。应该做了某种优化。
得益于网上有很多v8相关的分析文章 ,google搜索“v8 字符串”关键字,轻松就找到答案,比如这篇 ,看ConsString的部分,字符串连接后,产生的是一个ConsString实例,该对象仅需要两个指针指向被连接的两个字符串即可。最终测试用例那些字符串只需要一棵二叉树就可以表达。
对象存储效率对比分析
很早期我就做过这么个对比测试(对比的是lua54,而lua53的内存占用更高):
不少人问过为啥会有这差距,我最近和一位童鞋交流时写了段伪码来解释:
lua类似这样:
代码语言:javascript复制hashmap obj1;
obj1[key1] = value1;
obj1[key2] = value2;
hashmap obj2;
obj2[key1] = value1;
obj2[key2] = value2;
//obj3, obj4...
而v8类似这样
代码语言:javascript复制hashmap sharp;
sharp[key1] = offset(ObjectType.value1);
sharp[key2] = offset(ObjectType.value2);
struct ObjectType {
hashmap* ptr_to_sharp;
type1 value1;
type2 value2;
};
ObjectType obj1(&sharp, value1, value2);
ObjectType obj2(&sharp, value1, value2);
//obj3, obj4...
区别在于lua的table(就一个hash表)需要存储key,value,而且hash表为了减少hash冲突,减少扩容次数(扩容记得是翻倍递增),往往会有一定的空间浪费。
而v8相当于每个对象只紧凑存储了value,所有同结构对象共享一套key到value的偏移信息,而且不同结构的偏移信息还可能可以继承,比如程序存在{x:0,y:0}和{x:0, y:0, z:0}两种对象,后者是前者的基础上增加z字段的偏移信息。
那lua比v8更耗内存?
也不是,得看场景。
启动一个v8虚拟机的基础开销(1~2M堆内存)要比lua(20K 堆内存)高,jit也有额外内存开销,所以很简单的逻辑,没有常驻内存的数据,lua会更有优势。
和一些重度使用lua脚本的游戏交流,有的项目能占到200~300M,有的项目会把策划配表加载到内存,光是策划配表就有80M,这时基础内存的占比就几乎可以忽略了,而虚拟机的一些内存使用效率优化的作用会凸显出来。
ConsString实际上极少那么极端的使用场景,影响不会有开篇那测试那么可观。而游戏中的策划配表,常用的面向对象编程,都会有数量比较多的同结构对象,v8这方面的优化感觉还是能节省下比较可观的内存。
还有一个不容忽视的事实是,v8的gc有做内存整理,而lua没有。lua长时间运行,除了纸面上能统计到的内存占用,还有那些虽然空闲,但因碎片化而使用不了的隐性占用。代码量小不等于内存少,事实上很多内存方面的精细化管理就需要复杂的代码来支撑。
但也不能因而得出大应用v8占优的结论,这和你的代码是怎么写有很大关系。具体问题具体分析,没有放之四海而皆准的结论。
性能测试建议
文章开头的光子童鞋做的是选型评估,除了内存,也包括性能的测试。
puerts基本不会和其它不同语言的方案去对比性能,但同时我也十分理解做技术选型的童鞋做性能对比,毕竟这几乎是唯一可量化的对比。我只是想提几点建议。
别只关注跨语言
我觉得这是最重要的建议,我看到的几乎所有对比测试都是着重于“跨语言”测试,我觉得很不合理,我觉得设计良好的代码,脚本的大部分代码应该都是在虚拟机内部运行,互相调用,跨语言占比比较少。因而虚拟机本身的性能对业务更为重要。
早期各种lua方案间对比不需要对比虚拟机。但不同虚拟机还只关注跨语言,很可能会导致错误的导向。
也有部分选型测试测了虚拟机,但往往偏简单了:有的项目仅仅测试个fib,或者测试个加减乘除。业界也有语言性能对比方面的用例,可以参考下,比较常用的看这里,这些测试的主流语言实现看这里 。
还有不容忽视的GC的影响,做过unity开发的都会谈GC色变。GC的影响我觉得可以测试这两方面:
- 常驻对象的影响
- 临时对象的影响,Unty C#大家说的GC问题主要是这个,而lua没有分代GC的版本这块也是弱项,极端能让程序性能降几个数量级。lua5.4加入了分代GC,没仔细研究不做评论。
用例正交完备
何谓正交完备?
完备很好理解,就是覆盖要全,比如跨语言调用测试,要测试方法,属性,静态函数等等,数据类型要覆盖各种常用的类型。如果方案提供了多种调用方式(比如puerts同时提供了反射和静态两种),也建议都测试下。
正交,指的是这个用例测过了,别的用例就别测试了,每个用例的测试点不一样。
为何正交完备?
完备的用例有助于更全面的衡量,正交节省测试用例编写,也让测试数据更聚焦。
正交完备的用例不仅可以作为选型参考,还能指导我们后续的生产:
- 性能估算:用过基础数据估算复杂接口的性能,比如可以粗略的认为:void (int, Vector) = void (int) void (Vecot) - void ()
- 为性能优化提供方向:如果一个Vector传输远大于3个float,那么在性能要求高的地方,可以把Vector参数改为3个float。
不过实践中,可能由于工作量的原因,很多项目的测试并不完备,这完全可以理解。完备但不是很正交的也可以理解,可能过于谨慎细致了。
但既不完备,也不正交,这就有点奇怪了:按说没时间,所以写不来那么多用例导致不完备,但又花大量时间反复的测一个数据类型是咋回事?我看到一套用例三分之一函数调用都在测试TArray引用,恰好用来测试的puerts版本TArray引用有点问题,比较慢。难道他们业务代码就是重度使用TArray,这样测试能衡量业务实际情况?于是我挺好奇的去了解下这是个什么样的业务,交流下才发现用例是源自unlua,这运动员兼职裁判兼得可以。
真有卷性能的必要么?
不是怕卷
在一套客观完备正交的用例下测试,puerts并不虚其它方案,事实上在大部分我所知的UE项目选型测试中,puerts整体性能占优。
我十分欢迎任何人搞这种PK,甚至疑似“不客观”的用例也帮助puerts发现了不合理的实现。
但我自己不会花精力在这块。
所谓质量,就是满足需求
仍是以那个TArray引用的问题为例,老版本存在值拷贝(new一个TArray实例,并把元素拷贝到新实例)两次的行为,改为传指针后测试数字上差距很大。但并没有项目真实使用中反馈之前的版本这块慢了,包括一些很重度使用ts的项目。
究其原因,要么都没用到(可以排除),要么那数字上的差距并没带来能感知的卡顿。这也是为啥一直没发现这两次值拷贝的原因。
我一直认为,性能够用的情况下,测试数字上提升带来的价值不大,当年华为请来的一位质量管理大师说过:“所谓质量,就是满足需求”。我深以为然。
跨语言这块,ue下的puerts的反射都比xlua的静态封装更快,而且puerts还有更快的静态封装。这是我对于ue下puerts跨语言“够用”的依据。
v8虚拟机性能“够用”的依据更多是来自“信任”:
- v8在业界素以性能著称,超越不了所有脚本但也妥妥一线,猪场的荒野行动还用python呢
- 相信大佬,v8项目的组长Lars Bak此前做过多个虚拟机(其中一个是java虚拟机,担任技术负责人),也相信以技术著称的Google背书。
- 相信v8每天经过那么多代码,那么多平台的运行,不合理的性能短板应该都被发现并解决了。
不同虚拟机可比性不高
此类方案做的最主要的事情是跨语言访问,有人会把这块性能归功于“优化”得好,但这块很大程度上受限于脚本引擎提供的api以及语言特性,分别举个例子:
- lua获取一个lua字符串的api,只仅仅返回一个内部的字符串指针,而v8却得你自己分配一段内存,让v8把字符串拷贝到该内存。
- 引用参数的处理,在lua由于支持多返回值,引用参数输出时可以作为一个返回值,而js没有,puerts把参数装箱到一个js对象中,返回时把输出放到这个js对象,这意味着多了一个js对象的创建。
即使同样是js,不同实现提供的接口差别很大。比如苹果的jscore每个api全局加锁,它和原生交互就比v8要慢一个数量级,而v8嵌入api基本不会对外暴露数据结构,也不会让外部直接持有指针,而是通过句柄持有,传输数据用值拷贝。。。这些设计让其API相对lua会慢些。
别忘了还有C
最后,如果性能不够用?性能要求高的地方为啥不直接用C 呢?从实践来看,性能要求高的地方往往需要更新的概率低。
ps,不久puerts会全平台支持wasm,到时会有更高性能的热更新选项。
何必卷纸面性能
性能不能满足需求时,券性能对项目是有帮助的,超越了需求的性能提升,我更愿意称之为纸面性能。
当然,是不是纸面性能也不是我说了算,如果项目使用的过程,发现哪些地方的性能“不够用”,我们也会快速响应及优化。我希望用具体需求来驱动优化而不是PK驱动优化。
与其卷纸面性能,不如puerts把更多的精力投在其它方面,比如:
- 对接好v8现有的工具链,让用户在编码,调试,自动化测试等有更好的体验;
- 帮助用户写更健壮的代码,比如加入减少野指针访问的对象声明周期跟踪功能,比如更完美的支持ts的静态类型检查;
- 尝试提升和UE引擎配合度,降低UE程序员的使用门槛
- 尝试复用nodejs生态海量组件(实现cjs规范,甚至是直接对接嵌入式nodejs),让用户更专注于业务逻辑;
- 尝试引入web领域,特别是UI界面方面的成功实践,比如react;
- 将会尝试探索puerts在编辑器扩展上最佳实践;
- 将会尝试营造一个游戏专属的组件生态;
- 。。。
总结下观点
- 我们的性能测试往往忽视了最重要的部分:虚拟机性能
- 即使是大家最关注的跨语言测试,也往往做不到完备、客观
- 除了性能,内存也非常关键,甚至更关键,个人排序:内存 > 虚拟机运算性能 > 跨语言
- 性能满足需求后,纸面数据的提升并不能带来太多价值,应该多关注其它方面
- 以用户价值驱动优化
有时我会反感此类测试,就好比评价一个手机的好坏仅仅看CPU跑分(无视了GPU,内存等各种其它硬件,也不关心设计,稳定性,质量等等),而且往往该跑分的程序还不足以全面衡量CPU。对于决策者是省事,用小学算术就能做决策,但这未必和实际使用相符,而且容易被钻空子:做手机的针对跑分程序优化显然最划算,如果跑分程序是自己写的就更稳了。