Skip to content

Superseded by #2316: preserve nil checks for unused dereferences - #2256

Closed
cpunion wants to merge 1 commit into
xgo-dev:mainfrom
cpunion:codex/goroot-unused-nil-deref-20260801
Closed

Superseded by #2316: preserve nil checks for unused dereferences#2256
cpunion wants to merge 1 commit into
xgo-dev:mainfrom
cpunion:codex/goroot-unused-nil-deref-20260801

Conversation

@cpunion

@cpunion cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Superseded by #2316, which contains the same load-only commit and validation on a clean pull request.

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

Review: preserve nil checks for unused dereferences

The core change is correct and well-tested: collapsing the large-value / slice-conversion special cases into a single unconditional guard path ensures _ = *p still panics on a nil pointer, and the added compiler-IR and runtime regression tests (TestUnusedDerefEmitsNilGuard, TestUnusedNilDerefOperationsPanic) lock in the behavior. The retained skipUnusedArrayDeref carve-out is a correct, spec-mandated exception (ranging over / len of a *[N]T must not dereference the pointer). No correctness, security, or documentation-accuracy issues were found — the removed CHECK lines in cvar/varinit and the two dropped issue38496.go xfails are consistent with the new behavior.

Two non-blocking quality/performance findings are noted inline. Both concern emitting nil guards that are provably dead in specific cases; neither affects correctness.

Optional follow-up: test coverage for the collapsed branches

The new tests cover a small direct pointer and an array-element load, but not the two cases whose dedicated handling was removed — an unused deref of a large non-pointer struct, and an unused zero-length slice-to-array-conversion deref. Adding those would lock in the merged branch's behavior for exactly the paths that were deleted.

Comment thread cl/compile.go
Comment thread cl/compile.go
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

55a36cb9cd0a | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 18656 B +0.0% 319.708 ms -4.2% (better) 1.406 ms +2.1% (worse)
Linux fmtprintf 1899168 B +0.9% (worse) 2.719 s +4.6% (worse) 3.615 ms -0.8% (better)
Linux println 70680 B +3.2% (worse) 329.748 ms +8.5% (worse) 1.738 ms +0.7% (worse)
macOS cprintf 84672 B +0.0% 361.903 ms -41.1% (better) 3.981 ms +8.5% (worse)
macOS fmtprintf 1890352 B +0.1% (worse) 3.072 s -10.4% (better) 17.448 ms -17.5% (better)
macOS println 121408 B +0.2% (worse) 374.752 ms -22.4% (better) 4.478 ms -10.1% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 12.340 ns/op +0.5% (worse)
Linux BenchmarkMergeCompilerFlags 149.200 ns/op +2.8% (worse)
Linux BenchmarkMergeLinkerFlags 95.110 ns/op +0.7% (worse)
Linux BenchmarkChannelBuffered 36.950 ns/op +1.9% (worse)
Linux BenchmarkChannelHandoff 23770 ns/op -2.2% (better)
Linux BenchmarkDefer 50.970 ns/op -4.3% (better)
Linux BenchmarkDirectCall 1.758 ns/op +0.1% (worse)
Linux BenchmarkGlobalRead 1.760 ns/op -16.6% (better)
Linux BenchmarkGlobalWrite 2.809 ns/op +0.1% (worse)
Linux BenchmarkGoroutine 30333 ns/op -1.4% (better)
Linux BenchmarkInterfaceCall 8.444 ns/op -17.2% (better)
Linux BenchmarkRuntimeGetG 1.759 ns/op +0.1% (worse)
macOS BenchmarkLookupPCRandom 12.240 ns/op -39.5% (better)
macOS BenchmarkMergeCompilerFlags 126 ns/op -35.4% (better)
macOS BenchmarkMergeLinkerFlags 83.590 ns/op -39.1% (better)
macOS BenchmarkChannelBuffered 23 ns/op -17.0% (better)
macOS BenchmarkChannelHandoff 6995 ns/op +24.6% (worse)
macOS BenchmarkDefer 28.900 ns/op -16.6% (better)
macOS BenchmarkDirectCall 1.014 ns/op -24.7% (better)
macOS BenchmarkGlobalRead 1.016 ns/op -12.9% (better)
macOS BenchmarkGlobalWrite 1.015 ns/op -12.4% (better)
macOS BenchmarkGoroutine 37525 ns/op +12.9% (worse)
macOS BenchmarkInterfaceCall 4.426 ns/op -17.2% (better)
macOS BenchmarkRuntimeGetG 2.029 ns/op -13.7% (better)

Compared with 773642c9681c measured in the same runner job.

@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Handled both review findings in 50c5332b1:

  • known-non-nil local allocations and ssa:wrapnilchk operands no longer emit an unused nil guard
  • effectful array-pointer dereferences no longer emit the same guard twice
  • added the requested IR counterexamples, asserting exactly 0 and exactly 1 AssertNilDeref calls respectively

Fresh validation passes: focused compiler/fixture tests, the LLGo runtime nil-deref regression, and upstream fixedbugs/issue38496.go without xfail on Go 1.24.11, 1.25.0, and 1.26.5.

@cpunion
cpunion force-pushed the codex/goroot-unused-nil-deref-20260801 branch from 50c5332 to 3220195 Compare August 1, 2026 20:08
@cpunion cpunion changed the title cl: preserve nil checks for unused dereferences cl: preserve required nil dereference checks Aug 1, 2026
@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Expanded and rebased this PR in 3220195ef to cover the full shared nil-dereference root found in nilptr2.go:

  • aggregate and multiword loads
  • FieldAddr/IndexAddr base chains, including indexed address-of expressions
  • promoted, explicit, and bound pointer-method receivers derived from a nil outer pointer
  • the review counterexamples as LLGo runtime and compiler tests

Direct nil pointer receivers remain legal and are covered by a control test. The final implementation also removes redundant checks on already-derived addresses.

Fresh validation passes for the focused compiler suite, all new LLGo runtime cases, all affected LLVM snapshots, and upstream nilptr2.go with Go 1.24.11, 1.25.7, and 1.26.5. The PR contains no CI configuration changes.

@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

The first CI run exposed a conservative-GC interaction rather than a nil-check correctness failure: both Ubuntu LLGo matrix jobs failed the ESP32-C3 gc-runtime smoke tests (InterfaceLiveness, NestedStructPointers, and MultipleCyclesDisjoint) because the newly emitted helper calls could spill the checked pointer and keep it conservatively reachable on the non-nil path.

Fixed in a01b60e2f by:

  • branching to the panic helper only when the pointer is nil, leaving the normal path call-free
  • recognizing values already proven non-nil by a dominating == nil or != nil branch
  • adding compiler regressions for both comparison forms and both operand orders
  • synchronizing only the affected LLVM FileCheck blocks across _testgo, _testdata, and _testrt

Fresh local validation passes: full ssa, focused cl, the LLGo nil-dereference runtime suite, and both removed GOROOT xfails on Go 1.24.11, 1.25.7, and 1.26.5. The 43 updated golden files have identical non-CHECK Go source bodies. No CI or timeout configuration was changed.

@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

The prior branch-only change fixed InterfaceLiveness, but the full CI-equivalent ESP32-C3 run showed two more conservative-GC regressions: NestedStructPointers retained one object and MultipleCyclesDisjoint retained cycle B.

3528365ed completes the shared fix by proving only two narrow non-nil fact classes: a same-block pointer load following a non-nil store, and an equivalent reload dominated by an explicit nil comparison along a single-predecessor memory-unchanged path. Calls, unknown aliases, nil stores, joins, and loops stop the proof; the corresponding counterexamples assert that one guard remains.

With the exact Espressif QEMU setup used by CI, the complete gc-runtime program now reports PASS for all tests and ends in OK, including all three previously failing cases. No CI timeout or workflow setting was changed.

@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the patch-coverage failure in 836251d70. The missing lines were isolated to the new ssa.AssertNilDerefBranch builder helper; the test now covers the empty expression, constant non-nil fast path, and dynamic pointer nil-only branch, and verifies the emitted helper call, conditional branch, and unreachable panic block. Focused local coverage reports AssertNilDerefBranch at 100%.

@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Added focused fail-closed coverage in 1c5ec283c for the conservative non-nil proof boundaries: nil/visited values, pointer conversions, address equivalence, global-vs-local aliasing, distinct roots, memory side effects, and instructions without CFG ownership.

The previous Codecov report had 61 missed/partial lines in the new cl/instr.go regions; the focused coverage profile now hits 41 of those lines, in addition to the already-covered paths. Focused cl and ssa tests pass. A local full go test ./cl -coverprofile=... reached the package default 10m timeout late in TestRunAndTestFromTestrt/linkname; it was still progressing through integration cases, while CI uses the repository's 45m coverage timeout.

@cpunion

cpunion commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Codecov has now processed 1c5ec283c: patch coverage is 92.37% (351 hits, 20 misses, 9 partials), above the 88.21% target; overall reported coverage is 88.26%. The codecov/patch check is passing.

@cpunion cpunion added the go-test-compat Go standard-library and GOROOT test compatibility label Aug 9, 2026
@cpunion
cpunion force-pushed the codex/goroot-unused-nil-deref-20260801 branch 3 times, most recently from 714f0ee to a321a7e Compare August 12, 2026 02:17
@cpunion
cpunion force-pushed the codex/goroot-unused-nil-deref-20260801 branch from a321a7e to 55a36cb Compare August 12, 2026 10:52
@cpunion
cpunion marked this pull request as draft August 12, 2026 21:51
@cpunion

cpunion commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Full GOROOT regression passed at 9fff66068: workflow run 31652883292.

Platform Go Shards Selected Classified pass Failed Host skip
Darwin/arm64 1.24.11 4 1,023 1,013 0 10
Darwin/arm64 1.26.5 4 1,050 1,047 0 3
Linux/amd64 1.24.11 4 1,027 1,023 0 4
Linux/amd64 1.26.5 4 1,054 1,053 0 1
Total 16 4,154 4,136 0 18

All 16 shard jobs and the summary job passed. The first full run found one stale expectation: nilptr.go now passes on Linux with both supported Go versions, so commit 9fff66068 removes only that xfail. The full matrix was then rerun at that exact commit and passed completely.

@cpunion
cpunion marked this pull request as ready for review August 13, 2026 04:38

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

Review: nil-pointer-dereference check rework

Overall this is a carefully written change. The new dataflow analysis is conservative by default — unknown aliases, calls, joins, and loops all stop every "known non-nil" proof — which is the right posture for a memory-safety-critical elision. A dedicated soundness pass over isKnownNonNilAt, mayAliasMemoryAddresses, localStoredValue, memoryUnchangedBetween, and equivalentPointerValues found no unsound elision paths: distinctness is only ever claimed between two different Alloc/Global roots, load equivalence requires both equivalent addresses and unchanged memory, and unsafe-pointer conversions correctly never earn a local root. The pre-boxed memoryError and the branch-based PanicNilDeref (avoiding a spill that could retain a dead object under a conservative collector) are both well-motivated.

Findings below are non-blocking. Inline comments carry the concrete diff-line items.

Summary of findings

  • Dead production code: isSafeDerivedAddress is referenced only by a test. (inline)
  • Doc drift: isKnownNonNilAt's "only two classes of facts" comment omits address-based facts. (inline)
  • Compile-time perf: linear instructionIndex rescans and a per-function O(N) nilDerefFacts scan can compound to super-linear cost on large functions. (inline)
  • Implicit coupling between isNilDerefCoveredByAddressEvaluation (returns true unconditionally for IndexAddr) and the isArrayPointerGoType-guarded base check in emitNilDerefBaseCheck — the two must stay in sync but are enforced in different functions. (inline)
  • Asymmetric guard: emitNilDerefBaseCheck skips methodReceiverBases FieldAddr/IndexAddr, but assertNilDerefBase's FieldAddr arm has no equivalent guard. (inline)

Minor (no reliable inline anchor)

  • ssa/memory.go: AssertNilDerefBranch duplicates the early-return + nil-compare preamble of AssertNilDeref (~lines 340-347 vs 355-362). Consider extracting a small nilCompare helper so the two variants don't drift.
  • cl/instr.go equivalentPointerValues: on revisiting a seen pair it returns true (assumes equivalence). SSA pointer expressions form a DAG so this branch should be unreachable, but true fails unsafe if ever hit (asserts equivalence of possibly-distinct values → could suppress a needed check). A defensive false would fail safe; worth a one-line justification either way.

Comment thread cl/instr.go Outdated
return false
}

func isSafeDerivedAddress(v ssa.Value) bool {

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.

Dead production code. isSafeDerivedAddress has no caller in cl/compile.go or cl/instr.go — its only reference is cl/builtin_test.go:419. The production deref paths use isNilDerefCoveredByAddressEvaluation, isKnownNonNilAt, and localPointerRoot instead, and the Alloc/Global-root check here is already expressed inline in definitelyDistinctPointerRoots/mayAliasMemoryAddresses. Recommend deleting the function (and its test assertion), or wiring it into a real caller if it was meant to replace one of those inline checks.

Comment thread cl/instr.go Outdated
return false
}

// isKnownNonNilAt proves only two deliberately small classes of facts:

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.

Doc drift. The comment says the function "proves only two deliberately small classes of facts," but the very first line returns true for a third, address-based class: isKnownNonNilAddr (fresh Alloc/Global/non-empty SliceToArrayPointer and their derivations) and isLocallyDerivedNonNil (FieldAddr/IndexAddr rooted at a known-non-nil base) — neither involves a load or a store. Suggest broadening the comment (e.g. "plus values whose address is inherently non-nil, such as allocations and globals") or dropping the "only two" framing.

Comment thread cl/instr.go Outdated
return xok && yok && xv == yv
}

func instructionIndex(block *ssa.BasicBlock, target ssa.Instruction) int {

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.

Compile-time hot path. instructionIndex linearly scans block.Instrs (O(B)) on every call, and it's invoked from instructionDominates (once per fact in the hasDominatingNilDerefFact scan), localStoredValue (which then re-loops the block), and memoryUnchangedBetween (twice per call). Combined with the per-function O(N) nilDerefFacts scan, worst-case cost is super-linear (≈O(N²·B)) for large generated functions. Highest-leverage fix: build a per-block map[ssa.Instruction]int once per function and reuse it in these three callers — removes the B factor everywhere in a small, self-contained change. Correctness is fine today; this is purely about scaling.

Comment thread cl/instr.go Outdated
return p.hasDominatingNilDerefFact(v, instr)
}

func (p *context) hasDominatingNilDerefFact(v ssa.Value, instr ssa.Instruction) bool {

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.

hasDominatingNilDerefFact linearly scans the entire p.nilDerefFacts slice on every query, and recordNilDerefFact appends one entry per emitted check, so this is ≈O(N²) fact comparisons per function (each comparison also runs instructionDominates + equivalentPointerValues). The slice is correctly reset/restored per function (compile.go:697, saved at 655/672), so it doesn't leak across the build — good. But for very large functions consider keying facts by block or by root value to bound this.

Comment thread cl/compile.go Outdated
return !isKnownNonNilAt(v.X, v)
}

func isNilDerefCoveredByAddressEvaluation(v ssa.Value) bool {

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.

Implicit coupling. This returns true unconditionally for *ssa.IndexAddr (asserting the base is checked elsewhere), but the actual pointer-to-array base check lives in emitNilDerefBaseCheck's IndexAddr arm and only fires when isArrayPointerGoType(addr.X.Type()) is true. The two predicates are consistent today, but they're enforced in different functions — a future change to either could silently drop a required base check. Worth a cross-reference comment tying this true to the isArrayPointerGoType guard, or unifying the predicate.

Comment thread cl/compile.go Outdated
}

func (p *context) assertNilDerefBase(b llssa.Builder, addr ssa.Value) {
func (p *context) assertNilDerefBase(b llssa.Builder, addr ssa.Value, instr ssa.Instruction) {

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.

Asymmetric methodReceiverBases guard. emitNilDerefBaseCheck early-returns for FieldAddr/IndexAddr bases found in p.methodReceiverBases (instr.go), but this assertNilDerefBase FieldAddr arm has no equivalent guard and will emit a NilDerefCheck + record a fact. If both routines can be reached for the same receiver-base FieldAddr, that's a redundant check the other path was written to avoid. Please confirm the two entry points are mutually exclusive for method-receiver bases; if not, add the same guard here.

@cpunion
cpunion force-pushed the codex/goroot-unused-nil-deref-20260801 branch from 83db0cc to 1373066 Compare August 13, 2026 12:16
@cpunion cpunion changed the title cl: preserve required nil dereference checks cl: preserve nil checks for unused dereferences Aug 13, 2026
@cpunion
cpunion marked this pull request as draft August 13, 2026 12:25
@cpunion cpunion changed the title cl: preserve nil checks for unused dereferences Superseded by #2316: preserve nil checks for unused dereferences Aug 13, 2026
@cpunion cpunion closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go-test-compat Go standard-library and GOROOT test compatibility

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant