Skip to content

build: write back deadcode through ThinLTO - #2398

Open
luoliwoshang wants to merge 1 commit into
xgo-dev:mainfrom
luoliwoshang:codex/thinlto-deadcode-writeback-v1
Open

build: write back deadcode through ThinLTO#2398
luoliwoshang wants to merge 1 commit into
xgo-dev:mainfrom
luoliwoshang:codex/thinlto-deadcode-writeback-v1

Conversation

@luoliwoshang

Copy link
Copy Markdown
Contributor

Summary

This PR adds a one-shot ThinLTO writeback path for the existing -deadcodedrop option.

The user-facing flag remains unchanged:

  • -deadcodedrop without ThinLTO keeps the existing entry-module strong Global override path.
  • -deadcodedrop -lto=thin uses package-owned rewrite before the final ThinLTO link.
  • FullLTO/GlobalDCE behavior is unchanged.

Implementation

  1. Build packages normally through the existing LLVM ThinLTO pre-link pipeline, but retain each package module until link time.
  2. At link time, merge package metadata into one global Go deadcode plan.
  3. Serialize each package module as canonical ThinLTO bitcode without modifying the original module.
  4. Parse that bitcode in a fresh LLVM context and rewrite only the package-owned ABI type method tables.
  5. Keep method name/type metadata, replace dead IFn/TFn slots with runtime.unreachableMethod, and preserve the original global linkage/COMDAT.
  6. Re-emit the rewritten ThinLTO bitcode into a temporary package archive.
  7. Run the final linker/ThinLTO pipeline over the rewritten package archives.

The canonical bitcode is link-scoped and removed after materialization in this first version. Package cache hits are disabled for this mode because the rewrite plan is link-specific.

Why this path

The old deadcode implementation emits strong same-name globals from the entry module. That works as a compatibility path, but it does not let ThinLTO build summaries from the already-pruned package-owned definitions. This implementation moves the rewrite to the owner package, allowing ThinLTO to see the pruned metadata during its normal analysis and optimization.

This PR intentionally does not add MethodByName feedback, .4.opt.bc handling, --save-temps, archive caching, or multi-round fixed-point analysis.

Benchmark experiment

Environment: macOS darwin/arm64, LLGo built from this PR with -tags dev, Bent serial build (-j=1), one cold test -c -a build per configuration. Sizes are benchsize ELF/Mach-O total-bytes; times are Bent build-real-ns/op.

Benchmark Deadcode size ThinLTO+DCE size Delta Deadcode build ThinLTO+DCE build Time delta
k8s_workqueue 10,509,440 (10.02 MiB) 10,650,928 (10.16 MiB) +141,488 (+1.35%) 46.739 s 52.750 s +6.011 s (+12.86%)
uber_zap 9,408,496 (8.97 MiB) 9,355,632 (8.92 MiB) -52,864 (-0.56%) 56.323 s 50.572 s -5.751 s (-10.21%)
gorm_schema 6,916,032 (6.60 MiB) 6,865,856 (6.55 MiB) -50,176 (-0.73%) 22.200 s 17.286 s -4.914 s (-22.14%)

Section-level results:

  • k8s_workqueue: text 3,422,004 -> 3,869,720; data 334,880 -> 221,672.
  • uber_zap: text 2,544,836 -> 2,848,688; data 333,808 -> 223,216.
  • gorm_schema: text 1,996,248 -> 2,167,036; data 218,224 -> 137,328.

The results are intentionally mixed. The owner rewrite reduces data/metadata in all three cases, but ThinLTO can increase text because its package/object optimization and final code generation differ from the legacy non-LTO path. This PR establishes the integration point; further ThinLTO-specific tuning is needed before claiming a universal size win.

Verification

  • go test ./internal/dcepass ./internal/build -count=1
  • go test -tags dev ./internal/dcepass ./internal/build -run 'Test(RewriteTypeMethodTablesInPlace|ApplyDeadcodeDropOverridesWritesStrongTypeOverride|ThinLTODeadcodeEnabled|WriteCanonicalThinLTOBitcodeRoundTrip)' -count=1
  • go test ./internal/... -run '^$'
  • git diff --check

The implementation tests pass. A direct macOS demo link was also exercised; unrelated local runtime dependencies (GC_*, libffi) are unavailable in this environment, so that final executable link could not complete.

@fennoai fennoai Bot left a comment

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.

FennoAI Review

This adds a ThinLTO deadcode writeback mode that defers package export until linkMainPkg computes the global plan, then rewrites each package's ABI method tables in-place (dcepass.RewriteTypeMethodTables) rather than emitting entry-module overrides. The design is clean and closely mirrors the existing applyDeadcodeDropOverrides path.

Strengths

  • Careful buffer/module lifecycle: canonical buffer disposed via defer, rewritten buffer ownership transferred to ObjBuffers and freed by normalizeToArchive's disposeArchiveBuffers.
  • Temp-file cleanup in writeCanonicalThinLTOBitcode is thorough on all error paths.
  • The CacheHit guard plus the tryLoadFromCache skip form a coherent defensive pairing.
  • Doc comments are accurate and follow Go conventions; no security or documentation issues found.

The mode is buildenv.Dev-gated (experimental), which lowers the severity of the notes below. Inline findings cover the main points; a few non-line-specific observations:

  • No end-to-end test for materializeThinLTODeadcodePlan. writeCanonicalThinLTOBitcode and RewriteTypeMethodTables are tested in isolation, but the orchestration that wires them into the package archive (skip conditions, CacheHit branch, ObjBuffers append, normalizeToArchive, and the archiveInputs rebuild in linkMainPkg) — the part most likely to regress on refactor — has no direct coverage.
  • Duplication between the two dcepass paths. moduleRewriter.rewriteGlobal and overrideEmitter.emitTypeOverride share the per-slot rewrite logic and the identical "[dce] drop method ..." verbose format string. A small shared helper would keep them from drifting.
  • Performance (dev-mode, non-blocking): materialization runs serially per package on the link path doing full serialize/reparse/rewrite/reserialize, and the mode disables the package cache entirely (full source rebuild every invocation). Both are conscious, correctness-driven tradeoffs and acceptable for an experimental flag; worth revisiting if the mode is ever promoted toward non-dev use.

Comment thread internal/build/build.go
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.

Comment thread internal/build/build.go
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.

Comment thread internal/build/build.go
// 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.

Comment thread internal/build/build.go
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.

@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 39.39394% with 80 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/build/build.go 14.63% 62 Missing and 8 partials ⚠️
internal/dcepass/dcepass.go 80.00% 5 Missing and 5 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

LLGo baseline benchmarks

434ef4f1921d | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 19288 B 0 B / +0.0% 346.088 ms +1.433 ms / +0.4% (worse) 1.380 ms +24.88 us / +1.8% (worse)
Linux cprintf-lto 19120 B 0 B / +0.0% 343.434 ms -4.796 ms / -1.4% (better) 1.358 ms -59.67 us / -4.2% (better)
Linux fmtprintf 1806104 B 0 B / +0.0% 2.905 s +72.45 ms / +2.6% (worse) 3.563 ms +65.8 us / +1.9% (worse)
Linux fmtprintf-lto 1703944 B 0 B / +0.0% 10.330 s -165.8 ms / -1.6% (better) 3.507 ms +9.315 us / +0.3% (worse)
Linux println 68776 B 0 B / +0.0% 366.506 ms +10.74 ms / +3.0% (worse) 1.722 ms -28.07 us / -1.6% (better)
Linux println-lto 62464 B 0 B / +0.0% 552.442 ms -6.831 ms / -1.2% (better) 1.727 ms -7.16 us / -0.4% (better)
macOS cprintf 84672 B 0 B / +0.0% 648.899 ms +310.4 ms / +91.7% (worse) 4.415 ms +1.974 ms / +80.9% (worse)
macOS cprintf-lto 100912 B 0 B / +0.0% 578.300 ms +215.2 ms / +59.3% (worse) 3.925 ms +1.458 ms / +59.1% (worse)
macOS fmtprintf 1867264 B 0 B / +0.0% 2.731 s +295.9 ms / +12.1% (worse) 12.360 ms +406.2 us / +3.4% (worse)
macOS fmtprintf-lto 1566320 B 0 B / +0.0% 13.912 s +5.403 s / +63.5% (worse) 14.347 ms +7.878 ms / +121.8% (worse)
macOS println 121360 B 0 B / +0.0% 571.393 ms +177.9 ms / +45.2% (worse) 5.225 ms +2.17 ms / +71.0% (worse)
macOS println-lto 128528 B 0 B / +0.0% 816.288 ms +333.3 ms / +69.0% (worse) 8.462 ms +5.062 ms / +148.9% (worse)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 12.300 ns/op +0.05 ns/op / +0.4% (worse)
Linux BenchmarkMergeCompilerFlags 144.300 ns/op -0.2 ns/op / -0.1% (better)
Linux BenchmarkMergeLinkerFlags 94.230 ns/op -1.33 ns/op / -1.4% (better)
Linux BenchmarkChannelBuffered 36.970 ns/op -0.33 ns/op / -0.9% (better)
Linux BenchmarkChannelHandoff 24180 ns/op +816 ns/op / +3.5% (worse)
Linux BenchmarkDefer 44.690 ns/op -0.8 ns/op / -1.8% (better)
Linux BenchmarkDirectCall 1.757 ns/op -0.001 ns/op / -0.1% (better)
Linux BenchmarkGlobalRead 2.109 ns/op 0 ns/op / +0.0%
Linux BenchmarkGlobalWrite 2.804 ns/op +0.001 ns/op / +0.03568% (worse)
Linux BenchmarkGoroutine 29310 ns/op -917 ns/op / -3.0% (better)
Linux BenchmarkInterfaceCall 9.143 ns/op +0.001 ns/op / +0.01094% (worse)
Linux BenchmarkRuntimeGetG 1.760 ns/op -0.001 ns/op / -0.1% (better)
macOS BenchmarkLookupPCRandom 14.900 ns/op -2.93 ns/op / -16.4% (better)
macOS BenchmarkMergeCompilerFlags 165.900 ns/op +1.3 ns/op / +0.8% (worse)
macOS BenchmarkMergeLinkerFlags 125.300 ns/op +2.2 ns/op / +1.8% (worse)
macOS BenchmarkChannelBuffered 23.410 ns/op -9.25 ns/op / -28.3% (better)
macOS BenchmarkChannelHandoff 8660 ns/op -2997 ns/op / -25.7% (better)
macOS BenchmarkDefer 31.290 ns/op -12.72 ns/op / -28.9% (better)
macOS BenchmarkDirectCall 1.086 ns/op -0.132 ns/op / -10.8% (better)
macOS BenchmarkGlobalRead 1.124 ns/op -0.225 ns/op / -16.7% (better)
macOS BenchmarkGlobalWrite 1.190 ns/op -0.407 ns/op / -25.5% (better)
macOS BenchmarkGoroutine 34660 ns/op -13922 ns/op / -28.7% (better)
macOS BenchmarkInterfaceCall 6.541 ns/op -0.861 ns/op / -11.6% (better)
macOS BenchmarkRuntimeGetG 2.178 ns/op -0.664 ns/op / -23.4% (better)

Compared with e4786ae092be measured in the same runner job.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant