bigywater.tech
Go · 28

泛型的实现:GC shape stenciling 与字典

Go 的泛型既不是 C++ 那样每个类型一份代码,也不是 Java 那样完全擦除。它按“GC shape”分组生成机器码,再用一个字典传递真实类型信息。这篇用符号表与汇编验证这个设计,并量出它对运行速度和二进制体积的影响。

10 个函数符号
Show 与 Sum 被 13 个不同类型实参(含 *A、*B)实例化,但机器码只有 10 份:按 GC shape
args=0x10
泛型实例比同样函数体的非泛型函数多 8 字节参数:字典指针
3 倍
在泛型函数里调用约束的方法,比直接调用慢(910 ns vs 308 ns / 1000 次)

按 GC shape 生成代码

实测 · 本机执行输出
  程序输出:1 2 3 2.5 1.5 s &{1} &{2} [1] map[a:1] | 5 3 1 1.5 4
    main.Show[go.shape.*uint8]
    main.Show[go.shape.[]int]
    main.Show[go.shape.float64]
    main.Show[go.shape.int32]
    main.Show[go.shape.int]
    main.Show[go.shape.map[string]int]
    main.Show[go.shape.string]
    main.Sum[go.shape.float64]
    main.Sum[go.shape.int32]
    main.Sum[go.shape.int]
  共 10 个函数符号。注意:*A、*B 共用同一个符号(指针的 GC shape 相同);int 与 MyInt 共用 go.shape.int;float64 与 Celsius 共用 go.shape.float64
  另有字典符号 14 个(每个“真实类型实参的组合”一个):
    main..dict.Show[*main.A]
    main..dict.Show[*main.B]
    main..dict.Show[[]int]
    main..dict.Show[float64]
    main..dict.Show[int32]
    main..dict.Show[int]
    main..dict.Show[main.Celsius]
    main..dict.Show[main.MyInt]
    main..dict.Show[map[string]int]
    main..dict.Show[string]
    main..dict.Sum[float64]
    main..dict.Sum[int32]
    main..dict.Sum[int]
    main..dict.Sum[main.MyInt]

符号表里的 go.shape.int、go.shape.*uint8 就是 GC shape:内存布局与 GC 看法相同的类型共用一份机器码。MyInt 的底层类型是 int,所以与 int 共用;所有指针类型共用 *uint8 这一份。这就是为什么 16 种指针类型只生成了一个函数。

字典与调用约定

实测 · 本机执行输出
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[int](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.int](SB)
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[int32](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.int32](SB)
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[main.MyInt](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.int](SB)
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[float64](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.float64](SB)
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[main.Celsius](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.float64](SB)
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[string](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.string](SB)
    (<gtmp>/gi2/main.go:24)	MOVD	$main..dict.Show[*main.A](SB), R0
    (<gtmp>/gi2/main.go:24)	CALL	main.Show[go.shape.*uint8](SB)
  函数头(STEXT 行里的 args= 是参数占用的字节数)——泛型实例化 Show[go.shape.int] 与同样函数体的非泛型 ShowInt(x int) 对比:
    main.Show[go.shape.int] STEXT dupok size=112 align=0x0 args=0x10 locals=0x38 funcid=0x0
    main.ShowInt STEXT size=96 align=0x0 args=0x8 locals=0x38 funcid=0x0

共用机器码的代价是:函数体不知道自己具体是哪个类型,于是每个实例化多带一个字典参数。调用点用 MOVD $main..dict.Show[main.MyInt](SB), R0 先传字典的地址,再调用 shape 实例。Show[go.shape.int] 的参数大小比同样函数体的非泛型函数多 8 字节(args=0x10 对 0x8),吻合“多一个指针参数”。字典里具体放了什么(类型描述符、子字典、方法表),我没有解析。

速度

实测 · 本机执行输出
  SumInt([]int) 具体类型                   中位 305.59 ns/op(最小 302.65 / 最大 315.24)
  SumGeneric[int]([]int)               中位 314.15 ns/op(最小 304.65 / 最大 326.79)
  TotalDirect([]Foo) 直接调用方法            中位 307.96 ns/op(最小 303.29 / 最大 313.86)
  TotalGeneric[Foo] 约束里的方法             中位 909.62 ns/op(最小 903.11 / 最大 922.69)
  TotalGeneric[*PFoo] 指针类型实参           中位 938.29 ns/op(最小 902.63 / 最大 1102.09)
  TotalIface([]Valer) 接口切片             中位 2024.07 ns/op(最小 2009.24 / 最大 2032.19)

两种情形要分开看。不涉及方法调用时(求和),泛型与手写具体类型一样快;要调用约束里的方法时,实例化后的代码要通过字典找到方法再间接调用,实测约 3 倍慢于直接调用(但仍快于接口切片的 2024 ns)。注意这是 //go:noinline 函数上的微基准,内联后编译器可能做得更好,本文没有测。

体积

实测 · 本机执行输出
  1 种值类型:二进制 2430162 字节,main.Work 的函数符号 1 个
  4 种不同大小的值类型:二进制 2430690 字节,main.Work 的函数符号 4 个
  16 种不同大小的值类型:二进制 2465858 字节,main.Work 的函数符号 16 个
  16 种指针类型(指向不同结构体):二进制 2448162 字节,main.Work 的函数符号 1 个

shape 不同的类型才多一份机器码。16 种不同大小的值类型生成 16 个函数符号,二进制比 1 种时多约 35 KB;16 种指针类型只有 1 个符号,体积只多了字典与类型信息。所以大量用指针类型实例化泛型不会让代码膨胀。

分步演示

泛型的实现
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。符号来自 go tool nm 的 T 类型符号(代码)与名字含 main..dict. 的符号;汇编来自 go build -gcflags=-S;基准测试每个变体 7 轮取中位数;体积是 go build 默认选项下的可执行文件大小
    • 没有验证:字典的内部结构与子字典(没有解析)、泛型方法在接口值里的调用成本、内联与 PGO 对泛型调用的影响、amd64 上的符号与耗时、泛型类型(不只是泛型函数)的方法实例化、Go 版本演进里实现策略的变化
    京ICP备2026067502号-1京公网安备11010502063487号