Files
2026-07-13 13:17:40 +08:00

1769 lines
65 KiB
Python

import abc
import functools
import json
import logging
import os
import sys
import threading
import warnings
from abc import abstractmethod
from datetime import datetime
from enum import Enum
from typing import Any, Collection, Dict, Iterable, List, Optional, Tuple, Union
import numpy as np
import pyarrow as pa
from packaging.version import parse as parse_version
import ray.cloudpickle as cloudpickle
from ray._common.utils import env_integer
from ray.data._internal.arrow_utils import _combine_as_list_array
from ray.data._internal.numpy_support import (
_convert_datetime_to_np_datetime,
convert_to_numpy,
)
from ray.data._internal.object_extensions.arrow import ArrowPythonObjectArray
from ray.data._internal.tensor_extensions.utils import (
ArrayLike,
_is_ndarray_variable_shaped_tensor,
_should_convert_to_tensor,
create_ragged_ndarray,
)
from ray.data._internal.utils.arrow_utils import (
_check_pyarrow_version,
get_pyarrow_version,
)
from ray.data._internal.utils.transform_pyarrow import _is_native_tensor_type
from ray.util import log_once
from ray.util.annotations import DeveloperAPI, PublicAPI
from ray.util.common import INT32_MAX
# First, assert Arrow version is w/in expected bounds
_check_pyarrow_version()
PYARROW_VERSION = get_pyarrow_version()
# Minimum version supporting `zero_copy_only` flag in `ChunkedArray.to_numpy`
MIN_PYARROW_VERSION_CHUNKED_ARRAY_TO_NUMPY_ZERO_COPY_ONLY = parse_version("13.0.0")
# Minimum version supporting Arrow's native FixedShapeTensorArray and FixedShapeTensorType
MIN_PYARROW_VERSION_FIXED_SHAPE_TENSOR_ARRAY = parse_version("12.0.0")
# Minimum version supporting Arrow's native FixedShapeTensorScalar
MIN_PYARROW_VERSION_FIXED_SHAPE_TENSOR_SCALAR = parse_version("16.0.0")
# Min version supporting ``ExtensionArray``s in ``pyarrow.concat``
MIN_PYARROW_VERSION_EXT_ARRAY_CONCAT_SUPPORTED = parse_version("12.0.0")
NUM_BYTES_PER_UNICODE_CHAR = 4
class _SerializationFormat(Enum):
JSON = 0
CLOUDPICKLE = 1
# Set the default serialization format for Arrow extension types.
# JSON is the default (safe). Cloudpickle is opt-in for backward compatibility.
ARROW_EXTENSION_SERIALIZATION_FORMAT = _SerializationFormat(
_SerializationFormat.CLOUDPICKLE
if env_integer("RAY_DATA_ARROW_EXTENSION_SERIALIZATION_CLOUDPICKLE", 0) == 1
else _SerializationFormat.JSON
)
_AUTOLOAD_CLOUDPICKLE_TENSOR_METADATA = (
os.environ.get("RAY_DATA_AUTOLOAD_CLOUDPICKLE_TENSOR_METADATA", "0") == "1"
)
# Conditional imports for PyArrow features that are only available in newer versions
# FixedShapeTensorArray was introduced in PyArrow 12.0.0, but we want min version for
# 16.0.0, because 12.0.0 contains bugs in slicing arrays, and has no support for to_numpy()
# for scalars.
if (
PYARROW_VERSION is None
or PYARROW_VERSION >= MIN_PYARROW_VERSION_FIXED_SHAPE_TENSOR_SCALAR
):
from pyarrow import FixedShapeTensorArray, FixedShapeTensorType
else:
FixedShapeTensorArray = None
FixedShapeTensorType = None
# 100,000 entries, about 10MB in memory.
# Most users tables should have less than 100K columns.
ARROW_EXTENSION_SERIALIZATION_CACHE_MAXSIZE = env_integer(
"RAY_EXTENSION_SERIALIZATION_CACHE_MAXSIZE", 10**5
)
logger = logging.getLogger(__name__)
class FixedShapeTensorFormat(Enum):
"""Enum representing the different tensor type formats."""
# ArrowTensorType (legacy, limited to <2GB)
V1 = "v1"
# ArrowTensorTypeV2 (supports >2GB tensors)
V2 = "v2"
# PyArrow's native FixedShapeTensorType (requires PyArrow 12+)
ARROW_NATIVE = "native"
def to_type(self) -> pa.DataType:
if self == FixedShapeTensorFormat.V1:
return ArrowTensorType
if self == FixedShapeTensorFormat.V2:
return ArrowTensorTypeV2
assert FixedShapeTensorType is not None
return FixedShapeTensorType
def _native_tensor_value_type_can_convert_to_numpy(t: "pa.DataType") -> bool:
"""Pyarrow native fixed shaped tensors support most types. However, when converting
between numpy representions using their built-in `to_numpy_ndarray()` or `from_numpy_ndarray()`,
numbers (floating or integer) are only supported. It is possible to handle this logic using
other methods (`as_py()`, or `to_numpy()`), but for simplicity, we leave it at numbers only.
In the future, we may want to support more datatypes.
"""
return pa.types.is_floating(t) or pa.types.is_integer(t)
def _extension_array_concat_supported() -> bool:
return get_pyarrow_version() >= MIN_PYARROW_VERSION_EXT_ARRAY_CONCAT_SUPPORTED
def _deserialize_with_fallback(serialized: bytes, field_name: str = "data"):
"""Deserialize extension type metadata from Parquet field metadata.
Uses JSON only by default. cloudpickle deserialization is available as an
opt-in for files written by Ray 2.49-2.54, but MUST NOT be used with
untrusted Parquet files.
"""
try:
return json.loads(serialized)
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
if _AUTOLOAD_CLOUDPICKLE_TENSOR_METADATA:
# Opt-in only: files written by Ray 2.49-2.54 used cloudpickle.
# WARNING: Do not enable this for files from untrusted sources.
return cloudpickle.loads(serialized)
raise ValueError(
f"Unable to deserialize {field_name}. If this file was written by "
f"Ray 2.49-2.54, set RAY_DATA_AUTOLOAD_CLOUDPICKLE_TENSOR_METADATA=1 "
f"(trusted sources only)."
)
@DeveloperAPI(stability="beta")
class ArrowExtensionSerializeDeserializeCache(abc.ABC):
"""Base class for caching Arrow extension type serialization and deserialization.
The deserialization and serialization of Arrow extension types is frequent,
so we cache the results here to improve performance.
The deserialization cache uses functools.lru_cache as a classmethod. There is
a single cache instance shared across all subclasses, but the cache key includes
the class (cls parameter) as the first argument, so different subclasses get
different cache entries even when called with the same parameters. The cache is
thread-safe and has a maximum size limit to control memory usage. The cache key
is (cls, *args) where args are the parameters returned by _get_deserialize_parameter().
Attributes:
_serialize_cache: Instance-level cache for serialization results.
This is a simple cached value (bytes) that is computed once per
instance and reused.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the extension type with caching support.
Args:
*args: Positional arguments passed to the parent class.
**kwargs: Keyword arguments passed to the parent class.
"""
# Instance-level cache for serialization results, no TTL
self._serialize_cache = None
self._cache_lock = threading.RLock()
super().__init__(*args, **kwargs)
def __arrow_ext_serialize__(self) -> bytes:
"""Serialize the extension type using caching if enabled."""
if self._serialize_cache is not None:
return self._serialize_cache
with self._cache_lock:
if self._serialize_cache is None:
self._serialize_cache = self._arrow_ext_serialize_compute()
return self._serialize_cache
@abstractmethod
def _arrow_ext_serialize_compute(self) -> bytes:
"""Subclasses must implement this method to compute serialization."""
...
@classmethod
@functools.lru_cache(maxsize=ARROW_EXTENSION_SERIALIZATION_CACHE_MAXSIZE)
def _arrow_ext_deserialize_cache(cls: type, *args: Any, **kwargs: Any) -> Any:
"""Deserialize the extension type using the class-level cache.
This method is cached using functools.lru_cache to improve performance
when deserializing extension types. The cache key includes the class (cls)
as the first argument, ensuring different subclasses get separate cache entries.
Args:
*args: Positional arguments passed to _arrow_ext_deserialize_compute.
**kwargs: Keyword arguments passed to _arrow_ext_deserialize_compute.
Returns:
The deserialized extension type instance.
"""
return cls._arrow_ext_deserialize_compute(*args, **kwargs)
@classmethod
@abstractmethod
def _arrow_ext_deserialize_compute(cls, *args: Any, **kwargs: Any) -> Any:
"""Subclasses must implement this method to compute deserialization."""
...
@classmethod
@abstractmethod
def _get_deserialize_parameter(cls, storage_type, serialized) -> Tuple:
"""Subclasses must implement this method to return the parameters for the deserialization cache."""
...
@classmethod
def __arrow_ext_deserialize__(cls, storage_type, serialized) -> Any:
"""Deserialize the extension type using caching if enabled."""
return cls._arrow_ext_deserialize_cache(
*cls._get_deserialize_parameter(storage_type, serialized)
)
@DeveloperAPI
class ArrowConversionError(Exception):
"""Error raised when there is an issue converting data to Arrow."""
MAX_DATA_STR_LEN = 200
def __init__(
self,
data_str: str,
column_name: Optional[str] = None,
pa_type: Optional["pa.DataType"] = None,
):
if len(data_str) > self.MAX_DATA_STR_LEN:
data_str = data_str[: self.MAX_DATA_STR_LEN] + "..."
if column_name is not None:
type_info = f" (target type: {pa_type})" if pa_type is not None else ""
message = (
f"Error converting column '{column_name}'{type_info}"
f" to Arrow: {data_str}"
)
else:
message = f"Error converting data to Arrow: {data_str}"
super().__init__(message)
@DeveloperAPI
def pyarrow_table_from_pydict(
pydict: Dict[str, Union[List[Any], pa.Array]],
) -> pa.Table:
"""
Convert a Python dictionary to a pyarrow Table.
Args:
pydict: A dictionary mapping column names to column values.
Values can be either lists or PyArrow arrays.
Returns:
A PyArrow Table created from the input dictionary.
Raises:
ArrowConversionError: if the conversion fails.
"""
try:
return pa.Table.from_pydict(pydict)
except Exception as e:
raise ArrowConversionError(str(pydict)) from e
@DeveloperAPI(stability="alpha")
def convert_to_pyarrow_array(
column_values: Union[List[Any], np.ndarray, ArrayLike], column_name: str
) -> pa.Array:
"""Converts provided NumPy `ndarray` into PyArrow's `array` while utilizing
both Arrow's natively supported types as well as custom extension types:
- ArrowTensorArray (for tensors)
- ArrowPythonObjectArray (for user-defined python class objects, as well as
any python object that aren't represented by a corresponding Arrow's native
scalar type)
"""
try:
# Since Arrow does NOT support tensors (aka multidimensional arrays) natively,
# we have to make sure that we handle this case utilizing `ArrowTensorArray`
# extension type
if len(column_values) > 0 and _should_convert_to_tensor(
column_values, column_name
):
from ray.data.extensions.tensor_extension import ArrowTensorArray
# Convert to Numpy before creating instance of `ArrowTensorArray` to
# align tensor shapes falling back to ragged ndarray only if necessary
return ArrowTensorArray.from_numpy(
convert_to_numpy(column_values), column_name=column_name
)
else:
return _convert_to_pyarrow_native_array(column_values, column_name)
except ArrowConversionError as ace:
from ray.data.context import DataContext
enable_fallback_config: Optional[
bool
] = DataContext.get_current().enable_fallback_to_arrow_object_ext_type
# NOTE: By default setting is unset which (for compatibility reasons)
# is allowing the fallback
object_ext_type_fallback_allowed = (
enable_fallback_config is None or enable_fallback_config
)
if object_ext_type_fallback_allowed:
object_ext_type_detail = (
"falling back to serialize as pickled python objects"
)
else:
object_ext_type_detail = (
"skipping fallback to serialize as pickled python objects "
"(due to DataContext.enable_fallback_to_arrow_object_ext_type "
"= False)"
)
# To avoid logging following warning for every block it's
# only going to be logged in following cases
# - It's being logged for the first time, and
# - When config enabling fallback is not set explicitly (in this case
# fallback will still occur by default for compatibility reasons), or
# - Fallback is disallowed (explicitly)
if (
enable_fallback_config is None or not object_ext_type_fallback_allowed
) and log_once("_fallback_to_arrow_object_extension_type_warning"):
logger.warning(
f"Failed to convert column '{column_name}' into pyarrow array "
f"({type(ace).__name__}); {object_ext_type_detail}. "
f"To see the full error, set logging level to DEBUG.",
)
logger.debug(
f"Full details for Arrow conversion error on column '{column_name}':",
exc_info=ace,
)
if not object_ext_type_fallback_allowed:
# If `ArrowPythonObjectType` is not supported raise original exception
raise
# Otherwise, attempt to fall back to serialize as python objects
return ArrowPythonObjectArray.from_objects(column_values)
def _convert_to_pyarrow_native_array(
column_values: Union[List[Any], np.ndarray], column_name: str
) -> pa.Array:
"""Converts provided NumPy `ndarray` into PyArrow's `array` while only utilizing
Arrow's natively supported types (ie no custom extension types)"""
pa_type = None
try:
# NOTE: Python's `datetime` only supports precision up to us and could
# inadvertently lose precision when handling Pandas `Timestamp` type.
# To avoid that we convert provided list of `datetime` objects into
# ndarray of `np.datetime64`
if len(column_values) > 0 and isinstance(column_values[0], datetime):
column_values = _convert_datetime_to_np_datetime(column_values)
# To avoid deserialization penalty of converting Arrow arrays (`Array` and `ChunkedArray`)
# to Python objects and then back to Arrow, we instead combine them into ListArray manually
if len(column_values) > 0 and isinstance(
column_values[0], (pa.Array, pa.ChunkedArray)
):
return _combine_as_list_array(column_values)
# NOTE: We explicitly infer PyArrow `DataType` so that
# we can perform upcasting to be able to accommodate
# blocks that are larger than 2Gb in size (limited
# by int32 offsets used by Arrow internally)
pa_type = _infer_pyarrow_type(column_values)
if pa_type and pa.types.is_timestamp(pa_type):
# NOTE: Quirky Arrow behavior will coerce unsupported Numpy `datetime64`
# precisions that are nested inside a list type, but won't do it,
# if these are top-level ndarray. To work this around we have to cast
# ndarray values manually
if isinstance(column_values, np.ndarray):
column_values = _coerce_np_datetime_to_pa_timestamp_precision(
column_values, pa_type, column_name
)
logger.log(
logging.getLevelName("TRACE"),
f"Inferred dtype of '{pa_type}' for column '{column_name}'",
)
# NOTE: Pyarrow 19.0 is not able to properly handle `ListScalar(None)` when
# creating native array and hence we have to manually replace any such
# cases w/ an explicit null value
#
# See for more details https://github.com/apache/arrow/issues/45682
if len(column_values) > 0 and isinstance(column_values[0], pa.ListScalar):
column_values = [v if v.is_valid else None for v in column_values]
return pa.array(column_values, type=pa_type)
except Exception as e:
raise ArrowConversionError(
str(column_values), column_name=column_name, pa_type=pa_type
) from e
def _coerce_np_datetime_to_pa_timestamp_precision(
column_values: np.ndarray, dtype: pa.TimestampType, column_name: str
):
assert np.issubdtype(column_values.dtype, np.datetime64)
numpy_precision, _ = np.datetime_data(column_values.dtype)
arrow_precision = dtype.unit
if arrow_precision != numpy_precision:
# Arrow supports fewer timestamp resolutions than NumPy. So, if Arrow
# doesn't support the resolution, we need to cast the NumPy array to a
# different type. This can be a lossy conversion.
column_values = column_values.astype(f"datetime64[{arrow_precision}]")
if log_once(f"column_{column_name}_timestamp_warning"):
logger.warning(
f"Converting a {numpy_precision!r} precision datetime NumPy "
f"array to '{arrow_precision}' precision Arrow timestamp. This "
"conversion occurs because Arrow supports fewer precisions "
"than Arrow and might result in a loss of precision or "
"unrepresentable values."
)
return column_values
def _infer_pyarrow_type(
column_values: Union[List[Any], np.ndarray],
) -> Optional[pa.DataType]:
"""Infers target Pyarrow `DataType` based on the provided
columnar values.
NOTE: This is a wrapper on top of `pa.infer_type(...)` utility
performing up-casting of `binary` and `string` types to
corresponding `large_binary` and `large_string` types in case
any of the array elements exceeds 2Gb in size therefore
making it impossible for original types to accommodate such
values.
Unfortunately, for unknown reasons PA doesn't perform
that upcasting itself henceforth we have to do perform
it manually
Args:
column_values: List of columnar values
Returns:
Instance of PyArrow's `DataType` based on the provided
column values
"""
if len(column_values) == 0:
return None
# `pyarrow.infer_type` leaks memory if you pass an array with a datetime64 dtype.
# To avoid this, we handle datetime64 dtypes separately.
# See https://github.com/apache/arrow/issues/45493.
dtype_with_timestamp_type = _try_infer_pa_timestamp_type(column_values)
if dtype_with_timestamp_type is not None:
return dtype_with_timestamp_type
inferred_pa_dtype = pa.infer_type(column_values)
def _len_gt_overflow_threshold(obj: Any) -> bool:
# NOTE: This utility could be seeing objects other than strings or bytes in
# cases when column contains non-scalar non-homogeneous object types as
# column values, therefore making Arrow unable to infer corresponding
# column type appropriately, therefore falling back to assume the type
# of the first element in the list.
#
# Check out test cases for this method for an additional context.
if isinstance(obj, (str, bytes)):
return len(obj) > INT32_MAX
return False
if pa.types.is_binary(inferred_pa_dtype) and any(
[_len_gt_overflow_threshold(v) for v in column_values]
):
return pa.large_binary()
elif pa.types.is_string(inferred_pa_dtype) and any(
[_len_gt_overflow_threshold(v) for v in column_values]
):
return pa.large_string()
return inferred_pa_dtype
_NUMPY_TO_ARROW_PRECISION_MAP = {
# Coarsest timestamp precision in Arrow is seconds
"Y": "s",
"D": "s",
"M": "s",
"W": "s",
"h": "s",
"m": "s",
"s": "s",
"ms": "ms",
"us": "us",
"ns": "ns",
# Finest timestamp precision in Arrow is nanoseconds
"ps": "ns",
"fs": "ns",
"as": "ns",
}
def _try_infer_pa_timestamp_type(
column_values: Union[List[Any], np.ndarray],
) -> Optional[pa.DataType]:
if isinstance(column_values, list) and len(column_values) > 0:
# In case provided column values is a list of elements, this
# utility assumes homogeneity (in line with the behavior of Arrow
# type inference utils)
element_type = _try_infer_pa_timestamp_type(column_values[0])
return pa.list_(element_type) if element_type else None
if isinstance(column_values, np.ndarray) and np.issubdtype(
column_values.dtype, np.datetime64
):
np_precision, _ = np.datetime_data(column_values.dtype)
return pa.timestamp(_NUMPY_TO_ARROW_PRECISION_MAP[np_precision])
else:
return None
@DeveloperAPI
def get_arrow_extension_tensor_types():
"""Returns list of extension types of Arrow Array holding
multidimensional tensors
"""
return (
*get_arrow_extension_fixed_shape_tensor_types(),
*get_arrow_extension_variable_shape_tensor_types(),
)
@DeveloperAPI
def get_arrow_extension_fixed_shape_tensor_types():
"""Returns list of Arrow extension types holding multidimensional
tensors of *fixed* shape
"""
types = (ArrowTensorType, ArrowTensorTypeV2)
if FixedShapeTensorType is not None:
types = types + (FixedShapeTensorType,)
return types
@DeveloperAPI
def get_arrow_extension_variable_shape_tensor_types():
"""Returns list of Arrow extension types holding multidimensional
tensors of *fixed* shape
"""
return (ArrowVariableShapedTensorType,)
# ArrowExtensionSerializeDeserializeCache needs to be first in the MRO to ensure the cache is used
class _BaseFixedShapeArrowTensorType(
ArrowExtensionSerializeDeserializeCache, pa.ExtensionType
):
"""
Arrow ExtensionType for an array of fixed-shaped, homogeneous-typed
tensors.
This is the Arrow side of TensorDtype.
See Arrow extension type docs:
https://arrow.apache.org/docs/python/extending_types.html#defining-extension-types-user-defined-types
"""
def __init__(
self, shape: Tuple[int, ...], tensor_dtype: pa.DataType, ext_type_id: str
):
self._shape = shape
super().__init__(tensor_dtype, ext_type_id)
@property
def shape(self) -> Tuple[int, ...]:
"""
Shape of contained tensors.
"""
return self._shape
@property
def value_type(self) -> pa.DataType:
"""Returns the type of the underlying tensor elements."""
return self.storage_type.value_type
def to_pandas_dtype(self):
"""
Convert Arrow extension type to corresponding Pandas dtype.
Returns:
An instance of pd.api.extensions.ExtensionDtype.
"""
from ray.data._internal.tensor_extensions.pandas import TensorDtype
return TensorDtype(self._shape, self.value_type.to_pandas_dtype())
def __reduce__(self):
return self.__arrow_ext_deserialize__, (
self.storage_type,
self.__arrow_ext_serialize__(),
)
def _arrow_ext_serialize_compute(self):
if ARROW_EXTENSION_SERIALIZATION_FORMAT == _SerializationFormat.CLOUDPICKLE:
return cloudpickle.dumps(self._shape)
elif ARROW_EXTENSION_SERIALIZATION_FORMAT == _SerializationFormat.JSON:
return json.dumps(self._shape).encode()
else:
raise ValueError(
f"Invalid serialization format: {ARROW_EXTENSION_SERIALIZATION_FORMAT}"
)
def __arrow_ext_class__(self):
"""
ExtensionArray subclass with custom logic for this array of tensors
type.
Returns:
A subclass of pd.api.extensions.ExtensionArray.
"""
return ArrowTensorArray
def __arrow_ext_scalar_class__(self):
"""
ExtensionScalar subclass with custom logic for this array of tensors type.
"""
return ArrowTensorScalar
def _extension_scalar_to_ndarray(self, scalar: "pa.ExtensionScalar") -> np.ndarray:
"""
Convert an ExtensionScalar to a tensor element.
"""
return fixed_shape_extension_scalar_to_ndarray(scalar)
def __str__(self) -> str:
return f"{self.__class__.__name__}(shape={self.shape}, dtype={self.storage_type.value_type})"
def __repr__(self) -> str:
return str(self)
def __eq__(self, other):
return (
isinstance(other, type(self))
and other.extension_name == self.extension_name
and other.shape == self.shape
and other.value_type == self.value_type
)
def __ne__(self, other):
# NOTE: We override ``__ne__`` to override base class' method
return not self.__eq__(other)
def __hash__(self) -> int:
return hash((self.extension_name, self.value_type, self._shape))
def fixed_shape_extension_scalar_to_ndarray(
scalar: "pa.ExtensionScalar",
) -> np.ndarray:
"""
Convert an ExtensionScalar to a tensor element.
"""
# Handle None/null values
if scalar.value is None:
return None
raw_values = scalar.value.values
shape = scalar.type.shape
value_type = raw_values.type
offset = raw_values.offset
data_buffer = raw_values.buffers()[1]
return _to_ndarray_helper(shape, value_type, offset, data_buffer)
@PublicAPI(stability="beta")
class ArrowTensorType(_BaseFixedShapeArrowTensorType):
"""Arrow ExtensionType (v1) for tensors.
NOTE: This type does *NOT* support tensors larger than 2Gb (due to
overflow of int32 offsets utilized inside Pyarrow `ListType`)
"""
OFFSET_DTYPE = pa.int32()
def __init__(self, shape: Tuple[int, ...], dtype: pa.DataType):
"""
Construct the Arrow extension type for array of fixed-shaped tensors.
Args:
shape: Shape of contained tensors.
dtype: pyarrow dtype of tensor elements.
"""
super().__init__(shape, pa.list_(dtype), "ray.data.arrow_tensor")
@classmethod
def _get_deserialize_parameter(cls, storage_type, serialized):
return (serialized, storage_type.value_type)
@classmethod
def _arrow_ext_deserialize_compute(cls, serialized, value_type):
shape = tuple(_deserialize_with_fallback(serialized, "shape"))
return cls(shape, value_type)
@PublicAPI(stability="alpha")
class ArrowTensorTypeV2(_BaseFixedShapeArrowTensorType):
"""Arrow ExtensionType (v2) for tensors (supporting tensors > 2Gb)."""
OFFSET_DTYPE = pa.int64()
def __init__(self, shape: Tuple[int, ...], dtype: pa.DataType):
"""
Construct the Arrow extension type for array of fixed-shaped tensors.
Args:
shape: Shape of contained tensors.
dtype: pyarrow dtype of tensor elements.
"""
super().__init__(shape, pa.large_list(dtype), "ray.data.arrow_tensor_v2")
@classmethod
def _get_deserialize_parameter(cls, storage_type, serialized):
return (serialized, storage_type.value_type)
@classmethod
def _arrow_ext_deserialize_compute(cls, serialized, value_type):
shape = tuple(_deserialize_with_fallback(serialized, "shape"))
return cls(shape, value_type)
@DeveloperAPI(stability="alpha")
def create_arrow_fixed_shape_tensor_type(
shape: Tuple[int, ...],
dtype: pa.DataType,
) -> pa.ExtensionType:
"""
Factory method to create an Arrow tensor type.
Args:
shape: Shape of the tensor.
dtype: PyArrow data type of tensor elements.
Returns:
An Arrow ExtensionType for the tensor.
Raises:
ValueError: If NATIVE format is requested but PyArrow < 16.0.0.
"""
from ray.data.context import DataContext
is_valid_dim = all(dim is not None for dim in shape)
assert is_valid_dim
tensor_format = DataContext.get_current().arrow_fixed_shape_tensor_format
# Native tensor format requires PyArrow 16+
if tensor_format == FixedShapeTensorFormat.ARROW_NATIVE:
fallback = FixedShapeTensorFormat.V2
if FixedShapeTensorType is None:
if log_once("native_fixed_shape_tensors_not_supported"):
warnings.warn(
f"Please upgrade pyarrow version >= {MIN_PYARROW_VERSION_FIXED_SHAPE_TENSOR_SCALAR} "
f"to enable native tensor arrays. Falling back to {fallback}.",
UserWarning,
stacklevel=3,
)
tensor_format = fallback
elif not _native_tensor_value_type_can_convert_to_numpy(dtype):
if log_once("native_fixed_shape_tensors_unsupported_type"):
warnings.warn(
f"Native fixed-shape tensor arrays do not support dtype {dtype}. "
f"Only floating and integer types are supported. "
f"Falling back to {fallback}.",
UserWarning,
stacklevel=3,
)
tensor_format = fallback
if tensor_format == FixedShapeTensorFormat.ARROW_NATIVE:
return pa.fixed_shape_tensor(dtype, shape)
elif tensor_format == FixedShapeTensorFormat.V2:
return ArrowTensorTypeV2(shape, dtype)
else: # V1
return ArrowTensorType(shape, dtype)
@PublicAPI(stability="beta")
class ArrowTensorScalar(pa.ExtensionScalar):
def as_py(self, **kwargs) -> np.ndarray:
return self.__array__()
def __array__(self) -> np.ndarray:
return self.type._extension_scalar_to_ndarray(self)
# This function exists to mimic pyarrow's native fixed shaped tensors.
def to_numpy(self) -> np.ndarray:
return np.array(self)
@PublicAPI(stability="beta")
class ArrowTensorArray(pa.ExtensionArray):
"""
An array of fixed-shape, homogeneous-typed tensors.
This is the Arrow side of TensorArray.
See Arrow docs for customizing extension arrays:
https://arrow.apache.org/docs/python/extending_types.html#custom-extension-array-class
"""
@classmethod
def from_numpy(
cls,
arr: Union[np.ndarray, Iterable[np.ndarray]],
*,
column_name: Optional[str] = None,
) -> Union["ArrowTensorArray", "ArrowVariableShapedTensorArray"]:
"""
Convert an ndarray or an iterable of ndarrays to an array of homogeneous-typed
tensors. If given fixed-shape tensor elements, this will return an
``ArrowTensorArray``; if given variable-shape tensor elements, this will return
an ``ArrowVariableShapedTensorArray``.
Args:
arr: An ndarray or an iterable of ndarrays.
column_name: Optional. Used only in logging outputs to provide
additional details.
Returns:
- If fixed-shape tensor elements, an ``ArrowTensorArray`` containing
``len(arr)`` tensors of fixed shape.
- If variable-shaped tensor elements, an ``ArrowVariableShapedTensorArray``
containing ``len(arr)`` tensors of variable shape.
- If scalar elements, a ``pyarrow.Array``.
"""
if not isinstance(arr, np.ndarray) and isinstance(arr, Iterable):
arr = list(arr)
if isinstance(arr, (list, tuple)) and arr and isinstance(arr[0], np.ndarray):
# Stack ndarrays and pass through to ndarray handling logic below.
try:
arr = np.stack(arr, axis=0)
except ValueError as ve:
logger.warning(
f"Failed to stack lists due to: {ve}; "
f"falling back to using np.array(..., dtype=object)",
exc_info=ve,
)
# ndarray stacking may fail if the arrays are heterogeneously-shaped.
arr = np.array(arr, dtype=object)
if not isinstance(arr, np.ndarray):
raise ValueError(
f"Must give ndarray or iterable of ndarrays, got {type(arr)} {arr}"
)
try:
timestamp_dtype = _try_infer_pa_timestamp_type(arr)
if timestamp_dtype:
# NOTE: Quirky Arrow behavior will coerce unsupported Numpy `datetime64`
# precisions that are nested inside a list type, but won't do it,
# if these are top-level ndarray. To work this around we have to cast
# ndarray values manually
arr = _coerce_np_datetime_to_pa_timestamp_precision(
arr, timestamp_dtype, column_name
)
return cls._from_numpy(arr)
except Exception as e:
data_str = ""
if column_name:
data_str += f"column: '{column_name}', "
data_str += f"shape: {arr.shape}, dtype: {arr.dtype}, data: {arr}"
raise ArrowConversionError(data_str) from e
@classmethod
def _from_numpy(
cls,
arr: np.ndarray,
) -> Union["ArrowTensorArray", "ArrowVariableShapedTensorArray"]:
if len(arr) > 0 and np.isscalar(arr[0]):
# This is 1D tensor so a plain `pyarrow.Array` will work
return pa.array(arr)
elif arr.dtype == np.object_:
if _is_ndarray_variable_shaped_tensor(arr):
# Tensor elements have variable shape, so we delegate to
# ArrowVariableShapedTensorArray.
return ArrowVariableShapedTensorArray.from_numpy(arr)
else:
# NOTE: In case of conversion from Pandas extension types supporting
# nullable numeric values (like `pd.Int64Dtype`) we get object
# arrays. Convert the entire array to scalar dtype through PyArrow,
# which handles None -> null -> nan conversion.
# Ravel tensors to combine into contiguous block
_, raveled, shapes, _ = _ravel_tensors(arr)
assert len({tuple(s) for s in shapes}) == 1, (
f"Provided tensors must be homogeneously shaped "
f"(got: {set(tuple(s) for s in shapes)=})" # noqa: C401
)
num_tensors = len(arr)
target_shape = (num_tensors,) + shapes[0]
arr = _concat_ndarrays(raveled).reshape(target_shape)
if not arr.flags.c_contiguous:
# We only natively support C-contiguous ndarrays.
arr = np.ascontiguousarray(arr)
scalar_dtype: pa.DataType = pa.from_numpy_dtype(arr.dtype)
if pa.types.is_string(scalar_dtype):
if arr.dtype.byteorder == ">" or (
arr.dtype.byteorder == "=" and sys.byteorder == "big"
):
raise ValueError(
"Only little-endian string tensors are supported, "
f"but got: {arr.dtype}",
)
scalar_dtype = pa.binary(arr.dtype.itemsize)
outer_len = arr.shape[0]
element_shape = arr.shape[1:]
total_num_items = arr.size
num_items_per_element = np.prod(element_shape) if element_shape else 1
pa_tensor_type_ = create_arrow_fixed_shape_tensor_type(
element_shape, scalar_dtype
)
if _is_native_tensor_type(pa_tensor_type_):
if len(element_shape) > 0 and (
np.prod(element_shape) == 0 or outer_len == 0
):
# FixedShapeTensorArray.from_numpy_ndarray(arr) will fail complaining that
# the array must be non-empty (all dims must be > 0). We can bypass this
# using pa.array with an empty array.
return pa.array([[] for _ in range(outer_len)], type=pa_tensor_type_)
return FixedShapeTensorArray.from_numpy_ndarray(arr)
# Shape up data buffer
if pa.types.is_boolean(scalar_dtype):
# NumPy doesn't represent boolean arrays as bit-packed, so we manually
# bit-pack the booleans before handing the buffer off to Arrow.
# NOTE: Arrow expects LSB bit-packed ordering.
# NOTE: This creates a copy.
arr = np.packbits(arr, bitorder="little")
data_buffer = pa.py_buffer(arr)
data_array = pa.Array.from_buffers(
scalar_dtype, total_num_items, [None, data_buffer]
)
offset_dtype = pa_tensor_type_.OFFSET_DTYPE.to_pandas_dtype()
# Create offsets buffer
if num_items_per_element == 0:
offsets = np.zeros(outer_len + 1, dtype=offset_dtype)
else:
offsets = np.arange(
0,
(outer_len + 1) * num_items_per_element,
num_items_per_element,
dtype=offset_dtype,
)
offset_buffer = pa.py_buffer(offsets)
storage = pa.Array.from_buffers(
pa_tensor_type_.storage_type,
outer_len,
[None, offset_buffer],
children=[data_array],
)
return pa_tensor_type_.wrap_array(storage)
def to_numpy(self, zero_copy_only: bool = True):
"""
Convert the entire array of tensors into a single ndarray.
Args:
zero_copy_only: If True, an exception will be raised if the
conversion to a NumPy array would require copying the
underlying data (e.g. in presence of nulls, or for
non-primitive types). This argument is currently ignored, so
zero-copy isn't enforced even if this argument is true.
Returns:
A single ndarray representing the entire array of tensors.
"""
# Buffers layout: [None, offset_buffer, None, data_buffer]
buffers = self.buffers()
data_buffer = buffers[3]
storage_list_type = self.storage.type
value_type = storage_list_type.value_type
shape = self.type.shape
# Batch type checks
is_boolean = pa.types.is_boolean(value_type)
# Calculate buffer item width once
if is_boolean:
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buffer_item_width = value_type.bit_width
else:
# We assume all other array types are accessed via byte array
# offsets.
buffer_item_width = value_type.bit_width // 8
# Number of items per inner ndarray.
num_items_per_element = np.prod(shape) if shape else 1
# Base offset into data buffer, e.g. due to zero-copy slice.
buffer_offset = self.offset * num_items_per_element
# Offset of array data in buffer.
offset = buffer_item_width * buffer_offset
# Update the shape for ndarray
shape = (len(self),) + shape
if is_boolean:
# Special handling for boolean arrays, since Arrow bit-packs boolean arrays
# while NumPy does not.
# Cast as uint8 array and let NumPy unpack into a boolean view.
# Offset into uint8 array, where each element is a bucket for 8 booleans.
byte_bucket_offset = offset // 8
# Offset for a specific boolean, within a uint8 array element.
bool_offset = offset % 8
# The number of uint8 array elements (buckets) that our slice spans.
# Note that, due to the offset for a specific boolean, the slice can span
# byte boundaries even if it contains less than 8 booleans.
num_boolean_byte_buckets = 1 + ((bool_offset + np.prod(shape) - 1) // 8)
# Construct the uint8 array view on the buffer.
arr = np.ndarray(
(num_boolean_byte_buckets,),
dtype=np.uint8,
buffer=data_buffer,
offset=byte_bucket_offset,
)
# Unpack into a byte per boolean, using LSB bit-packed ordering.
arr = np.unpackbits(arr, bitorder="little")
# Interpret buffer as boolean array.
return np.ndarray(shape, dtype=np.bool_, buffer=arr, offset=bool_offset)
# Special handling of binary/string types. Assumes unicode string tensor columns
if pa.types.is_fixed_size_binary(value_type):
ext_dtype = np.dtype(
f"<U{value_type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}"
)
else:
ext_dtype = value_type.to_pandas_dtype()
return np.ndarray(shape, dtype=ext_dtype, buffer=data_buffer, offset=offset)
def to_numpy_ndarray(self, zero_copy_only: bool = True):
return self.to_numpy(zero_copy_only=zero_copy_only)
def to_var_shaped_tensor_array(
self,
ndim: int,
) -> "ArrowVariableShapedTensorArray":
"""
Convert this tensor array to a variable-shaped tensor array.
"""
shape = self.type.shape
if ndim < len(shape):
raise ValueError(
f"Can't convert {self.type} to var-shaped tensor type with {ndim=}"
)
# NOTE: For ``ArrowTensorTypeV2`` we can construct variable-shaped
# tensor directly w/o modifying its internal storage.
#
# For (deprecated) ``ArrowTensorType`` we fallback to converting to Numpy,
# and reconstructing.
if not isinstance(self.type, ArrowTensorTypeV2):
return ArrowVariableShapedTensorArray.from_numpy(self.to_numpy())
# Pad target shape with singleton axis to match target number of
# dimensions
# TODO avoid padding
target_shape = _pad_shape_with_singleton_axes(shape, ndim)
# Construct shapes array
shape_array = pa.nulls(
len(self.storage),
type=ArrowVariableShapedTensorArray.SHAPES_ARRAY_TYPE,
).fill_null(target_shape)
storage = pa.StructArray.from_arrays(
[self.storage, shape_array],
["data", "shape"],
)
target_type = ArrowVariableShapedTensorType(
self.type.value_type,
ndim=ndim,
)
return target_type.wrap_array(storage)
# ArrowExtensionSerializeDeserializeCache needs to be first in the MRO to ensure the cache is used
@PublicAPI(stability="alpha")
class ArrowVariableShapedTensorType(
ArrowExtensionSerializeDeserializeCache, pa.ExtensionType
):
"""
Arrow ExtensionType for an array of heterogeneous-shaped, homogeneous-typed
tensors.
This is the Arrow side of ``TensorDtype`` for tensor elements with different shapes.
NOTE: This extension only supports tensor elements with non-ragged, well-defined
shapes; i.e. every tensor element must have a well-defined shape and all of their
shapes have to have same number of dimensions (ie ``len(shape)`` has to be the
same for all of them).
See Arrow extension type docs:
https://arrow.apache.org/docs/python/extending_types.html#defining-extension-types-user-defined-types
"""
OFFSET_DTYPE = pa.int64()
def __init__(self, dtype: pa.DataType, ndim: int):
"""
Construct the Arrow extension type for array of heterogeneous-shaped tensors.
Args:
dtype: pyarrow dtype of tensor elements.
ndim: The number of dimensions in the tensor elements.
"""
self._ndim = ndim
super().__init__(
pa.struct(
[("data", pa.large_list(dtype)), ("shape", pa.list_(self.OFFSET_DTYPE))]
),
"ray.data.arrow_variable_shaped_tensor",
)
def to_pandas_dtype(self):
"""
Convert Arrow extension type to corresponding Pandas dtype.
Returns:
An instance of pd.api.extensions.ExtensionDtype.
"""
from ray.data._internal.tensor_extensions.pandas import TensorDtype
return TensorDtype(
self.shape,
self.storage_type["data"].type.value_type.to_pandas_dtype(),
)
@property
def ndim(self) -> int:
"""Return the number of dimensions in the tensor elements."""
return self._ndim
@property
def shape(self) -> Tuple[None, ...]:
return (None,) * self.ndim
@property
def value_type(self) -> pa.DataType:
"""Returns the type of the underlying tensor elements."""
data_field_index = self.storage_type.get_field_index("data")
return self.storage_type[data_field_index].type.value_type
def __reduce__(self):
return self.__arrow_ext_deserialize__, (
self.storage_type,
self.__arrow_ext_serialize__(),
)
def _arrow_ext_serialize_compute(self):
if ARROW_EXTENSION_SERIALIZATION_FORMAT == _SerializationFormat.CLOUDPICKLE:
return cloudpickle.dumps(self._ndim)
elif ARROW_EXTENSION_SERIALIZATION_FORMAT == _SerializationFormat.JSON:
return json.dumps(self._ndim).encode()
else:
raise ValueError(
f"Invalid serialization format: {ARROW_EXTENSION_SERIALIZATION_FORMAT}"
)
@classmethod
def _get_deserialize_parameter(cls, storage_type, serialized):
return (serialized, storage_type["data"].type.value_type)
@classmethod
def _arrow_ext_deserialize_compute(cls, serialized, value_type):
ndim = _deserialize_with_fallback(serialized, "ndim")
return cls(value_type, ndim)
def __arrow_ext_class__(self):
"""
ExtensionArray subclass with custom logic for this array of tensors
type.
Returns:
A subclass of pd.api.extensions.ExtensionArray.
"""
return ArrowVariableShapedTensorArray
def __arrow_ext_scalar_class__(self):
"""
ExtensionScalar subclass with custom logic for this array of tensors type.
"""
return ArrowTensorScalar
def __str__(self) -> str:
dtype = self.storage_type["data"].type.value_type
return f"ArrowVariableShapedTensorType(ndim={self.ndim}, dtype={dtype})"
def __repr__(self) -> str:
return str(self)
def __eq__(self, other):
# NOTE: This check is deliberately not comparing the ``ndim`` since
# we allow tensor types w/ varying ``ndim``s to be combined
return (
isinstance(other, ArrowVariableShapedTensorType)
and other.extension_name == self.extension_name
and other.value_type == self.value_type
)
def __ne__(self, other):
# NOTE: We override ``__ne__`` to override base class' method
return not self.__eq__(other)
def __hash__(self) -> int:
return hash((self.extension_name, self.value_type))
def _extension_scalar_to_ndarray(self, scalar: "pa.ExtensionScalar") -> np.ndarray:
"""
Convert an ExtensionScalar to a tensor element.
"""
# Handle None/null values
if scalar.value is None:
return None
data = scalar.value.get("data")
raw_values = data.values
value_type = raw_values.type
offset = raw_values.offset
data_buffer = raw_values.buffers()[1]
shape = tuple(scalar.value.get("shape").as_py())
return _to_ndarray_helper(shape, value_type, offset, data_buffer)
@PublicAPI(stability="alpha")
class ArrowVariableShapedTensorArray(pa.ExtensionArray):
"""
An array of heterogeneous-shaped, homogeneous-typed tensors.
This is the Arrow side of TensorArray for tensor elements that have differing
shapes. Note that this extension only supports non-ragged tensor elements; i.e.,
when considering each tensor element in isolation, they must have a well-defined
shape. This extension also only supports tensor elements that all have the same
number of dimensions.
See Arrow docs for customizing extension arrays:
https://arrow.apache.org/docs/python/extending_types.html#custom-extension-array-class
"""
SHAPES_ARRAY_TYPE = pa.list_(pa.int64())
@classmethod
def from_numpy(
cls,
arr: Union[np.ndarray, List[np.ndarray], Tuple[np.ndarray]],
) -> "ArrowVariableShapedTensorArray":
"""
Convert an ndarray or an iterable of heterogeneous-shaped ndarrays to an array
of heterogeneous-shaped, homogeneous-typed tensors.
Args:
arr: An ndarray or an iterable of heterogeneous-shaped ndarrays.
Returns:
An ArrowVariableShapedTensorArray containing len(arr) tensors of
heterogeneous shape.
"""
# Implementation note - Arrow representation of ragged tensors:
#
# We represent an array of ragged tensors using a struct array containing two
# fields:
# - data: a variable-sized list array, where each element in the array is a
# tensor element stored in a 1D (raveled) variable-sized list of the
# underlying scalar data type.
# - shape: a variable-sized list array containing the shapes of each tensor
# element.
if not isinstance(arr, (list, tuple, np.ndarray)):
raise ValueError(
"ArrowVariableShapedTensorArray can only be constructed from an "
f"ndarray or a list/tuple of ndarrays, but got: {type(arr)}"
)
if len(arr) == 0:
# Empty ragged tensor arrays are not supported.
raise ValueError("Creating empty ragged tensor arrays is not supported.")
# Ravel provided tensors to combine into contigous block
ndim, raveled, shapes, sizes = _ravel_tensors(arr)
# An optimized zero-copy path if raveled tensor elements are already
# contiguous in memory, e.g. if this tensor array has already done a
# roundtrip through our Arrow representation.
data_buffer = _concat_ndarrays(raveled)
# Get size offsets and total size.
size_offsets = np.cumsum(sizes)
total_size = size_offsets[-1]
dtype = data_buffer.dtype
pa_value_type = pa.from_numpy_dtype(dtype)
if pa.types.is_string(pa_value_type):
if dtype.byteorder == ">" or (
dtype.byteorder == "=" and sys.byteorder == "big"
):
raise ValueError(
f"Only little-endian string tensors are supported, but got: {dtype}"
)
pa_value_type = pa.binary(dtype.itemsize)
if dtype.type is np.bool_ and data_buffer.size > 0:
# NumPy doesn't represent boolean arrays as bit-packed, so we manually
# bit-pack the booleans before handing the buffer off to Arrow.
# NOTE: Arrow expects LSB bit-packed ordering.
# NOTE: This creates a copy.
data_buffer = np.packbits(data_buffer, bitorder="little")
# Use foreign_buffer for better performance when possible
data_buffer = pa.py_buffer(data_buffer)
# Construct underlying data array.
data_array = pa.Array.from_buffers(
pa_value_type, total_size, [None, data_buffer]
)
# Construct array for offsets into the 1D data array, where each offset
# corresponds to a tensor element.
size_offsets = np.insert(size_offsets, 0, 0)
offset_array = pa.array(size_offsets)
data_storage_array = pa.LargeListArray.from_arrays(offset_array, data_array)
# We store the tensor element shapes so we can reconstruct each tensor when
# converting back to NumPy ndarrays.
shape_array = pa.array(shapes)
# Build storage array containing tensor data and the tensor element shapes.
storage = pa.StructArray.from_arrays(
[data_storage_array, shape_array],
["data", "shape"],
)
type_ = ArrowVariableShapedTensorType(pa_value_type, ndim)
return type_.wrap_array(storage)
def to_numpy(self, zero_copy_only: bool = True):
"""
Convert the entire array of tensors into a single ndarray.
Args:
zero_copy_only: If True, an exception will be raised if the conversion to a
NumPy array would require copying the underlying data (e.g. in presence
of nulls, or for non-primitive types). This argument is currently
ignored, so zero-copy isn't enforced even if this argument is true.
Returns:
A single ndarray representing the entire array of tensors.
"""
data_array = self.storage.field("data")
shapes_array = self.storage.field("shape")
data_value_type = data_array.type.value_type
data_array_buffer = data_array.buffers()[3]
shapes = shapes_array.to_pylist()
offsets = data_array.offsets.to_pylist()
return create_ragged_ndarray(
[
_to_ndarray_helper(shape, data_value_type, offset, data_array_buffer)
for shape, offset in zip(shapes, offsets)
]
)
def to_var_shaped_tensor_array(self, ndim: int) -> "ArrowVariableShapedTensorArray":
if ndim == self.type.ndim:
return self
elif ndim < self.type.ndim:
raise ValueError(
f"Can't convert {self.type} to var-shaped tensor type with {ndim=}"
)
target_type = ArrowVariableShapedTensorType(self.type.value_type, ndim)
# Unpack source tensor array into internal data storage and shapes
# array
data_array = self.storage.field("data")
shapes_array = self.storage.field("shape")
# Pad individual shapes with singleton axes to match target number of
# dimensions
#
# TODO avoid python loop
expanded_shapes_array = pa.array(
[_pad_shape_with_singleton_axes(s, ndim) for s in shapes_array.to_pylist()]
)
storage = pa.StructArray.from_arrays([data_array, expanded_shapes_array])
return target_type.wrap_array(storage)
def _pad_shape_with_singleton_axes(
shape: Tuple[int, ...], ndim: int
) -> Tuple[int, ...]:
assert ndim >= len(shape)
return (1,) * (ndim - len(shape)) + shape
def _ravel_tensors(
arr: Union[np.ndarray, List[np.ndarray], Tuple[np.ndarray]],
) -> Tuple[int, np.ndarray, np.ndarray, np.ndarray,]:
# Pre-allocate lists for better performance
raveled = np.empty(len(arr), dtype=np.object_)
shapes = np.empty(len(arr), dtype=np.object_)
sizes = np.empty(len(arr), dtype=np.int64)
ndim = None
for i, a in enumerate(arr):
a = np.asarray(a)
if ndim is None:
ndim = a.ndim
elif a.ndim != ndim:
raise ValueError(
"ArrowVariableShapedTensorArray only supports tensor elements that "
"all have the same number of dimensions, but got tensor elements "
f"with dimensions: {ndim}, {a.ndim}"
)
shapes[i] = a.shape
sizes[i] = a.size
a = _ensure_scalar_ndarray(a)
# Convert to 1D array view; this should be zero-copy in the common case.
# NOTE: If array is not in C-contiguous order, this will convert it to
# C-contiguous order, incurring a copy.
raveled[i] = np.ravel(a, order="C")
return ndim, raveled, shapes, sizes
def _ensure_scalar_ndarray(a: np.ndarray) -> np.ndarray:
# NOTE: In cases of nullable types being passed from Pandas
# we might get ndarrays(dtype='O') that unfortunately
# would have to be copied. We cycle these t/h Pyarrow
# to appropriately handle type conversions
if a.dtype == np.object_:
shape = a.shape
a = pa.array(np.ravel(a)).to_numpy(zero_copy_only=False).reshape(shape)
return a
AnyArrowExtTensorType = Union[
ArrowTensorType, ArrowTensorTypeV2, ArrowVariableShapedTensorType
]
@DeveloperAPI(stability="alpha")
def unify_tensor_types(
types: Collection[AnyArrowExtTensorType],
) -> AnyArrowExtTensorType:
"""Unifies provided tensor types if compatible.
Otherwise raises a ``ValueError``.
"""
assert types, "List of tensor types may not be empty"
if len(types) == 1:
return types[0]
shapes = {tuple(t.shape) for t in types}
value_types = {t.value_type for t in types}
# Only tensors with homogenous scalar types and shape dimensions
# are currently supported
if len(value_types) > 1:
raise pa.lib.ArrowTypeError(
f"Can't unify tensor types with divergent scalar types: {types}"
)
# If all shapes are identical, it's a single tensor type
if len(shapes) == 1:
return next(iter(types))
# NOTE: Cardinality of variable-shaped tensor type's (``ndims``) is
# derived as the max length of the shapes that are making it up
return _get_variable_shaped_tensor_type(
dtype=value_types.pop(),
ndim=max(len(s) for s in shapes),
)
@functools.lru_cache(maxsize=ARROW_EXTENSION_SERIALIZATION_CACHE_MAXSIZE)
def _get_variable_shaped_tensor_type(
dtype: pa.DataType, ndim: int
) -> "ArrowVariableShapedTensorType":
"""Construct (and cache) a variable-shaped tensor type.
``ArrowVariableShapedTensorType`` is an immutable value type fully keyed by
``(dtype, ndim)``, but constructing one is expensive: pyarrow's ext-type
registration serializes the metadata on every instantiation. Schema
unification builds the same handful of types over and over (once per
diverging column, per call), so we memoize construction here.
"""
return ArrowVariableShapedTensorType(dtype=dtype, ndim=ndim)
@DeveloperAPI(stability="alpha")
def unify_tensor_arrays(
arrs: List[
ArrowTensorArray | ArrowVariableShapedTensorArray | FixedShapeTensorArray
],
) -> List[ArrowTensorArray | ArrowVariableShapedTensorArray | FixedShapeTensorArray]:
supported_tensor_types = get_arrow_extension_tensor_types()
# Derive number of distinct tensor types
distinct_types_ = set()
for arr in arrs:
if isinstance(arr.type, supported_tensor_types):
distinct_types_.add(arr.type)
else:
raise ValueError(
f"Trying to unify unsupported tensor type: {arr.type} (supported types: {supported_tensor_types})"
)
if len(distinct_types_) == 1:
return arrs
# Verify provided tensor arrays could be unified
#
# NOTE: If there's more than 1 distinct tensor types, then unified
# type will be variable-shaped
unified_tensor_type = unify_tensor_types(distinct_types_)
assert isinstance(unified_tensor_type, ArrowVariableShapedTensorType)
unified_arrs = []
for arr in arrs:
if _is_native_tensor_type(arr.type):
# Might be not be performant
arr = ArrowVariableShapedTensorArray.from_numpy(arr.to_numpy_ndarray())
else:
arr = arr.to_var_shaped_tensor_array(ndim=unified_tensor_type.ndim)
unified_arrs.append(arr)
return unified_arrs
@DeveloperAPI(stability="alpha")
def concat_tensor_arrays(
arrays: List[Union["ArrowTensorArray", "ArrowVariableShapedTensorArray"]],
ensure_copy: bool = False,
) -> Union["ArrowTensorArray", "ArrowVariableShapedTensorArray"]:
"""
Concatenates multiple tensor arrays.
NOTE: If one or more of the tensor arrays are variable-shaped and/or any
of the tensor arrays have a different shape than the others, a variable-shaped
tensor array will be returned.
Args:
arrays: Tensor arrays to concat
ensure_copy: Skip copying when ensure_copy is False and there is exactly 1 chunk.
Returns:
Either ``ArrowTensorArray`` or ``ArrowVariableShapedTensorArray`` holding
all of the given tensor arrays concatenated.
"""
assert arrays, "List of tensor arrays may not be empty"
if len(arrays) == 1 and not ensure_copy:
# Short-circuit
return arrays[0]
# First, unify provided tensor arrays
unified_arrays = unify_tensor_arrays(arrays)
# Then, simply concat underlying internal storage
storage = pa.concat_arrays([c.storage for c in unified_arrays])
unified_array_type = unified_arrays[0].type
return unified_array_type.wrap_array(storage)
def _concat_ndarrays(arrs: Union[np.ndarray, List[np.ndarray]]) -> np.ndarray:
"""Concatenates provided collection of ``np.ndarray``s in either of the following
ways:
- If provided ndarrays are contiguous, 1D views sharing the same dtype,
living w/in the same base view, these will be concatenated zero-copy
by reusing underlying view
- Otherwise, ``np.concatenate(arrays)`` will be invoked
"""
assert len(arrs) > 0, "Provided collection of ndarrays may not be empty"
if len(arrs) == 1:
# Short-circuit
return arrs[0]
elif not _are_contiguous_1d_views(arrs):
return np.concatenate(arrs)
dtype = arrs[0].dtype
base = _get_root_base(arrs[0])
base_ptr = _get_buffer_address(base)
start_byte = _get_buffer_address(arrs[0]) - base_ptr
end_byte = start_byte + sum(a.nbytes for a in arrs)
# Build the view from the base, using byte offsets for generality
byte_view = base.view(np.uint8).reshape(-1)
out = byte_view[start_byte:end_byte].view(dtype)
return out
def _are_contiguous_1d_views(arrs: Union[np.ndarray, List[np.ndarray]]) -> bool:
dtype = arrs[0].dtype
base = _get_root_base(arrs[0])
expected_addr = _get_base_ptr(arrs[0])
for a in arrs:
# Assert all provided arrays are
# - Raveled (1D)
# - Share dtype
# - Contiguous
# - Share the same `base` view (this is crucial to make sure
# that all provided ndarrays live w/in the same allocation and
# share its lifecycle)
if (
a.ndim != 1
or a.dtype != dtype
or not a.flags.c_contiguous
or _get_root_base(a) is not base
):
return False
# Skip empty ndarrays
if a.size == 0:
continue
buffer_addr = _get_base_ptr(a)
if buffer_addr != expected_addr:
return False
expected_addr = buffer_addr + a.size * dtype.itemsize
return True
def _get_base_ptr(a: np.ndarray) -> int:
# same as a.ctypes.data, but robust for views
return _get_buffer_address(a)
def _get_root_base(a: np.ndarray) -> np.ndarray:
b = a
while isinstance(b.base, np.ndarray):
b = b.base
return b if b.base is not None else b # owner if base is None
def _get_buffer_address(arr: np.ndarray) -> int:
"""Get the address of the buffer underlying the provided NumPy ndarray."""
return arr.__array_interface__["data"][0]
def _to_ndarray_helper(shape, value_type, offset, data_buffer):
if pa.types.is_boolean(value_type):
# Arrow boolean array buffers are bit-packed, with 8 entries per byte,
# and are accessed via bit offsets.
buffer_item_width = value_type.bit_width
else:
# We assume all other array types are accessed via byte array
# offsets.
buffer_item_width = value_type.bit_width // 8
data_offset = buffer_item_width * offset
if pa.types.is_boolean(value_type):
# Special handling for boolean arrays, since Arrow
# bit-packs boolean arrays while NumPy does not.
# Cast as uint8 array and let NumPy unpack into a boolean view.
# Offset into uint8 array, where each element is
# a bucket for 8 booleans.
byte_bucket_offset = data_offset // 8
# Offset for a specific boolean, within a uint8 array element.
bool_offset = data_offset % 8
# The number of uint8 array elements (buckets) that our slice spans.
# Note that, due to the offset for a specific boolean,
# the slice can span byte boundaries even if it contains
# less than 8 booleans.
num_boolean_byte_buckets = 1 + ((bool_offset + np.prod(shape) - 1) // 8)
# Construct the uint8 array view on the buffer.
arr = np.ndarray(
(num_boolean_byte_buckets,),
dtype=np.uint8,
buffer=data_buffer,
offset=byte_bucket_offset,
)
# Unpack into a byte per boolean, using LSB bit-packed ordering.
arr = np.unpackbits(arr, bitorder="little")
# Interpret buffer as boolean array.
return np.ndarray(shape, dtype=np.bool_, buffer=arr, offset=bool_offset)
ext_dtype = value_type.to_pandas_dtype()
# Special handling of ragged string tensors
if pa.types.is_fixed_size_binary(value_type):
ext_dtype = np.dtype(f"<U{value_type.byte_width // NUM_BYTES_PER_UNICODE_CHAR}")
return np.ndarray(shape, dtype=ext_dtype, buffer=data_buffer, offset=data_offset)
try:
# Registration needs an extension type instance, but then works for any instance of
# the same subclass regardless of parametrization of the type.
pa.register_extension_type(ArrowTensorType((0,), pa.int64()))
pa.register_extension_type(ArrowTensorTypeV2((0,), pa.int64()))
pa.register_extension_type(ArrowVariableShapedTensorType(pa.int64(), 0))
except pa.ArrowKeyError:
# Extension types are already registered.
pass