前言
自上篇文章写了 基础编码原则(https://www.jianshu.com/p/0dafe1059fdc
),已经过了一段时间了,此处是对上篇文章中提到的“低级优化”做个说明。
“低级优化”这个名词的含义是针对现代处理器的结构体系来设计代码,使自己运行的程序更充分发挥出处理器应该有的性能。
了解现代处理器
要明白如何进行“低级优化”,首先要知道现代处理器是如何执行一条指令,又在执行指令中会遇到什么情况会导致性能下降?
在单个处理器的设计中,cpu架构师为了提升指令的吞吐量,在处理器中实现了流水线系统。即原本一条指令的执行,需要经过取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)等一系列阶段。如执行一条指令需要等待上一条指令执行,那这个等待是对处理器来讲是一种浪费的行为。于是将一条执令的执行过程划分为许多的周期,在同一个周期内,多条指令能处于不同阶段执行,达到“指令级并行”现象,比如下图:指令i做完了取指阶段,到周期2时,则会进入到了译码阶段,同时会立即让下一条指令i+1进入取指阶段,这样指令i和i+1在周期2并行执行。
在现代处理器为了使每个周期延迟尽可能小,会一将指令的执行划分成非常简单的步骤,一般采用了很深的流水线 (15或更多的阶段)。虽然采用了流水线的执行,但在实际程序执行中,基本不太可能达到处理流水线百分之百饱合。
例如:指令B需要等待指令A在将计算结果写入寄存a1中,指令B才能获取寄存器a1中的数据做执行计算,在指令B等待的过程是会有几个周期的延迟。又或者遇到条件语句(if),如果处理器预测进入流水线执行的指令不对,则需要取消后面加载的指令,重新载入新的指令,这样也会白白流费十几个周期。
正常编写出的程序很难让流水线达到饱和,但我们可以想办法使流水线尽量饱和。以下是通过举一个例子来说明程序如何通过此方式来提高性能,希望能帮助大家理解。
演示代码
func toSum4(result *int) {
k := *result
data := GetData()
dataLength := len(data)
for i:=0;i< dataLength;i++{
k += data[i]
}
*result = k
}
此段代码是上篇文章中已经是经过指令级优过后的代码,现在我们再对它进行"低级优化"
测试数据
此次的测试代码同上次的代码是一样的,由于上次写的文章同此次的测试环境也有所差异,因此对上次的代码还得重新测试
//创建一个6000000000大小的整型切片
func CreateTestData()[]int {
data := make([]int,6000000000)
for index,_ := range data{
data[index] = index % 128
}
return data
}
toSum4()新的测试结果如下:
goos: darwin
goarch: amd64
pkg: GoTest/power
BenchmarkData4-4 1 164891691237 ns/op
PASS
循环展开
循环展开是通过程序的变换,通过增加每次迭代计算的元素,减少循环的迭代次数。对原有tosum4代码做以下的变换
func toSum5(result *int) {
k := *result
data := GetData()
dataLength := len(data)
for i:=1;i< dataLength;i+=2{
k += data[i] + data[i - 1]
}
if dataLength % 2 == 1{
k += data[dataLength-1]
}
*result = k
}
将 k += data[i] 变成 k += data[i] + data[i-1],这样做会带来怎么样的好处呢?
for语句中,每次迭代中都需要进行条件判断(i< dataLength),减少迭代次数,意味着可以节省掉一半的条件判断指令执行(i<datalength),其次每次迭代可以减少几个周期的延迟,因为在后续的指令执行需要等待控制语句更新程序计数器才能往下继续执行,在计数器没有更新时,是不能将计算结果更新到寄存器或内存中的。
提高并行性
虽然我们在上面进行展开了代码,但是还是不能让流水线达到饱合,考虑到 k += data[i] + data[i - 1],此处实际运行中可能是先执行k+=data[i],等计算结果写入到寄存器中才能执行 k+=data[i-1]。对于加法指令可能并不会有太多的周期延迟,但是如果是针对乘除指令就会比较明显。因此,我们可以用多个累积变量来打破顺序等待的过程,让着两步操作'并行'执行。
对 toSume5()函数改动后变成如下:
func toSum6(result *int) {
k1 := 0
k2 := 0
data := GetData()
dataLength := len(data)
for i:=1;i< dataLength;i+=2{
k1 += data[i]
k2 += data[i - 1]
}
//如果是传入的数量是奇数,则单独对最后一个数进行累加
if dataLength % 2 == 1{
k1 += data[dataLength-1]
}
*result = k1 + k2
}
此处改动将原有一个累积变量 k 变成 两个,分别是k1和k2,两个累积量将 k1 += data[i] 和 k2 = data[i-1]之间的指令变成不会相互依赖,指令k2 = data[i-1]不需要等待上条指令的结果才能往下执行。
测试结果
我们对toSum6()进行性能测试,得到以下数据
goos: darwin
goarch: amd64
pkg: GoTest/power
BenchmarkData6-4 1 150068753563 ns/op
PASS
对比toSum4() 和 toSum6()的性能对比又有了明显10s的提升。在这里只是对程序进行2次展开2次并行的处理,如果想让流水线更多饱合,那还可以进行更多的展开和并行处理,当然不是越多越好,考虑到寄存器的有限,如果累加值超过剩余寄存器的数量,增加多余的内存读写操作反而得不偿失,具体展开和并行次数还得根据程序所在的机器运行的情况决定。
总结
本文参考自《深入理解计算机系统》,在此只是举了一个例子,通过展开和并行的优化来加强对流水线的利用,表现出"低级优化"带来的效果。虽然正常的开发中我们很少会这样处理,同时对代码这样改动可能会变得更难理解。但是如果针对频繁运行的核心代码来优化,那这样的优化是非常有必要的。某些大神的开源代码,他们在编码的考虑非常多,希望下次看到类似这种展开和多累积量的代码,能帮助大家更明白其用意。