In this previous post I talked about how Python optimizes the complex attribute lookup process by means of a Global Attribute Cache/MRO cache. We'll see in this post the recent (since Python 3.11) complementary technique used to further optimize attribute lookup.
Time ago I talked about how the CPython bytecode interpreter is since version 3.11 an Adaptive Specializing Interpreter. This means that as it runs it monitors the code to find patterns that allow optimization by replacing generic bytecode instructions by specialized ones, this is also known as Quickening (this is similar to what we are used to with interpreter - layered JIT's combinations, only that working purely at a bytecode level). This specialization is not only a matter of changing one instruction by another, but also of storing (in an inline cache just next to the instruction, a per-site cache) additional information needed by the adaptative instruction.
Since 3.11, CPython rewrites hot instructions in place into **specialized** forms. Each specializable instruction is followed in the bytecode by **inline cache entries** that hold the facts the specialized form depends on.
For looking up an attribute we have a generic LOAD_ATTR instruction, that gets specialized like this:
1. The generic (adaptive) `LOAD_ATTR` keeps a warm-up counter.
2. When the counter runs out, the specializer looks at the current type and
attribute (calling `_PyType_Lookup` once) and rewrites the instruction, for
example to:
- `LOAD_ATTR_METHOD_WITH_VALUES`: method on a class whose instances use
key-sharing dicts
- `LOAD_ATTR_METHOD_NO_DICT`: method on instances without a `__dict__`
(for example `__slots__`)
- `LOAD_ATTR_INSTANCE_VALUE`, `LOAD_ATTR_SLOT`, `LOAD_ATTR_PROPERTY`,
`LOAD_ATTR_GETATTRIBUTE_OVERRIDDEN`, …
As you can see there are different specialized instructions for an attribute lookup that has ended up returning a method from a class and for one that ends up returning a value from an instance. We can read here:
Load methods for call: o.meth()
The actual address of the method is cached. Method loading now has no namespace lookups – even for classes with long inheritance chains.
Load attribute: o.attr
The attribute’s index inside the class/object’s namespace is cached. In most cases, attribute loading will require zero namespace lookups.
So for method lookup once the specialization is in place we no longer do a lookup in the Global Attribute Cache/MRO cache (that we can consider as a Global Type Cache), but directly have the address of the method in this Local Inline Cache next to the instruction.
For instance attributes it feels even more impressive. Without this optimization a normal lookup for person1.age would search the age attribute in the MRO (after the first attempt it would store in the MRO cache a "NOT_FOUND_IN_MRO" entry) and as not found there it would retrieve it from the dictionary in person1.__dict__. With this specialization, the Local Inline Cache next to the bytecode instruction stores an index to retrieve the value from the the internal structure that contains the values stored in person1.__dict__ (along with guards). This per-site cache works very nicely when we have loops traversing an iterable of instances of the same type, as the __dict__ structure looks the same for all of them and hence the index is valid for all of them.
A successful specialized fast path may therefore avoid both an MRO walk and a lookup in the more general type cache