Skip to content

Commit b6b6bc9

Browse files
authored
feat(data): preprocessing mode enum + prefetch_size config + lazy_cached memmap (#4173)
1 parent 84d180d commit b6b6bc9

12 files changed

Lines changed: 436 additions & 58 deletions

File tree

ludwig/backend/ray.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,11 @@ def to_tensors(df: pd.DataFrame) -> pd.DataFrame:
714714
from ludwig.data.dataframe.dask import tensor_extension_casting
715715

716716
with tensor_extension_casting(False):
717-
predictions = dataset.ds.map_batches(to_tensors, batch_format="pandas").map_batches(
717+
# Apply lazy-decode transforms before casting to tensors. Without this,
718+
# lazy audio/image columns still contain file-path strings at predict time,
719+
# causing cast_as_tensor_dtype to raise a TypeError.
720+
ds = dataset._with_lazy_decode(dataset.ds)
721+
predictions = ds.map_batches(to_tensors, batch_format="pandas").map_batches(
718722
batch_predictor,
719723
batch_size=self.batch_size,
720724
compute=ray.data.ActorPoolStrategy(),

ludwig/data/batcher/random_access.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,15 @@ class RandomAccessBatcher(Batcher):
4141
is a cheap numpy slice.
4242
4343
``PandasDataset.initialize_batcher`` automatically sets ``prefetch_size``
44-
to a non-zero value when any lazy column (audio/image path arrays) is
45-
present, so callers don't need to set this manually.
44+
from each feature's preprocessing config (``None`` → 4 for ``lazy`` /
45+
``lazy_cached`` features, 0 for ``eager``). Users can override this via
46+
the ``prefetch_size`` field in the feature's preprocessing config, or by
47+
passing ``prefetch_size`` directly to ``initialize_batcher``.
48+
49+
After the first epoch completes in ``lazy_cached`` mode, ``set_epoch``
50+
checks ``dataset.is_fully_cached()`` and resets ``prefetch_size`` to 0 —
51+
memmap reads (~0.1 ms/batch) are fast enough that background pipelining
52+
adds no measurable benefit and avoids unnecessary thread overhead.
4653
"""
4754

4855
def __init__(
@@ -234,6 +241,13 @@ def last_batch(self) -> bool:
234241
return False
235242

236243
def set_epoch(self, epoch: int, batch_size: int) -> None:
244+
"""Reset state for a new epoch; disables prefetch when the dataset is fully cached.
245+
246+
If the dataset exposes ``is_fully_cached()`` and it returns ``True``
247+
(i.e. all ``lazy_cached`` columns have finished their first-pass decode),
248+
``prefetch_size`` is set to 0 so subsequent epochs read directly from
249+
the memmap without spinning up a producer thread.
250+
"""
237251
if self._prefetch_size > 0:
238252
self._stop_async()
239253

@@ -244,6 +258,12 @@ def set_epoch(self, epoch: int, batch_size: int) -> None:
244258
self.sampler.set_epoch(epoch)
245259
self.sample_it = iter(self.sampler)
246260

261+
# After epoch 1, if all lazy columns are decoded and cached in memmaps,
262+
# disable prefetch — memmap reads are fast enough that pipelining adds
263+
# no measurable benefit and avoids unnecessary thread overhead.
264+
if self._prefetch_size > 0 and hasattr(self.dataset, "is_fully_cached") and self.dataset.is_fully_cached():
265+
self._prefetch_size = 0
266+
247267
if self._prefetch_size > 0:
248268
self._start_async_epoch()
249269

ludwig/data/dataset/pandas.py

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,31 @@ def __init__(self, dataset, features, data_cache_fp, training_set_metadata=None)
160160
# During preprocessing, lazy features stored file paths as strings. Here
161161
# we wrap those path arrays with the appropriate per-sample decode function
162162
# so that batches are decoded on demand rather than all at once.
163+
self._auto_prefetch_size = 0
163164
if training_set_metadata is not None:
164165
self._init_lazy_columns(features, training_set_metadata)
165166

166167
self.size = len(list(self.dataset.values())[0])
167168

169+
def _decoded_cache_path(self, proc_col: str, n: int, sample_shape: tuple) -> str:
170+
"""Return the path for the decoded numpy memmap file for a given column.
171+
172+
Placed next to the Parquet cache when ``data_cache_fp`` is set, otherwise
173+
falls back to ``~/.cache/ludwig/lazy_media/``.
174+
"""
175+
flat_shape = "_".join(str(d) for d in sample_shape)
176+
fname = f"{proc_col}_decoded_n{n}_{flat_shape}_f32.npy"
177+
if self.data_cache_fp:
178+
return os.path.join(os.path.dirname(self.data_cache_fp), fname)
179+
from ludwig.data.lazy_utils import get_default_lazy_cache_dir
180+
181+
return str(get_default_lazy_cache_dir() / fname)
182+
168183
def _init_lazy_columns(self, features: dict, training_set_metadata: dict) -> None:
169-
"""Replace string-path arrays for lazy features with LazyColumn objects."""
170-
from ludwig.data.lazy_utils import LazyColumn
184+
"""Replace string-path arrays for lazy features with LazyColumn or CachedLazyColumn objects."""
185+
from ludwig.data.lazy_utils import CachedLazyColumn, LazyColumn
186+
187+
max_prefetch = 0
171188

172189
for proc_col, feat_cfg in features.items():
173190
if proc_col not in self.dataset:
@@ -179,6 +196,11 @@ def _init_lazy_columns(self, features: dict, training_set_metadata: dict) -> Non
179196

180197
paths = self.dataset[proc_col]
181198
feat_type = feat_cfg.get("type", "")
199+
mode = feat_meta.get("mode", "lazy")
200+
# Per-feature prefetch override (None means auto → 4 for lazy modes)
201+
feat_prefetch = feat_meta.get("prefetch_size")
202+
effective_prefetch = feat_prefetch if feat_prefetch is not None else 4
203+
max_prefetch = max(max_prefetch, effective_prefetch)
182204

183205
if feat_type == "audio" and "lazy_audio_params" in feat_meta:
184206
from ludwig.features.audio_feature import AudioFeatureMixin
@@ -198,7 +220,16 @@ def _init_lazy_columns(self, features: dict, training_set_metadata: dict) -> Non
198220
import torch as _torch
199221

200222
_audio_workers = max(1, (os.cpu_count() or 4) // max(1, _torch.get_num_threads()))
201-
self.dataset[proc_col] = LazyColumn(paths, decode_fn, max_workers=_audio_workers)
223+
224+
if mode == "lazy_cached":
225+
# decode_fn returns (max_length, feature_dim) — match that order.
226+
sample_shape = (p["max_length"], p["feature_dim"])
227+
cache_path = self._decoded_cache_path(proc_col, len(paths), sample_shape)
228+
self.dataset[proc_col] = CachedLazyColumn(
229+
paths, decode_fn, cache_path, sample_shape, max_workers=_audio_workers
230+
)
231+
else:
232+
self.dataset[proc_col] = LazyColumn(paths, decode_fn, max_workers=_audio_workers)
202233

203234
elif feat_type == "image" and "lazy_image_params" in feat_meta:
204235
import torch
@@ -220,7 +251,15 @@ def _init_lazy_columns(self, features: dict, training_set_metadata: dict) -> Non
220251
channel_class_map=channel_class_map,
221252
default_image=default_image,
222253
)
223-
self.dataset[proc_col] = LazyColumn(paths, decode_fn)
254+
255+
if mode == "lazy_cached":
256+
sample_shape = tuple(p["default_image_shape"])
257+
cache_path = self._decoded_cache_path(proc_col, len(paths), sample_shape)
258+
self.dataset[proc_col] = CachedLazyColumn(paths, decode_fn, cache_path, sample_shape)
259+
else:
260+
self.dataset[proc_col] = LazyColumn(paths, decode_fn)
261+
262+
self._auto_prefetch_size = max_prefetch
224263

225264
def to_df(self, features: Iterable[BaseFeature] | None = None) -> DataFrame:
226265
"""Convert the dataset to a Pandas DataFrame.
@@ -271,6 +310,19 @@ def _has_lazy_columns(self) -> bool:
271310

272311
return any(is_lazy_column(v) for v in self.dataset.values())
273312

313+
def is_fully_cached(self) -> bool:
314+
"""Return ``True`` when every ``CachedLazyColumn`` in this dataset has finished its first-pass decode.
315+
316+
Returns ``False`` if there are no ``CachedLazyColumn`` instances (i.e. the dataset
317+
uses plain ``LazyColumn`` or eager mode).
318+
"""
319+
from ludwig.data.lazy_utils import is_cached_lazy_column
320+
321+
cached_cols = [v for v in self.dataset.values() if is_cached_lazy_column(v)]
322+
if not cached_cols:
323+
return False
324+
return all(col.is_fully_cached() for col in cached_cols)
325+
274326
@contextlib.contextmanager
275327
def initialize_batcher(
276328
self,
@@ -282,12 +334,29 @@ def initialize_batcher(
282334
augmentation_pipeline=None,
283335
prefetch_size: int | None = None,
284336
) -> Batcher:
337+
"""Yield a :class:`RandomAccessBatcher` configured for this dataset.
338+
339+
Parameters
340+
----------
341+
prefetch_size:
342+
Number of batches to pipeline in a background thread while the GPU
343+
processes the current batch. ``None`` (default) uses
344+
``self._auto_prefetch_size``, which is derived from the
345+
``prefetch_size`` field in each feature's preprocessing config
346+
(``None`` → 4 for ``lazy``/``lazy_cached`` features, 0 for
347+
``eager``). Pass ``0`` to disable prefetch entirely.
348+
349+
For ``lazy_cached`` mode, ``RandomAccessBatcher.set_epoch`` will
350+
automatically reset ``prefetch_size`` to 0 after epoch 1 once
351+
``dataset.is_fully_cached()`` returns ``True`` — memmap reads are
352+
fast enough that background pipelining adds no measurable benefit.
353+
"""
285354
# When the dataset contains lazy columns (audio / image file paths that
286355
# are decoded per-batch), automatically enable prefetch so the GPU is
287356
# not idle during decode. Callers can override by passing prefetch_size
288357
# explicitly (0 to disable, N to set a specific depth).
289358
if prefetch_size is None:
290-
prefetch_size = 4 if self._has_lazy_columns() else 0
359+
prefetch_size = self._auto_prefetch_size if self._has_lazy_columns() else 0
291360

292361
sampler = DistributedSampler(
293362
len(self), shuffle=should_shuffle, random_seed=random_seed, distributed=distributed

ludwig/data/dataset/ray.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ def _with_lazy_decode(self, ds: RayNativeDataset) -> RayNativeDataset:
133133
for proc_col, feat_cfg in self.features.items():
134134
feature_name = feat_cfg.get("name") or feat_cfg.get("column") or proc_col
135135
feat_meta = self.training_set_metadata.get(feature_name, {})
136-
if not isinstance(feat_meta, dict) or not feat_meta.get("lazy"):
136+
if not isinstance(feat_meta, dict) or (
137+
not feat_meta.get("lazy") and feat_meta.get("mode", "eager") == "eager"
138+
):
137139
continue
138140

139141
feat_type = feat_cfg.get("type", "")

ludwig/data/lazy_utils.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import os
20+
import threading
2021
from concurrent.futures import ThreadPoolExecutor
2122
from pathlib import Path
2223
from typing import Callable
@@ -31,6 +32,17 @@
3132
class LazyColumn:
3233
"""Array-like wrapper that decodes file paths on demand per batch.
3334
35+
Decoding runs in a ``ThreadPoolExecutor`` at batch-slice time, keeping peak
36+
memory bounded to ``batch_size`` samples at any one time. The thread pool
37+
overlaps with the GPU forward pass when ``RandomAccessBatcher`` prefetch is
38+
enabled.
39+
40+
.. warning::
41+
For audio FBANK features, each ``decode_fn`` call already spawns PyTorch's
42+
internal thread pool. Creating many ``LazyColumn`` workers in parallel will
43+
over-subscribe CPUs (up to 5× slowdown). ``PandasDataset._init_lazy_columns``
44+
caps ``max_workers`` at ``cpu_count // torch_num_threads`` for this case.
45+
3446
Parameters
3547
----------
3648
paths:
@@ -97,9 +109,151 @@ def __repr__(self) -> str:
97109
return f"LazyColumn(n={len(self._paths)}, decode_fn={self._decode_fn.__name__!r})"
98110

99111

112+
def _select(paths: np.ndarray, indices) -> tuple[list, list, bool]:
113+
"""Normalise any index type to ``(paths_list, int_indices_list, is_scalar)``."""
114+
scalar = isinstance(indices, (int, np.integer))
115+
if not scalar and isinstance(indices, np.ndarray) and indices.ndim == 0:
116+
scalar = True
117+
118+
if scalar:
119+
idx = int(indices)
120+
return [paths[idx]], [idx], True
121+
122+
if isinstance(indices, slice):
123+
int_indices = list(range(*indices.indices(len(paths))))
124+
elif isinstance(indices, np.ndarray) and indices.dtype == bool:
125+
int_indices = list(np.where(indices)[0])
126+
elif isinstance(indices, np.ndarray):
127+
int_indices = indices.tolist()
128+
else:
129+
int_indices = list(indices)
130+
131+
return [paths[i] for i in int_indices], int_indices, False
132+
133+
134+
class CachedLazyColumn:
135+
"""Like :class:`LazyColumn`, but writes decoded arrays to a numpy memmap for reuse.
136+
137+
**First pass (epoch 1):** behaves identically to :class:`LazyColumn` — decodes via
138+
``ThreadPoolExecutor`` — but also writes each decoded sample to a ``np.memmap`` file.
139+
A per-sample boolean array tracks which indices have been written. When every sample
140+
has been written, the memmap is flushed and an empty ``.done`` sentinel file is created
141+
next to the memmap.
142+
143+
**Subsequent passes (epoch 2+):** ``is_fully_cached()`` returns ``True`` and
144+
``__getitem__`` reads directly from the memmap (~0.1 ms/batch), bypassing the thread
145+
pool entirely. ``RandomAccessBatcher.set_epoch`` detects this via
146+
``dataset.is_fully_cached()`` and automatically disables prefetch.
147+
148+
If the ``.done`` file already exists when the object is constructed (e.g. a resumed
149+
run), the memmap is opened in read-only mode immediately — no decode occurs at all.
150+
151+
Parameters
152+
----------
153+
paths:
154+
1-D array or list of file paths.
155+
decode_fn:
156+
Callable ``path -> np.ndarray`` (same contract as :class:`LazyColumn`).
157+
cache_path:
158+
Full path for the memmap file (e.g. ``/data/audio_proc_decoded_n1000_8_23_f32.npy``).
159+
sample_shape:
160+
Shape of a single decoded sample, e.g. ``(max_length, feature_dim)``.
161+
dtype:
162+
Element dtype for the memmap. Default ``np.float32``.
163+
max_workers:
164+
Thread-pool size for parallel decode on cache-miss. Default ``_DEFAULT_MAX_WORKERS``.
165+
"""
166+
167+
def __init__(
168+
self,
169+
paths: np.ndarray | list,
170+
decode_fn: Callable[[str], np.ndarray],
171+
cache_path: str,
172+
sample_shape: tuple,
173+
dtype=np.float32,
174+
max_workers: int = _DEFAULT_MAX_WORKERS,
175+
) -> None:
176+
self._paths = np.asarray(paths, dtype=object)
177+
self._decode_fn = decode_fn
178+
self._cache_path = cache_path
179+
self._done_path = cache_path + ".done"
180+
self._sample_shape = sample_shape
181+
self._dtype = dtype
182+
self._max_workers = max_workers
183+
self._n = len(self._paths)
184+
self._written = np.zeros(self._n, dtype=bool)
185+
self._lock = threading.Lock()
186+
self._memmap = None
187+
self._fully_cached = os.path.exists(self._done_path)
188+
if self._fully_cached:
189+
self._written[:] = True
190+
self._memmap = np.memmap(cache_path, dtype=dtype, mode="r", shape=(self._n, *sample_shape))
191+
192+
# ------------------------------------------------------------------
193+
# numpy-compatible interface
194+
# ------------------------------------------------------------------
195+
196+
def __len__(self) -> int:
197+
return self._n
198+
199+
def __getitem__(self, indices) -> np.ndarray:
200+
_, int_indices, scalar = _select(self._paths, indices)
201+
202+
if self._fully_cached:
203+
result = np.array(self._memmap[int_indices])
204+
return result[0] if scalar else result
205+
206+
# Decode any samples not yet in the cache.
207+
need_decode = [i for i in int_indices if not self._written[i]]
208+
209+
if need_decode:
210+
paths_list = [self._paths[i] for i in need_decode]
211+
with ThreadPoolExecutor(max_workers=min(self._max_workers, len(paths_list))) as ex:
212+
decoded = list(ex.map(self._decode_fn, paths_list))
213+
214+
with self._lock:
215+
if self._memmap is None:
216+
os.makedirs(os.path.dirname(self._cache_path) or ".", exist_ok=True)
217+
self._memmap = np.memmap(
218+
self._cache_path, dtype=self._dtype, mode="w+", shape=(self._n, *self._sample_shape)
219+
)
220+
for i, arr in zip(need_decode, decoded):
221+
if not self._written[i]:
222+
self._memmap[i] = arr
223+
self._written[i] = True
224+
225+
if self._written.all() and not self._fully_cached:
226+
self._fully_cached = True
227+
self._memmap.flush()
228+
Path(self._done_path).touch()
229+
230+
result = np.array(self._memmap[int_indices])
231+
return result[0] if scalar else result
232+
233+
@property
234+
def dtype(self):
235+
return object
236+
237+
@property
238+
def shape(self):
239+
return (self._n,)
240+
241+
def is_fully_cached(self) -> bool:
242+
"""Return True once every sample has been decoded and written to the memmap."""
243+
return self._fully_cached
244+
245+
def __repr__(self) -> str:
246+
return f"CachedLazyColumn(n={self._n}, cached={self._fully_cached})"
247+
248+
100249
def is_lazy_column(col) -> bool:
101-
"""Return True if *col* is a ``LazyColumn`` instance."""
102-
return isinstance(col, LazyColumn)
250+
"""Return True if *col* is a ``LazyColumn`` or ``CachedLazyColumn`` instance."""
251+
return isinstance(col, (LazyColumn, CachedLazyColumn))
252+
253+
254+
def is_cached_lazy_column(col) -> bool:
255+
"""Return True if *col* is a ``CachedLazyColumn`` instance."""
256+
return isinstance(col, CachedLazyColumn)
103257

104258

105259
def get_default_lazy_cache_dir() -> Path:

0 commit comments

Comments
 (0)