Status update (2026-07-22)
The originally reported problem — LOAD_ATTR specialization never firing for user-defined classes — is resolved in current main (measured at dd9bce5):
update_slot for TpGetattro now extracts the native PyBaseObject::getattro function pointer from object's wrapper descriptor via lookup_slot_in_mro and installs it directly whenever no __getattr__ exists in the MRO (crates/vm/src/types/slot.rs ~893–936). Plain class C: pass therefore passes the is_default_getattro check in specialize_load_attr (crates/vm/src/frame.rs ~8225) and the inline caches fire.
Fresh measurements (Apple M5 Max, release build, vs CPython 3.14.5):
| benchmark |
RustPython |
CPython 3.14 |
ratio |
previously reported |
c.x read overhead (vs local read) |
25.4 ns |
2.2 ns |
~11.5x |
~48x |
self.value r/w + c.add(i) loop |
183.0 ns/iter |
24.4 ns/iter |
~7.5x |
~16.4x |
This issue now tracks the remaining ~7–11x gap.
Where the remaining time goes
sample profile of a function-scoped s += c.x loop (4106 samples):
| cost |
samples |
share |
interpreter dispatch + inline handler code in ExecutingFrame::run |
1846 |
~45% |
execute_binary_op_int/int_add incl. malachite conversion + PyInt::into_ref |
511 |
~12% |
PyInt dealloc (dropping the previous s) |
433 |
~11% |
Dict::get<PyInterned<PyStr>> — the attribute lookup inside LoadAttrInstanceValue |
277 |
~7% |
Context::new_int (boxing the new s) |
221 |
~5% |
Dict::get_hint (LOAD_GLOBAL c) |
131 |
~3% |
(The int-churn rows also affect the local-read baseline loop; the attr-specific overhead is dominated by the dict lookup plus extra dispatch.)
Cause
The specialized handler still pays for a full hash-based dict lookup on every hit. LoadAttrInstanceValue (crates/vm/src/frame.rs ~4459) verifies the type version, then calls
dict.get_item_opt(attr_name, vm)?
which hashes the interned name and probes the instance dict. CPython's LOAD_ATTR_INSTANCE_VALUE instead caches the dict-keys version and the entry index at specialization time, and on a hit reads values[index] with no hashing or probing at all — the type-version guard alone proves the layout.
Secondary contributors, shared with all arithmetic-heavy code rather than specific to attribute access:
- every
s += c.x allocates a fresh PyInt (Context::new_int → into_ref) and deallocates the old one — ~28% of the profile combined;
LOAD_GLOBAL still performs a dict probe per access.
Suggested direction
- Extend the LOAD_ATTR inline cache with a dict-keys version + entry index (this needs a version/mutation counter on the instance dict's key layout), so
LoadAttrInstanceValue/LoadAttrMethodWithValues hits become a guarded indexed read instead of get_item_opt.
- The interned-name case could at least skip re-hashing (interned strings can carry a precomputed hash) and use an identity-first probe.
- Int boxing/dealloc churn and
LOAD_GLOBAL probes are worth tracking separately; they cap every arithmetic loop, not just attribute access.
Original report (resolved) — LOAD_ATTR specialization never fires for user-defined classes
Problem
Instance attribute access on user-defined classes is ~48x slower than CPython (91.5ns overhead vs 1.9ns on Apple M4):
class C: pass
c = C(); c.x = 1
# loop reading c.x: 155.4ns/iter vs 63.9ns for the same loop reading a local
A method-call + attribute benchmark (self.value read/write + c.add(i) in a loop) is 16.4x slower than CPython — the single worst non-import result in the suite.
Cause
The LOAD_ATTR specializer refuses to specialize unless the receiver's class has the default native getattro (crates/vm/src/frame.rs specialize_load_attr ~7526):
let is_default_getattro = cls.slots.getattro.load()
.is_some_and(|f| f as usize == PyBaseObject::getattro as *const () as usize);
But plain user-defined classes (class C: pass) don't get that native slot. PyType::init_slots (crates/vm/src/builtins/type.rs ~883) collects all dunder names found anywhere in the MRO — which always includes object.__getattribute__ — and runs update_slot::<true> for each. For TpGetattro, lookup_slot_in_mro classifies object.__getattribute__ as a Python-level method rather than extracting the native slot, so getattro_wrapper is installed (crates/vm/src/types/slot.rs ~876–890).
Consequences, both visible in profiles of the method+attr benchmark:
- Specialization never happens for instances of user classes:
is_default_getattro is false; for method loads (oparg.is_method()) that branch can only back off, so c.add stays a generic LOAD_ATTR forever, re-entering the failing specializer on the adaptive counter cadence.
- The generic path is maximally expensive:
getattro_wrapper → vm.call_special_method(__getattribute__) → get_special_method does an MRO lookup (PyType::find_name_in_mro shows in profiles) → builds FuncArgs (FuncArgs::bind 3.0% self + drop_in_place<FuncArgs> 1.2%) → dispatches through slot_call into the native PyBaseObject::__getattribute__. That whole chain (get_attr_inner/getattro_wrapper/call_special_method) accounts for ~31% of total time in that benchmark.
Suggested direction
- When wiring
TpGetattro in init_slots/update_slot, recognize the case "resolved __getattribute__ is object.__getattribute__ and no __getattr__ anywhere in the MRO" and install the native PyBaseObject::getattro function pointer instead of getattro_wrapper. (Equivalently: make object.__getattribute__ extractable as a native SlotFunc::GetAttro the way wrapper descriptors are.)
- Even when a wrapper must stay (e.g.
__getattr__ present), the wrapper should call PyBaseObject::getattro directly for the __getattribute__ half instead of going through call_special_method's MRO-lookup + FuncArgs protocol.
This is likely the highest ratio of speedup to effort in the series: it doesn't add any new optimization, it just lets the existing specializations fire. Expected to collapse the 48x attr micro to the "specialized handler" cost, and to move every OO benchmark at once.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.
Part of #38.
Status update (2026-07-22)
The originally reported problem — LOAD_ATTR specialization never firing for user-defined classes — is resolved in current
main(measured at dd9bce5):update_slotforTpGetattronow extracts the nativePyBaseObject::getattrofunction pointer fromobject's wrapper descriptor vialookup_slot_in_mroand installs it directly whenever no__getattr__exists in the MRO (crates/vm/src/types/slot.rs~893–936). Plainclass C: passtherefore passes theis_default_getattrocheck inspecialize_load_attr(crates/vm/src/frame.rs~8225) and the inline caches fire.Fresh measurements (Apple M5 Max, release build, vs CPython 3.14.5):
c.xread overhead (vs local read)self.valuer/w +c.add(i)loopThis issue now tracks the remaining ~7–11x gap.
Where the remaining time goes
sampleprofile of a function-scopeds += c.xloop (4106 samples):ExecutingFrame::runexecute_binary_op_int/int_addincl. malachite conversion +PyInt::into_refPyIntdealloc (dropping the previouss)Dict::get<PyInterned<PyStr>>— the attribute lookup insideLoadAttrInstanceValueContext::new_int(boxing the news)Dict::get_hint(LOAD_GLOBAL c)(The int-churn rows also affect the local-read baseline loop; the attr-specific overhead is dominated by the dict lookup plus extra dispatch.)
Cause
The specialized handler still pays for a full hash-based dict lookup on every hit.
LoadAttrInstanceValue(crates/vm/src/frame.rs~4459) verifies the type version, then callswhich hashes the interned name and probes the instance dict. CPython's
LOAD_ATTR_INSTANCE_VALUEinstead caches the dict-keys version and the entry index at specialization time, and on a hit readsvalues[index]with no hashing or probing at all — the type-version guard alone proves the layout.Secondary contributors, shared with all arithmetic-heavy code rather than specific to attribute access:
s += c.xallocates a freshPyInt(Context::new_int→into_ref) and deallocates the old one — ~28% of the profile combined;LOAD_GLOBALstill performs a dict probe per access.Suggested direction
LoadAttrInstanceValue/LoadAttrMethodWithValueshits become a guarded indexed read instead ofget_item_opt.LOAD_GLOBALprobes are worth tracking separately; they cap every arithmetic loop, not just attribute access.Original report (resolved) — LOAD_ATTR specialization never fires for user-defined classes
Problem
Instance attribute access on user-defined classes is ~48x slower than CPython (91.5ns overhead vs 1.9ns on Apple M4):
A method-call + attribute benchmark (
self.valueread/write +c.add(i)in a loop) is 16.4x slower than CPython — the single worst non-import result in the suite.Cause
The LOAD_ATTR specializer refuses to specialize unless the receiver's class has the default native getattro (
crates/vm/src/frame.rsspecialize_load_attr~7526):But plain user-defined classes (
class C: pass) don't get that native slot.PyType::init_slots(crates/vm/src/builtins/type.rs~883) collects all dunder names found anywhere in the MRO — which always includesobject.__getattribute__— and runsupdate_slot::<true>for each. ForTpGetattro,lookup_slot_in_mroclassifiesobject.__getattribute__as a Python-level method rather than extracting the native slot, sogetattro_wrapperis installed (crates/vm/src/types/slot.rs~876–890).Consequences, both visible in profiles of the method+attr benchmark:
is_default_getattrois false; for method loads (oparg.is_method()) that branch can only back off, soc.addstays a genericLOAD_ATTRforever, re-entering the failing specializer on the adaptive counter cadence.getattro_wrapper→vm.call_special_method(__getattribute__)→get_special_methoddoes an MRO lookup (PyType::find_name_in_mroshows in profiles) → buildsFuncArgs(FuncArgs::bind3.0% self +drop_in_place<FuncArgs>1.2%) → dispatches throughslot_callinto the nativePyBaseObject::__getattribute__. That whole chain (get_attr_inner/getattro_wrapper/call_special_method) accounts for ~31% of total time in that benchmark.Suggested direction
TpGetattroininit_slots/update_slot, recognize the case "resolved__getattribute__isobject.__getattribute__and no__getattr__anywhere in the MRO" and install the nativePyBaseObject::getattrofunction pointer instead ofgetattro_wrapper. (Equivalently: makeobject.__getattribute__extractable as a nativeSlotFunc::GetAttrothe way wrapper descriptors are.)__getattr__present), the wrapper should callPyBaseObject::getattrodirectly for the__getattribute__half instead of going throughcall_special_method's MRO-lookup +FuncArgsprotocol.This is likely the highest ratio of speedup to effort in the series: it doesn't add any new optimization, it just lets the existing specializations fire. Expected to collapse the 48x attr micro to the "specialized handler" cost, and to move every OO benchmark at once.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.
Part of #38.