Monomorphization: key specializations on instantiation identity, not a resolved type - #50
Conversation
|
This change is part of the following stack:
Change managed by git-spice. |
b0245dc to
5560332
Compare
5560332 to
944d117
Compare
888e37f to
0684978
Compare
groundlar
left a comment
There was a problem hiding this comment.
Reviewed against the design docs, and verified the fix differentially rather than by inspection: built dacbbc2 in an isolated clone and ran the same 12-program probe set against both commits. One real miscompile fixed (h = \a, b -> a + b at h(1,2) + h(1,2) + h(1,5)), zero regressions in the differential, full ./ci.sh green locally.
The design is right, and the two-directed-reads-kept-apart argument — plus the "why not saturate" paragraph, which is the part most people would get wrong — is worth more than the code. Comments below are one structural concern, one follow-up bug that is not yours, and three small documentation asks.
Context from comparing this against other monomorphizersNot a blocker — this is the right fix. Two things I turned up that seem worth recording in the design doc, plus one concrete suggestion. 1. The resolved-type key was a fossil of the pre-
Post-coalesce, a resolved type is all you can see: 2. The key is already a function of the post-emission graph. I ran
So the discriminating information is in the graph the instant emission finishes, and the resolved type collides exactly as the commit message describes. The pin transports it across polarity; it doesn't discover it. That suggests a cheap test, and it closes the verification gap I raised on 3. Where Cambra sits relative to everyone else — the framing I'd suggest for that design-doc paragraph. rustc keys on Cambra has no For completeness on what is not the reason: poly-calls-poly doesn't block a phase split. Discovering the set of instantiations is a reachability fixpoint in every monomorphizer (rustc's collector, Swift's pass-manager worklist), and the pre- Prior art worth a look: Lutze, Schuster & Brachthäuser, The Simple Essence of Monomorphization (OOPSLA 2025) — monomorphization via a flow analysis explicitly inspired by algebraic subtyping (Dolan & Mycroft), i.e. this type system. Closest prior art I found, including on where monomorphization stops being possible (polymorphic recursion, which they detect as cyclic flow). |
Added
Once we have #51, this isn't entirely true. I added a section discussing
Added this discussion to the design |
e18f60f to
066d40f
Compare
…a resolved type `specialize_use`'s memo compared two values produced by two different procedures: a candidate's key was `resolve_var_type(&use_expr.ty)` taken *before* its pin, while an entry's was `clone.ty` — the clone's type *after* the pin and a full re-entrant coalesce. Neither is a faithful identity for what a clone gets specialized to, and the mismatch broke the memo in both directions. ## False hits, i.e. a miscompile A resolved type is a polarity-correct *rendering*: a domain resolves from upper bounds — what the definition body demands — so a position the body never touches is narrowed away, and an argument's refinement, which arrives as a *lower* bound of that domain, is invisible unless `compact_type`'s opposite-polarity fallback happens to fire at that exact position. The clone's interior reads its parameter at a *positive* position and sees precisely those refinements. So two uses differing only in a key-invisible position shared a clone whose interior was resolved against the first use's argument: ``` h = \a, b -> a + b h(1, 2) + h(1, 5) ``` Both uses keyed on `((1, Int) ⇒ Int)` — element 0's refinement reached the key, element 1's did not — and the shared clone typed `.1` as `2`. The post-inline consistency wall then panicked with `Type mismatch for Apply: expected 5, found 2`. A difference in the *first* argument keyed apart and compiled fine, so the failure was confined to differences the rendering could not see. ## A write-only table For any definition whose clone type gains a refinement across the pin, no candidate key could ever equal a stored one, so every call site minted its own clone — including character-identical ones — and the table accumulated several entries under one key. The comment justifying the stored key claimed a later same-typed use would resolve through the first pin's extended chains; it cannot, because every use instantiates its own fresh variables, which the first clone's pin never touches. ## `SpecKey` `SpecKey` replaces the resolved type. It is the pair of **directed reads** of the use's instantiation, kept apart: the *positive* read is the stamping view (a domain from the definition's demands), the *negative* read is the clone's view (a domain from the argument that flowed in, a codomain from the consumer's demand). The negative read is the load-bearing half — it is the polarity the clone's interior reads its parameter at — and the pair is exhaustive, because use-specific information enters an instantiation through exactly two channels: an `arg <: domain` edge and a `codomain <: demand` edge. Both reads stay *directed*, which matters: following both bound lists at every variable instead walks out of one use's cone and into every other (two calls' arguments meet at the shared variable of an operator's scheme), so every use keys on the whole program's literals and they all compare equal. Keeping the two views separate matters too — a merged view forgets which direction a contribution came from, and the pin the key stands in for is direction-sensitive. Within a read, merging is always union: a key that narrows can only under-split, and under-splitting is a miscompile while over-splitting is a wasted clone. An under-determined position is one canonical empty view rather than a fresh `Infer` placeholder, so two unexercised uses can now share. Both sides of the comparison are now taken by one procedure at one point in the pin's lifecycle, and an entry stores the key of the use that minted it, with a `debug_assert` that no two entries share a key. `poly_used_directly_and_through_wrapper_shares_specializations` drops from five specializations to four: the direct `f(1)` and the `f(y)` reached inside `g`'s `Int` clone instantiate `f` identically, so they now group onto one specialization — which is what the test's name and its "the memo is per frame, not per demanding region" comment always asked for. Consequences carried along: `ReadPurpose::SpecializationKey` becomes `Instantiation` (that resolution no longer keys anything — it seeds the clone's channel-domain pairings and blames a resolution failure, so its refinement exclusion is now justified by *those* consumers being refinement-insensitive rather than by the false claim that a refinement the read could not see can only cause a miss); `HistoryKind` derives `Ord` so a kind can key a `BTreeMap`; and a `specialize_use` doc block that had detached from its function is reattached. The remaining imprecision is documented on `SpecializeFrame::specs` and in the new "Keying a specialization" design section: the key summarizes the pin's *input*, so two uses differing only in a position the clone never reads still key apart. Closing that means keying on the pin's *output* — the finished clone, deduped structurally — which cannot be a lookup, only a build-then-dedupe. The two compose, so nothing here has to be undone to get there. ## Retiring "witness" The second commit is a vocabulary sweep with no behaviour change. `design/type-inference.md` defined a *witness* as "the term for a refinement in its role as a black box to the subtyping lattice", and the word had spread through the solver, the design docs, and four identifiers. The distinction does not earn a word. We already have `base` and `refinement`; "the refinement as the lattice sees it" is a fact about the lattice, not a second kind of object. Worse, the word names precisely a **limitation** — that the lattice accumulates refinements and matches them by identity instead of reasoning about what they imply — so having a term for it quietly asserts that the limitation is permanent. So `witness` → `refinement` throughout (185 occurrences, 26 files), including `join_witnesses` → `join_refinements` and `type_carries_witness` → `type_carries_refinement` (`channelize`), `input_witnesses` → `input_refinements` (`coalesce`), the flag on `types_agree_modulo_unread`, and the `#### Refinements as witness sets` section, which becomes `#### Refinements on the lattice` with an opening sentence that states the opacity as a property of the lattice rather than as the definition of a term. ## Testing Four end-to-end regression cases for the miscompile plus its controls (a difference in a key-visible position, and identical calls that must still share), two unit tests for the memo's two halves, and unit tests for `SpecKey` itself. Full `./ci.sh` passes. ## A function's kind is part of its identity `KeyView.fun` is keyed by `KindMerge`, the way `KeyView.history` is keyed by `HistoryKind` and for the same reason: `𝐷 ⇒ 𝑉` and `𝐷 ⤇ 𝑉` are one shape that compiles to different code, so a clone pinned at `⤇` — whose body iterates the domain — must not serve a `⇒` use that never supplies one. A union at one position keeps the two apart rather than letting one shadow the other. The kind is read through `KindMerge::of`, the same resolved-from-bounds view compaction takes, rather than off the `FunKind` itself. That distinction is the whole of it: an inferred kind is a *variable*, freshly minted per instantiation, so keying on its identity would give every use its own key and retire sharing altogether. Its bounds are the answer, and reading them mid-solve is the bargain every other field in the key already makes — the guarantee is agreement with the pin, not omniscience about the graph.
Review follow-ups on `SpecKey`, plus one negative result worth recording. ## A key is not a function of the post-emission graph The memo's comparison is self-consistent per use — each key is taken before that use's own pin — but the two keys in a comparison are not taken at the same *instant*: an entry's was taken before the pin of the use that minted it, a candidate's later, with every intervening pin already in the graph. The tempting strengthening is that a pin only *transports* use-specific information across polarity and never creates it, so no other use's pin could move a key. That would make the key a function of the post-emission graph, and it would make the cross-walk comparison checkable rather than argued. It is false. A pin does not only transport; for a *nested* use it deposits the consumer's demand. In `f(f(3))`, `coalesce_node`'s `Apply` arm takes function before argument, so the outer use specializes first and its pin drives the outer clone's domain concrete — and that domain *is* the demand on the inner call's result, which reaches the inner use through the `codomain <: demand` edge, one of the two channels the negative read follows by design. The inner use is keyed against a demand that did not exist at end of emission. Whether that deposit is *visible to the key* depends on how much structure the demand carries. Where it resolves to a bare base the two reads agree, which is why snapshotting every `Var` node's key on entry to `coalesce_pass` and comparing against the key `specialize_use` takes later passes on this branch. It stops passing as soon as a demand carries structure a key records — once an operator's effect on types is itself a type, the same program splits, the inner use's negative read gaining exactly the layer the outer pin deposited. The positive read does not move, and no key moves for any other reason. So key equality is walk-order sensitive. The residue is **over-splitting** — a use keyed against a thinner demand does not match an entry keyed against a fatter one, costing a redundant clone — and it is not symmetric, since a thin key and a fat key are unequal and a use carrying a real demand cannot be served by an entry that never saw one. The design section now carries the counterexample rather than the invariant, and `SpecializeFrame::specs` says the same thing at the memo itself. ## Documentation - `compact_go` gains the reciprocal half of its coupling to `key_go`. The two walk `Type` in lockstep — the same polarity flip on a `Fun` domain, the same no-flip on `History` children, the same `then(edge_subst, subst_acc)` composition at a bound edge, the same binder shadowing, the same `(uid, pol)` cycle guard — and that agreement *is* the soundness argument for a key: a bound the key cannot see is one the clone's own resolution cannot see either. Nothing enforces it, so the coupling now reads from both ends rather than only from `AtomKey::from_type`. - The `Type::Hole` arm of `key_go` says why the `Hole`/under-determined-`Infer` collision cannot merge two uses that actually differ: `normalize_annotation` turns a `Hole` into a fresh `Type::Infer` at emit, so no `Hole` reaches a use's instantiation type in the first place. - `SpecializeFrame::specs` drops "a binding has a handful" for what actually bounds it. Each comparison is a deep structural `SpecKey` walk, so the scan is quadratic in a binding's specialization count — and that count is bounded per *argument tuple*, not per distinct type, so a definition called with a fresh literal tuple at every site grows `specs` with call sites. What holds it down today is `inline` beta-reducing scalar UDFs, leaving the collection-producing ones. If it ever bites, the scan is what has to change, not the key. - `mut_elim`'s `collect_writes` still said "a witness acquired by erasure" — a miss in the vocabulary sweep, in the same breath as its updated citation. - The Σ-type material in `type-inference.md` keeps *witness* in its standard sense — the inhabitant that picks which summand you are in — and now says so, so a future sweep for the retired refinement sense leaves it alone. ## Keying a specialization Beyond the negative result above, two additions to the design section: - **The key is only expressible in-walk.** A monomorphizer running *after* coalesce would have no choice but to key on a resolved type, because by then a resolved type is all there is: `expr.ty` has been overwritten in place and the bound graph it was resolved from is gone. Specializing inside the walk is what leaves a use's instantiation var-laden with its bounds live at the moment the key is taken, which makes `SpecKey` an argument *for* in-walk specialization rather than a cost of it. - **Why other monomorphizers key on a finished value.** rustc keys an instance on its definition plus its generic arguments, C++ on the template-argument list, Swift on a substitution map, MLton on the type-argument list in a single post-inference pass. Every one keys on a finished value, and can, because their generic bodies are already typed and instantiation is substitution. Cambra has no `Type::ForAll` and never types a generic body at all, so monomorphization here *is* the act of typing the body — C++'s category, the one mainstream compiler where instantiation genuinely re-runs semantic analysis — and that is the real reason the key has to be read off a live graph. With a pointer to the closest prior art: Lutze, Schuster & Brachthäuser, *The Simple Essence of Monomorphization* (OOPSLA 2025), monomorphization as a flow analysis over an algebraic-subtyping system.
066d40f to
c825cef
Compare
specialize_use's memo compared two values produced by two different procedures: a candidate's key wasresolve_var_type(&use_expr.ty)taken before its pin, while an entry's wasclone.ty— the clone's type after the pin and a full re-entrant coalesce. Neither is a faithful identity for what a clone gets specialized to, and the mismatch broke the memo in both directions.False hits, i.e. a miscompile
A resolved type is a polarity-correct rendering: a domain resolves from upper bounds — what the definition body demands — so a position the body never touches is narrowed away, and an argument's refinement, which arrives as a lower bound of that domain, is invisible unless
compact_type's opposite-polarity fallback happens to fire at that exact position. The clone's interior reads its parameter at a positive position and sees precisely those refinements. So two uses differing only in a key-invisible position shared a clone whose interior was resolved against the first use's argument:Both uses keyed on
((1, Int) ⇒ Int)— element 0's refinement reached the key, element 1's did not — and the shared clone typed.1as2. The post-inline consistency wall then panicked withType mismatch for Apply: expected 5, found 2. A difference in the first argument keyed apart and compiled fine, so the failure was confined to differences the rendering could not see.A write-only table
For any definition whose clone type gains a refinement across the pin, no candidate key could ever equal a stored one, so every call site minted its own clone — including character-identical ones — and the table accumulated several entries under one key. The comment justifying the stored key claimed a later same-typed use would resolve through the first pin's extended chains; it cannot, because every use instantiates its own fresh variables, which the first clone's pin never touches.
SpecKeySpecKeyreplaces the resolved type. It is the pair of directed reads of the use's instantiation, kept apart: the positive read is the stamping view (a domain from the definition's demands), the negative read is the clone's view (a domain from the argument that flowed in, a codomain from the consumer's demand). The negative read is the load-bearing half — it is the polarity the clone's interior reads its parameter at — and the pair is exhaustive, because use-specific information enters an instantiation through exactly two channels: anarg <: domainedge and acodomain <: demandedge.Both reads stay directed, which matters: following both bound lists at every variable instead walks out of one use's cone and into every other (two calls' arguments meet at the shared variable of an operator's scheme), so every use keys on the whole program's literals and they all compare equal. Keeping the two views separate matters too — a merged view forgets which direction a contribution came from, and the pin the key stands in for is direction-sensitive. Within a read, merging is always union: a key that narrows can only under-split, and under-splitting is a miscompile while over-splitting is a wasted clone. An under-determined position is one canonical empty view rather than a fresh
Inferplaceholder, so two unexercised uses can now share.Both sides of the comparison are now taken by one procedure at one point in the pin's lifecycle, and an entry stores the key of the use that minted it, with a
debug_assertthat no two entries share a key.poly_used_directly_and_through_wrapper_shares_specializationsdrops from five specializations to four: the directf(1)and thef(y)reached insideg'sIntclone instantiatefidentically, so they now group onto one specialization — which is what the test's name and its "the memo is per frame, not per demanding region" comment always asked for.Consequences carried along:
ReadPurpose::SpecializationKeybecomesInstantiation(that resolution no longer keys anything — it seeds the clone's channel-domain pairings and blames a resolution failure, so its refinement exclusion is now justified by those consumers being refinement-insensitive rather than by the false claim that a refinement the read could not see can only cause a miss);HistoryKindderivesOrdso a kind can key aBTreeMap; and aspecialize_usedoc block that had detached from its function is reattached.The remaining imprecision is documented on
SpecializeFrame::specsand in the new "Keying a specialization" design section: the key summarizes the pin's input, so two uses differing only in a position the clone never reads still key apart. Closing that means keying on the pin's output — the finished clone, deduped structurally — which cannot be a lookup, only a build-then-dedupe. The two compose, so nothing here has to be undone to get there.Retiring "witness"
The second commit is a vocabulary sweep with no behaviour change.
design/type-inference.mddefined a witness as "the term for a refinement in its role as a black box to the subtyping lattice", and the word had spread through the solver, the design docs, and four identifiers.The distinction does not earn a word. We already have
baseandrefinement; "the refinement as the lattice sees it" is a fact about the lattice, not a second kind of object. Worse, the word names precisely a limitation — that the lattice accumulates refinements and matches them by identity instead of reasoning about what they imply — so having a term for it quietly asserts that the limitation is permanent.So
witness→refinementthroughout (185 occurrences, 26 files), includingjoin_witnesses→join_refinementsandtype_carries_witness→type_carries_refinement(channelize),input_witnesses→input_refinements(coalesce), the flag ontypes_agree_modulo_unread, and the#### Refinements as witness setssection, which becomes#### Refinements on the latticewith an opening sentence that states the opacity as a property of the lattice rather than as the definition of a term.Testing
Four end-to-end regression cases for the miscompile plus its controls (a difference in a key-visible position, and identical calls that must still share), two unit tests for the memo's two halves, and unit tests for
SpecKeyitself. Full./ci.shpasses.A function's kind is part of its identity
KeyView.funis keyed byKindMerge, the wayKeyView.historyis keyed byHistoryKindand for the same reason:𝐷 ⇒ 𝑉and𝐷 ⤇ 𝑉are one shape that compiles to different code, so a clone pinned at⤇— whose body iterates the domain — must not serve a⇒use that never supplies one. A union at one position keeps the two apart rather than letting one shadow the other.The kind is read through
KindMerge::of, the same resolved-from-bounds view compaction takes, rather than off theFunKinditself. That distinction is the whole of it: an inferred kind is a variable, freshly minted per instantiation, so keying on its identity would give every use its own key and retire sharing altogether. Its bounds are the answer, and reading them mid-solve is the bargain every other field in the key already makes — the guarantee is agreement with the pin, not omniscience about the graph.