现代CPU一般具有三级缓存,目的是追求性价比。CPU在存取内存的时候存在缓存命中的问题,因此一般内存占用少的程序有更高的缓存命中率,一般性能会更好,但是内存占用的大小究竟对程序性能有多大影响呢?今天我们用WEB框架中常见数据结构的设计来分析一下性能。

Web框架中间件设计

在Web框架中常见的路由数据结构都是数,每个叶子节点代表一条路由,节点中有所有中间件按顺序串联起来的handlers,一般是函数的切片数组。就拿Golang世界中目前用的最多的Gin框架来说,他支持路由分组,可以对分组应用中间件,最后每个路由节点上生成的执行链,就是把父级分组中所有的中间件和自己的handler函数,包装成[]HandlerFunc切片。

函数其实是一个指针类型,占用8个字节,如果有大量的分组,每个分组有大量的中间件函数,路由也超级多的情况下,一个WEB应用程序光是这个节点的执行链都会占用大量的内存。在访问的过程中势必造成大量的CPU缓存不命中的情况。

如果我们把所有的分组中间件函数放在统一的全局切片中,每个路由不用单独构建自己的执行链切片,只记录在全局切片中的起始位置,以后通过索引的方式访问中间件函数,能节省大量的内存。

下面时两种数据结构算法的公共共享代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package cpu

import (
	"runtime"
)

var GoroutinesNum = 200000   // Goroutines并发数
var NodeCount = 100000       // 路由节点个数
var OneNodeHandlerLen = 2000 // 每个节点中间件个数

func init() {
	runtime.GOMAXPROCS(2)
}

type MidHandler func()

func CusHandlers() {
	//var idx = 1
	//for idx < 1 {
	//	idx++
	//}
	//return 1
}

type TreeNodeMini struct {
	routerIdx  int16
	matchStart uint16

	HdsStart uint16
	HdsLen   uint16
}

type TreeNodeCommon struct {
	routerIdx  int16
	matchStart uint16

	Hds []MidHandler
}

A:类似Gin的数据结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package funcs

import (
	"math/rand"
	"testing"
	"time"
	"yufa/test/cpu"
)

var myTree []cpu.TreeNodeCommon

func init() {
	myTree = make([]cpu.TreeNodeCommon, cpu.NodeCount)

	for i := 0; i < cpu.NodeCount; i++ {
		myTree[i].Hds = make([]cpu.MidHandler, cpu.OneNodeHandlerLen)

		for j := 0; j < cpu.OneNodeHandlerLen; j++ {
			myTree[i].Hds[j] = cpu.CusHandlers
		}
	}
}

func BenchmarkFuncSlice(b *testing.B) {
	b.ReportAllocs()
	b.ResetTimer()
	b.StartTimer()

	rand.Seed(time.Now().UnixNano())

	//i := -1
	// 并发测试模式
	b.SetParallelism(cpu.GoroutinesNum)
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			//i++
			//execNodeCommon(myTree[i%cpu.NodeCount])
			execNodeCommon(myTree[rand.Intn(cpu.NodeCount)])
		}
	})
}

func execNodeCommon(common cpu.TreeNodeCommon) {
	for i := 0; i < len(common.Hds); i++ {
		common.Hds[i]()
	}
}

B:叶子节点共享分组中间件的数据结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package index

import (
	"math/rand"
	"testing"
	"time"
	"yufa/test/cpu"
)

var myTree []cpu.TreeNodeMini
var handlerSlice []cpu.MidHandler

func init() {
	myTree = make([]cpu.TreeNodeMini, cpu.NodeCount)
	handlerSlice = make([]cpu.MidHandler, cpu.OneNodeHandlerLen)

	for i := 0; i < cpu.OneNodeHandlerLen; i++ {
		handlerSlice[i] = cpu.CusHandlers
	}

	for j := 0; j < cpu.NodeCount; j++ {
		myTree[j].HdsStart = 0
		myTree[j].HdsLen = uint16(cpu.OneNodeHandlerLen)
	}
}

func BenchmarkIndexSlice(b *testing.B) {
	b.ReportAllocs()
	b.ResetTimer()
	b.StartTimer()

	rand.Seed(time.Now().UnixNano())
	//i := -1
	// 并发测试模式
	b.SetParallelism(cpu.GoroutinesNum)
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			//i++
			//execNodeMini(myTree[i%cpu.NodeCount])
			execNodeMini(myTree[rand.Intn(cpu.NodeCount)])
		}
	})
}

func execNodeMini(mini cpu.TreeNodeMini) {
	for mini.HdsLen > 0 {
		handlerSlice[mini.HdsStart]()
		mini.HdsLen--
		mini.HdsStart++
	}
}

性能测试

看看A的性能测试结果:

1
2
3
4
5
6
7
8
yufa\test\cpu\funcs> go test -bench=$ -benchmem -benchtime=10s
goos: windows
goarch: amd64
pkg: yufa/test/cpu/funcs
cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz
BenchmarkFuncSlice-2     8081774              1472 ns/op     1 B/op    0 allocs/op
PASS
ok      yufa/test/cpu/funcs     28.299s

image-20211103194221250

再看看B的性能测试结果:

1
2
3
4
5
6
7
8
yufa\test\cpu\index> go test -bench=$ -benchmem -benchtime=10s
goos: windows
goarch: amd64
pkg: yufa/test/cpu/index
cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz
BenchmarkIndexSlice-2            8510996              1403 ns/op      1 B/op   0 allocs/op
PASS
ok      yufa/test/cpu/index     20.322s

image-20211103194349690

总结

你会发现AB两种设计方法,除了A的内存占用比B多1.6GB以外,无论CPU占用情况还是最后的基准测试性能,几乎都是一样的。而多出来的1.6G刚好是每个路由节点都有自己的独立的执行链切片,每个函数变量占用8字节,最后算下来刚好是1.6G左右的空间。

1
2
3
4
5
var NodeCount = 100000       // 路由节点个数
var OneNodeHandlerLen = 2000 // 每个节点中间件个数

// 这种数据结构的内存占用多了下面 1.6G 的内存空间。
100000*2000*8 = 1600000000 = 1.6G

如果将路由节点个数和每个节点的中间件函数个数改小,比如:

1
2
3
var GoroutinesNum = 200000 // 测试时候的并发数
var NodeCount = 100        // 路由节点个数
var OneNodeHandlerLen = 20 // 每个节点中间件个数

测试之后你更会发现,两种情况执行性能几乎无差别。

好吧,经过测试我发现我以前完全想错了,本以为内存占用大的情况下,CPU三级缓存不命中的时候会带来可观的换页开销,但至少目前的测试狠狠的被打脸了,CPU访问内存的性能是惊人的高,CPU三级缓存换页的开销和业务逻辑的运算比起来根本不算啥。

内存当然需要节省,当运行大量程序的时候,内存占用过多可能导致程序直接崩溃。但是刻意节省少量内存不会给你的程序带来太大的性能改观,反而可能因为引入复杂的数据结构,造成代码逻辑复杂,而且中间换算的计算量增大反而消耗掉一部分性能。

(完)