Data Types
Every AnvlArray has a data type, which
dtype() reports. The data types fall into three categories,
where the number is the width in bits:
| Category | Data types |
|---|---|
| Boolean | bool |
| Integer | signed: i8, i16, i32,
i64; unsigned: ui8, ui16,
ui32, ui64
|
| Float |
f32, f64
|
Other floating-point data types, such as f16, and
complex numbers are currently not supported.
The data type of a new AnvlArray is chosen with the
dtype argument, and nv_convert() converts an
existing one:
## <i16>
nv_convert(x, "f64")## AnvlArray
## 1
## 2
## 3
## [ CPUf64{3} ]
Without a dtype, an R logical becomes a
bool, and R doubles and integers
become the default float and integer data types, which
default_dtypes() reports and the
anvl.default_dtypes option configures:
## $float
## <f32>
##
## $int
## <i32>
Converting an AnvlArray back to R with
as_array() gives the R type that can represent its values,
e.g. a double for an f32; see
?as_array for the full list. Help pages name groups of data
types that an argument accepts with single words, such as
numeric or integerish, which
?dtype_categories defines.
The rest of this article covers which data type the result of an operation has when its inputs have different ones.
Type Promotion Rules
When combining arrays of different types (e.g., adding an
f32 to an i32), {anvl} needs to determine a
common type. For example, below we are adding an f32 to an
f64, where the former is promoted to the latter’s type,
because it’s more expressive.
## AnvlArray
## 2
## [ CPUf64{} ]
The rules are defined by the common_dtype()
function.
common_dtype("f64", "f32")## <f64>
common_dtype("i64", "f32")## <f32>
The table below shows the common data type of every pair of data types. The cells are colored by the category of the result: boolean, signed integer, unsigned integer, float, and none.
| bool | i8 | i16 | i32 | i64 | ui8 | ui16 | ui32 | ui64 | f32 | f64 | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| bool | bool | i8 | i16 | i32 | i64 | ui8 | ui16 | ui32 | ui64 | f32 | f64 |
| i8 | i8 | i8 | i16 | i32 | i64 | i16 | i32 | i64 | – | f32 | f64 |
| i16 | i16 | i16 | i16 | i32 | i64 | i16 | i32 | i64 | – | f32 | f64 |
| i32 | i32 | i32 | i32 | i32 | i64 | i32 | i32 | i64 | – | f32 | f64 |
| i64 | i64 | i64 | i64 | i64 | i64 | i64 | i64 | i64 | – | f32 | f64 |
| ui8 | ui8 | i16 | i16 | i32 | i64 | ui8 | ui16 | ui32 | ui64 | f32 | f64 |
| ui16 | ui16 | i32 | i32 | i32 | i64 | ui16 | ui16 | ui32 | ui64 | f32 | f64 |
| ui32 | ui32 | i64 | i64 | i64 | i64 | ui32 | ui32 | ui32 | ui64 | f32 | f64 |
| ui64 | ui64 | – | – | – | – | ui64 | ui64 | ui64 | ui64 | f32 | f64 |
| f32 | f32 | f32 | f32 | f32 | f32 | f32 | f32 | f32 | f32 | f32 | f64 |
| f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
Two integer types meet at a type that holds every value of both, so
i8 and ui8 meet at i16. The
exception is ui64: no signed integer type holds its values,
and an integer never becomes a float on its own, so a ui64
and a signed integer have no common type at all (the red cells above).
This is where JAX and NumPy fall back to a float – {anvl} instead asks
you to say what you want, by converting one of the operands with
nv_convert().
R values are handled differently: {anvl}’s type system keeps their full precision, and lets the author of a function decide how they are promoted, without losing precision along the way. We describe how this works below.
R Values Have No Data Type
An R number is a 64-bit double, while {anvl} computes in
f32 by default. There are two simple ways to reconcile the
two, and both have a drawback:
- Converting R doubles always to
f64means that every R number, such as a step size orpi, pulls the computation tof64, which is considerably slower on GPUs. - Converting R doubles always to the default
f32means that an R number loses precision before it is even used, also when it is combined with anf64AnvlArray.
Below, exact is what {anvl} computes, and
rounded is what the second approach would give, where
pi is rounded to an f32 first:
exact <- nv_add(pi, nv_scalar(2, dtype = "f64"))
rounded <- nv_add(
nv_convert(nv_scalar(pi, dtype = "f32"), "f64"),
nv_scalar(2, dtype = "f64")
)
print(c(exact = as_array(exact), rounded = as_array(rounded), base_r = pi + 2), digits = 16)## exact rounded base_r
## 5.141592653589793 5.141592741012573 5.141592653589793
{anvl} avoids both drawbacks: an R value has no data type of its own,
and takes the data type of the AnvlArray it is combined
with. Combined with an f64, pi keeps its full
precision and gives the same result as base R, while combined with an
f32, the computation stays fast. This makes {anvl} well
suited for statistical computations, where precision often matters a
lot.
In {anvl}’s type system, R objects do not have a concrete data type.
dtype(1)## Error:
## ! An R value has no data type of its own until it is used.
## ℹ `dtype()` is undefined here for the same reason `dtype(1.5)` is: the value
## only takes a data type when it meets a typed array, or when it materializes
## at the default.
## ℹ Give it one explicitly with `nv_convert()`.
The type promotion table from above therefore does not apply to them. However, it is possible to apply an {anvl} function to R values (length-1 vectors and arrays):
nv_exp(1)## AnvlArray
## 2.7183
## [ CPUf32{} ]
## AnvlArray
## 2
## [ CPUf64{} ]
nv_add(1, 2)## AnvlArray
## 3
## [ CPUf32{} ]
It is therefore important to understand the rules that govern the
materialization of R objects as AnvlArrays. There is no
uniform rule how this is done, functions can decide this for themselves,
but the two most common routes are:
- The R value is materialized at its default data type, which
default_dtypes()reports: adoubleand anintegertake the defaults of the active backend, configurable through theanvl.default_dtypesoption, and alogicaltakesbool. This is e.g. the case in unary functions such asnv_exp. - The R value’s data type is inferred from the other arguments, as in
the
nv_addcall above. When no concrete data type is present,nv_add(1, 2)falls back to that default again.
Either way the value is built at that data type rather than converted into it, so one the data type cannot hold is an error:
## Error:
## ! Cannot build the R value -2 at data type "ui8".
## ✖ It is outside the range of "ui8" (0 to 255).
## ℹ An R value is built at the data type it meets rather than converted into it,
## so that data type has to hold it.
## ℹ Convert an array with `nv_convert()` where the wraparound is what you want.
Converting an array is a different matter and keeps XLA’s wraparound semantics:
nv_convert(nv_scalar(-2L, "i32"), "ui8")## AnvlArray
## 254
## [ CPUui8{} ]
Note that these rules are not universal and exceptions exist. Some
functions, such as nv_clamp, prioritize the data type of a
specific argument, in this case x, the value that is being
clamped. It fails if the boundary values cannot be promoted to
x without loss of precision:
## Error:
## ! Cannot bring `min` to data type "f32".
## ✖ "f64" is not promotable to "f32".
## ℹ Convert it explicitly with `nv_convert()`.
Functions document their behavior, so consult their respective help page for more information on how they promote their inputs.
When you are writing your own {anvl} functions, you can decide the
promotion rules yourself. This is possible via the .promote
argument of as_anvl_arrays(). It takes a
PromotionRule, which is a special function that takes in
the arguments and outputs a data type for each one.
as_anvl_arrays(1, 2, .promote = promotion_dtype("f64"))## [[1]]
## AnvlArray
## 1
## [ CPUf64{} ]
##
## [[2]]
## AnvlArray
## 2
## [ CPUf64{} ]
One common rule is promotion_common(), which is used by
functions such as nv_add above. It computes the common data
type of the inputs, so below, the R value 1 takes the data
type of the f64 input:
as_anvl_arrays(1, nv_scalar(2, dtype = "f64"), .promote = promotion_common())## [[1]]
## AnvlArray
## 1
## [ CPUf64{} ]
##
## [[2]]
## AnvlArray
## 2
## [ CPUf64{} ]
When none of the inputs has a data type of its own,
e.g. as_anvl_arrays(1, 2, .promote = promotion_common()),
it falls back to the default data type of an R double. We
can override that default via with_default_dtypes(), which
we use below to ask for double precision. The two elements contrast what
the override does and does not touch: materialized is an R
literal with no data type of its own, so it follows the new default,
while yielded meets a typed f32 array and
takes its data type – a default only decides what happens when
nothing else does.
with_default_dtypes(c(float = "f64"), {
list(
materialized = jit(\() 1.0)(),
yielded = nv_array(1, dtype = "f32") + 1.5
)
})## $materialized
## AnvlArray
## 1
## [ CPUf64{} ]
##
## $yielded
## AnvlArray
## 2.5000
## [ CPUf32{1} ]
Below, we compute the common data type of the arguments.
promotion_fn <- promotion_common()
args <- list(1, 2, nv_scalar(1L, "i8"))
promotion_fn(args)## [[1]]
## <f32>
##
## [[2]]
## <f32>
##
## [[3]]
## <f32>
When used in as_anvl_arrays(), it converts the inputs to
that data type:
## [[1]]
## AnvlArray
## 1
## [ CPUf32{} ]
##
## [[2]]
## AnvlArray
## 2
## [ CPUf32{} ]
##
## [[3]]
## AnvlArray
## 1
## [ CPUf32{} ]
For more information about the available rules, see
?promotion_rule.