在Golang中使用LEA替代ADD优化amd64性能

在Golang中使用LEA替代ADD优化amd64性能 大家好,

这更像是一个问题,但我并不期望得到确切的答案。只是出于好奇:

我想看看为闭包生成的汇编代码,所以我写了这个:

package main

import (
        "fmt"
)

func main() {
        var x int
        f := func() int {
                x++
                return x - 1
        }
        times(5, &x, f)
}

func times(n int, p *int, f func() int) {
        for i := 0; i < n; i++ {
                fmt.Println("*p:", *p)
                fmt.Println("f():", f())
                fmt.Println("*p:", *p)
                fmt.Println()
        }
}

main.func1 的汇编代码是:

"".main.func1 STEXT nosplit size=20 args=0x8 locals=0x0
	0x0000 00000 (./main.go:9)	TEXT	"".main.func1(SB), NOSPLIT|NEEDCTXT|ABIInternal, $0-8
	0x0000 00000 (./main.go:9)	PCDATA	$0, $-2
	0x0000 00000 (./main.go:9)	PCDATA	$1, $-2
	0x0000 00000 (./main.go:9)	FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x0000 00000 (./main.go:9)	FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x0000 00000 (./main.go:9)	FUNCDATA	$2, gclocals·db688afbc90e26183a53c9ad23b80c29(SB)
	0x0000 00000 (./main.go:9)	PCDATA	$0, $2
	0x0000 00000 (./main.go:9)	PCDATA	$1, $0
	0x0000 00000 (./main.go:9)	MOVQ	8(DX), AX
	0x0004 00004 (./main.go:10)	MOVQ	(AX), CX
	0x0007 00007 (./main.go:10)	LEAQ	1(CX), DX
	0x000b 00011 (./main.go:10)	PCDATA	$0, $0
	0x000b 00011 (./main.go:10)	MOVQ	DX, (AX)
	0x000e 00014 (./main.go:11)	MOVQ	CX, "".~r0+8(SP)
	0x0013 00019 (./main.go:11)	RET

真正让我惊讶的是地址 0x0007 处的 LEAQ 1(CX), DX。我对汇编不是很在行,但我认为这意味着将 CX 中的值加 1,并将结果存储在 DX 中。

经过一番搜索,似乎在旧的奔腾时代,使用 LEA 代替 ADD 是很常见的,因为 LEA 可以在指令解码流水线的更早阶段执行,而 ADD(总是?通常?)必须在 ALU 上执行。

这就是全部原因吗?仅仅是 Go 汇编器为了可能使递增操作更快而进行的微优化?这与那个递增操作无关,而我错过了其他东西吗?我想知道是否有人知道在哪里可以找到关于现代处理器指令选择的更多信息。


更多关于在Golang中使用LEA替代ADD优化amd64性能的实战教程也可以访问 https://www.itying.com/category-94-b0.html

1 回复

更多关于在Golang中使用LEA替代ADD优化amd64性能的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


在amd64架构中,使用LEA指令替代ADD进行算术运算确实是常见的性能优化手段。你的观察是正确的,LEAQ 1(CX), DX确实等价于DX = CX + 1

为什么使用LEA?

LEA指令在现代处理器上有几个优势:

  1. 执行端口更灵活:LEA可以在更多的执行端口上执行,而ADD通常只能在ALU端口执行
  2. 不修改标志位:LEA不影响标志寄存器,避免了潜在的标志位依赖链
  3. 单周期完成:现代CPU上LEA通常可以在一个周期内完成
  4. 支持复杂地址计算:可以同时进行多个算术运算

Go编译器的优化示例

// 原始代码
func addOne(x int) int {
    return x + 1
}

// 编译器可能生成的汇编(简化版)
// 使用ADD
ADDQ $1, AX

// 使用LEA优化
LEAQ 1(AX), AX

更复杂的例子

func calculate(a, b int) int {
    return a*5 + b + 1
}

编译器可能生成:

// 使用LEA一次完成多个运算
LEAQ 1(AX)(AX*4), AX  // AX = AX*5 + 1
ADDQ BX, AX           // 加上b

或者更优化的版本:

LEAQ 1(BX)(AX*4), AX  // AX = a*4 + b + 1
ADDQ AX, AX           // AX = a*8 + 2b + 2
ADDQ BX, AX           // 需要进一步调整

现代处理器的实际情况

在Intel Skylake及以后的架构上:

  • LEA可以在端口1、5、6上执行
  • ADD只能在端口0、1、5、6上执行,但端口0、1通常更繁忙
  • LEA的延迟通常为1周期,吞吐量为1周期/指令

查看Go编译器优化

你可以使用-S标志查看汇编输出:

go build -gcflags="-S" main.go

或者使用Godbolt编译器探索器查看不同优化级别的代码生成。

Go编译器的SSA后端在cmd/compile/internal/ssa/gen/AMD64Ops.go中定义了这些优化规则,包括何时使用LEA替代算术运算。

这种优化是编译器自动进行的,程序员通常不需要手动干预,除非在极端性能敏感的场景下进行手写汇编优化。

回到顶部