Golang中使用[3]float32实现的Vec3性能不如结构体

Golang中使用[3]float32实现的Vec3性能不如结构体 我正在处理一些图形相关的项目,并且注意到我的Go代码运行速度不如预期。

经过一番调查,我推测问题可能出在通过值传递我的Vec3上。因此,我将所有Vec3(定义为 [3] float32)分配在堆上,并使用 *Vec3 指针来操作,这使得我的代码快了很多(速度提升了两倍)。

第二天,我决定检查生成的汇编代码,这时我注意到我的代码是通过栈来传递Vec3 [3] float32 的。偶然间,我将Vec3转换为了 struct { X, Y, Z float32 },结果发现Go编译器不再通过栈传递它了(可能改为通过寄存器传递),并且这个结构体版本的速度与指向数组的指针版本一样快,甚至可能更快。

我的问题是,为什么Go编译器对数组版本的处理方式不同?编译器难道不能像处理结构体版本那样,通过寄存器传递它吗?

以下是我的基准测试代码:

package main

import (
	"testing"
)

type ArrVec3 [3]float32

func (v ArrVec3) Sub(u ArrVec3) (res ArrVec3) {
	res[0] = v[0] - u[0]
	res[1] = v[1] - u[1]
	res[2] = v[2] - u[2]
	return
}

func (v ArrVec3) Mul(t float32) (res ArrVec3) {
	res[0] = v[0] - t
	res[1] = v[1] - t
	res[2] = v[2] - t
	return
}

func (v ArrVec3) Dot(u ArrVec3) float32 {
	return u[0]*v[0] + u[1]*v[1] + u[2]*v[2]
}

type Vec3 struct {
	X, Y, Z float32
}

func (v Vec3) Sub(u Vec3) (res Vec3) {
	res.X = v.X - u.X
	res.Y = v.Y - u.Y
	res.Z = v.Z - u.Z
	return
}

func (v Vec3) Mul(t float32) (res Vec3) {
	res.X = v.X * t
	res.Y = v.Y * t
	res.Z = v.Z * t
	return
}

func (v Vec3) Dot(u Vec3) float32 {
	return u.X*v.X + u.Y*v.Y + u.Z*v.Z
}

func BenchmarkArrVec3(b *testing.B) {
	v := ArrVec3{1, 2, 3}
	n := ArrVec3{0, 1, 0}
	for i := 0; i < b.N; i++ {
		v.Sub(n.Mul(v.Dot(n) * 2))
	}
}


func BenchmarkVec3(b *testing.B) {
	v := Vec3{1, 2, 3}
	n := Vec3{0, 1, 0}
	for i := 0; i < b.N; i++ {
		v.Sub(n.Mul(v.Dot(n) * 2))
	}
}

这是godbolt链接:Compiler Explorer 如果我在阅读汇编代码时犯了任何错误,请见谅,这是我第一次阅读Go的汇编代码。


更多关于Golang中使用[3]float32实现的Vec3性能不如结构体的实战教程也可以访问 https://www.itying.com/category-94-b0.html

2 回复

MoustaphaSaad:

我的问题是,为什么 Go 编译器不区别对待数组版本?编译器难道不能像处理结构体版本那样,将其通过寄存器传递吗?

它很可能可以,但根据我对 Go 内部 ABI 规范 的理解,Go 团队可能没有费心去处理这个问题,具体来说:

非平凡的数组总是通过栈传递,因为对数组进行索引通常需要一个计算出的偏移量,而这通常无法用寄存器实现。总的来说,数组在函数签名中很少见(在 Go 1.15 标准库中仅占 0.7%,在 kubelet 中占 0.2%)。我们考虑过允许数组字段通过栈传递,而参数的其他字段通过寄存器传递,但如果被调用函数获取了参数的地址,这会产生与其他大型结构体相同的问题,并且只会让 kubelet 中不到 0.1% 的函数受益(即使对这些函数来说,收益也微乎其微)。

我不确定他们所说的“非平凡”具体指什么,因为 float32 数组在我看来似乎相当“平凡”,但在这里使用结构体似乎确实是最佳解决方案。

更多关于Golang中使用[3]float32实现的Vec3性能不如结构体的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


在 Go 编译器中,数组和结构体的参数传递优化确实存在差异。根据 Go 的调用约定,当结构体字段较少且大小适中时,编译器会尝试通过寄存器传递,而数组通常通过栈传递。以下是具体分析:

1. 参数传递差异

Go 1.17+ 引入了基于寄存器的调用约定(ABIInternal),对于小型结构体(如包含 2-3 个 float32 字段),编译器会优先使用寄存器传递。但数组目前仍被视为单一值,即使元素较少,也可能通过栈传递。

2. 汇编代码对比

Sub 方法为例,结构体版本可能生成如下优化代码(AMD64):

// Vec3.Sub 可能通过寄存器传递参数
MOVSS X0, res.X  // 使用 SSE 寄存器
MOVSS X1, res.Y
MOVSS X2, res.Z

而数组版本可能通过栈复制:

// ArrVec3.Sub 可能通过栈复制
MOVQ  v+0(FP), AX  // 从栈加载地址
MOVQ  u+24(FP), BX
MOVSS (AX), X0     // 额外内存访问

3. 性能影响

栈传递会导致:

  • 额外的内存读写
  • 限制寄存器优化
  • 更高的调用开销

4. 验证示例

可通过以下代码观察内存分配差异:

//go:noinline
func testStruct(v Vec3) Vec3 {
    return v
}

//go:noinline  
func testArray(v ArrVec3) ArrVec3 {
    return v
}

func main() {
    s := Vec3{1, 2, 3}
    a := ArrVec3{1, 2, 3}
    println(testStruct(s))
    println(testArray(a))
}

使用 go build -gcflags="-m -m" 查看编译器优化决策。

5. 建议方案

对于性能敏感的场景:

  1. 使用结构体而非数组定义向量
  2. 考虑使用 -m 编译标志分析优化决策
  3. 对于需要数组语义的情况,可尝试以下包装方式:
type Vec3 struct {
    arr [3]float32
}

func (v Vec3) X() float32 { return v.arr[0] }
// 提供类似数组的访问接口,同时保留结构体优化特性

这种差异源于 Go 编译器对类型布局的不同处理,目前结构体在寄存器传递优化方面更具优势。

回到顶部