Skip to content
Draft
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
4 changes: 4 additions & 0 deletions Include/internal/pycore_interp_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,8 @@ struct _Py_unique_id_pool {

typedef _Py_CODEUNIT *(*_PyJitEntryFuncPtr)(struct _PyExecutorObject *exec, _PyInterpreterFrame *frame, _PyStackRef *stack_pointer, PyThreadState *tstate);

struct _PyList_DequeHooks;

#define _PyInterpreterGuard_GUARDS_NOT_ALLOWED UINTPTR_MAX

/* PyInterpreterState holds the global state for one of the runtime's
Expand Down Expand Up @@ -996,6 +998,8 @@ struct _is {
struct _py_code_state code_state;

struct _Py_dict_state dict_state;
// Installed by _collections in this interpreter; NULL until then.
const struct _PyList_DequeHooks *list_deque_hooks;
struct _Py_exc_state exc_state;
struct _Py_mem_interp_free_queue mem_free_queue;

Expand Down
25 changes: 25 additions & 0 deletions Include/internal/pycore_list.h
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ union _PyStackRef;
PyAPI_FUNC(PyObject *)_PyList_FromStackRefStealOnSuccess(const union _PyStackRef *src, Py_ssize_t n);
PyAPI_FUNC(PyObject *)_PyList_AsTupleAndClear(PyListObject *v);

// Bulk extension fast path for collections.deque, which lives in the
// _collections module and is therefore unknown to listobject.c.
//
// A deque (or a subclass that keeps deque's tp_iter and sq_length) is
// recognised by deque_iter/deque_len; a forward deque iterator by
// dequeiter_next. extend() is called without any lock held and returns 0 on
// success, -1 with an exception set, or 1 to request the generic path.
typedef struct _PyList_DequeHooks {
getiterfunc deque_iter;
lenfunc deque_len;
iternextfunc dequeiter_next;
int (*extend)(PyListObject *self, PyObject *iterable);
} _PyList_DequeHooks;

PyAPI_FUNC(void) _PyList_SetDequeHooks(const _PyList_DequeHooks *hooks);

// Make room for 'n' (> 0) more items at the end of 'self' without changing
// its size; return a pointer to the first free slot, or NULL with an
// exception set. The caller must hold self's critical section, store new
// references with FT_ATOMIC_STORE_PTR_RELEASE() and then publish them with
// Py_SET_SIZE(). Matches the allocation behaviour of the generic
// list.extend(iterable) path when the length hint is exact.
PyAPI_FUNC(PyObject **) _PyList_ReserveForExtendLockHeld(PyListObject *self,
Py_ssize_t n);

#ifdef __cplusplus
}
#endif
Expand Down
123 changes: 123 additions & 0 deletions Lib/test/test_deque.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pickle
import random
import struct
import sys

BIG = 100000

Expand Down Expand Up @@ -798,6 +799,128 @@ def test_runtime_error_on_empty_deque(self):
d.append(10)
self.assertRaises(RuntimeError, next, it)

class TestBulkMaterialization(unittest.TestCase):
# list(), list.extend(), [*d] and str.join() copy a deque (or a forward
# deque iterator) into a list in one step. The result must be identical
# to element-by-element iteration, which a comprehension still uses.

def check(self, d):
expected = [x for x in d]
self.assertEqual(list(d), expected)
self.assertEqual([*d], expected)
lst = ['head']
lst.extend(d)
self.assertEqual(lst, ['head'] + expected)
self.assertEqual(list(iter(d)), expected)

def test_block_boundaries(self):
for n in (0, 1, 2, 31, 32, 33, 63, 64, 65, 127, 128, 129, 1000):
for shift in (0, 1, 31, 32, 63, 64, 65, 100):
d = deque(range(n + shift))
for _ in range(shift):
d.popleft()
self.check(d)
d.rotate(shift)
self.check(d)
d.appendleft(-1)
self.check(d)

def test_partially_consumed_iterator(self):
for n in (0, 1, 63, 64, 65, 200):
for k in sorted(k for k in {0, 1, n // 2, n - 1, n} if 0 <= k <= n):
d = deque(range(n))
it = iter(d)
for _ in range(k):
next(it)
self.assertEqual(list(it), list(range(k, n)))
self.assertEqual(it.__length_hint__(), 0)
self.assertEqual(list(it), [])
self.assertRaises(StopIteration, next, it)
# __reduce__ reports the iterator as exhausted
self.assertEqual(it.__reduce__()[1][1], n)

def test_reverse_iterator(self):
d = deque(range(300))
it = reversed(d)
next(it)
self.assertEqual(list(it), list(range(298, -1, -1)))

def test_join(self):
items = [str(i) for i in range(500)]
d = deque(items)
self.assertEqual(''.join(d), ''.join(items))
self.assertEqual(', '.join(d), ', '.join(items))
self.assertRaises(TypeError, ''.join, deque(['a', 1]))

def test_mutation_during_iteration(self):
d = deque(range(10))
it = iter(d)
next(it)
d.append(10)
self.assertRaises(RuntimeError, list, it)
# Like next(), the error exhausts the iterator.
self.assertEqual(it.__length_hint__(), 0)
self.assertRaises(RuntimeError, next, it)

d = deque()
it = iter(d)
d.append(1)
self.assertRaises(RuntimeError, list, it)

# The state is checked before exhaustion, as in next().
d = deque([1])
it = iter(d)
self.assertEqual(list(it), [1])
d.append(2)
self.assertRaises(RuntimeError, list, it)

lst = [0]
d = deque(range(5))
it = iter(d)
d.pop()
self.assertRaises(RuntimeError, lst.extend, it)
self.assertEqual(lst, [0])

def test_subclasses(self):
class D(deque):
pass
self.check(D(range(200)))

calls = []
class WithLen(deque):
def __len__(self):
calls.append(1)
return deque.__len__(self)
self.assertEqual(list(WithLen(range(5))), list(range(5)))
self.assertTrue(calls)

class WithIter(deque):
def __iter__(self):
return iter(['x'])
self.assertEqual(list(WithIter(range(5))), ['x'])
self.assertEqual(''.join(WithIter('abc')), 'x')

def test_allocation_matches_generic_path(self):
for n in (1, 2, 5, 64, 65, 129):
self.assertEqual(sys.getsizeof(list(deque(range(n)))),
sys.getsizeof(list(iter(range(n)))))
a = list(range(3))
a.extend(deque(range(n)))
b = list(range(3))
b.extend(iter(range(n)))
self.assertEqual(a, b)
self.assertEqual(sys.getsizeof(a), sys.getsizeof(b))

def test_refcounts(self):
obj = object()
before = sys.getrefcount(obj)
d = deque([obj] * 200)
lst = list(d)
lst2 = list(iter(d))
self.assertEqual(sys.getrefcount(obj), before + 600)
del d, lst, lst2
self.assertEqual(sys.getrefcount(obj), before)

class Deque(deque):
pass

Expand Down
85 changes: 85 additions & 0 deletions Lib/test/test_free_threading/test_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,90 @@ def iterate():
)


def test_shared_iterator(self):
# Deque iterators are advanced under the deque's lock, so threads
# sharing one iterator must together see every item exactly once.
n = 20000
for make_iter in (iter, reversed):
with self.subTest(make_iter=make_iter.__name__):
d = deque(range(n))
it = make_iter(d)
results = []

def consume():
results.append(list(x for x in it))

threading_helper.run_concurrently([consume] * 4)
self.assertEqual(sorted(x for r in results for x in r),
list(range(n)))

def test_iterate_while_mutating(self):
d = deque(range(100))

def mutate():
for i in range(5000):
d.append(i)
d.popleft()

def iterate():
for _ in range(500):
it = iter(d)
try:
for _ in it:
pass
except RuntimeError:
pass
it.__length_hint__()
it.__reduce__()

threading_helper.run_concurrently([mutate, iterate, iterate])

def test_bulk_materialize_race(self):
# list(d) copies the deque under its critical section and must see
# a consistent snapshot; list(iter(d)) and str.join() first create
# an iterator, so a mutation in between is reported as by next().
d = deque(str(i) for i in range(200))

def mutate():
for i in range(5000):
d.append(str(i))
d.popleft()
if i % 64 == 0:
d.rotate(7)

def materialize():
for _ in range(2000):
self.assertIn(len(list(d)), (200, 201))
for func in (list, ''.join):
try:
res = func(iter(d))
except RuntimeError:
continue
if func is list:
self.assertIn(len(res), (200, 201))

threading_helper.run_concurrently([mutate, materialize, materialize])

def test_shared_iterator_bulk_and_next(self):
# list.extend(it) and next(it) both advance a shared iterator while
# holding the deque's lock: every item is produced exactly once.
n = 20000
for _ in range(5):
d = deque(range(n))
it = iter(d)
results = []

def bulk():
got = []
got.extend(it)
results.append(got)

def step():
results.append([x for x in it])

threading_helper.run_concurrently([step, bulk, step, step])
self.assertEqual(sorted(x for r in results for x in r),
list(range(n)))

if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Improve performance of iterating over a :class:`collections.deque` in the
:term:`free-threaded build` by locking only the deque, not the iterator.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Speed up :func:`list`, :meth:`list.extend`, :meth:`str.join` and unpacking of
a :class:`collections.deque` by copying its items under a single lock in the
:term:`free-threaded build`.
Loading
Loading