From 2745084d59fbeeb93c798daa85b3e476d6f53142 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Tue, 6 Oct 2026 02:01:37 -0500 Subject: [PATCH 1/6] Probe the dict index table only once when inserting or deleting a str key Inserting a str key probed the index table twice: once in the lookup to see whether the key is present, again in find_empty_slot() to find the slot for it. Deleting one probed again in lookdict_index(). do_lookup() can now report the slot its probe ends on: the slot of the entry if the key is found, otherwise the slot find_empty_slot() would return. Only exact str keys in all-unicode tables use it, because that comparison cannot run Python code and mutate the dict during the probe. Readers pass NULL and generate the same code as before. The writer-side probe is a separate function and find_empty_slot() stays out of line, so keys that cannot use it (int, mixed) do not slow down. --- Objects/dictobject.c | 131 +++++++++++++++++++++++++++++++++---------- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index bab983c2509c2c..25208cd223eac1 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -1113,15 +1113,32 @@ lookdict_index(PyDictKeysObject *k, Py_hash_t hash, Py_ssize_t index) Py_UNREACHABLE(); } +static inline int +is_unusable_slot(Py_ssize_t ix) +{ +#ifdef Py_GIL_DISABLED + return ix >= 0 || ix == DKIX_DUMMY; +#else + return ix >= 0; +#endif +} + +/* If hashpos is not NULL, also store in *hashpos the slot of the index table + an insertion or deletion of the key needs, so that the caller does not have + to probe again: if the key is found, the slot that refers to its entry (see + lookdict_index()), otherwise the slot find_empty_slot() would return. + Only valid if check_lookup() cannot mutate the dict. */ static inline Py_ALWAYS_INLINE Py_ssize_t do_lookup(PyDictObject *mp, PyDictKeysObject *dk, PyObject *key, Py_hash_t hash, - int (*check_lookup)(PyDictObject *, PyDictKeysObject *, void *, Py_ssize_t ix, PyObject *key, Py_hash_t)) + int (*check_lookup)(PyDictObject *, PyDictKeysObject *, void *, Py_ssize_t ix, PyObject *key, Py_hash_t), + Py_ssize_t *hashpos) { void *ep0 = _DK_ENTRIES(dk); size_t mask = DK_MASK(dk); size_t perturb = hash; size_t i = (size_t)hash & mask; Py_ssize_t ix; + Py_ssize_t freeslot = -1; for (;;) { ix = dictkeys_get_index(dk, i); if (ix >= 0) { @@ -1129,12 +1146,23 @@ do_lookup(PyDictObject *mp, PyDictKeysObject *dk, PyObject *key, Py_hash_t hash, if (cmp < 0) { return cmp; } else if (cmp) { + if (hashpos) { + *hashpos = (Py_ssize_t)i; + } return ix; } } else if (ix == DKIX_EMPTY) { + if (hashpos) { + *hashpos = freeslot >= 0 ? freeslot : (Py_ssize_t)i; + } return DKIX_EMPTY; } + else if (hashpos && freeslot < 0 && !is_unusable_slot(ix)) { + // Reuse the first dummy slot, otherwise repeated insertions + // and deletions would make the probe sequence longer. + freeslot = (Py_ssize_t)i; + } perturb >>= PERTURB_SHIFT; i = mask & (i*5 + perturb + 1); @@ -1145,12 +1173,21 @@ do_lookup(PyDictObject *mp, PyDictKeysObject *dk, PyObject *key, Py_hash_t hash, if (cmp < 0) { return cmp; } else if (cmp) { + if (hashpos) { + *hashpos = (Py_ssize_t)i; + } return ix; } } else if (ix == DKIX_EMPTY) { + if (hashpos) { + *hashpos = freeslot >= 0 ? freeslot : (Py_ssize_t)i; + } return DKIX_EMPTY; } + else if (hashpos && freeslot < 0 && !is_unusable_slot(ix)) { + freeslot = (Py_ssize_t)i; + } perturb >>= PERTURB_SHIFT; i = mask & (i*5 + perturb + 1); } @@ -1189,7 +1226,7 @@ compare_unicode_generic(PyDictObject *mp, PyDictKeysObject *dk, static Py_ssize_t unicodekeys_lookup_generic(PyDictObject *mp, PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { - return do_lookup(mp, dk, key, hash, compare_unicode_generic); + return do_lookup(mp, dk, key, hash, compare_unicode_generic, NULL); } static inline int @@ -1210,7 +1247,16 @@ compare_unicode_unicode(PyDictObject *mp, PyDictKeysObject *dk, static Py_ssize_t _Py_HOT_FUNCTION unicodekeys_lookup_unicode(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { - return do_lookup(NULL, dk, key, hash, compare_unicode_unicode); + return do_lookup(NULL, dk, key, hash, compare_unicode_unicode, NULL); +} + +/* Like unicodekeys_lookup_unicode(), but also reports in *hashpos the index + table slot an insertion or deletion of the key needs (see do_lookup()). */ +static Py_ssize_t +unicodekeys_lookup_unicode_pos(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash, + Py_ssize_t *hashpos) +{ + return do_lookup(NULL, dk, key, hash, compare_unicode_unicode, hashpos); } static inline int @@ -1244,7 +1290,7 @@ compare_generic(PyDictObject *mp, PyDictKeysObject *dk, static Py_ssize_t dictkeys_generic_lookup(PyDictObject *mp, PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { - return do_lookup(mp, dk, key, hash, compare_generic); + return do_lookup(mp, dk, key, hash, compare_generic, NULL); } static bool @@ -1525,7 +1571,7 @@ compare_unicode_generic_threadsafe(PyDictObject *mp, PyDictKeysObject *dk, static Py_ssize_t unicodekeys_lookup_generic_threadsafe(PyDictObject *mp, PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { - return do_lookup(mp, dk, key, hash, compare_unicode_generic_threadsafe); + return do_lookup(mp, dk, key, hash, compare_unicode_generic_threadsafe, NULL); } static inline Py_ALWAYS_INLINE int @@ -1561,7 +1607,7 @@ compare_unicode_unicode_threadsafe(PyDictObject *mp, PyDictKeysObject *dk, static Py_ssize_t _Py_HOT_FUNCTION unicodekeys_lookup_unicode_threadsafe(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { - return do_lookup(NULL, dk, key, hash, compare_unicode_unicode_threadsafe); + return do_lookup(NULL, dk, key, hash, compare_unicode_unicode_threadsafe, NULL); } static inline Py_ALWAYS_INLINE int @@ -1598,7 +1644,7 @@ compare_generic_threadsafe(PyDictObject *mp, PyDictKeysObject *dk, static Py_ssize_t dictkeys_generic_lookup_threadsafe(PyDictObject *mp, PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { - return do_lookup(mp, dk, key, hash, compare_generic_threadsafe); + return do_lookup(mp, dk, key, hash, compare_generic_threadsafe, NULL); } Py_ssize_t @@ -1864,16 +1910,6 @@ _PyDict_EnablePerThreadRefcounting(PyObject *op) #endif } -static inline int -is_unusable_slot(Py_ssize_t ix) -{ -#ifdef Py_GIL_DISABLED - return ix >= 0 || ix == DKIX_DUMMY; -#else - return ix >= 0; -#endif -} - /* Internal function to find slot for an item from its hash when it is known that the key is not present in the dict. */ @@ -1893,15 +1929,36 @@ find_empty_slot(PyDictKeysObject *keys, Py_hash_t hash) return i; } +/* Like _Py_dict_lookup(), for callers which are about to insert or delete + the key. *hashpos is set as by do_lookup() for the most common case + (exact str key in a combined unicode table), else to -1. */ +static inline Py_ALWAYS_INLINE Py_ssize_t +dict_lookup_pos(PyDictObject *mp, PyObject *key, Py_hash_t hash, + PyObject **value_addr, Py_ssize_t *hashpos) +{ + _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(mp); + PyDictKeysObject *dk = mp->ma_keys; + if (dk->dk_kind == DICT_KEYS_UNICODE && PyUnicode_CheckExact(key)) { + Py_ssize_t ix = unicodekeys_lookup_unicode_pos(dk, key, hash, hashpos); + *value_addr = ix >= 0 ? DK_UNICODE_ENTRIES(dk)[ix].me_value : NULL; + return ix; + } + *hashpos = -1; + return _Py_dict_lookup(mp, key, hash, value_addr); +} + static int insertion_resize(PyDictObject *mp, int unicode) { return dictresize(mp, calculate_log2_keysize(GROWTH_RATE(mp)), unicode); } +/* hashpos is the slot of the index table found by dict_lookup_pos(), + or -1 if it is not known. */ static inline int insert_combined_dict(PyDictObject *mp, - Py_hash_t hash, PyObject *key, PyObject *value) + Py_hash_t hash, PyObject *key, PyObject *value, + Py_ssize_t hashpos) { // gh-140551: If dict was cleared in _Py_dict_lookup, // we have to resize one more time to force general key kind. @@ -1909,6 +1966,7 @@ insert_combined_dict(PyDictObject *mp, if (insertion_resize(mp, 0) < 0) return -1; assert(mp->ma_keys->dk_kind == DICT_KEYS_GENERAL); + hashpos = -1; } if (mp->ma_keys->dk_usable <= 0) { @@ -1916,12 +1974,16 @@ insert_combined_dict(PyDictObject *mp, if (insertion_resize(mp, 1) < 0) { return -1; } + hashpos = -1; } _PyDict_NotifyEvent(PyDict_EVENT_ADDED, mp, key, value); FT_ATOMIC_STORE_UINT32_RELAXED(mp->ma_keys->dk_version, 0); - Py_ssize_t hashpos = find_empty_slot(mp->ma_keys, hash); + if (hashpos < 0) { + hashpos = find_empty_slot(mp->ma_keys, hash); + } + assert(hashpos == find_empty_slot(mp->ma_keys, hash)); dictkeys_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries); if (DK_IS_UNICODE(mp->ma_keys)) { @@ -2056,6 +2118,7 @@ insertdict(PyDictObject *mp, PyObject *old_value = NULL; Py_ssize_t ix; + Py_ssize_t hashpos = -1; if (_PyDict_HasSplitTable(mp) && PyUnicode_CheckExact(key)) { ix = insert_split_key(mp->ma_keys, key, hash); @@ -2068,7 +2131,7 @@ insertdict(PyDictObject *mp, // No space in shared keys. Go to insert_combined_dict() below. } else { - ix = _Py_dict_lookup(mp, key, hash, &old_value); + ix = dict_lookup_pos(mp, key, hash, &old_value, &hashpos); if (ix == DKIX_ERROR) goto Fail; } @@ -2081,7 +2144,7 @@ insertdict(PyDictObject *mp, // // NOTE: ix may not be DKIX_EMPTY because split table may have key // without value. - if (insert_combined_dict(mp, hash, key, value) < 0) { + if (insert_combined_dict(mp, hash, key, value, hashpos) < 0) { goto Fail; } STORE_USED(mp, mp->ma_used + 1); @@ -2930,16 +2993,21 @@ delete_index_from_values(PyDictValues *values, Py_ssize_t ix) values->size = size; } +/* hashpos is the slot of the index table found by dict_lookup_pos(), + or -1 if it is not known. */ static void delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix, - PyObject *old_value) + PyObject *old_value, Py_ssize_t hashpos) { assert(can_modify_dict(mp)); PyObject *old_key; - Py_ssize_t hashpos = lookdict_index(mp->ma_keys, hash, ix); + if (hashpos < 0) { + hashpos = lookdict_index(mp->ma_keys, hash, ix); + } assert(hashpos >= 0); + assert(hashpos == lookdict_index(mp->ma_keys, hash, ix)); STORE_USED(mp, mp->ma_used - 1); if (_PyDict_HasSplitTable(mp)) { @@ -3006,7 +3074,8 @@ _PyDict_DelItem_KnownHash_LockHeld(PyObject *op, PyObject *key, Py_hash_t hash) assert(key); assert(hash != -1); - ix = _Py_dict_lookup(mp, key, hash, &old_value); + Py_ssize_t hashpos; + ix = dict_lookup_pos(mp, key, hash, &old_value, &hashpos); if (ix == DKIX_ERROR) return -1; if (ix == DKIX_EMPTY || old_value == NULL) { @@ -3015,7 +3084,7 @@ _PyDict_DelItem_KnownHash_LockHeld(PyObject *op, PyObject *key, Py_hash_t hash) } _PyDict_NotifyEvent(PyDict_EVENT_DELETED, mp, key, NULL); - delitem_common(mp, hash, ix, old_value); + delitem_common(mp, hash, ix, old_value, hashpos); return 0; } @@ -3060,7 +3129,7 @@ delitemif_lock_held(PyObject *op, PyObject *key, if (res > 0) { _PyDict_NotifyEvent(PyDict_EVENT_DELETED, mp, key, NULL); - delitem_common(mp, hash, ix, old_value); + delitem_common(mp, hash, ix, old_value, -1); return 1; } else { return 0; @@ -3296,7 +3365,8 @@ _PyDict_Pop_KnownHash(PyDictObject *mp, PyObject *key, Py_hash_t hash, } PyObject *old_value; - Py_ssize_t ix = _Py_dict_lookup(mp, key, hash, &old_value); + Py_ssize_t hashpos; + Py_ssize_t ix = dict_lookup_pos(mp, key, hash, &old_value, &hashpos); if (ix == DKIX_ERROR) { if (result) { *result = NULL; @@ -3313,7 +3383,7 @@ _PyDict_Pop_KnownHash(PyDictObject *mp, PyObject *key, Py_hash_t hash, assert(old_value != NULL); _PyDict_NotifyEvent(PyDict_EVENT_DELETED, mp, key, NULL); - delitem_common(mp, hash, ix, Py_NewRef(old_value)); + delitem_common(mp, hash, ix, Py_NewRef(old_value), hashpos); ASSERT_CONSISTENT(mp); if (result) { @@ -4837,6 +4907,7 @@ dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_valu PyObject *value; Py_hash_t hash; Py_ssize_t ix; + Py_ssize_t hashpos = -1; hash = _PyObject_HashDictKey(key); if (hash == -1) { @@ -4878,7 +4949,7 @@ dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_valu // No space in shared keys. Go to insert_combined_dict() below. } else { - ix = _Py_dict_lookup(mp, key, hash, &value); + ix = dict_lookup_pos(mp, key, hash, &value, &hashpos); if (ix == DKIX_ERROR) { if (result) { *result = NULL; @@ -4891,7 +4962,7 @@ dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_valu value = default_value; // See comment to this function in insertdict. - if (insert_combined_dict(mp, hash, Py_NewRef(key), Py_NewRef(value)) < 0) { + if (insert_combined_dict(mp, hash, Py_NewRef(key), Py_NewRef(value), hashpos) < 0) { Py_DECREF(key); Py_DECREF(value); if (result) { From 398d2fe6603fd05372de0eac486173829fd38d65 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Tue, 6 Oct 2026 02:01:40 -0500 Subject: [PATCH 2/6] Do not overwrite the hash of a deleted dict entry Entries whose value is NULL are never reached through the index table and every scan of the entry array skips them, so their hash is never read. Drop the stores in delitem_common() and popitem(). --- Objects/dictobject.c | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 25208cd223eac1..87385b801d6410 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -1186,6 +1186,8 @@ do_lookup(PyDictObject *mp, PyDictKeysObject *dk, PyObject *key, Py_hash_t hash, return DKIX_EMPTY; } else if (hashpos && freeslot < 0 && !is_unusable_slot(ix)) { + // Reuse the first dummy slot, otherwise repeated insertions + // and deletions would make the probe sequence longer. freeslot = (Py_ssize_t)i; } perturb >>= PERTURB_SHIFT; @@ -1250,15 +1252,6 @@ unicodekeys_lookup_unicode(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) return do_lookup(NULL, dk, key, hash, compare_unicode_unicode, NULL); } -/* Like unicodekeys_lookup_unicode(), but also reports in *hashpos the index - table slot an insertion or deletion of the key needs (see do_lookup()). */ -static Py_ssize_t -unicodekeys_lookup_unicode_pos(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash, - Py_ssize_t *hashpos) -{ - return do_lookup(NULL, dk, key, hash, compare_unicode_unicode, hashpos); -} - static inline int compare_generic(PyDictObject *mp, PyDictKeysObject *dk, void *ep0, Py_ssize_t ix, PyObject *key, Py_hash_t hash) @@ -1913,7 +1906,7 @@ _PyDict_EnablePerThreadRefcounting(PyObject *op) /* Internal function to find slot for an item from its hash when it is known that the key is not present in the dict. */ -static Py_ssize_t +static inline Py_ALWAYS_INLINE Py_ssize_t find_empty_slot(PyDictKeysObject *keys, Py_hash_t hash) { assert(keys != NULL); @@ -1939,7 +1932,7 @@ dict_lookup_pos(PyDictObject *mp, PyObject *key, Py_hash_t hash, _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(mp); PyDictKeysObject *dk = mp->ma_keys; if (dk->dk_kind == DICT_KEYS_UNICODE && PyUnicode_CheckExact(key)) { - Py_ssize_t ix = unicodekeys_lookup_unicode_pos(dk, key, hash, hashpos); + Py_ssize_t ix = do_lookup(NULL, dk, key, hash, compare_unicode_unicode, hashpos); *value_addr = ix >= 0 ? DK_UNICODE_ENTRIES(dk)[ix].me_value : NULL; return ix; } @@ -3032,7 +3025,6 @@ delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix, old_key = ep->me_key; STORE_KEY(ep, NULL); STORE_VALUE(ep, NULL); - STORE_HASH(ep, 0); } Py_DECREF(old_key); } @@ -5144,7 +5136,6 @@ dict_popitem_impl(PyDictObject *self) hash = ep0[i].me_hash; value = ep0[i].me_value; STORE_KEY(&ep0[i], NULL); - STORE_HASH(&ep0[i], -1); STORE_VALUE(&ep0[i], NULL); } From ae05c807e6150abd29051bea00a3fa81ec4a7f9f Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Tue, 6 Oct 2026 02:01:42 -0500 Subject: [PATCH 3/6] Let delitem_common() transfer the value reference to its caller _PyDict_Pop_KnownHash() returns the value, so it had to incref it before delitem_common() dropped the entry's reference. Transfer that reference instead; the two callers that discard the value decref it. --- Objects/dictobject.c | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 87385b801d6410..a96ddfa7d63cb4 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -2986,8 +2986,10 @@ delete_index_from_values(PyDictValues *values, Py_ssize_t ix) values->size = size; } -/* hashpos is the slot of the index table found by dict_lookup_pos(), - or -1 if it is not known. */ +/* Remove entry ix, whose value is old_value, from the dict. The reference + the entry held to old_value is transferred to the caller. hashpos is the + slot of the index table found by dict_lookup_pos(), or -1 if it is not + known. */ static void delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix, PyObject *old_value, Py_ssize_t hashpos) @@ -3028,7 +3030,6 @@ delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix, } Py_DECREF(old_key); } - Py_DECREF(old_value); ASSERT_CONSISTENT(mp); } @@ -3077,6 +3078,7 @@ _PyDict_DelItem_KnownHash_LockHeld(PyObject *op, PyObject *key, Py_hash_t hash) _PyDict_NotifyEvent(PyDict_EVENT_DELETED, mp, key, NULL); delitem_common(mp, hash, ix, old_value, hashpos); + Py_DECREF(old_value); return 0; } @@ -3122,6 +3124,7 @@ delitemif_lock_held(PyObject *op, PyObject *key, if (res > 0) { _PyDict_NotifyEvent(PyDict_EVENT_DELETED, mp, key, NULL); delitem_common(mp, hash, ix, old_value, -1); + Py_DECREF(old_value); return 1; } else { return 0; @@ -3375,7 +3378,7 @@ _PyDict_Pop_KnownHash(PyDictObject *mp, PyObject *key, Py_hash_t hash, assert(old_value != NULL); _PyDict_NotifyEvent(PyDict_EVENT_DELETED, mp, key, NULL); - delitem_common(mp, hash, ix, Py_NewRef(old_value), hashpos); + delitem_common(mp, hash, ix, old_value, hashpos); ASSERT_CONSISTENT(mp); if (result) { From 9a2235450e2c4f47c1de8c6a39a518b973ad9b15 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Tue, 6 Oct 2026 02:01:44 -0500 Subject: [PATCH 4/6] Keep the dict.pop() body inlined into dict_pop() The probe made _PyDict_Pop_KnownHash() big enough that inlining it into dict.pop() is left to the compiler's size thresholds. Move the body to a static always-inline helper and keep the exported function as a wrapper, so the hot caller inlines it either way. --- Objects/dictobject.c | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index a96ddfa7d63cb4..8b612f6d0d8049 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -1922,9 +1922,8 @@ find_empty_slot(PyDictKeysObject *keys, Py_hash_t hash) return i; } -/* Like _Py_dict_lookup(), for callers which are about to insert or delete - the key. *hashpos is set as by do_lookup() for the most common case - (exact str key in a combined unicode table), else to -1. */ +/* _Py_dict_lookup() that also sets *hashpos for exact str keys in a + combined unicode table, and to -1 otherwise. */ static inline Py_ALWAYS_INLINE Py_ssize_t dict_lookup_pos(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject **value_addr, Py_ssize_t *hashpos) @@ -3345,9 +3344,9 @@ PyDict_Next(PyObject *op, Py_ssize_t *ppos, PyObject **pkey, PyObject **pvalue) /* Internal version of dict.pop(). */ -int -_PyDict_Pop_KnownHash(PyDictObject *mp, PyObject *key, Py_hash_t hash, - PyObject **result) +static inline Py_ALWAYS_INLINE int +pop_known_hash_lock_held(PyDictObject *mp, PyObject *key, Py_hash_t hash, + PyObject **result) { assert(PyDict_Check(mp)); assert(can_modify_dict(mp)); @@ -3390,6 +3389,13 @@ _PyDict_Pop_KnownHash(PyDictObject *mp, PyObject *key, Py_hash_t hash, return 1; } +int +_PyDict_Pop_KnownHash(PyDictObject *mp, PyObject *key, Py_hash_t hash, + PyObject **result) +{ + return pop_known_hash_lock_held(mp, key, hash, result); +} + static int pop_lock_held(PyObject *op, PyObject *key, PyObject **result) { @@ -3423,7 +3429,7 @@ pop_lock_held(PyObject *op, PyObject *key, PyObject **result) } return -1; } - return _PyDict_Pop_KnownHash(dict, key, hash, result); + return pop_known_hash_lock_held(dict, key, hash, result); } int From 766070d5386e588750ca51b2fd307d70895f0231 Mon Sep 17 00:00:00 2001 From: Lucas Zhou Date: Tue, 6 Oct 2026 02:01:46 -0500 Subject: [PATCH 5/6] Use the reported slot for split (shared) keys tables A split table shares its keys with every instance of a type, so deleting never touches the index table and never needs the slot lookdict_index() computes. Move that lookup into the combined branch of delitem_common(). insert_split_key() already looks the key up under the keys mutex, so the slot that lookup reports is still the one find_empty_slot() would return. Reuse it: one index table traversal less per new shared key (three to two in the free-threaded build, two to one otherwise). --- Objects/dictobject.c | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 8b612f6d0d8049..a5bbb5f2f0bef1 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -1186,8 +1186,6 @@ do_lookup(PyDictObject *mp, PyDictKeysObject *dk, PyObject *key, Py_hash_t hash, return DKIX_EMPTY; } else if (hashpos && freeslot < 0 && !is_unusable_slot(ix)) { - // Reuse the first dummy slot, otherwise repeated insertions - // and deletions would make the probe sequence longer. freeslot = (Py_ssize_t)i; } perturb >>= PERTURB_SHIFT; @@ -1252,6 +1250,17 @@ unicodekeys_lookup_unicode(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) return do_lookup(NULL, dk, key, hash, compare_unicode_unicode, NULL); } +/* Like unicodekeys_lookup_unicode(), but also reports in *hashpos the index + table slot an insertion or deletion of the key needs (see do_lookup()). + Kept separate from the reader above so that readers keep generating code + without the slot bookkeeping. */ +static Py_ssize_t +unicodekeys_lookup_unicode_pos(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash, + Py_ssize_t *hashpos) +{ + return do_lookup(NULL, dk, key, hash, compare_unicode_unicode, hashpos); +} + static inline int compare_generic(PyDictObject *mp, PyDictKeysObject *dk, void *ep0, Py_ssize_t ix, PyObject *key, Py_hash_t hash) @@ -1906,7 +1915,7 @@ _PyDict_EnablePerThreadRefcounting(PyObject *op) /* Internal function to find slot for an item from its hash when it is known that the key is not present in the dict. */ -static inline Py_ALWAYS_INLINE Py_ssize_t +static Py_ssize_t find_empty_slot(PyDictKeysObject *keys, Py_hash_t hash) { assert(keys != NULL); @@ -1931,7 +1940,7 @@ dict_lookup_pos(PyDictObject *mp, PyObject *key, Py_hash_t hash, _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(mp); PyDictKeysObject *dk = mp->ma_keys; if (dk->dk_kind == DICT_KEYS_UNICODE && PyUnicode_CheckExact(key)) { - Py_ssize_t ix = do_lookup(NULL, dk, key, hash, compare_unicode_unicode, hashpos); + Py_ssize_t ix = unicodekeys_lookup_unicode_pos(dk, key, hash, hashpos); *value_addr = ix >= 0 ? DK_UNICODE_ENTRIES(dk)[ix].me_value : NULL; return ix; } @@ -2002,6 +2011,7 @@ insert_split_key(PyDictKeysObject *keys, PyObject *key, Py_hash_t hash) { assert(PyUnicode_CheckExact(key)); Py_ssize_t ix; + Py_ssize_t hashpos = -1; #ifdef Py_GIL_DISABLED @@ -2018,7 +2028,7 @@ insert_split_key(PyDictKeysObject *keys, PyObject *key, Py_hash_t hash) #endif LOCK_KEYS(keys); - ix = unicodekeys_lookup_unicode(keys, key, hash); + ix = unicodekeys_lookup_unicode_pos(keys, key, hash, &hashpos); if (ix == DKIX_EMPTY && keys->dk_usable > 0) { // Insert into new slot FT_ATOMIC_STORE_UINT32_RELAXED(keys->dk_version, 0); @@ -2028,7 +2038,6 @@ insert_split_key(PyDictKeysObject *keys, PyObject *key, Py_hash_t hash) // we acquired the type lock above _PyType_Modified_Unlocked(type); } - Py_ssize_t hashpos = find_empty_slot(keys, hash); ix = keys->dk_nentries; dictkeys_set_index(keys, hashpos, ix); PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(keys)[ix]; @@ -2997,12 +3006,6 @@ delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix, PyObject *old_key; - if (hashpos < 0) { - hashpos = lookdict_index(mp->ma_keys, hash, ix); - } - assert(hashpos >= 0); - assert(hashpos == lookdict_index(mp->ma_keys, hash, ix)); - STORE_USED(mp, mp->ma_used - 1); if (_PyDict_HasSplitTable(mp)) { assert(old_value == mp->ma_values->values[ix]); @@ -3013,6 +3016,12 @@ delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix, ASSERT_CONSISTENT(mp); } else { + if (hashpos < 0) { + hashpos = lookdict_index(mp->ma_keys, hash, ix); + } + assert(hashpos >= 0); + assert(hashpos == lookdict_index(mp->ma_keys, hash, ix)); + FT_ATOMIC_STORE_UINT32_RELAXED(mp->ma_keys->dk_version, 0); dictkeys_set_index(mp->ma_keys, hashpos, DKIX_DUMMY); if (DK_IS_UNICODE(mp->ma_keys)) { @@ -3060,13 +3069,13 @@ _PyDict_DelItem_KnownHash_LockHeld(PyObject *op, PyObject *key, Py_hash_t hash) } Py_ssize_t ix; + Py_ssize_t hashpos; PyObject *old_value; PyDictObject *mp = (PyDictObject *)op; assert(can_modify_dict(mp)); assert(key); assert(hash != -1); - Py_ssize_t hashpos; ix = dict_lookup_pos(mp, key, hash, &old_value, &hashpos); if (ix == DKIX_ERROR) return -1; From 9a16abaf1ca8268465b13588cb38659c924edcb4 Mon Sep 17 00:00:00 2001 From: Lucas Zhou Date: Tue, 6 Oct 2026 02:14:45 -0500 Subject: [PATCH 6/6] gh-158898: Add a NEWS entry --- .../2026-10-06-02-12-00.gh-issue-158898.J8Y6a5.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-06-02-12-00.gh-issue-158898.J8Y6a5.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-06-02-12-00.gh-issue-158898.J8Y6a5.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-06-02-12-00.gh-issue-158898.J8Y6a5.rst new file mode 100644 index 00000000000000..5a91b00d44978c --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-06-02-12-00.gh-issue-158898.J8Y6a5.rst @@ -0,0 +1,6 @@ +Speed up inserting and deleting string keys in :class:`dict` by reusing the +index table slot found by the initial lookup instead of probing the table a +second time. :meth:`dict.pop`, :meth:`dict.setdefault` and ``del d[key]`` +are affected; lookups and other key types keep their previous behaviour. + +Patch by Lucas Zhou and Pieter Eendebak