fix(nanoevents): correct parquet entry_start jagged reads and cache column reads - #1583
fix(nanoevents): correct parquet entry_start jagged reads and cache column reads#1583NJManganelli wants to merge 7 commits into
Conversation
The parquet source mapping's jagged-array reader sliced the column with pyarrow (which records a logical `aspa.offset` rather than copying buffers) and extracted the content via `aspa.flatten()` (offset-aware), but read the list offsets from the *start* of the buffer (`[:len(aspa)+1]`, ignoring `aspa.offset`). With entry_start > 0 the offsets and content were therefore misaligned: per-event jagged values (e.g. Muon.pt) were reassigned to the wrong events, and NanoAOD cross-references crashed on length mismatch. Read `len(aspa)+1` offsets starting at `aspa.offset` and rebase them to start at 0 so the returned ListOffsetArray indexes into the already-sliced flattened content correctly. Applies to both ListArray and LargeListArray. Adds a regression test asserting entry_start/entry_stop subrange reads equal the full-read slice for jagged (Muon/Jet/Electron) and flat (MET) fields, across both parquet sample encodings. Partially addresses scikit-hep#1578 (Parquet reads with entry_start > 0 return shifted jagged data). Assisted-by: Claude Fable 5
The parquet source mapping read the entire column from the file on every buffer-key access. A single jagged column is materialized through two separate buffer keys (offsets and content), each of which triggered its own full-column read, so every jagged branch was read from disk at least twice. Add a small per-source LRU cache (keyed by column name) on the parquet UprootLikeShim so the multiple buffer accesses for one column reuse a single materialized table. This mirrors how the mapping already caches column sources, keeps the returned tables immutable (pyarrow slicing returns views), and does not change any public API. On the nano_dy sample this halves reads for jagged columns via the raw from_buffers path (2 -> 1) and lowers total reads in the standard eager path (1599 -> 1516). Limitation: reads still fetch the whole column rather than only the requested row range; pyarrow's ParquetFile.read has no direct row-range argument, so a true partial read would require row-group iteration and a larger refactor. The existing slice in UprootLikeShim.array continues to trim to the range. Adds a regression test that counts underlying pyarrow reads and asserts a jagged column is read once (was twice) while returning identical data. Partially addresses scikit-hep#1578 (Parquet mapping reads the entire column per buffer access with no cache). Assisted-by: Claude Fable 5
|
Seems to be a useful performance optimization and correctness improvement for reading parquet files, will catch bugs in the future that we haven't hit yet due to low uptake of parquet across all analyses (and usually it's fairly flat tables). |
lgray
left a comment
There was a problem hiding this comment.
🤖 AI text below 🤖
This PR fixes a real, silent data-corruption bug: jagged parquet reads with entry_start > 0 read list offsets from the start of the offsets buffer while flatten() returned offset-aware content, reassigning per-event values to the wrong events (this hits the production Runner parquet path too — every chunk after the first, via executor.py:1657-1663 → from_parquet(mode="virtual", entry_start=...)). I verified the offset arithmetic against pyarrow's buffer semantics (raw_offsets[aspa.offset : aspa.offset + len(aspa) + 1] is exactly the n+1 offsets for the logical slice, and the rebase matches what flatten() returns), confirmed both new tests fail before their respective commits and pass after (merge-base: 7/9 fail with the described broadcast ValueError; commit-1-only: cache test fails with assert 2 == 1), probed the cache with back-to-back different subranges through one shared source (no poisoning — reads are always full-column, sliced after, so keying by column name alone is sound), and ran the full test_nanoevents.py (28 passed) plus test_local_executors.py -k parquet (98 passed). Virtual mode (the executor path) also verified correct. The branch merges cleanly onto current master. LGTM overall — one test-coverage gap worth closing before merge.
Should fix
- The int32
ListArraybranch of the fix is untested. Both sample files (nano_dy.parquetandnano_dy.extensionarray.parquet) decode toLargeListArray(int64 offsets, verified by inspecting the decoded chunks), so the two parametrized "encodings" both exercise only thenumpy.int64side of the dtype selection atsrc/coffea/nanoevents/mapping/parquet.py:143-147— thenumpy.int32path never runs in CI. I probed it manually (synthetic parquet written withpa.list_(pa.float32())decodes asListArray; all four subranges correct), so the code is right, but a regression there would currently go unnoticed. Suggest a smalltmp_pathfixture writing apa.list_()(non-large) column and running it through the same subrange-vs-full-slice assertion.
Nits / Optional
src/coffea/nanoevents/mapping/parquet.py:134—offsets = Noneis dead now that both branches were unified; it's immediately reassigned at line 149. Leftover from the old structure; drop it.- The subrange tests parametrize only
mode="eager". Virtual mode goes through the identical extraction code (base.py:170-172only defers the call) and I verified it passes, but the production executor path usesmode="virtual"— parametrizingmodeover both is nearly free and would pin the path users actually hit.
Test coverage: The new tests are non-vacuous and discriminating, proven per commit by revert runs (subrange tests: 6/8 fail at merge-base, exactly the entry_start=0 cases passing; read-count test fails at both merge-base and commit-1-only with assert 2 == 1). Coverage spans jagged + flat branches, boundary ranges including start=0, full range, and a 3-event tail; the read-count test monkeypatches the stable pq.ParquetFile.read seam, which is reasonably robust. Gaps: the int32 offsets branch (above) and eager-only mode. The claimed "2 encodings" are plain-vs-extensionarray, not int32-vs-int64 — worth being aware of. Side note: on pyarrow ≥ 24, ParquetFile.read returns single-chunk columns even for multi-row-group files, so the pre-existing .chunk(0) at parquet.py:127 doesn't truncate here; true row-group-level partial reads remain deferred as the PR states.
Pydantic: the diff touches no pydantic code (grep of the full diff: no matches) — not applicable.
…pping # Conflicts: # tests/test_nanoevents.py
| # Number of distinct parquet columns kept materialized per open file. A single | ||
| # jagged buffer access (offsets + content) reads the same column more than once, | ||
| # and a NanoEvents view typically touches only a handful of columns at a time, | ||
| # so a small cache eliminates redundant full-column reads without holding the | ||
| # whole file in memory. |
There was a problem hiding this comment.
I'm pretty sure the same happens in root files with base schema too but uproot's cache protects us I think from double reading. Should be investigated to make sure though. I also have a vague memory of adding a cache of size 1 somewhere but it may have been in awkward for a different reason. Just saying this as it should be investigated to make sure my assumptions are right.
There was a problem hiding this comment.
🤖 Opus 4.8 Output 🤖
Investigated and verified empirically. ikrommyd's assumption is correct, and the fix is in the right place for each format — no ROOT-side change is needed.
What I found
I built a genuinely jagged single ROOT branch (a std::vector-like ListOffset column — the base-schema case ikrommyd meant, not NanoAOD where offsets live in a separate nMuon counts branch and there's no double-read at all) and traced it through coffea's UprootSourceMapping in virtual mode with a spy on uproot's array_cache:
EXTRACT_COLUMN #1 (jag) ← offsets buffer key
MISS ...0:0 → SET ...0:0 physical read + decompress
MISS ...0:1 → SET ...0:1 physical read + decompress
EXTRACT_COLUMN #2 (jag) ← content buffer key
HIT ...0:0
HIT ...0:1 100% served from uproot's array_cache — no disk/decompressSo the double request does happen on the ROOT path (coffea calls extract_column once per buffer key — offsets and content), exactly as ikrommyd suspected. But uproot's built-in array_cache (a LRUArrayCache, default 100 MB, present on every default uproot.open — confirmed) serves the second call entirely from memory, so there is no double physical read/decompress. Coffea's own LRUCache(1) source cache in base.py is what keeps the opened file (and thus its array_cache) alive across the two buffer-key reads — likely the "cache of size 1" ikrommyd half-remembered, though it caches the source, not arrays.
This is precisely why parquet was different and needed our fix: the parquet mapping had no uproot-equivalent cache, so each buffer access called ParquetFile.read() and physically re-read the whole column. PR #1583's per-source column cache is the ROOT-array_cache analog, added in the one place parquet lacked it.
Answer to "addressed in the appropriate place?"
Yes:
- ROOT / base schema — nothing to add; uproot's array_cache already dedupes the double request at the physical-read level (verified: call Add .csv and .json interpretation to weights extractor #2 is all cache hits). The only way to lose that is opening with array_cache=None, which is uproot's documented knob and not the default.
- Parquet — the appropriate place was the parquet mapping itself, which is exactly where fix(nanoevents): correct parquet entry_start jagged reads and cache column reads #1583 put the per-source column cache.
There was a problem hiding this comment.
good bot: yeah, there's a LRUCache(1) in the nanoevents mapping to keep the file alive
…et entry_range; drop dead line Address review on scikit-hep#1583: - add test_parquet_int32_list_offsets_entry_range: the nano_dy samples all decode to LargeListArray (int64), so a plain pa.list_ column is needed to exercise the numpy.int32 offsets branch of the fix (3/4 subranges fail on unfixed code) - parametrize the subrange test over eager+virtual (virtual is the production Runner path); note virtual already returned correct data pre-fix, only eager reproduced the corruption - drop the dead `offsets = None` line unified out of both branches Assisted-by: Claude Opus 4.8
…et entry_range; drop dead line Address review on scikit-hep#1583: - add test_parquet_int32_list_offsets_entry_range: the nano_dy samples all decode to LargeListArray (int64), so a plain pa.list_ column is needed to exercise the numpy.int32 offsets branch of the fix (3/4 subranges fail on unfixed code) - parametrize the subrange test over eager+virtual (virtual is the production Runner path); note virtual already returned correct data pre-fix, only eager reproduced the corruption - drop the dead `offsets = None` line unified out of both branches Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reframe the sliced-offsets comment to describe what the rebasing does rather than narrate the prior bug, and drop issue-tracker references and pre-fix narration from the parquet entry-range and column-cache test docstrings. Assisted-by: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XeYa8sEdeLGa1VX2frvoNz
d1ddfe1 to
2b38f93
Compare
Part of #1578 — critical bug 4 (parquet reads with
entry_start > 0return shifted jagged data) and the performance bullet (parquet mapping reads the entire column per buffer access with no cache). Two commits, one per bullet.Correctness: reading a jagged parquet column with
entry_start > 0returned silently-corrupted data. The mapping sliced the column with pyarrow (which records a logicaloffsetrather than copying buffers) and extracted the content offset-aware viaflatten(), but read the list offsets from the start of the offsets buffer, ignoringaspa.offset. Per-event values (e.g.Muon.pt) were reassigned to the wrong events, and NanoAOD cross-references crashed with a broadcast length error (reproduced withentry_start=5). The fix readslen(aspa)+1offsets starting ataspa.offsetand rebases them to zero, for bothListArrayandLargeListArray; anyentry_start/entry_stopsubrange now equals the full-read slice exactly.Performance: each buffer-key access re-read the entire column from the file, so a jagged column (offsets + content keys) was read at least twice. A small per-source LRU cache keyed by column name collapses these into a single read with no public-API change (measured 2 → 1 reads per jagged column via the raw
from_bufferspath; 1599 → 1516 total reads in the standard eager path). A true partial row-range read would require row-group iteration and a larger refactor, noted in the commit body.Regression tests cover subrange-vs-full-slice equality (2 encodings × 4 ranges, jagged + flat branches) and a monkeypatched read-count assertion; both fail before their respective commits and pass after.
tests/test_nanoevents.py(28) andtests/test_local_executors.py -k parquet(98) pass; pre-commit clean.🤖 Generated with Claude Code