Data Types#
The data types supported by Ivy are as follows:
int8
int16
int32
int64
uint8
uint16
uint32
uint64
bfloat16
float16
float32
float64
bool
complex64
complex128
The supported data types are all defined at import time, with each of these set as an ivy.Dtype instance.
The ivy.Dtype
class derives from str
, and has simple logic in the constructor to verify that the string formatting is correct.
All data types can be queried as attributes of the ivy
namespace, such as ivy.float32
etc.
In addition, native data types are also specified at import time. Likewise, these are all initially set as ivy.Dtype instances.
There is also an ivy.NativeDtype
class defined, but this is initially set as an empty class.
The following tuples are also defined: all_dtypes
, all_numeric_dtypes
, all_int_dtypes
, all_float_dtypes
.
These each contain all possible data types which fall into the corresponding category.
Each of these tuples is also replicated in a new set of four valid tuples and a set of four invalid tuples.
When no backend is set, all data types are assumed to be valid, and so the invalid tuples are all empty, and the valid tuples are set as equal to the original four “all” tuples.
However, when a backend is set, then some of these are updated.
Firstly, the ivy.NativeDtype
is replaced with the backend-specific data type class.
Secondly, each of the native data types are replaced with the true native data types.
Thirdly, the valid data types are updated.
Finally, the invalid data types are updated.
This leaves each of the data types unmodified, for example ivy.float32
will still reference the original definition in ivy/ivy/__init__.py
,
whereas ivy.native_float32
will now reference the new definition in /ivy/functional/backends/backend/__init__.py
.
The tuples all_dtypes
, all_numeric_dtypes
, all_int_dtypes
and all_float_dtypes
are also left unmodified.
Importantly, we must ensure that unsupported data types are removed from the ivy
namespace.
For example, torch supports uint8
, but does not support uint16
, uint32
or uint64
.
Therefore, after setting a torch backend via ivy.set_backend('torch')
, we should no longer be able to access ivy.uint16
.
This is handled in ivy.set_backend()
.
Data Type Module#
The data_type.py module provides a variety of functions for working with data types.
A few examples include ivy.astype()
which copies an array to a specified data type, ivy.broadcast_to()
which broadcasts an array to a specified shape, and ivy.result_type()
which returns the dtype that results from applying the type promotion rules to the arguments.
Many functions in the data_type.py
module are convenience functions, which means that they do not directly modify arrays, as explained in the Function Types section.
For example, the following are all convenience functions: ivy.can_cast, which determines if one data type can be cast to another data type according to type-promotion rules, ivy.dtype, which gets the data type for the input array, ivy.set_default_dtype, which sets the global default data dtype, and ivy.default_dtype, which returns the correct data type to use.
ivy.default_dtype is arguably the most important function.
Any function in the functional API that receives a dtype
argument will make use of this function, as explained below.
Data Type Promotion#
In order to ensure that the same data type is always returned when operations are performed on arrays with different data types, regardless of which backend framework is set, Ivy has it’s own set of data type promotion rules and corresponding functions. These rules build directly on top of the rules outlined in the Array API Standard.
The rules are simple: all data type promotions in Ivy should adhere a promotion table that extends Array API Standard promotion table using this promotion table, and one of two extra promotion tables depending on precision mode that will be explained in the following section.
In order to ensure adherence to this promotion table, many backend functions make use of the functions ivy.promote_types, ivy.type_promote_arrays, or ivy.promote_types_of_inputs. These functions: promote data types in the inputs and return the new data types, promote the data types of the arrays in the input and return new arrays, and promote the data types of the numeric or array values inputs and return new type promoted values, respectively.
For an example of how some of these functions are used, the implementations for ivy.add()
in each backend framework are as follows:
JAX:
def add(
x1: Union[float, JaxArray],
x2: Union[float, JaxArray],
/,
*,
out: Optional[JaxArray] = None,
) -> JaxArray:
x1, x2 = ivy.promote_types_of_inputs(x1, x2)
return jnp.add(x1, x2)
NumPy:
@_handle_0_dim_output
def add(
x1: Union[float, np.ndarray],
x2: Union[float, np.ndarray],
/,
*,
out: Optional[np.ndarray] = None,
) -> np.ndarray:
x1, x2 = ivy.promote_types_of_inputs(x1, x2)
return np.add(x1, x2, out=out)
TensorFlow:
def add(
x1: Union[float, tf.Tensor, tf.Variable],
x2: Union[float, tf.Tensor, tf.Variable],
/,
*,
out: Optional[Union[tf.Tensor, tf.Variable]] = None,
) -> Union[tf.Tensor, tf.Variable]:
x1, x2 = ivy.promote_types_of_inputs(x1, x2)
return tf.experimental.numpy.add(x1, x2)
PyTorch:
def add(
x1: Union[float, torch.Tensor],
x2: Union[float, torch.Tensor],
/,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
x1, x2 = ivy.promote_types_of_inputs(x1, x2)
return torch.add(x1, x2, out=out)
It’s important to always make use of the Ivy promotion functions as opposed to backend-specific promotion functions such as jax.numpy.promote_types()
, numpy.promote_types()
, tf.experimental.numpy.promote_types()
and torch.promote_types()
, as these will generally have promotion rules which will subtly differ from one another and from Ivy’s unified promotion rules.
On the other hand, each frontend framework has its own set of rules for how data types should be promoted, and their own type promoting functions promote_types_frontend_name()
and promote_types_of_frontend_name_inputs()
in ivy/functional/frontends/frontend_name/__init__.py
.
We should always use these functions in any frontend implementation, to ensure we follow exactly the same promotion rules as the frontend framework uses.
It should be noted that data type promotion is only used for unifying data types of inputs to a common one for performing various mathematical operations.
Examples shown above demonstrate the usage of the add
operation.
As different data types cannot be simply summed, they are promoted to the least common type, according to the presented promotion table.
This ensures that functions always return specific and expected values, independently of the specified backend.
However, data promotion is never used for increasing the accuracy or precision of computations. This is a required condition for all operations, even if the upcasting can help to avoid numerical instabilities caused by underflow or overflow.
Assume that an algorithm is required to compute an inverse of a nearly singular matrix, that is defined in float32
data type.
It is likely that this operation can produce numerical instabilities and generate inf
or nan
values.
Temporary upcasting the input matrix to float64
for computing an inverse and then downcasting the matrix back to float32
may help to produce a stable result.
However, temporary upcasting and subsequent downcasting can not be performed as this is not expected by the user.
Whenever the user defines data with a specific data type, they expect a certain memory footprint.
The user expects specific behaviour and memory constraints whenever they specify and use concrete data types, and those decisions should be respected. Therefore, Ivy does not upcast specific values to improve the stability or precision of the computation.
Precise Mode#
There are cases that arise in mixed promotion (Integer and Float, Complex and Float) that aren’t covered by the Array API Standard promotion table, and depending on each use case, the mixed promotion rules differ as observed in different frameworks, for example Tensorflow leaves integer/floating mixed promotion undefined to make behavior utterly predictable (at some cost to user convenience), while Numpy avoids precision loss at all costs even if that meant casting the arrays to wider-than-necessary dtypes
Precise Promotion Table#
This table focuses on numerical accuracy at the cost of a higher memory footprint. A 16-bit signed or unsigned integer cannot be represented at full precision by a 16-bit float, which has only 10 bits of mantissa. Therefore, it might make sense to promote integers to floats represented by twice the number of bits. There are two disadvantages of this approach:
It still leaves int64 and uint64 promotion undefined, because there is no standard floating point type with enough bits of mantissa to represent their full range of values. We could relax the precision constraint and use
float64
as the upper bound for this case.Some operations result in types that are much wider than necessary; for example mixed operations between
uint16
and float16 would promote all the way tofloat64
, which is not ideal.
with ivy.PreciseMode(True):
print(ivy.promote_types("float32","int32"))
# float64
Non-Precise Promotion Table#
The advantage of this approach is that, outside unsigned ints, it avoids all wider-than-necessary promotions: you can never get an f64 output without a 64-bit input, and you can never get an float32
output without a 32-bit input: this results in convenient semantics for working on accelerators while avoiding unwanted 64-bit values. This feature of giving primacy to floating point types resembles the type promotion behavior of PyTorch.
the disadvantage of this approach is that mixed float/integer promotion is very prone to precision loss: for example, int64
(with a maximum value of 9.2*10^18 can be promoted to float16
(with a maximum value of 6.5*10^4, meaning most representable values will become inf, but we are fine accepting potential loss of precision (but not loss of magnitude) in mixed type promotion which satisfies most of the use cases in deep learning scenarios.
with ivy.PreciseMode(False):
print(ivy.promote_types("float32","int32"))
# float32
Arguments in other Functions#
All dtype
arguments are keyword-only.
All creation functions include the dtype
argument, for specifying the data type of the created array.
Some other non-creation functions also support the dtype
argument, such as ivy.prod()
and ivy.sum()
, but most functions do not include it.
The non-creation functions which do support it are generally functions that involve a compounding reduction across the array, which could result in overflows, and so an explicit dtype
argument is useful for handling such cases.
The dtype
argument is handled in the infer_dtype wrapper, for all functions which have the decorator @infer_dtype
.
This function calls ivy.default_dtype in order to determine the correct data type.
As discussed in the Function Wrapping section, this is applied to all applicable functions dynamically during backend setting.
Overall, ivy.default_dtype infers the data type as follows:
if the
dtype
argument is provided, use this directlyotherwise, if an array is present in the arguments, set
arr
to this array. This will then be used to infer the data type by callingivy.dtype()
on the arrayotherwise, if a relevant scalar is present in the arguments, set
arr
to this scalar and derive the data type from this by calling eitherivy.default_int_dtype()
orivy.default_float_dtype()
depending on whether the scalar is an int or float. This will either return the globally set default int data type or globally set default float data type (settable viaivy.set_default_int_dtype()
andivy.set_default_float_dtype()
respectively). An example of a relevant scalar isstart
in the functionivy.arange()
, which is used to set the starting value of the returned array. Examples of irrelevant scalars which should not be used for determining the data type areaxis
,axes
,dims
etc. which must be integers, and control other configurations of the function being called, with no bearing at all on the data types used by that function.otherwise, if no arrays or relevant scalars are present in the arguments, then use the global default data type, which can either be an int or float data type. This is settable via
ivy.set_default_dtype()
.
For the majority of functions which defer to infer_dtype for handling the data type, these steps will have been followed and the dtype
argument will be populated with the correct value before the backend-specific implementation is even entered into.
Therefore, whereas the dtype
argument is listed as optional in the ivy API at ivy/functional/ivy/category_name.py
, the argument is listed as required in the backend-specific implementations at ivy/functional/backends/backend_name/category_name.py
.
Let’s take a look at the function ivy.zeros()
as an example.
The implementation in ivy/functional/ivy/creation.py
has the following signature:
@outputs_to_ivy_arrays
@handle_out_argument
@infer_dtype
@infer_device
def zeros(
shape: Union[int, Sequence[int]],
*,
dtype: Optional[Union[ivy.Dtype, ivy.NativeDtype]] = None,
device: Optional[Union[ivy.Device, ivy.NativeDevice]] = None,
) -> ivy.Array:
Whereas the backend-specific implementations in ivy/functional/backends/backend_name/statistical.py
all list dtype
as required.
Jax:
def zeros(
shape: Union[int, Sequence[int]],
*,
dtype: jnp.dtype,
device: jaxlib.xla_extension.Device,
) -> JaxArray:
NumPy:
def zeros(
shape: Union[int, Sequence[int]],
*,
dtype: np.dtype,
device: str,
) -> np.ndarray:
TensorFlow:
def zeros(
shape: Union[int, Sequence[int]],
*,
dtype: tf.DType,
device: str,
) -> Union[tf.Tensor, tf.Variable]:
PyTorch:
def zeros(
shape: Union[int, Sequence[int]],
*,
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
This makes it clear that these backend-specific functions are only entered into once the correct dtype
has been determined.
However, the dtype
argument for functions which don’t have the @infer_dtype
decorator are not handled by infer_dtype, and so these defaults must be handled by the backend-specific implementations themselves.
One reason for not adding @infer_dtype
to a function is because it includes relevant scalar arguments for inferring the data type from.
infer_dtype is not able to correctly handle such cases, and so the dtype handling is delegated to the backend-specific implementations.
For example ivy.full()
doesn’t have the @infer_dtype
decorator even though it has a dtype
argument because of the relevant fill_value
which cannot be correctly handled by infer_dtype.
The PyTorch-specific implementation is as follows:
def full(
shape: Union[int, Sequence[int]],
fill_value: Union[int, float],
*,
dtype: Optional[Union[ivy.Dtype, torch.dtype]] = None,
device: torch.device,
) -> Tensor:
return torch.full(
shape_to_tuple(shape),
fill_value,
dtype=ivy.default_dtype(dtype=dtype, item=fill_value, as_native=True),
device=device,
)
The implementations for all other backends follow a similar pattern to this PyTorch implementation, where the dtype
argument is optional and ivy.default_dtype()
is called inside the backend-specific implementation.
Supported and Unsupported Data Types#
Some backend functions (implemented in ivy/functional/backends/
) make use of the decorators @with_supported_dtypes
or @with_unsupported_dtypes
, which flag the data types which this particular function does and does not support respectively for the associated backend.
Only one of these decorators can be specified for any given function.
In the case of @with_supported_dtypes
it is assumed that all unmentioned data types are unsupported, and in the case of @with_unsupported_dtypes
it is assumed that all unmentioned data types are supported.
The decorators take two arguments, a dictionary with the unsupported dtypes mapped to the corresponding version of the backend framework and the current version of the backend framework on the user’s system. Based on that, the version specific unsupported dtypes and devices are set for the given function every time the function is called.
For Backend Functions:
@with_unsupported_dtypes({"2.0.1 and below": ("float16",)}, backend_version)
def expm1(x: torch.Tensor, /, *, out: Optional[torch.Tensor] = None) -> torch.Tensor:
x = _cast_for_unary_op(x)
return torch.expm1(x, out=out)
and for frontend functions we add the corresponding framework string as the second argument instead of the version.
For Frontend Functions:
@with_unsupported_dtypes({"2.0.1 and below": ("float16", "bfloat16")}, "torch")
def trace(input):
if "int" in input.dtype:
input = input.astype("int64")
target_type = "int64" if "int" in input.dtype else input.dtype
return ivy.astype(ivy.trace(input), target_type)
For compositional functions, the supported and unsupported data types can then be inferred automatically using the helper functions function_supported_dtypes and function_unsupported_dtypes respectively, which traverse the abstract syntax tree of the compositional function and evaluate the relevant attributes for each primary function in the composition. The same approach applies for most stateful methods, which are themselves compositional.
It is also possible to add supported and unsupported dtypes as a combination of both class and individual dtypes. The allowed dtype classes are: valid
, numeric
, float
, integer
, and unsigned
.
For example, using the decorator:
@with_unsupported_dtypes{{"2.0.1 and below": ("unsigned", "bfloat16", "float16")}, backend_version)
would consider all the unsigned integer dtypes (uint8
, uint16
, uint32
, uint64
), bfloat16
and float16
as unsupported for the function.
In order to get the supported and unsupported devices and dtypes for a function, the corresponding documentation of that function for that specific framework can be referred. However, sometimes new unsupported dtypes are discovered while testing too. So it is suggested to explore it both ways.
It should be noted that unsupported_dtypes
is different from ivy.invalid_dtypes
which consists of all the data types that every function of that particular backend does not support, and so if a certain dtype
is already present in the ivy.invalid_dtypes
then we should not add it to the @with_unsupported_dtypes
decorator.
Sometimes, it might be possible to support a natively unsupported data type by either casting to a supported data type and then casting back, or explicitly handling these data types without deferring to a backend function at all.
An example of the former is ivy.logical_not()
with a tensorflow backend:
def logical_not(
x: Union[tf.Tensor, tf.Variable],
/,
*,
out: Optional[Union[tf.Tensor, tf.Variable]] = None,
) -> Union[tf.Tensor, tf.Variable]:
return tf.logical_not(tf.cast(x, tf.bool))
An example of the latter is ivy.abs()
with a tensorflow backend:
def abs(
x: Union[float, tf.Tensor, tf.Variable],
/,
*,
out: Optional[Union[tf.Tensor, tf.Variable]] = None,
) -> Union[tf.Tensor, tf.Variable]:
if "uint" in ivy.dtype(x):
return x
else:
return tf.abs(x)
The [un]supported_dtypes_and_devices
decorators can be used for more specific cases where a certain
set of dtypes is not supported by a certain device.
@with_unsupported_device_and_dtypes({"2.6.0 and below": {"cpu": ("int8", "int16", "uint8")}}, backend_version)
def gcd(
x1: Union[paddle.Tensor, int, list, tuple],
x2: Union[paddle.Tensor, float, list, tuple],
/,
*,
out: Optional[paddle.Tensor] = None,
) -> paddle.Tensor:
x1, x2 = promote_types_of_inputs(x1, x2)
return paddle.gcd(x1, x2)
These decorators can also be used as context managers and be applied to a block of code at once or even a module, so that the decorator is applied to all the functions within that context. For example:
# we define this function each time we use this context manager
# so that context managers can access the globals in the
# module they are being used
def globals_getter_func(x=None):
if not x:
return globals()
else:
globals()[x[0]] = x[1]
with with_unsupported_dtypes({"0.4.11 and below": ("complex",)}, backend_version):
def f1(*args,**kwargs):
pass
def f2(*args,**kwargs):
pass
from . import activations
from . import operations
In some cases, the lack of support for a particular data type by the backend function might be more difficult to handle correctly.
For example, in many cases casting to another data type will result in a loss of precision, input range, or both.
In such cases, the best solution is to simply add the data type to the @with_unsupported_dtypes
decorator, rather than trying to implement a long and complex patch to achieve the desired behaviour.
Some cases where a data type is not supported are very subtle.
For example, uint8
is not supported for ivy.prod()
with a torch backend, despite torch.prod()
handling torch.uint8
types in the input totally fine.
The reason for this is that the Array API Standard mandates that prod()
upcasts the unsigned integer return to have the same number of bits as the default integer data type.
By default, the default integer data type in Ivy is int32
, and so we should return an array of type uint32
despite the input arrays being of type uint8
.
However, torch does not support uint32
, and so we cannot fully adhere to the requirements of the standard for uint8
inputs.
Rather than breaking this rule and returning arrays of type uint8
only with a torch backend, we instead opt to remove official support entirely for this combination of data type, function, and backend framework.
This will avoid all of the potential confusion that could arise if we were to have inconsistent and unexpected outputs when using officially supported data types in Ivy.
Another important point to note is that for cases where an entire dtype series is not supported or supported. For example if float16, float32 and float64 are not supported or is supported by a framework which could be a backend or frontend framework, then we simply identify that by simply replacing the different float dtypes with the str float. The same logic is applied to other dtypes such as complex, where we simply replace the entire dtypes with the str complex
An example is ivy.fmin()
with a tensorflow backend:
@with_supported_dtypes({"2.13.0 and below": ("float",)}, backend_version)
def fmin(
x1: Union[tf.Tensor, tf.Variable],
x2: Union[tf.Tensor, tf.Variable],
/,
*,
out: Optional[Union[tf.Tensor, tf.Variable]] = None,
) -> Union[tf.Tensor, tf.Variable]:
x1, x2 = promote_types_of_inputs(x1, x2)
x1 = tf.where(tf.math.is_nan(x1), x2, x1)
x2 = tf.where(tf.math.is_nan(x2), x1, x2)
ret = tf.experimental.numpy.minimum(x1, x2)
return ret
As seen in the above code, we simply use the str float instead of writing all the float dtypes that are supported
Another example is ivy.floor_divide()
with a tensorflow backend:
@with_unsupported_dtypes({"2.13.0 and below": ("complex",)}, backend_version)
def floor_divide(
x1: Union[float, tf.Tensor, tf.Variable],
x2: Union[float, tf.Tensor, tf.Variable],
/,
*,
out: Optional[Union[tf.Tensor, tf.Variable]] = None,
) -> Union[tf.Tensor, tf.Variable]:
x1, x2 = ivy.promote_types_of_inputs(x1, x2)
return tf.experimental.numpy.floor_divide(x1, x2)
As seen in the above code, we simply use the str complex instead of writing all the complex dtypes that are not supported
Supported and Unsupported Data Types Attributes#
In addition to the unsupported / supported data types decorator, we also have the unsupported_dtypes
and supported_dtypes
attributes. These attributes operate in a manner similar to the attr:@with_unsupported_dtypes and attr:@with_supported_dtypes decorators.
Special Case#
However, the major difference between the attributes and the decorators is that the attributes are set and assigned in the ivy function itself ivy/functional/ivy/
,
while the decorators are used within the frontend ivy/functional/frontends/
and backend ivy/functional/backends/
to identify the supported or unsupported data types, depending on the use case.
The attributes are set for functions that don’t have a specific backend implementation for each backend, where we provide the backend as one of the arguments to the attribute of the framework agnostic function (because all ivy functions are framework agnostic), which allows it to identify the supported or unsupported dtypes for each backend.
An example of an ivy function which does not have a specific backend implementation for each backend is the einops_reduce
function. This function , makes use of a third-party library einops
which has its own backend-agnostic implementations.
The unsupported_dtypes
and supported_dtypes
attributes take two arguments, a dictionary with the unsupported dtypes mapped to the corresponding backend framework. Based on that, the specific unsupported dtypes are set for the given function every time the function is called.
For example, we use the unsupported_dtypes
attribute for the einops_reduce
function within the ivy functional API as shown below:
einops_reduce.unsupported_dtypes = {
"torch": ("float16",),
"tensorflow": ("complex",),
"paddle": ("complex", "uint8", "int8", "int16", "float16"),
}
With the above approach, we ensure that anytime the backend is set to torch, the einops_reduce
function does not support float16, likewise, complex dtypes are not supported with a tensorflow backend and
complex, uint8, int8, int16, float16 are not supported with a paddle backend.
Backend Data Type Bugs#
In some cases, the lack of support might just be a bug which will likely be resolved in a future release of the framework.
In these cases, as well as adding to the unsupported_dtypes
attribute, we should also add a #ToDo
comment in the implementation, explaining that the support of the data type will be added as soon as the bug is fixed, with a link to an associated open issue in the framework repos included in the comment.
For example, the following code throws an error when dtype
is torch.int32
but not when it is torch.int64
.
This is tested with torch version 1.12.1
.
This is a known bug:
dtype = torch.int32 # or torch.int64
x = torch.randint(1, 10, ([1, 2, 3]), dtype=dtype)
torch.tensordot(x, x, dims=([0], [0]))
Despite torch.int32
working correctly with torch.tensordot()
in the vast majority of cases, our solution is to still add "int32"
into the unsupported_dtypes
attribute, which will prevent the unit tests from failing in the CI.
We also add the following comment above the unsupported_dtypes
attribute:
# ToDo: re-add int32 support once
# (https://github.com/pytorch/pytorch/issues/84530) is fixed
@with_unsupported_dtypes({"2.0.1 and below": ("int32",)}, backend_version)
Similarly, the following code throws an error for torch version 1.11.0
but not 1.12.1
.
x = torch.tensor([0], dtype=torch.float32)
torch.cumsum(x, axis=0, dtype=torch.bfloat16)
Writing short-lived patches for these temporary issues would add unwarranted complexity to the backend implementations, and introduce the risk of forgetting about the patch, needlessly bloating the codebase with redundant code. In such cases, we can explicitly flag which versions support which data types like so:
@with_unsupported_dtypes(
{"2.0.1 and below": ("uint8", "bfloat16", "float16"), "1.12.1": ()}, backend_version
)
def cumsum(
x: torch.Tensor,
axis: int = 0,
exclusive: bool = False,
reverse: bool = False,
*,
dtype: Optional[torch.dtype] = None,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
In the above example the torch.cumsum
function undergoes changes in the unsupported dtypes from one version to another.
Starting from version 1.12.1
it doesn’t have any unsupported dtypes.
The decorator assigns the version specific unsupported dtypes to the function and if the current version is not found in the dictionary, then it defaults to the behaviour of the last known version.
The same workflow has been implemented for supported_dtypes
, unsupported_devices
and supported_devices
.
The slight downside of this approach is that there is less data type coverage for each version of each backend, but taking responsibility for patching this support for all versions would substantially inflate the implementational requirements for ivy, and so we have decided to opt out of this responsibility!
Data Type Casting Modes#
As discussed earlier, many backend functions have a set of unsupported dtypes which are otherwise supported by the backend itself. This raises a question that whether we should support these dtypes by casting them to some other but close dtype. We avoid manually casting unsupported dtypes for most of the part as this could be seen as undesirable behavior to some of users. This is where we have various dtype casting modes so as to give the users an option to automatically cast unsupported dtype operations to a supported and a nearly same dtype.
There are currently four modes that accomplish this.
upcast_data_types
downcast_data_types
crosscast_data_types
cast_data_types
upcast_data_types
mode casts the unsupported dtype encountered to the next highest supported dtype in the same
dtype group, i.e, if the unsupported dtype encountered is uint8
, then this mode will try to upcast it to the next available supported uint
dtype. If no
higher uint dtype is available, then there won’t be any upcasting performed. You can set this mode by calling ivy.upcast_data_types()
with an optional val
keyword argument that defaults to True
.
Similarly, downcast_data_dtypes
tries to downcast to the next lower supported dtype in the same dtype group. No casting is performed if no lower dtype is found in the same group.
It can also be set by calling ivy.downcast_data_types()
with the optional val
keyword that defaults to boolean value True
.
crosscast_data_types
is for cases when a function doesn’t support int
dtypes, but supports float
and vice-versa. In such cases,
we cast to the default supported float
dtype if it’s the unsupported integer case or we cast to the default supported int
dtype if it’s the unsupported float
case.
The cast_data_types
mode is the combination of all the three modes that we discussed till now. It works its way from crosscasting to upcasting and finally to downcasting to provide support
for any unsupported dtype that is encountered by the functions.
This is the unsupported dtypes for exmp1
. It doesn’t support float16
. We will see how we can
still pass float16
arrays and watch it pass for different modes.
Example of Upcasting mode :
@with_unsupported_dtypes({"2.0.1 and below": ("float16", "complex")}, backend_version)
@handle_numpy_arrays_in_specific_backend
def expm1(x: torch.Tensor, /, *, out: Optional[torch.Tensor] = None) -> torch.Tensor:
x = _cast_for_unary_op(x)
return torch.expm1(x, out=out)
The function expm1
has float16
as one of the unsupported dtypes, for the version 2.0.1
which
is being used for execution at the time of writing this. We will see how cating modes handles this.
import ivy
ivy.set_backend('torch')
ret = ivy.expm1(ivy.array([1], dtype='float16')) # raises exception
ivy.upcast_data_types()
ret = ivy.expm1(ivy.array([1], dtype='float16')) # doesn't raise exception
Example of Downcasting mode :
import ivy
ivy.set_backend('torch')
try:
ret = ivy.expm1(ivy.array([1], dtype='float16')) # raises exception
ivy.downcast_data_types()
ret = ivy.expm1(ivy.array([1], dtype='float16')) # doesn't raise exception
Example of Mixed casting mode :
import ivy
ivy.set_backend('torch')
ret = ivy.expm1(ivy.array([1], dtype='float16')) # raises exception
ivy.cast_data_types()
ret = ivy.expm1(ivy.array([1], dtype='float16')) # doesn't raise exception
Example of Cross casting mode :
@with_unsupported_dtypes({"2.0.1 and below": ("float",)}, backend_version)
@handle_numpy_arrays_in_specific_backend
def lcm(
x1: torch.Tensor,
x2: torch.Tensor,
/,
*,
out: Optional[torch.Tensor] = None,
) -> torch.Tensor:
x1, x2 = promote_types_of_inputs(x1, x2)
return torch.lcm(x1, x2, out=out)
This function doesn’t support any of the float
dtypes, so we will see how cross casting mode can
enable float
dtypes to be passed here too.
import ivy
ivy.set_backend('torch')
ret = ivy.lcm(ivy.array([1], dtype='float16'),ivy.array([1], dtype='float16')) # raises exception
ivy.crosscast_data_types()
ret = ivy.lcm(ivy.array([1], dtype='float16'),ivy.array([1], dtype='float16')) # doesn't raise exception
Since all float
dtypes are not supported by the lcm
function in torch
, it is
casted to the default integer dtype , i.e int32
.
While, casting modes can handle a lot of cases, it doesn’t guarantee 100% support for the unsupported dtypes. In cases where there is no other supported dtype available to cast to, casting mode won’t work and the function would throw the usual error. Since casting modes simply tries to cast an array or dtype to a different one that the given function supports, it is not supposed to provide optimal performance or precision, and hence should be avoided if these are the prime concerns of the user.
Together with these modes we provide some level of flexibility to users when they encounter functions that don’t support a dtype which is otherwise supported by the backend. However, it should be well understood that this may lead to loss of precision and/or an increase in memory consumption.
Superset Data Type Support#
As explained in the superset section of the Deep Dive, we generally go for the superset of behaviour for all Ivy functions, and data type support is no exception.
Some backends like tensorflow do not support integer array inputs for certain functions.
For example tensorflow.cos()
only supports non-integer values.
However, backends like torch and JAX support integer arrays as inputs.
To ensure that integer types are supported in Ivy when a tensorflow backend is set, we simply promote any integer array passed to the function to the default float dtype.
As with all superset design decisions, this behavior makes it much easier to support all frameworks in our frontends, without the need for lots of extra logic for handling integer array inputs for the frameworks which support it natively.
Round Up
This should have hopefully given you a good feel for data types, and how these are handled in Ivy.
If you have any questions, please feel free to reach out on discord in the `data types thread`_!