Golang 性能提高技术----低级优化

前言

自上篇文章写了 基础编码原则(https://www.jianshu.com/p/0dafe1059fdc
),已经过了一段时间了,此处是对上篇文章中提到的“低级优化”做个说明。
“低级优化”这个名词的含义是针对现代处理器的结构体系来设计代码,使自己运行的程序更充分发挥出处理器应该有的性能。

了解现代处理器

要明白如何进行“低级优化”,首先要知道现代处理器是如何执行一条指令,又在执行指令中会遇到什么情况会导致性能下降?

在单个处理器的设计中,cpu架构师为了提升指令的吞吐量,在处理器中实现了流水线系统。即原本一条指令的执行,需要经过取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)等一系列阶段。如执行一条指令需要等待上一条指令执行,那这个等待是对处理器来讲是一种浪费的行为。于是将一条执令的执行过程划分为许多的周期,在同一个周期内,多条指令能处于不同阶段执行,达到“指令级并行”现象,比如下图:指令i做完了取指阶段,到周期2时,则会进入到了译码阶段,同时会立即让下一条指令i+1进入取指阶段,这样指令i和i+1在周期2并行执行。

本图来源:https://www.cnblogs.com/CorePower/p/CorePower.html

在现代处理器为了使每个周期延迟尽可能小,会一将指令的执行划分成非常简单的步骤,一般采用了很深的流水线 (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

提高并行性

虽然我们在上面进行展开了代码,但是还是不能让流水线达到饱合,考虑到 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次并行的处理,如果想让流水线更多饱合,那还可以进行更多的展开和并行处理,当然不是越多越好,考虑到寄存器的有限,如果累加值超过剩余寄存器的数量,增加多余的内存读写操作反而得不偿失,具体展开和并行次数还得根据程序所在的机器运行的情况决定。

总结

本文参考自《深入理解计算机系统》,在此只是举了一个例子,通过展开和并行的优化来加强对流水线的利用,表现出"低级优化"带来的效果。虽然正常的开发中我们很少会这样处理,同时对代码这样改动可能会变得更难理解。但是如果针对频繁运行的核心代码来优化,那这样的优化是非常有必要的。某些大神的开源代码,他们在编码的考虑非常多,希望下次看到类似这种展开和多累积量的代码,能帮助大家更明白其用意。

代码

https://github.com/wpnine/PerformanceExample

你可能感兴趣的:(Golang 性能提高技术----低级优化)