十亿行挑战(1️⃣🐝🏎️ The One Billion Row Challenge)
原始仓库
文中源码仓库: https://github.com/zzhaolei/1brc
文本文件包含了一系列气象站的温度值。每行是一个测量值,格式为<string: station name>;<double: measurement>
,其中测量值精确到一位小数。以下是一些示例行:
1
2
3
4
5
6
7
8
9
10
| Hamburg;12.0
Bulawayo;8.9
Palembang;38.8
St. John's;15.2
Cracow;12.6
Bridgetown;26.9
Istanbul;6.2
Roseau;34.4
Conakry;31.2
Istanbul;23.0
|
任务是编写一个程序,该程序读取文本文件,计算每个气象站的最低、平均和最高温度值,并将结果输出到stdout
,
格式如下(按气象站名称字母顺序排序,并且每个气象站的结果值格式为<min>/<mean>/<max>
,保留一位小数点):
1
| {Abha=-23.0/18.0/59.2, Abidjan=-16.2/26.0/67.3, Abéché=-10.0/29.4/69.0, Accra=-10.1/26.4/66.4, Addis Ababa=-23.7/16.0/67.0, Adelaide=-27.8/17.3/58.5, ...}
|
只能使用标准库实现。
生成十亿行挑战所需的数据#
克隆原始仓库:
1
2
3
| git clone https://github.com/gunnarmorling/1brc
cd 1brc/src/main/python
python3 create_measurements.py 1000000000
|
生成的数据会在1brc/measurements.txt
,约为15Gi
的大小。
机器配置:
1
2
3
4
5
| goos: darwin
goarch: arm64
pkg: demo
cpu: Apple M2
core: 8
|
下面是一步步进行优化的顺序:
1. 基础结果#
将生成的measurements.txt
文件软链到当前目录:
1
| ln -s <path>/1brc/data/measurements.txt measurements.txt
|
先编译,然后再执行,这样可以忽略掉编译所造成的耗时:
1
2
| go build -o base.out baseline/main.go
./base.out measurements.txt > base.txt
|
base.txt
是基线结果,后续的优化可以和这个结果进行对比。
这将会打印毫秒的耗时信息,执行三次,取其平均数为135656
毫秒,耗时约为135.66秒
。
2. 协程#
将文件分块,并使用协程进行处理。协程数量需要调试出一个合适的值,这里使用的是25
。
1
2
| go build -o go.out goroutine/main.go
./go.out measurements.txt > go.txt
|
和base.txt
进行对比,看是否有差异:
1
| diff --side-by-side --suppress-common-lines base.txt go.txt
|
命令的输出结果应该显示没有差异,并且命令的退出状态码为0
。
取三次结果的平均数约为36818
毫秒,耗时为36.812秒
。减少了约98.84秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
3 更多优化#
3.1#
不使用scanner
,改为直接读取固定缓冲区的方案。
这样做可以减少对line
和args
的内存分配,直接引用buffer
的内存,延后到对map
操作时再解析。
手动解析换行符
和城市
、温度
,引用buffer
的内存,也可以减少直接分配。
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
| func scan(r io.Reader, meas map[string]*Meas) {
buffer := make([]byte, BufferSize)
remain := make([]byte, 0, BufferSize)
for {
n, _ := r.Read(buffer[:len(buffer)-len(remain)])
if n == 0 {
break
}
remain = append(remain, buffer[:n]...)
var (
cityByte []byte
tempByte []byte
next bool
newBuf = remain
)
for {
cityByte, tempByte, newBuf, next = parseLine(newBuf)
if !next { // 没有下一行,退出循环重新读取
copy(remain, newBuf)
remain = remain[:len(newBuf)]
break
}
city := string(cityByte)
temp, _ := strconv.ParseFloat(string(tempByte), 64)
v, ok := meas[city]
if !ok {
v = &Meas{}
meas[city] = v
}
v.min = min(temp, v.min)
v.max = max(temp, v.max)
v.sum += temp
v.count += 1
}
}
}
func parseLine(buffer []byte) (city []byte, temp []byte, buf []byte, next bool) {
end := 0
for i, b := range buffer {
if b != '\n' {
continue
}
next = true
end = i
break
}
if !next {
buf = buffer
return
}
idx := 0
for i, b := range buffer[:end] {
if b == ';' {
idx = i
break
}
}
city = buffer[:idx]
temp = buffer[idx+1 : end]
buf = buffer[end+1:]
return
}
|
平均耗时约为17497
毫秒,17.50秒
。减少了约19.32秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
3.2#
现在,每次city
都需要转为string
(city := string(cityByte)
),然后再去map中查询,这样会平白增加一次不必要的内存分配,可以考虑使用新的hash
函数生成key
。
在go
标准库中,有hash/fnv
和hash/maphash
,经过测试,fnv
的速度快一些。
使用fnv
重构(map
的key
类型也要进行相应的修改),这样city
只需要分配一次即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| hash.Reset()
_, _ = hash.Write(cityByte)
key := hash.Sum64()
temp, _ := strconv.ParseFloat(string(tempByte), 64)
v, ok := meas[key]
if !ok {
v = &Meas{city: string(cityByte)}
meas[key] = v
}
v.min = min(temp, v.min)
v.max = max(temp, v.max)
v.sum += temp
v.count += 1
|
平均耗时约为13060.66
毫秒,13.06秒
。减少了约4.437秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
goroutine-2 | 13060 |
3.3#
通过go
的pprof
来查看火焰图,进行针对性的优化。在main
函数中增加以下代码,以启用cpu pprof
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| ...
f, err := os.Create("cpu.prof")
if err != nil {
log.Fatal(err)
}
defer func() {
_ = f.Close()
}()
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
multiProcess(data, chunks)
|
可以通过以下命令,在浏览器中查看:
1
| go tool pprof -http=:8080 cpu.prof
|
火焰图:
通过查看火焰图可以发现,strconv.ParseFloat
比较耗时,官方的实现会涉及很多的处理,但是其实我们都不需要,只是假设全是标准的浮点数就行,类似-1.2
,只有一位小数。
既然假设数据是标准的了,那么可以先将[]byte
的浮点数,转为整数,最后计算的时候再转为保留一位小数的浮点数处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| func parseNumber(data []byte) int64 {
var (
result int64
isNegative bool
)
for _, b := range data {
if b == '-' {
isNegative = true
continue
}
if b >= '0' && b <= '9' {
result = result*10 + int64(b-'0')
}
}
if isNegative {
result = -result
}
return result
}
|
平均耗时约为10576.33
毫秒,10.58秒
。减少了约2.48秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
goroutine-2 | 13060 |
goroutine-3 | 10576 |
3.4#
调整rectifyChunk
中的校正结束行的逻辑,减少一次读取的buffer
为128
,一行数据不会很长。
1
| bufio.NewReaderSize(file, 128).ReadBytes('\n')
|
平均耗时约为10400.66
毫秒,10.40秒
。减少了约175.67毫秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
goroutine-2 | 13060 |
goroutine-3 | 10576 |
goroutine-4 | 10400 |
3.5#
通过一番搜索和查阅资料,发现可以再次优化hash
速度(参考这个文章)。
1
2
3
4
5
6
7
| func hash(name []byte) uint64 {
var h uint64 = 5381
for _, b := range name {
h = (h << 5) + h + uint64(b)
}
return h
}
|
平均耗时约为10092.33
毫秒,10.09秒
。减少了约308毫秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
goroutine-2 | 13060 |
goroutine-3 | 10576 |
goroutine-4 | 10400 |
goroutine-5 | 10092 |
3.6#
在go1.24
中,默认的map
实现改为使用swiss map
,速度又能带来提升。由于go1.24
还未发布(2025.01.20),可以先使用gotip
构建:
1
2
| gotip build -o opt.out optimize/main.go
./opt.out measurements.txt > opt.txt
|
平均耗时约为8271.33
毫秒,8.27秒
。减少了约1.82秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
goroutine-2 | 13060 |
goroutine-3 | 10576 |
goroutine-4 | 10400 |
goroutine-5 | 10092 |
goroutine-6(swissmap) | 8271 |
最终成果#
平均耗时约为8271.33
毫秒,8.27秒
。
程序 | 耗时(ms) |
---|
baseline | 135656 |
goroutine | 36818 |
goroutine-1 | 17497 |
goroutine-2 | 13060 |
goroutine-3 | 10576 |
goroutine-4 | 10400 |
goroutine-5 | 10092 |
goroutine-6(swissmap) | 8271 |
对比最初的baseline
实现,性能提升了约16.40
倍。