Golang性能优化中的奇怪现象解析

Golang性能优化中的奇怪现象解析 我一直在学习Go语言,并编写了一个计算Barn斯利蕨类分形的程序。该计算的大部分内容涉及为绘制蕨类所需的随机迭代生成随机数。

作为测试,我在Go中编写了简单的中平方Weyl序列随机数生成器,以查看它是否会使我的程序运行得更快或更慢。使用完全相同的源代码,结果如下:

  • 主Go编译器(版本1.16.3)生成的可执行文件运行速度慢了2.288倍。
  • GNU Go编译器(版本10.2.1)生成的可执行文件运行速度提高了5%。

还需要注意的是,主Go编译器在编译我的原始代码时,生成的可执行文件运行速度已经比GNU Go慢2倍,而在我添加了自定义随机数生成器后,它生成的可执行文件运行速度开始比GNU Go慢4倍。

我在这里发帖,是因为我对结果感到惊讶,并想知道是否有人愿意探究一下发生了什么。由于编译器调优团队可能使用的微基准测试集太小,无法确保良好的性能,我将在这里包含我的两个代码版本以供参考。

原始版本:

/*  fernpar.go -- Compute the Barnsley Fern */

package main

import ("fmt"; "os"; "bufio"; "runtime"; "time"; "math/rand")

const N=800000000

var tictime float64
func tic() {
    now:=time.Now()
    tictime=float64(now.Unix())+1.0E-9*float64(now.Nanosecond())
}
func toc() float64 {
    now:=time.Now()
    return float64(now.Unix())+1.0E-9*float64(now.Nanosecond())-tictime
}

var A=[4][2][2]float64{
    {{0.85, 0.04}, {-0.04, 0.85}},
    {{0.20, -0.26}, {0.23, 0.22}},
    {{-0.15, 0.28}, {0.26, 0.24}},
    {{0.00, 0.00}, {0.00, 0.16}}}

var B=[4][2]float64{
    {0.00, 1.60},
    {0.00, 1.60},
    {0.00, 0.44},
    {0.00, 0.00}}

var P=[4]float64{0.85, 0.07, 0.07, 0.01 }
var cdf [4]float64

func sum(p []float64)float64 {
    r:=0.0
    for i:=0; i<len(p); i++ { r+=p[i] }
    return r
}

func f(i int, x [2]float64) [2]float64 {
    var b [2]float64
    for j:=0; j<2; j++ {
        b[j]=B[i][j]
        for k:=0; k<2; k++ {
            b[j]+=A[i][j][k]*x[k]
        }
    }
    return b
}

func i(p float64) int {
    for j:=0; j<3; j++ {
        if p<=cdf[j] {
            return j
        }
    }
    return 3
}

var xmin=[2]float64{-2.1820,0}
var xmax=[2]float64{2.6558,9.9983}
const border=0.1

const scale=617.0
var image [][]byte

func doinit() {
    for i:=0; i<4; i++ {
        cdf[i]=sum(P[0:i+1])
    }
    var nu [2]int
    for i:=0; i<2; i++ {
        nu[i]=int(scale*(xmax[i]-xmin[i]+2*border))
    }
    image=make([][]byte,nu[1])
    for i:=0; i<nu[1]; i++ {
        image[i]=make([]byte,nu[0])
    }
}

func plot() uint64 {
    count:=uint64(0)
    io,_:=os.Create("fern.pnm")
    fp:=bufio.NewWriter(io)
    fmt.Fprintf(fp,"P4\n")
    fmt.Fprintf(fp,"%d %d\n",len(image[0]),len(image))
    row:=make([]byte,(len(image[0])+7)/8)
    for iy:=len(image)-1; iy>=0; iy-- {
        rx:=0; rb:=byte(0)
        ib:=byte(128)
        for ix:=0; ix<len(image[0]); ix++ {
            if image[iy][ix]!=0 {
                rb|=ib; count++
            }
            ib>>=1
            if ib==0 {
                row[rx]=rb; ib=128
                rb=0; rx++
            }
        }
        if ib!=0 { row[rx]=rb }
        fp.Write(row)
    }
    fp.Flush()
    io.Close()
    return count
}

func point(x [2]float64) {
    var coord [2]int
    for i:=0; i<2; i++ {
        coord[i]=int(scale*(x[i]-xmin[i]+border))
    }
    image[coord[1]][coord[0]]=1
}

func work(s uint64,jmax int,c chan int){
    gen:=rand.New(rand.NewSource(int64(s)))
    var xn=[2]float64{0.0,0.0}
    point(xn)
    for j:=0; j<jmax; j++ {
        xn=f(i(gen.Float64()),xn)
        point(xn)
    }
    c<-0
}

func main(){
    tic()
    ncpu:=runtime.GOMAXPROCS(0)
    doinit()
    fmt.Printf("fernpar.go -- Compute Barnsley's Fern"+
        " (GOMAXPROCS=%d)\n",ncpu)
    fmt.Printf("\nResolution: %d x %d\nIterations: %d\n",
        len(image[0]),len(image),N)
    ret:=make(chan int,ncpu)
    for n:=1;n<ncpu;n++ {
        go work(rand.Uint64(),N/ncpu,ret)
    }
    work(rand.Uint64(),N/ncpu,ret)
    for n:=0;n<ncpu;n++ { <-ret }
    fmt.Printf("Blk Pixels: %d\n",plot())
    tsec:=toc()
    fmt.Printf("\nIteration rate is %g per second.\n",N/tsec)
    fmt.Printf("Total execution time %g seconds.\n",tsec)
    os.Exit(0)
}

修改后的版本:

/*  fernweyl.go -- Compute the Barnsley Fern

    Modified to use the middle-square Weyl-sequence random number
    generator described in https://arxiv.org/abs/1704.00358
 */

package main

import ("fmt"; "os"; "bufio"; "runtime"; "time")

const N=800000000

var tictime float64
func tic() {
    now:=time.Now()
    tictime=float64(now.Unix())+1.0E-9*float64(now.Nanosecond())
}
func toc() float64 {
    now:=time.Now()
    return float64(now.Unix())+1.0E-9*float64(now.Nanosecond())-tictime
}

var A=[4][2][2]float64{
    {{0.85, 0.04}, {-0.04, 0.85}},
    {{0.20, -0.26}, {0.23, 0.22}},
    {{-0.15, 0.28}, {0.26, 0.24}},
    {{0.00, 0.00}, {0.00, 0.16}}}

var B=[4][2]float64{
    {0.00, 1.60},
    {0.00, 1.60},
    {0.00, 0.44},
    {0.00, 0.00}}

var P=[4]float64{0.85, 0.07, 0.07, 0.01 }
var cdf [4]float64

func sum(p []float64)float64 {
    r:=0.0
    for i:=0; i<len(p); i++ { r+=p[i] }
    return r
}

func f(i int, x [2]float64) [2]float64 {
    var b [2]float64
    for j:=0; j<2; j++ {
        b[j]=B[i][j]
        for k:=0; k<2; k++ {
            b[j]+=A[i][j][k]*x[k]
        }
    }
    return b
}

func i(p float64) int {
    for j:=0; j<3; j++ {
        if p<=cdf[j] {
            return j
        }
    }
    return 3
}

var xmin=[2]float64{-2.1820,0}
var xmax=[2]float64{2.6558,9.9983}
const border=0.1

const scale=617.0
var image [][]byte

func doinit() {
    for i:=0; i<4; i++ {
        cdf[i]=sum(P[0:i+1])
    }
    var nu [2]int
    for i:=0; i<2; i++ {
        nu[i]=int(scale*(xmax[i]-xmin[i]+2*border))
    }
    image=make([][]byte,nu[1])
    for i:=0; i<nu[1]; i++ {
        image[i]=make([]byte,nu[0])
    }
}

func plot() uint64 {
    count:=uint64(0)
    io,_:=os.Create("fern.pnm")
    fp:=bufio.NewWriter(io)
    fmt.Fprintf(fp,"P4\n")
    fmt.Fprintf(fp,"%d %d\n",len(image[0]),len(image))
    row:=make([]byte,(len(image[0])+7)/8)
    for iy:=len(image)-1; iy>=0; iy-- {
        rx:=0; rb:=byte(0)
        ib:=byte(128)
        for ix:=0; ix<len(image[0]); ix++ {
            if image[iy][ix]!=0 { 
                rb|=ib; count++
            }
            ib>>=1
            if ib==0 {
                row[rx]=rb; ib=128
                rb=0; rx++
            }
        }
        if ib!=0 { row[rx]=rb }
        fp.Write(row)
    }
    fp.Flush()
    io.Close()
    return count
}

func point(x [2]float64) {
    var coord [2]int
    for i:=0; i<2; i++ {
        coord[i]=int(scale*(x[i]-xmin[i]+border))
    }
    image[coord[1]][coord[0]]=1
}

type rstate struct {
    x,w,s uint64
}
func rint32(p *rstate) uint32 {
    (*p).x*=(*p).x; (*p).w+=(*p).s
    (*p).x+=(*p).w; (*p).x=((*p).x>>32)|((*p).x<<32)
    return uint32((*p).x)
}
func rint64(p *rstate) uint64 {
    r:=uint64(rint32(p))<<32
    return r|uint64(rint32(p))
}
func rfloat(p *rstate) float64 {
    return float64(rint32(p))/(1<<32)
}
var gs=rstate{0,0,0xb5ad4eceda1ce2a9}
func rseed() rstate {
    var p rstate
    p.x=rint64(&gs); p.w=rint64(&gs)
    p.s=rint64(&gs)|1
    return p
}

func work(p *rstate,jmax int,c chan int){
    var xn=[2]float64{0.0,0.0}
    point(xn)
    for j:=0; j<jmax; j++ {
        xn=f(i(rfloat(p)),xn)
        point(xn)
    }
    c<-0
}

func main(){
    tic()
    ncpu:=runtime.GOMAXPROCS(0)
    doinit()
    fmt.Printf("fernweyl.go -- Compute Barnsley's Fern"+
        " (GOMAXPROCS=%d)\n",ncpu)
    fmt.Printf("\nResolution: %d x %d\nIterations: %d\n",
        len(image[0]),len(image),N)
    ret:=make(chan int,ncpu)
    for n:=1;n<ncpu;n++ {
        p:=rseed()
        go work(&p,N/ncpu,ret)
    }
    p:=rseed()
    work(&p,N/ncpu,ret)
    for n:=0;n<ncpu;n++ { <-ret }
    fmt.Printf("Blk Pixels: %d\n",plot())
    tsec:=toc()
    fmt.Printf("\nIteration rate is %g per second.\n",N/tsec)
    fmt.Printf("Total execution time %g seconds.\n",tsec)
    os.Exit(0)
}

如果有人能深入了解为什么修改后的代码在GNU Go上加速,而在主Go编译器上却减速,那将非常有帮助。


更多关于Golang性能优化中的奇怪现象解析的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

听起来可能是指令向量化在起作用。你检查过生成的汇编代码吗?

更多关于Golang性能优化中的奇怪现象解析的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


time.Now() 相当慢。 为什么不使用 math/rand 包来生成随机数呢?

我刚刚更仔细地查看了 gccgo 生成的汇编输出。使用 -O3 优化选项时,编译器将随机数生成器的调用、函数 f 的调用以及索引函数的调用都进行了内联。令人惊讶的是,用于在共享数组中记录像素的函数调用却没有被内联。无论如何,浮点运算使用了标量 SSE2 的 mulsdaddsd 指令。我没有看到任何 AVX 或 AVX512 指令。需要注意的是,用于实现随机数生成器的整数运算看起来也相当标准。

当我使用 -O0 关闭 gccgo 的优化时,其性能表现与 go build 大致相当。我还没有非常仔细地查看 go compile 命令生成的汇编输出。

有没有可能 go build 未能内联函数 f 和随机数生成器?是否有办法让 go build 更激进地进行代码内联?

在优化编译器时使用 go test -bench 框架是合理的,但在程序开始和结束时通过单次调用获取实际时间,可以对系统整体性能进行合理性检查,并允许与其他编程语言进行比较。考虑到运行时间从使用 gccgo 的 3.7 秒到使用 go build 的大约 15 秒不等,两次调用系统时钟的开销似乎微不足道。

您会注意到原始代码确实使用了 math/rand。切换到中平方 Weyl 序列随机数生成器的原因是为了创建一个可重复的计算,以便用于比较不同的编程语言。例如,Go 中的默认随机数生成器与 Julia 中使用的生成器有很大不同。因此,为了对 Julia 中使用的 JIT 生成的代码质量与快速的 Go 编译器进行有意义的比较,需要在程序本身中包含一个完全相同的随机数生成器。请注意,Julia 在相同硬件上运行相同的计算需要 3.9 秒,这使得其性能更接近 gccgo,而不是标准的 go build 工具链。

中平方 Weyl 序列随机数生成器极其简单,并声称具有加密安全性,这可能在某些应用中有用,但显然不适用于生成蕨类植物分形图。然而,从性能角度来看,应该指出,对于这个特定的计算,使用完全相同的随机序列非常重要,因为 point(x) 函数中的赋值模式

image[coord[1]][coord[0]]=1

对硬件级缓存争用和失效很敏感。特别是,为了在编译器版本、编程语言和计算硬件之间进行有意义的比较,程序的任何实现都需要发生相同的写入模式。

虽然 go build 创建的可执行文件比 gccgo 运行得慢并不令人惊讶,但当前代码中 gccgo 有高达四倍的性能优势似乎太多了。我比较了 gccgo 和 go 编译器的汇编输出,以查看是否有任何一方意外地在代码中插入了线程锁以防止数据竞争,但没有发现任何异常。

有人知道为什么 go build 生成的可执行文件性能比 gccgo 慢四倍吗?这是预期的性能差异吗?

这个性能差异主要源于两个编译器对随机数生成和浮点运算的不同优化策略。以下是关键的技术分析:

1. 随机数生成器的差异

主Go编译器(gc)对标准库的math/rand有特殊优化,而GNU Go(gccgo)可能没有:

// 原始版本使用标准库的随机数生成器
gen.Float64()  // 在主Go编译器中有内联和SIMD优化

// 修改版本使用自定义生成器
func rfloat(p *rstate) float64 {
    return float64(rint32(p))/(1<<32)  // 除法操作较重
}

2. 编译器优化差异

主Go编译器更激进地进行内联和逃逸分析:

// 原始版本中,主Go编译器可能内联了rand.Float64()
// 而gccgo可能保持函数调用

// 修改版本中,指针传递可能阻止了某些优化
func work(p *rstate, jmax int, c chan int) {
    // 指针p阻止了内联和寄存器分配优化
}

3. 浮点运算优化

主Go编译器对浮点运算有特定的优化模式:

// 原始版本的浮点运算可能被向量化
for k:=0; k<2; k++ {
    b[j]+=A[i][j][k]*x[k]  // 可能被SIMD优化
}

// 修改版本中,编译器可能无法优化自定义随机数生成的浮点转换
func rfloat(p *rstate) float64 {
    return float64(rint32(p))/(1<<32)  // 除法是标量操作
}

4. 内存布局和缓存

数组和结构体的内存布局差异:

// 原始版本使用全局数组
var A=[4][2][2]float64{...}  // 连续内存,缓存友好

// 修改版本使用结构体指针
type rstate struct {
    x,w,s uint64  // 可能不是缓存最优布局
}

5. 编译器特定的优化

主Go编译器可能对某些模式有特殊优化:

// 原始版本中的模式可能触发了特定优化
gen:=rand.New(rand.NewSource(int64(s)))  // 可能被特殊处理

// 修改版本中的循环可能阻止了优化
for j:=0; j<jmax; j++ {
    xn=f(i(rfloat(p)),xn)  // 复杂的调用链阻止优化
}

6. 性能测试建议代码

要验证这些假设,可以尝试以下测试:

// 测试1:使用接口对比
type Random interface {
    Float64() float64
}

// 测试2:内联优化测试
//go:noinline
func rfloatNoInline(p *rstate) float64 {
    return float64(rint32(p))/(1<<32)
}

// 测试3:避免指针间接寻址
func workCopy(p rstate, jmax int, c chan int) {
    var xn=[2]float64{0.0,0.0}
    point(xn)
    for j:=0; j<jmax; j++ {
        xn=f(i(rfloat(&p)),xn)  // 传递副本地址
        // 每N次迭代更新p以避免复制开销
        if j%1000 == 0 {
            p.x = p.x + p.w  // 简单更新
        }
    }
    c<-0
}

7. 编译器标志影响

不同编译器对优化标志的响应不同:

# 主Go编译器
go build -gcflags="-m -m"  # 查看内联决策
go build -gcflags="-d=ssa/check_bce/debug=1"  # 边界检查

# GNU Go
go build -compiler=gccgo -gccgoflags="-O3 -march=native"

这种性能差异反映了编译器在优化策略上的根本不同。主Go编译器更注重标准库的优化和确定性的执行行为,而GNU Go可能采用更传统的编译器优化技术。对于数值计算密集型代码,编译器选择会显著影响性能。

回到顶部