脏标记遍历之极致优化

1.背景介绍

  本文适用于所有脏标记遍历功能,提升性能几十倍,本文以游戏中玩家的属性同步作为例子进行介绍。

  MMORPG游戏中玩家有大量属性需要从服务器同步给客户端,例如名字、血量、战力、等级、速度等等。每当某个玩家的某个属性发生变化时,需要将玩家该属性的标志位置脏,在心跳或者其他需要发送的时机,调用同步属性函数,判断置脏的属性并同步给所有客户端。需要注意,不能每次在某个玩家的某个属性发生变化时,均同步给所有客户端,因为开销太大,一般在心跳里(固定时间间隔)同步。为了保证玩家属性在所有客户端及时更新,心跳间隔一般在500ms左右,如此频繁的调用以及同步属性函数内大量的脏标记判断导致同步属性函数成为性能瓶颈之一,大约占后台性能消耗的10%。

  假设玩家有n个属性,k个属性置脏,最直观的做法是遍历所有n个属性,逐个判断是否置脏,置脏的属性同步给客户端,需要判断n次;本文作者之前提过一种优化算法,根据属性置脏的频率,优先判断置脏频率高的属性,当判断出的脏属性数目等于k,则停止,该优化效率显著优于遍历n个属性,但也不是最优的算法;还有一种优化思路是用set记录置脏的属性,set插入时间复杂度为O(logn),置脏m个属性,set插入操作总时间复杂度为O(log(1*2*..*m)),再考虑某个属性频繁置脏的情况,比如第m个属性在同步之前置脏p次,则set操作时间复杂度为O(log(1*2..*m*p),虽然一般情况下m比较小,但不排除某些情况m很大,就会导致出现性能瓶颈,实测中发现采用set,o2编译,如图1.1所示插入50个脏属性耗时165639微秒,如图1.2所示置脏50个标记位耗时1522微秒,set效率低108倍,虽然同步的时间间隔内更新的属性数目很少,但某个属性更新的可能非常频繁,所以同步时间间隔内假设插入50个脏属性模拟很合理。显然,如果算法无论属性更新是否频繁,无论属性数目是多是少,都能在k的时间判断出哪些属性置脏,则该算法为最优算法。因为置脏的属性非常稀疏,k往往是个位数,所以最优算法可以将效率提升几十倍。

2.优化算法

  为引出本文的优化算法,本文首先介绍一个问题:计算一个整数的二进制表示中有多少个1。基本做法如图2.1所示。假设该整数n为10001100,则需要右移7次。但计算机里的数字本来就是用二进制存的,所以计算过程也都是二进制计算。利用一些位运算的特性,可以很容易计算1的个数。该例子中n - 1为10001011,n & (n - 1)为10001000,可以看到低3位都变成了0。多验证几个例子,可以看出结论,要消除整数n最低位的1,可以使用 n = n & (n-1)。从而计算n的二进制表示有多少个1的算法如图2.2所示。

  本文介绍一个gcc的内置函数,__builtin_ffs(uint32 n),返回n最后一个为1的位是从后向前的第几位,例如若n为10001100,则__builtin_ffs(n)为3,该函数对应的x86汇编代码,O0编译结果如图2.3所示,仅有四条汇编指令。该内置函数64位的版本为__builtin_ffsll(uint64 n)。

  接下来正式介绍本文的优化算法,假设有64个属性,该64个属性用bit位表示是否置脏,共需64bit,将64bit转成uint64的n,每次用__builtin_ffsll找出最低位的1的位置,即可找出一个置脏的属性,然后用n&(n-1)消除最低位的1,当n为0时算法终止。假设64个属性第2个属性和第9个属性置脏,则该64bit编码为0…0100000010,首先用__builtin_ffsll找出低第2位的置脏属性,然后n=n&(n-1),即0…0100000000,再用__builtin_ffsll找出低第9位的置脏属性,然后n=n&(n-1)即为0,算法终止,可看出从64个属性里找出置脏的两个属性,只需要两次操作。需要注意,为尽可能提升性能,脏标记数目最好为64位整数倍,否则假如脏标记数目为120位,取出64位后,需要将剩下的63位拆分成32位、16位、8位,分三次取出,再计算脏标记位置,性能会降低。

3.性能对比

  num类型为uint64,记录所有脏标记,num低第2位、低第64位置脏,即num为100…010。图3.1所示为遍历所有属性,并逐个判断是否置脏,num & 1不为0时,表示第j个属性被置脏,图3.2所示为采用本文的优化算法,index为最低位的脏标记的位置。实测中图3.1耗时2011微秒,图3.2耗时26微秒,性能提升77倍。本文的优化算法不仅适用于属性同步,所有脏标记相关的功能均可采用,例如数据的判断置脏并落地存储。