diff --git a/cmd/internal/flags/flags.go b/cmd/internal/flags/flags.go index 60d9bc2a6b..49f62c939d 100644 --- a/cmd/internal/flags/flags.go +++ b/cmd/internal/flags/flags.go @@ -139,7 +139,7 @@ func AddLTOFlag(fs *flag.FlagSet) { LTO = ltoFlag{Mode: lto.Off} LTOPluginPath = "" fs.Var(<O, "lto", "Enable LTO optimization: thin or full (default: off)") - fs.StringVar(<OPluginPath, "lto-pass-plugin", "", "Load an LLVM LTO pass plugin during full LTO (ELF lld only)") + fs.StringVar(<OPluginPath, "lto-pass-plugin", "", "Load an LLVM LTO pass plugin during ThinLTO or full LTO (ELF lld only)") } var GoGlobalDCE *bool @@ -382,8 +382,8 @@ func UpdateConfig(conf *build.Config) error { conf.PCLNModeSet = true } if LTOPluginPath != "" { - if conf.LTO != lto.Full { - return fmt.Errorf("lto pass plugin can only be enabled with full LTO (-lto=full)") + if !conf.LTO.Enabled() { + return fmt.Errorf("lto pass plugin requires ThinLTO or full LTO (-lto=thin or -lto=full)") } conf.LTOPlugin = lto.PassPlugin{Path: LTOPluginPath} } diff --git a/cmd/internal/flags/flags_test.go b/cmd/internal/flags/flags_test.go index c5ec81dae7..eea5b2bbf4 100644 --- a/cmd/internal/flags/flags_test.go +++ b/cmd/internal/flags/flags_test.go @@ -384,24 +384,31 @@ func TestBuildLTOPassPluginFlags(t *testing.T) { } } -func TestBuildLTOPassPluginRequiresFullLTO(t *testing.T) { - tests := [][]string{ - {"-lto-pass-plugin=/tmp/libLLGOLTOPlugin.so"}, - {"-lto=thin", "-lto-pass-plugin=/tmp/libLLGOLTOPlugin.so"}, +func TestBuildLTOPassPluginSupportsThinLTOAndRequiresLTO(t *testing.T) { + fs := flag.NewFlagSet("lto-pass-plugin-thinlto", flag.ContinueOnError) + fs.SetOutput(new(bytes.Buffer)) + AddBuildFlags(fs) + args := []string{"-lto=thin", "-lto-pass-plugin=/tmp/libLLGOLTOPlugin.so"} + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse(%v) unexpected error: %v", args, err) + } + conf := &build.Config{} + if err := UpdateConfig(conf); err != nil { + t.Fatalf("UpdateConfig(%v) error: %v", args, err) + } + if conf.LTO != lto.Thin || conf.LTOPlugin.Path == "" { + t.Fatalf("ThinLTO plugin config = %#v", conf) } - for _, args := range tests { - t.Run(strings.Join(args, " "), func(t *testing.T) { - fs := flag.NewFlagSet("lto-pass-plugin-requires-fulllto", flag.ContinueOnError) - fs.SetOutput(new(bytes.Buffer)) - AddBuildFlags(fs) - if err := fs.Parse(args); err != nil { - t.Fatalf("Parse(%v) unexpected error: %v", args, err) - } - if err := UpdateConfig(&build.Config{}); err == nil { - t.Fatal("UpdateConfig expected error") - } - }) + fs = flag.NewFlagSet("lto-pass-plugin-requires-lto", flag.ContinueOnError) + fs.SetOutput(new(bytes.Buffer)) + AddBuildFlags(fs) + args = []string{"-lto-pass-plugin=/tmp/libLLGOLTOPlugin.so"} + if err := fs.Parse(args); err != nil { + t.Fatalf("Parse(%v) unexpected error: %v", args, err) + } + if err := UpdateConfig(&build.Config{}); err == nil { + t.Fatal("UpdateConfig expected error without LTO") } } diff --git a/internal/build/build.go b/internal/build/build.go index f9cf2a1cf9..4976f95f43 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -349,6 +349,32 @@ 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 +} + +// thinLTOFeedbackEnabled selects the opt-in two-link prototype. It is kept +// behind an environment gate while the archive overlay path is validated; the +// ordinary ThinLTO deadcode build remains a single link by default. +func (c *Config) thinLTOFeedbackEnabled() bool { + return c != nil && c.thinLTODeadcodeEnabled() && c.Goos == "linux" && c.Target == "" && c.BuildMode == BuildModeExe && os.Getenv("LLGO_THINLTO_FEEDBACK") == "1" +} + +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() } @@ -490,7 +516,11 @@ func Build(inv Invocation) ([]Package, error) { if conf.PthreadStackSize > 0 { prog.SetPthreadStackSize(uint64(conf.PthreadStackSize)) } - prog.EnableLTOPluginMarkers(conf.LTOPlugin.Enabled()) + // ThinLTO feedback consumes the same MethodByName call-site markers as the + // optional full-LTO pass plugin. The feedback path does not emit vcall + // checked-loads; it only asks LLVM to refine the dynamic string argument and + // feeds the resulting finite name set back into the Go deadcode planner. + prog.EnableLTOPluginMarkers(conf.LTOPlugin.Enabled() || conf.thinLTOFeedbackEnabled()) funcInfo := conf.Mode != ModeGen && conf.PCLNMode != PCLNNone prog.EnableFuncInfoMetadata(funcInfo) // Site records are inline-asm fragments inside function bodies. Darwin @@ -1190,7 +1220,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 { @@ -1220,6 +1255,12 @@ func finalizePackageBuild(ctx *context, task *packageBuildTask, verbose bool) er if aPkg.CacheHit { return nil } + if ctx.buildConf.thinLTODeadcodeEnabled() { + if task.kind == cl.PkgLinkExtern { + appendExternalLinkArgs(ctx, aPkg, task.kindParam) + } + return nil + } if err := normalizeToArchive(ctx, aPkg, verbose); err != nil { return err } @@ -1463,6 +1504,9 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa var linkArgs []string var rtLinkInputs []string var rtLinkArgs []string + var firstThinLTOPlan deadcode.Plan + var thinLTOKnownDefinitions map[string]struct{} + var err error linkedPkgs := make(map[string]bool) // Track linked packages by ID to avoid duplicates var linkedOrder []Package packages.Visit(visitRoots, nil, func(p *packages.Package) { @@ -1517,6 +1561,34 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa linkArgs = append(linkArgs, rtLinkArgs...) archiveInputs = append(archiveInputs, rtLinkInputs...) } + if ctx.buildConf.thinLTODeadcodeEnabled() { + firstThinLTOPlan, err = buildDeadcodePlan(linkedOrder, needRuntime) + if err != nil { + return err + } + if err := materializeThinLTODeadcodePlan(ctx, linkedOrder, firstThinLTOPlan, verbose); err != nil { + return err + } + if ctx.buildConf.thinLTOFeedbackEnabled() { + thinLTOKnownDefinitions = thinLTOFeedbackKnownDefinitions(linkedOrder) + } + // 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) @@ -1543,7 +1615,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 } @@ -1580,6 +1652,10 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa } linkArgs = append(linkArgs, cSharedExportArgs(ctx, linkedOrder)...) + if ctx.buildConf.thinLTOFeedbackEnabled() { + feedbackOutput := outputPath + ".thinlto-feedback" + return runThinLTOFeedback(ctx, feedbackOutput, outputPath, linkInputs, linkArgs, linkedOrder, needRuntime, firstThinLTOPlan, thinLTOKnownDefinitions, verbose) + } err = linkObjFiles(ctx, outputPath, linkInputs, linkArgs, verbose) if err != nil { return err @@ -1596,16 +1672,65 @@ 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 +} + +// 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 } + return materializeThinLTODeadcodePlan(ctx, pkgs, plan, verbose) +} - roots := dceEntryRootCandidates(pkgs, needRuntime) - liveSlots := deadcode.Analyze(summary, roots) - dcepass.EmitStrongTypeOverrides(entryPkg.LPkg.Module(), dceSourceModules(pkgs), liveSlots, verbose) +func materializeThinLTODeadcodePlan(ctx *context, pkgs []Package, plan deadcode.Plan, verbose bool) error { + 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) + } + if ctx.buildConf.thinLTOFeedbackEnabled() && aPkg.Meta != nil { + dcepass.MarkNoInlineFunctions(aPkg.LPkg.Module(), aPkg.Meta.DemandFunctionNames()) + } + 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 } @@ -1686,6 +1811,10 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose return err } buildArgs = append(buildArgs, ltoPluginFlags...) + buildArgs = append(buildArgs, thinLTODeadcodeLinkerArgs(ctx.buildConf)...) + if ctx.buildConf.thinLTOFeedbackEnabled() && ctx.buildConf.LTO == lto.Thin { + buildArgs = append(buildArgs, "-Wl,--save-temps") + } // Add build mode specific linker arguments switch ctx.buildConf.BuildMode { @@ -2065,7 +2194,7 @@ func compilePackageModule(ctx *context, aPkg *aPackage, externs []string, verbos if err := gllvm.VerifyModule(mod, gllvm.ReturnStatusAction); err != nil { return fmt.Errorf("verify LLVM module for %v failed: %w", pkgPath, err) } - if err := mod.RunPasses(llvmPassPipeline(ctx.buildConf.OptLevel, ctx.buildConf.ltoMode()), ctx.prog.TargetMachine(), pbo); err != nil { + if err := mod.RunPasses(llvmPassPipeline(ctx.buildConf.OptLevel, ctx.buildConf.ltoMode(), ctx.buildConf.Goos), ctx.prog.TargetMachine(), pbo); err != nil { return fmt.Errorf("run LLVM passes failed for %v: %w", pkgPath, err) } } @@ -2118,6 +2247,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) @@ -2801,12 +2936,23 @@ func effectiveOptLevel(conf *Config) optlevel.Level { return optlevel.O2 } -func llvmPassPipeline(level optlevel.Level, ltoMode lto.Mode) string { +func llvmPassPipeline(level optlevel.Level, ltoMode lto.Mode, goos string) string { switch ltoMode { case lto.Full: return "lto-pre-link<" + level.Name() + ">" case lto.Thin: - return "thinlto-pre-link<" + level.Name() + ">" + pipeline := "thinlto-pre-link<" + level.Name() + ">" + // LLVM 19's Mach-O LLD does not enable PTO.SLPVectorization for its + // LTO backend, unlike ELF LLD. Run SLP before emitting the ThinLTO + // summary so large constant store sequences are not lowered one byte + // at a time. O1 and Oz intentionally omit SLP in LLVM's pipelines. + if goos == "darwin" { + switch level { + case optlevel.O2, optlevel.O3, optlevel.Os: + pipeline += ",function(slp-vectorizer)" + } + } + return pipeline default: return "default<" + level.Name() + ">" } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 53c9d53d71..80b82f0077 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -1580,6 +1580,68 @@ 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 TestThinLTOFeedbackEnabled(t *testing.T) { + conf := &Config{Goos: "linux", DeadcodeDrop: true, LTO: lto.Thin, BuildMode: BuildModeExe} + if got := conf.thinLTOFeedbackEnabled(); got { + t.Fatal("feedback enabled without LLGO_THINLTO_FEEDBACK=1") + } + t.Setenv("LLGO_THINLTO_FEEDBACK", "1") + want := buildenv.Dev + if got := conf.thinLTOFeedbackEnabled(); got != want { + t.Fatalf("thinLTOFeedbackEnabled() = %v, want %v", got, want) + } + conf.LTO = lto.Full + if conf.thinLTOFeedbackEnabled() { + t.Fatal("feedback enabled for full LTO") + } +} + +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/build/optlevel_test.go b/internal/build/optlevel_test.go index 90c584197c..7fb57873c4 100644 --- a/internal/build/optlevel_test.go +++ b/internal/build/optlevel_test.go @@ -54,6 +54,7 @@ func TestLLVMPassPipeline(t *testing.T) { tests := []struct { level optlevel.Level ltoMode lto.Mode + goos string want string }{ {level: optlevel.O0, want: "default"}, @@ -63,11 +64,16 @@ func TestLLVMPassPipeline(t *testing.T) { {level: optlevel.Os, want: "default"}, {level: optlevel.Oz, want: "default"}, {level: optlevel.O2, ltoMode: lto.Full, want: "lto-pre-link"}, - {level: optlevel.Oz, ltoMode: lto.Thin, want: "thinlto-pre-link"}, + {level: optlevel.O2, ltoMode: lto.Thin, goos: "linux", want: "thinlto-pre-link"}, + {level: optlevel.O1, ltoMode: lto.Thin, goos: "darwin", want: "thinlto-pre-link"}, + {level: optlevel.O2, ltoMode: lto.Thin, goos: "darwin", want: "thinlto-pre-link,function(slp-vectorizer)"}, + {level: optlevel.O3, ltoMode: lto.Thin, goos: "darwin", want: "thinlto-pre-link,function(slp-vectorizer)"}, + {level: optlevel.Os, ltoMode: lto.Thin, goos: "darwin", want: "thinlto-pre-link,function(slp-vectorizer)"}, + {level: optlevel.Oz, ltoMode: lto.Thin, goos: "darwin", want: "thinlto-pre-link"}, } for _, tt := range tests { - if got := llvmPassPipeline(tt.level, tt.ltoMode); got != tt.want { - t.Fatalf("llvmPassPipeline(%v, %v) = %q, want %q", tt.level, tt.ltoMode, got, tt.want) + if got := llvmPassPipeline(tt.level, tt.ltoMode, tt.goos); got != tt.want { + t.Fatalf("llvmPassPipeline(%v, %v, %q) = %q, want %q", tt.level, tt.ltoMode, tt.goos, got, tt.want) } } } diff --git a/internal/build/thinlto_feedback.go b/internal/build/thinlto_feedback.go new file mode 100644 index 0000000000..914c042bb4 --- /dev/null +++ b/internal/build/thinlto_feedback.go @@ -0,0 +1,395 @@ +package build + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/xgo-dev/llgo/internal/dcepass" + "github.com/xgo-dev/llgo/internal/deadcode" + "github.com/xgo-dev/llgo/internal/meta" + gllvm "github.com/xgo-dev/llvm" +) + +const thinLTOFeedbackMaxRounds = 3 + +// runThinLTOFeedback performs the opt-in bounded feedback loop. Each link is +// intentionally real ThinLTO: its backend modules are inspected after +// optimization, then rewritten package bitcode is overlaid ahead of the old +// archives for the next link. +func runThinLTOFeedback(ctx *context, feedbackOutput, outputPath string, linkInputs, linkArgs []string, pkgs []Package, needRuntime bool, firstPlan deadcode.Plan, knownDefinitions map[string]struct{}, verbose bool) error { + defer os.Remove(feedbackOutput) + defer cleanupThinLTOFeedbackOutputTemps(feedbackOutput) + baseInputs := append([]string(nil), linkInputs...) + currentInputs := baseInputs + currentPlan := firstPlan + knownDead := make(map[string]struct{}) + knownRefinedNames := make(map[string][]string) + candidates := thinLTOFeedbackCandidates(pkgs) + var overlays []string + defer func() { removeThinLTOFeedbackFiles(overlays) }() + finish := func() error { + if len(knownDefinitions) == 0 { + return publishThinLTOFeedbackOutput(feedbackOutput, outputPath) + } + removeThinLTOFeedbackFiles(overlays) + overlays = nil + unmarked := unmarkThinLTOFeedbackFunctions(pkgs) + finalOverlays, err := materializeThinLTOFeedbackOverlays(ctx, pkgs, currentPlan, verbose) + if err != nil { + return err + } + overlays = finalOverlays + if len(overlays) == 0 { + return publishThinLTOFeedbackOutput(feedbackOutput, outputPath) + } + finalInputs := insertThinLTOOverlays(baseInputs, overlays) + cleanupThinLTOFeedbackOutputTemps(feedbackOutput) + if err := linkObjFiles(ctx, feedbackOutput, finalInputs, linkArgs, verbose); err != nil { + return fmt.Errorf("thin LTO feedback final link: %w", err) + } + cleanupThinLTOFeedbackTemps(finalInputs) + if verbose { + fmt.Fprintf(os.Stderr, "llgo: ThinLTO feedback final link removed %d temporary noinline attributes and used %d overlays\n", unmarked, len(overlays)) + } + return publishThinLTOFeedbackOutput(feedbackOutput, outputPath) + } + + for round := 0; round < thinLTOFeedbackMaxRounds; round++ { + cleanupThinLTOFeedbackOutputTemps(feedbackOutput) + if err := linkObjFiles(ctx, feedbackOutput, currentInputs, linkArgs, verbose); err != nil { + return fmt.Errorf("thin LTO feedback link round %d: %w", round+1, err) + } + modulePaths, err := thinLTOFeedbackModulePaths(currentInputs) + if err != nil { + return err + } + mods, dispose, err := parseThinLTOFeedbackModules(modulePaths) + if err != nil { + return err + } + roundDead := dcepass.DeadNoInlineFunctionsFromModulesWithDefinitions( + mods, + dceEntryRootCandidates(pkgs, needRuntime), + candidates, + knownDefinitions, + ) + roundRefinedNames := dcepass.RefinedMethodNamesFromModules(mods, candidates) + dispose() + cleanupThinLTOFeedbackTemps(currentInputs) + + newFacts := 0 + for name := range roundDead { + if _, seen := knownDead[name]; seen { + continue + } + knownDead[name] = struct{}{} + newFacts++ + } + newRefinements := 0 + for owner, names := range roundRefinedNames { + if reflect.DeepEqual(knownRefinedNames[owner], names) { + continue + } + knownRefinedNames[owner] = append([]string(nil), names...) + newRefinements++ + } + if verbose { + fmt.Fprintf(os.Stderr, "llgo: ThinLTO feedback round %d found %d new dead function facts (%d cumulative) and %d refined MethodByName owners (%d cumulative)\n", round+1, newFacts, len(knownDead), newRefinements, len(knownRefinedNames)) + for owner, names := range roundRefinedNames { + fmt.Fprintf(os.Stderr, "llgo: ThinLTO MethodByName refinement owner=%s names=%s\n", owner, strings.Join(names, ",")) + } + unrefined, err := thinLTOFeedbackUnrefinedReflectOwners(pkgs, knownDead, knownRefinedNames) + if err != nil { + return fmt.Errorf("ThinLTO feedback reflection diagnostics: %w", err) + } + if len(unrefined) > 0 { + fmt.Fprintf(os.Stderr, "llgo: ThinLTO feedback has %d remaining unrefined reflect owners: %s\n", len(unrefined), strings.Join(unrefined, ",")) + } + } + if newFacts == 0 && newRefinements == 0 { + return finish() + } + + nextPlan, err := buildDeadcodePlanWithFeedback(pkgs, needRuntime, knownDead, knownRefinedNames) + if err != nil { + return fmt.Errorf("thin LTO feedback round %d plan: %w", round+1, err) + } + if verbose && len(knownRefinedNames) > 0 { + withoutRefinement, err := buildDeadcodePlanWithFeedback(pkgs, needRuntime, knownDead, nil) + if err != nil { + return fmt.Errorf("thin LTO feedback round %d comparison plan: %w", round+1, err) + } + fmt.Fprintf(os.Stderr, "llgo: ThinLTO MethodByName refinement changed live method slots from %d to %d\n", liveMethodSlotCount(withoutRefinement), liveMethodSlotCount(nextPlan)) + } + if reflect.DeepEqual(currentPlan.LiveSlots, nextPlan.LiveSlots) { + return finish() + } + if round+1 == thinLTOFeedbackMaxRounds { + if verbose { + fmt.Fprintf(os.Stderr, "llgo: ThinLTO feedback reached %d rounds with %d dead function facts\n", thinLTOFeedbackMaxRounds, len(knownDead)) + } + return finish() + } + + removeThinLTOFeedbackFiles(overlays) + overlays, err = materializeThinLTOFeedbackOverlays(ctx, pkgs, nextPlan, verbose) + if err != nil { + return err + } + if len(overlays) == 0 { + return finish() + } + currentInputs = insertThinLTOOverlays(baseInputs, overlays) + currentPlan = nextPlan + if verbose { + fmt.Fprintf(os.Stderr, "llgo: ThinLTO feedback round %d removed %d new function facts and relinked %d overlays\n", round+1, newFacts, len(overlays)) + } + } + return finish() +} + +func liveMethodSlotCount(plan deadcode.Plan) int { + total := 0 + for _, slots := range plan.LiveSlots { + total += len(slots) + } + return total +} + +func thinLTOFeedbackUnrefinedReflectOwners(pkgs []Package, deadFunctions map[string]struct{}, refinedMethodNames map[string][]string) ([]string, error) { + summary, err := meta.NewGlobalSummary(linkedPackageMetas(pkgs)) + if err != nil { + return nil, err + } + var owners []string + for _, name := range thinLTOFeedbackCandidates(pkgs) { + if _, dead := deadFunctions[name]; dead { + continue + } + if _, refined := refinedMethodNames[name]; refined { + continue + } + sym, ok := summary.LookupSymbol(name) + if !ok { + continue + } + for _, demand := range summary.FuncDemands(sym) { + if demand.Kind == meta.DemandReflectMethod { + owners = append(owners, name) + break + } + } + } + return owners, nil +} + +func buildDeadcodePlanWithFeedback(pkgs []Package, needRuntime bool, deadFunctions map[string]struct{}, refinedMethodNames map[string][]string) (deadcode.Plan, error) { + metas := linkedPackageMetas(pkgs) + summary, err := meta.NewGlobalSummary(metas) + if err != nil { + return deadcode.Plan{}, err + } + return deadcode.BuildPlanWithFeedback(summary, dceEntryRootCandidates(pkgs, needRuntime), deadcode.Feedback{ + DeadFunctions: deadFunctions, + RefinedMethodNames: refinedMethodNames, + }), nil +} + +func thinLTOFeedbackKnownDefinitions(pkgs []Package) map[string]struct{} { + known := make(map[string]struct{}) + noInlineKind := gllvm.AttributeKindID("noinline") + for _, pkg := range pkgs { + if pkg == nil || pkg.Meta == nil || pkg.LPkg == nil { + continue + } + mod := pkg.LPkg.Module() + for _, name := range pkg.Meta.DemandFunctionNames() { + fn := mod.NamedFunction(name) + if fn.IsNil() || fn.IsDeclaration() || fn.GetEnumFunctionAttribute(noInlineKind).IsNil() { + continue + } + known[name] = struct{}{} + } + } + return known +} + +func unmarkThinLTOFeedbackFunctions(pkgs []Package) int { + unmarked := 0 + for _, pkg := range pkgs { + if pkg == nil || pkg.Meta == nil || pkg.LPkg == nil { + continue + } + unmarked += dcepass.UnmarkNoInlineFunctions(pkg.LPkg.Module(), pkg.Meta.DemandFunctionNames()) + } + return unmarked +} + +func thinLTOFeedbackCandidates(pkgs []Package) []string { + seen := make(map[string]struct{}) + for _, pkg := range pkgs { + if pkg == nil || pkg.Meta == nil { + continue + } + for _, name := range pkg.Meta.DemandFunctionNames() { + seen[name] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + sort.Strings(out) + return out +} + +func thinLTOFeedbackModulePaths(inputs []string) ([]string, error) { + seen := make(map[string]struct{}) + for _, input := range inputs { + pattern := input + ".4.opt.bc" + if strings.HasSuffix(input, ".a") { + pattern = input + "(*).4.opt.bc" + } + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("glob ThinLTO feedback modules %q: %w", pattern, err) + } + for _, match := range matches { + seen[match] = struct{}{} + } + } + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + sort.Strings(paths) + if len(paths) == 0 { + return nil, fmt.Errorf("ThinLTO feedback produced no .4.opt.bc modules") + } + return paths, nil +} + +func parseThinLTOFeedbackModules(paths []string) ([]gllvm.Module, func(), error) { + ctx := gllvm.NewContext() + mods := make([]gllvm.Module, 0, len(paths)) + dispose := func() { + for _, mod := range mods { + mod.Dispose() + } + ctx.Dispose() + } + for _, path := range paths { + mod, err := ctx.ParseBitcodeFile(path) + if err != nil { + dispose() + return nil, func() {}, fmt.Errorf("parse ThinLTO feedback module %s: %w", path, err) + } + mods = append(mods, mod) + } + return mods, dispose, nil +} + +func cleanupThinLTOFeedbackTemps(inputs []string) { + for _, input := range inputs { + pattern := input + ".*.bc" + if strings.HasSuffix(input, ".a") { + pattern = input + "(*).*bc" + } + matches, _ := filepath.Glob(pattern) + for _, match := range matches { + _ = os.Remove(match) + } + } +} + +func cleanupThinLTOFeedbackOutputTemps(output string) { + patterns := []string{ + output + ".*.bc", + output + ".index.dot", + output + ".resolution.txt", + output + ".lto.*.o", + } + for _, pattern := range patterns { + matches, _ := filepath.Glob(pattern) + for _, match := range matches { + _ = os.Remove(match) + } + } +} + +func removeThinLTOFeedbackFiles(paths []string) { + for _, path := range paths { + _ = os.Remove(path) + } +} + +func materializeThinLTOFeedbackOverlays(ctx *context, pkgs []Package, plan deadcode.Plan, verbose bool) ([]string, error) { + var overlays []string + succeeded := false + defer func() { + if !succeeded { + removeThinLTOFeedbackFiles(overlays) + } + }() + for _, aPkg := range pkgs { + if aPkg == nil || aPkg.LPkg == nil || aPkg.Package == nil || aPkg.Package.ExportFile == "" { + continue + } + dcepass.RewriteTypeMethodTables(aPkg.LPkg.Module(), plan.LiveSlots, verbose) + exportFile, exportBuffer, err := exportPackageObject(ctx, aPkg.PkgPath, aPkg.Package.ExportFile, aPkg.LPkg) + if err != nil { + return nil, fmt.Errorf("export ThinLTO feedback overlay for %s: %w", aPkg.PkgPath, err) + } + if exportFile != "" { + overlays = append(overlays, exportFile) + continue + } + file, err := os.CreateTemp("", "llgo-thinlto-overlay-*.o") + if err != nil { + exportBuffer.buffer.Dispose() + return nil, err + } + name := file.Name() + _, writeErr := file.Write(exportBuffer.buffer.Bytes()) + closeErr := file.Close() + exportBuffer.buffer.Dispose() + if writeErr != nil { + _ = os.Remove(name) + return nil, writeErr + } + if closeErr != nil { + _ = os.Remove(name) + return nil, closeErr + } + overlays = append(overlays, name) + } + succeeded = true + return overlays, nil +} + +func insertThinLTOOverlays(inputs, overlays []string) []string { + out := make([]string, 0, len(inputs)+len(overlays)) + inserted := false + for _, input := range inputs { + if !inserted && strings.HasSuffix(input, ".a") { + out = append(out, overlays...) + inserted = true + } + out = append(out, input) + } + if !inserted { + out = append(out, overlays...) + } + return out +} + +func publishThinLTOFeedbackOutput(from, to string) error { + if err := os.Rename(from, to); err != nil { + return fmt.Errorf("publish ThinLTO feedback output: %w", err) + } + return nil +} diff --git a/internal/build/thinlto_feedback_test.go b/internal/build/thinlto_feedback_test.go new file mode 100644 index 0000000000..27a1ddc2cc --- /dev/null +++ b/internal/build/thinlto_feedback_test.go @@ -0,0 +1,71 @@ +package build + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/xgo-dev/llgo/internal/meta" +) + +func TestThinLTOFeedbackModulePaths(t *testing.T) { + dir := t.TempDir() + direct := filepath.Join(dir, "entry.o") + archive := filepath.Join(dir, "pkg.a") + paths := []string{ + direct + ".4.opt.bc", + archive + "(pkg.o at 128).4.opt.bc", + } + for _, path := range paths { + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + } + got, err := thinLTOFeedbackModulePaths([]string{direct, archive}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, paths) { + t.Fatalf("thinLTOFeedbackModulePaths() = %#v, want %#v", got, paths) + } + if _, err := thinLTOFeedbackModulePaths([]string{filepath.Join(dir, "missing.o")}); err == nil { + t.Fatal("thinLTOFeedbackModulePaths accepted missing backend modules") + } +} + +func TestThinLTOFeedbackCandidates(t *testing.T) { + buildMeta := func(names ...string) *meta.PackageMeta { + b := meta.NewBuilder() + for _, name := range names { + b.MarkReflect(b.Sym(name)) + } + pm, err := b.Build() + if err != nil { + t.Fatal(err) + } + return pm + } + pkgs := []Package{ + &aPackage{Meta: buildMeta("pkg.z", "pkg.a")}, + nil, + &aPackage{Meta: buildMeta("pkg.a", "pkg.m")}, + } + want := []string{"pkg.a", "pkg.m", "pkg.z"} + if got := thinLTOFeedbackCandidates(pkgs); !reflect.DeepEqual(got, want) { + t.Fatalf("thinLTOFeedbackCandidates() = %#v, want %#v", got, want) + } +} + +func TestInsertThinLTOOverlays(t *testing.T) { + inputs := []string{"entry.o", "extra.o", "one.a", "two.a"} + overlays := []string{"a.overlay.o", "b.overlay.o"} + want := []string{"entry.o", "extra.o", "a.overlay.o", "b.overlay.o", "one.a", "two.a"} + if got := insertThinLTOOverlays(inputs, overlays); !reflect.DeepEqual(got, want) { + t.Fatalf("insertThinLTOOverlays() = %#v, want %#v", got, want) + } + withoutArchive := []string{"entry.o", "a.overlay.o", "b.overlay.o"} + if got := insertThinLTOOverlays([]string{"entry.o"}, overlays); !reflect.DeepEqual(got, withoutArchive) { + t.Fatalf("insertThinLTOOverlays(no archive) = %#v, want %#v", got, withoutArchive) + } +} diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index dae4bad706..566dce191f 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 fbeadc8d19..5045090fd4 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 36f179c5a9..d9b303c00b 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 d05d1145de..1bfd7cf4ca 100644 --- a/internal/dcepass/dcepass_test.go +++ b/internal/dcepass/dcepass_test.go @@ -59,6 +59,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/xgo-dev/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/dcepass/feedback.go b/internal/dcepass/feedback.go new file mode 100644 index 0000000000..da187bf260 --- /dev/null +++ b/internal/dcepass/feedback.go @@ -0,0 +1,153 @@ +package dcepass + +import "github.com/xgo-dev/llvm" + +// MarkNoInlineFunctions prevents function-scoped Go semantic facts from moving +// into callers during an experimental feedback link. The final rewritten link +// does not need this restriction; it exists only while feedback is represented +// at function granularity rather than by instruction-level DemandIDs. +func MarkNoInlineFunctions(mod llvm.Module, names []string) int { + if mod.IsNil() { + return 0 + } + kind := llvm.AttributeKindID("noinline") + attr := mod.Context().CreateEnumAttribute(kind, 0) + marked := 0 + for _, name := range names { + fn := mod.NamedFunction(name) + if fn.IsNil() || fn.IsDeclaration() || !fn.GetEnumFunctionAttribute(kind).IsNil() { + continue + } + fn.AddFunctionAttr(attr) + marked++ + } + return marked +} + +// UnmarkNoInlineFunctions removes the temporary feedback barrier before the +// final ThinLTO link so normal cross-package inlining is available again. +func UnmarkNoInlineFunctions(mod llvm.Module, names []string) int { + if mod.IsNil() { + return 0 + } + kind := llvm.AttributeKindID("noinline") + unmarked := 0 + for _, name := range names { + fn := mod.NamedFunction(name) + if fn.IsNil() || fn.GetEnumFunctionAttribute(kind).IsNil() { + continue + } + fn.RemoveEnumFunctionAttribute(kind) + unmarked++ + } + return unmarked +} + +// DeadNoInlineFunctionsFromModules returns noinline candidate functions that +// are not reachable from roots in the post-optimization LLVM global-reference +// graph. +// +// This is an intentionally small ThinLTO feedback prototype. It scans function +// instructions and global initializers across all backend modules. Following +// references instead of checking which definitions remain avoids treating a +// function kept alive by the initial ThinLTO index as semantically live after +// optimization deleted its last caller. Requiring noinline makes function-level +// feedback sound: semantic facts cannot have moved into a live caller. A future +// instruction-level DemandID design can remove this restriction. +func DeadNoInlineFunctionsFromModules(mods []llvm.Module, roots, candidates []string) map[string]struct{} { + return DeadNoInlineFunctionsFromModulesWithDefinitions(mods, roots, candidates, nil) +} + +// DeadNoInlineFunctionsFromModulesWithDefinitions is the feedback scanner used +// by the build pipeline. knownDefinitions contains candidate functions that +// were present and marked noinline before the ThinLTO link. A noinline +// function can be absent from every optimized module when ThinLTO deletes its +// whole body; those known definitions are therefore dead as well. Candidates +// outside knownDefinitions retain the conservative behavior of the legacy +// scanner and must still appear in an optimized module with a noinline +// attribute before they can be reported. +func DeadNoInlineFunctionsFromModulesWithDefinitions(mods []llvm.Module, roots, candidates []string, knownDefinitions map[string]struct{}) map[string]struct{} { + edges := make(map[string]map[string]struct{}) + noInlineDefinitions := make(map[string]struct{}) + noInlineKind := llvm.AttributeKindID("noinline") + for _, mod := range mods { + if mod.IsNil() { + continue + } + for fn := mod.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { + if fn.IsDeclaration() || fn.Name() == "" { + continue + } + if !fn.GetEnumFunctionAttribute(noInlineKind).IsNil() { + noInlineDefinitions[fn.Name()] = struct{}{} + } + for _, block := range fn.BasicBlocks() { + for inst := block.FirstInstruction(); !inst.IsNil(); inst = llvm.NextInstruction(inst) { + addGlobalReferences(edges, fn.Name(), inst) + } + } + } + for global := mod.FirstGlobal(); !global.IsNil(); global = llvm.NextGlobal(global) { + if global.IsDeclaration() || global.Name() == "" || global.Initializer().IsNil() { + continue + } + addGlobalReferences(edges, global.Name(), global.Initializer()) + } + } + + reachable := make(map[string]struct{}) + queue := append([]string(nil), roots...) + for len(queue) > 0 { + name := queue[0] + queue = queue[1:] + if _, seen := reachable[name]; seen { + continue + } + reachable[name] = struct{}{} + for next := range edges[name] { + queue = append(queue, next) + } + } + + dead := make(map[string]struct{}) + for _, name := range candidates { + _, known := knownDefinitions[name] + if _, eligible := noInlineDefinitions[name]; !eligible && !known { + continue + } + if _, live := reachable[name]; !live { + dead[name] = struct{}{} + } + } + return dead +} + +func addGlobalReferences(edges map[string]map[string]struct{}, from string, value llvm.Value) { + seen := make(map[llvm.Value]struct{}) + var visit func(llvm.Value) + visit = func(current llvm.Value) { + if current.IsNil() { + return + } + if _, ok := seen[current]; ok { + return + } + seen[current] = struct{}{} + if global := current.IsAGlobalValue(); !global.IsNil() { + name := global.Name() + if name != "" && name != from { + out := edges[from] + if out == nil { + out = make(map[string]struct{}) + edges[from] = out + } + out[name] = struct{}{} + } + return + } + for i := 0; i < current.OperandsCount(); i++ { + visit(current.Operand(i)) + } + } + visit(value) +} diff --git a/internal/dcepass/feedback_test.go b/internal/dcepass/feedback_test.go new file mode 100644 index 0000000000..b351ad038d --- /dev/null +++ b/internal/dcepass/feedback_test.go @@ -0,0 +1,74 @@ +package dcepass + +import ( + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestDeadNoInlineFunctionsFromModulesRequiresNoInline(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("feedback") + defer mod.Dispose() + void := ctx.VoidType() + fnType := llvm.FunctionType(void, nil, false) + main := llvm.AddFunction(mod, "main", fnType) + dead := llvm.AddFunction(mod, "candidate", fnType) + llvm.AddBasicBlock(main, "entry") + llvm.AddBasicBlock(dead, "entry") + + got := DeadNoInlineFunctionsFromModules([]llvm.Module{mod}, []string{"main"}, []string{"candidate"}) + if len(got) != 0 { + t.Fatalf("feedback without noinline = %#v, want empty", got) + } + + if got := MarkNoInlineFunctions(mod, []string{"candidate", "missing", "main"}); got != 2 { + t.Fatalf("MarkNoInlineFunctions() = %d, want 2", got) + } + if got := MarkNoInlineFunctions(mod, []string{"candidate", "main"}); got != 0 { + t.Fatalf("repeated MarkNoInlineFunctions() = %d, want 0", got) + } + got = DeadNoInlineFunctionsFromModules([]llvm.Module{mod}, []string{"main"}, []string{"candidate"}) + if _, ok := got["candidate"]; !ok { + t.Fatalf("feedback with noinline = %#v, want candidate dead", got) + } + if got := UnmarkNoInlineFunctions(mod, []string{"candidate", "missing", "main"}); got != 2 { + t.Fatalf("UnmarkNoInlineFunctions() = %d, want 2", got) + } + if got := UnmarkNoInlineFunctions(mod, []string{"candidate", "main"}); got != 0 { + t.Fatalf("repeated UnmarkNoInlineFunctions() = %d, want 0", got) + } + got = DeadNoInlineFunctionsFromModules([]llvm.Module{mod}, []string{"main"}, []string{"candidate"}) + if len(got) != 0 { + t.Fatalf("feedback after removing noinline = %#v, want empty", got) + } +} + +func TestDeadNoInlineFunctionsFromModulesReportsDeletedKnownDefinition(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("feedback") + defer mod.Dispose() + void := ctx.VoidType() + fnType := llvm.FunctionType(void, nil, false) + main := llvm.AddFunction(mod, "main", fnType) + llvm.AddBasicBlock(main, "entry") + + got := DeadNoInlineFunctionsFromModulesWithDefinitions( + []llvm.Module{mod}, + []string{"main"}, + []string{"candidate"}, + map[string]struct{}{"candidate": {}}, + ) + if _, ok := got["candidate"]; !ok { + t.Fatalf("deleted known definition feedback = %#v, want candidate dead", got) + } + + // Without an explicit pre-link definition fact, an absent candidate remains + // unknown rather than being treated as dead. + got = DeadNoInlineFunctionsFromModulesWithDefinitions([]llvm.Module{mod}, []string{"main"}, []string{"candidate"}, nil) + if len(got) != 0 { + t.Fatalf("unknown absent candidate feedback = %#v, want empty", got) + } +} diff --git a/internal/dcepass/method_names.go b/internal/dcepass/method_names.go new file mode 100644 index 0000000000..9d231a8fb7 --- /dev/null +++ b/internal/dcepass/method_names.go @@ -0,0 +1,72 @@ +package dcepass + +import ( + "sort" + "strings" + + "github.com/xgo-dev/llvm" +) + +const ( + reflectMethodByNameCallAttr = "llgo.reflect.methodbyname" + reflectMethodByNameNamesAttr = "llgo.reflect.methodbyname.names" +) + +// RefinedMethodNamesFromModules returns finite MethodByName name sets proved +// by an LLVM optimization pass for function-scoped semantic demand owners. +// +// The result is deliberately all-or-nothing per owner. If even one marked call +// in a function lacks a refinement, that owner is omitted and the Go planner +// retains its conservative dynamic-reflection behavior. +func RefinedMethodNamesFromModules(mods []llvm.Module, candidates []string) map[string][]string { + wanted := make(map[string]struct{}, len(candidates)) + for _, name := range candidates { + wanted[name] = struct{}{} + } + + result := make(map[string][]string) + for _, mod := range mods { + for fn := mod.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { + if _, ok := wanted[fn.Name()]; !ok || fn.IsDeclaration() { + continue + } + + found := false + known := true + names := make(map[string]struct{}) + for _, block := range fn.BasicBlocks() { + for inst := block.FirstInstruction(); !inst.IsNil(); inst = llvm.NextInstruction(inst) { + if inst.IsACallInst().IsNil() && inst.IsAInvokeInst().IsNil() { + continue + } + if inst.GetCallSiteStringAttribute(-1, reflectMethodByNameCallAttr).IsNil() { + continue + } + found = true + attr := inst.GetCallSiteStringAttribute(-1, reflectMethodByNameNamesAttr) + if attr.IsNil() || attr.GetStringValue() == "" { + known = false + continue + } + for _, name := range strings.Split(attr.GetStringValue(), ",") { + if name == "" { + known = false + break + } + names[name] = struct{}{} + } + } + } + if !found || !known || len(names) == 0 { + continue + } + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + sort.Strings(out) + result[fn.Name()] = out + } + } + return result +} diff --git a/internal/dcepass/method_names_test.go b/internal/dcepass/method_names_test.go new file mode 100644 index 0000000000..7def47ad1b --- /dev/null +++ b/internal/dcepass/method_names_test.go @@ -0,0 +1,62 @@ +package dcepass + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestRefinedMethodNamesFromModules(t *testing.T) { + ir := ` +declare void @reflect_call(ptr, i64) + +define void @known() { +entry: + call void @reflect_call(ptr null, i64 0) #0 + call void @reflect_call(ptr null, i64 0) #1 + ret void +} + +define void @partly_unknown() { +entry: + call void @reflect_call(ptr null, i64 0) #0 + call void @reflect_call(ptr null, i64 0) #2 + ret void +} + +define void @unmarked() { +entry: + ret void +} + +attributes #0 = { "llgo.reflect.methodbyname"="value" "llgo.reflect.methodbyname.names"="KeepB,KeepA" } +attributes #1 = { "llgo.reflect.methodbyname"="value" "llgo.reflect.methodbyname.names"="KeepC,KeepA" } +attributes #2 = { "llgo.reflect.methodbyname"="value" } +` + path := filepath.Join(t.TempDir(), "names.ll") + if err := os.WriteFile(path, []byte(ir), 0o644); err != nil { + t.Fatal(err) + } + buf, err := llvm.NewMemoryBufferFromFile(path) + if err != nil { + t.Fatal(err) + } + ctx := llvm.NewContext() + defer ctx.Dispose() + mod, err := ctx.ParseIR(buf) + if err != nil { + t.Fatal(err) + } + defer mod.Dispose() + + got := RefinedMethodNamesFromModules([]llvm.Module{mod}, []string{ + "known", "partly_unknown", "unmarked", "missing", + }) + want := map[string][]string{"known": {"KeepA", "KeepB", "KeepC"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("RefinedMethodNamesFromModules() = %#v, want %#v", got, want) + } +} diff --git a/internal/deadcode/analyze.go b/internal/deadcode/analyze.go index d3a1693691..b2d4ac3c49 100644 --- a/internal/deadcode/analyze.go +++ b/internal/deadcode/analyze.go @@ -30,28 +30,80 @@ type pass struct { methodRefs map[meta.MethodSig][]meta.Symbol // sig → []iface (built eagerly) ifaceMethodCounts map[meta.Symbol]int // iface → unique method name count reachable map[meta.Symbol]struct{} + blockedFunctions map[meta.Symbol]struct{} usedInIface map[meta.Symbol]struct{} processedIfaceTy map[meta.Symbol]struct{} workQueue []meta.Symbol ifaceMethod map[ifaceMethodKey]struct{} genericIfaceMethod map[meta.Name]struct{} + refinedMethodNames map[string]struct{} + refinedReflect map[meta.Symbol][]string reflectSeen bool markableMethods []methodRef 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 +} + +// Feedback carries facts learned after an LLVM optimization round. A function +// listed in DeadFunctions still exists in the package metadata, but its +// function-scoped edges and semantic demands no longer contribute to the next +// plan because the optimized whole-program reference graph cannot reach it. +// +// Roots always win over feedback. Callers must only list functions proven dead +// from post-optimization references; the mere absence of a function definition +// is not proof because ThinLTO may have inlined it into a live caller. +type Feedback struct { + DeadFunctions map[string]struct{} + RefinedMethodNames map[string][]string +} + +// 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 BuildPlanWithFeedback(info, rootNames, Feedback{}) +} + +// BuildPlanWithFeedback computes a plan after removing function-scoped facts +// that an LLVM optimization round proved unreachable. +func BuildPlanWithFeedback(info *meta.GlobalSummary, rootNames []string, feedback Feedback) Plan { + return Plan{LiveSlots: analyze(info, rootNames, feedback)} +} + // 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, feedback Feedback) map[string][]int { roots := make([]meta.Symbol, 0, len(rootNames)) + deadFunctions := make(map[meta.Symbol]struct{}, len(feedback.DeadFunctions)) + refinedReflect := make(map[meta.Symbol][]string, len(feedback.RefinedMethodNames)) + for name := range feedback.DeadFunctions { + if sym, ok := info.LookupSymbol(name); ok { + deadFunctions[sym] = struct{}{} + } + } + for owner, names := range feedback.RefinedMethodNames { + if sym, ok := info.LookupSymbol(owner); ok { + refinedReflect[sym] = names + } + } for _, name := range rootNames { if sym, ok := info.LookupSymbol(name); ok { roots = append(roots, sym) + delete(deadFunctions, sym) } } - liveSlots := deadcode(info, roots) + liveSlots := deadcode(info, roots, deadFunctions, refinedReflect) out := make(map[string][]int, len(liveSlots)) for typ, slots := range liveSlots { name := info.SymbolName(typ) @@ -62,23 +114,27 @@ func Analyze(info *meta.GlobalSummary, rootNames []string) map[string][]int { return out } -func deadcode(info *meta.GlobalSummary, roots []meta.Symbol) map[meta.Symbol][]int { +func deadcode(info *meta.GlobalSummary, roots []meta.Symbol, deadFunctions map[meta.Symbol]struct{}, refinedReflect map[meta.Symbol][]string) map[meta.Symbol][]int { d := &pass{ info: info, methodImplKeys: make(map[methodID][]ifaceMethodKey), methodRefs: make(map[meta.MethodSig][]meta.Symbol), ifaceMethodCounts: make(map[meta.Symbol]int), reachable: make(map[meta.Symbol]struct{}), + blockedFunctions: deadFunctions, usedInIface: make(map[meta.Symbol]struct{}), processedIfaceTy: make(map[meta.Symbol]struct{}), ifaceMethod: make(map[ifaceMethodKey]struct{}), genericIfaceMethod: make(map[meta.Name]struct{}), + refinedMethodNames: make(map[string]struct{}), + refinedReflect: refinedReflect, liveSlots: make(map[meta.Symbol][]int), } d.buildMethodRefs() // Seed the initial reachability flood with entry-point roots. for _, root := range roots { + delete(d.blockedFunctions, root) d.markReachable(root) } @@ -154,10 +210,21 @@ func (d *pass) flood() { d.markReachable(dst) } - for _, demand := range d.info.FuncDemands(sym) { + demands := d.info.FuncDemands(sym) + refinedNames, refined := d.refinedReflect[sym] + if refined && reflectDemandCount(demands) != 1 { + refined = false + } + for _, demand := range demands { switch demand.Kind { case meta.DemandReflectMethod: - d.reflectSeen = true + if refined { + for _, name := range refinedNames { + d.refinedMethodNames[name] = struct{}{} + } + } else { + d.reflectSeen = true + } case meta.DemandUseIface: d.markUsedInIface(demand.Target) case meta.DemandIfaceMethod: @@ -189,6 +256,16 @@ func (d *pass) flood() { } } +func reflectDemandCount(demands []meta.FuncDemand) int { + count := 0 + for _, demand := range demands { + if demand.Kind == meta.DemandReflectMethod { + count++ + } + } + return count +} + func (d *pass) methodMarkingLoop() bool { changed := false rem := d.markableMethods[:0] @@ -231,6 +308,9 @@ func (d *pass) shouldKeep(method methodRef) bool { if _, ok := d.genericIfaceMethod[method.slotInfo.Name]; ok { return true } + if _, ok := d.refinedMethodNames[d.info.Name(method.slotInfo.Name)]; ok { + return true + } id := methodID{owner: method.owner, slot: method.slot} for _, key := range d.methodImplKeys[id] { @@ -256,6 +336,9 @@ func (d *pass) markMethod(method methodRef) { } func (d *pass) markReachable(sym meta.Symbol) { + if _, blocked := d.blockedFunctions[sym]; blocked { + return + } if _, ok := d.reachable[sym]; ok { return } diff --git a/internal/deadcode/analyze_test.go b/internal/deadcode/analyze_test.go index 80d837944e..95d5833365 100644 --- a/internal/deadcode/analyze_test.go +++ b/internal/deadcode/analyze_test.go @@ -432,6 +432,125 @@ func TestAnalyze(t *testing.T) { } } +func TestBuildPlanWithFeedbackDropsFactsFromDeadFunction(t *testing.T) { + summary := newSummary(t, buildPackage(func(b *pkgBuilder) { + main := b.sym("pkg.main") + semanticDemand := b.sym("pkg.semanticDemand") + typ := b.sym("_llgo_pkg.T") + iface := b.sym("_llgo_iface$I") + mSig := methodSig(b, "M") + + b.addIfaceEntry(iface, []pkgSig{mSig}) + b.addMethodInfo(typ, []pkgSlot{ + methodSlot(b, mSig, "pkg.(*T).M", "pkg.T.M"), + }) + // The package Meta graph conservatively contains this edge. A later + // ThinLTO round can remove it after cross-package constant propagation. + b.addEdge(main, semanticDemand) + b.addEdge(semanticDemand, typ) + b.addUseIface(semanticDemand, typ) + b.addUseIfaceMethod(semanticDemand, iface, mSig) + })) + + first := BuildPlan(summary, []string{"pkg.main"}) + wantFirst := map[string][]int{"_llgo_pkg.T": {0}} + if !reflect.DeepEqual(first.LiveSlots, wantFirst) { + t.Fatalf("first plan LiveSlots = %#v, want %#v", first.LiveSlots, wantFirst) + } + + second := BuildPlanWithFeedback(summary, []string{"pkg.main"}, Feedback{ + DeadFunctions: map[string]struct{}{"pkg.semanticDemand": {}}, + }) + if len(second.LiveSlots) != 0 { + t.Fatalf("feedback plan LiveSlots = %#v, want empty", second.LiveSlots) + } + + // A root is never suppressed by feedback, even if a stale producer lists it. + rooted := BuildPlanWithFeedback(summary, []string{"pkg.semanticDemand"}, Feedback{ + DeadFunctions: map[string]struct{}{"pkg.semanticDemand": {}}, + }) + if !reflect.DeepEqual(rooted.LiveSlots, wantFirst) { + t.Fatalf("rooted feedback plan LiveSlots = %#v, want %#v", rooted.LiveSlots, wantFirst) + } +} + +func TestBuildPlanWithFeedbackRefinesDynamicMethodNames(t *testing.T) { + summary := newSummary(t, buildPackage(func(b *pkgBuilder) { + main := b.sym("pkg.main") + dynamic := b.sym("pkg.dynamicMethodByName") + typ := b.sym("_llgo_pkg.T") + keepA := methodSig(b, "KeepA") + keepB := methodSig(b, "KeepB") + drop := methodSig(b, "Drop") + + b.addMethodInfo(typ, []pkgSlot{ + methodSlot(b, keepA, "pkg.(*T).KeepA", "pkg.T.KeepA"), + methodSlot(b, keepB, "pkg.(*T).KeepB", "pkg.T.KeepB"), + methodSlot(b, drop, "pkg.(*T).Drop", "pkg.T.Drop"), + }) + b.addEdge(main, dynamic) + b.addEdge(dynamic, typ) + b.addUseIface(dynamic, typ) + b.b.MarkReflect(dynamic) + })) + + first := BuildPlan(summary, []string{"pkg.main"}) + wantFirst := map[string][]int{"_llgo_pkg.T": {0, 1, 2}} + if !reflect.DeepEqual(first.LiveSlots, wantFirst) { + t.Fatalf("initial plan LiveSlots = %#v, want %#v", first.LiveSlots, wantFirst) + } + + refined := BuildPlanWithFeedback(summary, []string{"pkg.main"}, Feedback{ + RefinedMethodNames: map[string][]string{ + "pkg.dynamicMethodByName": {"KeepA", "KeepB"}, + }, + }) + wantRefined := map[string][]int{"_llgo_pkg.T": {0, 1}} + if !reflect.DeepEqual(refined.LiveSlots, wantRefined) { + t.Fatalf("refined plan LiveSlots = %#v, want %#v", refined.LiveSlots, wantRefined) + } + + // A present but empty result means LLVM proved that every dynamic call in + // this owner disappeared. An absent result remains conservative. + empty := BuildPlanWithFeedback(summary, []string{"pkg.main"}, Feedback{ + RefinedMethodNames: map[string][]string{"pkg.dynamicMethodByName": {}}, + }) + if len(empty.LiveSlots) != 0 { + t.Fatalf("empty refinement LiveSlots = %#v, want empty", empty.LiveSlots) + } +} + +func TestBuildPlanWithFeedbackDoesNotRefineMixedReflectOwner(t *testing.T) { + summary := newSummary(t, buildPackage(func(b *pkgBuilder) { + main := b.sym("pkg.main") + dynamic := b.sym("pkg.mixedReflection") + typ := b.sym("_llgo_pkg.T") + keep := methodSig(b, "Keep") + drop := methodSig(b, "Drop") + + b.addMethodInfo(typ, []pkgSlot{ + methodSlot(b, keep, "pkg.(*T).Keep", "pkg.T.Keep"), + methodSlot(b, drop, "pkg.(*T).Drop", "pkg.T.Drop"), + }) + b.addEdge(main, dynamic) + b.addEdge(dynamic, typ) + b.addUseIface(dynamic, typ) + // One demand may come from MethodByName while the other may come from + // Method(index). Until metadata carries per-call IDs, owner-level name + // feedback must not replace this mixed pair. + b.b.MarkReflect(dynamic) + b.b.MarkReflect(dynamic) + })) + + got := BuildPlanWithFeedback(summary, []string{"pkg.main"}, Feedback{ + RefinedMethodNames: map[string][]string{"pkg.mixedReflection": {"Keep"}}, + }) + want := map[string][]int{"_llgo_pkg.T": {0, 1}} + if !reflect.DeepEqual(got.LiveSlots, want) { + t.Fatalf("mixed-reflect feedback LiveSlots = %#v, want %#v", got.LiveSlots, want) + } +} + // ── test builder helpers ────────────────────────────────────────────────────── type pkgSig struct { diff --git a/internal/deadcode/testdata/thinlto_feedback/deleted.ll b/internal/deadcode/testdata/thinlto_feedback/deleted.ll new file mode 100644 index 0000000000..2a38e86629 --- /dev/null +++ b/internal/deadcode/testdata/thinlto_feedback/deleted.ll @@ -0,0 +1,11 @@ +target triple = "x86_64-unknown-linux-gnu" + +define i32 @main() { +entry: + ret i32 0 +} + +define hidden void @deletedDemand() noinline { +entry: + ret void +} diff --git a/internal/deadcode/testdata/thinlto_feedback/demand.ll b/internal/deadcode/testdata/thinlto_feedback/demand.ll new file mode 100644 index 0000000000..bf15bf24a1 --- /dev/null +++ b/internal/deadcode/testdata/thinlto_feedback/demand.ll @@ -0,0 +1,10 @@ +target triple = "x86_64-unknown-linux-gnu" + +@feedback.enabled = hidden constant i1 false + +define hidden void @semanticDemand() #0 { +entry: + ret void +} + +attributes #0 = { noinline } diff --git a/internal/deadcode/testdata/thinlto_feedback/demand_live.ll b/internal/deadcode/testdata/thinlto_feedback/demand_live.ll new file mode 100644 index 0000000000..8564fc7328 --- /dev/null +++ b/internal/deadcode/testdata/thinlto_feedback/demand_live.ll @@ -0,0 +1,10 @@ +target triple = "x86_64-unknown-linux-gnu" + +@feedback.enabled = hidden constant i1 true + +define hidden void @semanticDemand() #0 { +entry: + ret void +} + +attributes #0 = { noinline } diff --git a/internal/deadcode/testdata/thinlto_feedback/main.ll b/internal/deadcode/testdata/thinlto_feedback/main.ll new file mode 100644 index 0000000000..f7ce1ab301 --- /dev/null +++ b/internal/deadcode/testdata/thinlto_feedback/main.ll @@ -0,0 +1,18 @@ +target triple = "x86_64-unknown-linux-gnu" + +@feedback.enabled = external hidden constant i1 + +declare hidden void @semanticDemand() + +define i32 @main() { +entry: + %enabled = load i1, ptr @feedback.enabled + br i1 %enabled, label %demand, label %done + +demand: + call void @semanticDemand() + br label %done + +done: + ret i32 0 +} diff --git a/internal/deadcode/testdata/thinlto_feedback/method_demand.ll b/internal/deadcode/testdata/thinlto_feedback/method_demand.ll new file mode 100644 index 0000000000..ed6ee933ea --- /dev/null +++ b/internal/deadcode/testdata/thinlto_feedback/method_demand.ll @@ -0,0 +1,43 @@ +target triple = "x86_64-unknown-linux-gnu" + +%"github.com/xgo-dev/llgo/runtime/abi.Method" = type { ptr, ptr, ptr, ptr } +%T.type = type { ptr, [2 x %"github.com/xgo-dev/llgo/runtime/abi.Method"] } +@T = hidden constant %T.type { + ptr null, + [2 x %"github.com/xgo-dev/llgo/runtime/abi.Method"] [ + %"github.com/xgo-dev/llgo/runtime/abi.Method" { ptr null, ptr null, ptr @T.M, ptr @T.M }, + %"github.com/xgo-dev/llgo/runtime/abi.Method" { ptr null, ptr null, ptr @T.N, ptr @T.N } + ] +} + +define hidden void @liveDemand() #0 { +entry: + %slot = getelementptr %T.type, ptr @T, i32 0, i32 1, i32 1, i32 2 + %fn = load ptr, ptr %slot + call void %fn() + ret void +} + +define hidden void @semanticDemand() #0 { +entry: + ret void +} + +define hidden void @T.M() #0 { +entry: + call void asm sideeffect "", "~{memory}"() + ret void +} + +define hidden void @T.N() #0 { +entry: + call void asm sideeffect "", "~{memory}"() + ret void +} + +define hidden void @"github.com/xgo-dev/llgo/runtime/internal/runtime.unreachableMethod"() { +entry: + ret void +} + +attributes #0 = { noinline } diff --git a/internal/deadcode/testdata/thinlto_feedback/method_main.ll b/internal/deadcode/testdata/thinlto_feedback/method_main.ll new file mode 100644 index 0000000000..78df8e6b7f --- /dev/null +++ b/internal/deadcode/testdata/thinlto_feedback/method_main.ll @@ -0,0 +1,21 @@ +target triple = "x86_64-unknown-linux-gnu" + +%"github.com/xgo-dev/llgo/runtime/abi.Method" = type { ptr, ptr, ptr, ptr } +%T.type = type { ptr, [2 x %"github.com/xgo-dev/llgo/runtime/abi.Method"] } +@T = external hidden constant %T.type +@flag = constant i1 false + +define i32 @main() { +entry: + call void @liveDemand() + %enabled = load i1, ptr @flag + br i1 %enabled, label %demand, label %done +demand: + call void @semanticDemand() + br label %done +done: + ret i32 0 +} + +declare hidden void @liveDemand() +declare hidden void @semanticDemand() diff --git a/internal/deadcode/thinlto_feedback_test.go b/internal/deadcode/thinlto_feedback_test.go new file mode 100644 index 0000000000..f7fbb2bd76 --- /dev/null +++ b/internal/deadcode/thinlto_feedback_test.go @@ -0,0 +1,362 @@ +package deadcode_test + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/xgo-dev/llgo/internal/dcepass" + "github.com/xgo-dev/llgo/internal/deadcode" + "github.com/xgo-dev/llgo/internal/meta" + "github.com/xgo-dev/llvm" +) + +func TestThinLTOFeedbackShrinksMethodPlan(t *testing.T) { + summary := feedbackSummary(t) + first := deadcode.BuildPlan(summary, []string{"main"}) + wantFirst := map[string][]int{"_llgo_feedback.T": {0}} + if !reflect.DeepEqual(first.LiveSlots, wantFirst) { + t.Fatalf("first plan LiveSlots = %#v, want %#v", first.LiveSlots, wantFirst) + } + + for _, tt := range []struct { + name string + demandFile string + wantDead bool + }{ + {name: "constant false drops demand", demandFile: "demand.ll", wantDead: true}, + {name: "constant true keeps demand", demandFile: "demand_live.ll", wantDead: false}, + } { + t.Run(tt.name, func(t *testing.T) { + dead := runThinLTOFeedback(t, tt.demandFile) + _, isDead := dead["semanticDemand"] + if isDead != tt.wantDead { + t.Fatalf("post-ThinLTO feedback = %#v, semanticDemand dead = %v, want %v", dead, isDead, tt.wantDead) + } + second := deadcode.BuildPlanWithFeedback(summary, []string{"main"}, deadcode.Feedback{DeadFunctions: dead}) + if tt.wantDead { + if len(second.LiveSlots) != 0 { + t.Fatalf("feedback plan LiveSlots = %#v, want empty", second.LiveSlots) + } + } else if !reflect.DeepEqual(second.LiveSlots, wantFirst) { + t.Fatalf("feedback plan LiveSlots = %#v, want %#v", second.LiveSlots, wantFirst) + } + }) + } +} + +func TestThinLTOFeedbackReportsCompletelyDeletedDefinition(t *testing.T) { + opt := requireTool(t, "opt") + linker := requireTool(t, "ld.lld") + tmp := t.TempDir() + input := filepath.Join(tmp, "deleted.o") + app := filepath.Join(tmp, "app") + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", "deleted.ll"), "-o", input) + runTool(t, linker, "--entry=main", "--save-temps", "--lto-O2", "-o", app, input) + + ctx := llvm.NewContext() + defer ctx.Dispose() + mod, err := ctx.ParseBitcodeFile(input + ".4.opt.bc") + if err != nil { + t.Fatalf("parse optimized deleted module: %v", err) + } + defer mod.Dispose() + if !mod.NamedFunction("deletedDemand").IsNil() { + t.Fatal("ThinLTO retained the deliberately unreachable noinline definition") + } + dead := dcepass.DeadNoInlineFunctionsFromModulesWithDefinitions( + []llvm.Module{mod}, + []string{"main"}, + []string{"deletedDemand"}, + map[string]struct{}{"deletedDemand": {}}, + ) + if _, ok := dead["deletedDemand"]; !ok { + t.Fatalf("feedback for completely deleted definition = %#v, want deletedDemand dead", dead) + } +} + +func TestThinLTOFeedbackFinalLinkRestoresInlining(t *testing.T) { + opt := requireTool(t, "opt") + linker := requireTool(t, "ld.lld") + tmp := t.TempDir() + mainObj := filepath.Join(tmp, "main.o") + demandObj := filepath.Join(tmp, "demand.o") + rewrittenObj := filepath.Join(tmp, "demand.rewritten.o") + firstApp := filepath.Join(tmp, "first") + finalApp := filepath.Join(tmp, "final") + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", "main.ll"), "-o", mainObj) + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", "demand_live.ll"), "-o", demandObj) + runTool(t, linker, "--entry=main", "--save-temps", "--lto-O2", "-o", firstApp, mainObj, demandObj) + + ctx := llvm.NewContext() + mod, err := ctx.ParseBitcodeFile(demandObj + ".0.preopt.bc") + if err != nil { + ctx.Dispose() + t.Fatalf("parse preopt demand module: %v", err) + } + if got := dcepass.UnmarkNoInlineFunctions(mod, []string{"semanticDemand"}); got != 1 { + mod.Dispose() + ctx.Dispose() + t.Fatalf("UnmarkNoInlineFunctions() = %d, want 1", got) + } + buf := llvm.WriteThinLTOBitcodeToMemoryBuffer(mod) + mod.Dispose() + ctx.Dispose() + if err := os.WriteFile(rewrittenObj, buf.Bytes(), 0o644); err != nil { + buf.Dispose() + t.Fatalf("write final-link overlay: %v", err) + } + buf.Dispose() + + runTool(t, linker, "--entry=main", "--save-temps", "--lto-O2", "-o", finalApp, mainObj, rewrittenObj) + finalCtx := llvm.NewContext() + defer finalCtx.Dispose() + finalMod, err := finalCtx.ParseBitcodeFile(rewrittenObj + ".4.opt.bc") + if err != nil { + t.Fatalf("parse final optimized demand module: %v", err) + } + defer finalMod.Dispose() + if fn := finalMod.NamedFunction("semanticDemand"); !fn.IsNil() && !fn.GetEnumFunctionAttribute(llvm.AttributeKindID("noinline")).IsNil() { + t.Fatal("final ThinLTO link retained temporary noinline on semanticDemand") + } +} + +func TestThinLTOFeedbackRewriteDropsDeadButKeepsLiveMethod(t *testing.T) { + opt := requireTool(t, "opt") + linker := requireTool(t, "ld.lld") + readelf := requireTool(t, "llvm-readelf") + archiver := requireTool(t, "llvm-ar") + tmp := t.TempDir() + mainObj := filepath.Join(tmp, "main.o") + demandObj := filepath.Join(tmp, "demand.o") + firstApp := filepath.Join(tmp, "first") + secondApp := filepath.Join(tmp, "second") + archive := filepath.Join(tmp, "libdemand.a") + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", "method_main.ll"), "-o", mainObj) + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", "method_demand.ll"), "-o", demandObj) + runTool(t, archiver, "rcs", archive, demandObj) + runTool(t, linker, "--export-dynamic", "--entry=main", "--save-temps", "--lto-O2", "-o", firstApp, mainObj, demandObj) + + firstSymbols := commandOutput(t, readelf, "-s", firstApp) + for _, name := range []string{"T.M", "T.N"} { + if !strings.Contains(firstSymbols, name) { + t.Fatalf("first ThinLTO link symbols missing %s:\n%s", name, firstSymbols) + } + } + + feedbackCtx := llvm.NewContext() + feedbackMods := make([]llvm.Module, 0, 2) + for _, path := range []string{mainObj + ".4.opt.bc", demandObj + ".4.opt.bc"} { + feedbackMod, err := feedbackCtx.ParseBitcodeFile(path) + if err != nil { + feedbackCtx.Dispose() + t.Fatalf("parse optimized feedback module %s: %v", path, err) + } + feedbackMods = append(feedbackMods, feedbackMod) + } + if feedbackMods[1].NamedFunction("semanticDemand").IsNil() { + feedbackCtx.Dispose() + t.Fatal("expected initial ThinLTO round to retain semanticDemand definition") + } + deadFunctions := dcepass.DeadNoInlineFunctionsFromModules(feedbackMods, []string{"main"}, []string{"semanticDemand"}) + for _, feedbackMod := range feedbackMods { + feedbackMod.Dispose() + } + feedbackCtx.Dispose() + if _, ok := deadFunctions["semanticDemand"]; !ok { + t.Fatalf("post-ThinLTO feedback = %#v, want semanticDemand dead", deadFunctions) + } + // liveDemand still reaches T.N, while the constant-false semanticDemand + // carried the only reason to keep T.M in the first plan. + secondPlan := deadcode.BuildPlanWithFeedback(feedbackMethodSummary(t), []string{"main"}, deadcode.Feedback{DeadFunctions: deadFunctions}) + wantSecond := map[string][]int{"T": {1}} + if !reflect.DeepEqual(secondPlan.LiveSlots, wantSecond) { + t.Fatalf("second plan LiveSlots = %#v, want %#v", secondPlan.LiveSlots, wantSecond) + } + + ctx := llvm.NewContext() + mod, err := ctx.ParseBitcodeFile(demandObj + ".0.preopt.bc") + if err != nil { + ctx.Dispose() + t.Fatalf("parse preopt demand module: %v", err) + } + if got := dcepass.RewriteTypeMethodTables(mod, secondPlan.LiveSlots, false); got != 1 { + mod.Dispose() + ctx.Dispose() + t.Fatalf("RewriteTypeMethodTables rewrote %d globals, want 1", got) + } + buf := llvm.WriteThinLTOBitcodeToMemoryBuffer(mod) + mod.Dispose() + ctx.Dispose() + rewrittenObj := filepath.Join(tmp, "demand.rewritten.o") + if err := os.WriteFile(rewrittenObj, buf.Bytes(), 0o644); err != nil { + buf.Dispose() + t.Fatalf("write rewritten ThinLTO module: %v", err) + } + buf.Dispose() + + // Keep the original package archive after the rewritten direct object. The + // overlay satisfies every Go symbol, so LLD leaves the stale Go member in the + // archive unextracted while still allowing other archive members to satisfy + // cgo/asm references. + runTool(t, linker, "--export-dynamic", "--entry=main", "--save-temps", "--lto-O2", "-o", secondApp, mainObj, rewrittenObj, archive) + secondSymbols := commandOutput(t, readelf, "-s", secondApp) + if strings.Contains(secondSymbols, "T.M") { + t.Fatalf("second ThinLTO link retained rewritten dead method T.M:\n%s", secondSymbols) + } + if !strings.Contains(secondSymbols, "T.N") { + t.Fatalf("second ThinLTO link dropped still-live method T.N:\n%s", secondSymbols) + } + archiveBackends, err := filepath.Glob(archive + "(*).4.opt.bc") + if err != nil { + t.Fatal(err) + } + if len(archiveBackends) != 0 { + t.Fatalf("rewritten overlay still extracted stale archive member: %v", archiveBackends) + } + + secondCtx := llvm.NewContext() + secondMods := make([]llvm.Module, 0, 2) + for _, path := range []string{mainObj + ".4.opt.bc", rewrittenObj + ".4.opt.bc"} { + secondMod, err := secondCtx.ParseBitcodeFile(path) + if err != nil { + secondCtx.Dispose() + t.Fatalf("parse second-round optimized module %s: %v", path, err) + } + secondMods = append(secondMods, secondMod) + } + deadRound2 := dcepass.DeadNoInlineFunctionsFromModules(secondMods, []string{"main"}, []string{"semanticDemand", "liveDemand"}) + for _, secondMod := range secondMods { + secondMod.Dispose() + } + secondCtx.Dispose() + if _, ok := deadRound2["semanticDemand"]; !ok { + t.Fatalf("second-round feedback lost dead semanticDemand: %#v", deadRound2) + } + if _, ok := deadRound2["liveDemand"]; ok { + t.Fatalf("second-round feedback incorrectly marked liveDemand dead: %#v", deadRound2) + } + stablePlan := deadcode.BuildPlanWithFeedback(feedbackMethodSummary(t), []string{"main"}, deadcode.Feedback{DeadFunctions: deadRound2}) + if !reflect.DeepEqual(stablePlan.LiveSlots, wantSecond) { + t.Fatalf("second-round stable plan LiveSlots = %#v, want %#v", stablePlan.LiveSlots, wantSecond) + } +} + +func runThinLTOFeedback(t *testing.T, demandFixture string) map[string]struct{} { + t.Helper() + opt := requireTool(t, "opt") + linker := requireTool(t, "ld.lld") + tmp := t.TempDir() + mainObj := filepath.Join(tmp, "main.o") + demandObj := filepath.Join(tmp, "demand.o") + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", "main.ll"), "-o", mainObj) + runTool(t, opt, "-module-summary", filepath.Join("testdata", "thinlto_feedback", demandFixture), "-o", demandObj) + runTool(t, linker, "--entry=main", "--save-temps", "--lto-O2", "-o", filepath.Join(tmp, "app"), mainObj, demandObj) + + ctx := llvm.NewContext() + defer ctx.Dispose() + mods := make([]llvm.Module, 0, 2) + for _, path := range []string{mainObj + ".4.opt.bc", demandObj + ".4.opt.bc"} { + mod, err := ctx.ParseBitcodeFile(path) + if err != nil { + t.Fatalf("parse ThinLTO optimized module %s: %v", path, err) + } + defer mod.Dispose() + mods = append(mods, mod) + } + + // In the false case ThinLTO removes main's call, but its initial combined + // index still makes the other backend retain the definition. Recomputing + // roots from post-opt references discovers the new global fixed point. + if mods[1].NamedFunction("semanticDemand").IsNil() { + t.Fatal("expected initial ThinLTO round to retain semanticDemand definition") + } + return dcepass.DeadNoInlineFunctionsFromModules(mods, []string{"main"}, []string{"semanticDemand"}) +} + +func feedbackSummary(t *testing.T) *meta.GlobalSummary { + t.Helper() + b := meta.NewBuilder() + main := b.Sym("main") + demand := b.Sym("semanticDemand") + typ := b.Sym("_llgo_feedback.T") + iface := b.Sym("_llgo_feedback.I") + mtype := b.Sym("_llgo_func$M") + b.AddOrdinaryEdge(mtype, mtype) + b.AddIfaceMethod(iface, "M", mtype) + b.AddMethodSlot(typ, "M", mtype, b.Sym("feedback.(*T).M"), b.Sym("feedback.T.M")) + b.AddOrdinaryEdge(main, demand) + b.AddOrdinaryEdge(demand, typ) + b.AddIfaceUse(demand, typ) + b.AddIfaceMethodUse(demand, iface, 0) + pm, err := b.Build() + if err != nil { + t.Fatal(err) + } + summary, err := meta.NewGlobalSummary([]*meta.PackageMeta{pm}) + if err != nil { + t.Fatal(err) + } + return summary +} + +func feedbackMethodSummary(t *testing.T) *meta.GlobalSummary { + t.Helper() + b := meta.NewBuilder() + main := b.Sym("main") + live := b.Sym("liveDemand") + dead := b.Sym("semanticDemand") + typ := b.Sym("T") + iface := b.Sym("_llgo_feedback.I") + mtype := b.Sym("_llgo_func$M") + b.AddOrdinaryEdge(mtype, mtype) + b.AddIfaceMethod(iface, "M", mtype) + b.AddIfaceMethod(iface, "N", mtype) + b.AddMethodSlot(typ, "M", mtype, b.Sym("feedback.(*T).M"), b.Sym("feedback.T.M")) + b.AddMethodSlot(typ, "N", mtype, b.Sym("feedback.(*T).N"), b.Sym("feedback.T.N")) + b.AddOrdinaryEdge(main, live) + b.AddOrdinaryEdge(main, dead) + b.AddOrdinaryEdge(live, typ) + b.AddOrdinaryEdge(dead, typ) + b.AddIfaceUse(live, typ) + b.AddIfaceMethodUse(live, iface, 1) + b.AddIfaceUse(dead, typ) + b.AddIfaceMethodUse(dead, iface, 0) + pm, err := b.Build() + if err != nil { + t.Fatal(err) + } + summary, err := meta.NewGlobalSummary([]*meta.PackageMeta{pm}) + if err != nil { + t.Fatal(err) + } + return summary +} + +func requireTool(t *testing.T, name string) string { + t.Helper() + path, err := exec.LookPath(name) + if err != nil { + t.Skipf("%s is required for ThinLTO feedback integration test", name) + } + return path +} + +func runTool(t *testing.T, tool string, args ...string) { + t.Helper() + if out, err := exec.Command(tool, args...).CombinedOutput(); err != nil { + t.Fatalf("%s %v: %v\n%s", tool, args, err, out) + } +} + +func commandOutput(t *testing.T, tool string, args ...string) string { + t.Helper() + out, err := exec.Command(tool, args...).CombinedOutput() + if err != nil { + t.Fatalf("%s %v: %v\n%s", tool, args, err, out) + } + return string(out) +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go index 13970e5b74..df2879c887 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -206,6 +206,22 @@ func (pm *PackageMeta) Close() error { return nil } +// DemandFunctionNames returns package-local symbols that own function-scoped +// interface, method, or reflection demands. The returned strings alias the +// PackageMeta backing bytes and must not be used after Close. +func (pm *PackageMeta) DemandFunctionNames() []string { + if pm == nil { + return nil + } + var names []string + for sym := Symbol(0); sym < Symbol(pm.nsyms); sym++ { + if pm.hasFuncDemand(sym) { + names = append(names, pm.symbolName(sym)) + } + } + return names +} + // symbolName returns the name of package-local sym as a string that aliases the // backing bytes. For a PackageMeta returned from Open, the string must not be // used after Close. diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go index 90438f5cb1..38e9d03e12 100644 --- a/internal/meta/meta_test.go +++ b/internal/meta/meta_test.go @@ -4,11 +4,33 @@ import ( "encoding/binary" "os" "path/filepath" + "reflect" "strings" "testing" "unsafe" ) +func TestDemandFunctionNames(t *testing.T) { + b := NewBuilder() + main := b.Sym("pkg.main") + reflectFn := b.Sym("pkg.reflectFn") + typ := b.Sym("_llgo_pkg.T") + b.AddIfaceUse(main, typ) + b.MarkReflect(reflectFn) + b.AddOrdinaryEdge(b.Sym("pkg.helper"), typ) + pm, err := b.Build() + if err != nil { + t.Fatal(err) + } + want := []string{"pkg.main", "pkg.reflectFn"} + if got := pm.DemandFunctionNames(); !reflect.DeepEqual(got, want) { + t.Fatalf("DemandFunctionNames() = %#v, want %#v", got, want) + } + if got := (*PackageMeta)(nil).DemandFunctionNames(); got != nil { + t.Fatalf("nil DemandFunctionNames() = %#v, want nil", got) + } +} + // TestWireLayout verifies the zero-copy structs match their on-disk byte layout: // correct total size and field offsets. If these drift, unsafe reinterpretation // of mmap bytes would silently corrupt — so we assert them explicitly. diff --git a/ltoplugin/LLGOLTOPlugin.cpp b/ltoplugin/LLGOLTOPlugin.cpp index 340e995b82..446dc1f737 100644 --- a/ltoplugin/LLGOLTOPlugin.cpp +++ b/ltoplugin/LLGOLTOPlugin.cpp @@ -23,6 +23,16 @@ PassPluginLibraryInfo getLLGOLTOPluginInfo() { [](ModulePassManager &MPM, OptimizationLevel) { llgo::addLLGOPreGlobalDCEPipeline(MPM); }); + + // ThinLTO optimizes each backend module independently through the + // regular optimizer pipeline. Run after its scalar/IPO pipeline so + // global slice loads and bounded loops have been simplified as far + // as possible, then leave the recovered names on the call site for + // LLGo's feedback planner to consume from .4.opt.bc. + PB.registerOptimizerLastEPCallback( + [](ModulePassManager &MPM, OptimizationLevel) { + llgo::addLLGOReflectMethodByNamePass(MPM); + }); }}; } diff --git a/ltoplugin/LLGOReflectMethodByNamePass.cpp b/ltoplugin/LLGOReflectMethodByNamePass.cpp index 4120522ef9..0c84a6da00 100644 --- a/ltoplugin/LLGOReflectMethodByNamePass.cpp +++ b/ltoplugin/LLGOReflectMethodByNamePass.cpp @@ -33,6 +33,8 @@ static constexpr char ReflectMethodByNameCallAttr[] = "llgo.reflect.methodbyname"; static constexpr char ReflectMethodByNameArgAttr[] = "llgo.reflect.methodbyname.name"; +static constexpr char ReflectMethodByNameNamesAttr[] = + "llgo.reflect.methodbyname.names"; static constexpr char ReflectMethodByNameValueKind[] = "value"; static constexpr char ReflectMethodByNameTypeKind[] = "type"; static constexpr char ReflectValueMethodTypeID[] = "go.method.value.reflect"; @@ -1361,6 +1363,20 @@ class LLGOLTOPreGlobalDCEPass : public PassInfoMixin { if (!KnownNames || Names.empty()) continue; + // Preserve the analysis result for LLGo's ThinLTO feedback planner. + // Go method names cannot contain commas, so the compact encoding is + // unambiguous and remains easy to inspect in saved backend bitcode. + std::string EncodedNames; + for (const std::string &Name : Names) { + if (!EncodedNames.empty()) + EncodedNames.push_back(','); + EncodedNames.append(Name); + } + ReflectCall->addFnAttr(Attribute::get(ReflectCall->getContext(), + ReflectMethodByNameNamesAttr, + EncodedNames)); + Changed = true; + SmallVector GenericLoads; SmallPtrSet SeenLoads; SmallPtrSet SeenValues; @@ -1374,7 +1390,7 @@ class LLGOLTOPreGlobalDCEPass : public PassInfoMixin { Changed = true; } - if (!GenericLoads.empty() && std::getenv("LLGO_LTO_PLUGIN_VERBOSE")) { + if (std::getenv("LLGO_LTO_PLUGIN_VERBOSE")) { errs() << "llgo-lto-plugin: refined " << Kind << " to"; for (const std::string &Name : Names) errs() << " " << Name;