Repository navigation
Perf: split keys — shared per-class keys object + per-instance values array (inline values) #46
Description
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()—InstanceDictRwLock read +PyDictRefrefcount inc/decDict::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_VALUEbecomes: type-version guard → keys-version guard →values[cached_index]read. No dict object access at all.STORE_ATTRon 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'sPyDictOrValues.- Per-instance memory drops to one pointer + values array.
Design questions to settle first
- Where the shared keys live: on
PyType(heaptype ext, like CPython'sht_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. - 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. - 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.
__dict__semantics: id stability,vars(obj)mutation coherence, dict views observing later attribute stores — CPython's materialization rules should be matched exactly.- Interaction with GC/traverse: values arrays must be traversed; materialized dicts must not double-count.
Expected effect
- Attr-read overhead currently ~16ns vs CPython 2.2ns (M5 Max); the dict-access share (~6% of the loop) is what this removes, worth roughly 3–5ns/iter on that micro today.
- The relative payoff grows once the larger costs land (Perf: Python→Python call overhead is ~15x CPython (eager frame lifecycle + recursive eval loop) #40 call overhead, Perf: small int/float arithmetic allocates heap objects; specialized BinaryOp*Int skips the i64 fast path #43 int boxing, Perf: per-instruction dispatch overhead — metadata lookups and atomic lasti cost 10–28% of runtime #39 dispatch) — worth sequencing after or alongside those.
- Instance memory: full dict (≈200+ bytes) → pointer + N×8-byte values.
Part of #38. Follow-up to #42 / RustPython#8350.
Researched and written by Claude on behalf of @youknowone. Part of the performance tracking series.
Activity