Skip to content

build: prototype ThinLTO deadcode planning, feedback, and size tuning - #2337

Draft
luoliwoshang wants to merge 17 commits into
xgo-dev:mainfrom
luoliwoshang:codex/darwin-thinlto-slp
Draft

build: prototype ThinLTO deadcode planning, feedback, and size tuning#2337
luoliwoshang wants to merge 17 commits into
xgo-dev:mainfrom
luoliwoshang:codex/darwin-thinlto-slp

Conversation

@luoliwoshang

@luoliwoshang luoliwoshang commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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 -deadcodedrop path in the tested
programs.

This PR is self-contained and based on main. It includes:

  1. package Meta production and one global deadcode planner;
  2. package-owned method-table rewriting before ThinLTO summary emission;
  3. regeneration of package ThinLTO bitcode and temporary linker archives;
  4. a size-oriented ThinLTO import budget for the deadcode mode;
  5. Darwin LLVM 19 SLP recovery for optimized ThinLTO pipelines;
  6. linker optimization-level compatibility for Os/Oz.

The resulting model is:

package LLVM modules + package Meta
    -> global Meta summary
    -> global deadcode.Plan
    -> rewrite each owning package module
    -> emit rewritten ThinLTO bitcode + summary
    -> build temporary package archives
    -> normal LLVM ThinLTO index/import/internalize/backend pipeline

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 -deadcodedrop path emits same-name strong globals in
the entry module to override package-owned weak method tables. That mechanism
does not compose with ThinLTO's module summaries and symbol resolution.

With:

-lto=thin -deadcodedrop

the strong-override experiment previously crashed LLVM 19.1.7 in:

FunctionImportGlobalProcessing::processGlobalForThinLTO

The replacement global also lives outside the package module that owns the
original weak_odr definition, COMDAT, and ThinLTO summary identity. Teaching
that 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.BuildPlan consumes the merged package Meta summary and root
set and returns an explicit plan:

type Plan struct {
    LiveSlots map[string][]int
}

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.RewriteTypeMethodTables applies the global plan to the LLVM
module that owns each method table.

For dead method slots, it replaces IFn/TFn targets with
runtime.unreachableMethod. The original global stays in the original package
module and preserves its:

  • ABI-compatible initializer layout;
  • weak_odr linkage;
  • COMDAT membership;
  • package/module identity used by ThinLTO.

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:

-lto=thin -deadcodedrop

Package LLVM modules are kept alive until linkMainPkg has collected all Meta
and built the link-specific plan. materializeThinLTODeadcode then:

  1. rewrites every package-owned method table;
  2. emits fresh ThinLTO bitcode from the rewritten module;
  3. emits a fresh ThinLTO summary describing the rewritten references;
  4. normalizes the rewritten object into a temporary package archive;
  5. rebuilds the final package input list before invoking the linker.

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:

-Wl,-mllvm,-import-instr-limit=5

This retains very small cross-package imports while avoiding the text and
funcinfo growth observed with the default import budget.

Size optimization levels

ld64.lld accepts numeric --lto-O0..3 flags and rejects --lto-Os/Oz.
LLGo now passes a linker optimization flag only for numeric levels. Os and
Oz still select the corresponding LLGo pre-link pipeline, while the linker
uses its supported default backend level.

Darwin ThinLTO SLP recovery

The K8s experiment exposed a separate LLVM 19 Mach-O LLD pipeline problem.

LLVM 19 PipelineTuningOptions default to:

LoopVectorization = true;
SLPVectorization = false;

ELF LLD explicitly enables both from the LTO optimization level, but LLVM 19
Mach-O LLD does not set PTO.SLPVectorization. LLGo's
thinlto-pre-link<O2> pipeline intentionally defers SLP to the backend, so
Darwin ThinLTO never runs the pass.

LLVM main now contains the missing Mach-O assignments:

For LLVM 19 compatibility, Darwin ThinLTO O2, O3, and Os package
pipelines now append:

function(slp-vectorizer)

Linux, FullLTO, non-LTO, O1, and Oz pipelines are unchanged. Once LLGo
moves 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:

crypto/internal/fips140/nistec.init

The Go standard library embeds an 88,064-byte P-256 precomputed table. The
retained ThinLTO pre-link module contained:

88,064 x store i8
0 x llvm.memcpy

Individual pass probes against the exact package bitcode produced:

Pass/pipeline nistec.init result
instcombine 88,064 scalar stores
memcpyopt 88,064 scalar stores
vector-combine 88,064 scalar stores
slp-vectorizer 5,504 <16 x i8> vector stores
default<O2> 5,504 vector stores
default<Os> 5,504 vector stores
default<O1> 88,064 scalar stores
default<Oz> 88,064 scalar stores

The real linker command contained -flto=thin and --lto-O2. A single-job
--lto-debug-pass-manager trace showed the complete O2 backend pipeline,
including LoopVectorizePass, but zero SLPVectorizerPass executions.
nistec.init stayed at 88,079 IR instructions through the backend.

Without SLP, code generation emitted repeated mov plus strb/strh/str
instructions. With SLP it emitted constant-pool ldr q and stp q sequences.
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:

macOS arm64
LLVM 19.1.7
O2
LLGO_BUILD_CACHE=off
-a (forced package rebuild)
PCLN/site information enabled

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.

Demo No-DCE baseline Existing DCE New ThinLTO+DCE New vs existing DCE
goimporter-1389 5342.0 KiB 3881.6 KiB 3716.4 KiB -165.2 KiB / -4.26%
embedunexport-1598 3904.5 KiB 2514.4 KiB 2429.8 KiB -84.6 KiB / -3.37%
mimeheader 2201.0 KiB 1613.3 KiB 1372.3 KiB -241.0 KiB / -14.94%
gotypes 3938.2 KiB 3269.1 KiB 3174.5 KiB -94.6 KiB / -2.90%

All four final binaries exited with status 0. mimeheader printed the expected
host value and the complete gotypes demo 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:

k8s.io/client-go/util/workqueue@v0.22.2
xgo-dev/benchmarks commit 94c0229de770b3b58dcb133c3dea60c4435c4a00
Mode Total bytes __text nistec.init __llgo_fie __LINKEDIT
Existing non-LTO DCE 7,601,824 1,629,080 77,244 59,040 2,244,608
ThinLTO+DCE before size fixes 8,036,320 2,575,756 767,116 147,328 1,851,392
Complete pipeline in this PR 7,259,760 1,629,720 77,244 147,744 1,851,392

Compared with ThinLTO+DCE before the import/SLP tuning:

  • total file size: -776,560 bytes / -9.66%;
  • __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_fie remains larger, but its smaller
__LINKEDIT and restored text optimization more than compensate in this case.

The K8s test binary still exits during startup with the existing:

fatal error: unreachable method called. linker bug?

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:

globaldce_interface_matrix
globaldce_interface_slots
globaldce_reflect_method
globaldce_reflect_type_method
globaldce_typeid_dce
globaldce_unexported_method_identity

A small interface experiment removed all three dead Drop symbols while
preserving output:

Metric ThinLTO baseline ThinLTO + planner DCE
File size 122,112 B 121,328 B
__text 0x51a4 0x506c
Drop symbols 3 0

Known limitations

  • Package cache use is temporarily disabled for ThinLTO + deadcode.
  • The prototype regenerates temporary package archives but does not yet read or
    write cached rewritten archives.
  • Reusing one in-memory package module for multiple entry-point plans is not
    supported; the original method table must become immutable or reloadable.
  • ThinLTO backend cache directories are not wired into the build yet.
  • The planner uses the current Meta reachability algorithm.
  • MethodByName string/control-flow propagation is out of scope.
  • Oz controls the LLGo pre-link pipeline, but an end-to-end size-oriented
    ThinLTO backend mode is not available through LLVM 19 ld64.lld.
  • The Darwin pre-link SLP workaround should be revisited after the LLVM
    toolchain includes the upstream Mach-O LTO fix.

Follow-ups

  • Make package bitcode immutable or reloadable so multiple plans can be applied
    independently.
  • Define cache keys for the global plan and rewritten package bitcode.
  • Wire a ThinLTO backend cache directory into the final link.
  • Extend planner inputs when reflection/string-flow analysis is ready.
  • Revisit temporary archive construction after the architecture is validated.
  • Investigate and fix the remaining unreachable method called K8s startup
    failure before treating that benchmark as runtime validation.

Tests

Passed on the complete branch:

go test ./internal/build ./internal/crosscompile ./internal/deadcode ./internal/dcepass
go test -tags=dev ./internal/build -run '^(TestLLVMPassPipeline|TestThinLTODeadcode(LinkerArgs|Enabled)|TestDeadcodeDropEnabled)$' -count=1

git diff --check upstream/main...HEAD also passes.

GORM MethodByName feedback experiment

This update extends the package-level ThinLTO prototype with a bounded LLVM-to-Go feedback loop for dynamic reflect.MethodByName sites. It remains opt-in through LLGO_THINLTO_FEEDBACK=1.

The updated flow is:

Go Meta planner + package-owned rewrite
    -> ThinLTO backend optimizer-last MethodByName analysis
    -> call-site llgo.reflect.methodbyname.names attribute
    -> scan per-module .4.opt.bc outputs
    -> deadcode.Feedback.RefinedMethodNames
    -> recompute the global Go method plan
    -> regenerate package overlays
    -> next ThinLTO round / final link

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 DemandReflectMethod for that owner before replacing its conservative dynamic-reflection demand. Owners that mix an unrefined MethodByName, Method(index), or another reflection demand remain conservative.

GORM schema result

The benchmark is the exact gorm_schema@v1.31.2 case from xgo-dev/benchmarks run #332, using benchmark source a8f126694f03, LLGo base e4786ae092be, Go 1.26.2, LLVM 19.1.7, Linux amd64, and Bent BuildCache = stdlib. Each target and its non-standard dependencies were rebuilt in an isolated cache.

The optimizer recovered the nine values stored in GORM callbackTypes:

BeforeCreate AfterCreate BeforeUpdate AfterUpdate BeforeSave AfterSave
BeforeDelete AfterDelete AfterFind
Configuration ELF bytes MiB vs existing Deadcode Local wall time
Existing -deadcodedrop 7,074,528 6.747 baseline 34.57 s
FullLTO + GlobalDCE 7,354,728 7.014 +280,200 / +3.96% 68.21 s
FullLTO + GlobalDCE + plugin 5,948,696 5.673 -1,125,832 / -15.91% 56.67 s
ThinLTO + feedback, no plugin 6,979,832 6.656 -94,696 / -1.34% 100.10 s
ThinLTO + plugin, feedback disabled 7,014,216 6.689 -60,312 / -0.85% 57.22 s
ThinLTO + feedback + plugin 5,628,880 5.368 -1,445,648 / -20.43% 100.00 s

The 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.

@luoliwoshang
luoliwoshang force-pushed the codex/darwin-thinlto-slp branch from 564db6f to a0a820e Compare August 15, 2026 14:56
@luoliwoshang luoliwoshang changed the title build: restore SLP for Darwin ThinLTO build: prototype ThinLTO deadcode planning and size tuning Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

fdd4b8519493 | 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% 343.410 ms -2.272 ms / -0.7% (better) 1.366 ms +10.19 us / +0.8% (worse)
Linux cprintf-lto 19120 B 0 B / +0.0% 341.974 ms -533.2 us / -0.2% (better) 1.349 ms -2.123 us / -0.2% (better)
Linux fmtprintf 1806104 B 0 B / +0.0% 2.775 s -58.97 ms / -2.1% (better) 3.462 ms +4.486 us / +0.1% (worse)
Linux fmtprintf-lto 1703944 B 0 B / +0.0% 10.055 s -1.163 ms / -0.01156% (better) 3.479 ms +95.16 us / +2.8% (worse)
Linux println 68776 B 0 B / +0.0% 336.044 ms -10.98 ms / -3.2% (better) 1.717 ms -170.1 us / -9.0% (better)
Linux println-lto 62464 B 0 B / +0.0% 549.258 ms +7.795 ms / +1.4% (worse) 1.780 ms +59.03 us / +3.4% (worse)
macOS cprintf 84672 B 0 B / +0.0% 403.215 ms -346.2 ms / -46.2% (better) 3.119 ms -1.94 ms / -38.3% (better)
macOS cprintf-lto 100912 B 0 B / +0.0% 685.463 ms +118.9 ms / +21.0% (worse) 4.119 ms -1.044 ms / -20.2% (better)
macOS fmtprintf 1867264 B 0 B / +0.0% 2.754 s -1.144 s / -29.3% (better) 13.061 ms -4.602 ms / -26.1% (better)
macOS fmtprintf-lto 1566320 B 0 B / +0.0% 8.304 s -5.586 s / -40.2% (better) 6.497 ms -278.4 us / -4.1% (better)
macOS println 121360 B 0 B / +0.0% 507.929 ms -40.19 ms / -7.3% (better) 5.515 ms +725.1 us / +15.1% (worse)
macOS println-lto 128528 B 0 B / +0.0% 674.555 ms -41.91 ms / -5.8% (better) 6.062 ms -56.46 us / -0.9% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 12.300 ns/op -0.5 ns/op / -3.9% (better)
Linux BenchmarkMergeCompilerFlags 144.200 ns/op -2.6 ns/op / -1.8% (better)
Linux BenchmarkMergeLinkerFlags 94.320 ns/op +0.51 ns/op / +0.5% (worse)
Linux BenchmarkChannelBuffered 37.300 ns/op -0.14 ns/op / -0.4% (better)
Linux BenchmarkChannelHandoff 25129 ns/op -137 ns/op / -0.5% (better)
Linux BenchmarkDefer 44.020 ns/op +0.08 ns/op / +0.2% (worse)
Linux BenchmarkDirectCall 1.759 ns/op 0 ns/op / +0.0%
Linux BenchmarkGlobalRead 2.109 ns/op +0.001 ns/op / +0.04744% (worse)
Linux BenchmarkGlobalWrite 2.822 ns/op +0.017 ns/op / +0.6% (worse)
Linux BenchmarkGoroutine 30718 ns/op -578 ns/op / -1.8% (better)
Linux BenchmarkInterfaceCall 9.148 ns/op +0.009 ns/op / +0.1% (worse)
Linux BenchmarkRuntimeGetG 1.764 ns/op +0.004 ns/op / +0.2% (worse)
macOS BenchmarkLookupPCRandom 16.500 ns/op +2.6 ns/op / +18.7% (worse)
macOS BenchmarkMergeCompilerFlags 132.300 ns/op -35.6 ns/op / -21.2% (better)
macOS BenchmarkMergeLinkerFlags 98.890 ns/op +6.26 ns/op / +6.8% (worse)
macOS BenchmarkChannelBuffered 27.760 ns/op -14.99 ns/op / -35.1% (better)
macOS BenchmarkChannelHandoff 7775 ns/op -3323 ns/op / -29.9% (better)
macOS BenchmarkDefer 29.580 ns/op -31.82 ns/op / -51.8% (better)
macOS BenchmarkDirectCall 1.116 ns/op -0.283 ns/op / -20.2% (better)
macOS BenchmarkGlobalRead 1.077 ns/op -0.403 ns/op / -27.2% (better)
macOS BenchmarkGlobalWrite 1.218 ns/op -0.535 ns/op / -30.5% (better)
macOS BenchmarkGoroutine 31331 ns/op -23570 ns/op / -42.9% (better)
macOS BenchmarkInterfaceCall 5.871 ns/op -2.916 ns/op / -33.2% (better)
macOS BenchmarkRuntimeGetG 2.013 ns/op -1.193 ns/op / -37.2% (better)

Compared with e4786ae092be measured in the same runner job.

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

@luoliwoshang luoliwoshang changed the title build: prototype ThinLTO deadcode planning and size tuning build: prototype ThinLTO deadcode planning, feedback, and size tuning Aug 23, 2026
@luoliwoshang

Copy link
Copy Markdown
Contributor Author

ThinLTO feedback experiment: findings and design notes

This 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 authority

The useful split is not "replace LLGo deadcode with LLVM DCE". It is:

package Meta
    -> LLGo global Go-semantic planner
    -> deadcode.Plan{LiveSlots}
    -> package-owned method-table rewrite
    -> LLVM ThinLTO / linker DCE

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:

  • package Meta is analyzer-independent, cacheable Go semantic input;
  • deadcode.Plan is the link-specific semantic decision;
  • package rewrite is the backend executor of that plan;
  • LLVM may become an additional producer of proven facts without owning Go reachability semantics.

2. ThinLTO must rewrite the definition in its owning package

The existing non-LTO -deadcodedrop implementation emits a same-name strong type descriptor in the entry module to override the package's weak definition. That is effective for ordinary linking, but it is the wrong ownership model for ThinLTO.

ThinLTO builds summaries and resolves weak_odr/COMDAT identities per input module before independently optimizing backend modules. An entry-module strong replacement can disagree with the original package's definition and summary. In LLVM 19 this path also reproduced a crash in FunctionImportGlobalProcessing.

The working ThinLTO model is therefore:

  1. compute one global plan from all package Meta;
  2. rewrite each type descriptor in the package LLVM module that owns it;
  3. preserve the descriptor's linkage, COMDAT, ABI layout, method names, and method types;
  4. replace only dead slots' IFn/TFn with runtime.unreachableMethod;
  5. regenerate ThinLTO bitcode and its summary before linking.

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 link

ThinLTO operates approximately as:

per-module bitcode + summaries
    -> combined index and symbol resolution
    -> promotion / internalization / importing decisions
    -> independent backend optimization for each module
    -> code generation and final ELF

There is no single FullLTO-style merged LLVM module. Each .4.opt.bc file is one backend module after promotion, internalization, importing, and optimization. It may contain imported bodies, but it is still one backend partition.

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 planner

The prototype feedback contract is deliberately small:

type Feedback struct {
    DeadFunctions      map[string]struct{}
    RefinedMethodNames map[string][]string
}

The loop is:

P0 = GoDeadcodePlan(Meta)
O0 = ThinLTO(RewritePackages(P0))
F0 = ExtractProvenFacts(O0)
P1 = GoDeadcodePlan(Meta, F0)
O1 = ThinLTO(RewritePackages(P1))
...

The loop stops when LiveSlots reaches a fixed point, or after the current bound of three feedback rounds. Before publishing the binary, temporary noinline attributes are removed and a final ThinLTO link restores normal inlining.

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 noinline

Current Meta semantic demands are owned by functions. If LLVM inlines an owner into a live caller and then deletes the original function, the semantic operation has not necessarily disappeared. Therefore "the definition is absent" is not sufficient proof that the owner's Meta demands are dead.

For feedback rounds, candidate demand owners are temporarily marked noinline. LLGo then scans the optimized cross-module reference graph:

  • a known input definition that is unreachable after optimization is dead;
  • a known input definition completely deleted by optimization is also dead;
  • an arbitrary missing definition is not treated as dead;
  • entry roots always override dead feedback.

The final link removes the barrier. A future instruction-level stable DemandID should replace this function-granularity workaround and permit normal inlining during analysis rounds.

6. What .4.opt.bc is doing in this prototype

-Wl,--save-temps asks lld to save ThinLTO backend snapshots. The relevant sequence is approximately:

.1.promote.bc
.2.internalize.bc
.3.import.bc
.4.opt.bc
    -> code generation

.4.opt.bc is LLVM bitcode after the backend optimization pipeline and after our optimizer-last plugin callback. It is not an LLGo format and the number is not a bitcode version.

The prototype scans all object and archive-member .4.opt.bc files to recover the real post-optimization reference graph and MethodByName attributes. This is useful for validating the architecture, but it should not be considered the final feedback transport because it:

  • requires a real ThinLTO link before planning again;
  • writes and reparses many large temporary bitcode files;
  • depends on lld save-temp naming conventions;
  • makes feedback builds significantly slower.

A production design should emit a small versioned sidecar, integrate with the ThinLTO summary/index protocol, or otherwise expose structured facts directly. The feedback digest and planner version must participate in cache keys.

7. GORM proves why LLVM facts need to return to the Go planner

In GORM schema.ParseWithSpecialTableName, the frontend sees:

for _, cbName := range callbackTypes {
    modelValue.MethodByName(string(cbName))
}

At package compile time cbName is a loop variable, so extractConstString fails. The existing Meta correctly records a conservative DemandReflectMethod, which means the deadcode planner must retain all relevant exported methods.

After ThinLTO optimization, the plugin proves that the only values are:

BeforeCreate AfterCreate BeforeUpdate AfterUpdate BeforeSave AfterSave
BeforeDelete AfterDelete AfterFind

It stores the finite set as a call-site llgo.reflect.methodbyname.names attribute. LLGo reads that attribute from the optimized backend module and feeds it to the original planner. The planner then replaces the owner's dynamic reflection demand with those nine names and reruns its complete method/interface/type fixed point.

The refinement is fail-closed:

  • every marked MethodByName call in the owner must have a finite non-empty set;
  • the Go-side owner must have exactly one DemandReflectMethod;
  • mixed or unproven reflection demands retain the original conservative behavior;
  • the current plugin bounds a recovered set to at most 32 names.

Therefore LLVM is not making the final method-liveness decision. It proves a string set; the existing Go deadcode algorithm interprets that proof.

8. The control experiment confirms where the size win comes from

Exact local GORM setup: gorm_schema@v1.31.2, benchmark source a8f126694f03, LLGo base e4786ae092be, Go 1.26.2, LLVM 19.1.7, Linux amd64, isolated Bent BuildCache = stdlib caches.

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:

  1. keep package Meta and the existing deadcode planner as the stable Go-semantic layer;
  2. define versioned, structured LLVM feedback instead of scraping .4.opt.bc by filename;
  3. assign stable instruction-level DemandIDs so feedback survives inlining precisely;
  4. include planner, Meta, plugin, LLVM, and feedback digests in package/ThinLTO cache keys;
  5. cache proven feedback so warm links can run one planner pass and one final ThinLTO link;
  6. 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.

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