有位知名技术博主贴了一张图片,问两段Go代码的性能优劣:
区别仅在 c<-r
和c<-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
}
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
执行时间快了很多...
有点出乎意料,在 go.godbolt.org[1]查看两段代码的汇编 (要加上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_pc48
main_f_pc32:
MOVW main.r-4(SP), R2
ADD $1, R2, R2
MOVW R2, main.r-4(SP)
ADD $1, R0, R0
main_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), R15
main_f_pc80:
NOP
PCDATA $1, $-1
PCDATA $0, $-2
MOVW R14, R3
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_f_pc0
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_pc32
main_g_pc28:
ADD $1, R1, R1
main_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), R15
main_g_pc68:
NOP
PCDATA $1, $-1
PCDATA $0, $-2
MOVW R14, R3
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_g_pc0
差异如下:
主要区别在于: (来自ChatGPT)
从性能上看:
所以总的来说,g函数的实现更加简洁高效,性能上也比f函数好。
通过清晰定义临时变量和避免多余内存操作,g函数的汇编实现利用了机器堆栈和寄存器更好,效率提升明显。这也符合go语言寄存器优先的设计理念。
再细致了解下关键的两段汇编代码的作用:
FUNCDATA $5, main.f.arginfo1(SB)
MOVW $0, R0
MOVW R0, main.r-4(SP)
MOVW main.n(FP), R1
JMP main_f_pc48
main_f_pc32:
MOVW main.r-4(SP), R2
ADD $1, R2, R2
MOVW R2, main.r-4(SP)
ADD $1, R0, R0
main_f_pc48:
CMP R0, R1
这段汇编代码段是f函数的主体循环部分:
FUNCDATA $5, main.f.arginfo1(SB)
这行声明f函数的参数信息,主要用于支持运行时类型检查。
MOVW $0, R0
MOVW R0, main.r-4(SP)
这两行将循环计数器R0初始化为0,并写入栈帧中定义的r变量地址。
MOVW main.n(FP), R1
JMP 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
循环体内:
main_f_pc48:
CMP R0, R1
循环判断:比对计数器和n参数,是否执行完整轮循环。
所以这个代码段实现了f函数中的主循环累加逻辑:
主要是通过堆栈和几个寄存器交互来实现循环内部的计算。
对于
FUNCDATA $5, main.g.arginfo1(SB)
MOVW main.n(FP), R0
MOVW $0, R1
JMP main_g_pc32
main_g_pc28:
ADD $1, R1, R1
main_g_pc32:
CMP R1, R0
BGT main_g_pc28
这段汇编代码实现的是g函数的主体循环逻辑:
FUNCDATA $5, main.g.arginfo1(SB)
声明g函数的参数信息
MOVW main.n(FP), R0
MOVW $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实现简单高效的计数和累加,避免了定义额外变量的开销。
这就是g函数循环实现的核心差异。
但u1s1,编译器不该屏蔽这样的细节差异吗...要靠这样犄角旮旯的tricks达到最佳性能,一定程度并不符合Go的理念
推荐阅读:Go 函数调用 ━ 栈和寄存器视角[2]
参考资料
[1]
go.godbolt.org: https://go.godbolt.org/
[2]
Go 函数调用 ━ 栈和寄存器视角: https://studygolang.com/articles/21875