Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions Lib/test/test_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,57 @@ def __new__(cls):
self.assertEqual(baddict3.fromkeys({"a", "b", "c"}), res)

def test_copy(self):
d = {1:1, 2:2, 3:3}
self.assertEqual(d.copy(), {1:1, 2:2, 3:3})
d = {1: 1, 2: 2, 3: 3}
self.assertIsNot(d.copy(), d)
self.assertEqual(d.copy(), d)
self.assertEqual(d.copy(), {1: 1, 2: 2, 3: 3})

copy = d.copy()
d[4] = 4
self.assertNotEqual(copy, d)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these test changes actually related to the code changes? Or are they just test hardening that you thought of because of the new code? If so, the test improvements should go in regardless of whether or not the code changes do, and ideally as a separate commit.

self.assertEqual({}.copy(), {})
self.assertRaises(TypeError, d.copy, None)

def test_copy_fuzz(self):
for dict_size in [10, 100, 1000, 10000, 100000]:
dict_size = random.randrange(
dict_size // 2, dict_size + dict_size // 2)
with self.subTest(dict_size=dict_size):
d = {}
for i in range(dict_size):
d[i] = i

d2 = d.copy()
self.assertIsNot(d2, d)
self.assertEqual(d, d2)
d2['key'] = 'value'
self.assertNotEqual(d, d2)
self.assertEqual(len(d2), len(d) + 1)

def test_copy_maintains_tracking(self):
class A:
pass

key = A()

for d in ({}, {'a': 1}, {key: 'val'}):
d2 = d.copy()
self.assertEqual(gc.is_tracked(d), gc.is_tracked(d2))

def test_copy_noncompact(self):
# Dicts don't compact themselves on del/pop operations.
# Copy will use a slow merging strategy that produces
# a compacted copy when roughly 33% of dict is a non-used
# keys-space (to optimize memory footprint).
# In this test we want to hit the slow/compacting
# branch of dict.copy() and make sure it works OK.
d = {k: k for k in range(1000)}
for k in range(950):
del d[k]
d2 = d.copy()
self.assertEqual(d2, d)

def test_get(self):
d = {}
self.assertIs(d.get('c'), None)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make dict.copy() up to 5.5 times faster.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one case it slowed down was an empty dict ... is it worth a special path to drop out and just create a new empty dict then?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a fast-path for empty dicts.

73 changes: 73 additions & 0 deletions Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,52 @@ new_dict_with_shared_keys(PyDictKeysObject *keys)
return new_dict(keys, values);
}


static PyObject *
clone_combined_dict(PyDictObject *orig)
{
assert(PyDict_CheckExact(orig));
assert(orig->ma_values == NULL);
assert(orig->ma_keys->dk_refcnt == 1);

Py_ssize_t keys_size = _PyDict_KeysSize(orig->ma_keys);
PyDictKeysObject *keys = PyObject_Malloc(keys_size);
if (keys == NULL) {
PyErr_NoMemory();
return NULL;
}

memcpy(keys, orig->ma_keys, keys_size);

/* After copying key/value pairs, we need to incref all
keys and values and they are about to be co-owned by a
new dict object. */
PyDictKeyEntry *ep0 = DK_ENTRIES(keys);
Py_ssize_t n = keys->dk_nentries;
for (Py_ssize_t i = 0; i < n; i++) {
PyDictKeyEntry *entry = &ep0[i];
PyObject *value = entry->me_value;
if (value != NULL) {
Py_INCREF(value);
Py_INCREF(entry->me_key);
}
}

PyDictObject *new = (PyDictObject *)new_dict(keys, NULL);
if (new == NULL) {
/* In case of an error, `new_dict()` takes care of
cleaning up `keys`. */
return NULL;
}
new->ma_used = orig->ma_used;
assert(_PyDict_CheckConsistency(new));
if (_PyObject_GC_IS_TRACKED(orig)) {
/* Maintain tracking. */
_PyObject_GC_TRACK(new);
}
return (PyObject *)new;
}

PyObject *
PyDict_New(void)
{
Expand Down Expand Up @@ -2484,7 +2530,13 @@ PyDict_Copy(PyObject *o)
PyErr_BadInternalCall();
return NULL;
}

mp = (PyDictObject *)o;
if (mp->ma_used == 0) {
/* The dict is empty; just return a new dict. */
return PyDict_New();
}

if (_PyDict_HasSplitTable(mp)) {
PyDictObject *split_copy;
Py_ssize_t size = USABLE_FRACTION(DK_SIZE(mp->ma_keys));
Expand All @@ -2510,6 +2562,27 @@ PyDict_Copy(PyObject *o)
_PyObject_GC_TRACK(split_copy);
return (PyObject *)split_copy;
}

if (PyDict_CheckExact(mp) && mp->ma_values == NULL &&
(mp->ma_used >= (mp->ma_keys->dk_nentries * 2) / 3))
{
/* Use fast-copy if:

(1) 'mp' is an instance of a subclassed dict; and

(2) 'mp' is not a split-dict; and

(3) if 'mp' is non-compact ('del' operation does not resize dicts),
do fast-copy only if it has at most 1/3 non-used keys.

The last condition (3) is important to guard against a pathalogical
case when a large dict is almost emptied with multiple del/pop
operations and copied after that. In cases like this, we defer to
PyDict_Merge, which produces a compacted copy.
*/
return clone_combined_dict(mp);
}

copy = PyDict_New();
if (copy == NULL)
return NULL;
Expand Down