Skip to content

Perf: split keys — shared per-class keys object + per-instance values array (inline values) #46

Description

@youknowone

Background

PR RustPython#8350 (issue #42) removed the per-execution hash probes from the specialized LOAD_ATTR/STORE_ATTR handlers using dict keys-version stamps (shared per shape) and cached entry-index hints. That captured the guard sharing half of CPython's split keys: instances of the same class carry equal stamps, so one inline cache serves all of them.

What it did not capture is the structural half. Even on a full cache hit, the specialized load still pays, per access:

  • owner.dict()InstanceDict RwLock read + PyDictRef refcount inc/dec
  • Dict::get_hint — inner RwLock read + entry identity check + value clone through a non-inlined call

In an attribute-read microbenchmark these two are ~6% of samples — essentially all of the remaining attr-specific cost. CPython's LOAD_ATTR_INSTANCE_VALUE pays none of it: after the type-version guard it reads obj->values[index] directly; no dict object, no lock, no probe.

Per-instance memory is also duplicated today: every instance dict owns a full Dict (indices table + key refs + hashes) even though all instances of a class share the same key set.

Proposal: CPython-style split keys

Give instances a "split" storage mode: a shared keys object per class (ordered interned keys + hash table, owned by the type or the shape registry) plus a per-instance values array indexed by insertion order.

  • LOAD_ATTR_INSTANCE_VALUE becomes: type-version guard → keys-version guard → values[cached_index] read. No dict object access at all.
  • STORE_ATTR on an existing key becomes an indexed write; appending a new key follows the shared-keys insertion order or falls back to un-split.
  • obj.__dict__ access materializes a real dict view lazily (or a dict-like proxy over keys+values), un-splitting on layout-diverging mutation — same lifecycle as CPython's PyDictOrValues.
  • Per-instance memory drops to one pointer + values array.

Design questions to settle first

  1. Where the shared keys live: on PyType (heaptype ext, like CPython's ht_cached_keys) vs. keyed by shape in the existing shape registry from Use dict keys-version stamps and entry-index hints in LOAD_ATTR/STORE_ATTR specializations RustPython/RustPython#8350. Type-owned matches CPython's insertion-order-per-class semantics; shape-keyed shares across classes with identical __init__ layouts but complicates growth.
  2. Un-split triggers and mechanics: deletion, non-interned key, layout divergence between instances, __dict__ replacement, exceeding the shared-keys capacity. The transition must be safe against concurrent readers.
  3. Free-threading: values array reads/writes need a synchronization story. CPython 3.13t keeps split keys with per-object locking + optimistic lock-free reads; our objects have no per-object lock today, so this likely needs a seqlock or QSBR-style scheme consistent with the existing type-cache patterns.
  4. __dict__ semantics: id stability, vars(obj) mutation coherence, dict views observing later attribute stores — CPython's materialization rules should be matched exactly.
  5. Interaction with GC/traverse: values arrays must be traversed; materialized dicts must not double-count.

Expected effect

Part of #38. Follow-up to #42 / RustPython#8350.


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