Skip to content

Commit 9cd4eb7

Browse files
giovpLucaMarconatoclaude
authored
improves dataloader performance (#687)
* implement selection * update * vectorize adjust_bounding_box_to_real_axes * update * replace append with insert * add comment * vectorize * update to handle multiple boxes * vectorize with numba * fix corner len * update * fix validation * refactor * refactor * add test for query with multiple bounding boxes * fix typing * vectorize bounding box query on polygons * add test to cover no polygon overlap (None) * vectorize bounding box query on points and tests * fix type * Fix rasterize path and bugs in PR #687 dataloader; add benchmark **Bugs fixed in datasets.py:** - rasterize=True path was broken: __getitem__ always called image.sel() regardless of rasterize flag, bypassing rasterize_fn entirely. Fixed by storing self._rasterize and branching in __getitem__. - ad.concat(*tables_l) unpacked the list as positional args, failing with >1 region. Fixed to ad.concat(tables_l). - Vectorized selection pre-computation was always run even for rasterize=True where it is unused. Fixed by guarding with `if not rasterize`. - Removed stale commented-out pandas.apply fallback code. **Fixes in _utils.py:** - Removed redundant nopython=True from @nb.njit (njit implies nopython=True, and the argument caused a RuntimeWarning). - Replaced invalid nb.types.Array[nb.float64, nb.float64] annotations with np.ndarray. **Fixes in spatial_query.py:** - Restored BoundingBoxRequest validation that was commented out. The validator's __post_init__ already handles both 1-D (single box) and 2-D (multi-box) arrays. **Benchmark (benchmark_dataloader.py):** Synthetic 2048x2048 image, 500 circle regions (32 px radius), 3-channel. Phase main PR (fixed) speedup init ~162 ms ~20 ms ~8x fetch 500 ~618 ms ~118 ms ~5x per-tile ~1237 us ~235 us ~5x Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * add asv benchmark for dataloader performance --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aff34d9 commit 9cd4eb7

5 files changed

Lines changed: 141 additions & 22 deletions

File tree

benchmarks/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,32 @@ git checkout - && git stash pop
6565
asv compare main HEAD
6666
```
6767

68+
### Dataloader benchmarks
69+
70+
Dataloader benchmarks live in `benchmarks/benchmark_dataloader.py`. They use a synthetic in-memory `SpatialData` (2048×2048 image, 500 circle regions) and compute two metrics:
71+
72+
- `time_init` — constructing `ImageTilesDataset` (includes bounding-box pre-computation).
73+
- `time_fetch` — iterating over all 500 tiles once (pure `__getitem__` calls, no `DataLoader` overhead).
74+
75+
Run both in your current environment:
76+
77+
```bash
78+
asv run --python=same --show-stderr -b TimeDataloader
79+
```
80+
81+
Or a single method:
82+
83+
```bash
84+
asv run --python=same --show-stderr -b TimeDataloader.time_init
85+
asv run --python=same --show-stderr -b TimeDataloader.time_fetch
86+
```
87+
88+
Compare against `main` in one shot:
89+
90+
```bash
91+
asv continuous --show-stderr -v -b TimeDataloader main HEAD
92+
```
93+
6894
### Querying benchmarks
6995

7096
Querying using a bounding box without a spatial index is highly impacted by large amounts of points (transcripts), more than table rows (cells).

benchmarks/benchmark_dataloader.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# type: ignore
2+
"""Benchmarks for ImageTilesDataset: init time and iteration time."""
3+
4+
from __future__ import annotations
5+
6+
import anndata as ad
7+
import geopandas as gpd
8+
import numpy as np
9+
import pandas as pd
10+
from shapely.geometry import Point
11+
12+
import spatialdata as sd
13+
from spatialdata.dataloader import ImageTilesDataset
14+
from spatialdata.models import Image2DModel, ShapesModel, TableModel
15+
from spatialdata.transformations import Identity
16+
17+
_RNG = np.random.default_rng(42)
18+
19+
_IMAGE_SIZE = 2048
20+
_N_CIRCLES = 500
21+
_N_CHANNELS = 3
22+
23+
_DATASET_KWARGS = {
24+
"regions_to_images": {"circles": "image"},
25+
"regions_to_coordinate_systems": {"circles": "global"},
26+
"table_name": "table",
27+
"return_annotations": "instance_id",
28+
}
29+
30+
31+
def _make_sdata() -> sd.SpatialData:
32+
img_data = _RNG.integers(0, 256, size=(_N_CHANNELS, _IMAGE_SIZE, _IMAGE_SIZE), dtype=np.uint8).astype(np.float32)
33+
image = Image2DModel.parse(img_data, dims=["c", "y", "x"], transformations={"global": Identity()})
34+
35+
radius = 32.0
36+
cx = _RNG.uniform(radius, _IMAGE_SIZE - radius, size=_N_CIRCLES)
37+
cy = _RNG.uniform(radius, _IMAGE_SIZE - radius, size=_N_CIRCLES)
38+
geom = gpd.GeoDataFrame({"geometry": [Point(x, y) for x, y in zip(cx, cy, strict=True)]})
39+
geom["radius"] = radius
40+
circles = ShapesModel.parse(geom, transformations={"global": Identity()})
41+
42+
table = ad.AnnData(
43+
_RNG.random((_N_CIRCLES, 10)).astype(np.float32),
44+
obs=pd.DataFrame(
45+
{
46+
"region": pd.Categorical(["circles"] * _N_CIRCLES),
47+
"instance_id": np.arange(_N_CIRCLES, dtype=np.int64),
48+
},
49+
index=[str(i) for i in range(_N_CIRCLES)],
50+
),
51+
)
52+
table = TableModel.parse(table, region="circles", region_key="region", instance_key="instance_id")
53+
54+
return sd.SpatialData(images={"image": image}, shapes={"circles": circles}, tables={"table": table})
55+
56+
57+
class TimeDataloader:
58+
"""Time ImageTilesDataset construction and tile iteration."""
59+
60+
def setup(self):
61+
self.sdata = _make_sdata()
62+
self.ds = ImageTilesDataset(sdata=self.sdata, **_DATASET_KWARGS)
63+
64+
def teardown(self):
65+
del self.ds
66+
del self.sdata
67+
68+
def time_init(self):
69+
"""Time constructing ImageTilesDataset (bounding-box pre-computation)."""
70+
ImageTilesDataset(sdata=self.sdata, **_DATASET_KWARGS)
71+
72+
def time_fetch(self):
73+
"""Time iterating over every tile once."""
74+
for i in range(len(self.ds)):
75+
_ = self.ds[i]

src/spatialdata/_core/query/_utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ def get_bounding_box_corners(
9191
return output.squeeze().drop_vars("box")
9292

9393

94-
@nb.jit(parallel=False, nopython=True)
94+
@nb.njit(parallel=False)
9595
def _create_slices_and_translation(
96-
min_values: nb.types.Array,
97-
max_values: nb.types.Array,
98-
) -> tuple[nb.types.Array, nb.types.Array]:
96+
min_values: np.ndarray,
97+
max_values: np.ndarray,
98+
) -> tuple[np.ndarray, np.ndarray]:
9999
n_boxes, n_dims = min_values.shape
100100
slices = np.empty((n_boxes, n_dims, 2), dtype=np.float64) # (n_boxes, n_dims, [min, max])
101101
translation_vectors = np.empty((n_boxes, n_dims), dtype=np.float64) # (n_boxes, n_dims)

src/spatialdata/_core/query/spatial_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def _(
556556
min_coordinate = _parse_list_into_array(min_coordinate)
557557
max_coordinate = _parse_list_into_array(max_coordinate)
558558

559-
# for triggering validation
559+
# for triggering validation (handles both 1-D single-box and 2-D multi-box arrays)
560560
_ = BoundingBoxRequest(
561561
target_coordinate_system=target_coordinate_system,
562562
axes=axes,

src/spatialdata/dataloader/datasets.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ def __init__(
127127
from spatialdata import bounding_box_query
128128
from spatialdata._core.operations.rasterize import rasterize as rasterize_fn
129129

130-
self._validate(sdata, regions_to_images, regions_to_coordinate_systems, return_annotations, table_name)
130+
self.sdata = sdata
131+
self._rasterize = rasterize
132+
self._validate(regions_to_images, regions_to_coordinate_systems, return_annotations, table_name)
131133
self._preprocess(tile_scale, tile_dim_in_units, rasterize, table_name)
132134

133135
if rasterize_kwargs is not None and len(rasterize_kwargs) > 0 and rasterize is False:
@@ -151,14 +153,12 @@ def __init__(
151153

152154
def _validate(
153155
self,
154-
sdata: SpatialData,
155156
regions_to_images: dict[str, str],
156157
regions_to_coordinate_systems: dict[str, str],
157158
return_annotations: str | list[str] | None,
158159
table_name: str | None,
159160
) -> None:
160161
"""Validate input parameters."""
161-
self.sdata = sdata
162162
if return_annotations is not None and table_name is None:
163163
raise ValueError("`table_name` must be provided if `return_annotations` is not `None`.")
164164

@@ -173,8 +173,8 @@ def _validate(
173173
image_name = regions_to_images[region_name]
174174

175175
# get elements
176-
region_elem = sdata[region_name]
177-
image_elem = sdata[image_name]
176+
region_elem = self.sdata[region_name]
177+
image_elem = self.sdata[image_name]
178178

179179
# check that the elements are supported
180180
if get_model(region_elem) == PointsModel:
@@ -199,13 +199,13 @@ def _validate(
199199
)
200200

201201
if table_name is not None:
202-
_, region_key, instance_key = get_table_keys(sdata.tables[table_name])
202+
_, region_key, instance_key = get_table_keys(self.sdata.tables[table_name])
203203
if get_model(region_elem) in [Labels2DModel, Labels3DModel]:
204204
indices = get_element_instances(region_elem).tolist()
205205
else:
206206
indices = region_elem.index.tolist()
207-
table = sdata.tables[table_name]
208-
if not isinstance(sdata.tables[table_name].obs[region_key].dtype, CategoricalDtype):
207+
table = self.sdata.tables[table_name]
208+
if not isinstance(self.sdata.tables[table_name].obs[region_key].dtype, CategoricalDtype):
209209
raise TypeError(
210210
f"The `regions_element` column `{region_key}` in the table must be a categorical dtype. "
211211
f"Please convert it."
@@ -228,8 +228,10 @@ def _preprocess(
228228
table_name: str | None,
229229
) -> None:
230230
"""Preprocess the dataset."""
231+
from spatialdata import bounding_box_query
232+
231233
if table_name is not None:
232-
_, region_key, instance_key = get_table_keys(self.sdata.tables[table_name])
234+
_, region_key, _ = get_table_keys(self.sdata.tables[table_name])
233235
filtered_table = self.sdata.tables[table_name][
234236
self.sdata.tables[table_name].obs[region_key].isin(self.regions)
235237
] # filtered table for the data loader
@@ -249,6 +251,18 @@ def _preprocess(
249251
tile_scale=tile_scale,
250252
tile_dim_in_units=tile_dim_in_units,
251253
)
254+
if not rasterize:
255+
# Pre-compute all per-tile slice selections in a single vectorized call.
256+
# Passing 2-D min/max arrays triggers the multi-box path in bounding_box_query,
257+
# which returns a list of {axis: slice} dicts — one per tile.
258+
tile_coords["selection"] = bounding_box_query(
259+
self.sdata[image_name],
260+
("x", "y"),
261+
min_coordinate=tile_coords[["minx", "miny"]].values,
262+
max_coordinate=tile_coords[["maxx", "maxy"]].values,
263+
target_coordinate_system=cs,
264+
return_request_only=True,
265+
)
252266
tile_coords_df.append(tile_coords)
253267

254268
inst = circles.index.values
@@ -276,7 +290,7 @@ def _preprocess(
276290
self.dataset_index = pd.concat(index_df).reset_index(drop=True)
277291
assert len(self.tiles_coords) == len(self.dataset_index)
278292
if table_name:
279-
self.dataset_table = ad.concat(*tables_l)
293+
self.dataset_table = ad.concat(tables_l)
280294
assert len(self.tiles_coords) == len(self.dataset_table)
281295

282296
dims_ = set(chain(*dims_l))
@@ -356,13 +370,17 @@ def __getitem__(self, idx: int) -> Any | SpatialData:
356370
t_coords = self.tiles_coords.iloc[idx]
357371

358372
image = self.sdata[row["image"]]
359-
tile = self._crop_image(
360-
image,
361-
axes=tuple(self.dims),
362-
min_coordinate=t_coords[[f"min{i}" for i in self.dims]].values,
363-
max_coordinate=t_coords[[f"max{i}" for i in self.dims]].values,
364-
target_coordinate_system=row["cs"],
365-
)
373+
if self._rasterize:
374+
tile = self._crop_image(
375+
image,
376+
axes=tuple(self.dims),
377+
min_coordinate=t_coords[[f"min{i}" for i in self.dims]].values,
378+
max_coordinate=t_coords[[f"max{i}" for i in self.dims]].values,
379+
target_coordinate_system=row["cs"],
380+
)
381+
else:
382+
# Use pre-computed slice selection (vectorized at init time).
383+
tile = image.sel(t_coords["selection"])
366384
if self.transform is not None:
367385
out = self._return(idx, tile)
368386
return self.transform(out)

0 commit comments

Comments
 (0)