堆栈分配优化

本文翻译自 Allocating on the Stack - The Go Programming Language,版权归原作者所有。

我们一直在寻找让 Go 程序更快运行的方法。在过去的两次发布中,我们专注于缓解一个特定的性能瓶颈来源——堆分配。每次 Go 程序从堆上分配内存时,都需要运行相当大一段代码来满足该分配请求。此外,堆分配还会给垃圾回收器带来额外负担。即使有了像 Green Tea 这样的近期改进,垃圾回收器仍然会产生相当可观的开销。

因此,我们一直在研究如何将更多分配操作放在堆栈上而非堆上执行。堆栈分配的开销要小得多(有时甚至完全免费)。此外,它们不会给垃圾回收器带来任何负担,因为堆栈分配可以连同堆栈帧本身一起自动回收。堆栈分配还能实现快速的内存重用,这对缓存非常友好。

固定大小切片的堆栈分配

考虑构建一个待处理任务切片的场景:

func process(c chan task) {
	var tasks []task
	for t := range c {
		tasks = append(tasks, t)
	}
	processAll(tasks)
}

让我们逐步分析从通道 c 拉取任务并添加到切片 tasks 时,运行时发生了什么。

在第一次循环迭代时,tasks 没有底层存储,因此 append 必须分配一个。由于它不知道切片最终会多大,所以不能过于激进。当前,它分配一个大小为 1 的底层存储。

在第二次循环迭代时,底层存储已存在,但已满。append 再次需要分配新的底层存储,这次大小为 2。大小为 1 的旧底层存储现在变成了垃圾。

在第三次循环迭代时,大小为 2 的底层存储已满。append 再次需要分配新的底层存储,这次大小为 4。大小为 2 的旧底层存储现在变成了垃圾。

在第四次循环迭代时,大小为 4 的底层存储中只有 3 个元素。append 只需将元素放入现有的底层存储并增加切片长度即可。太好了!这次迭代无需调用分配器。

在第五次循环迭代时,大小为 4 的底层存储已满,append 再次需要分配新的底层存储,这次大小为 8。

依此类推。我们通常在每次底层存储填满时将分配大小翻倍,这样最终可以在不分配的情况下将大多数新任务追加到切片中。但在切片较小时,“启动"阶段存在相当多的开销。在这个启动阶段,我们花费大量时间在分配器上,并产生一堆垃圾,这看起来相当浪费。而在你的程序中,切片可能永远不会真正变大。启动阶段可能是你唯一会遇到的情况。

如果这段代码是你程序中非常热的部分,你可能会想从更大的大小开始初始化切片,以避免所有这些分配。

func process2(c chan task) {
	tasks := make([]task, 0, 10) // 可能最多 10 个任务
	for t := range c {
		tasks = append(tasks, t)
	}
	processAll(tasks)
}

这是一个合理的优化。它永远不会出错;你的程序仍然能正确运行。如果估计太小,你会像以前一样从 append 获得分配。如果估计太大,你会浪费一些内存。

如果你对任务数量的估计是准确的,那么这个程序中只有一个分配点。make 调用分配了正确大小的切片底层存储,append 永远不需要进行任何重新分配。

令人惊讶的是,如果你用通道中的 10 个元素来基准测试这段代码,你会发现分配次数并没有减少到 1 次,而是减少到了 0 次!

原因是编译器决定在堆栈上分配底层存储。因为它知道需要的大小(10 倍于任务的大小),它可以在 process2 的堆栈帧中为其分配存储空间,而不是在堆上 1。请注意,这取决于底层存储没有在 processAll 内部 逃逸到堆上

可变大小切片的堆栈分配

但当然,硬编码大小估计有点僵化。也许我们可以传入一个估计长度?

func process3(c chan task, lengthGuess int) {
	tasks := make([]task, 0, lengthGuess)
	for t := range c {
		tasks = append(tasks, t)
	}
	processAll(tasks)
}

这让调用者可以为 tasks 切片选择一个合适的大小,这可能会根据调用这段代码的位置而变化。

不幸的是,在 Go 1.24 中,非恒定大小的底层存储意味着编译器不再能将底层存储分配在堆栈上。它最终会落在堆上,将我们的 0 分配代码转换为 1 分配代码。虽然比让 append 执行所有中间分配要好,但仍然令人遗憾。

但别担心,Go 1.25 来了!

假设你决定执行以下操作,以便仅在估计值较小时获得堆栈分配:

func process4(c chan task, lengthGuess int) {
	var tasks []task
	if lengthGuess <= 10 {
		tasks = make([]task, 0, 10)
	} else {
		tasks = make([]task, 0, lengthGuess)
	}
	for t := range c {
		tasks = append(tasks, t)
	}
	processAll(tasks)
}

有点丑,但它能工作。当估计值较小时,你使用恒定大小的 make,从而获得堆栈分配的底层存储;当估计值较大时,你使用可变大小的 make,并从堆上分配底层存储。

但在 Go 1.25 中,你不需要走这条丑陋的道路。Go 1.25 编译器会为你执行这个转换!对于某些切片分配位置,编译器会自动分配一个小的(当前为 32 字节)切片底层存储,如果请求的大小足够小,则使用该底层存储作为 make 的结果。否则,它会像往常一样使用堆分配。

在 Go 1.25 中,如果 lengthGuess 足够小,使得该长度的切片可以放入 32 字节中,则 process3 执行零次堆分配。(当然,lengthGuess 必须是对通道 c 中元素数量的正确估计。)

我们一直在改进 Go 的性能,所以升级到最新版本的 Go,并 惊叹于 你的程序变得多么快速和内存高效!

append 分配切片的堆栈分配

好的,但你仍然不想为了添加这个奇怪的长度估计而改变你的 API。还有什么其他方法吗?

升级到 Go 1.26!

func process(c chan task) {
	var tasks []task
	for t := range c {
		tasks = append(tasks, t)
	}
	processAll(tasks)
}

在 Go 1.26 中,我们在堆栈上分配同样类型的、小的、推测性的底层存储,但现在我们可以直接在 append 站点使用它。

在第一次循环迭代时,tasks 没有底层存储,因此 append 使用一个小的、堆栈分配的底层存储作为第一次分配。例如,如果我们可以将 4 个 task 放入该底层存储中,则第一个 append 从堆栈分配一个长度为 4 的底层存储。

接下来的 3 次循环迭代直接追加到堆栈底层存储,无需任何分配。

在第 4 次迭代时,堆栈底层存储终于满了,我们必须去堆上获取更多底层存储。但我们已经避免了本文前面描述的几乎所有启动开销。没有大小为 1、2 和 4 的堆分配,也没有它们最终变成的垃圾。如果你的切片很小,也许你永远不会进行堆分配。

append 分配逃逸切片的堆栈分配

好的,当 tasks 切片不逃逸时,这一切都很好。但如果我要返回这个切片呢?那么它就不能在堆栈上分配了,对吗?

没错!下面 extract 返回的切片的底层存储不能在堆栈上分配,因为当 extract 返回时,extract 的堆栈帧会消失。

func extract(c chan task) []task {
	var tasks []task
	for t := range c {
		tasks = append(tasks, t)
	}
	return tasks
}

但你可能会想,返回的切片不能在堆栈上分配。但那些只是变成垃圾的中间切片呢?也许我们可以将那些分配在堆栈上?

func extract2(c chan task) []task {
	var tasks []task
	for t := range c {
		tasks = append(tasks, t)
	}
	tasks2 := make([]task, len(tasks))
	copy(tasks2, tasks)
	return tasks2
}

这样,tasks 切片永远不会逃逸 extract2。它可以受益于上面描述的所有优化。然后在 extract2 的最后,当我们知道切片的最终大小时,我们进行一次所需大小的堆分配,将 task 复制进去,然后返回副本。

但你真的想写所有这些额外的代码吗?这似乎容易出错。也许编译器可以为我们做这个转换?

在 Go 1.26 中,它可以!

对于逃逸切片,编译器会将原始的 extract 代码转换为类似这样的东西:

func extract3(c chan task) []task {
	var tasks []task
	for t := range c {
		tasks = append(tasks, t)
	}
	tasks = runtime.move2heap(tasks)
	return tasks
}

runtime.move2heap 是一个特殊的编译器 + 运行时函数,对于已经在堆上分配的切片,它是恒等函数。对于在堆栈上的切片,它在堆上分配一个新切片,将堆栈分配的切片复制到堆副本中,并返回堆副本。

这确保了对于我们原始的 extract 代码,如果元素数量适合我们的小堆栈分配缓冲区,我们执行恰好 1 次分配,且大小完全正确。如果元素数量超过我们的小堆栈分配缓冲区的容量,一旦堆栈分配的缓冲区溢出,我们执行正常的翻倍分配。

Go 1.26 执行的优化实际上比手动优化的代码更好,因为它不需要手动优化代码在末尾始终执行的额外分配 + 复制。它仅在我们在返回点之前完全在堆栈支持的切片上操作的情况下才需要分配 + 复制。

我们确实要付出复制的代价,但这个代价几乎完全被我们不再需要在启动阶段执行的复制所抵消。(事实上,新方案在最坏情况下只需要比旧方案多复制一个元素。)

总结

手动优化仍然可能有益,尤其是如果你提前对切片大小有良好的估计。但希望编译器现在能为你捕获许多简单的情况,让你专注于真正重要的剩余情况。

编译器需要确保许多细节才能正确实现所有这些优化。如果你认为这些优化之一导致了正确性或(负面)性能问题,你可以使用 -gcflags=all=-d=variablemakehash=n 关闭它们。如果关闭这些优化有帮助,请 提交问题,以便我们进行调查。

脚注

  1. Go 堆栈没有任何 alloca 风格的动态大小堆栈帧机制。所有 Go 堆栈帧都是恒定大小的。
comments powered by Disqus