很早以前,我就在 Linux 上使用 /proc/、top、sar 等工具来排查问题,却从未意识到,“观测”竟然是一门独立的学问。
回顾我早期的编程经历,在设计模块时,我总是本能地选择时间复杂度最低的已知算法。
因为我并不知道这个模块的性能会对最终系统造成多大影响,只是凭着一种“贪心算法”的思维尽可能提升程序性能——也正是这种心态,造就了我后来的“性能强迫症”。
结果就是,代码变得不够简洁,而对整个系统性能的提升却微乎其微,甚至可能根本没有提升。我最近的重构再一次验证了这一点。
并不是我不想评估模块对系统的实际影响,而是现实中的负载情况实在太难模拟。
你测试出来的结果,往往和真实线上环境天差地别。多年来我也请教过很多人,但始终没有找到特别令人满意的答案。
在面对一个有问题的运行环境时,我们可以借助一些分析工具快速定位到热点函数,但这些工具通常都有一个弊端:它们往往会 Stop The World —— 比如 gdb、gprof 等。
然而线上问题通常无法容许我们用 Stop The World 的方式进行排查。
这也正是“可观测性”弥足珍贵的原因之一:当系统出问题时,我们可以通过系统本身提供的可观测能力,去追踪和理解到底发生了什么。
不得不佩服 Linux 的设计者们,/proc 文件系统的设计在多年以前就已体现出极强的可观测性理念。
我并不想讲怎么样实现可观测性,毕竟我不是专家。
但我想谈谈观测给了我们一个什么样的视角。
想象一下,不管在任何工厂或车间,总会有各种各样的仪表盘。
一方面,说明这些仪表盘设计的非常合理, 另一方面,说明一个复杂的系统仅在宏观上暴露一些参数指标,足够我们排查很多问题。
而会看仪表的人并不需要对整个系统的细枝末节完全理解。
这从侧面也说明了,当我们通过观测来排查问题时,并不需要一上来就去了解整个系统的实现细节,从宏观视角就可以排查很多问题。
这一点很重要,前面铺垫了这么多,都是为了这个观点。
我也是这两年看完《性能之巅》才慢慢领会到的, 来举几个这两年真实发生的例子。
一次线上更新之后,一台机器的负载飙升到了 14.84,整台机器几乎被卡爆。将刚更新的程序进程杀掉之后,负载才明显下降。
运维怀疑是我们最近更新的代码引入了 Bug,要求我们自查代码。这是合理的猜测,也是最常见的原因。
巧的是,我这两年刚好研究完《性能之巅》,一直没什么机会实践,正所谓“拿着锤子到处找钉子”。
我首先用 top 查看了该进程的 CPU 和内存使用率,发现都只有 1.0% 左右,显然负载问题并不在计算上,初步判断是 IO 引起的。
于是我使用 iostat 1 查看磁盘负载,发现 %iowait 高达 75%,但 kB_read/s 和 kB_wrtn/s 加起来也才 200 左右。
结合这些数据,我怀疑是硬盘出现了问题。果然,负责该程序的同事自查代码后并未发现明显异常。最终联系云服务商确认,确实是他们的硬盘出了问题。
另一次是在某个凌晨,游戏服务器访问数据库时出现了 i/o timeout 错误。这里的 i/o timeout 是网络连接层的报错。
但如果网络连接迟迟没有返回,理论上也可能是数据库处理被卡住了。
我通过 Prometheus 查找出故障时段的监控数据,发现读取速度仅有 500KB/s 左右,而 IOPS 并无明显变化,但单次 IO 读取的耗时却突然飙升到了 200ms。
综合来看,这很可能又是一场硬盘故障。与云服务商确认后,他们也承认是由于存储集群的某种原因触发了自动切换,导致了 IO 抖动。
上面两个案例中,我在分析问题时并没有去看程序的代码,甚至第一个程序的源码我根本没有阅读过。
这正说明,通过一些宏观指标,其实就可以定位出许多问题。不过,这两个案例还没有很好地凸显“视角”这个关键概念。
最近,我尝试对一个 Lua 程序的内存使用进行调优。同样地,我也没有看过这个 Lua 程序的源码。
我们可以假设这个程序只有一个接口 function foo(xx),foo 接收参数并返回计算结果,过程中会产生一堆垃圾对象。
也就是说,在调用 foo 前后各执行一次全量 GC,程序的内存使用应该没有任何变化。
在调优过程中,我发现每次调用 foo 函数之后,手动执行一次 lua_gc(LUA_GCSTEP, 10*1024),可以将程序的内存峰值从 560MB 降到 260MB。
根据 Lua 手册中对 LUA_GCSTEP 的说明,10*1024 相当于让 GC 系统分配约 10MB 的对象。
不过我一开始测出的是一个错误的数据:我认为 foo 每次调用都会产生 50MB 的垃圾,其实只有首次调用才会产生这么多。
我原以为程序在每次调用 foo 后都会生成 50MB 的垃圾对象。但根据手册和我之前阅读 lgc.c 的经验,我始终无法理解:为什么在程序初始内存为 210MB(即执行 dofile("main.lua") 后)时,手动调用 lua_gc(LUA_GCSTEP, 10*1024) 居然能把内存峰值降低超过 300MB?
根据默认 GC 参数,只要你分配了 100MB 内存,它就会尝试去 Mark 约 200MB 的对象。也就是说,为了 Mark 完 210MB 的初始内存,理论上需要实际分配 105MB。
我虚假分配了 10MB,加上 foo 每次调用产生的 50MB,就需要实际再分配大约:105 / (50 + 10) * 50 = 87.5MB的内存,才能完成对所有存活对象的标记。
而根据 Lua 的增量 GC 实现,只有当对象被全部 Mark 完后,才会进入清理阶段。
因此,在进入清理阶段前,内存峰值至少已经达到了 297.5MB。而我还没有考虑,LUA_GCSTEP 实际上是要在 foo 分配完 50MB 之后才会执行,另外还有 SWEEPCOST 带来的额外内存负担。
按默认 GC 策略,GC 会等到内存分配到 297.5 * 2 = 595MB 时才会启动下一轮 GC,这个计算远远高于我的观查到的数据。
我和两位同学讨论了一下,他们都认为是 Lua 业务逻辑(非 VM 实现)干扰了 GC 行为,让我去分析业务代码。
但我并不这么认为——不管业务逻辑怎么写,在 GC 系统的视角下,它的行为是稳定的,它始终以内存分配量的 2 倍作为标记对象的阈值。
于是我逐步打印了每次调用 foo 后的内存变化,最终发现我原来的测量是错误的。实际上,只有第一次调用 foo 才会分配 50MB,后续每次调用只产生约 0.8MB 的垃圾。
这就合理了:调用 foo 十次产生约 8MB 的垃圾,加上我虚假分配的 10MB,共计约 108MB,就足够触发 GC 标记完 210MB 的内存。此时内存峰值大约为:210 + 0.8 * 10 ≈ 218MB。
考虑到 GC 步长、首次 dofile 后仍残留的垃圾、以及 foo 首次调用产生的内存并非全是垃圾等因素,这个数字已经非常合理了,至少他是可解释的。
之所以举这三个例子,是希望引出这样一个宏观的抽象视角:
在一台计算机硬件上,运行着操作系统;在操作系统之上,运行着我们的应用程序;而我们的应用程序在设计上,往往又被进一步划分为多个抽象层。
当我们在排查问题时,可以尝试忽略其他抽象层与具体实现的干扰,只集中在某一个切面(Aspect)来分析问题究竟出在哪一层。
一旦我们确定了问题所在的抽象层,再去深入分析其具体实现细节,这将大大降低问题分析的复杂度,也能帮助我们更系统、更高效地定位和解决问题。
]]>我们的游戏服务器程序是采用Go程序编写的,后面在经过各种努力之后,终于将启动内存从350M降低到150M左右。
但是上线之后,奇怪的事情发生了。
由于一些原因,我们使用了两个云服务商来部署服务器。
一个空的游戏服进程, 在其中一个服务商的ECS上表现正常,即是正常的150+M内存, 但是另一个云服务商的ECS上,则启动内存有240+M之多,整整差了80M。
这我觉得非常诡异,我先是使用Go语自带的pprof拉了一下heap数据。两个进程的内存分配信息虽然不能说完全相同,但是也相关不大。
当然我知道,pprof是建立在应用程序的基础上进行的,也就是说内存管理那一块,其实是没法暴露出来的。
比如,在malloc之后,虽然调用了free, 但是你去top这个进程,一般来讲RES列不会立即变小,这和内存归还策略有关。
比如,每个操作系统线程也占了4M栈内存, 但是两个进程的线程数量并没有显著不同。
再比如,由于goroutine的调度问题,导致分配和释放顺序的差异造成的内存碎片率不一致,内存管理库有可能因为碎片的原因,无法归还大块内存。我查看了一下两台机器的CPU和内存,果然不一样。但是我仔细分析了一下,进程在启动阶段开启会分配内存的goroutine只有2~3个,理论上来讲就算对碎片率有影响,那也是有限,不可能会造成如此显著的差异。
于是我想,也许是进程打开了很多数据表文件,加载之后没有释放。由于操作系统页调度的原因,其中一个平台的操作系统将其从内存中移除了,所以表现的是150M。
我去查看了两台机器上进程的/proc/pid/smaps,发现并没有任何数据表文件的映射,我把别的文件映射大小全统计了一下,也不过几M而已。
最后,我其实已经没有办法了,去找ChatGPT找找灵感,他让我使用getconf PAGESIZE来查看一下系统的页大小。
当我看到这个答案时,我几乎觉得他就是正确答案,失望再次随之而来,两个系统的页大小均为4K。
于是我将目光投向了claude, 再次问出同样的问题,并说出两个机器的页大小一致。
claude给了一个思路,让我将使用pmap -x <pid>来分别打印出来两个机器上的服务器进程,以期望能找出点什么规律。
我太懒了,所以我将pmap的输出结果直接丢给了claude让其对比,claude输出如下:
通过对比两个进程的内存信息,我发现了一些关键差异:
虚拟内存大小差异:
进程1: VmSize = 1762456 kB
进程2: VmSize = 1688404 kB
差异约 74MB,这与你观察到的 80MB 接近
实际内存使用差异:
进程1: VmRSS = 248608 kB
RssAnon: 191344 kB
RssFile: 57264 kB
进程2: VmRSS = 161064 kB
RssAnon: 101188 kB
RssFile: 59876 kB
主要差异来自:
匿名内存(RssAnon)相差约 90MB:191MB vs 101MB
线程数不同:进程1有12个线程,进程2有11个线程
数据段大小(VmData)略有差异:
进程1: 336904 kB
进程2: 328260 kB
然而这个信息仅能说明内存的差异真的是来自于内存分配造成的,而不是别的什么原因。
最惨的是,这时我的免费次数用完了,于是我又切回了ChatGPT。
这一次我将两个进程的/proc/pid/smaps的输出喂给了ChatGPT。
ChatGPT发现了一个有意思的结论:
这74M的匿名内存块的映射大小不一致,一台机器上的部分内存块竟然是另一台机器上的两倍左右(并不是所有内存块都这样,由于这是前几天的事情,具体数值我忘记了)。
然而事情也仅仅到此了,再继续询问ChatGPT,他也只会车轱辘话来回说,说明他已经黔驴技穷了。
两个小时之后,运维同学说,怀疑是透明大页导致, 两个云服务商的机器,一个开了透明大页, 另一个没开。
而开了透明大页的机器刚好就是进程内存占用过多的机器。
运维同学将机器配置改完之后,重启进程果然内存变得一致了。
事实上,这次的排查我费了很大的力气,从早上9:30就开始了,一直查到11:00多。
但是翻来覆去,一直将目光锁定在操作系统之内。而没有怀疑是操作系统本身的机制引起的。
甚至都没往PAGESIZE上面想,更别说透明大页了,这次的排查经历给了我很大的警醒,这几年我太聚焦业务之上的代码分析了,已经很少会将其和内核的一些特殊机制去联想了。后期需要改进一下。
数据结构大致如下:
struct node {
uint32_t expire;
uint32_t session;
struct node *next;
};
struct slot_root {
struct node *slot[SR_SIZE];
};
struct slot_level {
struct node *slot[SL_SIZE];
};
struct silly_timer {
struct slot_root root;
struct slot_level level[4];
};
一直一来,我都没有给定时器增加取消功能。一是因为我在写业务逻辑时,对定时器取消需求不强烈。二是因为要想实现定时器取消功能,需要有一个数据结构根据session找到对应的node指针。并且可以O(1)的时间复杂度,将node从链表上删除,这会大大增加定时器的实现复杂度。
最近,当我在为silly增加etcd支持时,发现如果定时器支持取消功能,就能够以较小的开销实现各种超时逻辑,而无需构建特定的超时数据结构。这种方法会简化诸如HTTP读超时和RPC调用超时等各种超时逻辑的实现过程。
从实现上来讲,要想实现链表的O(1)删除,惟一的解决方案只能是双向链表,这几乎没有什么可以改进的余地。
但是根据session来找到对应的node指针,我一直在纠结要不要用hash表。
要实现一个可用的hash表,不可避免的需要处理冲突,理负载因子,rehash等各种情况。即使如此,也不能保证冲突不会发生。
而我加定时器取消功能的初衷是为了能在高频次场合使用,比如每秒10W次的rpc调用。
而RPC调用超时的实现一般如下:
local timer = core.timeout(5000, rpc_timer)
...
local ack, cmd = rpc:call()
if ack then
core.timercancel(timer)
end
也就是说,在正常情况下,每秒可能会调用10W次timeout和timercancel函数。
这要求session到node指针的映射必须始终保持高效。我考察了各种哈希表实现方式,包括Lua的,但都不太满意。
最近,我突然想到了一个完美的解决方案:利用内存池来完美解决这个问题。这个想法让我感到非常惊讶,因为我从未想到过内存池还可以这样用。
由于silly主要使用Lua作为业务逻辑开发语言,底层C代码通常通过id与Lua层进行通信。
每个定时器事件,C语言层都会返回给Lua层一个session,以代表该定时器事件。也就是说只要这个session和node有惟一的双向关系,那么怎么分配session其实并不重要。
在之前的实现中,我使用了递增分配session并强行绑定给node指针的方式。这种方式必须要使用哈希表才能实现session到node指针的映射。
现在我反过来思考,先分配一个node指针,然后根据该node指针的特征生成一个唯一的session。
这样,拿到这个session后就可以反向推理出对应的node指针的值。这种实现方式其实是得到了Linux中的syn cookies机制的启示。
通过实现一个内存池,当分配一个node指针后,计算该node指针到内存池首地址的偏移量,将其作为session来使用。
由于偏移量和session_id之间存在双向映射关系,因此可以通过session_id反向计算出node指针的地址。
这种实现方式完全避免了使用hash表的各种复杂度,同时大幅降低了内存分配的开销。
有了这个想法之后,剩下的就是一些更细致的优化了。
即然是内存池,就肯定需要扩容,选择哪种扩容方式也会影响到实现的复杂度。
如果使用realloc扩容的话,内存池首地址可能会变,势必会影响到时间轮中还未超时的node节点,这就需要改变时间论中链表的实现方式。
我认为这种实现复杂度太高了。所以我换了一种思路,将内存池抽象成一个page数组,每个page的大小为4K(保持与linux内存页大小一致,可以节省不必要的page fault开销,并且cache更友好)。
当我们需要扩容时,只需要malloc一个page并将其加入到page数组中,然后做一些必要的初始化即可。
在malloc一个page时分配一个page_id, 这个page_id即为page在page数组中的索引,相应的session计算公式修改为page_id * PAGE_SIZE + (node - page)。
之前递增分配session的方案中,session会过很久才会复用,可能是几个月也可能是几年。
这会对业务逻辑产生极大的容忍度,可以避免很多bug。这是内存池方案所不具备的。
幸运的是,lua中的Integer全是64bit, 即使我们的session是uint32_t, 到lua层之后依然会变成64 bit。即然如此,为什么要浪费那多出来的32bit呢。
因此我给node增加了一个version字段,每当node被释放过一次之后,就将version++。
相应的session计算公式改为version << 32 | page_id * PAGE_SIZE + (node - page)。
至此,我觉得整解决方案已经符合我预期了:以不高的复杂度实现了定时器取消功能。
整个数据结构大致如下:
struct node {
uint32_t expire;
uint32_t version;
uint32_t cookie; //page_id * PAGE_SIZE + page_offset
struct node *next;
struct node **prev;
};
#define PAGE_SIZE (4096/sizeof(struct node))
struct page {
struct node buf[PAGE_SIZE];
};
struct pool {
uint32_t cap;
uint32_t count;
struct node *free;
struct page **buf;
};
struct slot_root {
struct node *slot[SR_SIZE];
};
struct slot_level {
struct node *slot[SL_SIZE];
};
实现完成之后,我发现由于结构对齐,strcut node有4个字节的浪费。总想用这4个字节做点什么优化。
我会在timeout函数中返回一个session, 方便超时逻辑根据session来关联一些数据,以达到减少闭包的创建的目的。
大致的使用方式如下:
local user_data = {}
local function timer(session)
local ud = data[session]
data[session] = nil
--do some thing with ud
end
local session = core.timeout(1000, timer)
user_data[session] = ud
每个timer函数几乎都需要写这几行相似的代码,并且由于lua中table的特性,频繁的添加删除不重复key, 会频繁触发rehash。
虽然大概率这些rehash操作并不会成为瓶颈。但是,即可以利用一个浪费的4字节,又可以降低代码的重复度,还可以优化性能,何乐而不为呢:D。
优化之后,业务逻辑的代码就可以改为如下方式:
local function timer(ud)
end
local session = core.timeout(1000, timer, ud)
我在lua底层维护了一个timer_user_data数组。
当执行timeout时,从timer_user_data中找到一个空闲的位置(不一定是数组的末尾), 将user_data和这个位置进行绑定。
再将这个位置和C层的node进行绑定。当这个定时器超时后,将session和这个user_data所在的位置一起传入lua层。
由于timer_user_data是一个数组,因此他的key总是在1~#timer_user_data中循环使用,当timer_user_data扩容到一定程度后,再也不会触发rehash了。同样这个思路借鉴致于luaL_ref函数。
]]>在引擎可以加载出一个场景之后,我就需要一个相机控制器,来接收用户输入来移动和旋转相机,以实现场景漫游。
我打算使用Lua来编写这一逻辑。在计算相机的Transform时,需要进行一定的数学运算。这就需要一个Lua版的数学库。
怎么给Lua写一个简洁高效的数学库,这并不是最近才开始思考的问题。
早些年在使用tolua框架时,就发现在Lua中进行数学计算时会产生大量的临时对象,极大的加重GC的负担。
虽然这两年时不时就会想起这个问题,也一直没有解决方案。
这次,在没有了Unity的包袱之后, 希望能找到一条全新的思路。
为什么在C#中写数学运算就不会产生GC呢。根本原因是,在C#中(vector3,quaternion,matrix)等对象都是struct类型,即值类型。这些对象都是在栈上分配,函数返回即销毁。就算当值返回,也是直接值拷贝出去的。
在Lua中,严格意义的值类型只有boolean,number两种类型。虽然string表现的像个值类型,但是临时string对象一样会产生内存垃圾。
所以我们一般实现vector3时,会使用Table或userdata来保存xyz。这是因为Lua中的值类型不足以装下xyz这么多数据。
一个很直觉的思路,我们能不能扩展Lua中的值类型,使他最多能包含xyzw四个字段。
答案是能,但是代价很大。内存的代价,性能的代价,以及维护的代价。
我仔细回忆了这几年有限的客户端经历,我发现数学运算都是扎堆的。
换句话说,我们的数学运算一般都是几个有限的输入和几处有限的输出。但是中间计算过程很复杂,只要解决了这些中间过程产生的临时变量,那也算基本符合预期了。
沿着这个思路,即然Lua中只有numbert和boolean是值类型,那我有没有可能用number来代表一个vector3或quaternion呢?
答案是肯定的。我们只需要用C实现一片额外的空间,然后用索引指向这个vector3或quaternion的值就大功告成了。
基于以上思路,我实现了一个数学栈。这个栈的范围只能在一个函数内使用。
如果你想将计算结果返回到另一个函数使用,你只能将栈中的值取出,然后显式返回给其他函数。
如果其他函数需要再次进行数学计算,就需要重新开辟一个数学栈空间。
大致用法如下:
local mathx = require "engine.math"
local camera_up = {x = 0, y = 0, z = 0}
local camera_forward = {x = 0, y = 0, z = 0}
local camera_right = {x = 0, y = 0, z = 0}
local stk<close> = mathx.begin()
print("rotation", component.get_quaternion(self))
local rot = stk:quaternion(component.get_quaternion(self))
local up = stk:mul(rot, stk:vector3f_up())
local forward = stk:mul(rot, stk:vector3f_forward())
local right = stk:mul(rot, stk:vector3f_right())
stk:save(up, camera_up)
stk:save(right, camera_right)
stk:save(forward, camera_forward)
首先使用math.begin()来创建一个数学栈,接着我们就可以在栈上进行各种数学计算。
当数学计算结束时,我们可以使用stk:save来取出数学栈中的xyzw的值。
stk:save有两种使用方式,当我们传入一个table时,stk会直接将xyzw的值置入table内。我们还可以不传入参数,这时stk:save就会根据值的类型返回xyz或xyzw的值。
这里使用了Lua的toclose特性, 当栈使用完之后,__close函数会自动将栈对象放入Cache中。
下次调用math.begin时,直接从Cache中分配,这样可以做到0内存分配。
在实现完这个库之后,我特意与xlua做了一个性能对比。
local stk<close> = math.begin()
local v1 = stk:vector3f(xx_v3_1)
local v2 = stk:vector3f(xx_v3_2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
v2 = stk:vector3f_cross(v1, v2)
stk:save(v2, xx_v3_2)
与同样逻辑的xlua写法对比,性能要高出300%左右。并且随着计算过程的增加,性能优势会越来越明显。
除此之外,在进行数学计算之前,我们往往需要获取到transform中的position,rotation,scale等属性。
这些属性要么是vector3, 要么是quaternion。如果我们用table或userdata来返回依然会加重GC负担。
仔细数一下其实这些数据类型最大也只有4个变量。因此,我让函数component.get_position直接返回xyz三个值,而component.get_rotation直接返回xyzw四个值。
至于是否需要存到table里,这个交由业务逻辑来控制。
]]>书中第三章提到了一个Flocking算法,该算法一般用于模拟群体(羊群,鸟群,鱼群,人群)的移动行为。
这让我想起了大约一年前,他们QQ群里分享了一个蚁群行军的视频。当时为了研究他是如何时实现的,还特意去学习了VO,RVO算法(没有学会),最终也没有实现出来。
这次,我想用Flocking算法再试一次。
先简单介绍一下Flocking算法。
对于鸟群中的一只鸟而言,除了他本身要飞行的速度向量Velocity外,还有三个额外的分量来辅助校正最终的速度向量。
这三个额外分量分别如下:
Separation:每只鸟都会考虑到它们相对周围其他鸟的位置,如果过近,就会产生一个排斥速度分量。
Cohesion: 每只鸟都会检查自己半径R范围内鸟的位置,计算出这群鸟的质心,产生一个向质心靠拢的速度分量。
Alignment: 每只鸟都会检查自己半径R范围内的鸟的速度,计算出这群鸟的平均速度,然后产生一个向平均速度靠拢的速度分量。
最终每只鸟的速度为:Velocity + Separation + Cohesion + Alignment(在叠加过程中,可以根据情况给每个分量加上相应的权重)。
Flocking在没有障碍物的场景,比如天空,海底,平原等表现都很不错。但是一旦进入有障碍物的场景如蚁穴,就会很难工作。
这时就需要加入寻路系统来提供路径支持。
然而,事情并没有这么简单。
由于有Separation,Cohesion,Alignment速度分量的存在,即使我们给每只鸟单独寻出来一条路径,也不能保证这只鸟就一定会严格按照路径行走。
比如我们为某只鸟寻出来的路径为((0,0), (0,1),(0,2))(我们把地图切成很多小块格子,坐标为格子坐标,不是实际的世界坐标)。
在从(0,0)到(0,1)运行的过程中,由于鸟群的干扰,可能会把这只鸟挤到了(1,1)格子,这时可能(1,1)是到不了(0,2)的,需要重新寻路。
这就意味着,每只鸟每跨过一个格子,就需要重新寻路一次,这么大的开销足以使FPS降到5。
在网上搜到一种解决方案。
给整个鸟群指定一个Leader。为Leader计算一条路径,Leader严格按照路径行走。鸟群中的其他鸟使用Flocking算法来跟随Leader即可。
我尝试了这种方案后,发现这个方案在绕过大片障碍物时非常好用。但是在通过狭窄通道时,很容易发生跟随失败,导致一些鸟永远卡在那里不能行动。
比如下面这种情况:
xxxxxL
x
------------ x --------------
x
x
xB
Leader在位置L处,B位置处的鸟要跟随Leader,必然要产生一个从B位置向L位置的速度。
如果B鸟按这个跟随速度运动,就会被卡在墙的一侧,永远的脱离队伍。
我尝试优化这种方案,除了Leader之外,我加入了Target角色。
所有的鸟在运动时,会在自身周围一定范围内寻找一个Leader或Target作为跟随的目标。
找到跟随目标之后,自身也会变成Target角色,供其他鸟跟随。
如果找不到合适的跟随目标,自己就会变成临时Leader。然后重新计算一条路径,并严格按照路径运动。直到遇见一个合适的Target之后,这只鸟就会再次变回Target。
这种方案可以应对各种极端障碍物情况。但是这个方案几乎把Flocking所有的特性都抹掉了,鸟群在整个运动过程中会排成一字长蛇阵,看起来非常不自然。
我找到当时的QQ聊天记录,仔细读了几遍,然后换了个思路。
计划让鸟群运行到某个目标点那一刻,使用Dijkstra算法计算出地图上所有格子到目标点的最佳运动方向。
这里有个小技巧,我们使用目标点作起始点,然后运行Dijkstra算法。
当Open列表为空时,就已经完成了地图上所有格子到目标点的最佳方向计算。
每只鸟在移动前,根据当前位置计算出当前格子,然后直接查询出下一步的目标点。
理论上,根据目标点计算出鸟的Velocity速度向量,再叠加Separation,Cohesion,Alignment速度分量就是最终的速度值。
然而,现实是残酷的。
经过实验发现,由于鸟群的作用力,经常会有鸟被挤进障碍物中,尤其是在经过狭窄通道时。
因此我们还需要静态避障速度分量。
在《Artificial Intelligence for Games, Second Edition》中第“3.3.15 Obstacle and Wall Avoidance”节中,讲到可以使用射线检测来躲避静态障碍物。
测试发现,当角度比较奇葩时,射线检测不到障碍物的存在,从而导致最终被挤到墙里面去,3.3.15节也有提到过这种情况。
最终,我采用了AABB来检测周围是否存在障碍物,当有障碍物时,根据障碍物的质心和当前鸟的位置来产生一个远离障碍物的速度分量,这个分量的权重要显著大于其他4个速度分量。
如果障碍物形状态复杂时,可能需要重写AABB检测逻辑,根据相交的边计算出远离障碍物的速度分量。
到目前为止,最大的开销就剩下为地图上所有格子计算最佳方向了。
如果地图过大,这样计算是不现实的。
在写这篇文章时,我想到了一个优化算法,还没来得及测试。
通过观察Flocking算法,不难发现鸟群中的鸟几乎全是按照大致相同的路线行走的。
也就是说,只要我们想办法生成一个有宽度的路径,基本上就可以满足给鸟群寻路的需求了。
首先使用AStar算法,从整个鸟群的质心到目标点计算出一条路径。
然后,对第一步中路径的每个格子,都使用Dijkstra算法,计算出周边格子到这个格子的最短路径。计算时要限制Dijkstra算法遍历的深度。只要我们选取的深度合适,大部分鸟行走的格子都会被命中。
值得一提的是,在应用Dijkstra算法时,路径中相临格子的周围是相互覆盖的,需要根据权重进行刷新。
举个例子:
已经使用AStar算法计算出A到D的路径为(A,B,C,D)。
对格子B应用Dijkstra算法时,对邻居E生成了最佳运动方向为向B运动,E到D的权重为E(1)+B(2) = 3。
对格子C应用Dijkstra算法时,同样会处理到邻居E,这时不能简单的跳过E,而应该计算E到D的权重为E(1) + C(1) = 2。
这时应将E的最佳运动方向改为向C而不是B。
如果某只鸟被挤到了一个我们事先没有计算过的格子上,就使用AStar以此格子为原点向目标点寻路。
这里有一个可以优化的地方,我们已经有了一条很宽的路径,只要AStar寻到已有的路径格子就可以停止继续寻路了。
最后,Demo在此。
]]>通常实现智能会采用状态机,行为树,GOAP等技术。
GOAP技术我没有研究过,行为树在早些年大致了解过一些。因为觉得行为树性能太差,不可能取代状态机实现,之后就再也没有研究过了。
随着这些年我性能强迫症的好转,再加上听到行为树的次数逐年增加,我打算趁机仔细研究一下。
我找来《Behavior Trees in Robotics and AI》仔细读了一遍。这本书详细介绍了行为树,并且对比了行为树和状态机之间的优劣。
根据《Behavior Trees in Robotics and AI》描述,行为树一般有4种控制节点(Sequence, Fallback, Parallel, Decorator)和两种执行节点(Action和Condition)。只有执行节点才能成为叶子节点。
先来简单描述一下最重要的两种控制节点, Sequence和Fallback。
Sequence节点: 当执行Sequence节点时,从左往右顺序执行子节点,直到某一个子节点返回Failure或Running状态,伪码如下:
//Algorithm 1: Pseudocode of a Sequence node with N children
for i 1 to N do
childStatus <- Tick(child(i))
if childStatus = Running then
return Running
else if childStatus = Failure then
return Failure
return Success
Fallback节点:当执行Fallback节点时,从左往右顺序执行子节点,直到某一个子节点返回Success or Running状态,伪码如下:
//Algorithm 2: Pseudocode of a Fallback node with N children
for i 1 to N do
childStatus <- Tick(child(i))
if childStatus = Running then
return Running
else if childStatus = Success then
return Success
return Failure
Action和Condition节点,是我们具体的业务逻辑,不是本次优化的重点。
对比行为树和状态机可以发现,行为树比状态机额外多出的开销, 就是在执行执行节点之前,必须要先穿过控制节点。
如果我们在运行时能避过控制节点,只执行执行节点,那行为树和状态机的开销差别就只是多了几次函数调用而已。
仔细思考过之后, 我认为这是可能的。
结合上面对Sequence和Fallback节点的定义。我们不难发现,在编程语言中,Sequence就是and(与)逻辑,而Fallback就是or(或)逻辑。
整棵行为树的控制节点就是用来描述if-else的逻辑,叶子节点是相应的业务逻辑。从这个角度来看,行为树和语法树有颇多相似之处。
不难发现,整棵树的执行路径,其实依赖于特定执行节点的特定返回值。
某一个执行节点(叶子节点)返回Failure或Success, 整棵行为树下一步要执行的执行节点是固定的。
某个执行节点返回Running, 整棵树就停止执行。在下一Tick之后从头执行,这种情况比较简单,暂时不需要考虑。
来看一棵简单的行为树:
如果 Action 1 Done 返回Success,下一步将要执行的执行节点(叶子节点)就是 Actino 2 Done。
如果 Action 1 Done 返回Failure, 下一步将要执行的执行节点(叶子节点)就是 Action 1。
这种逻辑可以递归到所有的执行节点。
这样,我们只需要两张跳转表(Success跳转表,Failure跳转表),就可以在运行时,以状态机的开销来实现行为树的功能。
以上面的行为树为例,我们可以生成如下跳转表:
local tree = {
["Action 1 Done"] = {
["Success"] = "Action 2 Done",
["Failure"] = "Action 1"
},
["Action 1"] = {
["Success"] = "Action 2 Done",
["Failure"] = nil, --nil 代表整棵树执行结束
},
["Action 2 Done"] = {
["Success"] = nil,
["Failure"] = "Action 2"
},
["Action 2"] = {
["Success"] = nil,
["Failure"] = nil,
}
}
在运行时,我们首先执行整棵行为树的第一个节点"Action 1 Done"。
如果"Action 1 Done"返回Success, 根据表tree可知,下一步需要执行的是"Action 2 Done"。
如果"Action 2 Done"返回Failure, 根据表tree可知,下一步需要执行的是"Action 2"。
这样我们仅需要生成一个跳转表,就可以在运行时抹掉所有控制节点所带来的开销。
最终,我花了200行代码实现了根据行为树生成上述跳转表的逻辑。
PS.我把生成跳转表的行为称之为编译。如果控制节点是Parallel或Decorator类型,或者有记忆功能。在编译过程中,需要将其保留,不能将其编译掉。不然无法完成和行为树等价的逻辑。
]]>但这么做是否真的对所有模式的游戏服务器都合适呢, 对于某些游戏模式,是不是有更好的选择?
这是我最近在看《MySql是怎样运行的》,突然想到的问题。
我挑了三款存储模式完全不同的数据库, 来对比一下它们的特点。
Mysql: 一款关系型数据库。
由于有RedoLog,UndoLog的存在, 支持事务,数据落地比较可靠。
存储引擎InnoDB采用B+Tree作为存储结构, 而由于B+Tree的性质以及RedoLog,BinLog,UndoLog等机制的存在,导致Mysql的写入性能远低于查询性能。
因此Mysql适用于查询压力大,但是写入压力小的场景。虽然这些复杂的机制拖慢了写入速度,但是MySql可以提供各种复杂的查询。
即使如此,由于Mysql的数据结构是严格和磁盘对应的,相比Memcached和Redis等,将数据以内存数据结构的方式完全存储在内存的程序来讲,Mysql的查询性能还是要差不少。
这也是为什么在一些读流量大的地方,有时候会加Memcached或Redis作为前端,以防止大流量将Mysql冲垮(还可以使用从机做读写分离)。
Redis: 一款读写性能都很卓越的NoSql内存数据库。
本质上Redis就是一个带持久化功能的内存缓存,所有的数据以最适合内存访问的方式存储,因此查询极快, 写入极快,不支持事务,仅支持键-值查询。
其持久化方式分为RDB和AOF两种方式:
RDB是通过定时将进程内存中的数据集快照写入磁盘文件,这种持久化方式性能较高, 但安全性较低。默认配置下,可能会丢失最近60s的数据,由于RDB每次都是重新写入全量数据集,随着持久化频率间隔的降低,会显著增加CPU和IO开销。
AOF是性能和可靠性的另一种折衷, 每一条修改命令都会尽可能快的(不是立即,Redis会在Sleep前才会尝试)写入到文件系统缓存。至于什么时机通知操作系统将文件系统的脏页刷新到磁盘上, Redis最高可以配置为每次写入到操作系统文件系统缓存时,都执行刷新操作,默认为每秒通知操作系统刷新。
AOF文件的大小会随着数据修改次数的增加而逐渐变大,当大到一定程度后,Redis会Fork一个进程对AOF文件进行重写,以达到减少AOF文件尺寸的目的。AOF的重写时机同样可以进行配置。
不管是AOF还RDF方案, 都有一个不可避免的缺点, 每次生成RDB文件或重写AOF文件时, 都会将内存中全量的数据写入文件, 在数据量很大的情况下, 会产生CPU峰值。
LevelDB: 一款写性能卓越的NoSql数据库。
LevelDB底层采用LSM数据结构来存储数据, 所以写入极快, 查询较慢, 不支持事务,仅支持键-值查询(还支持键的遍历)
与Redis相反,LevelDB将所有数据都存储在硬盘上,仅在自身内存中缓存热数据。
由于LSM数据结构的特殊性,LevelDB还需要WAL(Write Ahead Log)来保证数据的可靠性。
WAL的作用和MySql的RedoLog的作用几乎一样,都是用于在意外Crash时,恢复还没有写入磁盘的数据。
在LSM数据结构中, 所有数据都是存储在SSTable中, 而SSTable是只读的。
这意味着随着数据增删改次数的增加,SSTable会变的越来越大。这时LevelDB的后台线程会在合适的时机,合并SSTable,以达到减少SSTable文件的目的。
LevelDB在合并数据时,是以SSTable文件为单位进行的, 而每个SSTable文件的大小一般为2M。这保证了,即使在数据库存有超大规模数据时,其合并过程依然是可控的。
总的来讲,MySql适合查询场景复杂, 而且查询多于写入的场景。Redis适合单进程数据量不大,并且对查询和写入都要求极高的场景。而LevelDB则适合于写多,读少的场景。
不管是Redis的内存限制,还是RDB生成/AOF的重写机制,都限制了其单进程能处理的数据量要远低于Mysql和LevelDB。同时,Redis的查询和写入性能也是这三者之间最出色的。
在我们游戏中,玩家数据是需要长驻内存的,即使一个玩家下线,别的玩家还是可以影响他的所有数据(包括货币和英雄)。
这意味着,我们必须在开服期间,就要从数据库加载所有游戏数据到游戏进程。之后只需要操作进程内数据即可。
在不考虑数据安全的情况下,甚至我们都不需要数据库。
只需要在停服时,像Redis写入RDB一样,将每个系统的数据按照约定的格式写入到不同的文件,下次开服再加载回来。
使用数据库的理由是,它可以让我们按需写入数据,可以提高数据的安全性。
那么,我们对它的需求就只剩一点了:“写入要快,持久化要安全”。
从安全性上来讲,Mysql, LevelDB, Redis的AOF都满足要求。
就写入速度而言,显然MySql落选了,因为他是为查询设计的数据库系统。
就现在需求而言,而Redis和LevelDB在CPU和内存足够的情况下,其实差别不大,甚至Redis要优于LevelDB。
如果我们想再节约一点,就会发现LevelDB在内存和CPU峰值方面优于Redis, 同时他的写入性能要差于Redis, 因为LevelDB有WAL和SSTable两次写入。
ps. 这种取舍其实很像数据结构的优化,一份平均每写10次只查一次的数据,显然没有必要每次写入之后都对数据排一下序,选择时关键是还是要看清数据库的定位以及自己的需求。
]]>从历史经验来看,将数据库存储行为收集并合并起来,确实可以极大的降低抽象代码过程中的心智负担。我们在写代码时已经不需要考虑数据库的存储频率,反正最终所有修改过的数据,只会存一次。
从我意识到定点存库的必要性之后,我就一直对当时的抽象不太满意,在最原始的抽象中,刷新数据是需要业务逻辑来完成的。而数据库合并模块本质上只做了去重而已,这也就是说,其实大量定点存储代码实际是散落在业务逻辑各处的。而在此之后虽然我一直在思考该怎么缓存这些数据库操作,但是一直没有什么实质性进展。
直到最近一段时间,我一直在刻意强迫自己放弃部分性能,以达到更好的抽象后才突然有了点想法。
以Redis数据库的hset(key, field, value)为例,最直接的设计就是把每个key下面的value有变动的field收集起来。然后在特定时机将这些field写入相应的key。相应的伪码如下:
struct key {
std::unordered_set<uint32_t> dirty_fields;
};
std::unordered_map<string, key> dirty_keys;
这其实就是第一版的抽象,因为没有value相关信息,所以序列化及更新数据库的责任都落在了相关的业务逻辑模块,这个数据库操作缓存模块有与没有其实区别不大。
最近我仔细研究了一下Redis的数据模型和我们业务逻辑的数据模型。我发现其实只要抽象出’set’,’hset’, ‘del’, ‘hdel’这些对应的操作,然后对这些操作进行缓存和去重就能满足99%的需求。
现在还有一个问题需要解决,如何将序列化的工作接管过来。这样就不会存在任何回调了。这个问题其实不难解决,只要我们设计一个通用的接口类提供一个serialize接口即可。
伪码大概如下:
struct obj {
std::string serialize() const = 0;
};
struct cmd {
std::string name;
uint32_t field;
obj *value;
};
struct key {
std::unordered_map<uint32_t, cmd> dirty_fields;
};
std::unordered_map<string, key> dirty_keys;
void hset(const std::string &key, uint32_t field, obj *value) {
auto &set = dirty_keys[key];
auto &cmd = set[field];
cmd.name = "hset";
cmd.field = field;
cmd.value = value;
}
void hdel(const std::string &key, uint32_t field) {
auto &set = dirty_keys[key];
auto &cmd = set[field];
cmd.name = "hdel";
cmd.field = field;
cmd.value = nullptr;
}
void set(const std::string &key, obj *value) {
auto &set = dirty_keys[key];
auto &cmd = set[0];
cmd.name = "set"
cmd.value = value;
}
void del(const std::string &key) {
auto &set = dirty_keys[key];
set.clear();
auto &cmd = set[0];
cmd.name = "del";
cmd.value = nullptr;
}
void flush() {
for (auto &kiter:dirty_keys) {
auto &key = kiter.second;
auto &kname = kiter.first;
for (auto &citer:key.dirty_fields) {
auto &cmd = citer.second;
if (cmd.name == "hset") {
auto dat = cmd.value->serialize();
HSET(kname, cmd.field, dat);
} else if (cmd.name == "set") {
auto dat = cmd.value->serialize();
SET(kname, dat);
} else if (cmd.name == "del") {
DEL(kname);
} else if (cmd.name == "hdel") {
HDEL(kname, cmd.field);
}
}
}
}
当然这里面其实还有两个个细节问题。
std::unordered_map<uint32_t, dbst> DB进行存储, 直接调用hset("foo", 1, &DB[1])需要考虑DB进行rehash的情况。因此需要new一个新的dbst并将新指针传递过去,类似这样hset("foo", 1, new dbst(DB[1]))。同时struct cmd需要做出如下改变:
struct cmd {
std::string name;
uint32_t field;
std::unique_ptr<obj> value;
};
看似我们需要很多次new, 但是仔细分析一下整个内存分配行为,就会发现,整个操作序列大概类似这样:new(首次对一个对象进行set/hset),new,new,new/delete(重复对一个对象进行set/hset),new/delete,delete,delete。也就是说这种分配具有局部性,一般不太会造成内存碎片和性能问题。
做完如上抽象后,我在想,有没有可能再次简化上述数据结构。
我仔细研究了一下我们的数据模型,我发现一件很有意思的事。
我们在数据库的每一条数据, 都在内存中有一份惟一的对应。也就是说一个指针一定只对应一个value(set(key, value)/hset(key, field, value))。只要这个指针在整个数据生命周期期间,不发生改变,我们就可以直接使用指针来作主键去重,在业务逻辑层使用std::unordered_map<uint32_t, std::unique_ptr<dbst>来缓存数据库数据即可。
这样数据结构就可以简化为如下:
struct obj {
std::string serialize() const = 0;
};
struct cmd {
std::string name;
std::string key;
uint32_t field;
obj *value;
};
std::unordered_map<intptr_t, size_t> v2i;
std::vector<cmd> cmds;
static cmd &overwrite(obj *v) {
auto iter = v2i.find((intptr_t)v);
if (iter != v2i.end()) {
cmds[iter->second].name = "nop"
}
cmds.emplace_back();
return cmds.back();
}
void hset(const std::string &key, uint32_t field, obj *value) {
auto &cmd = overwrite(value);
cmd.name = "hset";
cmd.key = key;
cmd.field = field;
cmd.value = value;
}
void set(const std::string &key, obj *value) {
auto &cmd = overwrite(value);
cmd.name = "set"
cmd.key = key;
cmd.value = value;
}
void hdel(const std::string &key, uint32_t field) {
cmds.emplace_back();
auto &cmd = cmds.back();
cmd.name = "hdel";
cmd.key = key;
cmd.field = field;
cmd.value = nullptr;
}
void del(const std::string &key) {
cmds.emplace_back();
auto &cmd = cmds.back();
cmd.name = "del";
cmd.key = key;
cmd.value = nullptr;
}
void flush() {
v2i.clear();
for (auto &cmd:cmds) {
if (cmd.name == "hset") {
auto dat = cmd.value->serialize();
HSET(cmd.key, cmd.field, dat);
} else if (cmd.name == "set") {
auto dat = cmd.value->serialize();
SET(cmd.key, dat);
} else if (cmd.name == "del") {
DEL(cmd.key);
} else if (cmd.name == "hdel") {
HDEL(cmd.key, cmd.field);
}
}
}
做成一个操作队列,最麻烦的其实是在两个hset/set之间插入hdel/del。这时会有两种选择:
扫描cmds, 找到相同的key+field, 将删除,并将最后一个相同key+field的cmd结构改成hdel/del。
直接将del/hdel添加到队列末尾。
由于没有直接证据表明,方式1会快,加上我最近刻意强迫自己牺牲部分性来保持简洁性。因此我选用了方式2.
]]>但是从技术上讲,我觉得”茴字的四种写法”在满足需求的前提下,有助于我们简化实现。
在我的历史经验中,我一共写过三种双向链表。
在最开始实现时,就是按算法导论最朴素的实现。
//算法1
struct node {
struct node *prev;
struct node *next;
}
struct node *head = NULL;
void insert(struct node *n) {
n->prev = NULL;
n->next = head;
if (head != NULL)
head->prev = n;
head = n;
}
void remove(struct node *n) {
if (n->prev != NULL)
n->prev->next = n->next;
else
head = n;
if (n->next != NULL)
n-next->prev = n->prev;
}
写了几次之后,我觉得每次修正head指针,心智负担有点重而且极易出错,于是浪费了一点点空间改进了一下。
//算法2
struct node {
struct node *prev;
struct node *next;
}
struct node head = {NULL, NULL}
void insert(struct node *n) {
n->next = head.next;
if (n->next != NULL)
n->next->prev = n;
head.next = n;
n->prev = &head;
}
void remove(struct node *n) {
n->prev->next = n->next;
if (n->next != NULL)
n->next->prev = n->prev;
}
虽然insert函数逻辑几乎没有减少,但是remove函数的逻辑大大减少,并且更容易理解了。
但是这样做有一个弊端,struct node是一个结构体而不是一个指针,在某些情况下不便于存储。
因此这些年,我几乎都是两种算法换着用。但是每次使用算法1时,总是要停下来仔细思考一下,才敢下手。
最近在Review几年前的代码时,发现之前使用算法1写的双向链表有bug.
这再次使我想对双向链表的算法2进行改进,我仔细思考了一下双向链表的特性。
双向链表主要有两个功能:
但是到目前为止, 我从来没有使用过双向链表的特性1.
我使用双向链表的惟一原因就是要快速删除某一个节点。
即然如此,根据“这个世界是平衡的”原则,如果我去掉某个特性,就一定能简化部分实现,只是简化多少的问题。
我仔细研究了算法2,想从中找到某种启发。
最终我发现,在整个逻辑中,prev指针的惟一用处就是用来访问或修改前置节点的next变量。
而head的prev变量同样是多余的。
那么,如果将prev的含义修改为指向前置节点的next变量,关于prev的循环不变式同样成立。
优化后的代码如下:
//算法3
struct node {
struct node **prev;
struct node *next;
}
struct node *head = NULL;
void insert(struct node *n) {
n->prev = &head;
n->next = head;
if (head != NULL)
head->prev = &n->next;
head = n;
}
void remove(struct node *n) {
*n->prev = n->next;
if (n->next != NULL)
n->next->prev = n->prev;
}
由此,终于可以在享有算法2逻辑复杂度的同时,而不必要承担一个head结构体。
BTW,在写本文的前一天,我无意间发现Lua源码中也是这样做的 😀
]]>--a.lua
collectgarbage("stop")
local function foo()
local a = 3
for i = 1, 64 * 1024 * 1024 do
a = i
end
print(a)
end
foo()
在 Lua5.3.4 和 Lua5.4-alpha-rc2 上,这段代码运行时间分为0.55,0.42s。
通过`./luac -p -l ./lua ` 可以得知,上段这代码性能热点一定是OP_MOVE,和OP_FORLOOP。因此一定是这两个opcode的执行解释代码有修改。
我仔细对比了一下,关于OP_FORLOOP和OP_MOVE的实现,发现实现上一共有三处优化。
1. vmcase(OP_FORLOOP)的执行代码去掉了’0<step’的判断。(由于一次for循环期间,step的符号总是固定的,因此cpu分支预测成功率是100%)
2. vmcase(OP_FORLOOP)向回跳转时,偏移量改成了正值,因此将Bx寄存器直接当作无符号数去处理,省了一个符号转换操作。
3. vmcase(OP_FORLOOP)向回跳转时,由直接修改ci->u.savedpc改为了修改一个局部变量pc。通过反汇编得知,修改局部pc可以省掉一次store操作。
经过测试发现,这三处修改都达不到0.13s这么大幅度的提升。
万般无奈的情况下,我使用git bisec测试了从 Lua5.3.4 到 Lua5.4-alpha-rc2的所有变更(这里说所有不准确,因为git bisec是通过二分法查找的)。
最终发现引起性能影响的竟然是下面一段赋值操作的修改。
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;
#define TValuefields Value value_; int tt_
typedef struct lua_TValue {
TValuefields;
} TValue;
#define setobj(L,obj1,obj2) \
-{ TValue *io1=(obj1); *io1 = *(obj2); \
+{ TValue *io1=(obj1); const TValue *io2=(obj2); \
+ io1->value_ = io2->value_; io1->tt_ = io2->tt_; \
(void)L; checkliveness(L,io1); }
两个赋值的作用都是复制一个结构体。只不过由于结构体对齐的存在,直接使用结构体赋值,会多复制了四个字节。
但是,在64位机器上,如果地址是对齐的,复制4个字节和复制8个字节不应该会有如此大的差异才对。毕竟都是一条指令完成的。为了近一步证明不是多复制4个字节带来的开销,我做了如下测试。
假设修改前的setobj是setobj_X, 修改后的setobj为setobj_Y。然后分别对setobj_X和setobj_Y进行测试tt_类型为char, short, int, long的情况。
测试结果如下:
typeof(tt_) char short int long setobj_X 0.55s 0.55s 0.55s 0.41s setobj_Y 0.52s 0.43s 0.42s 0.42s
从测试结果可以看出,setobj_X在tt_类型为long时反而是最快的,这说明开销并不是多复制4字节造成的。
反汇编之后发现,setobj_X 和 setobj_Y 惟一的差别就是赋值顺序和寻址模式。
汇编如下:
;setobj_X 0x413e10 : shr r13d,0x17 0x413e14 : shl r13,0x4 0x413e18 : mov rax,QWORD PTR [r15+r13*1] ;value_ 0x413e1c : mov rdx,QWORD PTR [r15+r13*1+0x8] ;tt_ 0x413e21 : mov QWORD PTR [rbx],rax 0x413e24 : mov QWORD PTR [rbx+0x8],rdx 0x413e28 : mov rsi,QWORD PTR [rbp+0x28] 0x413e2c : jmp 0x4131a0 ;setobj_Y 0x413da8 : shr r13d,0x17 0x413dac : shl r13,0x4 0x413db0 : add r13,r15 0x413db3 : mov eax,DWORD PTR [r13+0x8] ;tt_ 0x413db7 : mov DWORD PTR [rbx+0x8],eax 0x413dba : mov rax,QWORD PTR [r13+0x0] ;value_ 0x413dbe : mov QWORD PTR [rbx],rax 0x413dc1 : mov rax,QWORD PTR [rbp+0x28] 0x413dc5 : jmp 0x413170
猜测,难道是赋值顺序打乱了流水线并行,还是寻址模式需要额外的机器周期? 但是他们都无法解释,当我把tt_的类型改为long之后,setobj_X也会变得更快。
种种迹象把矛头指向Cache。 但这时我已经黔驴技穷了,我找不到更多的测试来继续缩小排查范围了。也没有办法进一步确定一定是Cache造成的(我这时还不知道PMU的存在)。
我开始查找《64-ia-32-architectures-optimization-manual》,试图能在这里找到答案。
找来找去,只在3.6.5.1节中找到了关于L1D Cache效率的相关内容。我又仔细阅读了一下lvm.c的代码,却并没有发现符合产生 Cache 惩罚的条件。(其实这里我犯了一个错误,不然走到这里我就已经找到答案了。以前看lparse.c中关于OP_FORLOOP部分时不仔细。欠的技术债这里终于还了。)
万般无奈下,我又测试了下面代码,想看看能否进一步缩小推断范围。
--b.lua
collectgarbage("stop")
local function foo()
local a = 3
local b = 4
for i = 1, 64 * 1024 * 1024 do
a = b
end
print(a)
end
foo()
这次测试其实是有点意外的,因为setobj_X版本的luaVM一下子跑的几乎跟setobj_Y版本一样快了。
看起来更像是3.6.5.1节中提到的L1D Cache的惩罚问题了。但是我依然没有找到惩罚的原因。
我把这一测试结果同步到lua的maillist上去(在我反汇编找不到答案后,就已经去maillist上提问了,虽然有进度,但是同样一直没有结论).
这一次maillist上的同学,终于有了进一步答案了。
他指出,在vmcase(OP_FORLOOP)中使用分开赋值的方式更新’i’(一次赋值value_, 一次赋值tt_,这次tt_赋值是store 32位)。而在vmcase(OP_MOVE)使用的setobj_X赋值时,使用了两次load 64位来读取value_和tt_。
这恰好就是3.6.5.1节中提到的规则(b),因此会有L1D Cache惩罚。
而这时我恰好已经通过perf观察到两个版本的setobj在PMU的l1d_pend_miss.pending_cycles和l1d_pend_miss.pending_cycles_any指标上有显著不同。 两相印证,基本可以90%的肯定就是这个问题。
现在来解释一下,我之前犯的错误。我之前一直认为,一个`for i = 1, 3, 1 do end`一共占三个lua寄存器:一个初始值i,一个最大值3, 暂时称为_m,一个步长1, 暂时称为_s。
但是经过maillist上的同学提醒后,我又仔细看了一下lparse.c,发现其实上面的for一共占四个lua寄存器:初始值1,暂称为_i,最大值_m, 步长_s,及变量i。
每次OP_FORLOOP在执行到最后会同步_i的值到变量i. 代码中的使用的值来自变量i所在的寄存器,而不是_i。
从lparse.c中得知,_i来自R(A), _m来自R(A+1), _s来自R(A+2), i来自R(A+3)。
再来看一下lvm.c中关于vmcase(OP_FORLOOP)的代码:
vmcase(OP_FORLOOP) {
if (ttisinteger(ra)) { /* integer loop? */
lua_Integer step = ivalue(ra + 2);
lua_Integer idx = intop(+, ivalue(ra), step);
lua_Integer limit = ivalue(ra + 1);
if ((0 < step) ? (idx <= limit) : (limit <= idx)) {
ci->u.l.savedpc += GETARG_sBx(i); /* jump back */
chgivalue(ra, idx); /* update internal index... */
setivalue(ra + 3, idx); /* ...and external index */
}
}
...
vmbreak;
}
可以很明显看出ra寄存器和(ra+3)的寄存器的赋值方式并不一样。其中chgivalue是只改value_部分,而setivalue是分别对value_和tt_进行赋值。
因此当接下来执行vmcase(OP_MOVE)时,setobj_X对tt_所在的地址,直接读取64位时就就会受到L1D Cache的惩罚。
而我之前犯的错误就是我一直认为修改i的值是通过chgivalue(ra, idx)来实现的。
为了更加确定是L1D Cache中Store-to-Load-Forwarding惩罚造成的开销。我将setivalue改为了chgivalue之后再测试。果然运行时间与setobj_Y的时间相差无几。这下结论已经99%可靠了,那剩下的1%恐怕要问Intel工程师了。
BTW,这次分析其实断断续续执行了4天。对于Cache对程序性能的影响,终于有了一次深刻的意识。
]]>