ssa: lower large array comparisons to runtime equality algorithms - #2416
ssa: lower large array comparisons to runtime equality algorithms#2416cpunion wants to merge 7 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
LLGo baseline benchmarks
Program measurements
Core language and compiler benchmarks
Compared with |
There was a problem hiding this comment.
Review: runtime-based array equality lowering
Solid change overall. Routing large-array comparisons through memequal/arrayequal (instead of scalarizing every array via extractvalue) and reusing immutable local backing storage to avoid a copy is a clear net win. The immutableLocalArrayLoadAddr analysis is sound: it is conservative in the correct direction — any store not provably preceding the load (including all cross-block stores), any pointer escape, and any non-derivation referrer all reject the reuse, so a reused address always reflects the loaded value. The snapshot/immutable tests confirm the mutation-after-load case falls back to the value-preserving temporary. callArrayEqual's StackSave/StackRestore pairing and the memequal size (SizeOf(x.Type)) are correct.
Two refinements below; nothing blocking.
Minor / non-blocking notes not placed inline:
cl/compile.go:1792instructionPrecedesre-scansblock.Instrsfrom the head on each call (O(k × block length) for an alloc with k stores). Negligible for typical array allocs (k=1), but it could track an ordering index if this ever shows up in hot compiles.- The block-local store-ordering check means an array whose alloc has stores in a different (even dominating) block bails to a full copy on the runtime path. Safe, but a missed optimization for some control-flow shapes.
Summary
LLGo currently expands every array equality operation element by element. The Go compiler only inlines small, simple arrays and uses type equality algorithms for larger values. For
GOROOT/test/fixedbugs/bug449.go, LLGo's expansion creates more than a million comparisons and causes LLVM to exhaust the runner resource budget.This PR:
runtime.memequaland other comparable arrays toruntime.arrayequal;github.com/goplus/llgopath;fixedbugs/bug449.go.The runtime algorithm also restores Go's required left-to-right short-circuit behavior for arrays containing interfaces; the previous eager element expansion could compare a later uncomparable dynamic value and panic after an earlier mismatch.
Measured result
Increasing the Windows test environment to 7 GiB was not sufficient: the old lowering still timed out after 10 minutes, with about 6.2 GiB sampled RSS and heavy paging. Before the final frontend load-elision optimization, runtime lowering alone already made the official Go 1.26.5 case pass without increasing the runner limit:
The Linux timing includes amd64 translation on an Apple Silicon host and is recorded as a functional/resource check, not a host-to-host speed comparison.
Why the frontend reuses proven-stable addresses
Array size is sufficient to choose between inline comparison and a runtime equality helper, and
CanInlineArrayEqualalready provides that gate. It is not sufficient to decide whether the runtime helper may read an operand's original address: a loaded array is a value snapshot, and the source allocation may be modified before the comparison.An A/B run of the same Go 1.26.5
fixedbugs/bug449.gocase shows why always materializing runtime operands into temporary arrays is not viable:The address-use walk is therefore deliberately conservative: it reuses storage only when all stores precede the SSA load and falls back to a snapshot temporary for heap, aliased, mutated, cross-block, or otherwise unknown operands. This avoids LLVM's aggregate-copy memory growth without changing Go value semantics.
Avoiding dead aggregate loads before LLVM
Once both operands have proven-stable addresses, the runtime equality helper does not consume their SSA aggregate values. The frontend previously emitted those whole-array loads anyway because it compiled each SSA load before reaching the comparison. LLVM eventually removed the dead loads, but constructing and optimizing them still dominated this generated case.
The final optimization omits a load only when every executable user is a non-inline array equality/inequality operation and both operands of every such comparison have reusable local addresses. Three alternating runs of prebuilt baseline and optimized compilers on the same macOS/arm64 Go 1.26.5 case produced:
This reduces the median build time by about 62% and peak RSS by about 85% on this stress case. The optimized Go 1.25.0 run also passed (
4.277 s,244.2 MiB). Mutable snapshots, small inline comparisons, heap values, parameters, and unknown uses keep their existing value-preserving lowering.Why
fixedbugs/bug449.gostresses time and memoryThe measurements above come from this generator in Go's
fixedbugs/bug449.go. It deliberately emits 1,024 progressively larger array-equality tests:Each iteration substitutes
ifor$, generating a distinct array type[i]uint8and its equality/inequality function. With 1,024 such functions, expanding large array comparisons makes compilation consume excessive time and memory. This change keeps small comparisons inline but lowers large arrays through the runtime equality path, which produces the measured reduction.Validation
go test ./ssa ./cl -run 'Test(ArrayEqualLowering|ArrayCompareReusesImmutableLocalStorage|CanInlineArrayEqual)$' -count=1TestLargeArrayEquality,TestArrayEqualityPreservesLoadedValue, andTestArrayEqualityUsesElementSemanticscl/_testgo/equalandcl/_testrt/slice2arraylowering/execution testsfixedbugs/bug449.goon macOS/arm64; Go 1.26.5 on Linux/amd64go test ./ssa -count=1go test ./test/goroot -count=1The full local
clsuite reaches an unrelated existing LLVM 19 warning inTestRunAndTestFromTestlto/abitype_runtime(+zcm/+zcztarget features); all changed and array-specificcltests pass.