Coverage for src/evutils/io/_prefetch.py: 96%
70 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-15 09:33 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-15 09:33 +0000
1"""Background-thread prefetching for :class:`~evutils.io.EventReader`.
3The reader's windowing loop runs in a worker thread and pushes finished
4windows into a small bounded queue, so decoding the next window overlaps the
5caller's processing of the current one. This works because
7* the native parsers are called through ctypes, which releases the GIL for
8 the duration of the C call (measured: a Python thread keeps ~90% of its
9 solo throughput while a decode runs), and
10* every yielded window is already an independent copy of the reader's reused
11 accumulator storage, so the worker can never mutate chunks the consumer
12 still holds.
14When it helps: any pipeline where per-chunk processing takes meaningful time
15-- numpy transforms, writing elsewhere, GPU inference (where the read becomes
16entirely free). When it does not: processing that already saturates memory
17bandwidth can get *slower* with prefetching enabled, because decode competes
18for the same bandwidth. Hence prefetching is opt-in
19(``EventReader(..., async_read=True)``).
20"""
21from __future__ import annotations
23import queue
24import threading
25from typing import Any, Callable, Iterator
27#: How many finished windows may be buffered ahead of the consumer. Depth 2 is
28#: enough to decouple producer and consumer; larger values only cost memory
29#: (depth x window size).
30DEFAULT_DEPTH = 2
33class PrefetchIterator:
34 """Iterate a chunk source through a bounded queue filled by a worker thread.
36 Semantics match plain iteration: same chunks in the same order, and an
37 exception raised by the source is re-raised to the consumer at the point
38 it would have occurred. Use :meth:`close` (or exhaust the iterator, or use
39 it as a context manager) to release the worker early; an abandoned,
40 unclosed iterator does not deadlock -- the worker is a daemon thread
41 parked on a stop-aware put.
43 Parameters
44 ----------
45 source : Iterator
46 The synchronous chunk iterator to drain (runs entirely in the worker
47 thread; it must not be touched by anyone else while this is alive).
48 depth : int
49 Maximum number of chunks buffered ahead of the consumer.
50 on_finish : callable, optional
51 Called exactly once, from whichever thread finishes the iterator
52 (exhaustion or :meth:`close`). The EventReader uses it to clear its
53 active-prefetch guard.
55 """
57 _SENTINEL: Any = object()
59 def __init__(self, source: Iterator[Any], depth: int = DEFAULT_DEPTH,
60 on_finish: Callable[[], None] | None = None):
61 if depth < 1:
62 raise ValueError("prefetch depth must be >= 1")
63 self._source = source
64 self._queue: queue.Queue[Any] = queue.Queue(maxsize=depth)
65 self._stop = threading.Event()
66 self._exc: BaseException | None = None
67 self._finished = False
68 self._on_finish = on_finish
69 self._worker = threading.Thread(
70 target=self._work, name="evutils-prefetch", daemon=True
71 )
72 self._worker.start()
74 # ------------------------------------------------------------------ #
75 # Worker side
76 # ------------------------------------------------------------------ #
77 def _put(self, item: Any) -> bool:
78 """Blocking put that stays responsive to :meth:`close`.
80 Returns False when cancelled. The end-of-stream sentinel must go
81 through here too: a non-blocking put of the sentinel can be dropped
82 when the queue is full, leaving the consumer waiting forever.
83 """
84 while not self._stop.is_set():
85 try:
86 self._queue.put(item, timeout=0.1)
87 return True
88 except queue.Full:
89 pass
90 return False
92 def _work(self) -> None:
93 try:
94 for chunk in self._source:
95 if not self._put(chunk):
96 return # cancelled by close()
97 except BaseException as exc: # propagated to the consumer
98 self._exc = exc
99 finally:
100 close = getattr(self._source, "close", None)
101 if callable(close):
102 close() # generators: release reader frame in this thread
103 self._put(self._SENTINEL)
105 # ------------------------------------------------------------------ #
106 # Consumer side
107 # ------------------------------------------------------------------ #
108 def __iter__(self) -> "PrefetchIterator":
109 return self
111 def __next__(self) -> Any:
112 if self._finished:
113 raise StopIteration
114 item = self._queue.get()
115 if item is self._SENTINEL:
116 self._worker.join()
117 self._finish()
118 if self._exc is not None:
119 raise self._exc
120 raise StopIteration
121 return item
123 def close(self) -> None:
124 """Cancel the worker and drop any buffered chunks. Idempotent."""
125 if self._finished:
126 return
127 self._stop.set()
128 # Drain so a worker blocked on put() can observe the stop event.
129 while True:
130 try:
131 self._queue.get_nowait()
132 except queue.Empty:
133 break
134 self._worker.join(timeout=5.0)
135 self._finish()
137 def _finish(self) -> None:
138 if not self._finished:
139 self._finished = True
140 if self._on_finish is not None:
141 self._on_finish()
143 def __enter__(self) -> "PrefetchIterator":
144 return self
146 def __exit__(self, *exc: Any) -> None:
147 self.close()