加速CQ码解析背后原理
前言
在开源项目 go-cqhttp 中,我提交了两个 pr(#659,#660) 使得CQ码
(一种用字符串表示IM消息的方式)效率变为了原来的400%。 这篇文章简单介绍了加速解析的原理,里面的某些优化方法对 Go 语言是通用的,
希望能对读者优化 Go 程序有一定启发。
CQ码解析的原方案
CQ码的语法规则
CQ码的表示可以为[1]
1CQCode
2 : "[CQ:" Type (',' ParameterList)? ']'
3 ;
4
5Type
6 : string
7 ;
8
9ParameterList
10 : Parmeter (',' Parmeter)*
11 ;
12
13Parameter
14 : string '=' string
15 ;
其中Type是消息类型,ParameterList是参数列表
正则表达式解析
在最初解析方案是采用正则表达式,使用标准库中的regex包进行解析
1var matchReg = regexp.MustCompile(`\[CQ:\w+?.*?]`)
2var typeReg = regexp.MustCompile(`\[CQ:(\w+)`)
3var paramReg = regexp.MustCompile(`,([\w\-.]+?)=([^,\]]+)`)
自动机解析
使用正则表达式可以获得比较高的通用性,可读性,可维护性,但是为了压榨性能,手写自动机就是优化解析的重要途经。 Mrs4s大佬给出了一份自动机解析的实现[2]
优化加速
在上一版自动机中,效率比正则表达式提高了 30% ,但这对于性能压榨还不够彻底,于是我便开始了优化之路。
优化后的初稿如下
1func ParseCQCode(s string) Message {
2 var d = map[string]string{} // parameters
3 var key, Type = "", ""
4 l := len(s)
5 i, j := 0, 0
6S1: // Plain Text
7 for ; i < l; i++ {
8 if s[i] == '[' && s[i:i+4] == "[CQ:" {
9 if i > j {
10 saveText(s[j:i])
11 }
12 i += 4
13 j = i
14 goto S2
15 }
16 }
17 goto End
18S2: // CQCode Type
19 d = map[string]string{}
20 for ; i < l; i++ {
21 switch s[i] {
22 case ',': // CQ Code with params
23 Type = s[j:i]
24 i++
25 j = i
26 goto S3
27 case ']': // CQ Code without params
28 Type = s[j:i]
29 i++
30 j = i
31 saveCQCode()
32 goto S1
33 }
34 }
35 goto End
36S3: // CQCode param key
37 for ; i < l; i++ {
38 if s[i] == '=' {
39 key = s[j:i]
40 i++
41 j = i
42 goto S4
43 }
44 }
45 goto End
46S4: // CQCode param value
47 for ; i < l; i++ {
48 switch s[i] {
49 case ',': // more param
50 d[key] = CQCodeUnescapeValue(s[j:i])
51 i++
52 j = i
53 goto S3
54 case ']':
55 d[key] = CQCodeUnescapeValue(s[j:i])
56 i++
57 j = i
58 saveCQCode()
59 goto S1
60 }
61 }
62 goto End
63End:
64 if i > j {
65 saveText(s[j:i])
66 }
67}
去除不必要的转换
在上一版自动机中,将整个字符串转换成了 []rune ,观察到 CQ 码的结构中只含有 ASCII 字符,根据 UTF-8 的性质,
所有的非 ASCII 字符的个个字节都大于128,所以对于解析CQ码,我们并不需要将字符串转换成 []rune 。
设计一个更全面的状态机
在上一版状态机中,状态数目较少,只能解析出整个CQ码的字符串,将Type和参数取出来,需要使用Split分割, 于是我设计了一个更加全面的状态机。
相比之前的状态机,可以更加精细的解析CQ码的内容,避免了重复操作,通过一次遍历就完成了所有 CQ码的解析。
避免拷贝,使用切片进行内存复用
对于CQ码,我们只需要从字符串中提取一部分,而且后续的操作中,我们不会对字符串进行修改,所以我是用了切片操作来 截取字符串,尽可能的减少内存分配
消除边界检查
Go语言是一门内存安全的语言,程序运行过程中会进行边界检查,这对程序的稳定避免发生更严重的错误很重要,但是有些情况下, 我们可以保证一定不会发生数组(切片)越界,这时边界检查就变得多余了。
在 Go 编译器中,实现了对某些情况的边界检查消除,例如标准库encoding/binary中
1func (littleEndian) PutUint32(b []byte, v uint32) {
2 _ = b[3] // early bounds check to guarantee safety of writes below
3 b[0] = byte(v)
4 b[1] = byte(v >> 8)
5 b[2] = byte(v >> 16)
6 b[3] = byte(v >> 24)
7}
在第二行中,提前检查了边界,Go编译器就会对后续的4行操作不进行边界检查。 Go对边界检查消除优化还不够全面,往往需要我们手动提示编译器进行边界检查消除。
例如下面代码中,对is的范围进行暗示
1func foo(is []int, bs []byte) {
2 if len(is) >= 256 {
3 is = is[:256] // 给编译器一个暗示
4 for _, n := range bs {
5 _ = is[n] // 边界检查消除了!
6 }
7 }
8}
虽然我重写了一遍状态机, 为了探究性能并无很大提升的原因,我使用了
1go build -gcflags="-d=ssa/check_bce/debug=1"
查找了代码中所有的边界检查,结果发现代码中几乎所有切片操作都进行了边界检查,
同时,在我的自动机实现代码中,保证了索引一定不会越界 (i < l),于是我尝试给编译器一定
提示,尝试消除边界检查。
不幸的是,我尝试了已知的所有 Hint 手段,编译器始终不能进行边界检查消除,这时候我选择
另一条道路—— unsafe。
通过 unsafe 操作,实现边界消除。
unsafe (×)
I say it is safe (√)
使用了unsafe操作后的代码如下
1// add 指针运算
2add := func(ptr unsafe.Pointer, offset uintptr) unsafe.Pointer {
3 return unsafe.Pointer(uintptr(ptr) + offset)
4}
5
6S1: // Plain Text
7 for ; i < l; i++ {
8 if *(*byte)(add(ptr, uintptr(i))) == '[' && i+4 < l &&
9 *(*uint32)(add(ptr, uintptr(i))) == magicCQ { // Magic :uint32([]byte("[CQ:"))
10 if i > j {
11 saveText(s[j:i])
12 }
13 i += 4
14 j = i
15 goto S2
16 }
17 }
18 goto End
19S2: // CQCode Type
20 d = map[string]string{}
21 for ; i < l; i++ {
22 switch *(*byte)(add(ptr, uintptr(i))) {
23 case ',': // CQ Code with params
24 Type = s[j:i]
25 i++
26 j = i
27 goto S3
28 case ']': // CQ Code without params
29 Type = s[j:i]
30 i++
31 j = i
32 saveCQCode()
33 goto S1
34 }
35 }
36 goto End
37S3: // CQCode param key
38 for ; i < l; i++ {
39 if *(*byte)(add(ptr, uintptr(i))) == '=' {
40 key = s[j:i]
41 i++
42 j = i
43 goto S4
44 }
45 }
46 goto End
47S4: // CQCode param value
48 for ; i < l; i++ {
49 switch *(*byte)(add(ptr, uintptr(i))) {
50 case ',': // more param
51 d[key] = CQCodeUnescapeValue(s[j:i])
52 i++
53 j = i
54 goto S3
55 case ']':
56 d[key] = CQCodeUnescapeValue(s[j:i])
57 i++
58 j = i
59 saveCQCode()
60 goto S1
61 }
62 }
63 goto End
64End:
65 if i > j {
66 saveText(s[j:i])
67 }
68 return
同时,我将判断是否是CQ码头 "[CQ:" 改成了用 uint32 进行比较(与大小端有关),进一步优化了性能.
最终的结果是十分的 Amazing 啊,比重写前的状态机快了几倍。
减少map的创建
目前的实现已经十分快了,正当我觉得没有任何可以优化的地方时,我突然发现了用于保存CQ码参数的map可以进行内存复用。
Go 编译器对map的清空进行了优化,下面这样的代码,Go 编译器会将它优化成一个runtime内部函数,对整个map一次清空。
1var a map[type1]type2
2
3for k := range a {
4 delete(a, k)
5}
虽然并不能释放已使用的内存,但是我们可以复用内存,减少map的扩容,减少alloc,大大提高运行速度。
sync 包中的 sync.Pool 实现线程安全的内存复用。于是我做了一个小改动
1S2: // CQCode Type
2 // d = map[string]string{} // old
3 for k := range a {
4 delete(a, k)
5 }
6 for ; i < l; i++ {
7 // ignore
8 }
9 goto End
经过这次改动后,CQ码的解析速度又提高了33%。
总结
最后总结下来,优化Go程序的性能可以从下面几个角度入手
- 善于内存复用,使用切片或
sync.Pool减少内存分配。 - 尽量避免不必要的类型转换带来的开销,例如
[]byte与string之间转换,[]rune与string之间的转换。 - 合理暗示编译器,避免不必要的越界检查。