Skip to content

Commit 1af7f81

Browse files
committed
Replace asyncio chunk prefetcher with plain concurrent.futures
The iter_disk prefetcher (used by the UDF and numexpr fallback engines when all operands are NDArrays and at least one is on disk) used an asyncio event loop only to gather ThreadPoolExecutor futures. Submit the per-operand get_chunk tasks directly and collect them with future.result(), which preserves operand order and propagates the first exception naturally. This drops the asyncio import altogether: `import blosc2` no longer pulls in ~30 asyncio modules, saving ~3 MB of memory footprint at import time. Also fix a latent deadlock: when an exception during evaluation closed the chunk iterator early, the generator finalizer blocked forever in thread.join() while the reader thread was stuck in queue.put() on the full prefetch queue. A stop event now makes the producer bail out when its consumer goes away.
1 parent 5a44237 commit 1af7f81

1 file changed

Lines changed: 39 additions & 27 deletions

File tree

src/blosc2/lazyexpr.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from __future__ import annotations
1010

1111
import ast
12-
import asyncio
1312
import builtins
1413
import concurrent.futures
1514
import contextlib
@@ -29,7 +28,7 @@
2928
from dataclasses import asdict
3029
from enum import Enum
3130
from pathlib import Path
32-
from queue import Empty, Queue
31+
from queue import Empty, Full, Queue
3332
from typing import TYPE_CHECKING, Any
3433

3534
from numpy.exceptions import ComplexWarning
@@ -1239,8 +1238,28 @@ def get_chunk(arr, info, nchunk):
12391238
return arr[slice_]
12401239

12411240

1242-
async def async_read_chunks(arrs, info, queue):
1243-
loop = asyncio.get_event_loop()
1241+
def _stoppable_put(queue, item, stop):
1242+
"""Put *item* into the bounded *queue*, giving up when *stop* gets set.
1243+
1244+
Returns False when aborted, so the producer can exit instead of blocking
1245+
forever on a queue whose consumer is gone.
1246+
"""
1247+
while not stop.is_set():
1248+
try:
1249+
queue.put(item, timeout=0.1)
1250+
return True
1251+
except Full:
1252+
continue
1253+
return False
1254+
1255+
1256+
def read_chunks_worker(arrs, info, queue, stop):
1257+
"""Read the chunks of all operands concurrently and feed them into *queue*.
1258+
1259+
For each chunk index, the reads are submitted to a thread pool (one task per
1260+
operand) so that file reads and decompression overlap; the bounded queue in
1261+
:func:`sync_read_chunks` provides the prefetch ahead of the consumer.
1262+
"""
12441263
shape, chunks_ = arrs[0].shape, arrs[0].chunks
12451264
max_workers = max(1, min(len(arrs), int(getattr(blosc2, "nthreads", 1) or 1)))
12461265
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
@@ -1250,42 +1269,34 @@ async def async_read_chunks(arrs, info, queue):
12501269
my_chunk_iter = sliced_chunk_iter(chunks_, (), shape, axis=info[-1], nchunk=True)
12511270
info = info[:4]
12521271
for i, nchunk in enumerate(my_chunk_iter):
1253-
futures = [
1254-
(index, loop.run_in_executor(executor, get_chunk, arr, info, nchunk))
1255-
for index, arr in enumerate(arrs)
1256-
]
1257-
chunks = await asyncio.gather(*(future for index, future in futures), return_exceptions=True)
1258-
chunks_sorted = []
1259-
for chunk in chunks:
1260-
if isinstance(chunk, Exception):
1261-
# Handle the exception (e.g., log it, raise a custom exception, etc.)
1262-
print(f"Exception occurred: {chunk}")
1263-
raise chunk
1264-
chunks_sorted.append(chunk)
1265-
queue.put((i, chunks_sorted)) # use non-async queue.put()
1266-
1267-
queue.put(None) # signal the end of the chunks
1268-
1272+
futures = [executor.submit(get_chunk, arr, info, nchunk) for arr in arrs]
1273+
# result() keeps operand order and propagates the first exception raised
1274+
if not _stoppable_put(queue, (i, [future.result() for future in futures]), stop):
1275+
return
12691276

1270-
def async_read_chunks_thread(arrs, info, queue):
1271-
asyncio.run(async_read_chunks(arrs, info, queue))
1277+
_stoppable_put(queue, None, stop) # signal the end of the chunks
12721278

12731279

12741280
def sync_read_chunks(arrs, info):
12751281
queue_size = 2 # maximum number of chunks in the queue
12761282
queue = Queue(maxsize=queue_size)
1283+
# Signals the producer to bail out when the consumer goes away (e.g. an
1284+
# exception during evaluation closes this generator early); without it,
1285+
# the producer can block forever on a full queue and deadlock the
1286+
# thread.join() below during generator finalization.
1287+
stop = threading.Event()
12771288
worker_exc = None
12781289

1279-
def _run_async_reader():
1290+
def _run_reader():
12801291
nonlocal worker_exc
12811292
try:
1282-
async_read_chunks_thread(arrs, info, queue)
1293+
read_chunks_worker(arrs, info, queue, stop)
12831294
except BaseException as exc:
12841295
worker_exc = exc
1285-
queue.put(None)
1296+
_stoppable_put(queue, None, stop)
12861297

1287-
# Start the async file reading in a separate thread
1288-
thread = threading.Thread(target=_run_async_reader)
1298+
# Start the file reading in a separate thread
1299+
thread = threading.Thread(target=_run_reader)
12891300
thread.start()
12901301

12911302
try:
@@ -1305,6 +1316,7 @@ def _run_async_reader():
13051316
break
13061317
continue
13071318
finally:
1319+
stop.set()
13081320
thread.join()
13091321

13101322

0 commit comments

Comments
 (0)