写点什么

毫巅之微 --- 不同写法的性能差异 番外篇

作者:fliter
  • 2024-01-24
    上海
  • 本文字数:3140 字

    阅读完需:约 10 分钟

有位知名技术博主贴了一张图片,问两段 Go 代码的性能优劣:



区别仅在 c<-rc<-r+0,直观感觉是不应该有差异。


做一个性能测试,看看结果


main.go:


package main
func f(n int, c chan<- int) { r := 0 for i := 0; i < n; i++ { r += 1 } c <- r}
func g(n int, c chan<- int) { r := 0 for i := 0; i < n; i++ { r += 1 } c <- r + 0}
复制代码


<br>


demo_test.go:


package main
import ( "testing")
func BenchmarkF(b *testing.B) { c := make(chan int) n := 1000000
for i := 0; i < b.N; i++ { go f(n, c) <-c }}
func BenchmarkG(b *testing.B) { c := make(chan int) n := 1000000
for i := 0; i < b.N; i++ { go g(n, c) <-c }}
复制代码


执行 go test -test.bench=".*" -benchmem



第 4 行显示了 BenchmarkF 执行了 495 次,每次的执行平均时间是 2097269 纳秒, 每次操作有 1 次内存分配,每次分配了 24Byte 大小的内存空间


第 5 行显示了 BenchmarkG 执行了 3765 次,每次的平均执行时间是 317891 纳秒, 每次操作有 1 次内存分配,每次分配了 24Byte 大小的内存空间


即 使用c <- r + 0比使用c <- r 执行时间快了很多...


<br>


有点出乎意料,在 go.godbolt.org查看两段代码的汇编 (要加上 func main,不然编译不过)



f:


        TEXT    main.main(SB), LEAF|NOFRAME|ABIInternal, $-4-0        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        JMP     (R14)main_f_pc0:        TEXT    main.f(SB), ABIInternal, $12-8        MOVW    8(g), R1        PCDATA  $0, $-2        CMP     R1, R13        BLS     main_f_pc80        PCDATA  $0, $-1        MOVW.W  R14, -16(R13)        FUNCDATA        $0, gclocals·IuErl7MOXaHVn7EZYWzfFA==(SB)        FUNCDATA        $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)        FUNCDATA        $5, main.f.arginfo1(SB)        MOVW    $0, R0        MOVW    R0, main.r-4(SP)        MOVW    main.n(FP), R1        JMP     main_f_pc48main_f_pc32:        MOVW    main.r-4(SP), R2        ADD     $1, R2, R2        MOVW    R2, main.r-4(SP)        ADD     $1, R0, R0main_f_pc48:        CMP     R0, R1        BGT     main_f_pc32        MOVW    main.c+4(FP), R0        MOVW    R0, 4(R13)        MOVW    $main.r-4(SP), R0        MOVW    R0, 8(R13)        PCDATA  $1, $1        CALL    runtime.chansend1(SB)        MOVW.P  16(R13), R15main_f_pc80:        NOP        PCDATA  $1, $-1        PCDATA  $0, $-2        MOVW    R14, R3        CALL    runtime.morestack_noctxt(SB)        PCDATA  $0, $-1        JMP     main_f_pc0
复制代码


<br>


g:


        TEXT    main.main(SB), LEAF|NOFRAME|ABIInternal, $-4-0        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        JMP     (R14)main_g_pc0:        TEXT    main.g(SB), ABIInternal, $12-8        MOVW    8(g), R1        PCDATA  $0, $-2        CMP     R1, R13        BLS     main_g_pc68        PCDATA  $0, $-1        MOVW.W  R14, -16(R13)        FUNCDATA        $0, gclocals·IuErl7MOXaHVn7EZYWzfFA==(SB)        FUNCDATA        $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)        FUNCDATA        $5, main.g.arginfo1(SB)        MOVW    main.n(FP), R0        MOVW    $0, R1        JMP     main_g_pc32main_g_pc28:        ADD     $1, R1, R1main_g_pc32:        CMP     R1, R0        BGT     main_g_pc28        MOVW    R1, main..autotmp_4-4(SP)        MOVW    main.c+4(FP), R0        MOVW    R0, 4(R13)        MOVW    $main..autotmp_4-4(SP), R0        MOVW    R0, 8(R13)        PCDATA  $1, $1        CALL    runtime.chansend1(SB)        MOVW.P  16(R13), R15main_g_pc68:        NOP        PCDATA  $1, $-1        PCDATA  $0, $-2        MOVW    R14, R3        CALL    runtime.morestack_noctxt(SB)        PCDATA  $0, $-1        JMP     main_g_pc0
复制代码


差异如下:



<br>


主要区别在于: (来自 ChatGPT)


  1. f 函数在循环内部定义了一个 main.r 变量来累加,g 函数直接使用 i 作为累加变量。

  2. f 函数传给 chansend1 调用的是 main.r 变量地址,g 函数直接传 i 计数器地址。


从性能上看:


  • g 函数优于 f 函数,因为不需要额外定义变量来累加,直接使用临时变量 i 更高效。

  • g 函数通过直接传递 i 变量地址,也比 f 函数少了一次内存操作(把 r 变量地址取出来)。(作者注:但从压测结果看,两个方法内存操作次数一样)


所以总的来说,g 函数的实现更加简洁高效,性能上也比 f 函数好。


通过清晰定义临时变量和避免多余内存操作,g 函数的汇编实现利用了机器堆栈和寄存器更好,效率提升明显。这也符合 go 语言寄存器优先的设计理念。



<br>


再细致了解下关键的两段汇编代码的作用:


FUNCDATA        $5, main.f.arginfo1(SB)        MOVW    $0, R0        MOVW    R0, main.r-4(SP)        MOVW    main.n(FP), R1        JMP     main_f_pc48main_f_pc32:        MOVW    main.r-4(SP), R2        ADD     $1, R2, R2        MOVW    R2, main.r-4(SP)        ADD     $1, R0, R0main_f_pc48:        CMP     R0, R1
复制代码


这段汇编代码段是 f 函数的主体循环部分:


FUNCDATA $5, main.f.arginfo1(SB)


这行声明 f 函数的参数信息,主要用于支持运行时类型检查。


MOVW $0, R0MOVW R0, main.r-4(SP) 
复制代码


这两行将循环计数器 R0 初始化为 0,并写入栈帧中定义的 r 变量地址。


MOVW main.n(FP), R1JMP main_f_pc48
复制代码


读取 n 参数的值赋给 R1,并跳转到主循环入口。


main_f_pc32:        MOVW    main.r-4(SP), R2        ADD     $1, R2, R2        MOVW    R2, main.r-4(SP)        ADD     $1, R0, R0
复制代码


循环体内:


  • 将 r 变量值读取到 R2 里

  • R2 加 1 模拟累加

  • 写回 r 变量地址

  • R0 计数器加 1


main_f_pc48:        CMP     R0, R1
复制代码


循环判断:比对计数器和 n 参数,是否执行完整轮循环。


所以这个代码段实现了 f 函数中的主循环累加逻辑:


  • 每轮循环都读取更新 r 变量的值

  • 将结果通过 channel 发送出去


主要是通过堆栈和几个寄存器交互来实现循环内部的计算。


<br>


对于


FUNCDATA        $5, main.g.arginfo1(SB)        MOVW    main.n(FP), R0        MOVW    $0, R1        JMP     main_g_pc32main_g_pc28:        ADD     $1, R1, R1main_g_pc32:        CMP     R1, R0        BGT     main_g_pc28
复制代码


这段汇编代码实现的是 g 函数的主体循环逻辑:


FUNCDATA $5, main.g.arginfo1(SB)


声明 g 函数的参数信息


MOVW main.n(FP), R0MOVW $0, R1 
复制代码


读取 n 参数值赋给 R0,计数器 R1 初始化为 0


JMP main_g_pc32


跳转到主循环入口


main_g_pc28:        ADD     $1, R1, R1
复制代码


循环体内:R1 计数器加 1


main_g_pc32:        CMP     R1, R0        BGT     main_g_pc28
复制代码


循环判断:比较 R1 和 R0,是否完成 n 次循环


与 f 函数不同的是,g 函数直接使用循环计数器 R1 作为累加变量,不需要额外定义变量。


所以整体逻辑是:


  • R1 作为循环计数器和累加器

  • 每轮循环内 R1 自增 1

  • 判断是否完成 n 轮循环


通过寄存器 R1 实现简单高效的计数和累加,避免了定义额外变量的开销。


这就是 g 函数循环实现的核心差异。


<br>


但 u1s1,编译器不该屏蔽这样的细节差异吗...要靠这样犄角旮旯的 tricks 达到最佳性能,一定程度并不符合 Go 的理念


<br>


推荐阅读:Go 函数调用 ━ 栈和寄存器视角

用户头像

fliter

关注

www.dashen.tech 2018-06-21 加入

Software Engineer. Focus on Micro Service,Containerization

评论

发布
暂无评论
毫巅之微---不同写法的性能差异 番外篇_fliter_InfoQ写作社区