棋牌AI手记——高性能计数器

0x00 问题来由

最近在做一个斗地主网游的AI。由于斗地主这种游戏,同样数字不同花式的牌是等效的,所以,很多计算我们不需要知道具体是哪几张牌,只需要关心每种牌有多少张。

于是,我引入了一个数据结构,姑且叫做CardsVector,表示每种牌的数量。为了方便描述,本文约定以下方式来表示一个CardsVector,与具体实现无关

X = {3:3, 4:2} // X为一个CardsVector,包含3张3,2张4
其中,X[3] == 3, X[4] == 2

这种数据结构除了初始化,还有两个主要的操作:

  1. 计算一个CardsVector是否包含另一个CardsVector。场景为判断某种出牌组合在不在当前手牌中。比如:
设A={3:2, 4:2, 5:2},X={3:1, 4:1, 5:1},Y={4:3,5:3},Z={4:1, 5:1, 6:1}
那么,A包含A、X,不包含Y、Z
  1. 从一个CardsVector中,减去一个CardsVector。场景为出牌后从手牌中移除出掉的牌,如:
设A={3:2, 4:2, 5:2},X={3:1, 4:1},A移除X后为{3:1, 4:1, 5:2}

0x01 初步方案

一开始,我是这样定义CardsVector的:

type CardsVector [16]int

很简单粗暴,用一个16个元素的int数组来保存,每个元素的值表示下标对应的牌的数量。“判断包含”和“移除”算法如下:

// Contains 判断包含
func (v CardsVector) Contains(other CardsVector) bool {
  for i := 1; i < 16; i++ {
    if v[i] < other[i] {
      return false
    }
  }
  return true
}
// Remove 移除
func (v CardsVector) Remove(other CardsVector) (res CardsVector) {
  for i := 1; i < 16; i++ {
    res[i] = v[i] - other[i]
  }
}

嗯,简单粗暴,性能凑合。如果是一般场合,也就这样算了。可是,对这个数据结构的操作,在我写的棋牌AI中,属于最高频的调用,优化性能收益非常大。所以,身为强迫症患者的我决定进一步压榨性能

0x02 第一次优化

根据我们的需求,总共只有15种牌(A/2/3/4/5/6/7/8/9/10/J/Q/K/小王/大王),不会多也不会少。很多大牛说过,for循环展开有助提高性能。姑且试试。
但有没有性能优化,还是要拉出来溜溜看。于是啪啪啪的写unittest,go test之

go test -bench=.VecContains.
BenchmarkVecContainsUsingForLoop-2      20000000                73.9 ns/op
BenchmarkVecContainsNoForLoop-2         100000000               34.0 ns/op

go test -bench=.VecRemove.
BenchmarkVecRemoveUsingForLoop-2        20000000                82.7 ns/op
BenchmarkVecRemoveNoForLoop-2           20000000                57.3 ns/op

不错不错,有了大幅提升!但,还能不能更好呢?

0x03 不屈不挠的折腾

其实,以我个人性格,如果只提升1倍左右的话,我是懒得专门写文章记录的
注意到这么一点:我们的场景是记录每种牌的数量。一副扑克牌中,每种数字的牌,最多只有4张。上面的数据结构,简单粗暴的用一个[]int来保存。我们能否从这方面入手优化呢?
[]int8的方案我自己先否决了。一个int刚好就是cpu一个字长,处理速度会更快些,即使在不同环境,cpu缓存等不确定因素可能有影响,也不会有太大的提升。
重要事情再默念三遍——每种最多只有4张!每种最多只有4张!每种最多只有4张!。
也就是说,我们可以只用3个位来表示一种牌的数量。3X16=48,貌似一个uint64能解决问题。
但,能表示数据只是一个前提,这个数据结构是否可用,还取决于能否实现上面两个关键操作。而如果每张牌的数据单独通过连串位运算单独提取出来,重复上面的算法,固然是能实现功能的。但这么繁琐的操作,必定令性能大打折扣。这不符合我们的初衷。于是,我们的思路就去到,通过神器的位运算,实现多个位的同时操作

  • 判断包含
    对于问题的描述,我们可以给出以下的等价表达
    a) A包含B
    b) 对于任意一种牌x,A[x] >= B[x]
    c) 对于任意牌x,有A[x]-B[x] >= 0
    对于一个k进制多位数的减法,我们可以表示为(打公式无能,仅用一个4位数示意):
A = a3*k^3+a2*k^2+a1*k^1+a0*k^0  (任意0 <= ax < k)
B = b3*k^3+b2*k^2+b1*k^1+b0*k^0  (任意0 <= bx < k)
A-B=(a3-b3)*k^3+(a2-b2)*k^2+(a1-b1)*k^1+(a0-b0)*k^0
设 cx = ax - bx
当任意ax>=bx时,A-B可以表示为“c3 c2 c1 c0”这个4位k进展数

回到我们的上文的设定,我们一个CardsVector用一个48位uint表示,我们可以视为一个16位的8进制数。但这样,我们拿两个不确定包含关系的CardsVector进行比较时,无法保证任意ax>=bx,相减后的结果无法判断是否符合要求
好吧,我们做一个修正(此处跳过各种不靠谱的草稿本尝试),我们用4位表示一种牌的数量。4X15=60,仍然能用一个uint64表示。这时候,每种牌数量二进制的最高位,必然是0的。然后,我们对上面描述c进一步转化:
d) 对任意牌x,有A[x] + C - B[x] >= C (C为任一常数)
我们在对两个A和B这两个CardsVector相减前,先将A中每种牌的数量最高位置为1,相当于加8。由于原本每种牌最多只有4张,所以必然有A[x]+8>B[x]。但如果A[x]

((A[x] | 0x08) - B[x]) & 0x08 == 0x08

说到这里,我们的Contains函数就呼之欲出了

type CardsVector uint64
const mask CardsVector = 0x8888888888888888 // 多么吉利的数字
func (v  CardsVector ) Contains(other CardsVector ) bool {
  return ((v | mask) - other) & mask == mask
}
  • 移除
    有了上述的推断,在保证A包含B的前提下,A移除B,就刚好等于A-B了,代码就懒得写了

看起来万事俱备,只欠bench了。不废话,来

go test -bench=.Contains
BenchmarkVecContainsUsingForLoop-2      20000000                64.3 ns/op
BenchmarkVecContainsNoForLoop-2         30000000                44.5 ns/op
BenchmarkVecBContains-2                 2000000000               1.09 ns/op

go test -bench=.Remove
BenchmarkVecRemoveUsingForLoop-2        20000000                92.8 ns/op
BenchmarkVecRemoveNoForLoop-2           100000000               63.9 ns/op
BenchmarkVecBRemove-2                   2000000000               1.15 ns/op

0x04 结论

我觉得我不需要说什么了

你可能感兴趣的:(棋牌AI手记——高性能计数器)