作者 | 董泽润 责编 | 张红月
出品 | CSDN(ID:CSDNnews)
Go 1.18 发布很久了,人们期待己久的第一个版本终于可以投入生产环境使用。泛型是经常被提到的功能,在 Go 社区中一直存在争议
一方面,强烈的反对者会担心增加复杂性,担心 go 会不可避免的演变成下一个企业版的 java-lite, 或者是一个用 Monnads 代替 ifs 的退化版 HaskellScript. 平心而论,这两种担心可能被夸大了
另一方成面,泛型支持都认为,这是大规模复用代码,并能保持干净的功能
本篇文章不想在争论中战队,也没有建议如何在 go 中使用泛型。想反,我想聊一下很多工程师感兴趣的,单态化以及带来的性能问题(很多人感兴趣,并且我们会失望)
The generics implementation in 1.18
业务有很多泛型实现,简单的说一下以便了解 go 1.18 的实现。由于本文重点在于 system engineering, 我们尽可能让这种理论的讨论变得轻松一些假设你想创建一个多态函数(polymorphic function), 广义来讲,有两种方法:
函数操作所有实现都有相同的外观和行为,通常在堆上分配对象,然后将指针传递给函数。由于所有的对象都有相同的形状(它们都是指针!),我们对它们操作所需要的就是知道,这些方法在哪里。因此,传递给我们的通常伴随一个函数指针表,通常称为 虚拟方法表或是 vtable. 大家对这个肯定有印象,这就是 go interface 实现方式,也是 rust 中 dyn Traits, 以及 c++ 中的虚拟类。这些都是多态的形式,在实践中容易使用,但由于运行时的开销比较高而受到限制;
我们常讲的单态化(monomorphization), 名字听起来吓人,但实现去相对简单。理解为为每个必须操作的类型单独,创建一个函数副本。比如,你想实现两数相加的函数,当调用 float64 类型时,编译器会创建一个函数的副本,并将通用类型占位符替换为 float64. 这是迄今为止实泛型最简单的,同时对于编译器来讲也带来开销。
历史上,单态化一直是在系统语言(如C++、D或Rust)中实现泛型的首选设计。这有很多原因,但都可以归结为用较长的编译时间来换取结果代码的显著性能提升。
当你在编译器执行任何优化过程之前,将泛型代码中的类型占位符替换为其最终类型时,你就创造了一个令人兴奋的优化宇宙,而这在使用 Boxed 类型(上面提到的装箱)时基本上是不可能的。
至少,你可以去掉虚函数调用,摆脱虚拟表;在最好的情况下,你可以内联代码,这反过来又可以进一步优化,内联代码是很好的。
对于系统编程语言来说,单态化是一个彻底的胜利:从本质上讲,它是唯一一种运行时开销为零的多态性形式,而且通常它的性能开销为负。它使通用代码更快。
所以,作为一个致力于提高大型 go 服务性能的人,我承认对 go 泛型并不特别兴奋,真的。我对单态化感到兴奋,也对 go 编译器有可能进行优化感到兴奋,因为它在处理接口时根本做不到。但我很失望。Go 1.18 中的泛型实现并没有使用单态化.....
实际上 go 泛型实现是基于 GCShape stenciling with Dictionaries 的部分单太化实现,细节大家可以参考官方 design document[1]. 为了完整性,咱们简单的看一下实现。
核心思想是,由于 fully monomorphizing 完全单太化每个实现,会产生大量的代码副本,我们可以在更高层次上做单态化,而不是基于每个类型。
因此,go 实现中,编译器根据参数的 GCShape 而不是类型来执行单态化,go 官方称这种单态化为 stenciling (中文是钢网,也是一种模板的意义,不好翻译,大家体会下吧)。
GCShape 是 go 特有的抽像概念,两个具体的类型可以归类同一个 gcshape group, 当且仅当他们有相同的底层类型或是都是指针(这是伏笔,后面的性能问题就来自于此). 这个概念很直白:比如你有个函数,要对参数进行运算,例如 go 编译器会根据它们的类型有效地进行单态化,使用积分算术指令的 uint32 生成的代码,肯定与浮点数的 float64 不同,同理基于 uint32 别名的类型肯定与底层 uint32 的代码相同。
到目前为止,一切都很好。然后,gcshape 定义的第二部分对性能有很大影响。让我再强调一下:All pointers to objects belong to the same GCShape, regardless of the object being pointed at。
这意味着 *time.Time 指针和 *uint64, *bytes.Buffer, *strings.Builder 同处于一个 gcshape group. 这可能让你感到奇怪:“哼,那么,当我们想在这些对象上调用方法时,会发生什么?这种方法的位置,不可能是 gcshape 的一部份!” 好吧,这种设计的名字破坏了我们的想法:gcshape 并不知道方法函数,所以我们需要讨论由此引出的 dictionaries 字典。
当前 go1.18 泛型实现,每次调用泛型函数时,都会把一个静态 dictionaries 字典当成第一个参数,传到函数中,字典中包函了类型的元数据信息。对于 AMD64 架构来说,字典会放到 AX 寄存器中,对于不支持 stack-based 调用归约的平台,会放到栈上。
字典的全部实现细节在上述设计文档中得到了深入的解释,一句话总结,它们包括所有需要的类型元数据,以将参数传递给的泛型函数,将它们从接口转换为接口,以及与我们最相关的,对它们进行方法调用。
这就对了,在单态化步骤完成后,生成的函数,需要为所有的泛型参数传入一个运行时参数,参数里面包含了 virtual method tables 虚函数表。直观的说,这么做减少了生成的代码量,但这种高次层的单态化,并不适合去做 de-virtualization, inline 或是说任何形式的性能优化。
事实上,对于绝大多数的Go代码来说,使其泛型化似乎意味着使其变得更慢。但在我们开始陷入绝望的深渊之前,让我们运行一些基准测试,看看一些汇编并验证。
Interface inlining
Vitess 开源的分布式数据库,它是非常庞大复杂的 go 应用,可以做为新特性很好的测试平台。在 vitess 中我遇到好多函数,或是数实,都是手工实现的单态(通俗来说,就是给每个类型手工实现,copy and past 代码),不可避免会有重复的代码,有些是因为 inteface 不能模拟这种多态,有些纯粹是为了性能。举个例子:sqltypes 包中的 BufEncodesql 函数,被重复定义,用来接收参数 *strings.Build 或是 *bytes.Buffer, 因为要针对 buffer 多次调用,并且会被 go 编译器 inline 内联,当且仅当参数是未装箱 (unBoxed) 类型 (interface 就不会被内联)。因为性能原因,可以看到在代码库中有大量类似的用法。
使这段代码泛型化是微不足道的,所以让我们这样做,并将该函数的泛型版本与以 io.ByteWriter 为接口的简单版本进行比较。
不出意外:WriteByte 的所有调用都是通过 interface itab 进行的,稍后讲一些这意味着什么。不过,泛型版本更有趣。
(BufEncodeStringsql[go.shape.*uint8_0])虽然我们没有在内联 inline 视图中显示出来,但我们必须调用该泛型实现,并携带参数 *strings.Builder 编译器才能生成我们用到的函数实例(这是废话,单态化都是按需生成)。
var sb strings.BuilderBufEncodeStringsql(&sb, byte(nil))调用完后,我们发现对于 *strings.Builder 的生成汇编代码,他的 gcshape 是 *uint8, 上面我们解释了,对于指针类型,go 模板化时都被归为 *uint8, 不论指针具体指向何种类型。而对象的属性 (最重要的 itab) 存储在当成第一个参数,而传递进来的字典 dictionaries 中。
这和我们在 generic design document 中看到的一致:对于结构体指针的单态化,就像 void * 指针一样,这点熟悉 c 的肯定了解,根本不考滤具体类型的其它属性,因此,也就不可能被内联 inline, 因为内联所需要的信息只能在 runtime 运行时获得。
非常糟糕,我们己经看到,go 的模板化 stenciling 设计不允许对函数进行 de-virtualization, 因为更不可能内联。事情变得更糟糕了!可以比较用 inteface 当参数调用 WriteByte 的汇编代码和泛型代码,来分析下性能。
Intermission: Calling an interface method in Go
当比较代码前,让我们回忆下 interface 在 go 中是如何实现的。上面提到,接口也是一种多态的形式,使用 Boxing 装箱方式实现的。inteface 是一个 16 bytes 的胖指针实现的,结构体名 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, _type, hash 字段包含了所有必要的信息,来允许做接口之间的转换,反射,以及 switch 切换。但在这里我们关心末尾的数组,尽管类型是 [1]uintptr, 实际上是一个可变长度的。
itab 结构体大小是可变的,以容纳足够多的空间,存储接口中每个方法的函数指针。当我们每次调用接口上的方法时,都要用到这个,类似于 c++ 中的 vtable。
记住这一点,我们就能理解非泛型实现下,是如何调用接口内方法的。这就是第 8 行 buf.WriteByte('\') 编译的汇编:
func BufEncodeStringI(buf io.ByteWriter, val []byte) { buf.WriteByte(''') for i := 0; i < len(val); i++ { ch := val[i] if encodedChar := sqlEncodeMap[ch]; encodedChar == DontEscape { buf.WriteByte(ch) } else { buf.WriteByte('\') buf.WriteByte(encodedChar) } } buf.WriteByte(''')}0089 MOVQ "".buf+48(SP), CX008e MOVQ 24(CX), DX0092 MOVQ "".buf+56(SP), AXM, 第二个参数 `\` 对应 ASCII 92, 然后调用 `CALL DX` 执行函数OVQ "".buf+56(SP), AX0097 MOVL $92, BX009c CALL DX为了调用 buf 的 WriteByte 方法,我们需要指向 itab 的指针。尽管 buf 原来是以一对寄存器传入函数中的,但是编译器在函数开始时把他放到了栈 stack 中,释放寄存器用于其它用途。
这段汇编,首先把 *itab 从栈 stack 装载回寄存器 CX 中。然后解引用 itab 指针,来获取他的字段,即 MOVQ 24(CX), DX, 根据 itab 定义,函数指针偏移量就是 24。
寄存器 DX 包含了我们想调用函数的地址,目前还缺少函数的参数。我们知道调用结构体的方法,就是把结构体当成第一个参数,这个例子中是 buf.(*iface).data, 即接口内的 strings.Builder 的实际指针,指针在栈 stack 上是可用的,即 itabe 后面 MOVQ "".buf+56(SP), AX, 第二个参数 \ 对应 ASCII 92, 然后调用 CALL DX 执行函数。
为了调用一个简单的方法,真是费了不少力气。尽管在实际性能方面,它并不那么糟糕。除了通过接口的调用总是能防止内联 inline 外,调用的实际开销是一个单一的指针解除引用,以便从 itab 内部加载函数地址。稍后我们将对其进行基准测试,看看这个开销多大,但首先,让我们看看泛型生成的代码。
Back to generics: Pointer calls
回到泛型函数的汇编代码,提醒一下,我们正在分析生成的 *uint8 实例化,因为所有指针的 gcshape 都一样,类似 void *. 让我们看看泛型 WriteByte 方法:008f MOVQ ""..dict+48(SP), CX0094 MOVQ 64(CX), CX0098 MOVQ 24(CX), CX009c MOVQ "".buf+56(SP), AX00a1 MOVL $92, BX00a6 CALL CX是不是似曾相识?还有有个很明显的区别。MOVQ 64(CX), CX 这里多了一次额外的指针解引用。
很无耐的操作,原因是:由于设计的问题,我们单态化所有指针为一个 gcshape group, 都是 *uint8, 不包含任何指针上可以调用的方法信息。那么从哪获取呢?理想情况下,应该存在于和我们指针关联的 itab 中,但是并没有,因为我们的 gcshape 需要一个 8byte 指针作为参数,而不是像接口那样信息很全的胖指针(即 type iface struct)。
如果你还记得,这就是为什么 go 所谓的模版化实现(stenciling), 要给每个泛型函数调用传递一个字典 dictionary 的全部原因:这个字典包含指向函数的所有泛型参数的 itab 的指针。
即然是这样实现的,那么汇编代码,多一次解引用获取数据,看起来合理了。WriteByte 方法调用开始,不是传入 itab 而是传递 dictionary 给泛型函数,存放到寄存器 CX, 解引用,然后偏移量 64 找到 *itabe, 还需要再解引用 MOVQ 24(CX), CX 才能找到函数地址。
额外的一次解引用性能有多糟糕呢?直观的讲,可以假设泛型方法调用,总是比 interface 接口的方法调用慢,原因就在于两次解引用。
name time/op alloc/op allocs/opMonomorphized-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%上面是 benchmark 结果,GenericWithPointer 传递 *strings.Builder 到泛型函数 func Escape[W io.ByteWriter](W, []byte), Iface 是函数 func Escape(io.ByteWriter, []byte), Monomorphized 就是普通的单态化函数 func Escape(*strings.Builder, []byte)。
结果不出意外,单态是最快的,因为它允许编译器在内部 inline 内部调用。泛型的慢一些,看起来没那么明显,因为 itab 和 dictionary 都缓存了,但是请继续阅读 cache contention 是如何影响泛型的。
这就是我们从分析中得到的第一个启示:go1.18 中转换成泛型是没有任何性能收益的,因为无法从指针中直接调用函数,相反还需要一次额外的解引用。这和我们希望的完全相反,即 de-virtualization 的同时,尽可能 inline。
结束当前小节前,我们再看一下 go 栈逃逸的一个细节:单态函数 2 allocs/op, 因为传进去的指针在栈 stack 上,并没逃逸。Iface 3 allocs/op,也可以理解,毕竟还要有接口 interface 的分配。但令人惊讶的是:泛型函数也是 3 allocs/op, 尽管生成的函数实例化直接使用了指针,但 escape analysis 不能再证明它是 non-escape, 所以我们得到了一个额外的堆分配。哦,好吧。这是一个小失望,后面还有更让人失望的。
Generic interface calls
在过去的几节中,我们一直在分析泛型函数的代码,如果你还记得,签名是 func Escape[W io.ByteWriter](W, []byte), 而 *strings.Builder 无疑满足了这个约束,产生了一个 *uint8 gcshape。如果我们把我们的 *strings.Builder 隐藏在一个接口后面,会发生什么?
var buf strings.Buildervar i io.ByteWriter = &bufBufEncodeStringsql(i, byte(nil))BufEncodeStringsql 泛型函数传进来的是接口,这么做肯定是可以的。但是生成的实例化代码会什么样?我们看下汇编。
00b6 LEAQ type.io.ByteWriter(SB), AX00bd MOVQ ""..autotmp_8+40(SP), BX00c2 CALL runtime.assertI2I(SB)00c7 MOVQ 24(AX), CX00cb MOVQ "".buf+80(SP), AX00d0 MOVL $92, BX00d5 CALL CX历害了!与前面生成的代码比较,多了很多。上面看到,额外指针解引用,对性能是有影响的,想象一下这次更多了。
这里发生什么了?我们知道 runtime.assertI2I 是 go runtime 函数:用来做接口的转换,接受 *interfacetype, *itab 作为它的两个参数,只有接口符合要求时才返回 itab. 恩, 什么意思?
type IBuffer interface { Write([]byte) (int, error) WriteByte(c byte) error Len int Cap int}假设有个接口 IBuffer, 我们没有提 io.ByteWriter 或者 io.Writer, 但任何实现了 IBuffer 的类型也自动隐式的实现这两个接口。这在我们泛型的生成代码中产生了有意义的影响:
由于我们泛型的约束是 [W io.ByteWriter], 传递任何实现访接口的都可以,当然包括上面提到的 IBuffer. 但当我们调用 WriteByte 方法时,在我们收到接口的 itab.fun 数组中,这个方法在哪里?方法偏移量在多少?我们根本不知道。
如果是传递的 *strings.Builder 作为参数,我们知道 itab.fun[0] 就是我们要的方法。如果我们传 IBuffer, 根据结构体定义,位于 itab.fun[1]. 我们需要一个 helper 辅助函数,它接收 IBuffer 的 itab, 并返回一个 io.ByteWriter 的 itab, 这样 WriteByte 稳定在 itab.fun[0] 位置。这就是 runtime.assertI2I 工作原理。
00b6 LEAQ type.io.ByteWriter(SB), AX00bd MOVQ ""..autotmp_8+40(SP), BX00c2 CALL runtime.assertI2I(SB)00c7 MOVQ 24(AX), CX00cb MOVQ "".buf+80(SP), AX00d0 MOVL $92, BX00d5 CALL CX首先,加载 io.ByteWriter 的 interfacetype (这是一个硬编码的全局,因为这是我们约束中定义的接口类型) 到 AX 寄存器中。
MOVQ ""..autotmp_8+40(SP), BX 将实际传递到参数的接口 itab 加载到 BX, 这是后面 assertI2I 要用到的参数,完成后 AX 中得到了 io.ByteWriter 的 itab, 然后就像我们上面调用函数一样工作即可,函数指针现在总是在我们的 itab 中的偏移量 24。
本质上讲,就个所谓的 shape instantiation 实例化所做的工作就是:将每个方法调用从 buf.WriteByte(ch) 转换为 buf.(io.ByteWriter).WriteByte(ch)。
确实,这样做看起来开销很大,也多余,所以不能在函数开始时,只获取一次 io.ByteWriter 的 itab, 后续复用不行嘛?看起来不行,但在有些函数实例化中做是安全的(比如,我们目前正在分析的函数),因为 buf 接口内的值永远不会改变,不需要进行类型转换或将 buf 接口向下传递到栈的任何其他函数。Go 编译器在这里肯定有一些优化的空间。看一下基准数据,看看这样的优化会有多大的影响。
name time/op alloc/op allocs/opMonomorphized-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 开销很明显,速度几乎比直接调用慢了一倍,比直接调用接口慢 30%. 不管怎么说,这都是需要注意的性能问题:同样的泛型函数,同样的参数,如果你在一个接口中传递参数,而不是直接以指针的形式传递,那么速度就会大大降低。
但是等等,我们在这里还没有完成! 还有更多迷人的性能细节可以分享,你可能已经从我们基准案例的仔细命名中猜到了。事实证明,GenericWithExactIface 基准测试实际上是最好的情况,因为我们函数中的约束条件 [W io.ByteWriter],而我们是以 io.ByteWriter 接口来传递我们的参数。
这意味着 runtime.assertI2I 立即返回我们所需要的 itabe, 但是如果把我们的参数作为先前定义的 IBuffer 接口来传递呢?
这应该可以正常工作,因为 *strings.Builder 同时实现了 IBuffer 和 io.ByteWriter, 但是在运行时,当 assertI2I 试图从 IBuffer 参数中获取 io.ByteWriter 的 itab 时,我们函数中的每个方法调用都会导致全局哈希表的查找。
name time/op alloc/op allocs/opMonomorphized-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%
有意思,看起来,我们可能完全用错了,取决于传递给泛型函数的接口,是否与它的约束条件匹配,或者是约束条件的超集。(Haha, awesome. This is a very cool insight. We’ve upgraded from a performance footgun to a footcannon, and this all depends on whether the interface you’re passing to a generic function matches exactly its constraint or is a super-set of the constraint. ) 这里贴了原文,footgun 是外国的笑话,形容 "shoot yourself in the foot",换句话说,这里形容泛型用错了,姿势不正确。
这是本文分件最有收获的点:向 go 泛型中传递一个 inteface 是错误的 最好的情况下,也和传递 interface 性能一样,否则会看到很显明的性能开销,特别是超集的情况下,每个方法调用,都必须从 hash 表中动态解析,无法从缓存中获益。
结束本节前,有一点非常重要,使用泛弄前一定要考滤能否接受额外的开销,本文测试 case 都是最好的情况,特别是对于接口调用,测试时 itab 和 dictionaries 可能都己经 cache 住了,全局 itabTable 也同理。实际生产环境中,cache contentions 缓存竞争是常态,itabTable 有成千上万个成员,这取决于你的服务运行时间,以及接口类型的数量。
所以,这意味着,真实环境下泛型调用开销更高。这也不是新鲜事情,实际上这种性能退化影响所有 go 服务的接口检查,但是这些接口检查通常不会像函数调用那样在紧密的循环中进行。
是否有办法在模拟测试环境中对这种退化进行基准测试?有的,但这不是很科学,你可以污染全局的 itabTable, 并从一个单独的 Goroutine 中不断地破坏 cpu L2 cpu 缓存。这种方法可以任意增加任何被测试的通用代码的方法调用开销,但很难在 itabTable 中创建一个与我们在实际生产服务中看到的情况准确匹配的竞争模式,所以测量的开销很难转化为更现实的环境。
尽管如此,在这些基准测试中观察到的行为仍然相当有趣。这是测量 Go 1.18 中不同方法调用开销(以每次调用纳秒为单位)的微观基准的结果。被测试的方法有一个非内联的函数体,所以这是严格的测量调用开销。该基准运行了三次:在真空状态下,在二级缓存持续加压的情况下,以及在激增和全局 itabTable 大大增加的情况下,这会影响我们的 itab 的查找效率。
可以看到性能和前面的相似,有趣的行为发生在我们增加竞争的时候:正如预期的,非泛型调用不受 L2 cache 竞争的影响,而所有泛型都有小幅的增加 (即使是不访问全局 itabTable 的代码,也很可能是因为所有泛型方法调用必须访问更大的运行时字典)。
当我们把 itabTable 的大小和 L2 缓存的竞争一起增加时,真正灾难性的组合就发生了:所有方法调用都增加了大量开销,因为全局 itabTable 太大,无法装入缓存 cache. 同样,从这个微观测试中不能有意义地分辨出开销的确切数量。
这取决于你的 Go 应用程序在生产中的复杂性和负载。从这个实验中得到的重要启示是,在泛型的 Go 代码中存在这种诡异的动作,所以要小心对待,并根据你的用例进行测量。
Byte sequences
在 Go 代码为中,有一个非常常见的模式,标准库中也能看到,一个以 byte 为参数的函数,同样的也会有一个以 string 的函数实现,几乎一模一样。比如 (*Buffer).Write 与 (*Buffer).WriteString, 不过 encoding/utf8 包是一个更夸张的例子:几乎 50% 的 API 都是重复的,分别手工实现了上述两种方法。
值得指出的是,这种重复实际上是一种性能优化:API 很可能只提供 byte 函数来操作 UTF8 数据,迫使用户在调用包之前将他们的字符串输入转换为 byte. 这并不是特别不符合人体工程学,而且开销也大,由于 Go 中的 slice 是可变的,而 string 是不可变的,在它们之间进行转换时,无论哪个方向都会强制进行分配对象。
这种大量的代码重复看起来确实是泛型的一个有利目标,但是由于代码的重复首先是为了防止额外的对象分配,在我们试图统一实现之前,我们必须确保生成的实例的行为符合我们的期望。
让我们比较一下 Valid 函数的两个版本:encoding/utf8 原始版本将 byte 作为输入,新的泛型版本用 byteseq 来做约束。
在新的泛型函数的形状之前,在非泛型代码中的一些优化细节应该回顾一下,这样可以验证它们在泛型实例化过程中是否存在。
两个很好的优化和另一个不那么好的优化。首先,在 go1.16 中引入的基于寄存器 stack-based 调用规约,在 byte 上表现很好:sliceheader 一共 24 字节,没有被放到 stack 栈上,而是作为 3 个寄存器中的单独传递,数据指针放到 AX 中,长度放到 BX, 所以上图能看到 len(p)>=8 对应汇编代码 CMPQ BX $8。
这个编译后的函数中唯一令人不快的细节发生在主for循环中:第19行的 pi := p[i] 加载有一个边界检查,这个检查本应该由上面的循环头中的i < n 检查而变得多余的。
我们可以在生成的汇编中看到,我们实际上是在一个接一个地链接两个跳转:一个 JGE(这是一个有符号的比较指令)和一个 JAE(这是一个无符号比较指令)。这是一个阴险的问题,产生于 Go 中 len 的返回值是有符号的,可能值得发表自己的博客。
不管怎么说,这个 Valid 函数的非泛型代码总体上看是相当不错的。让我们把它与泛型实例化进行比较吧。
我们在这里只看 byte 参数的,用字符串参数调用会产生不同的汇编代码,因为这两种内存布局是不同的(字符串为 16 字节,[]byte为 24 字节),即使它在两个实例化的形状中的用法是相同的,因为我们是以只读的方式访问字节序列的。
.结果是很好! 实际上是非常好的。我们找到了一个用例,在这个用例中,泛型可以帮助消除代码的重复性,而不会出现性能下降的情况。这真是令人振奋啊 从上到下,我们看到所有的优化都是有效的(对于字符串的也是如此,这里没有显示)。
基于寄存器 stack-based 调用归约,在泛型实例化后仍然有效,尽管注意到我们的 byte 参数的长度现在驻留在 CX 而不是 BX 中:所有的寄存器都向右移动了一个槽,因为AX 现在被 Generics 实现的运行时字典占据。
其他的都很整齐:32/64 位的加载仍然是两条指令,在非 Generic 版本中被省略的几个边界检查在这里也被省略了,而且没有任何地方被引入额外的开销。对这两个实现的快速基准测试验证了我们的解读:
name time/opValid/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%
两个实现之间的性能差异在误差范围之内,所以这确实是一个最好的情况:byte | string 约束可以在 Go 泛型中使用,以减少处理字节序列的函数中的代码重复,而不会引入任何额外的开销。
这里有一个有趣的例外:在运行 ASCII 基准时,字符串的泛型比非泛型的实现要快很多(~4%),尽管它们的程序集在功能上是相同的。然而,byte 在所有基准中的性能与非通用代码相同,同样具有相同的汇编。这是一个令人费解的现象,只有在对 ASCII 输入进行基准测试时才能可靠地再现。
Function Callbacks
从第一个版本开始,Go 就对匿名函数提供了非常好的支持,它们是语言的核心部分,一等公民,极大的增加语言的表现力。例如,用户代码不能被扩展以允许在自定义结构或接口上调用范围运算符。这意味着为了支持迭代器,数据结构需要实现自定义的迭代器结构(有很大的开销),或者有一个基于函数回调的 iter 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 loop 的迭代主体是内联的,所以最好的情况(一个纯粹的 ASCII 字符串)可以在没有任何函数调用的情况下处理。另一方面,我们的自定义函数必须为每个 rune 字符发出一个回调。
如果我们能以某种方式内联函数的每个回调,就可以用 range loop 来处理 ASCII 字符串,甚至可能对 Unicode 字符串更快。然而,Go 编译器要怎样才能内联我们的回调呢?
在一般情况下,这是个很难解决的问题。想一想吧。我们传递的回调并没有在我们的本地函数中执行。它是在 ForEachRune 中执行的,作为迭代的一部分。为了让回调在迭代器中被内联,我们必须用我们特定的回调实例化一个 ForEachRune 的副本。但是Go的编译器不会这么做。任何明智的编译器都不会为一个函数生成一个以上的实例。
除非我们欺骗编译器来做这件事! 因为这听起来确实很像单态化。有一种和时间一样古老的模式(至少和C++一样古老),那就是通过它所接收的回调的类型来参数化一个函数。
如果你曾经在C++代码库中工作过,可能已经注意到,接受回调的函数通常是泛型的,将函数回调的类型作为一个参数。
当闭包函数被单态化时,该函数调用的特定回调被替换到 IR 中,而且它常常变得很容易内联,特别是如果它是一个纯函数(即一个不捕获任何参数的回调)。
由于这种可靠的优化,lambdas 和模板的组合已经成为现代 C++ 中零成本抽象的基石。它为像 Go 一样的语言增加了很多表现力,在不引入新的语言语法和运行时开销的情况下,实现了迭代和其他功能结构。
问题是:我们能在 Go 中做同样的事情吗?可以根据函数的回调来对其进行参数化吗?事实证明我们可以,尽管有趣的是,在我找到的任何泛型文件中都没有解释。可以这样改写我们的迭代器函数的签名,它实际上可以被编译和运行:
func ForEachRune[F func(rune)](p []byte, each F) { // ...}是的,你可以使用一个 func 签名作为一个泛型约束。约束不一定需要是一个接口,这是值得记住的。
至于这个优化尝试的结果,我不打算在这里包括二进制汇编,但如果你一直跟到现在,你可能已经猜到这没有任何作用了。被实例化的通用函数的形状对我们的回调来说并不特别。它是一个 func(rune) 回调的 generic shape, 不允许任何形式的内联。这是另一个例子,更积极的单态化将开启一个非常有趣的优化机会。
事实证明,自 go1.0 版本以来,Go 编译器的内联功能已经相当不错了。现在它可以做一些非常强大的事情,当泛型不碍事的时候。
让我给你举个例子:想象一下我们正在开发一个库,为 Go 增加函数式调用。我们为什么要这样做呢?我也不知道。很多人似乎都在做这件事。也许是因为它很时髦。所以让我们从一个简单的例子开始,一个 Map 函数,它对一个 slice 的每个元素调用一个回调,并将其结果存储在原地。
在我们进入 Generic map(这是一个有趣的例子)之前,让我们看看 MapInt 硬编码为 int slices,看看Go 编译器能对这段代码做什么。事实证明,它可以做很多事情:MapInt 的汇编看起来非常好。
我们直接从加载全局输入 slice 进行迭代,map 操作(在本例中是一个简单的乘法)是通过一条指令在线进行的。该函数已被完全 inline,MapInt 和 IntMapTest 内部的匿名回调都已从代码中消失。
我们应该对这种代码生成印象深刻吗?这毕竟是一个非常微不足道的案例。也许 "印象深刻 "这个词并不恰当,但如果你一直在关注 Go 在过去十年中的性能演变,你至少应该感到相当兴奋。
你看,这个例子中的简单 MapInt 函数实际上是对 Go 编译器中的 inline 启发式方法的压力测试:它不是一个叶子函数(因为它在里面调用了另一个函数),而且它包含一个有范围的 for 循环。这两个细节会使这个函数在迄今为止的每一个Go版本中都无法被优化。栈中内联直到 Go 1.10 才稳定下来,而内联包含 for 循环的函数的问题已经存在6年多了。事实上,Go 1.18 是第一个可以内联范围循环的版本,所以如果 MapInt 是在几个月前编译的,它看起来会有很大不同。
当涉及到 Go 编译器的代码生成时,这是一些非常令人兴奋的进展,所以让我们继续庆祝,看看这个相同函数的泛型实现......哦。哦,不。它现在不见了。这可真让人扫兴。MapAny 的主体,由于堆栈中间的内联,已经被内联到它的父函数中。然而,实际的回调,现在在一个 generic shape 后面,被生成为一个独立的函数,必须在循环的每个迭代中明确调用。
让我们不要绝望:如果我们尝试我们刚刚讨论过的同样的模式,在回调的类型上进行参数化,会怎么样?这的确是个好办法。我们又回到了一个完全扁平化的函数,但是请注意,这并不神奇。
内嵌毕竟是一种启发式方法,而在这个特殊的例子中,我们已经用正确的方法来处理启发式方法了。
由于我们的 MapAny 函数足够简单,它的整个主体都可以被内联,我们所需要的只是为我们的 Generic 函数的添加更多的特殊性。如果我们的函数的回调不是对 generic shape 的回调,而是 func(rune) 回调的一个单态实例,这将允许 Go 编译器将整个调用扁平化。你明白我在说什么吗?
在这个例子中,内联函数体是一种非常特殊的单态化。一种非常积极的单态化,因为它所实例化的实际上是一种完全的单态化:它不可能是别的东西,因为闭包不是泛型的 当你将代码完全单态化时,Go 编译器能够进行非常有趣的优化。
总结一下:如果你在写使用回调的函数式方法时,比如 Iterators 或 Monads, 你要在回调的类型上对其进行参数化,如果并且只有在回调本身简单到可以完全内联的情况下,额外的参数化才会使内联器对调用进行完全的扁平化处理,然而,如果你的回调不够简单,不能被内联,那么参数化就毫无意义。实例化的泛型将过于粗糙,无法进行任何优化。
最后,让我指出,尽管这个完全的单态化例子可能不是在所有情况下都可靠,但它确实暗示了一些非常有希望的事情:Go 编译器在内联方面已经变得非常好,如果它能够处理非常具体的代码实例,它能够生成非常好的汇编。Go 编译器中已经实现了大量的优化机会,只是在等待泛型实现的一点推动而开始发光发热。
总结
这真是太有趣了! 我希望你和我一起看这些汇编实现时也有很多乐趣。在这篇文章的最后,让我们列举一下 Go 1.18 中关于性能和泛型的注意事项:
请尝试使用 ByteSeq 约束去掉接受一个 string 和一个 byte 的相同方法。生成的实例化函数非常接近于手工版本
请在数据结构中使用泛型。这是迄今为止它们最好的使用情况。以前使用 interface{} 实现的泛型数据结构是复杂的,而且不符合人机工程。去除类型断言,并以类型安全的方式存储未装箱的类型,使得这些数据结构更容易使用,性能更强
请尝试通过回调类型来参数化函数,在某些情况下,它可能允许 Go 编译器将其扁平化。
不要试图使用泛型来 de-virtualize 或内联方法调用。这是不可行的,因为所有指针类型都有一个单一的 gcshape, 相关的方法信息存在于运行时字典中
在任何情况下都不要向泛型函数传递一个接口。由于接口的实例化方式,你不是在 de-virtualizing,,而是增加了另一个虚拟化层,涉及到对每个方法调用的全局哈希表查询。当在对性能敏感的情况下处理泛型时,只使用指针而不是接口
不要重写基于接口的 API 来使用泛型。考虑到当前实现的限制,任何目前使用非空接口的代码,如果继续使用接口,其行为将更有预见性,而且会更简单。当涉及到方法调用时,泛型将指针变成了两次直接的接口,而接口则变成了......嗯,如果我说实话,是相当可怕的东西。
不要绝望和/或大哭,因为 Go 泛型的语言设计中没有任何技术限制,可以阻止(最终)实现更积极地使用单态化来内联或 de-virtualizing 方法调用。
啊,好吧。总的来说,这可能让那些期望将泛型作为优化 Go 代码的强大选项的人有点失望,就像在其他系统语言中那样。我们已经了解到(我希望!)很多关于Go编译器处理泛型的有趣细节。不幸的是,1.18 中的实现,往往会使泛型代码比它所替代的东西更慢。但正如我们在几个例子中所看到的,也不全是。
不管我们是否认为 Go 是一种 "面向系统 "的语言,感觉运行时字典 dictionary 根本就不是编译语言的正确技术实现选择。尽管 Go 编译器的复杂度不高,但很明显可以衡量的是,从 1.0 开始,它生成的代码在每个版本上都在稳步提高,很少有退步,一直到现在。
通过阅读 Go 1.18 中完全单态化的原始提案中的风险部分,似乎选择用字典实现泛型是由于单态化代码很慢。但这提出了一个问题:是这样吗?怎么会有人知道 Go 代码的单态化很慢呢?以前从来没有人这样做过。
事实上,从来没有任何 Go 的泛型代码可以被单态化。我觉得这个复杂的技术选择背后有一个强有力的指导因素,那就是我们都持有的潜在的误导性假设,比如说 "单态化C++代码很慢"。这又提出了一个问题:是这样吗?
相对于 C++ 的性能噩梦,即 C++ 的包含处理,或应用在单态代码之上的许多优化通道,C++ 的编译开销有多少是来自单态化?C++ 模板实例化的糟糕性能特征是否也适用于 Go 编译器,因为它的优化传递要少得多,而且有一个干净的模块系统,可以防止大量冗余代码的产生?而在编译 Kubernetes 或 Vitess 等大型 Go 项目时,实际的性能影响会是什么?
当然,答案将取决于这些代码库中使用泛型的频率和位置。这些都是我们现在可以开始测量的东西,但在早期是无法测量的。同样地,我们现在可以在现实世界的代码中测量模版化+字典(stenciling + dictionaries)的性能影响,就像我们在这个分析中所做的那样,可以看到我们在程序中为加快 Go 编译器的速度付出了巨大的性能代价。
考虑到我们现在所知道的,以及这种泛型实现对性能敏感代码采用的限制,我只能希望使用运行时字典 dictionary 来减少编译时间的选择将被重新评估,并且在未来的 Go 版本中会出现更积极的单态化。
在 Go 中引入泛型是一项艰巨的任务,虽然从任何角度来看,这项雄心勃勃的功能的设计都是成功的,但它在语言中引入的复杂性需要一个同样雄心勃勃的实现。这种实现可以在尽可能多的情况下使用,没有运行时的开销,而且不仅可以实现参数化的多态性,还可以进行更深层次的优化,很多实际的 Go 应用都会从中受益。
英文原文连:https://planetscale.com/blog/generics-can-make-your-go-code-slower
参考资料
[1]GCShape stenciling with Dictionaries: https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md
[2]google vitess: https://github.com/vitessio/vitess/
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。