Golang泛型是更快了还是慢了? - DoltHub


Go 1.18 已经发布,随之而来的是对泛型的期待已久的支持!泛型是多年来语言最重大的变化。它们为原本极简的类型系统增加了一个新维度。
当初一开始,Golang就通过 “接口”支持动态多态;泛型现在为Golang提供静态多态性。
今天我们将讨论 Golang 对泛型的实现以及它对 CPU 和内存利用率的意义。

泛型很慢?
在这个重要的版本发布之后,出现了一波关于Go中泛型变慢的讨论,以及它们将如何影响现有的Go项目。
到目前为止,最好的分析是Vincent Marti写的这个深度分析。如果你还没有看过,我强烈建议你看一下。
该文章的主要重点是Go编译器如何实现泛型,以及这些设计决策如何对泛型Go代码的性能产生负面影响。
该文章最后总结了Golang泛型的最佳实践:其中包括 "不要重写基于接口的API来使用泛型"。

单态化
在大多数常用的语言中,静态多态性和动态多态性从实现的角度看没有什么共同之处。

  1. 动态多态性最常使用虚拟方法表(简称vtables)来实现,在运行时动态解决方法调用。
  2. 静态多态性通常完全是在编译时实现的,为每个用于调用多态性函数的类型生成一个新版本,这个过程被称为单态化。

在实现泛型时,Golang团队选择了一条中间道路,他们称之为 "字典和Gcshape Stenciling"。
它是静态单态化("模版化")和通过vtables("字典")动态调用的结合。编译器不是为每个用于调用函数的类型编制一个新的函数,而是按 "gcshape "分组调用类型,并为每个gcshape生成一个函数的副本。
一般来说,值类型都有一个独特的gcshape,但引用类型(指针和接口)都共享一个gcshape:

当你在Golang中把引用类型传递给泛型函数时,静态多态性变成了动态函数调度。

这一设计决定的后果是对性能产生了重大影响。这就是为什么Vincent Marti的文章得出结论说泛型会使你的代码变慢的原因。
它的分析特别关注引用类型,并详细解释了当它们与泛型结合使用时,性能如何以及为什么会下降。
然而,讨论中缺少的是泛型与值类型的交互。

泛型是快速的?
我们对泛型的工作原理有了一定的了解,而且我们知道它们并没有为引用类型提供任何明显的性能优势。
所以现在的问题是,对于值类型来说,情况是否有任何不同?
我以前写过关于值类型的性能,以及它们如何比引用类型更好地配合Golang的内存模型。长话短说,值类型更容易减少内存分配,因为Go编译器的转义分析几乎总是将引用类型放在堆上。

.....更多点击标题

总结
我们已经找到了我们的答案:使用带有值型数据结构的算法的通用实现让我们把数据保留在栈上,避免了内存分配的开销。
我们的引用类型的数据结构会逃到堆中,而不管使用的是什么函数。这并不是严格意义上的必要,因为我们在这里创建的引用只在堆栈中传递,而不是在堆栈中传递。
然而,编译器的转义分析没有利用这一事实,而是将其放在堆上。
类似地,我们将我们的值类型数据结构传递给函数的接口实现,我们隐含地将它转换为引用类型,然后它就逃逸到了堆中。
只有当我们把值类型传递给它的通用函数的stenciled副本时,编译器才会避免这种分配。

静态多态性通常被认为是一种面向性能的特性。就像强类型化一样,它给编译器提供了额外的信息,为生成代码时进行更积极的优化提供了机会。这在像C++这样将泛型函数完全单态化的语言中当然是真的。但就像语言本身一样,Golang的泛型并不遵循既定的规则,即事情应该是怎样的。

Golang的泛型函数实际上可以帮助提高性能,但也不是你所期望的那样。编写高性能的Go代码通常意味着将接口类型限制在高层结构中,这正是我们前面看到的原因:它们与转义分析不兼容,而且几乎总是导致昂贵的内存分配。这使得程序员只能使用具体类型,而没有多态性的设施。泛型可能是这个问题的一个答案。

泛型是Golang的一个全新的特性,对它们的支持只会随着时间的推移而改善。我们今天的讨论完全集中在当前的实现上,但在语言规范中没有任何东西可以阻止当前设计的改变。