Add insitubatch streaming ERA5 data source and hindcast recipe - #962
Add insitubatch streaming ERA5 data source and hindcast recipe#962emfdavid wants to merge 16 commits into
Conversation
Greptile SummaryThis PR adds an optional insitubatch-based ERA5 streaming path. The main changes are:
|
| Filename | Overview |
|---|---|
| earth2studio/data/insitu.py | Adds the streaming feed and batch conversion logic; shifted leads need bounds validation against the requested init window. |
| pyproject.toml | Adds the optional insitubatch data-extra dependency for supported Python versions. |
| test/data/test_insitu.py | Adds offline coverage for feed shape, values, deduplication, transpose behavior, persistent cache, and lead-step validation. |
| recipes/insitubatch_hindcast/stream_score.py | Adds a streaming versus dense scoring benchmark using Persistence. |
| recipes/insitubatch_hindcast/bench_hindcast.py | Adds a before/after hindcast verification-read benchmark. |
| recipes/insitubatch_hindcast/bench_cache.py | Adds a cold/warm persistent-cache benchmark. |
| recipes/insitubatch_hindcast/README.md | Documents setup, benchmark usage, results, and caveats for the new recipe. |
| CHANGELOG.md | Documents the new optional streaming feed. |
Reviews (1): Last reviewed commit: "Sort recipe imports for the current ruff..." | Re-trigger Greptile
| row = [] | ||
| for v in self.variables: | ||
| label = f"{v}#{li}" | ||
| geometries[label] = opened[vmap[v]].shift(int(k)) |
There was a problem hiding this comment.
Shifted Leads Escape Sample Window
When sample_range starts at the first init and lead_times includes a history lead like -6h, this shift reads before index 0; when the range reaches the store tail, positive verification leads read past the last time step. The feed accepts those documented lead values, so iteration can fail or return boundary samples from the wrong stored time instead of rejecting the invalid init window.
|
Nick - I will update the branch anytime there are substantive conflicts but chasing the change log seems silly. I will fix that when we get close to merging. |
b496df1 to
824ceed
Compare
New optional data module `earth2studio.data.insitu` that feeds initial conditions to `earth2studio.run` workflows without going through xarray. - `batch_to_xcoords`: pure converter from an insitubatch numpy `Batch` to the exact `fetch_data(legacy=True)` contract -- an `(time, lead_time, variable, lat, lon)` tensor plus the matching 5-key `CoordSystem` OrderedDict -- so it drops straight into `prognostic.create_iterator` after `map_coords`. - `InSituForecastFeed`: prefetched IC iterator over a contiguous window of an analysis store. Reads with insitubatch (bounded-fan-out async prefetch and a read plan that de-duplicates chunks shared across init times) instead of the per-`(time, variable)` `DataSource -> xr.DataArray -> fetch_data` path, whose gather is unbounded and re-reads overlapping chunks. Emits a single 0 h lead (an initial condition) today; multi-step history and verification-lead offsets are the next step. insitubatch is imported lazily and only required when this module is used.
Rework InSituForecastFeed from a single-lead initial-condition feed into a verification-capable feed: - Unify history (lead_time <= 0) and verification (lead_time > 0) into one lead axis. Each lead is a sample-axis `shift` view of one stored array, stacked into `(time, lead_time, variable, lat, lon)`, so the `(init, lead)` grid decodes each shared chunk exactly once. - Take an injected `store: Store` instead of building obstore from a URL, so a caller can pick any insitubatch backend (e.g. anonymous public buckets over gcsfs). - Decode the CF `time` coordinate via `cftime` (an existing dependency) rather than assuming a datetime64 encoding. - `transpose_inner` swaps a store's `(lon, lat)` field layout to the contract's `(lat, lon)`. `self.dataset` exposes the underlying InSituDataset cache counters.
Two runnable benchmarks that feed ERA5 into an Earth2Studio prognostic via the insitubatch feed instead of the dense `fetch_data` grid, with a README framing the results: - bench_hindcast.py: verification-read de-duplication over a hindcast grid (WB2 fat-chunk / ARCO chunk-1), before (E2S fetch) vs after (insitubatch feed). - stream_score.py: streaming vs dense verification materialization, scored against ERA5 with Persistence; reports wall, peak RSS, and matching RMSE. Both read anonymous public GCS over gcsfs on both sides, so the delta isolates the loader (not an obstore-vs-gcsfs artifact). Motivated by recipes/eval's predownload sentinel, which exists because live fetch_data is too slow.
insitubatch 0.1.0 is on PyPI and now exposes InSituDataset and the framework adapters at the package root, so: - earth2studio/data/insitu.py imports them from `insitubatch` (public surface) instead of reaching into `insitubatch.source` / `.frameworks` / `.types`. - pyproject: add `insitubatch>=0.1.0` to the `data` extra, gated `python_version>='3.12'` (insitubatch requires 3.12; E2S supports 3.11) — mirrors the existing intake-esgf marker. Resolves from PyPI (uv.lock updated).
insitubatch is now a declared earth2studio `data`-extra dependency (insitubatch>=0.1.0, gated Python>=3.12), not a manual side install. Point the Setup at `earth2studio[data]` (or a direct pinned `insitubatch>=0.1.0`).
The earth2studio tree is uv-managed and the recipe runs in-tree, so match the repo convention (`uv sync --extra <name>`) instead of pip; the data extra brings insitubatch.
Cover InSituForecastFeed / batch_to_xcoords / decode_cf_time against a synthetic on-disk store (fat time-chunk, CF-encoded time coord): the (x, coords) contract, byte-correct sample-axis shift views, the decode-once dedup property (cache_misses), transpose_inner, and the integer-multiple-of-dt lead guard. No network / live bucket. Addresses the tests + CHANGELOG items of the PR checklist.
cache_dir now sets persist=True, so decoded chunks survive across runs (the flag was passed through but never persisted). Add a cold-vs-warm bench (bench_cache.py), a cross-run persistence test, and a README section framing it as a predownload replacement. Measured cold->warm: WB2 33->0 cloud fetches (~1.4x wall), ARCO 54->0 (~2.2x wall).
Clarify that bench_cache cold includes the one-time persist write, so it runs slightly above the persist-off de-dup figure in section 1.
Rebasing onto NVIDIA main picked up its import-grouping rules; re-sort bench_hindcast.py and stream_score.py (stdlib / third-party / first-party).
Greptile flagged that a history (<0) or verification (>0) lead near a store boundary reads outside the requested init window: the engine silently drops those edge anchors, so a scoring campaign would cover a shorter window than asked without any signal. Validate the requested sample_range against valid_anchor_range(lead_steps, n_samples) and raise an actionable ValueError; when sample_range is unset, default to the in-bounds init window. Covered by boundary tests (past-end, before-start, none-defaults).
The CI transition (NVIDIA#1021) runs `make lint`, `make license` and `make interrogate` over all files, so the recipe scripts are now gated where they previously were not: - Annotate the three recipe scripts (mypy `-a` runs with `disallow_untyped_defs`; library objects stay `Any` since the hook resolves imports with `follow_imports=skip`). - Add the NVIDIA SPDX headers the license check requires. - Rewrap the README over markdownlint's line-length budget: the shell blocks use line continuations, and the WB2 table row is trimmed to the 100-col table limit. No behavior change; `--help` and the offline tests are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Earth2Studio's zarr data sources migrated to obstore in NVIDIA#955 while this PR was in review, so the comparison had silently become obstore (before) vs gcsfs (after). Move the feed to insitubatch's `obstore_store` to match, and refresh every number. - Both sides now read via obstore anon; the de-dup ratios are unchanged (33 decodes on WB2, 162 on ARCO), which is the point -- the win is read planning, not transport. - Fix ARCO's `--start`: its axis is `hours since 1900-01-01` spanning 1900-2050, but chunks outside ~1940-2023 were never written and read back as all-NaN fill without a network request. The default of 1000 was 1900, so the documented ARCO command in §1 failed outright and §3's row measured nothing. `--start` now defaults per store, and both benchmarks raise if a window reads back entirely NaN. - `bench_cache.py` confines its cold-start wipe to `<cache-dir>/bench_cold_warm/<store>/` instead of `rmtree`-ing the user-supplied path. - Refresh §1/§2/§3 with 5-10 repeat medians, and record that the earlier figures were taken over gcsfs before NVIDIA#955 under different logging conditions -- superseded, not a controlled comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Earth2Studio's per-fetch debug logging sits inside the timed region and is not free -- say so, say the published numbers are all measured with it off, and drop the inconclusive breakdown of what changed since the first measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "2.3x slower per byte" claim was an artifact: it divided Earth2Studio's *notional* byte count (576 requested reads x 4.15 MB) by wall time, crediting it with 3.6x redundancy it never uniquely moved. Measured with redundancy removed from both sides -- one init at unit lead spacing, so 162 requested = 162 unique = 0.67 GB either way -- E2S takes 1.38 s and the feed 1.61 s. The feed is ~17% slower per byte, roughly at parity, not 2.3x behind. The gap in the headline ARCO row comes from somewhere else: de-duplication removes the fetch and decode of a redundant sample but not its assembly, since the tensor the model consumes still has one slot per requested (init, lead). Subtracting the two configurations, each redundant sample costs E2S 6.1 ms and the feed 2.3 ms -- 2.6x cheaper, not 3.6x, which is why 3.6x fewer decodes nets only 1.5x wall. Document the zero-redundancy control so the split is reproducible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…seline Two corrections, both from reading `recipes/eval` and Earth2Studio's own defaults rather than assuming them. Predownload already de-duplicates: `compute_verification_times` collapses the (init, lead) grid onto unique valid times before any fetch. It is also a deliberate cluster-scale ETL -- rank-parallel via `distribute_work`, resumable, leaving a durable pre-regridded artifact -- not a workaround. State the scope honestly: insitubatch replaces the predownload-then-read cycle for streaming consumption on one box, not the bulk ETL phase, which a single async event loop cannot scale across nodes. The §1 baseline also ran with `cache=False`, which is not the source default. `LocalCachingStore` caches compressed buffers keyed by chunk path, so a stock run serves redundant reads from local disk. Add `--before-cache` to run the baseline as configured by default, wiping a bench-owned cache directory before each repeat so every repeat is a cold pass, and report both configurations. WB2 6.8x cached / 9.5x uncached; ARCO 1.4x / 1.7x. Decode counts are identical either way -- the cache is below zarr's codec pipeline. Also record what the de-dup ratios are measured against. 174x and 3.6x are against the live per-work-item path; against a valid-time-deduplicated baseline the advantage is exactly the sample-axis steps per chunk -- 7.9x on WB2, none at all on chunk-1 ARCO. Relabel the §2 `e2s` row, which measures this harness's own dense buffer rather than predownload, and flag it pending a rerun. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@nsobhani — following up on your point in the call that Earth2Studio already de-duplicates. You were right, and pulling on it turned up three separate places where I'd measured Earth2Studio in a configuration it doesn't actually run — each one flattering to us. Benchmarking someone else's system fairly is harder than I'd given it credit for; I clearly should have read more of
The consequence is that §2's §1 is unaffected — its baseline is the live path, Reading the rest of insitubatch does not replace that, and structurally cannot — its parallelism lives in one async event loop rather than worker processes, so it does not scale a bulk fetch across nodes. What it replaces is the predownload-then-read cycle for streaming consumption on a single box: no separate phase to schedule, no materialized copy of the verification set to provision. Rank-parallel training and inference are unaffected, since each DDP rank streams its own shard. I've rewritten the recipe's framing and §3 to state that scope plainly, and to present the cache as an alternative for the no-ETL-phase case rather than as a substitute for predownload. So the honest claim is narrower than what I had written: Earth2Studio de-duplicates at timestamp granularity in an offline pass; insitubatch de-duplicates at chunk granularity in the live path. On WB2's Checking that against the code turned up a second thing I had wrong, so I re-measured §1. The baseline ran with
The cache recovers only ~25% of the WB2 baseline wall — WB2 chunks are ~116 KB, so the network was never the bottleneck; the cost is 5760 decodes against 33, and the cache sits below zarr's codec pipeline so decode counts are identical either way. The win survives on WB2, but as a decode-elimination result rather than a reads-eliminated one. ARCO is now essentially parity. Its cached figure is also disk-sensitive — the 363 MB cache is written cold each repeat, and moving it from the boot disk to local NVMe took the baseline 4.31 → 3.78 s (1.4× → 1.2×). I've quoted the NVMe number, the one favourable to your side, and the two distributions overlap at that point (feed 2.69–3.98 s against 3.71–4.02 s). That row reads as the boundary, not a win. These supersede the 12.8× / 1.5× this PR previously carried. One difference is not the cache: the insitubatch leg no longer reproduces its earlier WB2 wall — 0.84 s then, 1.17 s now (median of 10 repeats, range 1.02–1.24) against an unchanged 10.95 s baseline. The current figure reproduces across independent runs and repeat counts and the earlier one does not, so it is the one quoted, but I can't yet account for the shift. Both legs of every comparison were measured in the same session, so the ratios are unaffected. Worth being explicit about what those ratios are measured against, given the above: the live per-work-item path, not predownload. Against a valid-time-deduplicated baseline the advantage is exactly the sample-axis steps per chunk — WB2 261 decodes → 33 = 7.9×, ARCO 162 → 162 = none at all, since on a chunk-1 store chunk granularity is timestamp granularity. That is arithmetic from the index ranges rather than a measurement; I haven't measured that baseline's wall. Which raises the thing I'd most like a second pair of eyes on: is there something in the So: how much of the work — yours at NVIDIA, and what you see from external Earth2Studio users — actually runs through predownload, and how much is interactive or single-box scoring where standing up the ETL phase isn't worth it? That determines whether §2 and §3 are worth measuring properly or whether §1's live path is the only part that earns its place here. I'd rather build the benchmark you'd find persuasive than guess twice. |
Earth2Studio Pull Request
Closes #961
Description
A recipe for streaming ERA5 into an Earth2Studio prognostic with
insitubatch — a streaming, read-planning cloud-zarr
loader — instead of the dense
fetch_datagrid, for IO-bound hindcast / scoring campaigns.How it plugs in — around the model, not inside the xarray loop. insitubatch delivers
tensor batches and never builds
xr.DataArray. The newearth2studio.data.insitu.InSituForecastFeedreads the analysis store → DLPack →(torch.Tensor, CoordSystem)and feedsprognostic.create_iterator(x, coords)directly, whereeach forecast lead is a sample-axis
shiftview of one array (decode-once, no reshard). Itdoes not touch the
DataSource → xr.DataArray → fetch_datapath, so E2S's lexicon / coords /regrid machinery is untouched — this is a complementary, opt-in feed, not a replacement.
Why it helps an IO-bound campaign. A scoring grid needs ERA5 at
valid = init + leadforevery
(init, lead); consecutive inits share valid times and a fat time-chunk holds severalsteps, so the requested reads collapse onto far fewer stored chunks. insitubatch's read planning
de-duplicates those into one decode per stored chunk under a bounded
max_inflightbudget,and streams lead-by-lead so scoring never materializes a dense verification tensor.
What's in the PR
earth2studio/data/insitu.py— theInSituForecastFeedadapter (guarded optional import ofinsitubatch; yields(torch.Tensor, CoordSystem)).recipes/insitubatch_hindcast/— three runnable benchmarks + a README:bench_hindcast.py— verification-read de-duplication vs per-initfetch_data.stream_score.py— streaming vs dense materialization (three modes, identical RMSE).bench_cache.py— cross-run persistent cache (cold → warm).Benchmark results
Preliminary, one
n2-standard-8-class box (15 GB RAM), cold reads, obstore anon on both thebefore and after side, so neither side carries a backend handicap. Public WeatherBench2 and ARCO
ERA5 stores. Run these with
LOGURU_LEVEL=INFO— Earth2Studio's per-fetch debug logging sitsinside the timed region (14 760 lines on the
e2sleg below) and is not free. Every number here ismeasured with it off.
1. Verification-read de-duplication (
bench_hindcast.py) — BEFORE = per-initfetch_data,AFTER = the insitubatch feed:
Earth2Studio's data sources cache by default (
cache=True), which materially changes the wall, sothe baseline runs in both configurations.
LocalCachingStoreholds compressed buffers keyed bychunk path, below zarr's codec pipeline — so with the cache on a redundant read costs a local disk
hit instead of a round-trip, but the chunk is still decoded again. Decode counts are identical
either way.
(8,240,121)fat(1,721,1440)chunk-1Medians: WB2 8.22 → 1.21 s cached (5 repeats), 10.95 → 1.17 s uncached (10 repeats); ARCO
3.78 → 3.16 s cached, 5.20 → 3.11 s uncached (10 repeats each). The baseline's cache is written cold
on every repeat, so its device shows in the wall: ARCO's cache is 363 MB and moving it from the boot
disk to local NVMe took the cached leg 4.31 → 3.78 s, i.e. 1.4× → 1.2×. The NVMe figure is quoted —
the one favourable to the baseline. WB2's cache is ~10 MB and does not move. At 1.2× the ARCO
distributions overlap (feed 2.69–3.98 s against 3.71–4.02 s); that row is the boundary case, not a
win. Quote the cache-on column — it is how a stock run behaves. The
cache recovers only ~25% of the WB2 baseline wall, because WB2 chunks are ~116 KB and the network
was never the bottleneck there; the cost is 5760 decodes against 33, which no byte cache addresses.
These ratios are against the live path, not against
predownload.py. The baselinere-requests every
(init, lead)pair — what the pipelines do, not what predownload does, since itde-duplicates valid times first. Against a valid-time-deduplicated baseline the advantage is
exactly the sample-axis steps per chunk: WB2 261 decodes → 33 = 7.9×; ARCO 162 → 162 =
none at all, because on a chunk-1 store chunk granularity is timestamp granularity. That is
arithmetic from the index ranges, not a measurement — the wall for that baseline is unmeasured.
These supersede the figures this PR previously carried (12.8× WB2, 1.5× ARCO), which were cache-off
only. One difference is not the cache: the insitubatch leg no longer reproduces its earlier WB2 wall
— 0.84 s then, 1.17 s now (median of 10 repeats, range 1.02–1.24) against an unchanged 10.95 s
baseline. The current figure reproduces across independent runs and repeat counts and the earlier
one does not, so it is the one quoted; the cause of the shift is unexplained. Both legs of every
comparison were measured in the same session, so the ratios hold regardless.
2. Streaming vs dense materialization (
stream_score.py, N = 120 inits × 40 leads) — allthree modes produce identical RMSE (3.637 / 5.061 / 5.071 at 24 h / 120 h / 240 h):
e2s— live per-initfetch_data, dense bufferdense— insitubatch,batch_size=Nstream— insitubatch,batch_size=WThe
e2smode computes that RMSE through an entirely independent path — live per-initfetch_dataviaWB2ERA5_121x240, no insitubatch in the loop — so the agreement is a realcorrectness check; throughput alone would not catch a loader that silently aliased or double-lent
a buffer.
Streaming peak memory is flat at ~1.9 GB across N = 120 / 240 / 480, while the dense grid is
7.63 GB at N = 120 and OOMs a 15 GB box by ~N = 240. Dense scales with campaign size;
streaming does not — that bounded-memory property, not just throughput, is the point for a long
campaign. (Persistence is used as a checkpoint-free model that exercises the real
create_iteratorseam on CPU; a real NVIDIA checkpoint — SFNO/FCN — is a drop-in with the samecode on a GPU.)
Cross-run persistent cache
InSituForecastFeed(cache_dir=...)persists decoded chunks to local disk, covering the samere-score need as the eval recipe's
predownload.pywithout a separate phase: the first run decodeseach shared chunk once and caches it; a re-score of a different model against the same ERA5
reads from disk, fetching the cloud zero times. Only the chunks touched, no materialized copy of
the grid; a static reanalysis store never goes stale.
This is not an argument against predownload, which buys rank-parallel bulk fetch, resumability, and
a durable pre-regridded artifact. The trade is scope: the cache buys the same re-score property
with no ETL phase to schedule and no full copy to provision, which matters when there is no cluster
to run the phase on.
Measured cold→warm (cache on NVMe): WB2 33→0 cloud fetches, 1.16 s → 0.81 s (1.4×); ARCO
54→0, 0.81 s → 0.59 s (1.4×). Fetch elimination is the deterministic win. The wall speedup is
1.4× on both stores despite a 35× difference in field size, so it is not tracking how IO-bound
the cold fetch is — on this box's cheap same-region reads the cloud fetch simply isn't the
bottleneck. It grows under metered egress, requester-pays, or cross-region access, while the fetch
elimination holds everywhere. The cold wall includes the one-time persist write, so it sits
slightly above the persist-off de-dup figures above.
Honest boundary
Not a universal speed win — but on a chunk-1 store with large fields the reason is not raw
throughput. Re-run ARCO with redundancy removed from both sides (one init at unit lead spacing, so
162 requested = 162 unique = 0.67 GB either way) and E2S takes 1.38 s (486 MB/s) against the
feed's 1.61 s (416 MB/s): ~17% slower per byte, roughly at parity, over three independent
8-repeat runs.
The gap in the ARCO row above opens elsewhere: de-duplication removes the fetch and decode of a
redundant sample, but not its assembly — the tensor the model consumes still has one slot per
requested
(init, lead). Subtracting the two configurations, each of the 414 redundant samplescosts E2S 6.1 ms (it refetches) and the feed 2.3 ms (it re-assembles from an already-resident
chunk) — 2.6× cheaper, not 3.6×, which is why 3.6× fewer decodes nets only 1.5× wall. Where fields
are small (WB2) assembly is negligible and nearly the whole de-dup ratio converts.
A degenerate
batch_size=Nalso throws away the memory advantage:denseabove peaks at 7.63 GB,worse than the dense-buffer baseline it replaces (3.10 GB). The sweet spot is streaming with
bounded memory, and the large wins land when the chunk layout maps many samples onto shared
chunks (overlapping windows, verification grids, fat chunks).
A further boundary is scope rather than misuse: insitubatch does not replace a rank-parallel bulk
ETL.
recipes/eval'spredownload.pyscales a fetch across nodes viadistribute_work, resumesafter a failure, and leaves a durable pre-regridded artifact; one async event loop does none of
those. What insitubatch replaces is the predownload-then-read cycle for streaming consumption on a
single box. Rank-parallel training and inference are unaffected — each DDP rank streams its own
shard — but the bulk ETL phase is not something this feed competes with.
Scope / caveats
target infrastructure.
hours since 1900-01-01over 1 323 648 steps to 2050, but chunks outside ~1940–2023 were never written andread back as all-NaN fill in ~20 ms without a network request.
--startnow defaults per store(ARCO: 2020-01-01) and both benchmarks fail loudly if a window reads back entirely NaN. E2S's
ARCOsource validates this independently and refuses pre-1940 requests; insitubatch does not,so a window outside the populated range returns fill data rather than raising.
t2m,u10m,v10m); pressure-level indexing is not yet wired inthe adapter.
insitubatchis an optional dependency (the module raises a clear install hint if absent);nothing in the core E2S path changes. It is deliberately not exported from
earth2studio.data.__init__— that module imports unconditionally, andinsitubatchis adata-extra dependency gated to Python ≥ 3.12, so exporting it would break the core import foreveryone else. Reachable as
from earth2studio.data.insitu import InSituForecastFeed.Checklist
RE: Docs — added a recipe README. Please advise on API docs: the adapter is intentionally not in
earth2studio.data's namespace (see above), so it has nodocs/modules/entry; happy to add oneif you'd prefer it listed with a note about the optional dependency.
Dependencies
insitubatch>=0.1.0(PyPI) — added to thedataextra, gated
python_version>='3.12'. Optional; the adapter guards its import.Licensed MIT —
LICENSE,declared as
license = "MIT"inpyproject.tomland reported as SPDXMITby GitHub. Happy torevisit the license if that helps adoption.