Skip to content

Perf: hasattr/getattr miss builds and discards a full AttributeError — no suppressed-lookup path #48

Description

@youknowone

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:

  1. generic_getattrnew_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;
  2. 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;
  3. 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;
  4. 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.

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