-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
[red-knot] Implement C3 linearisation for calculating a class's Method Resolution Order #13722
base: main
Are you sure you want to change the base?
Conversation
8152f52
to
c9a0e1f
Compare
CodSpeed Performance ReportMerging #13722 will degrade performances by 4.05%Comparing Summary
Benchmarks breakdown
|
674a98b
to
d6db5d9
Compare
f735ce0
to
6895e75
Compare
@MichaReiser I've updated the PR summary and taken the PR out of draft mode :-) |
This is "ex_2" from <https://docs.python.org/3/howto/mro.html#the-end> | ||
|
||
```py | ||
class O: |
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 a massive fan of using O
(that's a capital-O letter!) as a variable name here, because it's so easy to misread it as 0
(the digit zero). However, I experimented with using a different variable name, and I found it even more confusing, because it was very hard to remember which class it corresponded to in the ex_2
example in https://docs.python.org/3/howto/mro.html#the-end. Unfortunately that article uses O
as the class name.
@AlexWaygood do you have an understanding of what's causing the perf regression? |
Not fully. The CodSpeed flamegraph isn't very informative because of all the Salsa frames. The fact that the incremental benchmark regresses much more than the cold benchmark makes me think that it's something to do with the method having the diff --git a/crates/red_knot_python_semantic/src/types/mro.rs b/crates/red_knot_python_semantic/src/types/mro.rs
index b32faf71a..ad4b6ea73 100644
--- a/crates/red_knot_python_semantic/src/types/mro.rs
b/crates/red_knot_python_semantic/src/types/mro.rs
@@ -20,19 20,19 @@ pub(super) enum MroPossibilities<'db> {
/// There are multiple possible `__mro__` values for this class, but they would
/// all lead to the class being successfully created. Here are the different
/// possibilities:
- MultipleSuccesses(FxHashSet<Mro<'db>>),
MultipleSuccesses(Box<[Mro<'db>]>),
/// It can be statically determined that the `__mro__` possibilities for this class
/// (possibly one, possibly many) always fail. Here are the various possible
/// bases that all lead to class creation failing:
- CertainFailure(FxHashSet<BasesList<'db>>),
CertainFailure(Box<[BasesList<'db>]>),
/// There are multiple possible `__mro__`s for this class. Some of these
/// possibilities result in the class being successfully created; some of them
/// result in class creation failure.
PossibleSuccess {
- possible_mros: FxHashSet<Mro<'db>>,
- failure_cases: FxHashSet<BasesList<'db>>,
possible_mros: Box<[Mro<'db>]>,
failure_cases: Box<[BasesList<'db>]>,
},
but unfortunately converting the HashSets into boxed slices seemed to cause a slowdown rather than a speedup; the cost of the conversion and new allocation was worse than any speed gained by storing smaller objects in the cache. Defining the The algorithm is in itself also inherently nontrivial (even with the fast paths I added for the common cases), and we have to call it for every class definition we encounter. There's quite a lot of allocation currently in some places: I think some of this is unfortunately unavoidable, but |
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.
Thanks for the excellent write up.
This code is indeed complicated. I don't think I fully understand what's going on and supporting multiple MROs also results in a fair amount of allocations and hashing all over the place (both are expensive). That's why I'm inclined to not support union MROs unless we have common use cases demonstrating why it's needed. I do fear that this requires making a decision on sys.version_info
.
I think we should prioritise our decision on checking multiple python versions because my first reaction on supporting unions was to defer the implementation (and all downstream complexity) until we have real use cases where it matters. But you then mentioned sys.version_info
support...
I do feel like there's a lot of boilerplate going on with many intermediate types. I would have to take a deeper look to understand if it's possible to simplify it some way but I noted that I struggled to connect all the dots and would probably also struggle to make changes to the implementation. I'm not sure if there's any simplification potential but it might be worth going over the implementation again to see if we can remove some of the many intermediate structs.
/// | ||
/// [method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order | ||
#[salsa::tracked(return_ref)] | ||
fn mro_possibilities(self, db: &'db dyn Db) -> MroPossibilities<'db> { |
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.
Do we expect this method to be called frequently for it to be worth to track as a separate salsa query?
Could you add an example where the resolved MRO has a cycle.
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 expect it to be called very frequently. We need to iterate over the MRO whenever we resolve the type of an attribute or method access (explicit or implict) on a class or an instance of a class. This PR already starts using it in ClassType::inherited_class_member
, which currently (incorrectly) only iterates over a class's bases rather than over a class's entire MRO.
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.
Could you add an example where the resolved MRO has a cycle.
Hmmm... what are you thinking of exactly? An MRO that requires an import cycle to be resolved?
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.
what are you thinking of exactly? An MRO that requires an import cycle to be resolved?
No, an actual cycle in the MRO, i.e. what you'd get if you had class str(str): ...
in a type stub. Clearly we can't allow this and have to error on it as an invalid MRO, but it would be good to have a test showing that we do this, rather than getting stuck in an infinite loop building the MRO.
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.
Ahh, got it, thanks.
pub fn bases(&self, db: &'db dyn Db) -> Box<[Type<'db>]> { | ||
let base_nodes = self.node(db).bases(); | ||
let object = KnownClass::Object.to_class(db); | ||
|
||
if Type::Class(*self) == object { | ||
// Uniquely among Python classes, `object` has no bases: | ||
// | ||
// ```pycon | ||
// >>> object.__bases__ | ||
// () | ||
// ``` | ||
Box::default() | ||
} else if base_nodes.is_empty() { | ||
// All Python classes that have an empty bases list in their AST | ||
// implicitly inherit from `object`: | ||
Box::new([object]) | ||
} else { | ||
base_nodes | ||
.iter() | ||
.map(move |base_expr| definition_expression_ty(db, self.definition(db), base_expr)) | ||
.collect() | ||
} | ||
} |
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 suggest that we
- implement this as an
Iterator
to avoid allocating for every class if the code is in the hot path (probably?) - return an
impl Iterator
and always returning aVec
over aBox<[]>
(boxed slices are mainly useful when storing in another struct it doesn't matter much when the variable is stored on the stack) when the code isn't performance sensitive.
let member = base.own_class_member(db, name); | ||
if !member.is_unbound() { | ||
union_builder = union_builder.add(member); | ||
continue 'outer; |
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.
Nit: Isn't this the same as using break
?
continue 'outer; | |
break; |
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.
No, we need to skip the union_builder = union_builder.add(Type::Unbound);
line which is called after the termination of the inner loop. We wouldn't skip that line if we used break
.
let mut mro = Vec::with_capacity(8); | ||
|
||
loop { | ||
sequences.retain(|sequence| !sequence.is_empty()); |
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.
Does the sequence order matter? It might otherwise be faster to use swap_remove
(and remove the element when the pop_front
call poped the last element)
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.
yes, the sequence order is crucial here
// Make sure we don't try to add the candidate to the MRO twice: | ||
for sequence in &mut sequences { | ||
if sequence[0] == mro_entry { | ||
sequence.pop_front(); |
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 we're always poping from the front. Would it instead be possible to use regular Vec
s and reverse the elements (and poping from the end)?
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.
Would you expect that to be more performant than using a VecDeque
here?
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.
Probably not by much but a Vec
is simpler than a VecDeque
. It also simplifies the method signature by requiring a less specific type (requiring a Vec
is more common)
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'll try this out and see if it's workable. A downside is that the implementation will become less recognisably a reimplementation of the algorithm given in the Python docs, so it'll be less self-evident that the function does the correct thing. But I've already made many changes in the process of rewriting it in Python 3, then Rust, so perhaps that's already lost
I'm not doing a detailed review of this yet because my understanding is we need to come to agreement on multi-version checking before we are ready to land this with union support. Let me know if that's wrong. |
That, and also @MichaReiser's review made me look again and realise that I can make some of this a fair bit more efficient and easier to understand with a rewrite :-) So yes, not much use looking at it right now. |
We could also consider splitting the PR so that we can land a "simple" version now. That would also give us a nice "diff" to assess the extra complexity |
It occurred to me today that if we do want to go forward with supporting multi-version-checking and thus unions in class bases, the better representation for that would probably be to have the single The trick here is that we can't really do this easily in stub files, where type inference of class bases can be cyclic and must be deferred :/ I wonder if the right fix for that is to not actually defer resolution of class bases in stub files, but only defer resolution of generic parameters within class bases; this would be sufficient to handle the actual cases that we need to support (like |
Summary
A Python class's "Method Resolution Order" ("MRO") is the order in which superclasses of that class are traversed by the Python runtime when searching for an attribute (which includes methods) on that class. Accurately inferring a class's MRO is essential for a type checker if it is going to be able to accurately lookup the types of attributes and methods accessed on that class (or instances of that class).
For simple classes, the MRO (which is accessible at runtime via the
__mro__
attribute on a class) is simple:For more complex classes that use multiple inheritance, things can get a bit more complicated, however:
And for some classes, Python realises that it cannot determine which order the superclasses should be positioned in order to create the MRO at class creation time:
The algorithm Python uses at runtime to determine what a class's MRO should be is known as the C3 linearisation algorithm. An in-depth description of the motivations and details of the algorithm can be found in this article in the Python docs. The article is quite old, however, and the algorithm given at the bottom of the page is written in Python 2. As part of working on this PR, I translated the algorithm first into Python 3 (see this gist), and then into Rust (the
c3_merge
function inmro.rs
in this PR). In order for us to correctly infer a class's MRO, we need our own implementation of the C3 linearisation algorithm.The C3 linearisation algorithm isn't too complicated by itself. However, there are additional complexities for a type checker when it comes to calculating a class's MRO. The C3 linearisation algorithm calculates an MRO from a list of bases given to it: but what if one of the bases of a class is inferred as a
Union
type by red-knot? In this situation, there will be multiple possible lists of bases, and therefore potentially multiple possible MROs, for that single class. For this reason, mypy and pyright both reject classes that have an object in their bases list which is inferred as aUnion
type, as it adds a fair amount of complexity to the semantic model. However, this PR nonetheless attempts to support such cases, for several reasons:sys.version_info
conditions, which means that we will likely often infer a union for an object used as a class base.As well as having to adapt the C3 linearisation algorithm to account for the possibility that a class might have a
Union
in its bases, I also had to make some changes to account for the fact that a class might have a dynamic type in its bases: in our current model, we have three dynamic types, which areUnknown
,Any
andTodo
. This PR takes the simple approach of deciding that the MRO ofAny
is[Any, object]
, the MRO ofUnknown
is[Unknown, object]
, and the MRO ofTodo
is[Todo, object]
; other than that, they are not treated particularly specially by the C3 linearisation algorithm. Other than simplicity, this has a few advantages:object
.Dynamic types will have to be treated specially when it comes to attribute and method access from these types; however, that is for a separate PR.
Implementation strategy
The implementation is encapsulated in a new
red_knot_python_semantic
submodule,types/mro.rs
.ClassType::mro_possibilities
returns a HashSet of possible MROs that the class could have given its bases, and this calls out to functions in themro.rs
submodule. If there are no union types in the bases list (or in any of the bases of those bases), then there will be exactly one possible MRO for the class; but if there are any union types in the class's bases or the bases of those bases (etc.), then the class could have one of several possible MROs. At each stage, the implementation tries to avoid forking if possible. I've attempted to optimise the implementation based on the assumption that most classes will not have union types in their bases (or the bases of their bases, etc.); there are various fast paths that are predicated on this.It's necessary for us to emit a diagnostic if we can determine that a particular list of bases will (or could) cause a
TypeError
to be raised at runtime due to an unresolvable MRO. However, we can't do this while creating theClassType
and storing it inself.types.declarations
ininfer.rs
, as we need to know the bases of the class in order to determine its MRO, and some of the bases may be deferred. This PR therefore iterates over all classes in a certain scope after all types (including deferred types) have been inferred, as part ofTypeInferenceBuilder::infer_region_scope
. For types that will (or could) raise an exception due to an invalid MRO, we infer the MRO as being[<class in question>, Unknown, object]
as well as emitting the diagnostic.For discussion
There's a performance regression of around 4% on the codspeed incremental benchmark. I've tried to get it down a bit, and it has gone down a bit, but I'm out of ideas for how to reduce it further. Open to suggestions :-)
Test Plan
Beautiful Markdown tests added using @carljm's new test framework. Several tests taken from https://docs.python.org/3/howto/mro.html#python-2-3-mro.