Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 134 additions & 3 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,13 @@ func (c *Config) deadcodeDropEnabled() bool {
return buildenv.Dev && c.DeadcodeDrop && !c.goGlobalDCEEnabled()
}

// thinLTODeadcodeEnabled selects the package-owned rewrite path. ThinLTO
// packages are materialized only after the link-specific Go plan is known so
// the bitcode and its ThinLTO summary describe the rewritten method tables.
func (c *Config) thinLTODeadcodeEnabled() bool {
return c != nil && c.deadcodeDropEnabled() && c.ltoMode() == lto.Thin
}

func (c *Config) packageMetaEnabled() bool {
return c.CollectPackageMeta || c.deadcodeDropEnabled()
}
Expand Down Expand Up @@ -1190,7 +1197,13 @@ func prePackageBuild(ctx *context, task *packageBuildTask, verbose bool) error {
if err := ctx.collectFingerprint(aPkg); err != nil {
return err
}
ctx.tryLoadFromCache(aPkg)
// The ThinLTO writeback mode needs the package-owned LLVM module and must
// materialize a link-specific archive after the global plan is known. A
// regular package cache hit only contains the already-published archive, so
// keep this first implementation on the source-build path.
if !ctx.buildConf.thinLTODeadcodeEnabled() {
ctx.tryLoadFromCache(aPkg)
}
if verbose {
status := "MISS"
if aPkg.CacheHit {
Expand Down Expand Up @@ -1220,6 +1233,13 @@ func finalizePackageBuild(ctx *context, task *packageBuildTask, verbose bool) er
if aPkg.CacheHit {
return nil
}
if ctx.buildConf.thinLTODeadcodeEnabled() {
// Package export is deferred until linkMainPkg computes the global plan.
if task.kind == cl.PkgLinkExtern {
appendExternalLinkArgs(ctx, aPkg, task.kindParam)
}
return nil
}
if err := normalizeToArchive(ctx, aPkg, verbose); err != nil {
return err
}
Expand Down Expand Up @@ -1517,6 +1537,30 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa
linkArgs = append(linkArgs, rtLinkArgs...)
archiveInputs = append(archiveInputs, rtLinkInputs...)
}
if ctx.buildConf.thinLTODeadcodeEnabled() {
plan, err := buildDeadcodePlan(linkedOrder, needRuntime)
if err != nil {
return err
}
if err := materializeThinLTODeadcodePlan(ctx, linkedOrder, plan, verbose); err != nil {
return err
}
// Package archives were intentionally deferred until the global plan
// was available. Rebuild the link inputs from the rewritten archives.
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)
Expand All @@ -1543,7 +1587,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
}
Expand Down Expand Up @@ -1591,11 +1635,21 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa
func linkedPackageMetas(pkgs []Package) []*meta.PackageMeta {
metas := make([]*meta.PackageMeta, 0, len(pkgs))
for _, pkg := range pkgs {
metas = append(metas, pkg.Meta)
if pkg != nil && pkg.Meta != nil {
metas = append(metas, pkg.Meta)
}
}
return metas
}

func buildDeadcodePlan(pkgs []Package, needRuntime bool) (map[string][]int, error) {
summary, err := meta.NewGlobalSummary(linkedPackageMetas(pkgs))
if err != nil {
return nil, err
}
return deadcode.Analyze(summary, dceEntryRootCandidates(pkgs, needRuntime)), nil
}

func applyDeadcodeDropOverrides(pkgs []Package, entryPkg Package, needRuntime bool, verbose bool) error {
metas := linkedPackageMetas(pkgs)
summary, err := meta.NewGlobalSummary(metas)
Expand All @@ -1609,6 +1663,78 @@ func applyDeadcodeDropOverrides(pkgs []Package, entryPkg Package, needRuntime bo
return nil
}

// materializeThinLTODeadcodePlan keeps the original package module untouched,
// writes it as canonical ThinLTO bitcode, and applies the link-specific plan to
// a fresh parsed module before creating the package archive. This is the
// package-owned counterpart to applyDeadcodeDropOverrides: no same-name global
// is emitted in the entry module.
func materializeThinLTODeadcodePlan(ctx *context, pkgs []Package, liveSlots map[string][]int, verbose bool) error {
for _, aPkg := range pkgs {
if aPkg == nil || aPkg.LPkg == nil || aPkg.Package == nil || aPkg.Package.ExportFile == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Empty-ExportFile packages silently un-archived in this mode

This skips any package with an empty ExportFile, and finalizePackageBuild also returns early before normalizeToArchive in this mode. In the normal path such a package (empty ExportFile but non-empty ObjFiles from cgo/asm/alias objects) would still be archived. Today packages reaching here with an empty ExportFile come from SkipToBuild and carry no ObjFiles, so there is likely no live gap — but the invariant is implicit. A short comment (or explicitly archiving their ObjFiles) would prevent a silent drop if that invariant ever changes.

continue
}
if aPkg.CacheHit {
return fmt.Errorf("thin LTO deadcode cannot rewrite cached package %s", aPkg.PkgPath)
}

canonical, err := writeCanonicalThinLTOBitcode(aPkg.LPkg.Module())
if err != nil {
return fmt.Errorf("write canonical ThinLTO bitcode for %s: %w", aPkg.PkgPath, err)
}
func() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Closure mutating enclosing err obscures control flow

The outer err from writeCanonicalThinLTOBitcode is reassigned inside the anonymous func and re-checked after it returns. It works, but relying on a closure to mutate an enclosing err that a different call just consumed is easy to misread. Extracting the closure body into a named helper (e.g. rewriteAndBufferPackage(aPkg, canonical, liveSlots, verbose) error) would make the temp-file lifetime, module disposal, and error propagation self-contained.

defer os.Remove(canonical)
llvmCtx := gllvm.NewContext()
defer llvmCtx.Dispose()
mod, parseErr := llvmCtx.ParseBitcodeFile(canonical)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Avoidable temp-file round-trip; in-memory parse is possible

A memory-buffer parse would avoid a disk round-trip here. writeCanonicalThinLTOBitcode already holds the serialized bytes in a gllvm.MemoryBuffer, but it writes them to a temp .bc file only to have ParseBitcodeFile read them back. The binding already exposes in-memory parsing (ParseIR over a MemoryBuffer, used in deadcode_test.go). Passing the buffer directly would remove one write + one read per package on the link path. Low priority given the dev-mode gating.

if parseErr != nil {
err = fmt.Errorf("parse canonical ThinLTO bitcode for %s: %w", aPkg.PkgPath, parseErr)
return
}
defer mod.Dispose()
dcepass.RewriteTypeMethodTables(mod, liveSlots, verbose)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Rewritten ThinLTO module is emitted without VerifyModule

After RewriteTypeMethodTables, the freshly parsed module is handed directly to WriteThinLTOBitcodeToMemoryBuffer with no verification. The normal compile path verifies each module via gllvm.VerifyModule(mod, gllvm.ReturnStatusAction) (build.go:2191), and this rewrite path bypasses that check. A malformed rewrite (e.g. a mismatched named-struct type) would silently produce invalid bitcode instead of failing fast. Consider adding a VerifyModule call after the rewrite and returning an error on failure, matching the fail-fast pattern used elsewhere.

Relatedly, RewriteTypeMethodTables returns the count of rewritten globals (the unit test asserts it), but the count is discarded here. When verbose, reporting it — or diagnosing a zero-rewrite result for a package expected to have live/dead slots — would surface liveSlots key-format mismatches that currently produce no signal at all.

buf := gllvm.WriteThinLTOBitcodeToMemoryBuffer(mod)
if buf.IsNil() {
err = fmt.Errorf("write rewritten ThinLTO bitcode for %s: empty buffer", aPkg.PkgPath)
return
}
aPkg.ObjBuffers = append(aPkg.ObjBuffers, packageArchiveBuffer{
name: filepath.Base(aPkg.Package.ExportFile) + ".o",
buffer: buf,
})
}()
if err != nil {
return err
}
if err := normalizeToArchive(ctx, aPkg, verbose); err != nil {
return fmt.Errorf("archive rewritten ThinLTO package %s: %w", aPkg.PkgPath, err)
}
}
return nil
}

func writeCanonicalThinLTOBitcode(mod gllvm.Module) (string, error) {
buf := gllvm.WriteThinLTOBitcodeToMemoryBuffer(mod)
if buf.IsNil() {
return "", errors.New("empty ThinLTO bitcode buffer")
}
defer buf.Dispose()
f, err := os.CreateTemp("", "llgo-thinlto-canonical-*.bc")
if err != nil {
return "", err
}
name := f.Name()
if _, err := f.Write(buf.Bytes()); err != nil {
f.Close()
os.Remove(name)
return "", err
}
if err := f.Close(); err != nil {
os.Remove(name)
return "", err
}
return name, nil
}

func dceSourceModules(pkgs []Package) []gllvm.Module {
mods := make([]gllvm.Module, 0, len(pkgs))
for _, pkg := range pkgs {
Expand Down Expand Up @@ -2118,6 +2244,11 @@ 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() {
// Keep the package module alive until linkMainPkg has merged all
// Meta and can apply one link-specific owner rewrite.
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)
Expand Down
21 changes: 21 additions & 0 deletions internal/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,27 @@ func TestDeadcodeDropEnabled(t *testing.T) {
}
}

func TestThinLTODeadcodeEnabled(t *testing.T) {
tests := []struct {
name string
conf *Config
want bool
}{
{name: "thin lto with deadcode drop", conf: &Config{DeadcodeDrop: true, LTO: lto.Thin}, want: buildenv.Dev},
{name: "thin lto without deadcode drop", conf: &Config{LTO: lto.Thin}, want: false},
{name: "full lto uses global dce", conf: &Config{DeadcodeDrop: true, LTO: lto.Full}, want: false},
{name: "full lto with global dce disabled", 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 TestPackageMetaEnabled(t *testing.T) {
tests := []struct {
name string
Expand Down
37 changes: 37 additions & 0 deletions internal/build/deadcode_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package build

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
Expand Down Expand Up @@ -86,6 +88,41 @@ func TestDCEEntryRootCandidatesIncludesCExports(t *testing.T) {
}
}

func TestWriteCanonicalThinLTOBitcodeRoundTrip(t *testing.T) {
ctx := llvm.NewContext()
defer ctx.Dispose()
path := filepath.Join("..", "dcepass", "testdata", "method_slots", "in.ll")
buf, err := llvm.NewMemoryBufferFromFile(path)
if err != nil {
t.Fatal(err)
}
mod, err := ctx.ParseIR(buf)
if err != nil {
t.Fatal(err)
}
defer mod.Dispose()

canonical, err := writeCanonicalThinLTOBitcode(mod)
if err != nil {
t.Fatal(err)
}
defer os.Remove(canonical)
if info, err := os.Stat(canonical); err != nil || info.Size() == 0 {
t.Fatalf("canonical ThinLTO bitcode stat = (%v, %v)", info, err)
}

parsedCtx := llvm.NewContext()
defer parsedCtx.Dispose()
parsed, err := parsedCtx.ParseBitcodeFile(canonical)
if err != nil {
t.Fatal(err)
}
defer parsed.Dispose()
if err := llvm.VerifyModule(parsed, llvm.ReturnStatusAction); err != nil {
t.Fatalf("canonical ThinLTO bitcode is invalid: %v", err)
}
}

func buildDeadcodeMeta(t *testing.T) *meta.PackageMeta {
t.Helper()
b := meta.NewBuilder()
Expand Down
83 changes: 83 additions & 0 deletions internal/dcepass/dcepass.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,89 @@ func EmitStrongTypeOverrides(dst llvm.Module, srcMods []llvm.Module, liveSlots m
}
}

// RewriteTypeMethodTables rewrites ABI method table initializers in mod in
// place. It preserves the package-owned global, including its linkage and
// COMDAT, so ThinLTO can build summaries from the rewritten definition without
// introducing an entry-module override.
//
// A missing type entry in liveSlots means that no method slot is demanded. The
// method name and type operands remain intact while IFn/TFn point to the
// runtime unreachable stub, preserving the ABI table shape and reflection
// 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()
if init.IsNil() || init.OperandsCount() == 0 {
return false
}
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
Expand Down
Loading
Loading