Skip to content

Commit c117406

Browse files
authored
Added an optimization to use multithreading in gdalwarp (#69)
* Added an optimization to use multithreading in gdalwarp * Updated changelog and service version
1 parent de76172 commit c117406

5 files changed

Lines changed: 47 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ HyBIG follows semantic versioning. All notable changes to this project will be
44
documented in this file. The format is based on [Keep a
55
Changelog](http://keepachangelog.com/en/1.0.0/).
66

7+
## [2.8.2] - 2026-07-30
8+
9+
* Added multithreading to gdalwarp call to improve performance.
10+
11+
712
## [2.8.1] - 2026-07-28
813

914
* [GITC-9248](https://bugs.earthdata.nasa.gov/browse/GITC-9248): Reduced number of output tiles from HyBIG service through a number of optimizations.

docker/service_version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.8.1
1+
2.8.2

hybig/browse.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Module containing core functionality for browse image generation."""
22

3+
import os
34
import re
45
from itertools import zip_longest
56
from logging import Logger, getLogger
@@ -37,6 +38,32 @@
3738

3839
DST_NODATA = NODATA_IDX
3940

41+
# Upper bound on gdalwarp threads, aka rasterio.warp.reproject
42+
MAX_WARP_THREADS = 8
43+
44+
45+
def warp_thread_count() -> int:
46+
"""Return the thread count to use for GDAL warp/reproject.
47+
48+
gdalwarp parallelizes the reprojection across output chunks, which is
49+
the dominant cost for large jobs but it has minimal impact on memory
50+
to multithread.
51+
52+
The count is the number of CPUs available to the process (uses
53+
container CPU limits on Linux based on scheduler affinity), capped at
54+
MAX_WARP_THREADS. Operators may override it with the HYBIG_NUM_THREADS
55+
environment variable.
56+
"""
57+
# sched_getaffinity checks container cpuset limits but is Linux-only, so
58+
# fall back to the reported CPU count on other platforms (local dev)
59+
sched_getaffinity = getattr(os, 'sched_getaffinity', None)
60+
if sched_getaffinity is not None:
61+
available = len(sched_getaffinity(0))
62+
else:
63+
available = os.cpu_count() or 1
64+
65+
return max(1, min(available, MAX_WARP_THREADS))
66+
4067

4168
def create_browse(
4269
source_tiff: str,
@@ -806,6 +833,7 @@ def write_georaster_as_browse(
806833
dst_crs=grid_parameters['crs'],
807834
dst_nodata=int(dst_nodata),
808835
resampling=Resampling.nearest,
836+
num_threads=warp_thread_count(),
809837
)
810838

811839
# Skip tiles that turned out empty once mapped onto the actual footprint.

tests/test_service/test_adapter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from harmony_service.exceptions import HyBIGServiceError
2020
from hybig.browse import (
2121
convert_multiband_to_raster,
22+
warp_thread_count,
2223
)
2324
from tests.utilities import Granule, create_stac
2425

@@ -249,6 +250,7 @@ def move_tif(*args, **kwargs):
249250
)
250251
self.assertEqual(actual_call.kwargs['dst_crs'], expected_params['crs'])
251252
self.assertEqual(actual_call.kwargs['resampling'], Resampling.nearest)
253+
self.assertEqual(actual_call.kwargs['num_threads'], warp_thread_count())
252254

253255
# Ensure the browse image and ESRI world file were staged as expected:
254256
mock_stage.assert_has_calls(
@@ -453,6 +455,7 @@ def fake_reproject(*args, **kwargs):
453455
actual_call.kwargs['dst_nodata'], expected_params['dst_nodata']
454456
)
455457
self.assertEqual(actual_call.kwargs['resampling'], Resampling.nearest)
458+
self.assertEqual(actual_call.kwargs['num_threads'], warp_thread_count())
456459

457460
# Ensure the browse image and ESRI world file were staged as expected:
458461
mock_stage.assert_has_calls(

tests/unit/test_browse.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from rasterio.warp import Resampling
2020

2121
from hybig.browse import (
22+
MAX_WARP_THREADS,
2223
convert_multiband_to_raster,
2324
convert_singleband_to_raster,
2425
create_browse,
@@ -31,6 +32,7 @@
3132
reprojected_output_is_empty,
3233
validate_file_crs,
3334
validate_file_type,
35+
warp_thread_count,
3436
write_georaster_as_browse,
3537
)
3638
from hybig.color_utility import (
@@ -241,6 +243,8 @@ def test_create_browse_imagery_with_mocks(self, rasterio_open_mock, reproject_mo
241243
self.assertEqual(actual_call.kwargs['dst_crs'], CRS.from_string('EPSG:4326'))
242244
self.assertEqual(actual_call.kwargs['dst_nodata'], 0) # TRANSPARENT
243245
self.assertEqual(actual_call.kwargs['resampling'], Resampling.nearest)
246+
# Reprojection runs multithreaded to speed up large jobs.
247+
self.assertEqual(actual_call.kwargs['num_threads'], warp_thread_count())
244248

245249
self.assertEqual(
246250
(self.tmp_dir / 'input_file_path.jpg').resolve(), actual_image.resolve()
@@ -442,6 +446,12 @@ def test_process_tile_returns_false_for_all_nan_window(self, rasterio_open_mock)
442446
f'Skipping all-NaN tile: {self.tmp_dir / "output.png"}'
443447
)
444448

449+
def test_warp_thread_count_is_bounded(self):
450+
"""Thread count is between 1 and MAX_WARP_THREADS."""
451+
count = warp_thread_count()
452+
self.assertGreaterEqual(count, 1)
453+
self.assertLessEqual(count, MAX_WARP_THREADS)
454+
445455
def test_reprojected_output_is_empty(self):
446456
"""Test reprojected_output_is_empty across output types."""
447457
# Paletted single-band: every cell holds the fill index -> empty.

0 commit comments

Comments
 (0)