ssa: make recursive type conversion order-independent - #2280
Conversation
There was a problem hiding this comment.
Review summary
The refactor makes named-type conversion independent of traversal order for mutually recursive types by deciding conversion need up front via a namedNeedsTypeConversion pre-pass — a sound approach, and the tests cover the key invariants (order-independence, recursive generics, per-shape detection, identity preservation). No blocking correctness defects. A few maintainability and performance points below; inline comments cover the concrete diff-line findings.
Cross-cutting notes (not tied to a single line):
-
Two-switch synchronization risk.
needsTypeConversion(ssa/type_cvt.go:245) re-implements the traversal thatcvtType(ssa/type_cvt.go:85) performs, rather than deriving from it. Thedefault: panicguards against newtypes.Typekinds, but not against a changed traversal within an existing case (e.g. ifcvtStructlater converted tags, orcvtNamedstarted converting method signatures). The InC guard and theIsClosureshort-circuit are each duplicated across both switches and must stay in lock-step. Consider a comment pointing maintainers atcvtTypeas the source of truth, and/or factoring the shared predicates (InC check, closure check) into helpers used by both. -
Concurrency.
p.typsand the newp.cvtneedare plain maps mutated without synchronization (ssa/type_cvt.go:186,210,213,236,296), whiletypbgis async.Map. If anyProgramis ever driven concurrently throughType/FuncDecl/Closure, these writes race (concurrent-map-write panic). Worth confirming the single-threaded-per-Programinvariant giventypbgalready hedges for concurrency.
| p.typs[unsafe.Pointer(t)] = unsafe.Pointer(named) | ||
| tund, _ := p.cvtType(t.Underlying()) | ||
| origin.SetUnderlying(tund) | ||
| p.typs[unsafe.Pointer(t)] = unsafe.Pointer(named) |
There was a problem hiding this comment.
This second p.typs[...] = unsafe.Pointer(named) is a no-op. named is not reassigned between line 210 and here — only origin.SetUnderlying(tund) runs, which mutates the object named points to, not the map entry. Any recursive back-edge that reached t during cvtType(t.Underlying()) hit the fast path in cvtNamed and read the value already installed at line 210; it never rewrites this entry. The assignment can be removed. It's also slightly misleading: the comment above describes a single "publish before descending," but the code publishes twice.
There was a problem hiding this comment.
Addressed in ef145c2: removed the redundant second typs assignment.
| // the cycle observes the same conversion decision. | ||
| p.typs[unsafe.Pointer(t)] = unsafe.Pointer(named) | ||
| tund, _ := p.cvtType(t.Underlying()) | ||
| origin.SetUnderlying(tund) |
There was a problem hiding this comment.
The origin vs named split here is load-bearing and non-obvious: for a generic instantiation, named = Instantiate(origin, t), and SetUnderlying is called on origin, not named. This works because go/types materializes the instance's underlying lazily by substituting type args into the origin's underlying — so setting the origin's underlying before named.Underlying() is first forced reflects into the returned instance. Since this is exactly what TestRecursiveGenericNamedTypeConversion guards, a one-line comment explaining why SetUnderlying targets origin (and that it must precede any named.Underlying() call) would help future readers.
There was a problem hiding this comment.
Addressed in ef145c2: added a comment documenting lazy generic-instance underlying materialization and why SetUnderlying must target origin before named.Underlying is observed.
| // A complete negative query proves that every named type it reached is | ||
| // also conversion-free. Negative results observed only on a cycle | ||
| // back-edge are never stored here. | ||
| for named := range query.seen { |
There was a problem hiding this comment.
Negative results are only cached when the whole top-level query returns false (this sweep). When the top-level query returns true, conversion-free subgraphs visited along the way are never recorded as conversionNotNeeded — only the nodes on the true-returning path get conversionNeeded (line 296). So a large conversion-free named subgraph reached under a conversion-needing root stays conversionUnknown and is re-walked from scratch on every later independent query that touches it. In conversion-heavy packages (signatures/closures are common and always force conversion), this turns one-time work into repeated full-graph walks. Consider also caching conversionNotNeeded for a node whose completed underlying walk returned false (i.e. not the visiting back-edge short-circuit at line 289), which safely memoizes those subtrees.
There was a problem hiding this comment.
I am keeping the conservative negative-cache rule. Caching a node merely because its completed underlying walk returned false is unsafe when that result depends on a cycle back-edge. For B -> A -> B where B later exposes a function field, A completes false during the B-rooted query but must ultimately be converted; caching A as not-needed would recreate the original order-dependent bug. The regression that starts conversion from B covers this case. Negative states are therefore published only when the entire top-level query is negative.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
LLGo baseline benchmarks
Program measurements
Core language and compiler benchmarks
Compared with |
51a6db7 to
ef145c2
Compare
|
Review follow-up in ef145c2:
Full ssa tests, focused repeated tests, race, and coverage passed locally. The conservative negative-cache behavior is intentionally retained because a completed false subtree can still depend on an unresolved cycle back-edge. |
ef145c2 to
880b903
Compare
|
@xgopilot please review the current PR head (880b903) from scratch. Focus on recursive type-conversion correctness, placeholder/cache state transitions, concurrent access safety, order independence and deterministic results, pointer/struct recursion, and regression-test completeness. Please ignore findings that only apply to earlier revisions. |
Review completeReviewed PR head Verdict: No correctness bugs found. The order-independence fix is well-reasoned and sound. Verified:
Minor findings (non-blocking):
Not verified: |
There was a problem hiding this comment.
Review: recursive type conversion, order-independent
Reviewed PR head 880b903 from scratch against base 6670dae, focused on the areas requested. No correctness bugs found. The change is well-reasoned and the approach is sound. Findings below are minor (a maintenance risk and test-coverage gaps).
What the change does (and why it's correct)
The old cvtNamed installed the original type as the recursion placeholder and only swapped in the converted type if the underlying descent reported cvt==true. For mutually recursive named types this made the result depend on which cycle member was converted first. The new flow fixes that cleanly:
namedNeedsTypeConversion(t)decides up-front, over the whole reachable graph, whether conversion is required — memoized incvtneed, so the decision is independent of entry point (true order independence).- If not needed → cache the original identity and return
(t, false)(conversion-free recursive graphs keep their identity — verified byTestRecursiveNamedTypesWithoutConversionKeepTheirIdentity). - If needed → build the converted
named/origin, publish it as the placeholder before descending, thenorigin.SetUnderlying(tund). Every back-edge now observes the same converted placeholder.
I verified the following:
- Order independence / determinism — the conversion decision is computed for the full graph and cached in
cvtneedbefore any placeholder is published;TestNamedTypeConversionIsIndependentOfTraversalOrdercovers A-first vs B-first over a struct+pointer cycle containing a closure. - Placeholder / cache state transitions — the memoization asymmetry is correct:
conversionNeededis cached inline during traversal (a positive result is always sound), whileconversionNotNeededis cached only when the complete top-level query is negative (a negative observed on a cycle back-edge alone is never stored). This correctly avoids caching a "false" that was contaminated by a premature back-edge return. cvtType/needsTypeConversionlock-step — audited all 15 type kinds; each predicate matches whatcvtTypewould set forcvt, including the top-level opaque-type check,IsClosureshort-circuits,Signature/Unionalways-true,Interface(params + results of each explicit method, plus embeddeds), andTypeParam/Alias. Because they match, thenamedNeedsTypeConversion==truepath can never publish a converted placeholder whose underlying descent then returnscvt==false(which would have minted a spurious duplicate identity).- Pointer/struct recursion — placeholder-before-descent makes back-edges resolve to the converted type; generic instances resolve
named.Underlying()lazily fromorigin, so a recursiveMy[T]back-edge lands on the convertedMy[args]instance (TestRecursiveGenericNamedTypeConversionassertsnext == raw). - Concurrency safety —
cvtneedis a plain map, consistent withtypsalready being a plain map; the doc comment states both are owned by the single lowering goroutine whiletypbgstays async.Mapbecause it is populated during concurrent preloading before lowering. This introduces no new shared-mutable state beyond the existing model, so no new data race — provided the single-owner invariant holds during lowering.
Findings (minor)
1. Lock-step is a silent maintenance hazard (informational). needsTypeConversion must stay exactly in step with cvtType; the code documents this but nothing enforces it. If a future edit adds a convertible case to cvtType (or changes cvtUnion/cvtClosure to sometimes be a no-op) without updating needsTypeConversion, the two can silently disagree — a true predicate with a no-op descent would mint a spurious duplicate named-type identity, and a false predicate with a real conversion would skip conversion. Neither would panic. Consider a small assertion path in tests, or a shared traversal, to guard against drift. (Inline note added.)
2. Test-coverage gaps (minor). Current tests are strong for pointer/struct cycles, generic recursion, per-kind requirement shapes, and identity preservation. Not directly covered:
- A named type that recurses through an interface method (rather than a pointer/struct field) — exercises the
Interfacebranch ofneedsTypeConversioninside a cycle. - An explicit assertion that the
cvtneedmemoization is reused across two separatecvtNamed/namedNeedsTypeConversioncalls (the perf/determinism claim of the PR), rather than only implied. - The
InCskip path (shouldConvertNamed==false) interacting with a recursive graph.
These are optional hardening, not blockers.
Verification note
I could not execute go test ./ssa in this sandbox: the ssa package transitively requires the LLVM C headers (llvm-c/Core.h, PassBuilder.h) via github.com/xgo-dev/llvm, which are not installed here, so the package does not build. The type-conversion logic itself is pure go/types and was reviewed by hand. Please rely on the PR's own go test ./ssa -count=1 and -race runs for execution evidence.
| // needsTypeConversion reports whether cvtType changes any part of typ. The | ||
| // recursion set deliberately belongs to one query: a cycle back-edge alone is | ||
| // not a conversion, but another member of that cycle may still require one. | ||
| // Keep its traversal and conversion predicates in lock-step with cvtType. |
There was a problem hiding this comment.
Maintenance hazard: needsTypeConversion must stay exactly in lock-step with cvtType, but nothing enforces it. If a future change adds a convertible case to cvtType (or makes cvtUnion/cvtClosure a no-op in some case) without mirroring it here, the two silently diverge:
- predicate
true+ no-op descent →cvtNamedpublishes a converted placeholder whose underlying equals the original, minting a spurious duplicate named-type identity; - predicate
false+ real conversion → conversion is skipped entirely.
Neither case panics, so drift would be hard to catch. Consider a shared traversal or a test that asserts needsTypeConversion(x) == (cvtType(x) changed) across a representative type corpus to guard against this.
Summary
Make Go-to-raw type conversion deterministic for mutually recursive named types.
This is an SSA correctness prerequisite for isolated backend Programs and does not depend on package-parallel build code.
Rebase boundary
6670dae38(latestxgo-dev/llgo:main)880b90308Validation
go test ./ssa -count=1-race