详解泛型是如何让你的GO代码变慢插图

Go 1.18就在这里,有了它,期待已久的泛型实现的第一个版本终于准备好用于生产使用。泛型是一个经常被请求的特性,在整个 Go 社区中一直备受争议。一方面,批评者担心增加的复杂性。他们担心 Go 不可避免地会演变为具有通用工厂的冗长和企业级 Java 精简版,或者最可怕的是,将ifs 替换为 Monad 的退化 HaskellScript。平心而论,这两种担忧都可能被夸大了。另一方面,泛型的支持者认为它们是大规模实现干净和可重用代码的关键特性。

这篇博文不支持这场辩论,也不建议在 Go 中何时何地使用泛型。相反,这篇博文是关于泛型难题的第三个方面:它是关于对泛型本身并不感兴趣的系统工程师,而是关于单态化及其性能影响的系统工程师。

1.18 中的泛型实现

在编程语言中实现参数多态(我们通常称之为“泛型”)有许多不同的方法。让我们简要讨论一下这个问题空间,以了解 Go 1.18 中已经实现的解决方案。由于这是一篇关于系统工程的博客文章,我们将使这种类型理论的讨论变得轻松而轻松。而不是技术术语,我们将经常使用“事物”这个词。

假设您要创建一个多态函数,即一个对不同事物模糊地操作的函数。从广义上讲,有两种方法可以解决这个问题。

第一种方法是使函数将操作的所有事物看起来和行为都相同。这种方法称为“装箱”,它通常涉及分配堆上的东西并将指向它们的指针传递给我们的函数。由于所有的东西都有相同的形状(它们是指针!),我们需要对它们进行操作就是知道这些东西的方法在哪里。因此,传递给我们函数的东西的指针通常伴随着一个函数指针表,通常称为“虚拟方法表”或简称为 vtable。这会敲响警钟吗?这就是 Go 接口的实现方式,但也dyn TraitRust 中的 s 和 C++ 中的虚拟类。这些都是在实践中易于使用的多态形式,但受限于它们的表现力和运行时开销。

使函数对许多不同事物进行操作的第二种方法称为“单态化”。这个名字可能听起来很吓人,但它的实现相对简单。它归结为为它必须操作的每一个独特的东西创建一个不同的函数副本。就是这样,真的。如果您有一个添加两个数字的函数,并且您调用它来添加两个float64s,编译器会创建该函数的副本并将泛型类型占位符替换为float64,然后编译该函数。到目前为止,它是实现多态性的最简单方法(即使有时它在实践中变得非常难以使用),而且对于编译器来说也是最昂贵的。

从历史上看,单态化一直是在 C++、D 或 Rust 等系统语言中实现泛型的首选设计。造成这种情况的原因有很多,但归根结底是用更长的编译时间换取结果代码的显着性能提升。当您在编译器执行任何优化传递之前将泛型代码中的类型占位符替换为它们的最终类型时,您将创建一个令人兴奋的优化领域,这在使用装箱类型时基本上是不可能的。至少,你可以去虚拟化函数调用并摆脱虚拟表;在最好的情况下,您可以使用内联代码,这反过来又可以进行进一步的优化。内联代码很棒. 单态化是系统编程语言的全部胜利:本质上,它是唯一一种运行时开销为零的多态形式,而且通常具有性能开销。它使通用代码更快

所以,作为一个致力于大型 Go 应用程序性能的人,我承认我对 Go 中的泛型并不是特别兴奋,真的。我对单态化以及 Go 编译器执行在处理接口时根本无法进行的优化的潜力感到兴奋。让我失望的是:Go 1.18 中的泛型实现不使用单态化……至少,不完全是。

它实际上基于一种称为“GCShape stenciling with Dictionaries”的部分单态化技术。这个技术选择背后的全部细节可以在上游存储库中的这个设计文档中找到。为了完整起见,并指导这篇文章的性能分析,我将对其进行快速总结:

核心思想是,由于根据输入参数对每个函数调用进行完全单态化会导致生成大量代码,因此我们可以通过比参数类型更广泛的单态化来减少独特函数形状的数量. 因此,在泛型的这个实现中,Go 编译器基于参数的GCShape而不是它们的类型来执行单态化(他们称之为“模板化”) 。类型的GCShape是特定于 Go 和泛型实现的抽象概念。正如设计文档所述,当且仅当它们具有相同的基础类型或它们都是指针类型时,两个具体类型才在同一个 gcshape 分组中。这个定义的第一部分很简单:如果你有一个方法,例如,对其参数执行算术运算,Go 编译器将根据它们的类型有效地对其进行单态化。使用积分算术指令生成uint32的代码将不同于float64使用浮点指令的 代码。uint32另一方面,为 的类型别名生成的代码将与底层的相同uint32

到目前为止,一切都很好。然而, GCShape定义的第二部分具有巨大的性能影响。让我强调一点:所有指向对象的指针都属于同一个GCShape,而不管指向的对象是什么。这意味着*time.Time指针具有与a 、 a和 a相同的GCShape。这可能会让你想知道:“嗯,那么,当我们想在这些对象上调用方法时会发生什么?这种方法的位置不可能是 GCShape 的一部分!” . 好吧,设计的名称对我们来说是破坏了这一点:GCShapes不知道方法,所以我们需要谈谈伴随它们的字典。*uint64*bytes.Buffer*strings.Builder

在 1.18 中的当前泛型实现中,泛型函数的每次运行时调用都将透明地接收作为其第一个参数的静态字典,其中包含有关传递给函数的参数的元数据。该字典将放置在AXAMD64 的寄存器中,以及 Go 编译器尚不支持基于寄存器的调用约定的平台中的堆栈中。这些字典的完整实现细节在上述设计文档中进行了深入解释,但作为总结,它们包括所有必需的类型元数据,以将参数传递给进一步的泛型函数,将它们从/转换为接口,最相关的是我们,在他们身上调用方法。没错,之后在单态化步骤中,生成的函数形状需要将其所有通用参数的虚拟方法表作为运行时输入。直观地说,虽然这大大减少了生成的唯一代码的数量,但这种广泛的单态化并不适合去虚拟化、内联或任何类型的性能优化。

事实上,对于绝大多数 Go 代码来说,使其通用化似乎意味着它会变慢。但在我们开始陷入绝望的深坑之前,让我们运行一些基准测试,查看一些程序集并验证一些行为。

接口内联

Vitess是为 PlanetScale提供支持的开源分布式数据库,它是一个大型且复杂的现实世界 Go 应用程序,可作为新 Go 语言功能(尤其是与性能相关的功能)的出色测试平台。我碰巧有一长串 Vitess 中当前手动单态化的函数和实现(这是一种奇特的说法,即“复制和粘贴,但类型不同”)。其中一些函数是重复的,因为它们的多态性不能用接口建模;其他一些是重复的,因为它们对性能至关重要,并且在没有接口的情况下编译它们可以提供可衡量的性能增益。

让我们看看这个列表中的一个很好的候选:包BufEncodeSQL中的函数sqltypes。这些函数已被复制为采用 a*strings.Builder或 a *bytes.Buffer,因为它们对提供的缓冲区执行许多调用,如果缓冲区作为未装箱类型而不是接口传递,编译器可以内联这些调用。这导致在整个代码库中广泛使用的函数中获得有意义的性能提升。

使这段代码通用是微不足道的,所以让我们这样做并将函数的通用版本与以 aio.ByteWriter作为接口的简单版本进行比较。

详解泛型是如何让你的GO代码变慢插图1

该版本的程序集io.ByteWriter没有什么令人惊讶的:所有调用都WriteByte通过itab. 我们稍后将准确回顾这意味着什么。不过,通用版本变得更加有趣。我们看到的第一件事是编译器为函数 ( )生成了单个形状实例化BufEncodeStringSQL[go.shape.*uint8_0]。虽然我们没有在内联视图中显示它,但我们必须使用*strings.Builderfrom 可访问代码调用泛型函数;否则编译器根本不会为函数生成任何实例:

var sb strings.Builder
BufEncodeStringSQL(&sb, []byte(nil))

由于我们以 a*strings.Builder作为参数调用该函数,因此我们在生成的程序集中看到了*uint8. 如前所述,所有将指针作为泛型参数的泛型调用都被模板化为*uint8,而不管指向的对象是什么。对象的实际属性——最重要的是它的itab——存储在传递给通用函数的字典中。

这一切都与我们在设计文档中阅读的内容相匹配:传递指向结构的指针的模板过程将指针单态化为类似 void 的指针。在单态化过程中不考虑指向对象的其他属性,因此不可能进行内联可以内联的结构的方法信息仅在运行时在字典中可用。这已经很糟糕了:我们已经看到,这种模板背后的设计不允许对函数调用进行去虚拟化,因此,它没有为编译器提供任何内联机会。但是等等,情况会变得更糟!

WriteByte通过将生成的程序集调用接口代码中的方法与通用代码进行比较,我们可以在这段代码中进行深入的性能分析。

调用 Go 中的接口方法

在我们比较两个版本的代码之间的调用之前,我们需要快速回顾一下接口是如何在 Go 中实现的。我们已经简单地谈到了接口是一种涉及装箱的多态形式,即确保我们操作的所有对象具有相同的形状。对于 Go 接口,这个形状是一个 16 字节的胖指针 ( iface),其中前半部分指向有关装箱值的元数据(我们称之为itab),后半部分指向值本身。

type iface struct {
	tab *itab
	data unsafe.Pointer
}

type itab struct {
	inter *interfacetype // offset 0
	_type *_type // offset 8
	hash  uint32 // offset 16
	_     [4]byte
	fun   [1]uintptr // offset 24...
}

itab包含大量关于接口内部类型的信息。,和字段包含所有必需的元数据inter,以允许接口之间的转换、反射和切换接口的类型。但是这里我们关心的是:末尾的数组,虽然在类型描述中显示为 a ,但这实际上是一个变长分配。结构的大小在特定接口之间变化,结构末尾有足够的空间来存储接口中每个方法的函数指针。这些函数指针是我们每次要调用接口上的方法时需要访问的;它们是 Go 等价于 C++ 虚拟表。_typehashfunitab[1]uintptritab

考虑到这一点,我们现在可以理解函数的非泛型实现中接口方法的调用程序集。这就是第 8 行buf.WriteByte('\\')编译成的内容:

0089  MOVQ "".buf+48(SP), CX
008e  MOVQ 24(CX), DX
0092  MOVQ "".buf+56(SP), AX
0097  MOVL $92, BX
009c  CALL DX

要调用WriteByteon 方法buf,我们首先需要一个指向itabfor的指针buf。虽然buf最初是通过一对寄存器传递给我们的函数,但编译器在函数体的开头将其溢出到堆栈中,以便它可以将寄存器用于其他事情。要调用一个方法buf,我们首先必须从堆栈中加载*itab回寄存器( CX)。现在,我们可以取消对itabin 指针的引用CX来访问它的字段:我们将 offset 处的双字移动24DX中,快速查看itab上面的原始定义表明,确实,第一个函数指针itab位于 offset 处24——到目前为止,这一切都说得通。

通过DX包含我们要调用的函数的地址,我们只是缺少它的参数。Go 所谓的“结构附加方法”是对一个独立函数的糖分,该函数将其接收者作为其第一个参数,例如func (b *Builder) WriteByte(x byte)func "".(*Builder).WriteByte(b *Builder, x byte). 因此,我们函数调用的第一个参数必须是,指向存在于我们接口内的buf.(*iface).dataa 的实际指针。该指针在堆栈中可用,在我们刚刚加载的指针之后 8 个字节。最后,我们函数的第二个参数是字面量(ASCII 92),我们可以执行我们的方法。strings.Buildertab\\CALL DX

呸!这是调用简单方法的一些努力。尽管在实际性能方面,它并没有那么糟糕。除了通过接口调用总是阻止内联这一事实之外,调用的实际开销是从itab. 稍后我们将对此进行基准测试,以了解取消引用的代价有多大,但首先,让我们看看通用代码的代码生成器。

回到泛型:指针调用

我们回到通用函数的程序集。提醒一下,我们正在分析为 生成的实例化形状*uint8,因为所有指针实例化形状都使用相同的类似 void 的指针类型。让我们看看这里调用WriteByte方法的buf样子:

008f  MOVQ ""..dict+48(SP), CX
0094  MOVQ 64(CX), CX
0098  MOVQ 24(CX), CX
009c  MOVQ "".buf+56(SP), AX
00a1  MOVL $92, BX
00a6  CALL CX

它看起来很熟悉,但有一个明显的区别。Offset0x0094包含我们不希望函数调用站点包含的内容:另一个指针取消引用。再次,这个技术术语是完全无赖。这是发生的事情:由于我们将所有指针形状单形化为单个形状实例化*uint8,因此该形状不包含有关可以在这些指针上调用的方法的任何信息。这些信息将保存在哪里?理想情况下,它会存在于itab与我们的指针相关联的地方,但没有itab直接与我们的指针相关联,因为我们函数的形状采用单个 8 字节指针作为其buf参数,而不是带有*itaband的 16 字节胖指针。data字段,就像接口一样。如果您还记得,这就是模板实现将字典传递给每个泛型函数调用的全部原因:该字典包含指向itab函数所有泛型参数的 s 的指针。

好的,因此带有额外负载的组装现在非常有意义。方法调用开始,而不是加载itabfor our buf,而是加载已传递给我们的泛型函数(并且也已溢出到堆栈中)的字典。使用 中的字典CX,我们可以取消引用它,并且在偏移量 64 处我们找到了*itab我们正在寻找的。遗憾的是,我们现在需要另一个解引用 ( 24(CX))来从itab. 方法调用的其余部分与前面的代码生成相同。

这种额外的取消引用在实践中有多糟糕?直观地说,我们可以假设在泛型函数中调用对象的方法总是比在简单地将接口作为参数的非泛型函数中慢,因为泛型会将以前的指针调用转移到两次间接接口中调用,表面上比普通接口调用慢。

name                      time/op      alloc/op     allocs/op
Monomorphized-16          5.06µs ± 1%  2.56kB ± 0%  2.00 ± 0%
Iface-16                  6.85µs ± 1%  2.59kB ± 0%  3.00 ± 0%
GenericWithPtr-16         7.18µs ± 2%  2.59kB ± 0%  3.00 ± 0%

这个简单的基准测试使用 3 个略有不同的实现来测试相同的函数体。GenericWithPointer将 a 传递*strings.Builder给我们的func Escape[W io.ByteWriter](W, []byte)通用函数。Iface基准是针对func Escape(io.ByteWriter, []byte)直接采用接口的。Monomorphized用于手动单态化func Escape(*strings.Builder, []byte).

结果并不奇怪。专门用于*strings.Builder直接接受 a 的函数是最快的,因为它允许编译器内联其中的WriteByte调用。io.ByteWriter泛型函数比以接口作为参数的最简单实现要慢得多。我们可以看到,来自泛型字典的额外负载的影响并不显着,因为itab在这个微基准测试中,泛型字典和泛型字典在缓存中都会非常温暖(但是,请继续阅读以分析缓存争用如何影响通用代码)。

这是我们可以从该分析中收集到的第一个见解:在 1.18 中没有动力将采用接口的函数转换为使用泛型。它只会让它变慢,因为 Go 编译器目前无法生成通过指针调用方法的函数形状。相反,它将引入具有两层间接的接口调用。这与我们想要的方向完全相反,即去虚拟化,并在可能的情况下进行内联。

在结束本节之前,让我们指出 Go 编译器逃逸分析中的一个细节:我们可以看到我们的单态化函数2 allocs/op在我们的基准测试中。这是因为我们传递了一个指向strings.Builder堆栈中的 a 的指针,编译器可以证明它没有逃逸,因此不需要堆分配。Iface基准测试显示,3 allocs/op即使我们也从堆栈中传递了一个指针。这是因为我们将指针移动到接口,并且总是分配。令人惊讶的是,GenericWithPointer实施还表明3 allocs/op. 即使为函数生成的实例化直接获取指针,转义分析也不能再证明它是非转义的,因此我们获得了额外的堆分配。那好吧。这是一个小小的失望,但现在是时候转向更大、更好的失望了。

通用接口调用

在过去的几节中,我们一直在Escape通过查看使用*strings.Builder. 如果您还记得,我们方法的通用签名是func Escape[W io.ByteWriter](W, []byte),并且*strings.Builder肯定满足该约束,从而产生 的实例化形状*uint8

但是,如果我们将我们*strings.Builder隐藏在界面后面会发生什么?

var buf strings.Builder
var i io.ByteWriter = &buf
BufEncodeStringSQL(i, []byte(nil))

我们的泛型函数的参数现在是一个接口,而不是一个指针。并且调用显然是有效的,因为我们传递的接口与我们方法的约束相同。但是我们生成的实例化形状是什么样的呢?我们没有嵌入完整的反汇编,因为它真的很吵,但就像我们之前所做的那样,让我们​​分析WriteByte函数中方法的调用站点:

00b6  LEAQ type.io.ByteWriter(SB), AX
00bd  MOVQ ""..autotmp_8+40(SP), BX
00c2  CALL runtime.assertI2I(SB)
00c7  MOVQ 24(AX), CX
00cb  MOVQ "".buf+80(SP), AX
00d0  MOVL $92, BX
00d5  CALL CX

大呀!与我们之前的代码生成相比,这看起来确实不太熟悉。我们同意(并衡量)每个调用站点上的额外取消引用不是一件好事,所以想象一下我们应该如何看待整个额外的函数调用。

这里发生了什么?我们可以在 Go 运行时中找到该runtime.assertI2I方法:它是断言接口之间转换的助手。它接受一个*interfacetype和一个*itab作为它的两个参数,并且仅当给定的接口也实现了我们的目标接口itab时才返回给定的一个。啊,啥?interfacetype itab

假设你有一个这样的界面:

type IBuffer interface {
	Write([]byte) (int, error)
	WriteByte(c byte) error
	Len() int
	Cap() int
}

这个接口没有提到io.ByteWriteror io.Writer,然而,任何实现的类型IBuffer也隐式地实现了这两个接口。这对我们的泛型函数的代码生成产生了有意义的影响:由于对我们函数的泛型约束是[W io.ByteWriter],我们可以将任何实现的接口作为参数传递io.ByteWriter——这包括类似IBuffer. 但是当我们需要调用WriteByte参数上的方法时,这个方法在我们itab.fun收到的接口的数组中的哪个位置存在?我们不知道!如果我们将 our*strings.Builder作为io.ByteWriter接口传递,则itab该接口中的 将有我们的方法 at fun[0]。如果我们将它作为 传递IBuffer,它将位于fun[1]。我们需要的是一个可以itabIBuffer并返回一个itabfor an io.ByteWriter,我们的WriteByte函数指针总是稳定在fun[0]

这就是 的工作assertI2I,也是函数中的每个调用站点都在做的事情。让我们一步一步地分解它。

00b6  LEAQ type.io.ByteWriter(SB), AX
00bd  MOVQ ""..autotmp_8+40(SP), BX
00c2  CALL runtime.assertI2I(SB)
00c7  MOVQ 24(AX), CX
00cb  MOVQ "".buf+80(SP), AX
00d0  MOVL $92, BX
00d5  CALL CX

首先,它将for (这是一个硬编码的全局,因为这是我们的约束中定义的接口类型)加载interfacetypeio.ByteWriterAX到. 然后,它将我们传递给函数的接口的实际值加载itabBX到. 这是assertI2I需要的两个参数,在调用它之后,我们只剩下itabfor io.ByteWriterin 了AX,我们可以像在之前的 codegen 中那样继续调用接口函数,因为我们知道我们的函数指针现在总是在 offset 24inside我们的itab. 本质上,这个形状实例化正在做的是将每个方法调用从 转换buf.WriteByte(ch)buf.(io.ByteWriter).WriteByte(ch)

是的,这看起来很贵。是的,它看起来也很多余。难道不能io.ByteWriter itab在函数开始时只获取一次并在所有函数调用中重用它吗?嗯,不是在一般情况下,但有一些函数形状可以安全地执行(例如,我们当前正在分析的函数),因为buf接口内的值永远不会改变,我们不需要类型切换或将buf接口向下传递到堆栈中的任何其他功能。Go 编译器肯定有一些优化空间。让我们看看基准数据,看看这种优化会产生多大的影响:

name                      time/op      alloc/op     allocs/op
Monomorphized-16          5.06µs ± 1%  2.56kB ± 0%  2.00 ± 0%
Iface-16                  6.85µs ± 1%  2.59kB ± 0%  3.00 ± 0%
GenericWithPtr-16         7.18µs ± 2%  2.59kB ± 0%  3.00 ± 0%
GenericWithExactIface-16  9.68µs ± 2%  2.59kB ± 0%  3.00 ± 0%

那不是很好。调用的开销assertI2I是显而易见的,即使是在一个不仅仅是调用其他函数的函数中也是如此。我们的速度几乎是直接调用的手动单态化函数的两倍WriteByte,并且比简单地使用io.ByteWriter没有泛型的接口慢 30%。无论如何,这是一个需要注意的性能枪:相同的泛型函数,具有相同的参数,如果您在接口内部传递参数而不是直接作为指针传递,将会显着变慢。

…可是等等!我们还没有在这里完成!您可能已经从我们对基准案例的仔细命名中猜到了,还有更多令人着迷的性能细节要分享。事实证明,我们的GenericWithExactIface基准测试实际上是最好的方案,因为我们函数中的约束是[W io.ByteWriter]并且我们将参数作为io.ByteWriter接口传递。这意味着runtime.assertI2I调用将立即返回itab我们传递给它的 – 因为它与itab我们的形状实例化正在寻找的相匹配。但是,如果我们将参数作为先前定义的IBuffer接口传递呢?这应该可以正常工作,因为*strings.Builder同时实现IBufferio.ByteWriter,但是在运行时,我们函数内的每个方法调用都会导致assertI2I试图io.ByteWriter itab从我们的IBuffer参数中获取一个时,全局哈希表查找。

name                      time/op      alloc/op     allocs/op
Monomorphized-16          5.06µs ± 1%  2.56kB ± 0%  2.00 ± 0%
Iface-16                  6.85µs ± 1%  2.59kB ± 0%  3.00 ± 0%
GenericWithPtr-16         7.18µs ± 2%  2.59kB ± 0%  3.00 ± 0%
GenericWithExactIface-16  9.68µs ± 2%  2.59kB ± 0%  3.00 ± 0%
GenericWithSuperIface-16  17.6µs ± 3%  2.59kB ± 0%  3.00 ± 0%
详解泛型是如何让你的GO代码变慢插图1

哈哈,厉害 这是一个非常酷的见解。我们已经从高性能footgun 升级为footcannon,这完全取决于您传递给泛型函数的接口是否与其约束完全匹配,或者是约束的超集。这可能是该分析中最突出的一点:将接口传递给 Go 中的泛型函数绝不是一个好主意。在最好的情况下,如果您的接口与约束完全匹配,您将看到对您的类型的每个方法调用的显着开销。在您的接口是约束的超集的可能情况下,每个方法调用都必须从哈希表中动态解析,并且没有为此功能实现缓存。

在结束本节之前,在确定 Go Generics 的开销是否适合您的用例时,需要考虑以下几点:该基准中显示的数字是最佳情况值,特别是对于接口调用,而不是代表函数调用在现实世界应用程序中的开销。这些微基准是在真空中运行的,其中itab通用函数的 和 字典在缓存中始终是温暖的,而使能的全局itabTableassertI2I的和无竞争的。实际生产服务中存在缓存争用,全局itabTable可能包含数亿到数百万条目数,具体取决于您的服务运行了多长时间以及已编译代码中唯一类型/接口对的数量。这意味着 Go 程序中的通用方法调用开销将随着代码库的复杂性而降低。这并不是什么新鲜事,因为降级实际上会影响 Go 程序中的所有接口检查,但这些接口检查通常不会像函数调用那样在紧密循环中执行。

有没有办法在合成环境中对这种退化进行基准测试?有,但不是很科学。你可以用条目污染全局itabTable,并不断地从一个单独的 Goroutine 丢弃 L2 CPU 缓存。这种方法可以任意增加任何被基准测试的通用代码的方法调用开销,但是很难在其中创建一个itabTable与我们在实时生产服务中看到的内容准确匹配的争用模式,因此测量的开销很难转化为更真实的环境。

尽管如此,在这些基准测试中观察到的行为仍然非常有趣。这是一个微基准测量 Go 1.18 中不同可能的方法调用代码生成的方法调用开销(每次调用以纳秒为单位)的结果。被测试的方法有一个非内联的空主体,所以这是严格测量调用开销。基准测试运行了 3 次:在真空中,L2 缓存的连续颠簸,以及颠簸和一个itabTable包含itab我们正在寻找的冲突的大大扩大的全局。

图片

我们可以看到,真空中的方法调用开销与我们在 Escape 基准测试中看到的类似。当我们添加争用时会发生有趣的行为:正如我们所料,非泛型方法调用的性能不受 L2 缓存争用的影响,而所有泛型代码的开销(即使是不访问全局- 很可能是因为所有通用方法调用都itabTable必须访问更大的运行时字典)。当我们增加L2 缓存垃圾的大小时,真正灾难性的组合发生了:它在每个方法调用之后引入了大量开销,因为全局itabTableitabTable太大而无法放入缓存中,并且相关条目不再温暖。同样,从这个微基准无法有意义地辨别出确切的开销量。这取决于生产环境中 Go 应用程序的复杂性和负载。这个实验的重要收获是,这种幽灵般的远距离动作存在于通用 Go 代码中,所以要小心它,并针对你的用例进行衡量。

字节序列

Go 代码库中有一个非常常见且反复出现的模式,甚至可以在整个标准库中看到,其中将[]byte切片作为其参数的函数也将具有相同的等价物,string而不是取而代之。

我们可以在任何地方找到这种模式(例如(*Buffer).Writevs (*Buffer).WriteString),但是这个encoding/utf8包确实是一个很好的例子,它开始成为一个问题:大约 50% 的 API 表面是重复的方法,这些方法已经被手动单态化以支持[]bytestring.

字节细绳
DecodeLastRuneDecodeLastRuneInString
DecodeRuneDecodeRuneInString
FullRuneFullRuneInString
RuneCountRuneCountInString
ValidValidString

值得指出的是,这种重复实际上是一种性能优化:API 很可能只提供[]byte对 UTF8 数据进行操作的函数,强制用户在调用包之前将其string输入转换为。[]byte这不会特别不符合人体工程学,但会非常昂贵。由于 Go 中的字节切片是可变的,而strings 不是,因此在它们之间的任一方向转换总是[1]强制分配。

大量的代码重复确实看起来像是泛型的多汁目标,但是由于首先重复代码是为了防止额外的分配,所以在我们尝试统一实现之前,我们必须确保生成的形状实例的行为与我们一样期望他们这样做。

让我们比较一下该函数的两个不同版本:一个以 a作为输入Valid的原始版本,以及一个新的通用版本,它受 a 约束,一个非常简单的约束应该允许我们互换使用这两种参数类型。

详解泛型是如何让你的GO代码变慢插图3

在我们查看新泛型函数的形状之前,我们应该查看非泛型代码生成中的一些优化细节,以便我们可以验证它们在泛型实例化过程中是否存在。我们可以看到两个不错的优化和另一个不太好的优化:首先, 1.16 中引入的基于寄存器的 Go 调用约定在我们的[]byte参数中表现得很好。这个函数接收到的切片头的 24 个字节不是被推入堆栈,而是作为 3 个指针在 3 个寄存器中单独传递:*byte切片的指针驻留在AX整个函数体中,其长度驻留在 中BX,它们永远不会溢出. 我们可以看到比较复杂的表达式比如len(p) >= 8compile intoCMPQ BX, $8因为这种有效的寄存器使用。同样, 32/ 64 位加载 fromp被适当优化为MOVLORLfromAX

这个编译函数中唯一令人讨厌的细节发生在主for循环中:行中的pi := p[i]加载19有一个边界检查,本应该通过上面循环头中的i < n检查而变得多余。我们可以在生成的程序集中看到,我们实际上是一个接一个地链接了两个跳转:a JGE(这是一个有符号比较指令)和 a JAE(这是一个无符号比较指令)。这是一个隐蔽的问题,因为lenin Go 的返回值是签名的,并且可能值得在自己的博客文章中发表。

无论哪种方式,这个函数的非通用代码生成Valid总体上看起来都不错。让我们将它与通用实例化进行比较!我们只是在[]byte这里讨论一个参数的形状;使用参数调用泛型函数string将生成不同的形状,因为这两个的内存布局不同(16 字节为string,24 为[]byte)即使它在两个实例化形状中的使用是相同的,因为我们正在访问字节以只读方式排序。

……结果是……很好!实际上非常好。我们发现了一个用例,其中泛型可以帮助进行代码重复数据删除,而不会显示性能回归。这真让人兴奋!从上到下,我们看到所有优化都成立(string形状也是如此,此处未显示)。基于寄存器的调用约定在泛型实例化中仍然存在,尽管请注意我们的[]byte参数的长度现在驻留在CX而不是BX:所有寄存器都右移了一个槽,因为AX现在被泛型实现的运行时字典占用。

其他一切都像针脚一样整洁:32/64 位加载仍然是两条指令,在非通用版本中省略的少数绑定检查仍然在这里省略,并且没有在任何地方引入额外的开销。

两个实现的快速基准验证了我们的阅读:

name                             time/op
Valid/Japanese/Bytes-16          2.63µs ± 2%
Valid/Japanese/GenericBytes-16   2.67µs ± 1%
Valid/Japanese/String-16         2.48µs ± 2%
Valid/Japanese/GenericString-16  2.53µs ± 0%
Valid/ASCII/Bytes-16              937ns ± 1%
Valid/ASCII/GenericBytes-16       943ns ± 1%
Valid/ASCII/String-16             930ns ± 3%
Valid/ASCII/GenericString-16      811ns ± 2%

两种实现之间的性能差异在误差范围内,因此这确实是一个最佳情况:可以在 Go 泛型中使用约束来减少处理字节序列的函数中的代码重复,而不会引入任何额外的开销[]byte | string。这里有一个有趣的例外:string在运行 ASCII 基准测试时,通用形状比非通用实现要快得多(~4%),即使它们的程序集在功能上是相同的。但是,对于所有基准测试,形状 for[]byte与非通用代码具有相同的性能,同样具有相同的程序集。这是一个令人费解的工件,只有在对 ASCII 输入进行基准测试时才能可靠地重现。

函数回调

自从第一次发布以来,Go 就对匿名函数有很好的支持。它们是语言的核心部分,它们通过允许在不改变语言语法的情况下变得非常冗长的许多模式来增加其表现力。例如,不能扩展用户代码以允许在range自定义结构或接口上调用运算符。这意味着为了支持迭代,我们的数据结构需要实现自定义的迭代器结构(开销很大),或者有一个基于函数回调的迭代 API,这通常更快。这是一个小示例,它使用函数回调来遍历 UTF-8 编码字节切片中的所有有效符文(即 Unicode 代码点):

func ForEachRune(p []byte, each func(rune)) {
	np := len(p)
	for i := 0; i < np; {
		c0 := p[i]
		if c0 < RuneSelf {
			each(rune(c0))
			i++
			continue
		}
		x := first[c0]
		if x == xx {
			i++ // invalid.
			continue
		}
		size := int(x & 7)
		if i+size > np {
			i++ // Short or invalid.
			continue
		}
		accept := acceptRanges[x>>4]
		if c1 := p[i+1]; c1 < accept.lo || accept.hi < c1 {
			size = 1
		} else if size == 2 {
			each(rune(c0&mask2)<<6 | rune(c1&maskx))
		} else if c2 := p[i+2]; c2 < locb || hicb < c2 {
			size = 1
		} else if size == 3 {
			each(rune(c0&mask3)<<12 | rune(c1&maskx)<<6 | rune(c2&maskx))
		} else if c3 := p[i+3]; c3 < locb || hicb < c3 {
			size = 1
		} else {
			each(rune(c0&mask4)<<18 | rune(c1&maskx)<<12 | rune(c2&maskx)<<6 | rune(c3&maskx))
		}
		i += size
	}
}

不看任何基准:你认为这个函数与更惯用的迭代相比有多好for _, cp := range string(p)?对,跟不上。这样做的原因是因为range字符串上的循环具有内联的迭代主体,所以最好的情况(纯 ASCII 字符串)可以在没有任何函数调用的情况下处理。另一方面,我们的自定义函数必须为每个 rune 发出回调。

如果我们能以某种方式为我们的函数内联我们的each回调,我们将与rangeASCII 字符串的循环竞争,并且对于 Unicode 字符串可能更快!唉,Go 编译器内联我们的回调需要什么?在一般情况下,这是一个很难解决的问题。想想看。我们传递的回调不在我们的本地函数中执行。它在 内部执行ForEachRune,作为迭代的一部分。为了将回调内联在迭代器中,我们必须ForEachRune使用我们的特定回调实例化一个副本。但是 Go 编译器不会这样做。没有一个明智的编译器会生成一个以上的纯函数实例。除非…

除非我们欺骗编译器去做!因为这听起来很像单态化。有一种与时间一样古老(或至少与 C++ 一样古老)的模式,它根据接收到的回调类型对函数进行参数化。如果您曾经使用过 C++ 代码库,您可能已经注意到接受回调的函数通常是通用的,函数回调的类型作为参数。当封闭函数被单态化时,该函数调用的特定回调被替换为 IR,并且它通常变得微不足道内联——特别是如果它是一个纯函数(即不捕获任何参数的回调)。由于这种可靠的优化,lambda 和模板的组合已成为现代 C++ 中零成本抽象的基石。它为像 Go 一样具有骨科性的语言增加了很多表现力,支持迭代和其他功能构造,而不会引入新的语言语法或运行时开销。

问题是:我们可以在 Go 中做同样的事情吗?我们可以根据回调函数对函数进行参数化吗?事实证明我们可以,尽管有趣的是,这在我发现的任何泛型文档中都没有解释。我们可以像这样重写迭代器函数的签名,它实际上可以编译并运行

func ForEachRune[F func(rune)](p []byte, each F) {
	// ...
}

是的,您可以使用func签名作为通用约束。约束不一定需要是interface. 这是值得牢记的。

至于这次优化尝试的结果,我不打算在此处包含反汇编,但如果您到目前为止一直在关注,您可能已经猜到这没有任何用处。实例化的通用函数的形状并不特定于我们的回调。它是func(rune)回调的通用形状,不允许任何类型的内联。这是另一个例子,其中更积极的单态化将打开一个非常有趣的优化机会。

那么,是这样吗?与函数回调无关?嗯,不完全是。事实证明,自 1.0 版本以来,Go 编译器的内联功能已经相当不错了。如今,它可以做一些非常强大的事情——当泛型不会妨碍时。

让我给你看一个例子:假设我们正在开发一个库来向 Go 添加功能结构。我们为什么要这样做?我不知道。很多人似乎都在这样做。也许是因为它很时髦。因此,让我们从一个简单的案例开始,一个“映射”函数,它在切片的每个元素上调用回调并将其结果存储在适当的位置。

详解泛型是如何让你的GO代码变慢插图4

在我们进入 Generic 映射(这是一个有趣的例子)之前,让我们看一下MapInt硬编码到int切片,看看 Go 编译器可以用这个代码做什么。事实证明它可以做很多事情:组装MapInt看起来非常好。我们可以从我们的示例中看到 main 中没有CALLIntMapTest:我们直接从加载全局input1slice到对其进行迭代,并且映射操作(在本例中为简单的乘法)通过一条指令内联执行。该函数已经完全展平,并且MapInt内部的匿名回调IntMapTest都从 codegen 中消失了。

我们应该对这种代码生成印象深刻吗?毕竟这是一个非常微不足道的案例。也许“印象深刻”这个词并不合适,但如果你在过去十年中一直关注 Go 性能的演变,那么你至少应该感到非常兴奋!

你看,这个例子中的简单MapInt函数实际上是 Go 编译器中内联启发式的压力测试:它不是叶函数(因为它在其中调用另一个函数),它包含一个for带有range. 这两个细节会使该功能无法针对迄今为止的每个 Go 版本进行优化。堆栈中内联直到 Go 1.10 才稳定下来,并且包含for循环的内联函数已经成为 6 年多的问题。事实上,Go 1.18 是第一个range可以内联循环的版本,所以MapInt如果它是在几个月前编译的,看起来会大不相同。

当谈到 Go 编译器中的代码生成时,这是一个非常令人兴奋的进步,所以让我们继续通过查看这个相同函数的通用实现来庆祝……哦。不好了。现在没了。好吧,那真是太可惜了。的主体MapAny,由于堆栈中内联,已内联在其父函数中。但是,现在位于通用形状后面的实际回调已作为独立函数生成,并且必须在循环的每次迭代中显式调用。

让我们不要绝望:如果我们尝试我们刚刚讨论过的相同模式,对回调的类型进行参数化怎么办?这实际上是诀窍!我们回到了一个完全扁平化的函数,但请注意这不是魔术。内联毕竟是一种启发式方法,在这个特定的示例中,我们以正确的方式对启发式方法进行了处理。由于我们的MapAny函数足够简单,它的整个主体都可以内联,我们所需要的只是为我们的 Generic 函数的形状添加更多的特异性。如果对我们函数的回调不是对通用形状的回调,而是func(rune)回调,这将允许 Go 编译器展平整个调用。你知道我要去哪里吗?在这个例子中,内联函数体是一种非常特殊的单态化。一个非常激进的,因为它实例化的形状实际上是一个完整的单态:它不能是其他任何东西,因为封闭函数不是通用的!当你完全单态化代码时,Go 编译器能够执行非常有趣的优化。

总结一下:如果您正在编写使用回调的函数式助手,例如迭代器或 Monad,您希望根据回调的类型对它们进行参数化。当且仅当帮助器本身足够简单可以完全内联时,额外的参数化将使内联器完全扁平化调用,这正是您想要的功能帮助器。但是,如果您的助手不够简单而无法内联,则参数化将毫无意义。实例化的通用形状将太粗糙而无法执行任何优化。

最后,让我指出,尽管这个完整的单态化示例可能并非在所有情况下都可靠,但它确实暗示了一些非常有希望的事情:Go 编译器已经非常擅长内联,并且如果它能够处理非常具体的问题代码实例化,它能够生成非常好的汇编。Go 编译器中已经实现了大量的优化机会,它们只是在等待泛型实现的一点点推动才能开始发光。

结论

这很有趣!我希望你和我一起看这些集会也很开心。让我们用一个简短的列表来结束这篇文章,其中列出了Go 1.18中的性能和泛型:

  • 一定要尝试使用约束对采用 astring和 a 的相同方法进行重复数据删除。生成的形状实例化非常接近于手动编写两个几乎相同的函数。[]byteByteSeq
  • 在数据结构中使用泛型。这是迄今为止他们最好的用例:以前使用interface{}的通用数据结构复杂且不符合人体工程学。删除类型断言并以类型安全的方式存储未装箱的类型,使这些数据结构更易于使用且性能更高。
  • 尝试通过回调类型参数化功能助手。在某些情况下,它可能允许 Go 编译器将它们展平。
  • 不要尝试使用泛型去虚拟化或内联方法调用。它不起作用,因为所有指针类型都有一个可以传递给泛型函数的单一形状;相关的方法信息存在于运行时字典中。
  • 在任何情况下都不要将接口传递给泛型函数。由于形状实例化适用于接口的方式,而不是去虚拟化,您正在添加另一个虚拟化层,该层涉及每个方法调用的全局哈希表查找。在性能敏感的上下文中处理泛型时,只使用指针而不是接口。
  • 不要重写基于接口的 API 来使用泛型。鉴于当前实现的限制,如果继续使用接口,当前使用非空接口的任何代码都会表现得更可预测,并且会更简单。当谈到方法调用时,泛型将指针转化为两次间接接口,并将接口转化为……好吧,如果我说实话,这是非常可怕的事情。
  • 不要绝望和/或大哭,因为 Go Generics 的语言设计没有技术限制,这会阻止(最终)实现更积极地使用单态化来内联或去虚拟化方法调用。

呃,好吧。总体而言,对于那些希望使用泛型作为优化 Go 代码的强大选项的人来说,这可能有点令人失望,就像在其他系统语言中所做的那样。我们已经(我希望!)了解了很多关于 Go 编译器处理泛型的有趣细节。不幸的是,我们还了解到,在 1.18 中发布的实现通常会使通用代码比它所替换的任何代码都慢。但正如我们在几个例子中看到的那样,它不必是这样的。不管我们是否认为 Go 是一种“面向系统”的语言,运行时字典都不是编译后的正确技术实现选择。语言。尽管 Go 编译器的复杂度很低,但很明显且可衡量的是,自 1.0 以来,它生成的代码在每个版本中都在稳步提高,直到现在为止几乎没有回归。

通过阅读Go 1.18 中完全单态化的原始提案中的风险部分,似乎选择使用字典实现泛型是因为单态化代码很慢。但这提出了一个问题:是吗?怎么会有人知道单态化 Go 代码很慢?以前从未做过!事实上,从来没有任何通用 Go 代码可以单态化。这种复杂的技术选择背后的一个强有力的指导因素似乎是我们都持有的潜在误导性假设,例如“单态化 C++ 代码很慢”。这再次提出了一个问题:是吗?有多少 C++ 编译开销来自单态化,而不是 C++ 的性能噩梦include处理,还是在单态代码之上应用的许多优化通道?C++ 模板实例化的糟糕性能特征是否也适用于 Go 编译器,它具有更少的优化通道和一个可以防止大量冗余代码生成的干净模块系统?实际会是什么编译 Kubernetes 或 Vitess 等大型 Go 项目时会影响性能吗?当然,答案将取决于这些代码库中使用泛型的频率和位置。这些都是我们现在可以开始测量的东西,但不能更早地测量。同样,我们现在可以测量模版 + 字典在现实世界代码中的性能影响,就像我们在这个分析中所做的那样,并看到我们在我们的程序中支付了高额的性能税来加速 Go 编译器。

考虑到我们现在所知道的,以及这个泛型实现对其在性能敏感代码中的采用施加的限制,我只能希望重新评估使用运行时字典来缩短编译时间的选择,以及更积极的单态化将在未来的 Go 版本中出现。将泛型引入 Go 是一项艰巨的任务,尽管从任何方面来看,这个雄心勃勃的特性的设计都是成功的,但它引入语言的复杂性需要同样雄心勃勃的实现。一种可以在尽可能多的上下文中使用,没有运行时开销,并且不仅可以实现参数多态性,还可以实现更深层次的优化,许多现实世界的 Go 应用程序都将从中受益。


  1. 这实际上是不正确的。Go 编译器有一些转换可以防止从 转换[]bytestring. 最值得注意的是,在给定的情况下,您可以在withvar b []byte中遍历 UTF8 代码点,并且该转换不会强制分配。同样,您可以在键使用字节切片的映射中查找值:不会分配。相反,分配,因为映射需要获取字符串键的所有权。
  2. ↩︎bfor i, cp := range string(b)stringx = m[string(b)]m[string(b)] = x