perf: extract file metadata concurrently (thread pool)#112
Conversation
Per-file extraction -- handler selection, whole-file SHA-256, and header/schema reads -- is I/O-bound and independent across files, but ran strictly serially. On datasets with many files (PhysioNet/MIMIC-scale: thousands) this is the dominant cost of a bake. Run extraction on a ThreadPoolExecutor sized from the CPU count (the work is off-GIL: file reads + hashlib + pyarrow/pydicom/nibabel header parsing). Results are collected by submission index and reassembled in discovery order *before* any FileObject @id is assigned, so output -- including the order of per-file warnings -- is byte-identical regardless of worker count. No mlcroissant objects are constructed off the main thread. - MetadataGenerator(max_workers=None) auto-sizes to min(8, cpu*2); 1 forces serial. - New CLI flag --jobs/-j (0 = auto, 1 = serial). - The progress callback now reports a completion count (one event per file). Benchmark (60 CSVs, 1.2 GB, 18 cores, warm cache): 1.19s serial -> 0.72-0.80s (~1.5-1.65x). Larger gains are expected on cold or networked storage, where per-file I/O latency overlaps across threads. tests/test_parallel.py asserts byte-identical output across worker counts (1/2/4/8), one progress event per file, and worker-count resolution. The full suite (274) passes through the auto-parallel path with no change to the committed example outputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
be13754 to
17431be
Compare
rafiattrach
left a comment
There was a problem hiding this comment.
This is solid: verified byte-identical output serial vs -j8 on the OMOP demo, and
handlers are stateless today so the shared-singleton concurrency is safe. Two asks
before merge, both about keeping it safe as handlers get added:
-
Please add the thread-safety contract to
FileTypeHandler.extract_metadata's
docstring insrc/croissant_baker/handlers/base_handler.py(it's a public
symbol, so this also publishes to the API docs). Suggested, right under the
summary line "Extract comprehensive metadata from a single file.":Thread-safety: extract_metadata may be called concurrently across files on a single shared handler instance (handlers are registered as singletons and extraction is parallelised — see MetadataGenerator). Implementations must be safe to call concurrently: do not mutate shared or instance state during extraction. Read-only state set once at construction is fine; mutable per-call state on self is a data race. -
A tripwire test so this can't regress silently: see inline comment on
test_parallel.py.
| @@ -0,0 +1,95 @@ | |||
| """Parallel extraction must not change output and must respect ``max_workers``. | |||
There was a problem hiding this comment.
Could you add a tripwire here that fails CI if a future handler introduces instance
state? The whole parallel path rests on handlers being stateless, and a byte-identical
test won't reliably catch a race. Something like:
def test_registered_handlers_are_stateless() -> None:
"""Extraction runs concurrently on shared handler singletons (#112), so
handlers must not carry mutable instance state. Fails loudly if one does,
forcing a conscious thread-safety review before it can ship."""
from croissant_baker.handlers.registry import (
get_registered_handlers,
register_all_handlers,
)
register_all_handlers()
stateful = {type(h).__name__: vars(h) for h in get_registered_handlers() if vars(h)}
assert not stateful, (
f"Handlers with instance state: {stateful}. extract_metadata is called "
"concurrently on a shared instance. If this state is read-only and set at "
"construction, confirm it's thread-safe and update this test."
)
…P#112 review) Addresses rafiattrach's review: - Document the concurrency contract on FileTypeHandler.extract_metadata — it may be called concurrently on a shared singleton, so implementations must not mutate shared/instance state during extraction and must use reentrant parsers. - Add test_registered_handlers_are_stateless as a regression tripwire that fails CI if a registered handler grows per-instance state, forcing a conscious thread-safety review. It covers both __dict__ and __slots__ layouts (bare vars(h) would crash on a future __slots__ handler with TypeError), and its docstring is explicit that class/module-level state and library non-reentrancy are out of its introspective reach and rest on the contract + review. Full suite: 283 passed.
|
Thanks @rafiattrach — both addressed in dcdf179. 1. Docstring contract — added to 2. Tripwire — added Before pushing I also ran an independent per-handler thread-safety audit — you flagged that a byte-identical test won't reliably catch a race, which is exactly right. Result: all nine handlers are stateless on the extraction path today — no One caveat to note: the instance-state tripwire guards the narrowest case. The more dangerous future races carry no instance state and so are invisible to any introspective test — a class-level mutable attribute (e.g. a shared Full suite green (283). Ready for another look whenever you have a moment. |
Draft PR opened by Chimera (an autonomous code agent operated by @elementalcollision), as part of a review of croissant-baker. Opened as a draft for maintainer review.
What
Per-file metadata extraction — handler selection, whole-file SHA-256, and header/schema reads — is I/O-bound and independent across files, but ran strictly serially in
generate_metadata. On datasets with many files (PhysioNet/MIMIC-scale: thousands) that loop is the dominant cost of a bake.This runs extraction on a
ThreadPoolExecutor. The work is off-GIL (file reads +hashlib+ pyarrow/pydicom/nibabel header parsing), so threads give real overlap.Correctness is preserved exactly
The risk with parallelism here is output drift (FileObject
@ids are positional). Guarded by construction:@idis assigned.mlcroissantobjects are built off the main thread — workers return plain dicts.tests/test_parallel.pypins this: byte-identical output across worker counts 1/2/4/8. And the full e2e suite now runs through the auto-parallel path (max_workers=None) with zero change to the committedtests/data/output/*.jsonld— the strongest possible evidence the output is unchanged.API
MetadataGenerator(max_workers=None)— auto-sizes tomin(8, cpu*2);1forces serial.--jobs/-j(0= auto,1= serial).Benchmark
Synthetic dataset, 60 CSVs / 1.2 GB, 18-core box, warm cache (isolates compute concurrency):
Caveat: This is a warm-cache, CPU-bound measurement (SHA-256 throughput becomes memory-bandwidth bound past ~4 threads, hence the tail-off). On cold or networked storage where PhysioNet-scale datasets actually live, the gain should be larger because per-file I/O latency overlaps across threads. The
--jobsflag lets users tune for their storage.Tests
Full suite: 274 passed (269 + 5 new). No changes to committed outputs.