chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
View File
@@ -0,0 +1,376 @@
import warnings
from enum import Enum
from typing import TYPE_CHECKING, Dict, List, Union
import numpy as np
from ray.air.data_batch_type import DataBatchType
from ray.data.constants import TENSOR_COLUMN_NAME
from ray.data.util.expression_utils import _get_setting_with_copy_warning
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
import pandas as pd
# TODO: Consolidate data conversion edges for arrow bug workaround.
try:
import pyarrow
except ImportError:
pyarrow = None
# Lazy import to avoid ray init failures without pandas installed and allow
# dataset to import modules in this file.
_pandas = None
def _lazy_import_pandas():
global _pandas
if _pandas is None:
import pandas
_pandas = pandas
return _pandas
@DeveloperAPI
class BatchFormat(str, Enum):
PANDAS = "pandas"
# TODO: Remove once Arrow is deprecated as user facing batch format
ARROW = "arrow"
NUMPY = "numpy" # Either a single numpy array or a Dict of numpy arrays.
CUDF = "cudf"
_CUDF_UNSET = object()
_cudf = _CUDF_UNSET
def _lazy_import_cudf():
"""Lazy import cudf, returning the module or None if not installed."""
global _cudf
if _cudf is _CUDF_UNSET:
try:
import cudf
_cudf = cudf
except ImportError:
_cudf = None
return _cudf
def _convert_batch_type_to_pandas(
data: DataBatchType,
cast_tensor_columns: bool = False,
) -> "pd.DataFrame":
"""Convert the provided data to a Pandas DataFrame.
Args:
data: Data of type DataBatchType
cast_tensor_columns: Whether tensor columns should be cast to NumPy ndarrays.
Returns:
A pandas Dataframe representation of the input data.
"""
pd = _lazy_import_pandas()
if isinstance(data, np.ndarray):
data = pd.DataFrame({TENSOR_COLUMN_NAME: _ndarray_to_column(data)})
elif isinstance(data, dict):
tensor_dict = {}
for col_name, col in data.items():
if not isinstance(col, np.ndarray):
raise ValueError(
"All values in the provided dict must be of type "
f"np.ndarray. Found type {type(col)} for key {col_name} "
f"instead."
)
tensor_dict[col_name] = _ndarray_to_column(col)
data = pd.DataFrame(tensor_dict)
elif pyarrow is not None and isinstance(data, pyarrow.Table):
data = data.to_pandas()
else:
# Handle cudf.DataFrame (lazy check to avoid import when not used)
cudf = _lazy_import_cudf()
if cudf is not None and isinstance(data, cudf.DataFrame):
data = data.to_pandas()
if not isinstance(data, pd.DataFrame):
raise ValueError(
f"Received data of type: {type(data)}, but expected it to be one "
f"of {DataBatchType}"
)
if cast_tensor_columns:
data = _cast_tensor_columns_to_ndarrays(data)
return data
def _convert_pandas_to_batch_type(
data: "pd.DataFrame",
type: BatchFormat,
cast_tensor_columns: bool = False,
) -> DataBatchType:
"""Convert the provided Pandas dataframe to the provided ``type``.
Args:
data: A Pandas DataFrame
type: The specific ``BatchFormat`` to convert to.
cast_tensor_columns: Whether tensor columns should be cast to our tensor
extension type.
Returns:
The input data represented with the provided type.
"""
if cast_tensor_columns:
data = _cast_ndarray_columns_to_tensor_extension(data)
if type == BatchFormat.PANDAS:
return data
elif type == BatchFormat.NUMPY:
if len(data.columns) == 1:
# If just a single column, return as a single numpy array.
return data.iloc[:, 0].to_numpy()
else:
# Else return as a dict of numpy arrays.
output_dict = {}
for column in data:
output_dict[column] = data[column].to_numpy()
return output_dict
elif type == BatchFormat.ARROW:
if not pyarrow:
raise ValueError(
"Attempted to convert data to Pyarrow Table but Pyarrow "
"is not installed. Please do `pip install pyarrow` to "
"install Pyarrow."
)
return pyarrow.Table.from_pandas(data)
elif type == BatchFormat.CUDF:
cudf = _lazy_import_cudf()
if cudf is None:
raise ValueError(
"Attempted to convert data to cuDF DataFrame but cuDF "
"is not installed. Please do `pip install cudf-cu12` to "
"install cuDF (GPU required)."
)
return cudf.from_pandas(data)
else:
raise ValueError(
f"Received type {type}, but expected it to be one of {DataBatchType}"
)
def _convert_batch_type_to_numpy(
data: DataBatchType,
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
"""Convert the provided data to a NumPy ndarray or dict of ndarrays.
Args:
data: Data of type DataBatchType
Returns:
A numpy representation of the input data.
"""
pd = _lazy_import_pandas()
if isinstance(data, np.ndarray):
return data
elif isinstance(data, dict):
for col_name, col in data.items():
if not isinstance(col, np.ndarray):
raise ValueError(
"All values in the provided dict must be of type "
f"np.ndarray. Found type {type(col)} for key {col_name} "
f"instead."
)
return data
elif pyarrow is not None and isinstance(data, pyarrow.Table):
from ray.data._internal.arrow_ops import transform_pyarrow
from ray.data._internal.tensor_extensions.arrow import (
get_arrow_extension_fixed_shape_tensor_types,
)
column_values_ndarrays = []
for col in data.columns:
# Combine columnar values arrays to make these contiguous
# (making them compatible with numpy format)
combined_array = transform_pyarrow.combine_chunked_array(col)
column_values_ndarrays.append(
transform_pyarrow.to_numpy(combined_array, zero_copy_only=False)
)
arrow_fixed_shape_tensor_types = get_arrow_extension_fixed_shape_tensor_types()
# NOTE: This branch is here for backwards-compatibility
if data.column_names == [TENSOR_COLUMN_NAME] and (
isinstance(data.schema.types[0], arrow_fixed_shape_tensor_types)
):
return column_values_ndarrays[0]
return dict(zip(data.column_names, column_values_ndarrays))
elif isinstance(data, pd.DataFrame):
return _convert_pandas_to_batch_type(data, BatchFormat.NUMPY)
else:
# Handle cudf.DataFrame via pandas path
cudf = _lazy_import_cudf()
if cudf is not None and isinstance(data, cudf.DataFrame):
return _convert_pandas_to_batch_type(data.to_pandas(), BatchFormat.NUMPY)
raise ValueError(
f"Received data of type: {type(data)}, but expected it to be one "
f"of {DataBatchType}"
)
def _ndarray_to_column(arr: np.ndarray) -> Union["pd.Series", List[np.ndarray]]:
"""Convert a NumPy ndarray into an appropriate column format for insertion into a
pandas DataFrame.
If conversion to a pandas Series fails (e.g. if the ndarray is multi-dimensional),
fall back to a list of NumPy ndarrays.
"""
pd = _lazy_import_pandas()
try:
# Try to convert to Series, falling back to a list conversion if this fails
# (e.g. if the ndarray is multi-dimensional).
return pd.Series(arr)
except ValueError:
return list(arr)
def _unwrap_ndarray_object_type_if_needed(arr: np.ndarray) -> np.ndarray:
"""Unwrap an object-dtyped NumPy ndarray containing ndarray pointers into a single
contiguous ndarray, if needed/possible.
"""
if arr.dtype.type is np.object_:
try:
# Try to convert the NumPy ndarray to a non-object dtype.
arr = np.array([np.asarray(v) for v in arr])
except Exception:
# This may fail if the subndarrays are of heterogeneous shape
pass
return arr
def _cast_ndarray_columns_to_tensor_extension(df: "pd.DataFrame") -> "pd.DataFrame":
"""
Cast all NumPy ndarray columns in df to our tensor extension type, TensorArray.
"""
# Get the SettingWithCopyWarning class if available
SettingWithCopyWarning = _get_setting_with_copy_warning()
from ray.data._internal.tensor_extensions.pandas import (
TensorArray,
column_needs_tensor_extension,
)
# Try to convert any ndarray columns to TensorArray columns.
# TODO(Clark): Once Pandas supports registering extension types for type
# inference on construction, implement as much for NumPy ndarrays and remove
# this. See https://github.com/pandas-dev/pandas/issues/41848
# TODO(Clark): Optimize this with propagated DataFrame metadata containing a list of
# column names containing tensor columns, to make this an O(# of tensor columns)
# check rather than the current O(# of columns) check.
# Scan dtypes rather than df.items(), which would
# materialize a Series for every column just to read its dtype.
# The below approach avoids the cost of a Series build for non-tensor columns.
#
# When column names are unique we select and assign by label.
# With duplicate names, ``df[col_name]`` returns a DataFrame
# rather than a Series, so we select and assign by position instead.
columns_unique = df.columns.is_unique
for i, (col_name, dtype) in enumerate(df.dtypes.items()):
if (
dtype.type is not np.object_
): # Short circuit if non-object type before materializing the column
continue
col = df[col_name] if columns_unique else df.iloc[:, i]
if column_needs_tensor_extension(col):
try:
# Suppress Pandas warnings:
# https://github.com/ray-project/ray/issues/29270
# We actually want in-place operations so we surpress this warning.
# https://stackoverflow.com/a/74193599
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=FutureWarning)
if SettingWithCopyWarning is not None:
warnings.simplefilter("ignore", category=SettingWithCopyWarning)
if columns_unique:
df[col_name] = TensorArray(col)
else:
df.isetitem(i, TensorArray(col))
except Exception as e:
raise ValueError(
f"Tried to cast column {col_name} to the TensorArray tensor "
"extension type but the conversion failed. To disable "
"automatic casting to this tensor extension, set "
"ctx = DataContext.get_current(); "
"ctx.enable_tensor_extension_casting = False."
) from e
return df
def _cast_tensor_columns_to_ndarrays(
df: "pd.DataFrame",
arrow_schema: "pyarrow.Schema" = None,
) -> "pd.DataFrame":
"""Cast all tensor extension columns in df to NumPy ndarrays.
Args:
df: The DataFrame whose tensor columns should be converted.
arrow_schema: If provided, used to reshape columns that were native
``FixedShapeTensorType`` in Arrow. PyArrow's ``to_pandas()``
flattens these to 1-D ndarrays; passing the original schema
lets us restore the correct shape.
Returns:
The DataFrame with tensor columns converted to NumPy ndarrays.
"""
# Get the SettingWithCopyWarning class if available
SettingWithCopyWarning = _get_setting_with_copy_warning()
from ray.data._internal.tensor_extensions.pandas import TensorDtype
# Try to convert any tensor extension columns to ndarray columns.
# TODO(Clark): Optimize this with propagated DataFrame metadata containing a list of
# column names containing tensor columns, to make this an O(# of tensor columns)
# check rather than the current O(# of columns) check.
# Reshape native FixedShapeTensorType columns that were flattened by
# to_pandas().
if arrow_schema is not None:
from ray.data._internal.utils.transform_pyarrow import (
_is_native_tensor_type,
)
for field in arrow_schema:
if _is_native_tensor_type(field.type) and field.name in df.columns:
shape = tuple(field.type.shape)
df[field.name] = [
arr.reshape(shape) if arr is not None else None
for arr in df[field.name]
]
# Scan dtypes rather than df.items(), which would
# materialize a Series for every column just to read its dtype.
# The below approach avoids the cost of a Series build for non-tensor columns.
#
# When column names are unique we select and assign by label (the fast,
# cached path). With duplicate names, ``df[col_name]`` returns a DataFrame
# rather than a Series, so we select and assign by position instead.
columns_unique = df.columns.is_unique
for i, (col_name, dtype) in enumerate(df.dtypes.items()):
if isinstance(dtype, TensorDtype):
# Suppress Pandas warnings:
# https://github.com/ray-project/ray/issues/29270
# We actually want in-place operations so we surpress this warning.
# https://stackoverflow.com/a/74193599
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=FutureWarning)
if SettingWithCopyWarning is not None:
warnings.simplefilter("ignore", category=SettingWithCopyWarning)
if columns_unique:
df[col_name] = list(df[col_name].to_numpy())
else:
df.isetitem(i, list(df.iloc[:, i].to_numpy()))
return df
+264
View File
@@ -0,0 +1,264 @@
"""Utility functions for expression-based operations."""
from typing import TYPE_CHECKING, Any, Callable, Hashable, List, Optional
if TYPE_CHECKING:
from ray.data.expressions import (
AliasExpr,
BinaryExpr,
ColumnExpr,
DownloadExpr,
Expr,
LiteralExpr,
MonotonicallyIncreasingIdExpr,
RandomExpr,
UDFExpr,
UnaryExpr,
UUIDExpr,
)
def _get_setting_with_copy_warning() -> Optional[type]:
"""Get the SettingWithCopyWarning class from pandas, if available.
Pandas has moved/renamed this warning across versions, and pandas 3.x may not
expose it at all. This function handles the version differences gracefully
using hasattr checks instead of try-except blocks.
Returns:
The SettingWithCopyWarning class if found, None otherwise.
"""
import pandas as pd
# Use hasattr to avoid try-catch blocks as suggested
if hasattr(pd.core.common, "SettingWithCopyWarning"):
return pd.core.common.SettingWithCopyWarning
elif hasattr(pd.errors, "SettingWithCopyWarning"):
return pd.errors.SettingWithCopyWarning
else:
# Warning not available in this pandas version
return None
def _create_callable_class_udf_init_fn(
exprs: List["Expr"],
) -> Optional[Callable[[], None]]:
"""Create an init_fn to initialize all callable class UDFs in expressions.
This function collects all _CallableClassUDF instances from the given expressions,
groups them by their callable_class_spec key, and returns an init_fn that
initializes each group at actor startup. UDFs with the same key (same class and
constructor args) share a single instance to ensure all are properly initialized.
Args:
exprs: List of expressions to collect callable class UDFs from.
Returns:
An init_fn that initializes all callable class UDFs, or None if there are
no callable class UDFs in the expressions.
"""
from ray.data._internal.planner.plan_expression.expression_visitors import (
_CallableClassUDFCollector,
)
callable_class_udfs = []
for expr in exprs:
collector = _CallableClassUDFCollector()
collector.visit(expr)
callable_class_udfs.extend(collector.get_callable_class_udfs())
if not callable_class_udfs:
return None
# Group UDFs by callable_class_spec key.
# Multiple _CallableClassUDF objects may have the same key (same class + args).
# We need to initialize ALL of them, sharing a single instance per key.
udfs_by_key = {}
for udf in callable_class_udfs:
key = udf.callable_class_spec.make_key()
if key not in udfs_by_key:
udfs_by_key[key] = []
udfs_by_key[key].append(udf)
def init_fn():
for udfs_with_same_key in udfs_by_key.values():
# Initialize the first UDF to create the instance
first_udf = udfs_with_same_key[0]
first_udf.init()
# Share the instance with all other UDFs that have the same key
for other_udf in udfs_with_same_key[1:]:
other_udf._instance = first_udf._instance
return init_fn
def _call_udf_instance_with_async_bridge(
instance: Any,
*args,
**kwargs,
) -> Any:
"""Call a UDF instance, bridging from sync context to async if needed.
This handles the complexity of calling callable class UDF instances that may
be sync, async coroutine, or async generator functions.
Args:
instance: The callable instance to call
*args: Positional arguments
**kwargs: Keyword arguments
Returns:
The result of calling the instance
"""
import asyncio
import inspect
# Check if the instance's __call__ is async
if inspect.iscoroutinefunction(instance.__call__):
# Async coroutine: bridge from sync to async
return asyncio.run(instance(*args, **kwargs))
elif inspect.isasyncgenfunction(instance.__call__):
# Async generator: collect results
async def _collect():
results = []
async for item in instance(*args, **kwargs):
results.append(item)
# In expressions, the UDF must return a single array with the same
# length as the input (one output element per input row).
# If the async generator yields multiple arrays, we take the last one
# since expressions don't support multi-batch output semantics.
if not results:
return None
elif len(results) == 1:
return results[0]
else:
import logging
logging.warning(
f"Async generator yielded {len(results)} values in expression context; "
"only the last (most recent) is returned. Use map_batches for multi-yield support."
)
return results[-1]
return asyncio.run(_collect())
else:
# Synchronous instance - direct call
return instance(*args, **kwargs)
def _make_hashable(value: Any) -> Hashable:
try:
hash(value)
return value
except TypeError:
pass
if isinstance(value, list):
return tuple(_make_hashable(v) for v in value)
if isinstance(value, tuple):
return tuple(_make_hashable(v) for v in value)
if isinstance(value, dict):
return tuple(
sorted(
((k, _make_hashable(v)) for k, v in value.items()),
key=lambda item: repr(item[0]),
)
)
if isinstance(value, set):
return frozenset(_make_hashable(v) for v in value)
return repr(value)
def _data_type_key(expr: "Expr") -> Hashable:
return repr(getattr(expr, "data_type", None))
def _udf_function_key(fn: Any) -> Hashable:
from ray.data.expressions import _CallableClassUDF
if isinstance(fn, _CallableClassUDF):
return ("callable_class", fn.callable_class_spec.make_key())
return ("function", _make_hashable(fn))
def _column_fingerprint_key(expr: "ColumnExpr") -> Hashable:
return ("column", expr.name)
def _literal_fingerprint_key(expr: "LiteralExpr") -> Hashable:
return ("literal", type(expr.value), _make_hashable(expr.value))
def _binary_fingerprint_key(
expr: "BinaryExpr", left_key: Hashable, right_key: Hashable
) -> Hashable:
return ("binary", expr.op, left_key, right_key)
def _unary_fingerprint_key(expr: "UnaryExpr", operand_key: Hashable) -> Hashable:
return ("unary", expr.op, operand_key)
def _udf_fingerprint_key(
expr: "UDFExpr",
arg_keys: tuple[Hashable, ...],
kwarg_keys: tuple[tuple[str, Hashable], ...],
) -> Hashable:
from ray.data.expressions import PyArrowComputeUDFExpr
if isinstance(expr, PyArrowComputeUDFExpr):
return (
"pyarrow_compute_udf",
_make_hashable(expr.pc_func),
_make_hashable(expr.pc_positional),
_make_hashable(expr.pc_kwargs),
arg_keys,
kwarg_keys,
_data_type_key(expr),
)
return (
"udf",
_udf_function_key(expr.fn),
arg_keys,
kwarg_keys,
_data_type_key(expr),
)
def _alias_fingerprint_key(expr: "AliasExpr", child_key: Hashable) -> Hashable:
return (
"alias",
expr.name,
expr._is_rename,
child_key,
_data_type_key(expr),
)
def _download_fingerprint_key(expr: "DownloadExpr") -> Hashable:
return ("download", expr.uri_column_name)
def _star_fingerprint_key() -> Hashable:
return ("star",)
def _monotonically_increasing_id_fingerprint_key(
expr: "MonotonicallyIncreasingIdExpr",
) -> Hashable:
return ("monotonically_increasing_id", expr._instance_id)
def _random_fingerprint_key(expr: "RandomExpr") -> Hashable:
return (
"random",
expr.seed,
expr.reseed_after_execution,
_data_type_key(expr),
)
def _uuid_fingerprint_key(expr: "UUIDExpr") -> Hashable:
return ("uuid", _data_type_key(expr))
+416
View File
@@ -0,0 +1,416 @@
import logging
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
Iterator,
List,
Optional,
Tuple,
Union,
)
import numpy as np
if TYPE_CHECKING:
import jax
logger = logging.getLogger(__name__)
_GLOBAL_MESH_1D_AXIS = "data"
NumpyBatch = Union[np.ndarray, Dict[str, np.ndarray]]
JaxBatch = Union["jax.Array", Dict[str, "jax.Array"]]
DTypeLikeSpec = Union["jax.typing.DTypeLike", Dict[str, "jax.typing.DTypeLike"]]
Scalar = Union[int, float, bool]
PaddingsSpec = Union[Scalar, Dict[str, Scalar]]
def _get_column_value(mapping_or_value: Any, key: str) -> Any:
"""Get the value for a specific column from a mapping or a single value."""
if isinstance(mapping_or_value, dict):
return mapping_or_value[key]
return mapping_or_value
def _unwrap_single_column_value(mapping_or_value: Any, name: str) -> Any:
"""Unwrap a single value from a mapping if it's a dictionary."""
if isinstance(mapping_or_value, dict):
if len(mapping_or_value) != 1:
raise ValueError(
f"When constructing a single-tensor batch, only a single {name} "
f"should be given, instead got: {mapping_or_value}"
)
return next(iter(mapping_or_value.values()))
return mapping_or_value
def _create_sharding_1d(axis_name: str) -> "jax.sharding.Sharding":
"""Create a 1D JAX sharding, preferably using topology-aware mesh_utils."""
import jax
from jax.sharding import Mesh, NamedSharding, PartitionSpec
devices = None
try:
from jax.experimental import mesh_utils
# Attempt to create a topology-aware mesh (e.g. for TPU/GPU interconnects)
devices = mesh_utils.create_device_mesh((jax.device_count(),))
except Exception as e:
logger.debug(f"Failed to use jax.experimental.mesh_utils: {e}")
if devices is None:
# Fallback to default device ordering if mesh_utils fails or is unavailable
devices = np.array(jax.devices())
physical_mesh = Mesh(devices, (axis_name,))
return NamedSharding(physical_mesh, PartitionSpec(axis_name))
def _convert_ndarray_to_jax_array(
ndarray: np.ndarray,
sharding: "jax.sharding.Sharding", # noqa: F821
dtype: Optional["jax.typing.DTypeLike"] = None,
) -> "jax.Array": # noqa: F821
import jax
local_batch_size = ndarray.shape[0]
host_count = jax.process_count()
# Global shape assumes each host gets the exact same local batch size.
global_shape = (local_batch_size * host_count,) + ndarray.shape[1:]
# Construct the globally aware 1D array from process-local data.
# This automatically shards the local ndarray across the local devices
# assigned to this process by the provided sharding.
physical_array = jax.make_array_from_process_local_data(
sharding, ndarray, global_shape
)
if dtype is not None:
physical_array = physical_array.astype(dtype)
return physical_array
def _convert_batch(
ndarrays: NumpyBatch,
sharding: "jax.sharding.Sharding",
dtypes: Optional[DTypeLikeSpec] = None,
) -> JaxBatch:
"""Convert a NumPy ndarray batch to a globally sharded JAX Array batch.
Args:
ndarrays: A single NumPy ndarray or dictionary of NumPy ndarrays.
sharding: The JAX sharding to use for the conversion.
dtypes: A single JAX dtype or dictionary of JAX dtypes for the created arrays.
Returns:
A globally sharded JAX Array (or dictionary of arrays) residing
in TPU/GPU memory.
"""
if isinstance(ndarrays, np.ndarray):
dtype = _unwrap_single_column_value(dtypes, "dtype")
jax_batch = _convert_ndarray_to_jax_array(ndarrays, sharding, dtype=dtype)
else:
jax_batch = {}
for col_name, col_ndarray in ndarrays.items():
dtype = _get_column_value(dtypes, col_name)
try:
jax_batch[col_name] = _convert_ndarray_to_jax_array(
col_ndarray, sharding, dtype=dtype
)
except ValueError as e:
raise ValueError(
f"JAX Array Conversion Error for column '{col_name}'"
) from e
return jax_batch
def _get_batch_size(batch: NumpyBatch) -> int:
"""Get the batch size of a NumPy ndarray or dictionary of NumPy ndarrays."""
if isinstance(batch, dict):
# Use the first column to determine the batch size
try:
return len(next(iter(batch.values())))
except StopIteration:
return 0
return len(batch)
def _pad_array(arr: np.ndarray, target_size: int, pad_value: Scalar) -> np.ndarray:
"""Pad a single array to target_size using pad_value."""
current_size = len(arr)
if current_size == target_size:
return arr
padding_shape = (target_size - current_size,) + arr.shape[1:]
padding = np.full(padding_shape, pad_value, dtype=arr.dtype)
return np.concatenate([arr, padding], axis=0)
def _dummy_array(arr: np.ndarray, target_size: int, pad_value: Scalar) -> np.ndarray:
"""Create a dummy array of target_size filled with pad_value."""
shape = (target_size,) + arr.shape[1:]
return np.full(shape, pad_value, dtype=arr.dtype)
def _pad_batch(
batch: NumpyBatch,
target_size: int,
paddings: PaddingsSpec,
) -> NumpyBatch:
"""Pad a batch to target_size using paddings."""
if isinstance(batch, dict):
return {
k: _pad_array(v, target_size, _get_column_value(paddings, k))
for k, v in batch.items()
}
return _pad_array(
batch,
target_size,
_unwrap_single_column_value(paddings, "padding"),
)
def _create_dummy_batch(
template_batch: NumpyBatch,
target_size: int,
paddings: PaddingsSpec,
) -> NumpyBatch:
"""Create a dummy batch of target_size filled with paddings."""
if isinstance(template_batch, dict):
return {
k: _dummy_array(v, target_size, _get_column_value(paddings, k))
for k, v in template_batch.items()
}
return _dummy_array(
template_batch,
target_size,
_unwrap_single_column_value(paddings, "padding"),
)
def _yield_batches_no_sync(
iterator: Iterator[NumpyBatch],
sharding: "jax.sharding.Sharding",
num_local_devices: int,
batch_size: int,
paddings: Optional[PaddingsSpec],
dtypes: Optional[DTypeLikeSpec] = None,
) -> Iterator[JaxBatch]:
"""Yield batches without multi-host synchronization."""
for batch in iterator:
local_batch_size = _get_batch_size(batch)
if local_batch_size == 0:
continue
if paddings is not None:
if local_batch_size < batch_size:
batch = _pad_batch(batch, batch_size, paddings)
elif local_batch_size % num_local_devices != 0:
# Without padding, batch size must be divisible by num_local_devices
raise ValueError(
f"The local batch size ({local_batch_size}) must be evenly "
f"divisible by the number of local JAX devices "
f"({num_local_devices}) on this host. "
f"To safely truncate or pad the batch, "
f"set `drop_last=True` or provide a `paddings` in `iter_jax_batches()`."
)
yield _convert_batch(batch, sharding, dtypes=dtypes)
def _fetch_lookahead_batches(
iterator: Iterator[NumpyBatch],
lookahead: int,
) -> Tuple[List[Optional[NumpyBatch]], List[int], Optional[NumpyBatch]]:
"""Fetch a window of batches and prepare synchronization info."""
local_batches = []
local_infos = []
template_batch: Optional[NumpyBatch] = None
for _ in range(lookahead):
try:
batch = next(iterator)
has_batch = True
local_batch_size = _get_batch_size(batch)
if template_batch is None:
template_batch = batch
except StopIteration:
batch = None
has_batch = False
local_batch_size = 0
local_batches.append(batch)
local_infos.extend([int(has_batch), local_batch_size])
if not has_batch:
break
return local_batches, local_infos, template_batch
def _yield_batches_with_sync(
iterator: Iterator[NumpyBatch],
sharding: "jax.sharding.Sharding",
num_local_devices: int,
drop_last: bool,
batch_size: int,
paddings: Optional[PaddingsSpec],
synchronize_lookahead: int,
dtypes: Optional[DTypeLikeSpec] = None,
) -> Iterator[JaxBatch]:
"""Yield batches with multi-host synchronization."""
import jax.numpy as jnp
from jax.experimental.multihost_utils import process_allgather
template_batch: Optional[NumpyBatch] = None
while True:
local_batches, local_infos, window_template = _fetch_lookahead_batches(
iterator, synchronize_lookahead
)
if template_batch is None:
template_batch = window_template
# Pad local_infos to 2 * synchronize_lookahead
padding_needed = 2 * synchronize_lookahead - len(local_infos)
if padding_needed > 0:
local_infos.extend([0] * padding_needed)
gathered = process_allgather(jnp.array(local_infos, dtype=jnp.int32))
for i in range(synchronize_lookahead):
h = gathered[:, 2 * i]
s = gathered[:, 2 * i + 1]
all_have_batch = bool(h.all())
any_have_batch = bool(h.any())
min_batch_size = int(s.min())
max_batch_size = int(s.max())
if not any_have_batch:
return
if not all_have_batch:
# Some workers have exhausted their data while others have more.
if drop_last:
# If drop_last=True, we stop as soon as any worker is exhausted.
return
elif paddings is not None:
# If paddings is set, we continue until all workers are exhausted.
# Workers that are already exhausted will yield dummy batches.
pass
else:
raise ValueError(
"Uneven number of batches detected across JAX workers. "
"To safely drop orphaned batches without hanging, "
"set `drop_last=True` or provide a `paddings` in `iter_jax_batches()`."
)
if paddings is not None:
batch = local_batches[i]
if batch is None:
if template_batch is None:
raise ValueError(
"Cannot create dummy batches for synchronization because this "
"JAX host has not received any data batches to use as a "
"template. This usually happens if one JAX host's dataset "
"shard is completely empty while others have data. "
"Ensure that all JAX hosts have at least one batch of data, "
"or use `drop_last=True` to avoid yielding dummy batches."
)
batch = _create_dummy_batch(template_batch, batch_size, paddings)
else:
local_batch_size = _get_batch_size(batch)
if local_batch_size < batch_size:
batch = _pad_batch(batch, batch_size, paddings)
assert batch is not None
yield _convert_batch(batch, sharding, dtypes=dtypes)
else:
if max_batch_size > min_batch_size:
raise ValueError(
"Uneven batch sizes detected across JAX workers. "
f"Host batch sizes range from {min_batch_size} to {max_batch_size}. "
"To handle uneven batch sizes, provide a `paddings` in `iter_jax_batches()`."
)
if min_batch_size % num_local_devices != 0:
raise ValueError(
f"The globally minimum batch size ({min_batch_size}) must be evenly "
f"divisible by the number of local JAX devices "
f"({num_local_devices}) on this host. "
f"To safely truncate or pad the batch, "
f"set `drop_last=True` or provide a `paddings` in `iter_jax_batches()`."
)
batch = local_batches[i]
assert batch is not None
yield _convert_batch(batch, sharding, dtypes=dtypes)
def jax_sync_generator(
batch_iterable: Iterable[NumpyBatch],
drop_last: bool,
batch_size: int = 256,
paddings: Optional[PaddingsSpec] = None,
dtypes: Optional[DTypeLikeSpec] = None,
synchronize_batches: bool = False,
synchronize_lookahead: int = 10,
) -> Iterator[JaxBatch]:
"""A generator that synchronizes and shards batches across JAX workers.
This generator wraps a locally yielded batch iterable and ensures that all JAX
workers within a multi-host training setup receive the exact same number of batches
and identical batch shapes, which is required for JAX's SPMD execution.
Args:
batch_iterable: An iterable yielding local data batches (either a NumPy ndarray
or a dictionary of NumPy ndarrays).
drop_last: Whether to drop partial or uneven batches.
batch_size: The target batch size for each host.
paddings: The value to use for padding uneven batches to `batch_size`.
If a dictionary is provided, it must map column names to padding values.
If None, padding is disabled.
dtypes: A single JAX dtype or dictionary of JAX dtypes for the created arrays.
synchronize_batches: Whether to synchronize batch shapes across all hosts.
Setting this to False can improve performance if you guarantee that all
hosts produce identical batch shapes and counts beforehand.
synchronize_lookahead: The number of batches to look ahead and synchronize at
once. Increasing this value reduces synchronization overhead but may
increase memory usage as more batches are buffered locally.
Yields:
JaxBatch: Globally sharded batches.
"""
import jax
# Physical Sharding (1D across the _GLOBAL_MESH_1D_AXIS dimension)
# The sharding is created once for the lifetime of this generator and reused
# across all batches.
sharding = _create_sharding_1d(_GLOBAL_MESH_1D_AXIS)
num_local_devices = jax.local_device_count()
iterator = iter(batch_iterable)
if not synchronize_batches or jax.process_count() == 1:
yield from _yield_batches_no_sync(
iterator,
sharding,
num_local_devices,
batch_size,
paddings,
dtypes=dtypes,
)
else:
yield from _yield_batches_with_sync(
iterator,
sharding,
num_local_devices,
drop_last,
batch_size,
paddings,
synchronize_lookahead,
dtypes=dtypes,
)
+497
View File
@@ -0,0 +1,497 @@
import warnings
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
import numpy as np
import pyarrow
import torch
from ray._common.utils import env_bool
from ray.data.collate_fn import (
TensorBatchReturnType,
TensorBatchType,
_is_nested_tensor_sequence,
_is_tensor,
_is_tensor_mapping,
_is_tensor_sequence,
_is_tensor_sequence_mapping,
)
from ray.data.util.data_batch_conversion import _unwrap_ndarray_object_type_if_needed
# Default non-blocking transfer for tensors.
DEFAULT_TENSOR_NON_BLOCKING_TRANSFER = env_bool(
"RAY_AIR_DEFAULT_TENSOR_NON_BLOCKING_TRANSFER",
True,
)
def convert_table_to_torch_tensor(
data_batch: pyarrow.Table,
columns: Optional[Union[List[str], List[List[str]]]] = None,
column_dtypes: Optional[Union[torch.dtype, List[torch.dtype]]] = None,
unsqueeze: bool = True,
) -> Union[torch.Tensor, List[torch.Tensor]]:
"""Converts a PyArrow table to a torch Tensor or list of torch Tensors.
The return type matches the format of ``columns``: a flat list of column
names produces a single tensor; a list of lists produces a list of tensors
(one per group). If ``columns`` is None, all columns are used.
Args:
data_batch: The PyArrow table to convert.
columns: Column names to include. A list of lists returns one tensor
per group (useful for multi-input models). None uses all columns.
column_dtypes: Torch dtype(s) for the output. A single dtype applies
to all columns/groups. A list must match the number of groups when
``columns`` is a list of lists.
unsqueeze: If True, reshape each per-column tensor from (N,) to (N, 1)
before concatenating. Defaults to True.
Returns:
A single tensor of shape (N, len(columns)), or a list of tensors when
``columns`` is a list of lists.
"""
multi_input = columns and isinstance(columns[0], (list, tuple))
if columns is None:
columns = data_batch.column_names
if not multi_input and column_dtypes and not isinstance(column_dtypes, torch.dtype):
raise TypeError(
"If `columns` is a list of strings, "
"`column_dtypes` must be None or a single `torch.dtype`."
f"Got {type(column_dtypes)} instead."
)
if multi_input:
if not isinstance(column_dtypes, (list, tuple)):
column_dtypes = [column_dtypes] * len(columns)
return [
_columns_to_tensor(data_batch, group, dtype, unsqueeze)
for group, dtype in zip(columns, column_dtypes)
]
return _columns_to_tensor(data_batch, columns, column_dtypes, unsqueeze)
def _columns_to_tensor(
table: pyarrow.Table,
column_names: List[str],
dtype: Optional[torch.dtype],
unsqueeze: bool,
) -> torch.Tensor:
"""Convert selected columns from a PyArrow table into a single tensor."""
from ray.data._internal.arrow_block import ArrowBlockAccessor
numpy_batch = ArrowBlockAccessor(table).to_numpy(columns=column_names)
tensors = []
for col in column_names:
try:
t = convert_ndarray_to_torch_tensor(numpy_batch[col], dtype)
except Exception as e:
raise ValueError(
f"Failed to convert column {col} to a Torch Tensor of dtype "
f"{dtype}. See above exception chain for the exact failure."
) from e
if unsqueeze:
t = t.unsqueeze(1)
tensors.append(t)
if len(tensors) > 1:
return torch.cat(tensors, dim=1)
return tensors[0]
def convert_ndarray_to_torch_tensor(
ndarray: np.ndarray,
dtype: Optional[torch.dtype] = None,
device: Optional[Union[str, "torch.device"]] = None,
pin_memory: bool = False,
) -> torch.Tensor:
"""Convert a NumPy ndarray to a Torch Tensor.
Args:
ndarray: A NumPy ndarray that we wish to convert to a Torch Tensor.
dtype: A Torch dtype for the created tensor; if None, the dtype will be
inferred from the NumPy ndarray data.
device: The device on which the tensor(s) should be placed; if None, the Torch
tensor(s) will be constructed on the CPU.
pin_memory: Whether to pin the memory of the created tensors.
Returns:
A Torch Tensor.
"""
ndarray = _unwrap_ndarray_object_type_if_needed(ndarray)
# Object dtype cannot be converted into PyTorch Tensor.
if ndarray.dtype.type is np.object_:
raise RuntimeError(
"Numpy array of object dtype cannot be converted to a Torch Tensor. This "
"may because the numpy array is a ragged tensor--it contains items of "
"different sizes. If using `iter_torch_batches()` API, you can pass in a "
"`collate_fn` argument to specify custom logic to convert the Numpy array "
"batch to a Torch tensor batch."
)
# The numpy array is not always writeable as it can come from the Ray object store.
# Numpy will throw a verbose warning here, which we suppress, as we don't write
# to the tensors. We also don't want to copy the array to avoid memory overhead.
# Original warning: https://github.com/pytorch/pytorch/blob/v1.13.0/
# torch/csrc/utils/tensor_numpy.cpp#L198-L206
with warnings.catch_warnings():
warnings.simplefilter("ignore")
result = torch.as_tensor(ndarray, dtype=dtype, device=device)
if pin_memory:
assert result.device.type == "cpu", (
"Pin memory is only supported for CPU tensors. "
f"Got device: {result.device} and pin_memory: {pin_memory}."
)
result = result.pin_memory()
return result
def convert_ndarray_batch_to_torch_tensor_batch(
ndarrays: Union[np.ndarray, Dict[str, np.ndarray]],
dtypes: Optional[Union[torch.dtype, Dict[str, torch.dtype]]] = None,
device: Optional[Union[str, "torch.device"]] = None,
pin_memory: bool = False,
) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
"""Convert a NumPy ndarray batch to a Torch Tensor batch.
Args:
ndarrays: A (dict of) NumPy ndarray(s) that we wish to convert to a Torch Tensor.
dtypes: A (dict of) Torch dtype(s) for the created tensor; if None, the dtype
will be inferred from the NumPy ndarray data.
device: The device on which the tensor(s) should be placed; if None, the Torch
tensor(s) will be constructed on the CPU.
pin_memory: Whether to pin the memory of the created tensors.
Returns:
A (dict of) Torch Tensor(s).
"""
if isinstance(ndarrays, np.ndarray):
# Single-tensor case.
if isinstance(dtypes, dict):
if len(dtypes) != 1:
raise ValueError(
"When constructing a single-tensor batch, only a single dtype "
f"should be given, instead got: {dtypes}"
)
dtypes = next(iter(dtypes.values()))
batch = convert_ndarray_to_torch_tensor(
ndarrays,
dtype=dtypes,
device=device,
pin_memory=pin_memory,
)
else:
# Multi-tensor case.
batch = {
col_name: convert_ndarray_to_torch_tensor(
col_ndarray,
dtype=dtypes[col_name] if isinstance(dtypes, dict) else dtypes,
device=device,
pin_memory=pin_memory,
)
for col_name, col_ndarray in ndarrays.items()
}
return batch
def convert_ndarray_list_to_torch_tensor_list(
ndarrays: Dict[str, List[np.ndarray]],
dtypes: Optional[Union[torch.dtype, Dict[str, torch.dtype]]] = None,
device: Optional[Union[str, "torch.device"]] = None,
pin_memory: bool = False,
) -> Dict[str, List[torch.Tensor]]:
"""Convert a dict mapping column names to lists of ndarrays to Torch Tensors.
Args:
ndarrays: A dict mapping column names to lists of ndarrays that we wish to convert
to Torch Tensors.
dtypes: A (dict of) Torch dtype(s) for the created tensors; if None, the dtype
will be inferred from the NumPy ndarray data.
device: The device on which the tensor(s) should be placed; if None, the Torch
tensor(s) will be constructed on the CPU.
pin_memory: Whether to pin the memory of the created tensors.
Returns:
A dict mapping column names to lists of Tensors.
"""
return {
col_name: [
convert_ndarray_batch_to_torch_tensor_batch(
ndarray,
dtypes=dtypes[col_name] if isinstance(dtypes, dict) else dtypes,
device=device,
pin_memory=pin_memory,
)
for ndarray in col_ndarrays
]
for col_name, col_ndarrays in ndarrays.items()
}
def arrow_batch_to_tensors(
batch: pyarrow.Table,
dtypes: Optional[Union[torch.dtype, Dict[str, torch.dtype]]] = None,
combine_chunks: bool = False,
pin_memory: bool = False,
threadpool: Optional[ThreadPoolExecutor] = None,
) -> Union[Dict[str, torch.Tensor], Dict[str, List[torch.Tensor]]]:
"""Convert PyArrow batch to PyTorch tensors.
Args:
batch: PyArrow batch to convert
dtypes: A (dict of) Torch dtype(s) for the created tensors; if None, the dtype
will be inferred from the NumPy ndarray data.
combine_chunks: If True, combine chunks in Arrow batch before converting to
tensors.
pin_memory: Whether to pin the memory of the created tensors.
threadpool: Optional ThreadPoolExecutor for parallel processing. If provided,
columns/arrays will be processed in parallel. If None, processing is
sequential.
Returns:
When combine_chunks=True: A dictionary of column name to single tensor.
When combine_chunks=False: A dictionary of column name to list of tensors.
"""
from ray.data._internal.arrow_block import ArrowBlockAccessor
from ray.data._internal.arrow_ops import transform_pyarrow
if combine_chunks:
numpy_batch = ArrowBlockAccessor(batch).to_batch_format("numpy")
num_columns = len(numpy_batch)
if num_columns > 1 and threadpool is not None:
# Process columns in parallel using provided threadpool
def process_column(
col_name_col_array: Tuple[str, np.ndarray]
) -> Tuple[str, torch.Tensor]:
col_name, col_array = col_name_col_array
return col_name, convert_ndarray_batch_to_torch_tensor_batch(
col_array,
dtypes=dtypes[col_name] if isinstance(dtypes, dict) else dtypes,
pin_memory=pin_memory,
)
# Submit all columns to threadpool and collect results
processed_cols = threadpool.map(process_column, numpy_batch.items())
return dict(processed_cols)
else:
# Sequential processing for single column or single worker
return {
col_name: convert_ndarray_batch_to_torch_tensor_batch(
col_array,
dtypes=dtypes[col_name] if isinstance(dtypes, dict) else dtypes,
pin_memory=pin_memory,
)
for col_name, col_array in numpy_batch.items()
}
else:
numpy_list = transform_pyarrow.table_to_numpy_dict_chunked(
batch,
)
# Count total number of arrays across all columns
total_arrays = sum(len(arrays) for arrays in numpy_list.values())
num_columns = len(numpy_list)
if total_arrays > 1 and threadpool is not None:
# Process arrays in parallel using provided threadpool
def process_array(
array_item: Tuple[str, int, np.ndarray]
) -> Tuple[str, int, torch.Tensor]:
col_name, array_index, array = array_item
return (
col_name,
array_index,
convert_ndarray_batch_to_torch_tensor_batch(
array,
dtypes=dtypes[col_name] if isinstance(dtypes, dict) else dtypes,
pin_memory=pin_memory,
),
)
# Flatten arrays with column name and index for parallel processing
array_items = [
(col_name, idx, array)
for col_name, arrays in numpy_list.items()
for idx, array in enumerate(arrays)
]
# Submit all arrays to threadpool and collect results
processed_arrays = list(threadpool.map(process_array, array_items))
# Initialize result with all columns from numpy_list, including empty ones
# Pre-allocate lists of the correct size for each column
result: Dict[str, List[torch.Tensor]] = {
col_name: [None] * len(arrays)
for col_name, arrays in numpy_list.items()
}
# Populate result with processed tensors
for col_name, array_index, tensor in processed_arrays:
result[col_name][array_index] = tensor
return result
else:
# Sequential processing
return convert_ndarray_list_to_torch_tensor_list(
numpy_list,
dtypes=dtypes,
pin_memory=pin_memory,
)
@torch.no_grad()
def concat_tensors_to_device(
tensor_sequence: Sequence[torch.Tensor],
device: Optional[Union[str, "torch.device"]] = None,
non_blocking: bool = DEFAULT_TENSOR_NON_BLOCKING_TRANSFER,
) -> torch.Tensor:
"""Stack sequence of tensors into a contiguous GPU tensor.
Args:
tensor_sequence: Sequence of tensors to stack
device: The device to move tensors to. If None, tensors are not moved.
non_blocking: If True, perform device transfer without forcing a
synchronization.
Returns:
A contiguous tensor on the target device
"""
# Assumes tensors have the same shape/dtype
assert (
tensor_sequence
), f"Cannot stack empty sequence of tensors. Received: {tensor_sequence}"
assert all(
isinstance(t, torch.Tensor) for t in tensor_sequence
), "All items must be torch.Tensor. Found invalid types: " + str(
[type(t) for t in tensor_sequence if not isinstance(t, torch.Tensor)]
)
# If there is only one tensor and its device already matches, return it directly.
if len(tensor_sequence) == 1 and (
device is None or tensor_sequence[0].device == torch.device(device)
):
return tensor_sequence[0]
first_dtype = tensor_sequence[0].dtype
assert all(t.dtype == first_dtype for t in tensor_sequence), (
"All tensors must have the same dtype. "
f"Expected: {first_dtype}, got: {[t.dtype for t in tensor_sequence]}"
)
first_shape = tensor_sequence[0].shape[1:]
assert all(t.shape[1:] == first_shape for t in tensor_sequence), (
"All tensors must have the same shape[1:]. "
f"Expected: {first_shape}, got: {[t.shape[1:] for t in tensor_sequence]}"
)
first = tensor_sequence[0]
dtype = first.dtype
shape_tail = first.shape[1:]
total_rows = sum(t.shape[0] for t in tensor_sequence)
# Allocate an empty Tensor on device
result = torch.empty((total_rows, *shape_tail), dtype=dtype, device=device)
row_start = 0
for t in tensor_sequence:
row_end = row_start + t.shape[0]
result[row_start:row_end].copy_(t, non_blocking=non_blocking)
row_start = row_end
return result
def _get_type_str(batch: Any) -> str:
"""Get a string representation of the possibly nested type of the batch.
>>> import torch
>>> _get_type_str([1, 2, "???"])
'list[int | str]'
>>> _get_type_str({"a": [1, 2, 3], "b": 4})
'dict[str, int | list[int]]'
>>> _get_type_str({"a": torch.tensor(1), "b": [torch.tensor(2)]})
'dict[str, Tensor | list[Tensor]]'
>>> _get_type_str({"a": torch.tensor(1), "b": {"c": torch.tensor(2)}})
'dict[str, Tensor | dict[str, Tensor]]'
"""
curr_type = type(batch).__name__
if isinstance(batch, (list, tuple)):
val_types = " | ".join(sorted({_get_type_str(v) for v in batch}))
invalid_type_str = f"{curr_type}[{val_types}]"
elif isinstance(batch, dict):
val_types = " | ".join(sorted({_get_type_str(v) for v in batch.values()}))
invalid_type_str = f"{curr_type}[str, {val_types}]"
else:
invalid_type_str = curr_type
return invalid_type_str
@torch.no_grad()
def move_tensors_to_device(
batch: TensorBatchType,
device: Optional[Union[str, "torch.device"]] = None,
non_blocking: bool = DEFAULT_TENSOR_NON_BLOCKING_TRANSFER,
) -> TensorBatchReturnType:
"""Move tensors to the specified device.
Concatenate nested lists/tuples of tensors along the first (batch) dimension.
For example, for the input
((feature_0_chunk_0,), (feature_1_chunk_0, feature_1_chunk_1))
the output will be (feature_0_chunk_0, feature_1_chunk_0+1)
where each feature is concatenated along the batch dimension.
Args:
batch: A tensor or collection of tensors to move to device. Can be:
- A single tensor
- A sequence of tensors
- A sequence of sequences of tensors. The inner sequence of tensors is
combined during GPU transfer.
- A mapping (e.g., dict) of keys to tensors or sequences of tensors. The
sequence of tensors is combined during GPU transfer.
device: The device to move tensors to. If None, tensors are not moved.
non_blocking: If True, perform device transfer without forcing a
synchronization.
Returns:
The input tensors moved to the specified device
"""
if device is None:
return batch
if _is_tensor(batch):
return batch.to(device, non_blocking=non_blocking)
elif _is_tensor_sequence(batch):
return type(batch)([t.to(device, non_blocking=non_blocking) for t in batch])
elif _is_nested_tensor_sequence(batch):
return type(batch)(
[concat_tensors_to_device(t, device, non_blocking) for t in batch]
)
elif _is_tensor_mapping(batch):
return {k: t.to(device, non_blocking=non_blocking) for k, t in batch.items()}
elif _is_tensor_sequence_mapping(batch):
return {
k: concat_tensors_to_device(v, device, non_blocking)
for k, v in batch.items()
}
else:
raise ValueError(
f"Invalid input type: {_get_type_str(batch)}.\n"
"Expected one of the following: "
"torch.Tensor, "
"List/Tuple[torch.Tensor], "
"Dict[str, torch.Tensor], "
"Mapping[str, List/Tuple[torch.Tensor]]"
)