Skip to content

proposal: LLGo Whole-Program Method Reachability Analysis and Package Summary Cache #1853

@luoliwoshang

Description

@luoliwoshang

1. 摘要

本提案定义 LLGo 面向 Go 语义的全图方法裁剪方案,以及与之配套的包级摘要缓存方案。该方案面向可执行构建路径,借鉴 Go 在全图视角下整合程序语义输入并求解方法可达性的设计思路,在 LLGo 的构建链路中建立全局方法可达性分析能力。当前仅依赖普通引用关系的删除无法精确表达类型元数据、接口调用、按名字取方法和反射带来的方法需求,因此许多语义上不可达的方法仍会被保活。该方案的核心目标是在 whole-program 视角下稳定判定哪些具体类型上的哪些方法槽位在语义上必须保留,并输出可供后续裁剪阶段消费的 type symbol -> live method indexes 结果。

本提案定义方法可达性分析所需的正式输入事实,包括普通符号可达关系、类型关联关系、接口转换、接口方法调用、常量 MethodByName、反射方法访问,以及具体类型的方法槽位信息。分析算法只消费这些事实,不依赖 ABI type 常量中方法表引用的偶然可达性;同时,这些事实会在单包粒度上进入 package summary,使后续全局阶段能够从缓存直接恢复 whole-program 分析输入。

在该设计下,LLGo 的正式链路为:单包构建阶段生成 package summary,缓存命中时直接恢复 package summary,全局汇总阶段将多个 package summary 合并为统一符号空间下的 whole-program 视图,方法可达性分析在该视图上迭代传播相关语义直到结果收敛,得到稳定的“具体类型到存活方法槽位集合”结果,最后由后续裁剪阶段消费该结果。本文范围覆盖分析事实模型、包级缓存边界、全局合并视图、方法判活语义、结果表达形式以及后续消费阶段的职责分层。

2. 背景与问题

2.1 类型元数据可达不等于全部方法可达

Go 类型元数据中的 abi.Method 表会包含方法名、方法类型、ifntfn 等引用。若普通 linker reachability 直接沿这些引用传播,则某个类型描述符一旦可达,就可能把该类型的全部方法都保活。

例如:

type Reader interface {
    Read([]byte) (int, error)
}

type stringReader struct{}

func (*stringReader) Len() int                  { return 0 }
func (*stringReader) Read([]byte) (int, error) { return 0, nil }
func (*stringReader) Close() error             { return nil }

func ReadAll(r Reader) {
    _, _ = r.Read(nil)
}

func main() {
    ReadAll(&stringReader{})
}

从源码语义看,这个程序只要求 *stringReader 作为 Reader 被使用,并通过接口调用 ReadLenClose 没有对应的语义需求。

但在 reader 用例生成的 IR 中,*stringReader 的 ABI type symbol 会携带完整方法表:

@"*_llgo_github.com/goplus/llgo/cl/_testgo/reader.stringReader" = weak_odr constant
  { %"github.com/goplus/llgo/runtime/abi.PtrType",
    %"github.com/goplus/llgo/runtime/abi.UncommonType",
    [10 x %"github.com/goplus/llgo/runtime/abi.Method"] } {
    ...,
    [10 x %"github.com/goplus/llgo/runtime/abi.Method"] [
      { ..., ptr @"github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Len",  ptr @"github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Len" },
      { ..., ptr @"github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Read", ptr @"github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Read" },
      ...,
      { ..., ptr @"github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Close", ptr @"github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Close" }
    ]
  }

main 调用 ReadAll(&stringReader{}) 时,接口转换会使 *stringReader 的类型元数据进入可达集合:

%2 = call ptr @"github.com/goplus/llgo/runtime/internal/runtime.NewItab"(
  ptr @"_llgo_iface$...",
  ptr @"*_llgo_github.com/goplus/llgo/cl/_testgo/reader.stringReader")

如果普通可达性直接沿 ABI method table 中的 ifn / tfn 引用传播,那么 *stringReader 的类型元数据一旦因接口转换可达,LenClose 等没有语义需求的方法也会被一并保活。本文的算法要把“类型描述符可达”和“方法槽位被需求”拆开:类型可达只让类型进入分析,方法是否保留由接口调用、MethodByName 和反射规则共同决定。

2.2 方法裁剪需要 whole-program 语义

方法是否可达无法仅由单包独立判断。一个包可能定义了某个具体类型及其方法集合;另一个包可能把该具体类型转换为接口;还有一个包可能通过该接口调用某个方法。只有把这些包放在同一个全局视角下,才能判断“某个类型上的某个方法”是否真的可能被接口动态访问。

如果只在单包内分析,定义类型的包不知道该类型会被转换成哪些接口,发生接口调用的包也不知道运行时可能流入哪些具体类型。此时分析要么过于保守,把大量方法继续保留;要么过于激进,错误删除仍可能被接口调用、按名字取方法或反射访问到的方法。

因此,方法裁剪必须在所有参与链接的包都已知之后进行。单包阶段只产出局部事实;全局阶段再合并这些事实,形成 whole-program 视角下的方法可达性输入。

2.3 分析事实需要进入包级缓存

方法可达性分析依赖的事实不是临时调试信息,而是构建链路中的正式中间产物。即使当前 LLGo 在 cache hit 路径上仍然需要执行 Go SSA / cl 阶段,不能完全跳过前端编译,这些事实也不应每次都从 LLVM IR、类型元数据或编译期结构中重新提取和计算。

一方面,这些事实的提取可能需要扫描 module、解析 ABI type initializer 或重新遍历编译期结构;这部分工作在缓存命中路径上属于重复成本。另一方面,后续构建链路可能进一步减少 cache hit 时需要生成的 IR 或 module,此时若方法可达性分析仍绑定在“重新扫描 LLVM module”上,就会阻碍构建流程继续优化。

因此,本提案将这些事实定义为 package summary 的内容:cache miss 时随单包编译产出并写入 .meta;cache hit 时直接反序列化恢复。全局阶段只消费恢复后的 package summary 合并视图,而不关心这些事实来自本次编译还是缓存文件。

3. 设计目标

  • 在 whole-program 视角下精确判定必须保留的方法槽位。
  • 保证可能被 Go 语义动态访问的方法不会被错误裁剪。
  • 避免类型元数据可达自动导致整个方法表全部保活。
  • 为方法可达性算法定义稳定、可缓存的输入事实模型。
  • 在 cache hit 路径上复用 package summary,避免重复恢复分析事实。
  • 将分析结果稳定表达为 type symbol -> live method slot indexes

4. 总体架构边界

本方案将方法裁剪拆成“事实生产、事实缓存、全局合并、语义分析、结果消费”几层。各层之间只传递明确的数据产物,避免把单包编译、缓存格式、全局算法和后续裁剪实现耦合在一起。

  1. 单包事实生产

    每个包在自身编译阶段产出局部 package summary。该 summary 只记录本包能够确定的分析事实,不判断任何方法是否最终可达。

  2. 包级缓存

    Package summary 与单包编译产物一起进入构建缓存。缓存命中时恢复的是分析事实,不是全局分析结论。

  3. 全局视图合并

    全局阶段将所有参与链接的 package summary 合并到统一符号空间,形成 whole-program 视图。该阶段只负责 ID remap 和事实合并,不决定方法保活策略。

  4. 方法可达性分析

    分析器消费 whole-program 视图和构建入口 roots,执行 Go 语义上的方法可达性分析,输出 type symbol -> live method slot indexes

  5. 结果消费

    后续裁剪阶段消费 live slots,保证未保留槽位不再强引用方法实现。裁剪阶段不重新解释接口转换、接口调用、反射等语义。

5. 分析事实模型

Package summary 需要表达以下事实。

5.1 Symbol 与 Name

Symbol 表示 LLGo 编译产物中的模块级命名实体,即 LLVM IR 顶层带名字的 global value,例如函数、全局变量、ABI type symbol、接口 type symbol、方法类型 symbol、方法实现 symbol 等。这些名字是后续 object/link 阶段用于引用和裁剪的稳定锚点。

换句话说,Symbol 记录的是编译产物里真实会出现、能在 .ll / object / linker 输入中被引用的名字,而不是分析器临时创造的逻辑 ID。

例如:

  • github.com/goplus/llgo/cl/_testgo/reader.ReadAll
  • github.com/goplus/llgo/cl/_testgo/reader.(*stringReader).Read
  • *_llgo_github.com/goplus/llgo/cl/_testgo/reader.stringReader
  • _llgo_iface$...
  • _llgo_func$...

Symbol 不是 Go 源码层类型名或函数名,也不是 LLVM 函数体内部的 SSA 临时值。%2 = call ... 中的 %2 只在单个函数体内有意义,不会作为 package summary 的 Symbol

Name 表示不参与可达传播、只用于语义匹配的普通名字,例如方法短名 ReadClose,或常量 MethodByName("Read") 中的字符串 Read

两者可以共享底层字符串表,但属于不同语义命名空间,不能相互替代。

5.2 OrdinaryEdges

OrdinaryEdges 表示编译产物中模块级符号之间的普通引用关系。它只描述“某个符号在函数体或全局 initializer 中直接引用了另一个符号”,用于从 roots 出发建立 ordinary reachable set。

OrdinaryEdges 不表达 Go 接口语义,也不表达“某个方法槽位应该保留”。接口转换、接口方法调用、按名字取方法和反射访问由后续事实单独记录。

收集对象是顶层 Symbol 之间的引用边:

  • 函数体中的直接调用或全局引用。
  • 全局变量 initializer 中的普通符号引用。
  • ABI type descriptor 中非方法表部分引用到的类型或其他全局符号。

例如:

define void @"github.com/goplus/llgo/cl/_testgo/reader.main"() {
  %2 = call ptr @"github.com/goplus/llgo/runtime/internal/runtime.NewItab"(
      ptr @"_llgo_iface$...",
      ptr @"*_llgo_github.com/goplus/llgo/cl/_testgo/reader.stringReader")
  call void @"github.com/goplus/llgo/cl/_testgo/reader.ReadAll"(...)
  ret void
}

对应的普通引用边包括:

reader.main -> runtime.NewItab
reader.main -> _llgo_iface$...
reader.main -> *_llgo_...reader.stringReader
reader.main -> reader.ReadAll

但 ABI method table 中的 ifn / tfn 不进入 OrdinaryEdges。例如 *stringReader 的 ABI type descriptor 可以通过 ordinary reachability 进入分析,但其方法表中指向 (*stringReader).Len(*stringReader).Read(*stringReader).Close 等方法实现的引用不会作为普通边传播;否则类型元数据一旦可达,就会直接退化为该类型全部方法都被保活。

这些函数指针由 MethodInfo 记录,只有当对应方法槽位被算法判定为 live 时,才会继续进入 ordinary reachability。

5.3 TypeChildren

TypeChildren 表示类型元数据之间的结构性引用关系。若类型 A 的类型描述符中引用了类型 B,则记录 A -> B

例如:

type Inner struct{}

type Outer struct {
    F Inner
}

Outer 的类型描述符会引用字段类型 Inner,因此可以记录:

Outer -> Inner

数组、切片、指针、map、chan、函数签名中的参数和返回值类型,也都属于类似的结构性类型引用。

TypeChildren 不是 ordinary reachability,也不会直接保活 child type 的方法;它只用于传播“可能作为接口动态值使用”的语义域。

当某个类型在可达路径中被转换为接口后,程序可能通过反射从该类型信息继续取得其引用到的 child types。Go linker 也基于类似保守规则处理这种情况,例如从 []chan T 的类型信息可以通过反射继续取得 chan TT。因此,当类型 A 进入接口语义域时,分析会沿 TypeChildren[A] 将其 child types 也纳入接口语义域。

被传播到的 child type 不会因此直接保活全部方法。它们只是进入后续方法判活范围;具体方法槽位是否 live,仍由接口方法需求、常量 MethodByName 和反射保守规则决定。

5.4 InterfaceInfo

InterfaceInfo 记录接口类型的完整方法集合。对于每个接口 type symbol,它保存该接口要求的所有方法签名。每个方法签名由 ABI method name 和 ABI function type symbol 共同决定。

例如命名接口:

type I interface {
    M()
}

对应事实形态为:

[InterfaceInfo]
_llgo_github.com/goplus/llgo/cl/_testmeta/interface_named.I:
    M _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac

其中 _llgo_github.com/goplus/llgo/cl/_testmeta/interface_named.I 是接口 type symbol,M 是 ABI method name,_llgo_func$... 是 ABI function type symbol。

匿名接口同样会生成接口 symbol 并进入 InterfaceInfo

func use(v interface {
    M()
    N()
}) {}

对应事实形态为:

[InterfaceInfo]
_llgo_iface$f14WsslTA1u5wwC83jLU0HU2u2mmAWxBVE38vPBbRAo:
    M _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac
    N _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac

方法名必须保持 ABI method name 的匹配语义:导出方法必须使用短名,例如 M;非导出方法必须保留 package path 语义,例如:

[InterfaceInfo]
_llgo_github.com/goplus/llgo/cl/_testmeta/interface_unexported.I:
    github.com/goplus/llgo/cl/_testmeta/interface_unexported.m _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac

这样可以避免不同包中同名非导出方法被误认为同一个方法。

InterfaceInfo 本身不表示该接口在可达路径中被调用,也不表示任何具体类型的方法应该保留。它只描述接口定义的完整方法集合;接口方法是否真的形成可达需求,由 UseIfaceMethod 决定。

5.5 UseIface

UseIface 记录可达函数中发生的具体类型到接口的转换。它的 key 是发生该转换的 owner symbol,value 是被转换为接口的具体类型 symbol 集合。

例如:

type I interface{ M() }

type T struct{}

func (T) M() {}

func use(v I) {}

func main() {
    use(T{})
}

对应事实形态为:

[UseIface]
github.com/goplus/llgo/cl/_testmeta/interface_named.main:
    _llgo_github.com/goplus/llgo/cl/_testmeta/interface_named.T

这表示:当 main 可达时,T 可能作为接口动态值使用,需要进入接口语义域。

UseIface 本身不表示接口方法被调用,也不表示 T 的方法全部保留。它只提供类型侧事实;具体方法是否 live,需要与 UseIfaceMethodMethodInfo 和反射规则共同判断。

5.6 UseIfaceMethod

UseIfaceMethod 记录可达函数中发生的接口方法调用。它的 key 是发生接口调用的 owner symbol,value 是被调用的接口方法需求集合;每个需求由接口 type symbol、ABI method name 和 ABI function type symbol 组成。

例如:

func use(v I) {
    v.M()
}

对应事实形态为:

[UseIfaceMethod]
github.com/goplus/llgo/cl/_testmeta/interface_named.use:
    _llgo_github.com/goplus/llgo/cl/_testmeta/interface_named.I M _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac

这表示:当 use 可达时,接口 IM 方法被需求。

UseIfaceMethod 不直接指向任何具体类型的方法实现。后续算法会在已进入接口语义域的类型中查找:哪些类型完整实现该接口,并且哪些方法槽位可以作为该接口方法的实现。

5.7 MethodInfo

MethodInfo 记录具体类型的 ABI method table 槽位信息。它的 key 是具体类型 symbol,value 是按 ABI method table 顺序排列的方法槽位数组。该数组不需要额外保存槽位 index;后续分析输出的 live method indexes 按数组下标解释,因此这里的顺序必须与最终 ABI method table 顺序一致。

每个槽位记录:

  • ABI method name
  • ABI function type symbol
  • ifn:接口调用入口符号
  • tfn:普通方法调用入口符号

例如:

type T struct{}

func (T) M() {}
func (T) N() {}

对应事实形态为:

[MethodInfo]
*_llgo_github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.T:
    M _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.(*T).M github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.(*T).M
    N _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.(*T).N github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.(*T).N
_llgo_github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.T:
    M _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.(*T).M github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.T.M
    N _llgo_func$2_iS07vIlF2_rZqWB5eU0IvP_9HviM4MYZNkXZDvbac github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.(*T).N github.com/goplus/llgo/cl/_testmeta/interface_anyonmous.T.N

每行按顺序表示:

method-name method-type-symbol ifn-symbol tfn-symbol

MethodInfo 只记录槽位事实,不表示这些方法都必须保留。ifn / tfn 不会因为出现在 MethodInfo 中就进入 ordinary reachability;只有当该槽位被方法可达性算法判定为 live 时,它们才会继续作为普通符号传播。

值类型和指针类型可能拥有不同的 method table,因此需要分别记录。例如上例中 _llgo_...T*_llgo_...T 都有自己的 MethodInfo

5.8 UseNamedMethod

UseNamedMethod 记录可达函数中出现的常量 MethodByName(name) 方法名需求。当 reflect.Type.MethodByNamereflect.Value.MethodByName 的方法名参数可以在编译期确定时,记录该方法名。

例如:

func main() {
    _, _ = reflect.TypeOf(T{}).MethodByName("M")
}

对应事实形态为:

[UseNamedMethod]
github.com/goplus/llgo/cl/_testmeta/reflect_named.main:
    M

这表示:当 main 可达时,已进入接口语义域的类型上,与这些方法名匹配的槽位需要保留。

UseNamedMethod 只记录可静态确定的方法名。方法名的匹配语义必须与 ABI method name 一致;导出方法使用短名。

5.9 ReflectMethod

ReflectMethod 记录可达函数中出现无法静态确定方法名的反射方法访问。包括 reflect.Type.Method(index)reflect.Value.Method(index),以及方法名参数不是编译期常量的 MethodByName

例如:

func use(name string) {
    _ = reflect.ValueOf(T{}).MethodByName(name)
}

对应事实形态为:

[ReflectMethod]
github.com/goplus/llgo/cl/_testmeta/reflect_dynamic.use

这表示:当 use 可达时,分析器无法静态知道反射会访问哪个方法名,因此需要启用保守反射规则。

保守规则只作用于已进入接口语义域的类型:这些类型上的导出方法需要保留。ReflectMethod 不表示所有类型的所有方法都可达,也不直接保活非导出方法。

6. Package Summary 与 Global Summary

上一章定义的事实都以单包为生产边界。Package Summary 是这些局部事实的内存视图;Global Summary 是多个 Package Summary 合并后的 whole-program 查询视图。

本章只定义 Package Summary 与 Global Summary 的数据边界和合并语义。Package Summary 可以来自本次编译,也可以来自缓存恢复;其来源不影响后续全局合并和方法可达性分析。缓存文件格式、版本校验以及 cache hit/cache miss 生命周期见“缓存与序列化边界”。

6.1 Package Summary

Package Summary 在工程中由 internal/metadata.PackageMeta 表示。PackageMeta 是单包局部事实的内存结构,负责保存并暴露上一章定义的分析事实。它只描述本包在编译阶段能够确定的信息,不感知其他包,也不尝试推断 whole-program 方法可达性。

Package Summary 中的 SymbolName 都是局部引用。它们只在当前 PackageMeta 内有意义,不能跨包直接比较;不同包中的相同整数 ID 不代表相同的 symbol 或 name。每个局部 Symbol / Name 必须能够在该 PackageMeta 内解析回对应的模块级符号名或 ABI method name,以便后续全局合并按语义 remap。

PackageMeta 不包含 roots,也不包含方法可达性分析结论。它只回答“这个包贡献了哪些分析事实”。

预期结构与访问方法如下:

package metadata

type Symbol uint32
type Name uint32

type MethodSig struct {
    Name  Name
    MType Symbol
}

type MethodSlot struct {
    Sig MethodSig
    IFn Symbol
    TFn Symbol
}

type IfaceMethodDemand struct {
    Target Symbol
    Sig    MethodSig
}

type PackageMeta struct {
    // internal representation
}

func (pm *PackageMeta) SymbolName(sym Symbol) string
func (pm *PackageMeta) Name(ref Name) string

func (pm *PackageMeta) ForEachOrdinaryEdge(func(src Symbol, dsts []Symbol))
func (pm *PackageMeta) ForEachTypeChildren(func(parent Symbol, children []Symbol))
func (pm *PackageMeta) ForEachInterfaceInfo(func(iface Symbol, methods []MethodSig))
func (pm *PackageMeta) ForEachUseIface(func(owner Symbol, types []Symbol))
func (pm *PackageMeta) ForEachUseIfaceMethod(func(owner Symbol, demands []IfaceMethodDemand))
func (pm *PackageMeta) ForEachMethodInfo(func(owner Symbol, slots []MethodSlot))
func (pm *PackageMeta) ForEachUseNamedMethod(func(owner Symbol, names []Name))
func (pm *PackageMeta) ForEachReflectMethod(func(owner Symbol))

PackageMeta 的字段布局不是外部契约。调用方通过上述方法遍历事实,而不是直接读取内部 map、slice 或字符串表。这样后续无论缓存文件格式如何演进,或者内存结构是否改成更紧凑的布局,Global Summary 与分析器都不需要依赖这些细节。

PackageMeta 不要求调用方直接修改内部字段。单包编译阶段通过 metadata.Builder 构造它:

type Builder struct {
    // internal representation
}

func NewBuilder() *Builder

func (b *Builder) Symbol(name string) Symbol
func (b *Builder) Name(name string) Name

func (b *Builder) AddEdge(src, dst Symbol)
func (b *Builder) AddTypeChild(parent, child Symbol)
func (b *Builder) AddIfaceEntry(iface Symbol, methods []MethodSig)
func (b *Builder) AddUseIface(owner Symbol, types []Symbol)
func (b *Builder) AddUseIfaceMethod(owner Symbol, demands []IfaceMethodDemand)
func (b *Builder) AddMethodInfo(owner Symbol, slots []MethodSlot)
func (b *Builder) AddUseNamedMethod(owner Symbol, names []Name)
func (b *Builder) AddReflectMethod(owner Symbol)

func (b *Builder) Build() *PackageMeta

Builder 的职责是把编译阶段收集到的局部事实组装成 PackageMeta,并维护本包内的 Symbol / Name 注册关系。Build() 之后得到的 PackageMeta 作为事实视图交给后续流程消费。

Builder 只描述内存构造过程;PackageMeta 是否随后写入缓存、如何编码、如何从缓存恢复,属于“缓存与序列化边界”。

6.2 Global Summary

Global Summary 在工程中由 internal/metadata.GlobalSummary 表示。它是多个 PackageMeta 合并后的 whole-program 查询视图。

构建 Global Summary 时,需要把每个 PackageMeta 中的局部 Symbol / Name 解析为字符串,再重映射到统一的全局 ID。若不同包中的局部 Symbol 解析后对应同一个模块级符号名,则它们在 Global Summary 中必须合并为同一个全局 SymbolName 也需要按 ABI method name 语义合并。

合并时必须按字段语义 remap:

  • Symbol 字段按模块级符号名合并。
  • Name 字段按 ABI method name 语义合并。
  • MethodSig.NameNameMethodSig.MTypeSymbol
  • MethodSlot.Sig.NameNameMethodSlot.Sig.MTypeMethodSlot.IFnMethodSlot.TFnSymbol
  • IfaceMethodDemand.TargetSymbolIfaceMethodDemand.Sig 内部继续按 MethodSig 规则 remap。

Global Summary 对外提供按全局 Symbol 查询事实的能力:

func NewGlobalSummary(pkgs []*PackageMeta) (*GlobalSummary, error)

type GlobalSummary struct {
    // internal representation
}

func (g *GlobalSummary) LookupSymbol(name string) (Symbol, bool)
func (g *GlobalSummary) SymbolName(sym Symbol) string
func (g *GlobalSummary) Name(ref Name) string

func (g *GlobalSummary) Interfaces() []Symbol
func (g *GlobalSummary) TypesWithMethods() []Symbol

func (g *GlobalSummary) OrdinaryEdges(sym Symbol) []Symbol
func (g *GlobalSummary) TypeChildren(typ Symbol) []Symbol
func (g *GlobalSummary) InterfaceMethods(iface Symbol) []MethodSig
func (g *GlobalSummary) UseIface(owner Symbol) []Symbol
func (g *GlobalSummary) UseIfaceMethod(owner Symbol) []IfaceMethodDemand
func (g *GlobalSummary) MethodSlots(typ Symbol) []MethodSlot
func (g *GlobalSummary) UseNamedMethod(owner Symbol) []Name
func (g *GlobalSummary) HasReflectMethod(owner Symbol) bool

其中 TypesWithMethods() 返回所有作为 MethodInfo key 出现过的类型 symbol。它不做额外的“具体类型”分类判断,只表示这些类型拥有可参与方法判活的 ABI method slot。

GlobalSummary 不决定 roots,不执行方法可达性分析,也不判断方法是否 live。roots 由构建入口根据构建模式决定,再通过 LookupSymbol 解析为全局 Symbol

7. 方法可达性算法

本章定义 Global Summary 上的方法可达性语义。算法输入为:

  • GlobalSummary
  • 构建入口提供的 root symbol names

算法输出为:

  • map[type symbol name][]int,即具体类型到 live method slot indexes 的映射

算法只依赖 Global Summary 查询接口,不依赖单包 PackageMeta 的内部布局,也不依赖缓存文件格式。

方法裁剪的核心不是“类型可达就保留全部方法”,而是在 whole-program 可达路径上求两个语义集合的交集。

第一类是接口方法需求集合:当可达代码中出现 i.M() 这样的接口方法调用时,分析记录“接口 I 的方法 M 被需求”。这只说明某个接口槽位被调用,不直接指向任何具体实现。

第二类是可能动态调用的方法集合:当可达代码中发生具体类型到接口的转换时,该具体类型进入接口语义域;该类型及其相关 child types 的方法槽位成为候选动态调用目标。进入候选集合不表示这些方法已经 live,只表示它们需要接受后续判活规则检查。

真正需要保留的方法,是上述两类信息匹配后的结果:某个已进入接口语义域的类型 T,如果完整实现接口 I,且 T 的某个方法槽位与可达接口方法需求 I.M 的方法名和方法类型匹配,则该槽位可能在执行过程中被接口动态调用,必须保留。

这个过程必须迭代到 fixed point。因为一旦某个方法槽位被判定为 live,它的 mtypeifntfn 会继续进入普通可达传播;新可达的方法体可能继续产生新的接口转换、接口方法需求、常量 MethodByName 或反射方法访问,从而反过来使更多方法槽位被保留。

flowchart TD
    Roots[Root symbols<br/>根符号] --> Reach[Ordinary reachable symbols<br/>普通可达符号]
    Reach --> Facts[Collect semantic facts<br/>收集可达路径上的语义事实]

    Facts --> Demands[Interface method demands<br/>接口方法需求 I.M]
    Facts --> Types[Types converted to interface<br/>T 进入接口语义域]
    Types --> Children[Propagate through TypeChildren<br/>沿 TypeChildren 传播]
    Children --> Candidates[Markable method slots<br/>接口语义域类型上的候选方法槽位]
    Types --> Candidates

    Demands --> Match[Match demands with candidates<br/>T 完整实现 I 且方法签名匹配]
    Candidates --> Match

    Match --> Live[Live method slots<br/>存活方法槽位]
    Live --> MoreReach[Mark mtype / ifn / tfn reachable<br/>继续进入普通可达传播]
    MoreReach --> Reach
Loading

图中从 Live method slots 回到 Ordinary reachable symbols 的路径表示 fixed-point 回流:新判活的方法会继续带来普通引用和新的 Go 语义事实,直到不再产生新的 live method slot。

与 Go linker 当前算法相比,LLGo 在接口方法匹配上保留了更精确的调用点信息。Go linker 在可达接口调用点处主要记录方法名与方法类型;后续扫描候选方法时,只要某个方法的 name/type 与已记录需求匹配,就可能被保留。这种做法保守且正确,但会把一些“同名同类型、但其所属类型并未完整实现该接口”的方法一并保留。

LLGo 的分析事实中,接口方法需求记录为 (interface symbol, method signature),而不是只有 method signature。因此,在决定某个类型 T 的方法槽位 m 是否需要保留时,算法不仅检查 m 的方法名和方法类型是否匹配,还要求 T 完整实现该接口 I,并确认 m 可以作为接口方法 I.M 的实现。这样,同名同类型但并不属于该接口实现关系的方法不会被误保留。

7.1 核心状态

算法状态可以概念性表示为:

type MethodRef struct {
    Owner Symbol    // 所属具体类型
    Slot  int       // MethodInfo[Owner] 中的数组下标
    Sig   MethodSig // 方法名 + ABI function type symbol
    IFn   Symbol    // 接口调用入口
    TFn   Symbol    // 普通方法调用入口
}

type deadcodeState struct {
    info *metadata.GlobalSummary

    methodImplKeys map[MethodID][]IfaceMethodKey // 预构建的接口方法实现索引

    reachable map[Symbol]struct{} // 已确认普通可达的符号集合,用于去重
    workQueue []Symbol            // 待展开处理的可达符号

    usedInIface        map[Symbol]struct{} // 已进入接口语义域的类型
    processedIfaceType map[Symbol]struct{} // 已按接口语义域身份展开过方法槽位的类型

    ifaceMethods map[IfaceMethodKey]struct{} // 可达路径中出现的接口方法需求
    namedMethods map[Name]struct{}           // 可达路径中出现的常量 MethodByName 方法名
    reflectSeen  bool                        // 可达路径中是否出现无法静态确定方法名的反射访问

    markableMethods []MethodRef       // 候选方法槽位:已可达且处于接口语义域的类型上的方法
    liveSlots       map[Symbol][]int  // 输出累加器:具体类型 symbol -> live method slot indexes
}

reachableworkQueue 的职责不同:reachable 是已确认普通可达的集合;workQueue 是仍需展开其引用边和语义事实的待办队列。通常新进入 reachable 的符号会进入 workQueue;如果某个类型已经 reachable,但后来首次进入接口语义域,也会重新进入 workQueue,以便展开它的方法槽位。

func markReachable(sym Symbol) {
    if reachable.has(sym) {
        return
    }

    reachable.add(sym)
    workQueue.push(sym)
}

func markUsedInIface(typ Symbol) {
    if usedInIface.has(typ) {
        return
    }

    usedInIface.add(typ)

    if reachable.has(typ) {
        workQueue.push(typ)
    }

    for _, child := range info.TypeChildren(typ) {
        markUsedInIface(child)
    }
}

普通可达传播中,一个 symbol 只会因为第一次 reachable 入队;但类型 symbol 可以在已经 reachable 之后,因为首次进入接口语义域而再次入队一次。processedIfaceType 用于保证同一类型最多按接口语义域身份展开一次方法槽位。

7.2 接口方法实现关系

为了避免在 fixed-point 过程中反复判断完整接口实现关系,分析开始前会基于 InterfaceInfoMethodInfo 预先构建接口方法实现索引。

该索引回答的问题是:

对于类型 T 的某个方法槽位 m,如果 T 完整实现接口 I,那么 m 可以实现哪些接口方法 I.M

概念结构如下:

type IfaceMethodKey struct {
    Iface Symbol
    Sig   MethodSig
}

type MethodID struct {
    Owner Symbol
    Slot  int // MethodInfo[Owner] 中的数组下标
}

methodImplKeys map[MethodID][]IfaceMethodKey

构建过程分为三个阶段:

  1. InterfaceInfo 建立 methodRefs,用于按方法签名反查可能包含该方法的接口。
  2. 对每个有 MethodInfo 记录的类型 T,统计它的方法集合分别命中了每个接口的多少个不同必需方法。
  3. 只有当 T 命中了接口 I 的全部必需方法时,才把 T 上对应的方法槽位登记为 I 的方法实现。

对应伪代码如下:

func buildMethodImplKeys(info GlobalSummary) map[MethodID][]IfaceMethodKey {
    methodRefs := map[MethodSig][]Symbol{}
    methodImplKeys := map[MethodID][]IfaceMethodKey{}

    for _, iface := range info.Interfaces() {
        for _, sig := range info.InterfaceMethods(iface) {
            methodRefs[sig] = append(methodRefs[sig], iface)
        }
    }

    for _, typ := range info.TypesWithMethods() {
        slots := info.MethodSlots(typ)
        impls := map[Symbol]int{} // interface -> matched required method count

        for _, slot := range slots {
            for _, iface := range methodRefs[slot.Sig] {
                impls[iface]++
            }
        }

        for slotIndex, slot := range slots {
            id := MethodID{Owner: typ, Slot: slotIndex}

            for _, iface := range methodRefs[slot.Sig] {
                if impls[iface] != len(info.InterfaceMethods(iface)) {
                    continue
                }

                key := IfaceMethodKey{Iface: iface, Sig: slot.Sig}
                methodImplKeys[id] = append(methodImplKeys[id], key)
            }
        }
    }

    return methodImplKeys
}

impls[iface] 表示类型 T 的方法集合命中了接口 iface 的多少个不同必需方法。只有当该数量等于 InterfaceInfo[iface] 的方法数量时,才认为 T 完整实现了 iface

后续扫描候选方法槽位时,算法只需要查看 methodImplKeys[MethodID{T, slot}]:如果其中存在某个已经可达的接口方法需求 (I, MethodSig),则该槽位可以作为该需求的实现并被判定为 live。

7.3 固定点传播

算法以轮次推进。每一轮先从 work queue 中扩展普通可达符号,并收集这些符号携带的 Go 语义事实;随后扫描当前候选方法槽位,根据已经收集到的接口方法需求、MethodByName 和反射规则判定新的 live 方法。新 live 方法会把自己的 mtypeifntfn 加入普通可达队列,进入下一轮。

直到一轮中既没有新的普通可达符号,也没有新的 live 方法,算法收敛。

主循环如下:

func deadcode(info *GlobalSummary, rootNames []string) map[Symbol][]int {
    roots := lookupRoots(info, rootNames)
    methodImplKeys := buildMethodImplKeys(info)

    for _, root := range roots {
        markReachable(root)
    }

    for {
        collectReachableFacts()
        changed := markLiveMethods(methodImplKeys)

        if workQueue.empty() && !changed {
            return liveSlots
        }
    }
}

第一阶段收集可达路径上的事实。该阶段处理 workQueue 中的普通可达符号:

func collectReachableFacts() {
    for !workQueue.empty() {
        sym := workQueue.pop()

        for _, dst := range info.OrdinaryEdges(sym) {
            markReachable(dst)
        }

        for _, typ := range info.UseIface(sym) {
            markUsedInIface(typ)
        }

        for _, demand := range info.UseIfaceMethod(sym) {
            ifaceMethods.add(IfaceMethodKey{demand.Target, demand.Sig})
        }

        for _, name := range info.UseNamedMethod(sym) {
            namedMethods.add(name)
        }

        if info.HasReflectMethod(sym) {
            reflectSeen = true
        }

        if sym is a reachable type already in interface domain {
            append its MethodSlots to markableMethods
        }
    }
}

markUsedInIface 的语义见 7.1:它负责维护接口语义域,并沿 TypeChildren 传播。若一个类型已经普通可达但后来才进入接口语义域,它会重新进入 workQueue,以便展开其方法槽位。

第二阶段扫描候选方法槽位。满足判活规则的方法被标记为 live;不满足的槽位保留在候选集合中,等待后续轮次中出现新的接口方法需求、MethodByName 或反射规则:

markLiveMethods 负责遍历 markableMethods,对每个候选方法调用 shouldKeep。若候选方法被判定为 live,则 markMethodLive 将该槽位加入 liveSlots,并把该方法槽位的 mtypeifntfn 继续标记为普通可达符号;未判活的候选方法继续留在 markableMethods 中等待后续轮次。

func markLiveMethods(methodImplKeys map[MethodID][]IfaceMethodKey) bool {
    changed := false
    remaining := markableMethods[:0]

    for _, method := range markableMethods {
        if shouldKeep(method, methodImplKeys) {
            markMethodLive(method)
            changed = true
        } else {
            remaining = append(remaining, method)
        }
    }

    markableMethods = remaining
    return changed
}

shouldKeep 的具体判活规则见下一节。

7.4 方法判活规则

shouldKeep 判断某个候选方法槽位是否必须保留。候选方法必须来自已进入接口语义域的类型;如果类型没有进入接口语义域,其方法不会进入 markableMethods,也不会走到这里。

func shouldKeep(method MethodRef, methodImplKeys map[MethodID][]IfaceMethodKey) bool {
    if reflectSeen && isExported(method.Sig.Name) {
        return true
    }

    if namedMethods.has(method.Sig.Name) {
        return true
    }

    id := MethodID{Owner: method.Owner, Slot: method.Slot}
    for _, key := range methodImplKeys[id] {
        if ifaceMethods.has(key) {
            return true
        }
    }

    return false
}

三类规则含义如下:

  • 反射保守规则:若可达路径中出现无法静态确定方法名的反射方法访问,则已进入接口语义域类型上的导出方法需要保留。
  • 常量 MethodByName 规则:若可达路径中出现常量 MethodByName("M"),则已进入接口语义域类型上的同名方法需要保留。方法名匹配使用前文定义的 ABI method name 语义。
  • 接口方法需求规则:若可达路径中存在接口方法需求 (I, MethodSig),并且候选方法槽位在 methodImplKeys 中登记为该接口方法的实现,则该槽位需要保留。

markMethodLive 在槽位判活后执行:

func markMethodLive(method MethodRef) {
    liveSlots[method.Owner] = append(liveSlots[method.Owner], method.Slot)

    markReachable(method.Sig.MType)
    markReachable(method.IFn)
    markReachable(method.TFn)
}

这样,方法的类型信息、接口调用入口和普通调用入口都会进入 ordinary reachability,并在后续轮次继续带出新的普通引用和 Go 语义事实。

7.5 收敛结果

workQueue 为空,且一轮 markLiveMethods 没有新增 live 方法时,fixed-point 收敛。此时:

  • 所有从 roots 可达的普通符号都已展开。
  • 所有可达路径中出现的接口转换、接口方法需求、MethodByName 与反射事实都已收集。
  • 所有因此可能判活的方法槽位都已被扫描。

算法输出 liveSlots,并在对外返回时转换为:

map[string][]int // type symbol name -> live method slot indexes

其中 key 是具体类型的模块级 symbol name,value 是需要保留的方法槽位下标集合。下标按照 MethodInfo[type] 的槽位数组顺序解释,也必须与最终 ABI method table 顺序一致。

未出现在输出中的类型,表示其方法槽位没有被任何语义规则保留;出现在输出中的类型,仅 value 中列出的槽位需要保留。

该输出只表达方法可达性分析结论。后续构建流程如何提供 roots、以及如何消费这些 live indexes,见下一章。

8. 构建流程集成

方法可达性算法本身只消费 GlobalSummary 与 root symbol names。当前工程中,单包 aPkg.Meta 需要等该包编译阶段结束后才完整产生;因此 Global Summary 合并与 deadcode 分析发生在所有参与链接的包完成编译之后、最终结果回写与链接之前。

构建流程需要从所有参与链接的包中取出 aPkg.Meta,合并为 GlobalSummary,再把程序入口对应的 Go symbol names 作为 deadcode 分析 roots 传入。

对于普通可执行程序,roots 至少包含入口包的初始化函数和主函数:

roots := []string{
    entryPkgPath + ".init",
    entryPkgPath + ".main",
}

metas := make([]*metadata.PackageMeta, 0, len(pkgs))
for _, pkg := range pkgs {
    metas = append(metas, pkg.Meta)
}

summary, err := metadata.NewGlobalSummary(metas)
liveSlots := deadcode(summary, roots)

GlobalSummary 只提供全局事实查询,不自行推导 roots;roots 由构建流程根据当前构建模式决定。后续如果存在 runtime init、plugin、shared library 或其他 build mode 特殊入口,也应在这一层扩展 roots,而不是放进 package summary 格式。

8.1 分析结果消费

方法可达性算法只输出 type symbol name -> live method slot indexes,不直接修改 IR 或 ABI type metadata。后续阶段负责消费该结果,并确保未保留的方法槽位不再强引用原方法实现。

当前工程化回写方案可以通过生成同名 strong ABI type definition 覆盖原 weak type metadata。对于 live slot,保留原方法名、方法类型、ifntfn;对于 dead slot,保留方法名和方法类型,但将 ifn / tfn 重定向到专门的 unreachable method stub,而不是继续引用原方法实现。

这里不能把 dead slot 的 ifn / tfn 写成 null。运行时构造 itab 时,fun[0] == 0 具有特殊含义:表示该 concrete type 没有实现目标接口。如果 DCE 将一个仍然匹配接口方法签名的槽位函数指针清成 0,runtime 可能把对应 itab 当成“不完整实现”,从而破坏 itab cache 语义。

这个 stub 应与 Go linker 的做法保持一致:Go linker 会把不可达方法重定向到同一个 runtime.unreachableMethod stub;该 stub 不执行任何原方法逻辑,一旦被调用就立即 throw/panic,Go runtime 中的错误信息类似 unreachable method called. linker bug?。这样 dead method slot 仍保持非零函数入口,接口实现判断和 itab cache 不会被破坏;如果该方法真的在运行时被调用,也会以和 Go 一致的 stub panic 方式明确失败。

Go linker 中对应逻辑大致如下:

// runtime.unreachableMethod is a function that will throw if called.
// We redirect unreachable methods to it.
names = append(names, "runtime.unreachableMethod")

if weak && !ldr.AttrReachable(rs) {
    // Redirect it to runtime.unreachableMethod, which will throw if called.
    rs = syms.unreachableMethod
}

func unreachableMethod() {
    throw("unreachable method called. linker bug?")
}

该回写方案只是当前工程实现选择。后续可以采用其他等价 rewrite 方式,但必须满足两个语义约束:dead slot 不再强引用原方法实现;dead slot 不能破坏 runtime 的接口实现判断和 itab cache 语义。

9. 缓存与序列化边界

Package Summary 是可以缓存的单包分析事实产物。缓存层只负责把 PackageMeta 写入文件,并在 cache hit 时恢复为等价的 PackageMeta;它不参与全局合并,不执行方法可达性分析,也不保存分析结果。

无论 PackageMeta 来自本次编译还是缓存反序列化,后续 Global Summary 看到的都是同一种内存视图。

9.1 缓存命中与生成时机

本方案将 metadata 的生命周期分为两条互斥路径,最终汇聚到内存中的单包 PackageMeta。所有参与构建的 PackageMeta 随后由 metadata.NewGlobalSummary 合并为全局视图,供全局分析消费。

Cache MISS

首次编译某个包时:

  1. 构建层创建 metadata.Builder
  2. 编译阶段通过 Builder 填入类型、接口、方法、反射等语义事实。
  3. 生成 llvm.Module 后,构建阶段扫描 module 提取 OrdinaryEdges,填入同一个 Builder
  4. 调用 Builder.Build() 得到内存中的 PackageMeta
  5. PackageMeta 直接参与本次 Global Summary 合并。
  6. 同一个 PackageMeta 序列化写入 .meta 文件,供后续 cache hit 使用。

这里 OrdinaryEdges 仍然来自扫描 llvm.Module:它们描述函数体指令和全局 initializer 中的直接符号引用。类型相关事实(例如 InterfaceInfoMethodInfoTypeChildren 等)则由编译阶段通过 Builder 填入。

Cache HIT

缓存命中时:

  1. .meta 读取并反序列化恢复 PackageMeta
  2. 恢复出的 PackageMeta 直接参与 Global Summary 合并。
  3. 不需要重新从 LLVM module 中恢复这些分析事实。

也就是说,.meta 文件只在两个时刻参与 I/O:首次编译结束时写入一次,缓存命中时读取一次。不存在“写到磁盘再立刻读回”的循环。

9.2 落盘格式

package summary 采用自定义二进制格式落盘。文件头使用固定宽度字段,便于快速识别格式和版本;payload 中的计数、字符串长度和字符串表下标使用标准库 encoding/binaryPutUvarint / ReadUvarint 编码。

文件以固定 4 字节 magic 开头,紧随一个 uint32le 版本号,之后按固定顺序排列各 section。本格式不依赖分隔符或 section offset table;每个 section 内通过“先写 count、再写内容”的 uvarint 前缀模式自定边界,读完一个 section 后文件指针自然停在下一 section 开头。

落盘文件名为 <fingerprint>.meta,与现有的 <fingerprint>.a<fingerprint>.manifest 存放在同一缓存目录下,三者共享同一套目录层级和缓存 key。

以下用 BNF 产生式描述文件结构。每条产生式右侧字段按书写顺序依次写入。

file = header stringTable ordinaryEdges typeChildren
       interfaceInfo useIface useIfaceMethod methodInfo
       useNamedMethod reflectMethod

; -- Header --

header     = magic:byte[4] version:uint32le
magic      = "LLPS"                   ; LLgo Package Summary
version    = 1                        ; 当前格式版本,固定小端 32 位整数

; -- String Table --

stringTable = count:uvarint str*[count]
str         = len:uvarint bytes*[len]
            ; 统一字符串表:模块级命名符号、方法名等全部字符串只在此处存储一次
            ; 后续所有 section 通过整数 ID 引用此表中的字符串

; -- Ordinary Edges(邻接表,每个源符号一组) --

ordinaryEdges = count:uvarint edgeGroup*[count]
edgeGroup     = src:uvarint ndst:uvarint dst*[ndst]:uvarint

; -- Type Children(邻接表,每个父类型一组) --

typeChildren = count:uvarint childGroup*[count]
childGroup   = parent:uvarint nchildren:uvarint child*[nchildren]:uvarint

; -- Interface Info(每个接口的方法签名集合) --

interfaceInfo = count:uvarint ifaceEntry*[count]
ifaceEntry    = iface:uvarint nmethods:uvarint methodSig*[nmethods]
methodSig     = name:uvarint mtype:uvarint
              ; name: 方法名字符串的字符串表 ID,反序列化为 Name
              ; mtype: 方法函数类型字符串表 ID,反序列化为 Symbol

; -- UseIface(每个 owner 可达后进入接口语义域的具体类型集合) --

useIface = count:uvarint uiEntry*[count]
uiEntry  = owner:uvarint ntypes:uvarint type*[ntypes]:uvarint

; -- UseIfaceMethod(每个 owner 可达后产生的接口方法需求集合) --

useIfaceMethod = count:uvarint uimEntry*[count]
uimEntry       = owner:uvarint ndemands:uvarint demand*[ndemands]
demand         = iface:uvarint methodSig
               ; iface: 目标接口符号 ID,methodSig 同上

; -- MethodInfo(每个具体类型的方法槽位表) --

methodInfo = count:uvarint miEntry*[count]
miEntry    = type:uvarint nslots:uvarint slot*[nslots]
slot       = sig:methodSig ifn:uvarint tfn:uvarint
           ; sig: 方法签名,ifn: 接口方法入口字符串表 ID,tfn: 普通方法入口字符串表 ID
           ; ifn/tfn 反序列化为 Symbol
           ; 槽位写入顺序必须与最终 ABI method table 顺序一致

; -- UseNamedMethod(每个 owner 可达后产生的按名字匹配的方法名集合) --

useNamedMethod = count:uvarint unmEntry*[count]
unmEntry       = owner:uvarint nnames:uvarint mname*[nnames]:uvarint
               ; mname: 方法名字符串的字符串表 ID,反序列化为 Name

; -- ReflectMethod(进入保守反射模式的 owner 集合) --

reflectMethod = count:uvarint owner*[count]:uvarint

所有 section 按上述固定顺序依次写入。若某 section 无数据,写入 count=0。除 header 中的 version:uint32le 外,产生式中出现的整数均为 uvarint,所有字符串均为 [len:uvarint] [bytes]。payload 中引用字符串的整数是 stringTable 下标;反序列化时必须根据字段语义恢复为 SymbolName

9.3 反序列化

反序列化按文件顺序逐 section 读取,与写入逻辑完全对称:

  1. 校验 magic,读取 version,若 version 不被当前编译器支持则放弃该缓存。
  2. 读取字符串表,恢复为 []string
  3. 按固定顺序依次读取各 section,内部整数 ID 作为字符串表下标解析为对应字符串,并根据字段语义恢复为局部 SymbolName

反序列化只恢复单包 PackageMeta,不做跨包合并,也不产生分析结论。所有 package summary 都恢复成内存结构后,再由 metadata.NewGlobalSummary 统一合并为 whole-program 视图。

[summary A] --反序列化--> [PackageMeta A]
[summary B] --反序列化--> [PackageMeta B] --> metadata.NewGlobalSummary()
[summary C] --反序列化--> [PackageMeta C]

后续 fixed-point 算法只消费合并后的内存视图,不关心这些 PackageMeta 来自缓存文件还是本次编译直接产生的内存对象。

9.4 版本与兼容性

version 字段用于标识 package summary 二进制格式的布局版本。当前 version = 1

version 是缓存有效性判断的一部分。reader 只接受自己明确支持的 version;只要缓存文件中的 version 与当前 reader 支持的 version 不一致,该缓存文件就视为 invalid,不能读取,也不做跨版本兼容。

因此,版本 1 的 reader 不能读取版本 2 的缓存文件;版本 2 的 reader 也不应默认读取版本 1 的缓存文件,除非实现显式声明并验证了对应的兼容逻辑。当前方案不定义这种兼容路径。

缓存失效主要由构建 action ID 保证:包源码、依赖、编译参数或编译器相关输入变化时,action ID 自动不同,旧缓存自然失效。magic/version 提供额外的格式防御;若 magic 不匹配或 version 不被支持,应放弃该缓存文件并回退到重新生成 PackageMeta

10. MVP 验证结果

当前 MVP 已在 xgo-dev/llgo#1868 中做过一轮尺寸对比验证。该 PR 还不是最终合入形态,后续仍需要按 package summary、metadata cache、deadcode analyzer、结果回写等边界拆分 PR,但现有结果已经可以验证:相对于未开启 DCE 的产物,方法裁剪能够删除符合预期的内容,并在多个反射、接口、标准库相关 demo 上带来可观的二进制尺寸下降。

以下数据按节省字节数从高到低排序:

Demo No DCE DCE Saved Saved %
goimporter-1389 4267280 2875648 1391632 32.61%
embedunexport-1598 3056032 1762912 1293120 42.31%
gotypes 3072896 2458912 613984 19.98%
abimethod 1434896 912224 522672 36.43%
mimeheader 1527664 1005856 521808 34.16%
reflectpointerto 1719408 1236656 482752 28.08%
reflectmake 1905136 1443568 461568 24.23%
netip 1504976 1048512 456464 30.33%
gobuild-1389 1590144 1158624 431520 27.14%
randdemo 1415280 985376 429904 30.38%
gotoken 1433408 1003808 429600 29.97%
sysexec 1414528 985376 429152 30.34%
oslookpath 1414144 985008 429136 30.35%
sync 1419840 990800 429040 30.22%
createtemp-1654 1431072 1002992 428080 29.91%
gobuild 2199920 1776784 423136 19.23%
randcrypt 1557968 1143248 414720 26.62%
maphash 1576416 1162128 414288 26.28%
failed/stacktrace 1376288 963392 412896 30.00%
cgo 1376928 964128 412800 29.98%
readdir 1395248 982512 412736 29.58%
timedur 1375904 963440 412464 29.98%
mkdirdemo 1394336 981920 412416 29.58%
sysopen-1654 1394208 981840 412368 29.58%
reflectfunc 1396240 984240 412000 29.51%
reflectcopy 1413152 1002256 410896 29.08%
logdemo 1377216 980864 396352 28.78%
checkfile 1377344 981264 396080 28.76%
reflectname-1412 1376032 979968 396064 28.78%
oswritestring 1376704 981216 395488 28.73%
texttemplate 2172768 1929248 243520 11.21%
osfile 788480 554368 234112 29.69%
commandrun 1160880 938496 222384 19.16%
reflectindirect 687056 487888 199168 28.99%
reflectmethod 1716672 1543392 173280 10.09%
timer 508304 419856 88448 17.40%
async 319600 277024 42576 13.32%
gotime 354480 328496 25984 7.33%
syscall 173696 151856 21840 12.57%
ifaceconv 74736 72896 1840 2.46%
ifaceprom-1559 91120 89312 1808 1.98%
mapclosure 78416 77136 1280 1.63%
export 95072 93856 1216 1.28%
cabi 70592 69472 1120 1.59%
issue1538-floatcvtuint-over 70592 69472 1120 1.59%
defer 72384 71280 1104 1.53%
defer/stress 72320 71216 1104 1.53%
issue1538 70512 69408 1104 1.57%
complex 73440 72352 1088 1.48%
defer/setjmp/c_standard_demo/closure_test 72464 71376 1088 1.50%
math 73248 72176 1072 1.46%
defer/setjmp/c_standard_demo/llgo_test 70816 70816 0 0.00%
goroutine 71040 71040 0 0.00%
runtime 89760 89760 0 0.00%
statefn 71568 71568 0 0.00%

另外,xgo-dev/llgo#1866 中也在本机对 cache hit 后的 .meta 读取与反序列化开销做了观测。选取的 test/std/net/http/cookiejar 是更接近 CI 慢用例的 case,该用例在 CI 上大约会运行 68s,因此更能代表较大依赖图下的 package summary cache 读取成本。

统计口径是 cache hit 后执行 readMeta(paths.Meta) 的耗时,也就是 .meta 文件读取加 metadata.ReadMeta 反序列化,不包含 manifest 读取/解析、archive 检查或后续 cache-hit build 流程。

cache hits: 209
cache misses: 1
total meta read: 59.223 ms
avg per package: 283.363 us
min: 38.500 us
max: 2.908 ms
p50: 150.666 us
p90: 701.916 us
p95: 936.333 us
p99: 1.283 ms

最慢的几个 cache hit:

net/http        2.907834 ms
time            1.528417 ms
go/ast          1.462417 ms
crypto/tls      1.283375 ms
encoding/asn1   1.172792 ms
net             1.095833 ms
os/exec         1.020917 ms
reflect         1.020583 ms
encoding/json   1.011542 ms
fmt             994.375 us

结论是:即使用 test/std/net/http/cookiejar 这种较大的用例,.meta 读取与反序列化总耗时也只有约 59ms,占 68s 级别总耗时很小。当前瓶颈基本不在 package summary 的读取反序列化上。

11. PR 拆分计划

该方案建议按从“无行为变化”到“主流程启用”的顺序分阶段推进。package summary cache 不应在缺少实际消费者时接入普通构建路径,以避免引入无法抵消的额外构建成本。

  • In-memory Package Summary 与 facts extraction PR

    引入内存态 metadata 模型与事实提取能力,但不引入 .meta 二进制缓存格式,也不改变普通构建主流程。

    该 PR 包含:

    • internal/metadata.PackageMeta
    • metadata.Builder
    • Symbol / Name / MethodSig / MethodSlot / IfaceMethodDemand
    • GlobalSummary
    • FormatMeta 或等价的文本 dump 能力,用于 golden 验证
    • 编译阶段可选 MetaBuilder
    • TypeChildrenInterfaceInfoUseIfaceUseIfaceMethodMethodInfoUseNamedMethodReflectMethod 的提取
    • OrdinaryEdges 提取:在 package module 生成后扫描函数体和全局 initializer,将普通符号引用填入同一个 PackageMeta
    • _testmeta golden 测试

    这一阶段的关键约束是:普通构建默认不创建 MetaBuilder,也不写 .meta 文件。只有测试、meta check 或显式调试路径启用 builder,并在对应路径中执行 OrdinaryEdges 扫描。该阶段用于验证 facts extraction 的正确性,同时避免引入默认编译开销。

  • Package Summary binary cache format PR

    PackageMeta 内存模型稳定后,单独引入二进制序列化与反序列化能力。

    该 PR 包含:

    • WriteMeta
    • ReadMeta
    • magic/version header
    • string table 编码
    • 各 section 的二进制编码
    • round-trip、invalid magic、unsupported version、截断文件等测试
    • chore/metadump 等调试工具,用于检查 .meta 内容

    这一 PR 仍不需要将 .meta 接入普通构建主流程。该阶段仅验证 PackageMeta 可以稳定落盘和恢复。

  • Deadcode Analyzer PR

    独立引入方法可达性分析器,不接入主流程、不修改 IR。

    该 PR 包含:

    • internal/deadcode
    • 输入 GlobalSummary + root symbol names
    • 输出 map[type symbol name][]int
    • 接口方法实现关系预计算
    • fixed-point 可达传播
    • UseIfaceUseIfaceMethodTypeChildrenUseNamedMethodReflectMethod 规则
    • 使用手写 PackageMeta / GlobalSummary 的单元测试

    这一阶段用于验证算法语义,不承担构建流程和 IR rewrite 风险。

  • DCE 主流程 MVP PR

    最后一个 PR 将前述能力接入真实构建流程,并完成端到端方法裁剪。

    该 PR 包含:

    • 普通构建中为参与链接的包生成 PackageMeta
    • cache miss 时写入 .meta
    • cache hit 时读取 .meta
    • 从所有 aPkg.Meta 合并 GlobalSummary
    • 由构建流程提供 roots,例如入口包的 .init / .main
    • 调用 deadcode.Analyze
    • 引入并调用 dcepass
    • 根据 live method slot indexes 生成 strong ABI type override
    • dead method slot 的 ifn / tfn 重定向到 runtime.unreachableMethod,而不是写 null
    • 使用 nm、尺寸对比和运行测试验证方法实现被删除且程序行为正确

    这是第一个在普通主流程中启用 package summary cache I/O 的 PR;同时也是第一个消费这些 metadata 并产生 DCE 收益的 PR。因此,缓存读写成本与方法裁剪收益在同一阶段出现,不会存在“缓存已经接入但尚无消费者”的中间状态。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions