Problem
A failing hasattr() / getattr(obj, name, default) costs ~620 ns per call — RustPython builds a complete AttributeError (message formatting, exception object, args tuple, exception instance dict, context attributes — about 6 allocations plus 2 extra attribute lookups) and then immediately discards it. CPython answers the same question with zero allocations via a suppressed lookup.
Measurements
Apple M4 (10 cores), macOS Darwin 25.5.0. RustPython typelock @ 8d7d9d9, cargo build --release; CPython 3.14.2 (Homebrew). 2026-07-23. Min-of-3 alternating passes; incremental = per-op minus the acc += 1 loop baseline (4.0x).
| benchmark |
ratio |
incremental RustPython |
incremental CPython |
getattr(x, "missing", None) loop |
25.8x |
+626 ns/call |
+8 ns |
hasattr(x, "missing") loop |
21.1x |
+623 ns/call |
+14 ns |
hasattr(x, "real") (hit) loop |
5.4x |
+101 ns |
+14 ns |
These are the two worst ratios in the entire 54-bench family suite. The miss path matters in real code: hasattr/getattr-with-default probing is a standard stdlib idiom (protocols, duck typing, copy, pickle, inspect, …).
Mechanism
vm.get_attribute_opt (crates/vm/src/vm/mod.rs:2072-2083) — used by hasattr and getattr with default (crates/vm/src/stdlib/builtins.rs:689,715) — always calls the full raising get_attr_inner and converts an AttributeError back to None. On a miss:
generic_getattr → new_no_attribute_error (crates/vm/src/vm/vm_new.rs:411-423): format!s the message (String alloc), allocates a PyStr, the exception object, and its args tuple;
set_attribute_error_context then does a full get_attr("name") lookup plus two set_attrs that create the exception's instance dict (PyDict alloc + 8-slot indices vec) and insert name/obj;
get_attr_inner's inspect_err (crates/vm/src/protocol/object.rs:134-140) calls set_attribute_error_context again — another get_attr("name") that now hits the dict;
get_attribute_opt runs fast_isinstance on the exception and drops everything — deallocating the exception, dict, tuple, and strings.
No traceback is attached (the error never propagates through a frame), but everything else is built and torn down per miss.
What CPython does
builtin_hasattr_impl uses PyObject_GetOptionalAttr (Objects/object.c:1320): when tp_getattro is PyObject_GenericGetAttr it calls _PyObject_GenericGetAttrWithDict(v, name, NULL, suppress=1), which returns NULL without creating any exception. Suppressed variants also exist for the type and module getattro slots.
Suggested direction
The non-raising building block already exists: PyObject::generic_getattr_opt (crates/vm/src/protocol/object.rs:227). get_attribute_opt should check whether the object's getattro slot is the generic one and take the suppressed path directly (~20 lines), falling back to the raising path only for custom __getattribute__/__getattr__. Follow-ups in the same shape: suppressed lookups for module and type getattro slots, and using the opt path for internal probing lookups (vm.get_method, special-method lookup) where a raised-then-swallowed AttributeError has the same cost profile.
Independent of this, the double set_attribute_error_context (once in generic_getattr's raise, once in get_attr_inner's inspect_err) is a redundant lookup+store pair on every raising attribute miss, suppressed or not.
Related: #41 covers the specialization side of attribute access; this issue is purely about the miss/probe path cost. Exception-object allocation cost in general (raise/except loops, StopIteration) is a separate axis, not covered here.
Part of #38.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.
Problem
A failing
hasattr()/getattr(obj, name, default)costs ~620 ns per call — RustPython builds a completeAttributeError(message formatting, exception object, args tuple, exception instance dict, context attributes — about 6 allocations plus 2 extra attribute lookups) and then immediately discards it. CPython answers the same question with zero allocations via a suppressed lookup.Measurements
Apple M4 (10 cores), macOS Darwin 25.5.0. RustPython
typelock@ 8d7d9d9,cargo build --release; CPython 3.14.2 (Homebrew). 2026-07-23. Min-of-3 alternating passes; incremental = per-op minus theacc += 1loop baseline (4.0x).getattr(x, "missing", None)loophasattr(x, "missing")loophasattr(x, "real")(hit) loopThese are the two worst ratios in the entire 54-bench family suite. The miss path matters in real code:
hasattr/getattr-with-default probing is a standard stdlib idiom (protocols, duck typing,copy,pickle,inspect, …).Mechanism
vm.get_attribute_opt(crates/vm/src/vm/mod.rs:2072-2083) — used byhasattrandgetattrwith default (crates/vm/src/stdlib/builtins.rs:689,715) — always calls the full raisingget_attr_innerand converts anAttributeErrorback toNone. On a miss:generic_getattr→new_no_attribute_error(crates/vm/src/vm/vm_new.rs:411-423):format!s the message (String alloc), allocates aPyStr, the exception object, and its args tuple;set_attribute_error_contextthen does a fullget_attr("name")lookup plus twoset_attrs that create the exception's instance dict (PyDict alloc + 8-slot indices vec) and insertname/obj;get_attr_inner'sinspect_err(crates/vm/src/protocol/object.rs:134-140) callsset_attribute_error_contextagain — anotherget_attr("name")that now hits the dict;get_attribute_optrunsfast_isinstanceon the exception and drops everything — deallocating the exception, dict, tuple, and strings.No traceback is attached (the error never propagates through a frame), but everything else is built and torn down per miss.
What CPython does
builtin_hasattr_implusesPyObject_GetOptionalAttr(Objects/object.c:1320): whentp_getattroisPyObject_GenericGetAttrit calls_PyObject_GenericGetAttrWithDict(v, name, NULL, suppress=1), which returnsNULLwithout creating any exception. Suppressed variants also exist for the type and module getattro slots.Suggested direction
The non-raising building block already exists:
PyObject::generic_getattr_opt(crates/vm/src/protocol/object.rs:227).get_attribute_optshould check whether the object'sgetattroslot is the generic one and take the suppressed path directly (~20 lines), falling back to the raising path only for custom__getattribute__/__getattr__. Follow-ups in the same shape: suppressed lookups for module and type getattro slots, and using the opt path for internal probing lookups (vm.get_method, special-method lookup) where a raised-then-swallowedAttributeErrorhas the same cost profile.Independent of this, the double
set_attribute_error_context(once ingeneric_getattr's raise, once inget_attr_inner'sinspect_err) is a redundant lookup+store pair on every raising attribute miss, suppressed or not.Related: #41 covers the specialization side of attribute access; this issue is purely about the miss/probe path cost. Exception-object allocation cost in general (raise/except loops, StopIteration) is a separate axis, not covered here.
Part of #38.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.