结论

一、GOAMD64 不会让 Go 编译器自动向量化你的数值循环。把 v1 和 v3 编出来的二进制反汇编,涉及 YMM 寄存器的指令条数一模一样。

二、v3 实际带来的变化,集中在编译器能不能用 BMI/ABM 那一小撮位操作指令。在一个 hello-world 级别的程序上,差异是 TZCNT 从 0 条变成 81 条、LZCNT 从 0 条变成 29 条。

三、运行时和标准库里那些真正用到 AVX2 的代码,是手写汇编,靠 CPU 特性检测在运行时选路,和你编译时设的 GOAMD64 没关系。

下面是得出这三条的过程。

实验

环境是 go1.27.3 linux/amd64。先看默认值:

go env GOAMD64
v1

写一个带浮点累加循环的程序,故意留一个"看起来该被向量化"的函数:

mkdir -p /tmp/exp-goamd64 && cd /tmp/exp-goamd64
cat > main.go <<'EOF'
package main

import "fmt"

func sum(a []float64) float64 {
	s := 0.0
	for _, v := range a {
		s += v
	}
	return s
}

func main() {
	fmt.Println(sum([]float64{1, 2, 3, 4}))
}
EOF
go mod init demo
GOAMD64=v1 go build -o bin-v1 .
GOAMD64=v3 go build -o bin-v3 .
ls -l bin-v1 bin-v3 | awk '{print $5, $9}'
2346681 bin-v1
2339641 bin-v3

体积差了 7040 字节。接下来把两个二进制整个反汇编出来数指令:

go tool objdump bin-v1 > dump-v1.txt
go tool objdump bin-v3 > dump-v3.txt
for v in v1 v3; do
  printf '%s total=%s ymm=%s vex=%s\n' "$v" \
    "$(wc -l < dump-$v.txt)" \
    "$(grep -cE '\bY[0-9]+\b' dump-$v.txt)" \
    "$(grep -cE '\bV(MOV|PADD|PSUB|PXOR|PAND|POR|MUL|ZERO|BROADCAST)' dump-$v.txt)"
done
v1 total=146793 ymm=125 vex=379
v3 total=146663 ymm=125 vex=379

ymm=125 和 vex=379 两边完全相同。v3 编出来的二进制没有多一条 256 位指令。总指令数少了 130 条,这才是那 7KB 的来源。

再确认一下 main.sum 本身:

go tool objdump -s 'main\.sum' bin-v3 | grep -cE '\bY[0-9]+\b'
0

那个浮点累加循环在 v3 下也没有被向量化。Go 的 gc 编译器不做自动向量化,GOAMD64 改不了这一点。

那 130 条差在哪

按指令助记符逐个对比,只有一类出现了差异:

for op in LZCNT TZCNT POPCNT ANDN BLSI SHLX BZHI PEXT MULX CRC32; do
  printf '%-7s v1=%-4s v3=%-4s\n' "$op" \
    "$(grep -cE "\b$op\b" dump-v1.txt)" "$(grep -cE "\b$op\b" dump-v3.txt)"
done
LZCNT   v1=0    v3=29
TZCNT   v1=0    v3=81
POPCNT  v1=0    v3=0
ANDN    v1=0    v3=0
BLSI    v1=0    v3=0
SHLX    v1=0    v3=0
BZHI    v1=0    v3=0
PEXT    v1=0    v3=0
MULX    v1=0    v3=0
CRC32   v1=0    v3=0

TZCNT(数末尾零)和 LZCNT(数前导零)合计 110 条,加上连带的寄存器搬运变化,凑出 130 条的差额。这两个指令在 v1 基线里不可用——v1 只保证到 SSE3,TZCNT 属于 BMI1,LZCNT 属于 ABM,都要 v3 才有。

v1 下编译器要表达同样的语义,只能用 BSF/BSR 加标志位判断,或者一小段分支。v3 下换成单条指令,顺带把分支去掉,指令数就下来了。

这解释了 GOAMD64 的真实作用面:它给编译器放开一批标量指令,让位运算和边界判断的 codegen 更紧凑。它不是 SIMD 开关。

那 125 条 YMM 是谁的

把 v3 里用到 YMM 的指令按源文件归类:

grep -E '\bY[0-9]+\b' dump-v3.txt | cut -f1 | sed 's/:.*//' | sort | uniq -c | sort -rn
     39   memmove_amd64.s
     32   preempt_amd64.s
     17   memclr_amd64.s
     13   count_amd64.s
      8   indexbyte_amd64.s
      8   equal_amd64.s
      8   compare_amd64.s

七个文件全是 .s 结尾的手写汇编,分布在 runtime 的内存搬移/清零和 internal/bytealg 的字节比较、计数、查找里。v1 编出来的二进制里,这七个文件的 YMM 条数一字不差。

原因是这些 AVX2 路径不受 GOAMD64 控制,它们在运行时通过 CPU 特性检测选择走哪条分支。也就是说,即便你用 GOAMD64=v1 编译,跑在支持 AVX2 的机器上,memmove 照样会走 256 位那条路。

这是我认为最容易被误解的一点:很多人设 GOAMD64=v3 是期待"用上 AVX2 加速内存操作",而那部分加速本来就已经在发生了。

怎么确认一个现成二进制是用什么级别编的

编译设置被写进了二进制的构建信息里,不需要源码:

go version -m bin-v3 | grep -i goamd64
	build	GOAMD64=v3

拿到一个来路不明的 Go 二进制,先跑这条。它同时能告诉你 Go 版本、构建参数和依赖版本,排查"在别人机器上跑不起来"时很有用。

风险

GOAMD64=v3 编出来的二进制,在不支持 AVX2/BMI 的 CPU 上会执行到非法指令。这是把兼容性换性能的直接代价。

我的做法是:只有当部署目标的 CPU 型号明确、且能用同一条命令核实的时候才设 v3。核实方式是在目标机器上跑:

grep -o -m1 -E 'avx2|bmi1|bmi2|lzcnt|popcnt' /proc/cpuinfo | sort -u | tr '\n' ' '; echo

五个标志都在,才谈得上 v3。少任何一个都不要设。

不确定的一点:Go 运行时在 v3 二进制遇到 v1 CPU 时,到底是直接 SIGILL 崩掉,还是会先打印一句说明再退出,我没有在老 CPU 上实际验证过,也没有找到能模拟指令集缺失的简便办法。所以我在文档里一律按"会崩,且崩得不友好"来写,不给运维留侥幸空间。

另一个分情况的地方是容器镜像。基础镜像和 Go 二进制是两回事——你在 golang:1.x 里设了 GOAMD64=v3 编译,产物拿到别的机器上跑;但如果整个镜像被搬到一台老 CPU 的宿主机上,崩溃点可能在你的程序里,也可能在基础镜像自带的某个工具里,排查方向完全不同。

值不值得设

在这个 hello-world 级别的样本上,v3 换来的是 130 条指令、7KB 体积。真实业务代码里 TZCNT/LZCNT 的出现频率取决于你用了多少位运算、多少 bits.TrailingZeros、多少哈希和掩码操作。写解析器、压缩、加密、位图的代码受益会明显一些;写 HTTP handler 和 CRUD 的代码,差异基本测不出来。

我倾向的默认是不设。理由不是收益小,而是收益小且风险不对称:设对了没人注意到,设错了一批机器起不来。真要设,把它写进构建脚本并附上 CPU 核实步骤,而不是塞进某个人的 shell 环境里——GOAMD64 是环境变量,本地设了忘记,产物就带着一个没人知道的兼容性前提发出去了。