diff --git a/internal/build/build.go b/internal/build/build.go index 6db6ae9085..1b9f478d82 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -343,6 +343,25 @@ func (c *Config) deadcodeDropEnabled() bool { return buildenv.Dev && c.DeadcodeDrop && !c.goGlobalDCEEnabled() } +// thinLTODeadcodeEnabled selects the experimental planner/rewrite path. The +// package archives are materialized only after the link-specific plan has been +// computed so their ThinLTO summaries describe the rewritten method tables. +func (c *Config) thinLTODeadcodeEnabled() bool { + return c != nil && c.deadcodeDropEnabled() && c.ltoMode() == lto.Thin +} + +const thinLTODeadcodeImportLimitFlag = "-Wl,-mllvm,-import-instr-limit=5" + +func thinLTODeadcodeLinkerArgs(c *Config) []string { + if !c.thinLTODeadcodeEnabled() { + return nil + } + // LLVM's default import budget is performance-biased. Imported bodies also + // duplicate LLGo funcinfo sites, so use the established size-oriented + // budget while retaining imports of very small cross-package callees. + return []string{thinLTODeadcodeImportLimitFlag} +} + func (c *Config) packageMetaEnabled() bool { return c.CollectPackageMeta || c.deadcodeDropEnabled() } @@ -1108,7 +1127,12 @@ func prePackageBuild(ctx *context, task *packageBuildTask, verbose bool) error { if err := ctx.collectFingerprint(aPkg); err != nil { return err } - ctx.tryLoadFromCache(aPkg) + // The first ThinLTO planner prototype needs the package LLVM modules alive + // until linkMainPkg computes the link-specific rewrite. Avoid consuming a + // prebuilt archive here; cache-aware bitcode overlays are a follow-up. + if !ctx.buildConf.thinLTODeadcodeEnabled() { + ctx.tryLoadFromCache(aPkg) + } if verbose { status := "MISS" if aPkg.CacheHit { @@ -1138,6 +1162,12 @@ func finalizePackageBuild(ctx *context, task *packageBuildTask, verbose bool) (p if aPkg.CacheHit { return packageBuildResultFor(task), nil } + if ctx.buildConf.thinLTODeadcodeEnabled() { + if task.kind == cl.PkgLinkExtern { + appendExternalLinkArgs(ctx, aPkg, task.kindParam) + } + return packageBuildResultFor(task), nil + } if err := normalizeToArchive(ctx, aPkg, verbose); err != nil { return packageBuildResultFor(task), err } @@ -1435,6 +1465,27 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa linkArgs = append(linkArgs, rtLinkArgs...) archiveInputs = append(archiveInputs, rtLinkInputs...) } + if ctx.buildConf.thinLTODeadcodeEnabled() { + if err := materializeThinLTODeadcode(ctx, linkedOrder, needRuntime, verbose); err != nil { + return err + } + // The package archives are intentionally delayed in this mode so the + // rewritten module, rather than the original module, supplies the + // ThinLTO summary. Rebuild the package input list after materialization. + archiveInputs = archiveInputs[:0] + for _, aPkg := range linkedOrder { + if aPkg == nil || aPkg.ArchiveFile == "" { + continue + } + if isRuntimePkg(aPkg.PkgPath) { + if needRuntime || needPyInit || ctx.buildConf.Target == "" { + archiveInputs = append(archiveInputs, aPkg.ArchiveFile) + } + continue + } + archiveInputs = append(archiveInputs, aPkg.ArchiveFile) + } + } // Generate main module file (needed for global variables even in library modes) // This is compiled directly to .o and added to linkInputs (not cached) @@ -1460,7 +1511,7 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa funcInfo: funcInfo, pcLineInfo: pcLineInfo, }) - if ctx.buildConf.deadcodeDropEnabled() { + if ctx.buildConf.deadcodeDropEnabled() && !ctx.buildConf.thinLTODeadcodeEnabled() { if err := applyDeadcodeDropOverrides(linkedOrder, entryPkg, needRuntime, verbose); err != nil { return err } @@ -1513,16 +1564,58 @@ func linkedPackageMetas(pkgs []Package) []*meta.PackageMeta { return metas } -func applyDeadcodeDropOverrides(pkgs []Package, entryPkg Package, needRuntime bool, verbose bool) error { +func buildDeadcodePlan(pkgs []Package, needRuntime bool) (deadcode.Plan, error) { metas := linkedPackageMetas(pkgs) summary, err := meta.NewGlobalSummary(metas) + if err != nil { + return deadcode.Plan{}, err + } + return deadcode.BuildPlan(summary, dceEntryRootCandidates(pkgs, needRuntime)), nil +} + +func applyDeadcodeDropOverrides(pkgs []Package, entryPkg Package, needRuntime bool, verbose bool) error { + plan, err := buildDeadcodePlan(pkgs, needRuntime) if err != nil { return err } + dcepass.EmitStrongTypeOverrides(entryPkg.LPkg.Module(), dceSourceModules(pkgs), plan.LiveSlots, verbose) + return nil +} - roots := dceEntryRootCandidates(pkgs, needRuntime) - liveSlots := deadcode.Analyze(summary, roots) - dcepass.EmitStrongTypeOverrides(entryPkg.LPkg.Module(), dceSourceModules(pkgs), liveSlots, verbose) +// materializeThinLTODeadcode applies the link-specific Go plan to each package +// module before its ThinLTO bitcode is emitted. Package cache overlays are +// deliberately out of scope for this first prototype; cache loading is +// disabled for thinLTODeadcodeEnabled above, so every package still owns its +// full LLVM module here. +func materializeThinLTODeadcode(ctx *context, pkgs []Package, needRuntime, verbose bool) error { + plan, err := buildDeadcodePlan(pkgs, needRuntime) + if err != nil { + return err + } + for _, aPkg := range pkgs { + if aPkg == nil || aPkg.LPkg == nil || aPkg.Package == nil { + continue + } + if aPkg.CacheHit { + return fmt.Errorf("thin LTO deadcode planner cannot rewrite cached package %s yet", aPkg.PkgPath) + } + dcepass.RewriteTypeMethodTables(aPkg.LPkg.Module(), plan.LiveSlots, verbose) + if aPkg.Package.ExportFile == "" { + continue + } + exportFile, exportBuffer, err := exportPackageObject(ctx, aPkg.PkgPath, aPkg.Package.ExportFile, aPkg.LPkg) + if err != nil { + return fmt.Errorf("export rewritten ThinLTO object of %s failed: %w", aPkg.PkgPath, err) + } + if exportFile != "" { + aPkg.ObjFiles = append(aPkg.ObjFiles, exportFile) + } else { + aPkg.ObjBuffers = append(aPkg.ObjBuffers, exportBuffer) + } + if err := normalizeToArchive(ctx, aPkg, verbose); err != nil { + return fmt.Errorf("archive rewritten ThinLTO object of %s failed: %w", aPkg.PkgPath, err) + } + } return nil } @@ -1597,6 +1690,7 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose return err } buildArgs = append(buildArgs, ltoPluginFlags...) + buildArgs = append(buildArgs, thinLTODeadcodeLinkerArgs(ctx.buildConf)...) // Add build mode specific linker arguments switch ctx.buildConf.BuildMode { @@ -1992,6 +2086,12 @@ func compilePackageModule(ctx *context, aPkg *aPackage, externs []string, verbos aPkg.LinkArgs = append(aPkg.LinkArgs, goCgoLinkArgs(ctx.buildConf.Goos, aPkg.AltPkg.Syntax)...) } if pkg.ExportFile != "" { + if ctx.buildConf.thinLTODeadcodeEnabled() { + if debugBuild || verbose { + fmt.Fprintf(os.Stderr, "==> Defer ThinLTO export %s: %s\n", aPkg.PkgPath, pkg.ExportFile) + } + return nil + } exportFile, exportBuffer, err := exportPackageObject(ctx, pkg.PkgPath, pkg.ExportFile, ret) if err != nil { return fmt.Errorf("export object of %v failed: %v", pkgPath, err) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 74951d5cd1..3fa3d0cf39 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -1373,6 +1373,52 @@ func TestDeadcodeDropEnabled(t *testing.T) { } } +func TestThinLTODeadcodeEnabled(t *testing.T) { + tests := []struct { + name string + conf *Config + want bool + }{ + {name: "not requested", conf: &Config{LTO: lto.Thin}, want: false}, + {name: "thin lto", conf: &Config{DeadcodeDrop: true, LTO: lto.Thin}, want: buildenv.Dev}, + {name: "lto off", conf: &Config{DeadcodeDrop: true, LTO: lto.Off}, want: false}, + {name: "full lto", conf: &Config{DeadcodeDrop: true, LTO: lto.Full, DisableGoGlobalDCE: true}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.conf.thinLTODeadcodeEnabled(); got != tt.want { + t.Fatalf("thinLTODeadcodeEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestThinLTODeadcodeLinkerArgs(t *testing.T) { + tests := []struct { + name string + conf *Config + want []string + }{ + {name: "not requested", conf: &Config{LTO: lto.Thin}}, + {name: "lto off", conf: &Config{DeadcodeDrop: true, LTO: lto.Off}}, + {name: "full lto", conf: &Config{DeadcodeDrop: true, LTO: lto.Full, DisableGoGlobalDCE: true}}, + {name: "thin lto deadcode", conf: &Config{DeadcodeDrop: true, LTO: lto.Thin}, want: []string{thinLTODeadcodeImportLimitFlag}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + want := tt.want + if !buildenv.Dev { + want = nil + } + if got := thinLTODeadcodeLinkerArgs(tt.conf); !reflect.DeepEqual(got, want) { + t.Fatalf("thinLTODeadcodeLinkerArgs() = %v, want %v", got, want) + } + }) + } +} + func TestPackageMetaEnabled(t *testing.T) { tests := []struct { name string diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index c017c7e5f4..1260875ea1 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -252,7 +252,10 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-Wl,--icf=none", } if ltoMode.Enabled() { - export.LDFLAGS = append(export.LDFLAGS, ltoMode.ClangFlag(), "-Wl,--lto"+level.Flag()) + export.LDFLAGS = append(export.LDFLAGS, ltoMode.ClangFlag()) + if flag := ltoLinkerOptFlag(level); flag != "" { + export.LDFLAGS = append(export.LDFLAGS, flag) + } } if clangRoot != "" { clangLib := filepath.Join(clangRoot, "lib") @@ -465,6 +468,17 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le return } +func ltoLinkerOptFlag(level optlevel.Level) string { + switch level { + case optlevel.O0, optlevel.O1, optlevel.O2, optlevel.O3: + return "-Wl,--lto" + level.Flag() + default: + // LLD's --lto-O option accepts only numeric levels. Clang likewise + // omits it for -Os/-Oz and lets the size-optimized IR drive LTO. + return "" + } +} + // UseTarget loads configuration from a target name (e.g., "rp2040", "wasi") func UseTarget(targetName string, level optlevel.Level, ltoMode lto.Mode) (export Export, err error) { resolver := targets.NewDefaultResolver() diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index ea89a9596a..299dd3989c 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -429,6 +429,17 @@ func TestDevLTOGlobalDCEUseLTOFlagsControlledByOption(t *testing.T) { if !slices.Contains(thin.LDFLAGS, "-Wl,--lto-O2") { t.Fatalf("missing thin LTO linker opt flag: %v", thin.LDFLAGS) } + for _, level := range []optlevel.Level{optlevel.Os, optlevel.Oz} { + thinSize, err := use(runtime.GOOS, runtime.GOARCH, false, false, level, lto.Thin, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + for _, flag := range thinSize.LDFLAGS { + if strings.HasPrefix(flag, "-Wl,--lto-O") { + t.Fatalf("unexpected numeric-only LTO linker opt flag for %s: %v", level, thinSize.LDFLAGS) + } + } + } full, err := use(runtime.GOOS, runtime.GOARCH, false, false, optlevel.O2, lto.Full, false) if err != nil { diff --git a/internal/dcepass/dcepass.go b/internal/dcepass/dcepass.go index db37db6725..63325de608 100644 --- a/internal/dcepass/dcepass.go +++ b/internal/dcepass/dcepass.go @@ -38,6 +38,85 @@ func EmitStrongTypeOverrides(dst llvm.Module, srcMods []llvm.Module, liveSlots m } } +// RewriteTypeMethodTables rewrites ABI method table initializers in mod in +// place. It preserves each type descriptor's existing linkage and COMDAT, so +// ThinLTO sees one ordinary definition per package instead of an entry-module +// strong override competing with weak_odr definitions. +// +// A missing type entry in liveSlots means that no method slot is demanded. +// The method name and type are retained while IFn/TFn point at the runtime +// unreachable stub, preserving ABI table shape and method matching metadata. +func RewriteTypeMethodTables(mod llvm.Module, liveSlots map[string][]int, verbose bool) int { + if mod.IsNil() { + return 0 + } + rewriter := &moduleRewriter{mod: mod} + rewritten := 0 + for g := mod.FirstGlobal(); !g.IsNil(); g = llvm.NextGlobal(g) { + if g.IsDeclaration() || !g.IsGlobalConstant() { + continue + } + methodsVal, elemTy, ok := methodArray(g.Initializer()) + if !ok { + continue + } + if rewriter.rewriteGlobal(g, methodsVal, elemTy, liveSlotSet(liveSlots[g.Name()]), verbose) { + rewritten++ + } + } + return rewritten +} + +type moduleRewriter struct { + mod llvm.Module + unreachable llvm.Value +} + +func (r *moduleRewriter) unreachableMethod() llvm.Value { + if r.unreachable.IsNil() { + r.unreachable = r.mod.NamedFunction(unreachableMethodName) + } + if r.unreachable.IsNil() { + r.unreachable = llvm.AddFunction(r.mod, unreachableMethodName, + llvm.FunctionType(r.mod.Context().VoidType(), nil, false)) + } + return r.unreachable +} + +func (r *moduleRewriter) rewriteGlobal(g, methodsVal llvm.Value, elemTy llvm.Type, keepIdx map[int]bool, verbose bool) bool { + init := g.Initializer() + fields := make([]llvm.Value, init.OperandsCount()) + for i := range fields { + fields[i] = init.Operand(i) + } + methods := make([]llvm.Value, methodsVal.OperandsCount()) + dropped := false + for i := range methods { + orig := methodsVal.Operand(i) + if keepIdx[i] { + methods[i] = orig + continue + } + dropped = true + if verbose { + fmt.Fprintf(os.Stderr, "[dce] drop method %s[%d] ifn=%s tfn=%s\n", g.Name(), i, orig.Operand(2).Name(), orig.Operand(3).Name()) + } + unreachable := r.unreachableMethod() + methods[i] = llvm.ConstNamedStruct(elemTy, []llvm.Value{ + orig.Operand(0), + orig.Operand(1), + unreachable, + unreachable, + }) + } + if !dropped { + return false + } + fields[len(fields)-1] = llvm.ConstArray(elemTy, methods) + g.SetInitializer(constStructOfType(init.Type(), fields)) + return true +} + type overrideEmitter struct { dst llvm.Module values map[llvm.Value]llvm.Value diff --git a/internal/dcepass/dcepass_test.go b/internal/dcepass/dcepass_test.go index c275804e30..9a713d7ea3 100644 --- a/internal/dcepass/dcepass_test.go +++ b/internal/dcepass/dcepass_test.go @@ -3,6 +3,7 @@ package dcepass import ( "os" "path/filepath" + "strings" "testing" qtest "github.com/qiniu/x/test" @@ -47,6 +48,30 @@ func TestEmitStrongTypeOverrides(t *testing.T) { } } +func TestRewriteTypeMethodTablesPreservesLinkage(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := parseModule(t, &ctx, filepath.Join("testdata", "method_slots", "in.ll")) + defer mod.Dispose() + + if got := RewriteTypeMethodTables(mod, map[string][]int{ + taskTypeName: {1}, + ptrTaskTypeName: {1}, + }, false); got != 2 { + t.Fatalf("RewriteTypeMethodTables rewrote %d globals, want 2", got) + } + out := mod.String() + if !strings.Contains(out, `@_llgo_main.Task = weak_odr constant`) { + t.Fatalf("rewrite changed the source type linkage:\n%s", out) + } + if strings.Contains(out, `@_llgo_main.Task = constant`) { + t.Fatalf("rewrite introduced a strong duplicate:\n%s", out) + } + if !strings.Contains(out, `ptr @"github.com/goplus/llgo/runtime/internal/runtime.unreachableMethod"`) { + t.Fatalf("rewrite did not replace the dead method slot:\n%s", out) + } +} + func TestMethodArray(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() diff --git a/internal/deadcode/analyze.go b/internal/deadcode/analyze.go index 222c4e13f5..4b73a5864f 100644 --- a/internal/deadcode/analyze.go +++ b/internal/deadcode/analyze.go @@ -42,8 +42,25 @@ type pass struct { liveSlots map[meta.Symbol][]int } +// Plan is the link-specific semantic result consumed by a backend rewrite. +// The package metadata remains analyzer-independent; this structure is the +// boundary between whole-program planning and LLVM module transformation. +type Plan struct { + LiveSlots map[string][]int +} + +// BuildPlan computes the conservative Go method liveness plan for one link. +// rootNames are final linker-visible roots, not package-local source names. +func BuildPlan(info *meta.GlobalSummary, rootNames []string) Plan { + return Plan{LiveSlots: analyze(info, rootNames)} +} + // Analyze returns live ABI method slot indexes by concrete type symbol name. func Analyze(info *meta.GlobalSummary, rootNames []string) map[string][]int { + return BuildPlan(info, rootNames).LiveSlots +} + +func analyze(info *meta.GlobalSummary, rootNames []string) map[string][]int { roots := make([]meta.Symbol, 0, len(rootNames)) for _, name := range rootNames { if sym, ok := info.LookupSymbol(name); ok {