perf(data): vectorize NNJA IR decode and accumulate Arrow tables per batch - #1059
Conversation
Addresses the performance/memory findings from the PR 1046 review (sections 5.1-5.2 of the attached analysis): - The per-channel conversion loop did roughly a dozen numpy entries per channel on one-element arrays. The footprint's channels are now converted in one numpy pass (radiance scaling, wavenumber grid, and Planck inversion each called once per footprint on the full channel vector). Measured ~694k channel-rows/s/core on a synthetic 616-channel IASI footprint, roughly an order of magnitude over the per-channel form. - Decode workers now return per-batch Arrow tables instead of pickled lists of per-row dicts (~550 B/row measured in review). Row dicts are converted per message inside the worker and freed immediately, the batch crosses the process boundary as Arrow buffers, and the parent accumulates columnar tables (~50 B/row) rather than dicts - it never holds the dict, Arrow, and pandas representations simultaneously. decode_ir_sounder concatenates the batch tables and converts to pandas once. Output schema, dtypes, row order, and filtering semantics are unchanged; the existing decode tests pass unmodified except the failure-contract test's stub return type.
Greptile SummaryThe PR vectorizes NNJA infrared sounder conversion and replaces cross-process row dictionaries with schema-bound Arrow tables to reduce decode time and memory use.
|
| Filename | Overview |
|---|---|
| earth2studio/data/utils_ncep.py | Vectorizes IR channel decoding and changes worker aggregation from row dictionaries to consistently schema-bound Arrow tables without an identified behavioral regression. |
| test/data/test_nnja.py | Updates the failure stub for Arrow returns and adds focused tests for concatenation, empty output, and dataframe dtype preservation. |
Reviews (1): Last reviewed commit: "Vectorize IR subset conversion; accumula..." | Re-trigger Greptile
…g IR chunks - _table_to_dataframe: use types_mapper=pd.ArrowDtype for all columns; dictionary-encode satellite/variable/class (low-cardinality strings) - compile_dataframe: accumulate pa.Table per task instead of pd.DataFrame, concat via Arrow at the end — eliminates pd.concat memory doubling - _decode_ir_sounder_chunks: new generator that yields one pa.Table per completed batch; decode_ir_sounder becomes a thin wrapper over it
for more information, see https://pre-commit.ci
|
/ok to test b96a883 |
There was a problem hiding this comment.
Reviewed at 53f6a47 against origin/main at 33eca38, with the decode path exercised directly on both revisions.
Three small things before merge, in the line comments: the combine_chunks() at the end (utils_ncep.py:2189) allocates a second full copy and doubles the peak this PR reduces; the new generator made validation lazy and the failure contract skippable; and the new test bakes in the combine_chunks call rather than testing what production should do.
Three figures in the description need restating, all measurement artifacts rather than problems with the work:
- The "105x memory reduction, 2 B/row" comes from
tracemalloc, which traces the Python allocator and so cannot see PyArrow's memory pool — it reports approximately nothing for Arrow buffers. Measured withpa.total_allocated_bytesand RSS, the same rows go from 533 B/row aslist[dict]to 86 B/row as apa.Table: a genuine 6.2x. The "before" figure of 167 B/row also looks like a peak divided by rows across batches that were not simultaneously alive. - *Throughput— measured AIRS separately at 1.2x: its radiances are already brightness temperatures, so there is no Planck inversion to vectorize, and its three descriptors per channel make the serial descriptor walk dominate.
- The dtype row attributes
float64 -> double[pyarrow]tolat, which is declaredpa.float32()in the shared schema (lexicon/base.py:410-411) and taken from it directly byNCEP_MICROWAVE_OUTPUT_SCHEMA(utils_ncep.py:1357). Converting through each revision's helper giveslat:float32 -> float[pyarrow]. The described transition is real but belongs towavenumber, the one genuinelyfloat64column. The string half is also pandas-version-dependent: on pandas 3 the "before" state is already an Arrow-backedstrrather thanobject.
Two wins here are larger than the ones advertised, and neither is mentioned.
- Row serialization across the process boundary essentially disappears. For 400k rows the
list[dict]pickles to 58.6 MB at 0.17 s to dump plus 0.24 s to load; the equivalentpa.Tableis 34.4 MB and both directions round to 0.00 s — roughly 1 µs/row that the old path paid twice. from_pylistmoves off the serial critical path. On one 32-channel CrIS aggregate, converting all 46.7M rows in the parent takes ~50 s of strictly serial wall time; the same work in message-sized calls across eight workers takes 6.56 s (barrier-synchronized so row-building cannot overlap conversion; summed work 47.07 s at 1.01 µs/row, so it scales essentially linearly).
Also worth a line in the description:_rows_to_dataframenow delegates to_table_to_dataframe, sodecode_microwaveoutput changes dtype too — floats todouble[pyarrow], strings todictionary<int8, string>[pyarrow]. That is a good and consistent change, but it is API-visible for ATMS, AMSU-A, AMSU-B and MHS users, which the title does not suggest.
One consequence of the dtype switch deserves more emphasis than the footprint reduction it is credited with: with every column Arrow-backed, pa.Table.from_pandas on the returned frame is zero-copy — identical buffer addresses, all chunks preserved, nothing allocated — so a consumer can do projection, filtering and thinning in Arrow despite being handed a DataFrame. Numpy-backed columns cannot, because to_pandas consolidates each column before the consumer ever sees it.
Follow-up ideas (probably future PRs)Issue A — Wide return for the IR (and MW) sounder decode pathThe IR decode emits long format: one row per (footprint, channel). For a 48-hour, seven-sensor, Wide also enables an ordering that long format forecloses. Quality screening and spatial thinning need Note run-end encoding is not an alternative route here. It compresses well — a Issue B — Share the frame conversion and the footprint-to-long expansion across observation sourcesSix sources perform the same operation — per-footprint scalars plus a Two extractions, in order of value:
The helper is also where emitted row order should be decided, because it currently differs by source Measured, the emitted order should follow the orientation the values already have, since whichever This is about the order rows come out in, and is distinct from a source's internal matrix layout, which Issue C —
|
Co-authored-by: Aayush Gupta <19579293+aayushg55@users.noreply.github.com>
Address aayushg55's review on NVIDIA#1059: - Split _decode_ir_sounder_chunks so validation and file I/O run eagerly at call time; the streaming half (_yield_ir_batches) raises _NCEPIRSounderDecodeError on the first failing batch, so early-exiting consumers cannot silently accept a partial decode - Reflect first-failing-batch semantics in the exception text/context - Assert the shared dtype contract on a chunked table in test_nnja_ir_batch_tables_concat_to_shared_dtypes, matching what production hands _table_to_dataframe after dropping combine_chunks - Update microwave dtype assertions to the Arrow-backed output (_rows_to_dataframe now delegates to _table_to_dataframe) - Add missing empty_dataframe docstring (interrogate 95% gate)
…, share test fixture - Move the Arrow-to-pandas conversion to earth2studio/data/utils.py as public table_to_dataframe(table, dict_string_columns=()), so other Arrow-backed sources can adopt the same dtype contract; the dictionary-encoding column set is now a parameter (NCEP policy stays in utils_ncep via a thin _table_to_dataframe delegate) rather than being baked into a shared helper - Name the pickled IR worker argument tuple (_IRBatchArgs) instead of spelling the six-element type twice - Hoist the duplicated 18-field test row into _ir_table_row No behavior or performance change; conversion logic is byte-identical.
|
Scope note for re-review: 564b5de (trimmed back in e287696) adds one refactor on top of the review fixes — |
Keep only the table_to_dataframe promotion from 564b5de. The alias and fixture deduplicated two-occurrence code introduced by this PR itself and churned exactly the regions under re-review, for no behavioral benefit: the inline tuple type is self-describing at the point of use (mypy already catches drift), and the inline test rows show the asserted frame contents without a jump to hidden defaults.
…ocstring Exercises the parameterized behavior the NCEP delegate does not: absent and non-string names in dict_string_columns are ignored, default call does plain ArrowDtype conversion, and chunked tables convert intact. int8 dictionary indices allow 128 distinct values, not 127.
|
/ok to test fd339b6 |
|
/ok to test e78044e |
|
Thanks @aayushg55 |
|
/ok to test e78044e |
Automates the manual steps used to benchmark NVIDIA#1059: clones the repo at the pre- and post-optimization commits into a shared workdir, sets up one venv, downloads a real IASI aggregate once, and times bench_nnja_decode.py against both, printing a speedup summary.
Runs old-vs-new at decode_workers=1 and decode_workers=<nproc> by default (override with WORKER_COUNTS), so the driver also measures the NVIDIA#1059 parallel-decode path on many-core nodes, not just the per-message vectorization at workers=1.
Earth2Studio Pull Request
Description
Vectorizes the NNJA IR sounder decode inner loop and switches decode workers to return Arrow tables instead of pickled row dicts. Addresses the performance and memory findings (§5.1–5.2) from the review analysis on #1046, which were explicitly deferred to a follow-on PR.
Vectorized inner loop
_decode_ir_subsetpreviously ran roughly a dozen single-element NumPy operations per channel (twonp.arrayconstructions,np.power, a grid lookup with range check, and a full Planck inversion). The function now converts each footprint's channels in one NumPy pass: radiance scaling, wavenumber grid lookup, andradiance_to_btare each called once per footprint on the full channel vector.Arrow tables per batch
Decode workers previously returned pickled
list[dict]rows that the parent accumulated in full before a singlefrom_pylist→to_pandascall, holding the dict list, Arrow table, and pandas frame in memory simultaneously. Workers now convert rows to an Arrow table immediately after each message (freeing the dicts), return onepa.Tableper batch across the process boundary, and the parent concatenates (without consolidating —pd.ArrowDtypecolumns holdChunkedArrays natively, so nocombine_chunkscopy is paid) and converts to pandas once at the end.Two additional wins from the Arrow return path (measured by @aayushg55 in review):
list[dict]pickle to 58.6 MB at 0.17 s to dump plus 0.24 s to load; the equivalentpa.Tableis 34.4 MB and both directions round to 0.00 s — roughly 1 µs/row the old path paid twice.from_pylistmoves off the serial critical path. On one 32-channel CrIS aggregate (46.7M rows), converting all rows in the parent is ~50 s of strictly serial wall time; the same work in message-sized calls across eight workers takes 6.56 s and scales essentially linearly.Full
pd.ArrowDtypeoutput + dictionary-encoded strings_table_to_dataframepreviously only mapped unsigned-integer columns topd.ArrowDtype, leaving floats and strings as standard pandas types. It now passestypes_mapper=pd.ArrowDtypefor all columns and dictionary-encodes the three low-cardinality string columns (satellite,variable,class) before conversion, cutting per-row string overhead on large frames (~24% smaller DataFrame memory footprint). With every column Arrow-backed,pa.Table.from_pandason the returned frame is zero-copy, so consumers can do projection/filtering/thinning in Arrow despite being handed a DataFrame.API-visible dtype change beyond IR:
_rows_to_dataframenow delegates to_table_to_dataframe, sodecode_microwaveoutput dtypes change too for ATMS, AMSU-A, AMSU-B, and MHS — floats becomefloat[pyarrow]/double[pyarrow], strings becomedictionary<int8, string>[pyarrow], times becometimestamp[ns][pyarrow]. This makes MW and IR share one dtype contract.Streaming IR decode —
_decode_ir_sounder_chunksNew private generator that yields one
pa.Tableper completed decode batch.decode_ir_sounderis a thin wrapper over it, so callers processing multiple files can pipeline decode and downstream work without waiting for all batches to finish. Validation and file I/O run eagerly at call time, and the iterator raises_NCEPIRSounderDecodeErroron the first batch containing failures, so an early-exiting consumer cannot silently accept a partial decode (preserving the #1046 completeness contract).Unchanged
Output schema, row order, and all filtering semantics (non-finite observations, missing-CHSF skip counting, non-finite BT, quality packing, CHNM channel ordering). Dtypes are intentionally changed as described above.
Performance
Benchmarked on this machine (single core, warm cache). IASI uses a 616-channel footprint; CrIS uses 431 channels (FSR).
Throughput —
_decode_ir_subsetAIRS gains far less: its radiances are already brightness temperatures (no Planck inversion to vectorize), and its three descriptors per channel make the serial descriptor walk dominate.
Memory — batch accumulation
Measured with
pa.total_allocated_bytesand RSS (an earlier draft quotedtracemallocfigures, which trace only the Python allocator and cannot see PyArrow's memory pool, overstating the reduction):list[dict]pa.Tableper batchDataFrame footprint —
_table_to_dataframe(200k rows)satellitedtypestr(object)¹dictionary<int8, string>[pyarrow]latdtypefloat32float[pyarrow]wavenumberdtypefloat64double[pyarrow]DataFrame.memory_usage¹ On pandas 3 the "before" state is already an Arrow-backed
strrather thanobject.Note: the dict-encoding cast adds ~6 ms overhead per
_table_to_dataframecall on a 200k-row table. For the typical per-file decode path this is negligible relative to BUFR I/O and decode time.Checklist
Dependencies
No new dependencies.