-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[ty] Fix non-determinism in ConstraintSet.specialize_constrained
#21744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Diagnostic diff on typing conformance testsNo changes detected when running ty on typing conformance tests ✅ |
|
017fe62 to
9e5ea4b
Compare
| reveal_type(generic_context(unbounded).specialize_constrained(ConstraintSet.range(bool, T, bool) & ConstraintSet.range(Never, T, str))) | ||
|
|
||
| # revealed: ty_extensions.Specialization[T@unbounded = int] | ||
| # revealed: ty_extensions.Specialization[T@unbounded = bool] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this test (and the other changed one below) were other possible points of nondeterminism, since the result could depend on whether T ≤ bool was kept in the BDD or simplified away as described in the PR body.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I understand why we solve this to bool. The constraints are T <= int | T <= bool, which seems equivalent to T <= int. Why do we prefer solving to bool when there's the additional (seemingly redundant) constraint present?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is because of our current heuristic for handling multiple paths in a BDD. T ≤ int ∨ T ≤ bool ends up looking like this now:
(T ≤ int)
┡━₁ (T ≤ bool)
│ ┡━₁ true
│ └─₀ true
└─₀ (T ≤ bool)
┡━₁ impossible
└─₀ false
There are two paths to the true terminal, one representing T ≤ bool and one representing bool < T ≤ int. T = bool and T = int are the largest types that satisfy each respective path. Our current heuristic says that if there's a type that satisfies all paths, we choose that. (That is, if the intersection of the specializations is non-empty, use it.)
I'm very much open to changing that heuristic, but think that should be follow-on work. I'll mark this as a TODO that we'd rather produce T = int here.
| # Same tests as above, but with the typevar constraints in a different order, to make sure the | ||
| # results do not depend on our BDD variable ordering. | ||
| def constrained_by_gradual_list_reverse[T: (list[Any], list[Base])](): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test failed before these fixes, reproducing the test failure in the macOS CI job
| if if_true == if_false { | ||
| return if_true; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the clause that used to remove redundant BDD nodes. Removing it changes our BDDs from reduced to quasi-reduced.
| (Node::AlwaysTrue, Node::Interior(interior)) | ||
| | (Node::Interior(interior), Node::AlwaysTrue) => Node::new( | ||
| db, | ||
| interior.constraint(db), | ||
| Node::AlwaysTrue, | ||
| Node::AlwaysTrue, | ||
| ), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method skips calling Node::new for some inputs, so we have to change the base cases to not throw away redundant nodes. (ditto below)
| return String::from("always"); | ||
| return String::from("never"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a bug in our display rendering that we couldn't trigger before, since never BDDs were always collapsed into the AlwaysNever terminal. Now we might have BDDs where all paths lead to the never terminal, which would hit this clause. (The intuition here is that we're displaying a DNF formula — a union of intersections — and so if the union contains no elements, the overall formula is false/never. And the new snippet just above handles where there's a single-element union of an empty intersection, which represents true/always.)
| │ └─₀ (U = bool) | ||
| │ ┡━₁ always | ||
| │ └─₀ never | ||
| ┡━₁ (T = bool) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like a big blow-up in the size of the BDD, but we still have structural sharing from the salsa interning. So for example, all of the copies of
(U = bool)
┡━₁ always
└─₀ never
are stored once as a single InternalNode. So the rendered size here is not a good proxy for how much memory is used internally.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Primer doesn't report any noticeable memory increases, that's good enough for me!
CodSpeed Performance ReportMerging #21744 will not alter performanceComparing Summary
Footnotes
|
carljm
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems a bit sad that we have to do this. I guess ultimately the reason for it boils down to the fact that constraints can involve gradual types, and we don't staticify those via materialization, and this can lead to these path-dependent redundancy determinations. And we can't reduce constraints to fully static types without breaking the gradual guarantee / causing false positives.
But I guess the positive is that this fix isn't complex at all, fixes the issue, and doesn't seem to cause memory issues!
Thank you.
| reveal_type(generic_context(unbounded).specialize_constrained(ConstraintSet.range(bool, T, bool) & ConstraintSet.range(Never, T, str))) | ||
|
|
||
| # revealed: ty_extensions.Specialization[T@unbounded = int] | ||
| # revealed: ty_extensions.Specialization[T@unbounded = bool] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I understand why we solve this to bool. The constraints are T <= int | T <= bool, which seems equivalent to T <= int. Why do we prefer solving to bool when there's the additional (seemingly redundant) constraint present?
| │ └─₀ (U = bool) | ||
| │ ┡━₁ always | ||
| │ └─₀ never | ||
| ┡━₁ (T = bool) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Primer doesn't report any noticeable memory increases, that's good enough for me!
* origin/main: [ty] Improve `@override`, `@final` and Liskov checks in cases where there are multiple reachable definitions (#21767) [ty] Extend `invalid-explicit-override` to also cover properties decorated with `@override` that do not override anything (#21756) [ty] Enable LRU collection for parsed module (#21749) [ty] Support typevar-specialized dynamic types in generic type aliases (#21730) Add token based `parenthesized_ranges` implementation (#21738) [ty] Default-specialization of generic type aliases (#21765) [ty] Suppress false positives when `dataclasses.dataclass(...)(cls)` is called imperatively (#21729) [syntax-error] Default type parameter followed by non-default type parameter (#21657) new module for parsing ranged suppressions (#21441) [ty] `type[T]` is assignable to an inferable typevar (#21766) Fix syntax error false positives for `await` outside functions (#21763) [ty] Improve diagnostics for unsupported comparison operations (#21737) Move `Token`, `TokenKind` and `Tokens` to `ruff-python-ast` (#21760) [ty] Don't confuse multiple occurrences of `typing.Self` when binding bound methods (#21754) Use our org-wide Renovate preset (#21759) Delete `my-script.py` (#21751) [ty] Move `all_members`, and related types/routines, out of `ide_support.rs` (#21695)
* origin/main: [ty] Reachability constraints: minor documentation fixes (#21774) [ty] Fix non-determinism in `ConstraintSet.specialize_constrained` (#21744) [ty] Improve `@override`, `@final` and Liskov checks in cases where there are multiple reachable definitions (#21767) [ty] Extend `invalid-explicit-override` to also cover properties decorated with `@override` that do not override anything (#21756) [ty] Enable LRU collection for parsed module (#21749) [ty] Support typevar-specialized dynamic types in generic type aliases (#21730) Add token based `parenthesized_ranges` implementation (#21738) [ty] Default-specialization of generic type aliases (#21765) [ty] Suppress false positives when `dataclasses.dataclass(...)(cls)` is called imperatively (#21729) [syntax-error] Default type parameter followed by non-default type parameter (#21657)
#21744 fixed some non-determinism in our constraint set implementation by switching our BDD representation from being "fully reduced" to being "quasi-reduced". We still deduplicate identical nodes (via salsa interning), but we removed the logic to prune redundant nodes (one with identical outgoing true and false edges). This ensures that the BDD "remembers" all of the individual constraints that it was created with. However, that comes at the cost of creating larger BDDs, and on #21551 that was causing performance issues. `scikit-learn` was producing a function signature with dozens of overloads, and we were trying to create a constraint set that would map a return type typevar to any of those overload's return types. This created a combinatorial explosion in the BDD, with by far most of the BDD paths leading to the `never` terminal. This change updates the quasi-reduction logic to prune nodes that are redundant _because both edges lead to the `never` terminal_. In this case, we don't need to "remember" that constraint, since no assignment to it can lead to a valid specialization. So we keep the "memory" of our quasi-reduced structure, while still pruning large unneeded portions of the BDD structure. Pulling this out of #21551 for separate review.
This fixes a non-determinism that we were seeing in the constraint set tests in #21715.
In this test, we create the following constraint set, and then try to create a specialization from it:
That is,
Tis either specificallylist[Base], or it's anylist. Our current heuristics say that, absent other restrictions, we should specializeTto the more specific type (list[Base]).In the correct test output, we end up creating a BDD that looks like this:
In the incorrect output, the BDD looks like this:
The difference is the ordering of the two individual constraints. Both constraints appear in the first BDD, but the second BDD only contains
T is any list. If we were to force the second BDD to contain both constraints, it would look like this:This is the standard shape for an OR of two constraints. However! Those two constraints are not independent of each other! If
Tis specificallylist[Base], then it's definitely also "anylist". From that, we can infer the contrapositive: that ifTis not any list, then it cannot belist[Base]specifically. When we encounter impossible situations like that, we prune that path in the BDD, and treat it asfalse. That rewrites the second BDD to the following:We then would see that that BDD node is redundant, since both of its outgoing edges point at the
nevernode. Our BDDs are reduced, which means we have to remove that redundant node, resulting in the BDD we saw above:The end result is that we were "forgetting" about the
T = list[Base]constraint, but only for some BDD variable orderings.To fix this, I'm leaning in to the fact that our BDDs really do need to "remember" all of the constraints that they were created with. Some combinations might not be possible, but we now have the sequent map, which is quite good at detecting and pruning those.
So now our BDDs are quasi-reduced, which just means that redundant nodes are allowed. (At first I was worried that allowing redundant nodes would be an unsound "fix the glitch". But it turns out they're real! This is the paper that introduces them, though it's very difficult to read. Knuth mentions them in §7.1.4 of TAOCP, and this paper has a nice short summary of them in §2.)
While we're here, I've added a bunch of
debugandtracelevel log messages to the constraint set implementation. I was getting tired of having to add these by hands over and over. To enable them, just setTY_LOGin your environment, e.g.[Note, this has an
internallabel because are still not usingspecialize_constrainedin anything user-facing yet.]