Skip to content

Perf: instance attribute access still ~7-11x slower than CPython (specialized LOAD_ATTR does a full dict lookup) #41

Description

@youknowone

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_intinto_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:

  1. 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.
  2. The generic path is maximally expensive: getattro_wrappervm.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions