Skip to content

Commit d0c69f8

Browse files
committed
gh-161524: Speed up copying sparse dictionaries
1 parent c8eabcc commit d0c69f8

4 files changed

Lines changed: 227 additions & 31 deletions

File tree

‎Lib/test/test_capi/test_watchers.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ def test_clone(self):
6363
d.update(d2)
6464
self.assert_events(["clone"])
6565

66+
def test_clone_sparse_dict(self):
67+
for keys in (list(range(200)), [f"key{i}" for i in range(200)]):
68+
with self.subTest(key_type=type(keys[0])):
69+
source = {key: key for key in keys}
70+
for key in keys[:180]:
71+
del source[key]
72+
73+
target = {}
74+
with self.watcher() as wid:
75+
self.watch(wid, target)
76+
target.update(source)
77+
self.assert_events(["clone"])
78+
79+
self.assertEqual(list(target.items()), list(source.items()))
80+
source.clear()
81+
self.assertEqual(len(target), 20)
82+
6683
def test_no_event_if_not_watched(self):
6784
d = {}
6885
with self.watcher() as wid:

‎Lib/test/test_dict.py‎

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def test_bool(self):
8686
self.assertIs(bool({}), False)
8787
self.assertIs(bool({1: 2}), True)
8888

89+
8990
def test_keys(self):
9091
d = {}
9192
self.assertEqual(set(d.keys()), set())
@@ -465,18 +466,120 @@ class A:
465466
self.assertEqual(gc.is_tracked(d), gc.is_tracked(d2))
466467

467468
def test_copy_noncompact(self):
468-
# Dicts don't compact themselves on del/pop operations.
469-
# Copy will use a slow merging strategy that produces
470-
# a compacted copy when roughly 33% of dict is a non-used
471-
# keys-space (to optimize memory footprint).
472-
# In this test we want to hit the slow/compacting
473-
# branch of dict.copy() and make sure it works OK.
469+
# Deletions leave holes in the entry table. Copying a sparse
470+
# dictionary must preserve its contents and compact the table.
474471
d = {k: k for k in range(1000)}
475472
for k in range(950):
476473
del d[k]
477474
d2 = d.copy()
478475
self.assertEqual(d2, d)
479476

477+
def test_sparse_dict_copy(self):
478+
for keys in ([f'key{i}' for i in range(120)], list(range(120))):
479+
for remaining in (0, 1, 10, 40, 80, 119, 120):
480+
with self.subTest(key_type=type(keys[0]), remaining=remaining):
481+
source = {key: object() for key in keys}
482+
for key in keys[:120 - remaining]:
483+
del source[key]
484+
485+
expected = list(source.items())
486+
for result in (source.copy(), dict(source), {**source}):
487+
self.assertIsNot(result, source)
488+
self.assertEqual(list(result.items()), expected)
489+
for (_, value), (_, copied) in zip(
490+
expected, result.items()):
491+
self.assertIs(copied, value)
492+
result.clear()
493+
self.assertEqual(list(source.items()), expected)
494+
495+
def test_sparse_dict_copy_reinserted_keys(self):
496+
for keys in ([f'key{i}' for i in range(120)], list(range(120))):
497+
source = {key: object() for key in keys}
498+
for key in keys[:100]:
499+
del source[key]
500+
for key in keys[:5]:
501+
source[key] = object()
502+
503+
expected = list(source.items())
504+
for result in (source.copy(), dict(source), {**source}):
505+
self.assertEqual(list(result.items()), expected)
506+
507+
def test_sparse_dict_copy_does_not_rehash(self):
508+
class Key:
509+
def __init__(self, value):
510+
self.value = value
511+
self.hash_calls = 0
512+
513+
def __hash__(self):
514+
self.hash_calls += 1
515+
return self.value % 5
516+
517+
def __eq__(self, other):
518+
return self is other
519+
520+
keys = [Key(i) for i in range(90)]
521+
source = {key: key.value for key in keys}
522+
for key in keys[:70]:
523+
del source[key]
524+
calls = [key.hash_calls for key in keys]
525+
expected = list(source.items())
526+
for result in (source.copy(), dict(source), {**source}):
527+
self.assertEqual([key.hash_calls for key in keys], calls)
528+
for (key, value), (copy_key, copy_value) in zip(
529+
expected, result.items()):
530+
self.assertIs(copy_key, key)
531+
self.assertEqual(copy_value, value)
532+
533+
def test_sparse_dict_copy_releases_references(self):
534+
class Key:
535+
pass
536+
537+
class Value:
538+
pass
539+
540+
key = Key()
541+
value = Value()
542+
source = {i: None for i in range(150)}
543+
source[key] = value
544+
for i in range(150):
545+
del source[i]
546+
547+
key_ref = weakref.ref(key)
548+
value_ref = weakref.ref(value)
549+
copies = [source.copy(), dict(source), {**source}]
550+
del source, key, value
551+
552+
self.assertIsNotNone(key_ref())
553+
self.assertIsNotNone(value_ref())
554+
copies.clear()
555+
gc.collect()
556+
self.assertIsNone(key_ref())
557+
self.assertIsNone(value_ref())
558+
559+
def test_sparse_dict_copy_threshold(self):
560+
for key_type in (str, int):
561+
for remaining in (1, 10, 14, 15, 16, 17, 30, 31, 32, 33, 500):
562+
with self.subTest(key_type=key_type, remaining=remaining):
563+
d = {key_type(i): i for i in range(1000)}
564+
for i in range(1000 - remaining):
565+
del d[key_type(i)]
566+
expected = list(d.items())
567+
for copy in (d.copy(), dict(d), {**d}):
568+
self.assertEqual(list(copy.items()), expected)
569+
570+
@support.nomemtest
571+
def test_sparse_dict_copy_no_memory(self):
572+
import _testcapi
573+
574+
d = dict.fromkeys(range(128))
575+
for i in range(96):
576+
del d[i]
577+
578+
for copy in (d.copy, lambda: dict(d), lambda: {**d}):
579+
with self.subTest(copy=copy):
580+
with self.assertRaises(MemoryError):
581+
_testcapi.call_with_nomemory(0, 0, copy)
582+
480583
@support.nomemtest
481584
def test_copy_empty_no_memory(self):
482585
import _testcapi
@@ -1958,6 +2061,19 @@ def test_copy(self):
19582061
self.assertEqual(d2, frozendict(x=1, y=2))
19592062
self.assertEqual(type(d2), frozendict)
19602063

2064+
def test_copy_from_sparse_dict(self):
2065+
for keys in ([f'key{i}' for i in range(1000)], list(range(1000))):
2066+
source = {key: key for key in keys}
2067+
for key in keys[:700]:
2068+
del source[key]
2069+
2070+
expected = list(source.items())
2071+
d = frozendict(source)
2072+
self.assertEqual(list(d.items()), expected)
2073+
self.assertEqual(list(d.copy().items()), expected)
2074+
self.assertEqual(list(dict(d).items()), expected)
2075+
self.assertEqual(list(FrozenDict(d).copy().items()), expected)
2076+
19612077
def test_merge(self):
19622078
# test "a | b" operator
19632079
self.assertEqual(frozendict(x=1) | frozendict(y=2),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Speed up copying dictionaries with many deleted entries by copying live
2+
entries directly into a compact table instead of inserting them one by one.

‎Objects/dictobject.c‎

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4296,6 +4296,70 @@ PyDict_MergeFromSeq2(PyObject *d, PyObject *seq2, int override)
42964296
return dict_merge_from_seq2(d, seq2, override);
42974297
}
42984298

4299+
4300+
/* The generic merge visits only live entries, so it is faster when
4301+
very few entries remain in the original table. */
4302+
static inline bool
4303+
can_clone_sparse_dict(PyDictObject *mp)
4304+
{
4305+
return mp->ma_used >= mp->ma_keys->dk_nentries / 64;
4306+
}
4307+
4308+
/* Clone only live entries from a combined table, preserving insertion order.
4309+
The source is locked while its keys and values are referenced. */
4310+
static PyDictKeysObject *
4311+
clone_compact_dict_keys(PyDictObject *orig)
4312+
{
4313+
assert(PyAnyDict_Check(orig));
4314+
assert(Py_TYPE(orig)->tp_iter == dict_iter);
4315+
assert(orig->ma_values == NULL);
4316+
assert(orig->ma_used > 0);
4317+
if (!PyFrozenDict_Check(orig)) {
4318+
ASSERT_DICT_LOCKED(orig);
4319+
}
4320+
4321+
PyDictKeysObject *oldkeys = orig->ma_keys;
4322+
Py_ssize_t used = orig->ma_used;
4323+
int unicode = DK_IS_UNICODE(oldkeys);
4324+
PyDictKeysObject *keys = new_keys_object(estimate_log2_keysize(used),
4325+
unicode);
4326+
if (keys == NULL) {
4327+
return NULL;
4328+
}
4329+
4330+
Py_ssize_t n = 0;
4331+
if (unicode) {
4332+
PyDictUnicodeEntry *src = DK_UNICODE_ENTRIES(oldkeys);
4333+
PyDictUnicodeEntry *dst = DK_UNICODE_ENTRIES(keys);
4334+
for (Py_ssize_t i = 0; i < oldkeys->dk_nentries; i++) {
4335+
if (src[i].me_value != NULL) {
4336+
dst[n].me_key = Py_NewRef(src[i].me_key);
4337+
dst[n].me_value = Py_NewRef(src[i].me_value);
4338+
n++;
4339+
}
4340+
}
4341+
assert(n == used);
4342+
build_indices_unicode(keys, dst, n);
4343+
}
4344+
else {
4345+
PyDictKeyEntry *src = DK_ENTRIES(oldkeys);
4346+
PyDictKeyEntry *dst = DK_ENTRIES(keys);
4347+
for (Py_ssize_t i = 0; i < oldkeys->dk_nentries; i++) {
4348+
if (src[i].me_value != NULL) {
4349+
dst[n].me_key = Py_NewRef(src[i].me_key);
4350+
dst[n].me_hash = src[i].me_hash;
4351+
dst[n].me_value = Py_NewRef(src[i].me_value);
4352+
n++;
4353+
}
4354+
}
4355+
assert(n == used);
4356+
build_indices_generic(keys, dst, n);
4357+
}
4358+
keys->dk_nentries = n;
4359+
keys->dk_usable -= n;
4360+
return keys;
4361+
}
4362+
42994363
static int
43004364
dict_dict_merge(PyDictObject *mp, PyDictObject *other, int override, PyObject **dupkey)
43014365
{
@@ -4313,15 +4377,19 @@ dict_dict_merge(PyDictObject *mp, PyDictObject *other, int override, PyObject **
43134377
override = 1;
43144378
PyDictKeysObject *okeys = other->ma_keys;
43154379

4316-
// If other is clean, combined, and just allocated, just clone it.
4317-
if (mp->ma_values == NULL &&
4318-
other->ma_values == NULL &&
4319-
other->ma_used == okeys->dk_nentries &&
4320-
(DK_LOG_SIZE(okeys) == PyDict_LOG_MINSIZE ||
4321-
USABLE_FRACTION(DK_SIZE(okeys)/2) < other->ma_used)
4322-
) {
4380+
// Clone a combined table directly, compacting deleted entries.
4381+
if (mp->ma_values == NULL && other->ma_values == NULL &&
4382+
can_clone_sparse_dict(other)) {
43234383
_PyDict_NotifyEvent(PyDict_EVENT_CLONED, mp, (PyObject *)other, NULL);
4324-
PyDictKeysObject *keys = clone_combined_dict_keys(other);
4384+
PyDictKeysObject *keys;
4385+
if (other->ma_used == okeys->dk_nentries &&
4386+
(DK_LOG_SIZE(okeys) == PyDict_LOG_MINSIZE ||
4387+
USABLE_FRACTION(DK_SIZE(okeys)/2) < other->ma_used)) {
4388+
keys = clone_combined_dict_keys(other);
4389+
}
4390+
else {
4391+
keys = clone_compact_dict_keys(other);
4392+
}
43254393
if (keys == NULL)
43264394
return -1;
43274395

@@ -4618,24 +4686,17 @@ copy_lock_held_untracked(PyObject *o, int as_frozendict)
46184686
}
46194687

46204688
if (Py_TYPE(mp)->tp_iter == dict_iter &&
4621-
mp->ma_values == NULL &&
4622-
(mp->ma_used >= (mp->ma_keys->dk_nentries * 2) / 3))
4689+
mp->ma_values == NULL && can_clone_sparse_dict(mp))
46234690
{
4624-
/* Use fast-copy if:
4625-
4626-
(1) type(mp) doesn't override tp_iter; and
4627-
4628-
(2) 'mp' is not a split-dict; and
4629-
4630-
(3) if 'mp' is non-compact ('del' operation does not resize dicts),
4631-
do fast-copy only if it has at most 1/3 non-used keys.
4632-
4633-
The last condition (3) is important to guard against a pathological
4634-
case when a large dict is almost emptied with multiple del/pop
4635-
operations and copied after that. In cases like this, we defer to
4636-
PyDict_Merge, which produces a compacted copy.
4637-
*/
4638-
PyDictKeysObject *keys = clone_combined_dict_keys(mp);
4691+
/* Clone compact tables as-is, but omit deleted entries from sparse
4692+
tables so copying an almost emptied dict stays inexpensive. */
4693+
PyDictKeysObject *keys;
4694+
if (mp->ma_used >= (mp->ma_keys->dk_nentries * 2) / 3) {
4695+
keys = clone_combined_dict_keys(mp);
4696+
}
4697+
else {
4698+
keys = clone_compact_dict_keys(mp);
4699+
}
46394700
if (keys == NULL) {
46404701
return NULL;
46414702
}

0 commit comments

Comments
 (0)