build: prototype ThinLTO deadcode planning, feedback, and size tuning - #2337
build: prototype ThinLTO deadcode planning, feedback, and size tuning#2337luoliwoshang wants to merge 17 commits into
Conversation
564db6f to
a0a820e
Compare
LLGo baseline benchmarks
Program measurements
Core language and compiler benchmarks
Compared with |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
ThinLTO feedback experiment: findings and design notesThis comment records what this experiment has established so far, so that the reasoning is not lost when the prototype implementation changes. 1. The existing Go deadcode planner remains the semantic authorityThe useful split is not "replace LLGo deadcode with LLVM DCE". It is: The existing planner understands facts that LLVM does not: concrete types entering interface domains, complete interface implementation relationships, method signatures, reflection demands, method slots, and type-child propagation. LLVM remains responsible for optimization and deleting code after method-table edges have been removed. This gives us a stable boundary:
2. ThinLTO must rewrite the definition in its owning packageThe existing non-LTO ThinLTO builds summaries and resolves The working ThinLTO model is therefore:
This is the essential result of the original prototype. It lets the existing LLGo deadcode algorithm work under ThinLTO without teaching the strong-override mechanism ThinLTO-specific exceptions. 3. ThinLTO is not a simple combined-module linkThinLTO operates approximately as: There is no single FullLTO-style merged LLVM module. Each This matters because an optimizer-last pass can discover a fact in one backend module after the combined index has already been constructed. It cannot by itself rerun the LLGo interface/reflection fixed point or rewrite every other package's method table and summary. 4. Feedback means facts, not IR, flow back into the Go plannerThe prototype feedback contract is deliberately small: type Feedback struct {
DeadFunctions map[string]struct{}
RefinedMethodNames map[string][]string
}The loop is: The loop stops when This is a cross-abstraction fixed point: LLVM discovers lower-level facts, while the existing LLGo planner decides what those facts mean for Go method/interface/reflection reachability. 5. Why function-level dead feedback currently needs
|
| Configuration | ELF bytes | vs existing Deadcode | Wall time |
|---|---|---|---|
Existing -deadcodedrop |
7,074,528 | baseline | 34.57 s |
| FullLTO + GlobalDCE | 7,354,728 | +3.96% | 68.21 s |
| FullLTO + GlobalDCE + plugin | 5,948,696 | -15.91% | 56.67 s |
| ThinLTO + feedback, no plugin | 6,979,832 | -1.34% | 100.10 s |
| ThinLTO + plugin, feedback disabled | 7,014,216 | -0.85% | 57.22 s |
| ThinLTO + feedback + plugin | 5,628,880 | -20.43% | 100.00 s |
The important control is ThinLTO plugin without feedback: the plugin alone saves only 60,312 bytes. Feeding the recovered name set into the Go planner saves another 1,385,336 bytes. This establishes that the main gain is the LLVM -> LLGo planner -> package rewrite loop, not an incidental LLVM pass effect.
The new ThinLTO result is also 319,816 bytes (5.38%) smaller than the local FullLTO plugin result. All final GORM test binaries completed with PASS.
9. Current prototype boundaries
The feedback path is intentionally gated and currently supports only:
- Linux/ELF with lld;
- native executable builds;
-lto=thin -deadcodedrop;LLGO_THINLTO_FEEDBACK=1;- up to three feedback rounds;
- rebuilt package modules rather than ordinary package-cache hits.
ThinLTO deadcode also uses -import-instr-limit=5 because LLVM's default import budget is performance-oriented and imported LLGo bodies can duplicate funcinfo sites. This size tuning is independent from the semantic feedback contract.
10. Likely production direction
The experiment suggests the following progression:
- keep package Meta and the existing deadcode planner as the stable Go-semantic layer;
- define versioned, structured LLVM feedback instead of scraping
.4.opt.bcby filename; - assign stable instruction-level
DemandIDs so feedback survives inlining precisely; - include planner, Meta, plugin, LLVM, and feedback digests in package/ThinLTO cache keys;
- cache proven feedback so warm links can run one planner pass and one final ThinLTO link;
- eventually integrate the feedback production closer to the ThinLTO index/backend API.
The most important architectural finding is that package Meta does not need to predict every LLVM optimization. It only needs to preserve enough Go semantics for the planner, while later optimization stages can contribute conservative, verifiable facts through a stable feedback protocol.
Summary
Prototype a complete ThinLTO-compatible deadcode pipeline for LLGo's Go
method-table pruning, then tune the Darwin LLVM 19 ThinLTO pipeline until the
result is smaller than the existing non-LTO
-deadcodedroppath in the testedprograms.
This PR is self-contained and based on
main. It includes:Os/Oz.The resulting model is:
LLGo remains responsible for Go-specific reachability. LLVM receives the
already-rewritten package modules and remains responsible for ThinLTO and
subsequent cross-module optimization.
Motivation
The existing non-ThinLTO
-deadcodedroppath emits same-name strong globals inthe entry module to override package-owned weak method tables. That mechanism
does not compose with ThinLTO's module summaries and symbol resolution.
With:
the strong-override experiment previously crashed LLVM 19.1.7 in:
The replacement global also lives outside the package module that owns the
original
weak_odrdefinition, COMDAT, and ThinLTO summary identity. Teachingthat override mechanism more ThinLTO special cases would preserve the wrong
ownership boundary.
This PR instead computes one global Go reachability plan, then applies the plan
inside each package module before that module's ThinLTO summary is written.
Design
Global planner
internal/deadcode.BuildPlanconsumes the merged package Meta summary and rootset and returns an explicit plan:
The current Meta analysis remains the source of Go-specific reachability facts.
The pipeline boundary does not require the current algorithm to remain fixed:
future work can add reflection facts, string-flow information, or other planner
inputs without restoring link-time strong overrides.
Package-level rewrite
internal/dcepass.RewriteTypeMethodTablesapplies the global plan to the LLVMmodule that owns each method table.
For dead method slots, it replaces
IFn/TFntargets withruntime.unreachableMethod. The original global stays in the original packagemodule and preserves its:
weak_odrlinkage;No same-name strong duplicate is emitted in the entry module for this mode.
Build integration and bitcode regeneration
The ThinLTO deadcode path is enabled only for:
Package LLVM modules are kept alive until
linkMainPkghas collected all Metaand built the link-specific plan.
materializeThinLTODeadcodethen:This ordering matters. Rewriting after summary emission would leave LLVM
analyzing stale edges: the summary could retain a method target that the IR had
already replaced with
runtime.unreachableMethod.The first prototype deliberately disables package-cache hits in this combined
mode. Cache overlays and immutable source bitcode are follow-up work.
ThinLTO import budget
LLVM's default ThinLTO import budget is performance-oriented. Imported bodies
also duplicate LLGo funcinfo entry sites. The combined ThinLTO deadcode mode
uses:
This retains very small cross-package imports while avoiding the text and
funcinfo growth observed with the default import budget.
Size optimization levels
ld64.lldaccepts numeric--lto-O0..3flags and rejects--lto-Os/Oz.LLGo now passes a linker optimization flag only for numeric levels.
OsandOzstill select the corresponding LLGo pre-link pipeline, while the linkeruses its supported default backend level.
Darwin ThinLTO SLP recovery
The K8s experiment exposed a separate LLVM 19 Mach-O LLD pipeline problem.
LLVM 19
PipelineTuningOptionsdefault to:ELF LLD explicitly enables both from the LTO optimization level, but LLVM 19
Mach-O LLD does not set
PTO.SLPVectorization. LLGo'sthinlto-pre-link<O2>pipeline intentionally defers SLP to the backend, soDarwin ThinLTO never runs the pass.
LLVM main now contains the missing Mach-O assignments:
For LLVM 19 compatibility, Darwin ThinLTO
O2,O3, andOspackagepipelines now append:
Linux, FullLTO, non-LTO,
O1, andOzpipelines are unchanged. Once LLGomoves to an LLVM version containing the upstream fix, post-link SLP is
preferable because it can also see imported code.
SLP root-cause evidence
The dominant K8s regression was:
The Go standard library embeds an 88,064-byte P-256 precomputed table. The
retained ThinLTO pre-link module contained:
Individual pass probes against the exact package bitcode produced:
nistec.initresultinstcombinememcpyoptvector-combineslp-vectorizer<16 x i8>vector storesdefault<O2>default<Os>default<O1>default<Oz>The real linker command contained
-flto=thinand--lto-O2. A single-job--lto-debug-pass-managertrace showed the complete O2 backend pipeline,including
LoopVectorizePass, but zeroSLPVectorizerPassexecutions.nistec.initstayed at 88,079 IR instructions through the backend.Without SLP, code generation emitted repeated
movplusstrb/strh/strinstructions. With SLP it emitted constant-pool
ldr qandstp qsequences.The function's estimated machine-code range fell from 767,116 bytes to 77,244
bytes.
Import budgets 0 and 5 produced the same 767,116-byte function before the SLP
fix, proving that cross-module importing was not the primary cause.
Size results
Environment for the final measurements:
Four demos
The baseline is non-ThinLTO without deadcode pruning. Existing DCE is the
current non-ThinLTO strong-override implementation. New is the complete pipeline
in this PR.
All four final binaries exited with status 0.
mimeheaderprinted the expectedhost value and the complete
gotypesdemo finished successfully.Single forced-build wall-time samples for the final binaries were 30.55 s,
24.46 s, 20.99 s, and 21.78 s respectively. These are diagnostic samples, not
reported as benchmark medians.
K8s workqueue
Benchmark source:
__textnistec.init__llgo_fie__LINKEDITCompared with ThinLTO+DCE before the import/SLP tuning:
__text: -946,036 bytes / -36.73%;nistec.init: -689,872 bytes / -89.93%.The final binary is 342,064 bytes (4.50%) smaller than the existing non-LTO
DCE binary. ThinLTO's
__llgo_fieremains larger, but its smaller__LINKEDITand restored text optimization more than compensate in this case.The K8s test binary still exits during startup with the existing:
The existing non-LTO DCE binary fails the same way. K8s is therefore currently
a build-size sample, not a runtime-correctness result.
Correctness validation
The package-owned rewrite path preserves linkage/COMDAT and has focused tests
for method-table initializer replacement. The ThinLTO combination also builds
and runs the interface/reflection cases used during the prototype:
A small interface experiment removed all three dead
Dropsymbols whilepreserving output:
__text0x51a40x506cDropsymbolsKnown limitations
write cached rewritten archives.
supported; the original method table must become immutable or reloadable.
MethodByNamestring/control-flow propagation is out of scope.Ozcontrols the LLGo pre-link pipeline, but an end-to-end size-orientedThinLTO backend mode is not available through LLVM 19
ld64.lld.toolchain includes the upstream Mach-O LTO fix.
Follow-ups
independently.
unreachable method calledK8s startupfailure before treating that benchmark as runtime validation.
Tests
Passed on the complete branch:
git diff --check upstream/main...HEADalso passes.GORM MethodByName feedback experiment
This update extends the package-level ThinLTO prototype with a bounded LLVM-to-Go feedback loop for dynamic
reflect.MethodByNamesites. It remains opt-in throughLLGO_THINLTO_FEEDBACK=1.The updated flow is:
The C++ pass recovers finite string sets after ThinLTO constant propagation. The Go-side scanner accepts a refinement only when every marked call in the owning function has a finite non-empty set. The deadcode planner additionally requires exactly one
DemandReflectMethodfor that owner before replacing its conservative dynamic-reflection demand. Owners that mix an unrefinedMethodByName,Method(index), or another reflection demand remain conservative.GORM schema result
The benchmark is the exact
gorm_schema@v1.31.2case fromxgo-dev/benchmarksrun #332, using benchmark sourcea8f126694f03, LLGo basee4786ae092be, Go 1.26.2, LLVM 19.1.7, Linux amd64, and BentBuildCache = stdlib. Each target and its non-standard dependencies were rebuilt in an isolated cache.The optimizer recovered the nine values stored in GORM
callbackTypes:-deadcodedropThe no-feedback plugin control is important: loading the plugin for one ThinLTO link saves only 60,312 bytes. Feeding its finite name set back into the Go planner saves another 1,385,336 bytes, showing that the main gain comes from the planner/rewrite feedback rather than from the plugin pass alone.
The new ThinLTO result is also 319,816 bytes (5.38%) smaller than the local FullLTO plugin result. The local FullLTO sizes differ from run #332 by only 4.5-4.6 KiB, which validates the reproduction against the published 7,359,264-byte and 5,953,296-byte results.
All measured GORM schema test binaries used for the final comparison completed with
PASS. The wall times are single local diagnostic samples; the feedback mode currently performs multiple real ThinLTO links and build-time optimization remains follow-up work.