Skip to content

Fetch COG tiles concurrently in HTTP path to mask RTT - #1487

Merged
brendancol merged 2 commits into
mainfrom
issue-1480
May 5, 2026
Merged

brendancol merged 2 commits into
mainfrom
issue-1480

Conversation

@brendancol

Copy link
Copy Markdown
Contributor

Closes #1480.

Summary

_read_cog_http walked tiles one at a time, blocking on each range request before sending the next. Over a 50 ms RTT link, a 100-tile COG paid 5 s in round trips before any data flowed.

This PR adds _HTTPSource.read_ranges, which takes a list of (offset, length) pairs and fetches them through a ThreadPoolExecutor. Pool size defaults to 8 and can be overridden with XRSPATIAL_COG_HTTP_WORKERS. _read_cog_http now collects every tile range up front, fetches them in parallel, then decodes and places each tile in input order.

_HTTPSource.read_range is unchanged, so no other call sites are affected. Local file reads do not go through this path.

Measured on the test fake source (20 requests, 50 ms each, 8 workers): sequential bound is 1.00 s, concurrent fetch took 0.15 s, a 6.6x speedup.

Test plan

  • pytest xrspatial/geotiff/tests/test_cog_http_concurrent.py -- 6 new tests cover ordering, empty list, single-range fast path, concurrency speedup vs an artificial-latency fake, and round-trip correctness against a local http.server.
  • pytest xrspatial/geotiff/tests/test_cog.py xrspatial/geotiff/tests/test_reader.py -- existing 32 tests still pass.

@github-actions github-actions Bot added the performance PR touches performance-sensitive code label May 5, 2026
brendancol added 2 commits May 5, 2026 06:07
_read_cog_http walked tiles one at a time, blocking on each range
request before sending the next. Over a 50 ms RTT link, a 100-tile
COG paid 5 s in round trips before any data flowed.

Add _HTTPSource.read_ranges, which takes a list of (offset, length)
pairs and fetches them through a ThreadPoolExecutor (default 8
workers, override via XRSPATIAL_COG_HTTP_WORKERS). _read_cog_http
collects every tile range up front, fetches them in parallel, then
decodes and places each tile in input order.

read_range is unchanged so other call sites are unaffected. Local
file reads do not go through this path.

Tests: order preservation, empty-list handling, single-request fast
path, concurrency speedup against an artificial-latency fake source,
and a round-trip correctness test against a local http.server.
The previous test_read_ranges_concurrency_masks_latency required 20
fake-latency requests at 50ms each to finish under 500ms. On a busy
macOS CI runner the run took 519ms — 4% over the budget — and tripped
fail-fast across the matrix.

Track max-in-flight count in _FakeHTTPSource and assert >= 2. That
proves the threadpool dispatches concurrently without depending on
wall-clock timing.
@brendancol
brendancol merged commit 4943df9 into main May 5, 2026
11 checks passed
@brendancol
brendancol deleted the issue-1480 branch May 15, 2026 04:39
brendancol added a commit to brendancol/xarray-spatial that referenced this pull request May 16, 2026
The HTTP COG read path fetches tiles concurrently via
``read_ranges_coalesced`` (xarray-contrib#1480, xarray-contrib#1487) and then decoded them
sequentially in a Python for-loop. Local ``_read_tiles`` already
parallelises decode via ``ThreadPoolExecutor`` whenever
``tile_pixels >= 64 * 1024`` and ``n_tiles > 1`` (see _reader.py:2017);
the HTTP path was structurally similar but never adopted the same
gate, so wide windowed COG reads with many tiles left deflate/zstd
decoding single-threaded after a parallel fetch.

Mirror the local-file path's threshold and pool here. Codec
extensions release the GIL inside their C implementations, so a
``ThreadPoolExecutor`` overlaps decode work across cores. The
placement loop that writes pixels into ``result`` stays serial to
avoid contended writes.

Pass 10 of the geotiff performance sweep.

Tests in test_cog_http_parallel_decode_2026_05_15.py:
- parallel-branch end-to-end correctness (deflate, tile_size=256)
- serial-branch end-to-end correctness (tile_size=128, single tile)
- ThreadPoolExecutor instantiation above the threshold
- single-tile path skips the decode pool
- _decode_strip_or_tile invoked exactly once per placement
brendancol added a commit that referenced this pull request May 16, 2026
…) (#1981)

* geotiff: parallelise tile decode in _fetch_decode_cog_http_tiles

The HTTP COG read path fetches tiles concurrently via
``read_ranges_coalesced`` (#1480, #1487) and then decoded them
sequentially in a Python for-loop. Local ``_read_tiles`` already
parallelises decode via ``ThreadPoolExecutor`` whenever
``tile_pixels >= 64 * 1024`` and ``n_tiles > 1`` (see _reader.py:2017);
the HTTP path was structurally similar but never adopted the same
gate, so wide windowed COG reads with many tiles left deflate/zstd
decoding single-threaded after a parallel fetch.

Mirror the local-file path's threshold and pool here. Codec
extensions release the GIL inside their C implementations, so a
``ThreadPoolExecutor`` overlaps decode work across cores. The
placement loop that writes pixels into ``result`` stays serial to
avoid contended writes.

Pass 10 of the geotiff performance sweep.

Tests in test_cog_http_parallel_decode_2026_05_15.py:
- parallel-branch end-to-end correctness (deflate, tile_size=256)
- serial-branch end-to-end correctness (tile_size=128, single tile)
- ThreadPoolExecutor instantiation above the threshold
- single-tile path skips the decode pool
- _decode_strip_or_tile invoked exactly once per placement

* geotiff: DRY parallel-decode threshold + helper across local/HTTP tile paths

Review nits on the parallel-HTTP-decode change:

- Extract the 64K-pixel cutoff into a module-level
  _PARALLEL_DECODE_PIXEL_THRESHOLD constant. The same magic number lived
  in _read_tiles and _fetch_decode_cog_http_tiles; the only docstring
  describing why it was chosen sat next to the local path.

- Lift _decode_one out of the parallel branch in
  _fetch_decode_cog_http_tiles so the serial fallback calls the same
  helper instead of inlining a duplicate _decode_strip_or_tile call.
  Mirrors how _read_tiles defines _decode_one once and reuses it for
  both paths.

- Drop the ThreadPoolExecutor-as-_Pool alias for parity with the local
  path, which imports ThreadPoolExecutor by its real name.

No behaviour change. Existing 268 COG/HTTP and 734 tile/reader tests
still pass; the 6 pre-existing failures (predictor2 big-endian GPU,
tile_size=4 validator) predate this PR per the prior sweep notes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance PR touches performance-sensitive code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fetch COG tiles concurrently in HTTP path to mask RTT

1 participant