Skip to content

Commit f07cad9

Browse files
authored
Add ExternalSource to dynamic mode (#6395)
* Add ExternalSource to dynamic mode * Add tests for ndd.ExternalSource --------- Signed-off-by: Rostan Tabet <rtabet@nvidia.com>
1 parent 4652f5e commit f07cad9

3 files changed

Lines changed: 412 additions & 0 deletions

File tree

dali/python/nvidia/dali/experimental/dynamic/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ._tensor import Tensor, tensor, as_tensor # noqa: F401
2626
from ._batch import Batch, batch, as_batch # noqa: F401
2727
from ._imread import imread # noqa: F401
28+
from ._external_source import ExternalSource # noqa: F401
2829

2930
from . import _ops
3031
from . import math # noqa: F401
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from collections.abc import Callable, Iterable, Sequence
16+
from typing import Any, Literal, TypeAlias, cast, TypeGuard
17+
18+
from ..._typing import BatchLike
19+
from ..._utils.external_source_impl import get_callback_from_source
20+
from ._batch import Batch, _get_batch_size, as_batch
21+
from ._device import DeviceLike
22+
from ._nvtx import NVTXRange
23+
from ._tensor import Tensor, as_tensor
24+
from ._type import DTypeLike
25+
26+
# Note: TensorLike <: BatchLike
27+
_SourceOutput: TypeAlias = BatchLike | Sequence[BatchLike]
28+
SourceType: TypeAlias = Callable[[], _SourceOutput] | Iterable[_SourceOutput]
29+
30+
31+
# We don't inherit from _ops.Operator because there's nothing to reuse from there
32+
class ExternalSource:
33+
"""Consume data from a Python callable or iterable source.
34+
35+
The `source` can be either a callable or an iterable, returning a tensor-like or batch-like.
36+
An instance of this class is stateful; calling it pulls the next element(s) from the source.
37+
38+
Parameters
39+
----------
40+
41+
source: callable or iterable
42+
The source of the data.
43+
44+
The source is polled via ``source()`` or ``next(source)``. Data provided by `source`
45+
can be tensor-like, batch-like or a tuple thereof if `num_outputs` > 1.
46+
47+
num_outputs : int, default: 1
48+
If specified, denotes the number of outputs produced by `source`.
49+
50+
cycle : string or bool, optional
51+
Specifies if and how to cycle through the source. It can be one of the following values:
52+
53+
- ``"no"``, ``False`` or ``None`` - don't cycle; ``StopIteration`` is raised when
54+
end of data is reached; this is the default behavior
55+
- ``"quiet"`` or ``True`` - the data is repeated indefinitely,
56+
- ``"raise"`` - when the end of data is reached, ``StopIteration`` is raised, but
57+
the iteration is restarted on subsequent call.
58+
59+
This flag requires that `source` is an iterable.
60+
61+
device : device-like, default: "cpu"
62+
Device of the output data. If the device mismatches, this can cause implicit D2H/H2D copies.
63+
64+
layout : :ref:`layout str<layout_str_doc>` or sequence thereof, optional
65+
Layout of the output data. May be a sequence of size `num_outputs`.
66+
67+
dtype : dtype-like or sequence thereof, optional
68+
Data type of the output data. May be a sequence of size `num_outputs`.
69+
70+
Examples
71+
--------
72+
>>> import nvidia.dali.experimental.dynamic as ndd
73+
>>> import numpy as np
74+
75+
An iterable source is consumed one element at a time:
76+
77+
>>> es = ndd.ExternalSource([np.full((2, 2), i) for i in range(4)])
78+
>>> _ = es() # skip the first one
79+
>>> es()
80+
Tensor(
81+
[[1 1]
82+
[1 1]],
83+
dtype=i64,
84+
device="cpu",
85+
shape=(2, 2))
86+
87+
A sample output can be broadcast to a batch:
88+
89+
>>> es = ndd.ExternalSource(lambda: np.arange(3))
90+
>>> es(batch_size=2)
91+
Batch(
92+
[[0 1 2],
93+
[0 1 2]],
94+
dtype=i64,
95+
device="cpu",
96+
num_samples=2,
97+
shape=[(3,), (3,)])
98+
99+
With `num_outputs` > 1, a tuple is returned
100+
101+
>>> es = ndd.ExternalSource(lambda: (np.zeros(4), np.ones(4)), num_outputs=2)
102+
>>> a, b = es()
103+
>>> b
104+
Tensor(
105+
[1. 1. 1. 1.],
106+
dtype=f64,
107+
device="cpu",
108+
shape=(4,))
109+
"""
110+
111+
def __init__(
112+
self,
113+
source: SourceType,
114+
num_outputs: int = 1,
115+
*,
116+
cycle: Literal["no", "quiet", "raise"] | bool | None = None,
117+
device: DeviceLike = "cpu",
118+
layout: str | Sequence[str] | None = None,
119+
dtype: DTypeLike | Sequence[DTypeLike] | None = None,
120+
):
121+
callback, source_desc = get_callback_from_source(source, cycle)
122+
assert source_desc is not None # `source` is never None here, so a callback is built
123+
if source_desc.has_inputs:
124+
raise ValueError("ndd.ExternalSource only supports callables with no parameters")
125+
self._callback = cast(Callable[[], _SourceOutput], callback)
126+
127+
if num_outputs <= 0:
128+
raise ValueError("num_outputs must be strictly positive")
129+
self._num_outputs = num_outputs
130+
self._device = device
131+
self._layouts = self._broadcast_arg(layout)
132+
self._dtypes = self._broadcast_arg(dtype)
133+
134+
@NVTXRange("__call__: ExternalSource", category="op_builder")
135+
def __call__(
136+
self, *, batch_size: int | None = None
137+
) -> Tensor | Batch | tuple[Tensor, ...] | tuple[Batch, ...]:
138+
"""Consume one item from the source.
139+
140+
Parameters
141+
----------
142+
batch_size : int, optional
143+
The batch size to broadcast output tensors to. Validated against batch outputs.
144+
145+
Returns
146+
-------
147+
`Tensor`, `Batch`, or tuple thereof
148+
A `Batch` if the source produced a `Batch` or a TensorList, a `Tensor` otherwise.
149+
If `num_outputs` > 1, a tuple is returned.
150+
151+
Raises
152+
------
153+
StopIteration
154+
When the source is exhausted, depending on the ``cycle`` argument.
155+
"""
156+
outputs = self._get_outputs(self._callback())
157+
results = tuple(
158+
self._convert_output(output, batch_size, idx) for idx, output in enumerate(outputs)
159+
)
160+
if not _are_types_uniform(results):
161+
raise TypeError("Outputs must be uniformly Tensors or uniformly Batches")
162+
return results[0] if self._num_outputs == 1 else results
163+
164+
def _get_outputs(self, data: _SourceOutput) -> Sequence[BatchLike]:
165+
if self._num_outputs == 1:
166+
return (cast(BatchLike, data),)
167+
if not isinstance(data, Sequence) or len(data) != self._num_outputs:
168+
raise ValueError(f"Expected {self._num_outputs} outputs from the source")
169+
return data # type: ignore
170+
171+
def _convert_output(self, data: BatchLike, batch_size: int | None, idx: int) -> Tensor | Batch:
172+
layout = self._layouts[idx]
173+
dtype = self._dtypes[idx]
174+
175+
actual_batch_size = _get_batch_size(data)
176+
if actual_batch_size is not None:
177+
batch = as_batch(data, dtype=dtype, device=self._device, layout=layout)
178+
if batch_size is not None and actual_batch_size != batch_size:
179+
raise ValueError(f"Expected batch size {batch_size}, got {actual_batch_size}")
180+
return batch
181+
182+
tensor = as_tensor(data, dtype=dtype, device=self._device, layout=layout)
183+
if batch_size is not None:
184+
return Batch.broadcast(tensor, batch_size=batch_size)
185+
return tensor
186+
187+
def _broadcast_arg(self, value: Any | Sequence) -> Sequence:
188+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
189+
return (value,) * self._num_outputs
190+
191+
if len(value) != self._num_outputs:
192+
raise ValueError(f"Expected a sequence of size {self._num_outputs}, got {len(value)}")
193+
return value
194+
195+
196+
def _are_types_uniform(
197+
values: tuple[Tensor | Batch, ...],
198+
) -> TypeGuard[tuple[Tensor, ...] | tuple[Batch, ...]]:
199+
# We know that values[0] exists since _num_outputs > 0
200+
expected_type = Batch if isinstance(values[0], Batch) else Tensor
201+
return all(isinstance(value, expected_type) for value in values)

0 commit comments

Comments
 (0)