|
| 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