diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index c01f2f057..3a7eec439 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -776,12 +776,15 @@ impl CodeUnits { /// Store a pointer-sized value atomically in the pointer cache at `index`. /// /// Uses a single `AtomicUsize` store to prevent torn writes when - /// multiple threads specialize the same instruction concurrently. + /// multiple threads specialize the same instruction concurrently. The + /// tear-free width also makes this the right slot for non-pointer guard + /// values (e.g. dict keys-version stamps) that must never be observed + /// half-written. /// /// # Safety /// - `index` must be in bounds. - /// - `value` must be `0` or a valid `*const PyObject` encoded as `usize`. - /// - Callers must follow the cache invalidation/upgrade protocol: + /// - When the slot holds a `*const PyObject` encoded as `usize` (or `0`), + /// callers must follow the cache invalidation/upgrade protocol: /// invalidate the version guard before writing and publish the new /// version after writing. pub unsafe fn write_cache_ptr(&self, index: usize, value: usize) { diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 06791410e..d14536eef 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -753,6 +753,63 @@ impl Py { } } + /// Lookup trying a cached entry index hint first. + /// + /// When the hint misses but the key is present, also returns a refreshed + /// hint (`None` when the hint hit or no hint is representable). + pub(crate) fn get_item_opt_refresh_hint( + &self, + key: &K, + hint: u16, + vm: &VirtualMachine, + ) -> PyResult)>> { + if self.exact_dict(vm) { + if let Some(value) = self.entries.get_hint(vm, key, usize::from(hint))? { + return Ok(Some((value, None))); + } + self.entries.get_with_hint(vm, key) + } else { + Ok(self.get_item_opt(key, vm)?.map(|value| (value, None))) + } + } + + /// Store using a cached entry index hint for the value-replace fast path. + /// + /// On a hint miss, returns a refreshed hint for the key (`None` when the + /// hint hit or no hint is representable). + pub(crate) fn set_item_with_hint( + &self, + key: &K, + hint: u16, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult> { + if self.exact_dict(vm) { + self.entries + .insert_with_hint(vm, key, usize::from(hint), value) + } else { + self.as_object().set_item(key, value, vm)?; + Ok(None) + } + } + + /// Current keys-version stamp of the underlying storage (0 if unset). + pub(crate) fn keys_version(&self) -> u32 { + self.entries.keys_version() + } + + /// Current keys-version stamp, assigning one if none is set. + /// + /// Returns 0 for dict subclasses: their lookup can be overridden, so a + /// key-set attestation on the raw storage must never be cached for them. + pub(crate) fn assign_keys_version(&self, vm: &VirtualMachine) -> u32 { + if self.exact_dict(vm) { + self.entries.assign_keys_version() + } else { + 0 + } + } + pub fn get_item(&self, key: &K, vm: &VirtualMachine) -> PyResult { if self.exact_dict(vm) { self.inner_getitem(key, vm) diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 8112bbe25..3b5f5617f 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -20,8 +20,8 @@ use alloc::fmt; use core::mem::size_of; use core::ops::ControlFlow; use core::sync::atomic::{ - AtomicU64, - Ordering::{Acquire, Release}, + AtomicU32, AtomicU64, + Ordering::{AcqRel, Acquire, Relaxed, Release}, }; use num_traits::ToPrimitive; @@ -40,6 +40,104 @@ type EntryIndex = usize; pub(crate) struct Dict { inner: PyRwLock>, version: AtomicU64, + /// Keys-version stamp, assigned lazily by `assign_keys_version` and + /// reset to 0 whenever the key set changes. Value-only updates keep it. + /// + /// A nonzero stamp identifies either a *shape* — an exact hole-free + /// entry sequence of interned string keys, shared by every dict with + /// that layout — or, when no shape is derivable, this dict's key set + /// frozen at assignment time. Either way a stamp match guarantees the + /// entry layout is exactly the one the stamp was issued for, so entry + /// indexes cached against a stamp stay valid wherever the stamp matches. + keys_version: AtomicU32, +} + +/// Source of keys-version stamps. Allocated globally so a shape stamp and a +/// dict-unique stamp can never collide. +static KEYS_VERSION: AtomicU32 = AtomicU32::new(0); + +/// Allocate a new keys-version stamp. Returns 0 once the stamp space is +/// exhausted; stamps are only allocated on specialization, so exhaustion is +/// unrealistic in practice. +fn next_keys_version() -> u32 { + KEYS_VERSION + .fetch_update(Relaxed, Relaxed, |v| v.checked_add(1)) + .map_or(0, |v| v + 1) +} + +/// Largest key count eligible for a shared shape stamp. +const SHAPE_MAX_KEYS: usize = 32; +/// Shape registry slot count (power of two). +const SHAPE_TABLE_SIZE: usize = 1 << 12; +/// Linear-probe limit before giving up on registering a shape. +const SHAPE_MAX_PROBE: usize = 8; + +/// A registered shape: the ordered interned-key-pointer sequence of a +/// hole-free dict, plus the stamp shared by every dict with that layout. +/// Interned strings are never freed, so the addresses are stable identities. +struct ShapeData { + keys: Box<[usize]>, + stamp: u32, +} + +/// Lock-free registry mapping shapes to shared stamps. Fixed-size open +/// addressing; slots are installed with CAS and never removed, so a stamp +/// permanently means "exactly this entry sequence". Registered `ShapeData` +/// is intentionally leaked (bounded by the table size). Lock-free makes the +/// registry safe across fork() without reinitialization. +static SHAPE_TABLE: std::sync::LazyLock]>> = + std::sync::LazyLock::new(|| { + (0..SHAPE_TABLE_SIZE) + .map(|_| core::sync::atomic::AtomicPtr::new(core::ptr::null_mut())) + .collect() + }); + +fn shape_stamp(shape: &[usize]) -> Option { + use core::hash::BuildHasher; + use core::sync::atomic::AtomicPtr; + // The hasher seed must be process-stable so equal shapes always probe + // the same slots. + static SHAPE_HASHER: std::sync::LazyLock = + std::sync::LazyLock::new(Default::default); + let hash = SHAPE_HASHER.hash_one(shape) as usize; + let mut candidate: *mut ShapeData = core::ptr::null_mut(); + let mut result = None; + for probe in 0..SHAPE_MAX_PROBE { + let slot: &AtomicPtr = &SHAPE_TABLE[(hash + probe) & (SHAPE_TABLE_SIZE - 1)]; + let mut installed = slot.load(Acquire); + if installed.is_null() { + if candidate.is_null() { + let stamp = next_keys_version(); + if stamp == 0 { + break; + } + candidate = Box::into_raw(Box::new(ShapeData { + keys: shape.into(), + stamp, + })); + } + match slot.compare_exchange(core::ptr::null_mut(), candidate, AcqRel, Acquire) { + Ok(_) => { + // SAFETY: candidate was just leaked into the table. + result = Some(unsafe { (*candidate).stamp }); + candidate = core::ptr::null_mut(); + break; + } + Err(current) => installed = current, + } + } + // SAFETY: non-null slots reference leaked ShapeData, never freed. + let data = unsafe { &*installed }; + if *data.keys == *shape { + result = Some(data.stamp); + break; + } + } + if !candidate.is_null() { + // SAFETY: the candidate lost the race and was never shared. + drop(unsafe { Box::from_raw(candidate) }); + } + result } unsafe impl Traverse for Dict { @@ -105,6 +203,7 @@ impl Clone for Dict { Self { inner: PyRwLock::new(self.inner.read().clone()), version: AtomicU64::new(0), + keys_version: AtomicU32::new(0), } } } @@ -119,6 +218,7 @@ impl Default for Dict { entries: Vec::new(), }), version: AtomicU64::new(0), + keys_version: AtomicU32::new(0), } } } @@ -272,6 +372,83 @@ impl Dict { self.version.fetch_add(1, Release); } + /// Current keys-version stamp, or 0 if none has been assigned since the + /// last key-set change. Equal nonzero stamps guarantee an unchanged key + /// set (values may differ). + pub(crate) fn keys_version(&self) -> u32 { + self.keys_version.load(Acquire) + } + + /// Return the current keys-version stamp, assigning one if none is set. + /// Returns 0 only if no stamp could be allocated. + /// + /// When the dict is hole-free and all keys are interned strings, the + /// stamp is the *shared shape stamp* for that exact key sequence, so + /// dicts with identical layouts (e.g. instances of the same class built + /// by the same `__init__`) carry equal stamps and one cached stamp or + /// entry index serves them all. Otherwise a dict-unique stamp is used. + /// + /// The shape inspection and the stamp install happen under the inner + /// read lock. Key-set changes reset the stamp under the write lock, so + /// an installed stamp always attests the layout it was derived from. + pub(crate) fn assign_keys_version(&self) -> u32 { + let version = self.keys_version.load(Acquire); + if version != 0 { + return version; + } + let inner = self.read(); + // Re-check under the lock: a concurrent assign may have won. + let version = self.keys_version.load(Acquire); + if version != 0 { + return version; + } + let new_version = Self::derive_shape_stamp(&inner).unwrap_or_else(next_keys_version); + if new_version == 0 { + return 0; + } + // Only install over 0 so an already-valid stamp is never replaced. + match self + .keys_version + .compare_exchange(0, new_version, AcqRel, Acquire) + { + Ok(_) => new_version, + Err(current) => current, + } + } + + /// Compute the shared shape stamp for the current layout, if it + /// qualifies: hole-free entries, bounded size, all keys interned strings. + fn derive_shape_stamp(inner: &DictInner) -> Option { + if inner.entries.len() != inner.used || inner.used > SHAPE_MAX_KEYS { + return None; + } + let shape = inner + .entries + .iter() + .map(|entry| { + let key = &entry.as_ref()?.key; + key.is_interned() + .then(|| key.as_ref() as *const PyObject as usize) + }) + .collect::>>()?; + shape_stamp(&shape) + } + + /// Reset the keys-version stamp on a key-set change (insert of a new + /// key, deletion, or clear). Value-only updates keep the stamp. + /// + /// Must be called while holding the write lock, *before* the key set is + /// modified: a lock-free stamp reader that still observes the old stamp + /// then provably ran before the change became visible, so acting on the + /// old key set is linearizable. A stamp assigned concurrently (between + /// this reset and the mutation) can only be trusted by a caller whose + /// subsequent probe serializes after the mutation through the inner + /// lock, which then reflects the new key set. Since stamps are never + /// reused, a cached stamp can never spuriously match again. + fn invalidate_keys_version(&self) { + self.keys_version.store(0, Release); + } + fn read(&self) -> PyRwLockReadGuard<'_, DictInner> { self.inner.read() } @@ -320,6 +497,7 @@ impl Dict { // Dict was resized since lookup, retry continue; } + self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key.to_pyobject(vm), value, entry_index); self.bump_version(); break None; @@ -366,6 +544,62 @@ impl Dict { Ok(u16::try_from(index).ok()) } + /// Retrieve a key along with its entry index, for hint caching. + /// + /// Same as [`Self::get`], but on a hit also returns the entry index + /// usable as a `hint` for [`Self::get_hint`] (`None` if it doesn't fit). + pub(crate) fn get_with_hint( + &self, + vm: &VirtualMachine, + key: &K, + ) -> PyResult)>> { + let hash = key.key_hash(vm)?; + let ret = loop { + let (entry, index_index) = self.lookup(vm, key, hash, None)?; + if let Some(index) = entry.index() { + let inner = self.read(); + if let Some(entry) = inner.get_entry_checked(index, index_index) { + // The dict was not changed since we did lookup + break Some((entry.value.clone(), u16::try_from(index).ok())); + } + // The dict was changed since we did lookup. Let's try again. + } else { + break None; + } + }; + Ok(ret) + } + + /// Replace the value at entry index `hint` if that entry's key is + /// identical to `key`, otherwise fall back to a full probing store. + /// + /// On a hint miss, returns a refreshed hint for the key (`None` when the + /// hint hit or no hint is representable). + pub(crate) fn insert_with_hint( + &self, + vm: &VirtualMachine, + key: &K, + hint: usize, + value: T, + ) -> PyResult> { + let value = { + let mut inner = self.write(); + match inner.entries.get_mut(hint) { + Some(Some(entry)) if key.key_is(&entry.key) => { + let removed = core::mem::replace(&mut entry.value, value); + self.bump_version(); + drop(inner); + // defer dec RC until after the lock is released + drop(removed); + return Ok(None); + } + _ => value, + } + }; + self.insert(vm, key, value)?; + self.hint_for_key(vm, key) + } + /// Fast path lookup using a cached entry index (`hint`). /// /// Returns `None` if the hint is stale or the key no longer matches. @@ -433,6 +667,7 @@ impl Dict { pub(crate) fn clear(&self) { let _removed = { let mut inner = self.write(); + self.invalidate_keys_version(); inner.indices.clear(); inner.indices.resize(8, IndexEntry::FREE); inner.used = 0; @@ -521,6 +756,7 @@ impl Dict { if inner.indices.get(index_index) != Some(&entry) { continue; } + self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key.to_owned(), value, entry); self.bump_version(); break None; @@ -551,6 +787,7 @@ impl Dict { let value = default .take() .expect("default must only be computed on insertion")(); + self.invalidate_keys_version(); inner.unchecked_push( index_index, hash, @@ -594,6 +831,7 @@ impl Dict { .expect("default must only be computed on insertion")(); let key_obj = key.to_pyobject(vm); let ret = (key_obj.clone(), value.clone()); + self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key_obj, value, index_entry); self.bump_version(); return Ok(ret); @@ -787,6 +1025,7 @@ impl Dict { // The dict was changed since we did lookup. Let's try again. _ => return Ok(ControlFlow::Continue(())), } + self.invalidate_keys_version(); *unsafe { // index_index is result of lookup inner.indices.get_unchecked_mut(index_index) @@ -822,6 +1061,7 @@ impl Dict { break entry; } }; + self.invalidate_keys_version(); inner.used -= 1; *unsafe { // entry.index always refers valid index @@ -843,6 +1083,7 @@ impl Dict { /// This is used for circular reference resolution in GC. /// Requires &mut self to avoid lock contention. pub(crate) fn drain_entries(&mut self) -> impl Iterator + '_ { + self.keys_version.store(0, Release); let inner = self.inner.get_mut(); inner.used = 0; inner.filled = 0; diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index ae7f2c061..2743bf3d5 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -4430,18 +4430,11 @@ impl ExecutingFrame<'_> { let type_version = self.code.instructions.read_cache_u32(cache_base + 1); if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version { - // Check instance dict doesn't shadow the method - let shadowed = if let Some(dict) = owner.dict() { - match dict.get_item_opt(attr_name, vm) { - Ok(Some(_)) => true, - Ok(None) => false, - Err(_) => { - // Dict lookup error -> use safe path. - return self.load_attr_slow(vm, oparg); - } - } - } else { - false + // Check instance dict doesn't shadow the method. + let shadowed = match self.shadowing_instance_attr(cache_base, attr_name, vm) { + Ok(shadowed) => shadowed.is_some(), + // Dict lookup error -> use safe path. + Err(_) => return self.load_attr_slow(vm, oparg), }; if !shadowed @@ -4489,16 +4482,29 @@ impl ExecutingFrame<'_> { if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version && let Some(dict) = owner.dict() - && let Some(value) = dict.get_item_opt(attr_name, vm)? { - self.pop_value(); - if oparg.is_method() { - self.push_value(value); - self.push_value_opt(None); - } else { - self.push_value(value); + // Try the cached entry index first; a hit is an identity + // check on the entry key instead of a hash probe. + let hint = self.code.instructions.read_cache_u16(cache_base + 3); + if let Some((value, refreshed)) = + dict.get_item_opt_refresh_hint(attr_name, hint, vm)? + { + if let Some(new_hint) = refreshed { + unsafe { + self.code + .instructions + .write_cache_u16(cache_base + 3, new_hint); + } + } + self.pop_value(); + if oparg.is_method() { + self.push_value(value); + self.push_value_opt(None); + } else { + self.push_value(value); + } + return Ok(None); } - return Ok(None); } self.load_attr_slow(vm, oparg) @@ -4559,9 +4565,7 @@ impl ExecutingFrame<'_> { if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version { // Instance dict has priority — check if attr is shadowed - if let Some(dict) = owner.dict() - && let Some(value) = dict.get_item_opt(attr_name, vm)? - { + if let Some(value) = self.shadowing_instance_attr(cache_base, attr_name, vm)? { self.pop_value(); if oparg.is_method() { self.push_value(value); @@ -4725,7 +4729,10 @@ impl ExecutingFrame<'_> { { self.pop_value(); // owner let value = self.pop_value(); - dict.set_item(attr_name, value, vm)?; + // The key was absent at specialization time, but this + // very store inserts it; hint learning makes later + // executions replace by entry index. + self.store_attr_dict_hinted(&dict, attr_name, value, cache_base, vm)?; return Ok(None); } self.store_attr(vm, attr_idx) @@ -4744,7 +4751,7 @@ impl ExecutingFrame<'_> { { self.pop_value(); // owner let value = self.pop_value(); - dict.set_item(attr_name, value, vm)?; + self.store_attr_dict_hinted(&dict, attr_name, value, cache_base, vm)?; return Ok(None); } self.store_attr(vm, attr_idx) @@ -8055,6 +8062,61 @@ impl ExecutingFrame<'_> { Ok(None) } + /// Store an instance attribute through the cached entry index at + /// `cache_base + 3`, refreshing the cache when the hint missed. + fn store_attr_dict_hinted( + &mut self, + dict: &Py, + attr_name: &'static PyStrInterned, + value: PyObjectRef, + cache_base: usize, + vm: &VirtualMachine, + ) -> PyResult<()> { + let hint = self.code.instructions.read_cache_u16(cache_base + 3); + if let Some(new_hint) = dict.set_item_with_hint(attr_name, hint, value, vm)? { + unsafe { + self.code + .instructions + .write_cache_u16(cache_base + 3, new_hint); + } + } + Ok(()) + } + + /// Shadow check for method/nondescriptor loads: return the instance + /// attribute shadowing the cached class attr, or `None` if not shadowed. + /// + /// A keys-version stamp of the instance dict is kept in the pointer cache + /// at `cache_base + 3`. While the dict reports the same stamp, its key + /// set is unchanged since the name was last verified absent, so the probe + /// is skipped. On a verified-absent probe the current stamp is recorded + /// for the next execution. + fn shadowing_instance_attr( + &self, + cache_base: usize, + attr_name: &'static PyStrInterned, + vm: &VirtualMachine, + ) -> PyResult> { + let Some(dict) = self.top_value().dict() else { + return Ok(None); + }; + let stamp = self.code.instructions.read_cache_ptr(cache_base + 3); + if stamp != 0 && stamp == dict.keys_version() as usize { + return Ok(None); + } + // Take the stamp before probing so it attests the probed key set. + let stamp = dict.assign_keys_version(vm); + if let Some(value) = dict.get_item_opt(attr_name, vm)? { + return Ok(Some(value)); + } + unsafe { + self.code + .instructions + .write_cache_ptr(cache_base + 3, stamp as usize); + } + Ok(None) + } + /// Read a cached descriptor pointer and validate it against the expected /// type version, using a lock-free double-check pattern: /// 1. read pointer → incref (try_to_owned) @@ -8408,10 +8470,12 @@ impl ExecutingFrame<'_> { // attribute is missing on both the class and the current // instance, keep the generic opcode and just enter // cooldown instead of specializing a repeated miss path. - let has_instance_attr = if let Some(dict) = obj.dict() { - match dict.get_item_opt(attr_name, _vm) { - Ok(Some(_)) => true, - Ok(None) => false, + // A present attribute always specializes; when no entry + // index is representable the hint degrades to 0 and the + // handler simply keeps taking its full-probe fallback. + let instance_attr_hint = if let Some(dict) = obj.dict() { + match dict.get_item_opt_refresh_hint(attr_name, 0, _vm) { + Ok(present) => present.map(|(_, refreshed)| refreshed.unwrap_or(0)), Err(_) => { unsafe { self.code.instructions.write_adaptive_counter( @@ -8427,13 +8491,14 @@ impl ExecutingFrame<'_> { } } } else { - false + None }; - if has_instance_attr { + if let Some(hint) = instance_attr_hint { unsafe { self.code .instructions .write_cache_u32(cache_base + 1, type_version); + self.code.instructions.write_cache_u16(cache_base + 3, hint); } self.specialize_at(instr_idx, cache_base, Instruction::LoadAttrWithHint); } else { @@ -9951,9 +10016,8 @@ impl ExecutingFrame<'_> { } } } else if let Some(dict) = owner.dict() { - let use_hint = match dict.get_item_opt(attr_name, vm) { - Ok(Some(_)) => true, - Ok(None) => false, + let hint = match dict.hint_for_key(attr_name, vm) { + Ok(hint) => hint, Err(_) => { unsafe { self.code.instructions.write_adaptive_counter( @@ -9970,11 +10034,14 @@ impl ExecutingFrame<'_> { self.code .instructions .write_cache_u32(cache_base + 1, type_version); + self.code + .instructions + .write_cache_u16(cache_base + 3, hint.unwrap_or(0)); } self.specialize_at( instr_idx, cache_base, - if use_hint { + if hint.is_some() { Instruction::StoreAttrWithHint } else { Instruction::StoreAttrInstanceValue diff --git a/extra_tests/snippets/vm_specialization.py b/extra_tests/snippets/vm_specialization.py index 2c884cc2f..f2415b4b2 100644 --- a/extra_tests/snippets/vm_specialization.py +++ b/extra_tests/snippets/vm_specialization.py @@ -69,3 +69,145 @@ def check_latin1_subscr_singleton_after_warmup(): check_latin1_subscr_singleton_after_warmup() + + +## LOAD_ATTR_METHOD_WITH_VALUES: keys-version shadow check + + +class MethodHolder: + def m(self): + return "method" + + +def method_shadowed_after_specialization(): + obj = MethodHolder() + obj.pad = 1 + for _ in range(300): + assert obj.m() == "method" + # Shadowing after warmup must deopt the stamp-based shadow skip. + obj.m = lambda: "instance" + assert obj.m() == "instance" + del obj.m + assert obj.m() == "method" + obj.__dict__["m"] = lambda: "dict" + assert obj.m() == "dict" + del obj.__dict__["m"] + assert obj.m() == "method" + + +method_shadowed_after_specialization() + + +def method_with_value_only_updates(): + obj = MethodHolder() + obj.pad = 0 + for i in range(500): + obj.pad = i # value-only update keeps the keys-version stamp + assert obj.m() == "method" + + +method_with_value_only_updates() + + +## LOAD_ATTR_WITH_HINT / STORE_ATTR: entry-index hint invalidation + + +class Plain: + pass + + +def load_hint_survives_key_churn(): + obj = Plain() + obj.a = 1 + obj.b = 2 + obj.x = "first" + for _ in range(300): + assert obj.x == "first" + del obj.a + del obj.b + assert obj.x == "first" + del obj.x + try: + obj.x + except AttributeError: + pass + else: + raise AssertionError("expected AttributeError") + obj.x = "second" + assert obj.x == "second" + + +load_hint_survives_key_churn() + + +def store_hint_survives_dict_replacement(): + obj = Plain() + obj.v = 0 + for i in range(500): + obj.v = i + assert obj.v == i + obj.__dict__ = {"v": "fresh"} + for i in range(300): + obj.v = i + assert obj.v == i + obj.__dict__.clear() + obj.v = "back" + assert obj.v == "back" + + +store_hint_survives_dict_replacement() + + +## Shared shape stamps: same-layout instances share the shadow-check stamp + + +class ShapedCounter: + def __init__(self): + self.a = 1 + self.b = 2 + + def m(self): + return "method" + + +def shape_sharing_shadow_one_instance(): + objs = [ShapedCounter() for _ in range(30)] + for _ in range(300): + for o in objs: + assert o.m() == "method" + objs[13].m = lambda: "thirteen" + for i, o in enumerate(objs): + expected = "thirteen" if i == 13 else "method" + assert o.m() == expected + del objs[13].m + for o in objs: + assert o.m() == "method" + + +shape_sharing_shadow_one_instance() + + +def holey_dict_falls_back(): + class Holey: + def m(self): + return "g" + + def call_m(obj): + # single LOAD_ATTR cache site shared by both instances + return obj.m() + + g1, g2 = Holey(), Holey() + for o in (g1, g2): + o.x = 1 + o.y = 2 + o.z = 3 + del o.y # leaves a hole in the entries + for _ in range(300): + assert call_m(g1) == "g" + assert call_m(g2) == "g" + g2.m = lambda: "g2" + assert call_m(g1) == "g" + assert call_m(g2) == "g2" + + +holey_dict_falls_back()