Skip to content

perf: stream the remaining eager Dataset methods (#969) - #971

Merged
MAfarrag merged 22 commits into
mainfrom
perf/to-feature-collection-autotile
Aug 12, 2026
Merged

perf: stream the remaining eager Dataset methods (#969)#971
MAfarrag merged 22 commits into
mainfrom
perf/to-feature-collection-autotile

Conversation

@MAfarrag

@MAfarrag MAfarrag commented Aug 12, 2026

Copy link
Copy Markdown
Member

Description

Optimizes the remaining eager, full-array Dataset methods from the #969 audit so a very large or
/vsicurl-backed raster is no longer materialised whole. Each method keeps its exact output (byte-identical)
unless a new opt-in flag is passed; the streaming technique is matched to the shape of each method.

Method Change Result
to_feature_collection tile defaults to None (auto): read whole below 256 MiB, else tile by tile byte-identical (tiled path already row-major)
sieve seed the target with gdal.Translate (block-based C copy) instead of WriteArray(ReadAsArray()) byte-identical; no full-band NumPy copy
crop(mask=<raster>) tile the raster-mask apply in _crop_aligned (no-gap-fill case) byte-identical; never holds full source + full mask
apply opt-in elementwise=True streams func tile by tile byte-identical for a per-pixel func; default unchanged
plot_histogram opt-in max_samples= reads a decimated grid exact by default; approximate (documented) when set
footprint opt-in max_samples= builds the coverage mask from a decimated read (geotransform scaled) exact by default; approximate (documented) when set
change_no_data_value stream the old→new swap tile by tile (keeps CreateCopy for metadata fidelity); add path= for a disk-backed, out-of-core result byte-identical; metadata (colour table, scale/offset, description, RAT) preserved

Design notes:

  • Byte-identical by default. apply, plot_histogram, and footprint only change behaviour when their new
    opt-in (elementwise=True / max_samples=) is passed; otherwise the output is identical to before. The other
    four are byte-identical unconditionally.
  • change_no_data_value keeps GDAL's CreateCopy deliberately — it clones every band's colour table,
    description, scale/offset, RAT, and metadata exactly, which an empty_like + manual metadata copy would risk
    dropping. The memory win comes from streaming the no-data swap one tile at a time; the new path= makes the
    clone a disk-backed GeoTIFF so the whole operation is genuinely out-of-core.
  • NetCDF variable-view behaviour change (small): the old change_no_data_value called read_array(band)
    positionally, and since NetCDF.read_array takes variable first, that incidentally raised a "pinned"
    ValueError on a variable view. The windowed read (band= keyword) removes that accident, so
    change_no_data_value now works on a variable view like fill() and the other band ops already do. The
    test that asserted the accidental guard is updated to assert it works.

Issues

Type of change

Check relevant points.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Notes: default output is byte-identical for every method; the new parameters (tile=None, elementwise=,
max_samples=, path=) are additive and backward-compatible. The one behaviour change is that
change_no_data_value now succeeds on a NetCDF variable view instead of raising an (accidental) error.

How Has This Been Tested?

  • sieve: existing 13 test_sieve.py cases (nodata both ways, geotransform/CRS, band selection, mask) pin
    the byte-identical output through the new gdal.Translate seeding.
  • crop(mask=raster): TestCropAlignedTiling (new) proves the tiled apply matches a direct NumPy apply
    across 256-px tile seams (1- and 2-band) and matches the eager numpy-mask path; the existing TestCrop cases
    still pass.
  • apply: test_apply_elementwise.py (new) — streamed vs whole-array equal across tile boundaries for
    abs/affine/sqrt, no-data preserved, band selection, in-place.
  • plot_histogram / footprint: new tests assert the exact default is unchanged and that max_samples=
    decimates (fewer samples / coarser polygon within bounds).
  • change_no_data_value: TestChangeNoDataValueStreaming (new) — multi-tile swap matches a direct swap,
    colour table + scale/offset + description survive, and path= persists a disk-backed result; the NetCDF
    variable-view test now asserts it works.
  • Consolidated run across tests/dataset/{analysis,spatial,unit,create} + the touched NetCDF tests: 1288
    passed
    . mypy clean (136 source files); doctests green for analysis.py, spatial.py, bands.py,
    vectorize.py.

Reproduce:

pixi run -e dev pytest tests/dataset/analysis tests/dataset/spatial tests/dataset/unit tests/dataset/create -m "not plot" -q

Checklist:

  • updated version number in pyproject.toml. (handled by commitizen on release)
  • added changes to History.rst. (changelog is commitizen-generated)
  • updated the latest version in README file.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • documentation are updated. (new parameters documented in each method's docstring)

…d of always reading whole

to_feature_collection read the whole raster into memory by default (tile=False).
Make tile default to None and auto-select: read the whole array when it is at most
256 MiB, else read it tile by tile, so a huge or /vsicurl source is never
materialised whole (#969). The tiled path already restores row-major order, so the
result is byte-identical; an explicit tile=True/False still overrides the choice.

Refs #969
…d copy

sieve() seeded its in-memory target with dst_band.WriteArray(src_band.ReadAsArray()),
materialising the whole source band as a NumPy array on top of the MEM buffer (peak
memory ~2x the band). Replace the manual MEM Create + NumPy round trip with a
gdal.Translate to a single-band MEM dataset: GDAL copies the band block by block in
the C layer, carrying the geotransform, CRS, dtype, and no-data across, so no NumPy
array is ever allocated. The gdal.SieveFilter call is unchanged; output is byte-identical.

Refs #969
…sk whole

_crop_aligned() read the full source (all bands) AND the full mask band into memory
before stamping the mask's no-data layout onto the source (peak ~ source + mask). For a
raster mask with no gap-filling, apply the mask tile by tile via a new
_crop_aligned_tiled helper: each 256x256 window reads its own source and mask block,
applies the no-data value, and writes straight into the destination, so the full arrays
are never held together. The mask-apply is purely per-pixel, so the result is
byte-identical to the whole-array path. The numpy-array mask (already in memory) and the
fill_gaps path (interpolates across the whole array) keep the eager path.

Refs #969
apply() read the whole band and applied func to every domain value at once. Add an
opt-in elementwise=True that applies func one 256x256 tile at a time via a new
_apply_elementwise_tiled helper, so a very large or /vsicurl source is never
materialised whole. For a genuine per-pixel func the tiled result is byte-identical to
the whole-array pass; the shared domain-apply logic is factored into
_apply_func_to_domain so both paths behave identically. func that depend on the whole
array (normalisation, ranking, any global reduction) must keep the default
elementwise=False, whose behaviour is unchanged.

Refs #969
plot_histogram() flattened the whole band and footprint() read the whole band to build
the coverage mask, materialising a very large raster just to reduce it to a plot or a
polygon. Add an opt-in max_samples cap to both: when set and the band has more cells
than that, GDAL reads a nearest-neighbour decimated grid (~max_samples cells) in the C
layer instead of the full band. A shared _read_decimated helper does the read; footprint
scales the mask geotransform to the coarser grid via _scaled_geotransform so the polygon
coordinates stay geographically correct. The result is then approximate (a subsample /
coarser trace), documented as such. max_samples=None (default) reads every pixel, so both
methods stay exact and byte-identical to before.

Refs #969
change_no_data_value read each band whole (a full NumPy array per band) to swap the old
no-data value for the new one. Keep GDAL's block-based CreateCopy (so every band's colour
table, description, scale/offset, RAT and metadata survive exactly) but stream the swap
one 256x256 tile at a time via a new _swap_no_data_tiled helper, so a full band is never
held as a NumPy array. Add a path= option: when given, the clone is a disk-backed GeoTIFF
and the tiled swap makes the whole operation out-of-core; FlushCache persists it before
reopen. Output is byte-identical to before.

Because NetCDF.read_array takes variable as its first positional, the old positional
read_array(band) call incidentally raised a "pinned" ValueError on a variable-view
NetCDF; the windowed read (band= keyword) removes that accident, so change_no_data_value
now works on a variable view like fill() and the other band ops already do. The test that
asserted the accidental guard is updated to assert it works.

Refs #969
@MAfarrag MAfarrag changed the title perf(vectorize): size-adaptive to_feature_collection (auto-tile large rasters) perf: stream the remaining eager Dataset methods (#969) Aug 12, 2026
…ks on a NetCDF view

apply()'s default path called read_array(band) positionally; because NetCDF.read_array
puts variable first, that mis-bound band->variable and raised a spurious "pinned" error
on a variable view, while apply(elementwise=True) (which passes band=) worked. Both paths
now read with band= as a keyword, so apply behaves consistently on a variable view like
the other band ops. The test that asserted the accidental guard now asserts both paths
work and agree.

Refs #969
… fails mid-swap

If the tiled no-data swap raised mid-stream (e.g. a dtype-mismatch NoDataValueError) on the
disk-backed path, a half-written GeoTIFF was left at path with the write handle still open.
Wrap the swap in try/except that, on the disk path, closes the wrapper handle, drops the
local dst reference (both are needed or GDAL keeps the file locked on Windows), and deletes
the partial file plus its .aux.xml sidecar before re-raising.

Refs #969
Add regression tests for the tiled/CreateCopy redesign: a per-band old_value list on a
multi-band raster, the dtype guard firing on a band with zero matching cells (proving the
swap is attempted per tile), RAT preservation through CreateCopy, inplace=True + path=
updating the source and persisting to disk, and a mid-swap failure removing the partial
disk file.

Refs #969
…ts memory claim

Sum the per-band itemsizes instead of assuming band 0's dtype, so a mixed-dtype stack is
estimated correctly for the tile=None auto-select. Reword the comments/docstring: the
tiled path avoids the full-band ndarray, but the resulting DataFrame still scales with the
surviving non-no-data cells, so 'never materialised whole' overstated the bound.

Refs #969
…ll raster

The existing max_samples footprint tests used axis-aligned square-cell rasters, so the
geotransform rotation terms (gt[2]/gt[4]) were always 0 and a regression dropping their
scaling in _scaled_geotransform would pass. Add a fully-covered raster with non-zero
rotation and non-square cells, asserting exact and decimated footprints share total_bounds.

Refs #969
_crop_aligned_tiled called _apply_mask_nodata per tile, which re-ran the per-band
_check_no_data_value dtype coercion on every window. Precompute the coerced list once
before the loop and pass it in; _apply_mask_nodata still validates it itself when called
without one (the non-tiled path), so behaviour is unchanged.

Refs #969
When apply(elementwise=True) streamed a fully-no-data tile, domain_values was size 0; a
func that raises on array input (a legitimate per-pixel map like lambda v: 1 if v > 5
else 0) fell back to np.vectorize(func), which raises "cannot call 'vectorize' on size 0
inputs unless otypes is set". The whole-array path never hit this (the band domain is
non-empty), so the tiled path diverged. Return early on an empty domain -- out_array is
already the no-data fill -- restoring byte-identity and also hardening the whole-array
path for an all-no-data band.

Refs #969
…ation

max_samples is a public keyword on footprint and plot_histogram; max_samples=0 reached the
decimation factor and raised ZeroDivisionError, and a negative value produced a complex
factor and a 'complex has no __round__' TypeError. Reject max_samples < 1 at the read
boundary with a clear ValueError.

Refs #969
…t never masks the real error

The disk-path failure cleanup unlinked the partial file with a bare unlink; if a
GDAL/OS build still held the GeoTIFF locked, that unlink would raise PermissionError and
replace the original NoDataValueError. Wrap each unlink in try/except OSError so a residual
lock only lets the file linger and the original exception always propagates.

Refs #969
…ta copy

Tighten the loose decimation checks: the decimated footprint now must track the covered
block's extent (not merely stay inside the raster) and its area within rel=0.3; the
histogram test asserts the decimated samples are real band values (nearest-neighbour), not
just a small count. Document that sieve's gdal.Translate seed also carries the band color
table / RAT / scale-offset onto the result.

Refs #969
Split the `assert X and Y` checks added in this PR into separate asserts so a failure
points at the exact condition: the decimated-footprint extent bounds, the both-footprints
existence check, the multi-band non-matching-cell preservation, and the variable-view
apply both-paths check.

Refs #969
…narCloud S3776)

The added path=/cleanup logic pushed change_no_data_value's cognitive complexity to 22.
Extract three helpers -- _normalize_no_data_arg (per-band arg validation),
_swap_all_bands (the per-band swap loop), and _discard_partial_output (the disk-path
failure cleanup) -- so the method reads as a linear clone -> set -> swap -> flush with a
single try/except. Behaviour is unchanged.

Refs #969
test_peak_memory_is_bounded_by_the_tile asserted a hard-coded Python-heap budget
(dense_bytes // 4) that does not hold across GDAL builds: on the macOS cibuildwheel GDAL
the streamed pass traced ~3.9 MB (vs the 0.5 MB budget) even though it tiles correctly,
because tracemalloc only sees the Python heap, not GDAL's C buffers, and the wheel build's
read/write path allocates differently. Replace the absolute budget with a relative one:
measure a whole-array pass (same read -> transform -> write, materialised at once) in the
same process and assert the streamed peak stays below it. The baseline shares the streamed
path's machinery, so any per-build overhead cancels and only the full-array data streaming
avoids remains -- locally the streamed peak is ~2% of the whole-array peak.
@sonarqubecloud

Copy link
Copy Markdown

@MAfarrag
MAfarrag merged commit 83c8e53 into main Aug 12, 2026
30 checks passed
@MAfarrag
MAfarrag deleted the perf/to-feature-collection-autotile branch August 12, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant