Coverage for src/evutils/io/_prefetch.py: 96%
70 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-18 05:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-18 05:24 +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 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
32class PrefetchIterator:
33 """Iterate a chunk source through a bounded queue filled by a worker thread.
35 Semantics match plain iteration: same chunks in the same order, and an
36 exception raised by the source is re-raised to the consumer at the point
37 it would have occurred. Use :meth:`close` (or exhaust the iterator, or use
38 it as a context manager) to release the worker early; an abandoned,
39 unclosed iterator does not deadlock -- the worker is a daemon thread
40 parked on a stop-aware put.
42 Parameters
43 ----------
44 source : Iterator
45 The synchronous chunk iterator to drain (runs entirely in the worker
46 thread; it must not be touched by anyone else while this is alive).
47 depth : int
48 Maximum number of chunks buffered ahead of the consumer.
49 on_finish : callable, optional
50 Called exactly once, from whichever thread finishes the iterator
51 (exhaustion or :meth:`close`). The EventReader uses it to clear its
52 active-prefetch guard.
54 """
56 _SENTINEL: object = object()
58 def __init__(self, source: "Iterator[EventArray]", depth: int = DEFAULT_DEPTH,
59 on_finish: Callable[[], None] | None = None):
60 if depth < 1:
61 raise ValueError("prefetch depth must be >= 1")
62 self._source = source
63 self._queue: "queue.Queue[EventArray]" = queue.Queue(maxsize=depth)
64 self._stop = threading.Event()
65 self._exc: BaseException | None = None
66 self._finished = False
67 self._on_finish = on_finish
68 self._worker = threading.Thread(
69 target=self._work, name="evutils-prefetch", daemon=True
70 )
71 self._worker.start()
73 # ------------------------------------------------------------------ #
74 # Worker side
75 # ------------------------------------------------------------------ #
76 def _put(self, item: "EventArray") -> bool:
77 """Blocking put that stays responsive to :meth:`close`.
79 Returns False when cancelled. The end-of-stream sentinel must go
80 through here too: a non-blocking put of the sentinel can be dropped
81 when the queue is full, leaving the consumer waiting forever.
82 """
83 while not self._stop.is_set():
84 try:
85 self._queue.put(item, timeout=0.1)
86 return True
87 except queue.Full:
88 pass
89 return False
91 def _work(self) -> None:
92 try:
93 for chunk in self._source:
94 if not self._put(chunk):
95 return # cancelled by close()
96 except BaseException as exc: # propagated to the consumer
97 self._exc = exc
98 finally:
99 close = getattr(self._source, "close", None)
100 if callable(close):
101 close() # generators: release reader frame in this thread
102 self._put(self._SENTINEL)
104 # ------------------------------------------------------------------ #
105 # Consumer side
106 # ------------------------------------------------------------------ #
107 def __iter__(self) -> "PrefetchIterator":
108 return self
110 def __next__(self) -> "EventArray":
111 if self._finished:
112 raise StopIteration
113 item = self._queue.get()
114 if item is self._SENTINEL:
115 self._worker.join()
116 self._finish()
117 if self._exc is not None:
118 raise self._exc
119 raise StopIteration
120 return item
122 def close(self) -> None:
123 """Cancel the worker and drop any buffered chunks. Idempotent."""
124 if self._finished:
125 return
126 self._stop.set()
127 # Drain so a worker blocked on put() can observe the stop event.
128 while True:
129 try:
130 self._queue.get_nowait()
131 except queue.Empty:
132 break
133 self._worker.join(timeout=5.0)
134 self._finish()
136 def _finish(self) -> None:
137 if not self._finished:
138 self._finished = True
139 if self._on_finish is not None:
140 self._on_finish()
142 def __enter__(self) -> "PrefetchIterator":
143 return self
145 def __exit__(self, *exc: object) -> None:
146 self.close()