diff --git a/aesara/graph/fg.py b/aesara/graph/fg.py index 26fb74bd7f..532507720e 100644 --- a/aesara/graph/fg.py +++ b/aesara/graph/fg.py @@ -484,7 +484,9 @@ def replace( f"rewriting: rewrite {reason} replaces {var} of {var.owner} with {new_var} of {new_var.owner}" ) + name = new_var.name new_var = var.type.filter_variable(new_var, allow_convert=True) + new_var.name = name if var not in self.variables: # TODO: Raise an actual exception here. diff --git a/aesara/link/numba/dispatch/basic.py b/aesara/link/numba/dispatch/basic.py index fe4caf08d0..18761a96b6 100644 --- a/aesara/link/numba/dispatch/basic.py +++ b/aesara/link/numba/dispatch/basic.py @@ -48,10 +48,14 @@ def numba_njit(*args, **kwargs): + kwargs = kwargs.copy() + if "cache" not in kwargs: + kwargs["cache"] = config.numba__cache + if len(args) > 0 and callable(args[0]): - return numba.njit(*args[1:], cache=config.numba__cache, **kwargs)(args[0]) + return numba.njit(*args[1:], **kwargs)(args[0]) - return numba.njit(*args, cache=config.numba__cache, **kwargs) + return numba.njit(*args, **kwargs) def numba_vectorize(*args, **kwargs): @@ -319,10 +323,8 @@ def numba_typify(data, dtype=None, **kwargs): return data -@singledispatch -def numba_funcify(op, node=None, storage_map=None, **kwargs): +def generate_fallback_impl(op, node=None, storage_map=None, **kwargs): """Create a Numba compatible function from an Aesara `Op`.""" - warnings.warn( f"Numba will use object mode to run {op}'s perform method", UserWarning, @@ -375,6 +377,12 @@ def perform(*inputs): return perform +@singledispatch +def numba_funcify(op, node=None, storage_map=None, **kwargs): + """Generate a numba function for a given op and apply node.""" + return generate_fallback_impl(op, node, storage_map, **kwargs) + + @numba_funcify.register(OpFromGraph) def numba_funcify_OpFromGraph(op, node=None, **kwargs): @@ -506,7 +514,6 @@ def {fn_name}({", ".join(input_names)}): @numba_funcify.register(Subtensor) -@numba_funcify.register(AdvancedSubtensor) @numba_funcify.register(AdvancedSubtensor1) def numba_funcify_Subtensor(op, node, **kwargs): @@ -524,7 +531,6 @@ def numba_funcify_Subtensor(op, node, **kwargs): @numba_funcify.register(IncSubtensor) -@numba_funcify.register(AdvancedIncSubtensor) def numba_funcify_IncSubtensor(op, node, **kwargs): incsubtensor_def_src = create_index_func( diff --git a/aesara/link/numba/dispatch/cython_support.py b/aesara/link/numba/dispatch/cython_support.py new file mode 100644 index 0000000000..ab551bb1d3 --- /dev/null +++ b/aesara/link/numba/dispatch/cython_support.py @@ -0,0 +1,211 @@ +import ctypes +import importlib +import re +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, cast + +import numba +import numpy as np +from numpy.typing import DTypeLike +from scipy import LowLevelCallable + + +_C_TO_NUMPY: Dict[str, DTypeLike] = { + "bool": np.bool_, + "signed char": np.byte, + "unsigned char": np.ubyte, + "short": np.short, + "unsigned short": np.ushort, + "int": np.intc, + "unsigned int": np.uintc, + "long": np.int_, + "unsigned long": np.uint, + "long long": np.longlong, + "float": np.single, + "double": np.double, + "long double": np.longdouble, + "float complex": np.csingle, + "double complex": np.cdouble, +} + + +@dataclass +class Signature: + res_dtype: DTypeLike + res_c_type: str + arg_dtypes: List[DTypeLike] + arg_c_types: List[str] + arg_names: List[Optional[str]] + + @property + def arg_numba_types(self) -> List[DTypeLike]: + return [numba.from_dtype(dtype) for dtype in self.arg_dtypes] + + def can_cast_args(self, args: List[DTypeLike]) -> bool: + ok = True + count = 0 + for name, dtype in zip(self.arg_names, self.arg_dtypes): + if name == "__pyx_skip_dispatch": + continue + if len(args) <= count: + raise ValueError("Incorrect number of arguments") + ok &= np.can_cast(args[count], dtype) + count += 1 + if count != len(args): + return False + return ok + + def provides(self, restype: DTypeLike, arg_dtypes: List[DTypeLike]) -> bool: + args_ok = self.can_cast_args(arg_dtypes) + if np.issubdtype(restype, np.inexact): + result_ok = np.can_cast(self.res_dtype, restype, casting="same_kind") + # We do not want to provide less accuracy than advertised + result_ok &= np.dtype(self.res_dtype).itemsize >= np.dtype(restype).itemsize + else: + result_ok = np.can_cast(self.res_dtype, restype) + return args_ok and result_ok + + @staticmethod + def from_c_types(signature: bytes) -> "Signature": + # Match strings like "double(int, double)" + # and extract the return type and the joined arguments + expr = re.compile(rb"\s*(?P[\w ]*\w+)\s*\((?P[\w\s,]*)\)") + re_match = re.fullmatch(expr, signature) + + if re_match is None: + raise ValueError(f"Invalid signature: {signature.decode()}") + + groups = re_match.groupdict() + res_c_type = groups["restype"].decode() + res_dtype: DTypeLike = _C_TO_NUMPY[res_c_type] + + raw_args = groups["args"] + + decl_expr = re.compile( + rb"\s*(?P((long )|(unsigned )|(signed )|(double )|)" + rb"((double)|(float)|(int)|(short)|(char)|(long)|(bool)|(complex)))" + rb"(\s(?P[\w_]*))?\s*" + ) + + arg_dtypes = [] + arg_names: List[Optional[str]] = [] + arg_c_types = [] + for raw_arg in raw_args.split(b","): + re_match = re.fullmatch(decl_expr, raw_arg) + if re_match is None: + raise ValueError(f"Invalid signature: {signature.decode()}") + groups = re_match.groupdict() + arg_c_type = groups["type"].decode() + try: + arg_dtype = _C_TO_NUMPY[arg_c_type] + except KeyError: + raise ValueError(f"Unknown C type: {arg_c_type}") + + arg_c_types.append(arg_c_type) + arg_dtypes.append(arg_dtype) + name = groups["name"] + if not name: + arg_names.append(None) + else: + arg_names.append(name.decode()) + + return Signature(res_dtype, res_c_type, arg_dtypes, arg_c_types, arg_names) + + +def _available_impls(func: Callable) -> List[Tuple[Signature, Any]]: + """Find all available implementations for a fused cython function.""" + impls = [] + mod = importlib.import_module(func.__module__) + + signatures = getattr(func, "__signatures__", None) + if signatures is not None: + # Cython function with __signatures__ should be fused and thus + # indexable + func_map = cast(Mapping, func) + candidates = [func_map[key] for key in signatures] + else: + candidates = [func] + for candidate in candidates: + name = candidate.__name__ + capsule = mod.__pyx_capi__[name] + llc = LowLevelCallable(capsule) + try: + signature = Signature.from_c_types(llc.signature.encode()) + except KeyError: + continue + impls.append((signature, capsule)) + return impls + + +class _CythonWrapper(numba.types.WrapperAddressProtocol): + def __init__(self, pyfunc, signature, capsule): + self._keep_alive = capsule + get_name = ctypes.pythonapi.PyCapsule_GetName + get_name.restype = ctypes.c_char_p + get_name.argtypes = (ctypes.py_object,) + + raw_signature = get_name(capsule) + + get_pointer = ctypes.pythonapi.PyCapsule_GetPointer + get_pointer.restype = ctypes.c_void_p + get_pointer.argtypes = (ctypes.py_object, ctypes.c_char_p) + self._func_ptr = get_pointer(capsule, raw_signature) + + self._signature = signature + self._pyfunc = pyfunc + + def signature(self): + return numba.from_dtype(self._signature.res_dtype)( + *self._signature.arg_numba_types + ) + + def __wrapper_address__(self): + return self._func_ptr + + def __call__(self, *args, **kwargs): + args = [dtype(arg) for arg, dtype in zip(args, self._signature.arg_dtypes)] + if self.has_pyx_skip_dispatch(): + output = self._pyfunc(*args[:-1], **kwargs) + else: + output = self._pyfunc(*args, **kwargs) + return self._signature.res_dtype(output) + + def has_pyx_skip_dispatch(self): + if not self._signature.arg_names: + return False + if any( + name == "__pyx_skip_dispatch" for name in self._signature.arg_names[:-1] + ): + raise ValueError("skip_dispatch parameter must be last") + return self._signature.arg_names[-1] == "__pyx_skip_dispatch" + + def numpy_arg_dtypes(self): + return self._signature.arg_dtypes + + def numpy_output_dtype(self): + return self._signature.res_dtype + + +def wrap_cython_function(func, restype, arg_types): + impls = _available_impls(func) + compatible = [] + for sig, capsule in impls: + if sig.provides(restype, arg_types): + compatible.append((sig, capsule)) + + def sort_key(args): + sig, _ = args + + # Prefer functions with less inputs bytes + argsize = sum(np.dtype(dtype).itemsize for dtype in sig.arg_dtypes) + + # Prefer functions with more exact (integer) arguments + num_inexact = sum(np.issubdtype(dtype, np.inexact) for dtype in sig.arg_dtypes) + return (num_inexact, argsize) + + compatible.sort(key=sort_key) + + if not compatible: + raise NotImplementedError(f"Could not find a compatible impl of {func}") + sig, capsule = compatible[0] + return _CythonWrapper(func, sig, capsule) diff --git a/aesara/link/numba/dispatch/elemwise.py b/aesara/link/numba/dispatch/elemwise.py index 74ac324eaa..5efed59403 100644 --- a/aesara/link/numba/dispatch/elemwise.py +++ b/aesara/link/numba/dispatch/elemwise.py @@ -27,6 +27,7 @@ OR, XOR, Add, + Composite, IntDiv, Mean, Mul, @@ -40,6 +41,7 @@ from aesara.tensor.elemwise import CAReduce, DimShuffle, Elemwise from aesara.tensor.math import MaxAndArgmax, MulWithoutZeros from aesara.tensor.special import LogSoftmax, Softmax, SoftmaxGrad +from aesara.tensor.type import scalar @singledispatch @@ -162,6 +164,15 @@ def create_vectorize_func( return elemwise_fn +def normalize_axis(axis, ndim): + if axis < 0: + axis = ndim + axis + + if axis < 0 or axis >= ndim: + raise np.AxisError(ndim=ndim, axis=axis) + return axis + + def create_axis_reducer( scalar_op: Op, identity: Union[np.ndarray, Number], @@ -216,6 +227,8 @@ def careduce_axis(x): """ + axis = normalize_axis(axis, ndim) + reduce_elemwise_fn_name = "careduce_axis" identity = str(identity) @@ -338,6 +351,8 @@ def careduce_maximum(input): if len(axes) == 1: return create_axis_reducer(scalar_op, identity, axes[0], ndim, dtype) + axes = [normalize_axis(axis, ndim) for axis in axes] + careduce_fn_name = f"careduce_{scalar_op}" global_env = {} to_reduce = reversed(sorted(axes)) @@ -407,6 +422,8 @@ def jit_compile_reducer(node, fn, **kwds): def create_axis_apply_fn(fn, axis, ndim, dtype): + axis = normalize_axis(axis, ndim) + reaxis_first = tuple(i for i in range(ndim) if i != axis) + (axis,) @numba_basic.numba_njit(boundscheck=False) @@ -424,8 +441,17 @@ def axis_apply_fn(x): @numba_funcify.register(Elemwise) def numba_funcify_Elemwise(op, node, **kwargs): - - scalar_op_fn = numba_funcify(op.scalar_op, node=node, inline="always", **kwargs) + # Creating a new scalar node is more involved and unnecessary + # if the scalar_op is composite, as the fgraph already contains + # all the necessary information. + scalar_node = None + if not isinstance(op.scalar_op, Composite): + scalar_inputs = [scalar(dtype=input.dtype) for input in node.inputs] + scalar_node = op.scalar_op.make_node(*scalar_inputs) + + scalar_op_fn = numba_funcify( + op.scalar_op, node=scalar_node, parent_node=node, inline="always", **kwargs + ) elemwise_fn = create_vectorize_func(scalar_op_fn, node, use_signature=False) elemwise_fn_name = elemwise_fn.__name__ @@ -598,6 +624,8 @@ def numba_funcify_Softmax(op, node, **kwargs): x_dtype = numba.np.numpy_support.from_dtype(x_dtype) axis = op.axis + axis = normalize_axis(axis, x_at.ndim) + if axis is not None: reduce_max_py = create_axis_reducer( scalar_maximum, -np.inf, axis, x_at.ndim, x_dtype, keepdims=True @@ -635,6 +663,7 @@ def numba_funcify_SoftmaxGrad(op, node, **kwargs): sm_dtype = numba.np.numpy_support.from_dtype(sm_dtype) axis = op.axis + axis = normalize_axis(axis, sm_at.ndim) if axis is not None: reduce_sum_py = create_axis_reducer( add_as, 0.0, axis, sm_at.ndim, sm_dtype, keepdims=True @@ -665,6 +694,7 @@ def numba_funcify_LogSoftmax(op, node, **kwargs): x_dtype = x_at.type.numpy_dtype x_dtype = numba.np.numpy_support.from_dtype(x_dtype) axis = op.axis + axis = normalize_axis(axis, x_at.ndim) if axis is not None: reduce_max_py = create_axis_reducer( @@ -699,6 +729,7 @@ def numba_funcify_MaxAndArgmax(op, node, **kwargs): x_dtype = x_at.type.numpy_dtype x_dtype = numba.np.numpy_support.from_dtype(x_dtype) x_ndim = x_at.ndim + axis = normalize_axis(axis, x_ndim) if x_ndim == 0: diff --git a/aesara/link/numba/dispatch/extra_ops.py b/aesara/link/numba/dispatch/extra_ops.py index bbb0e15ad5..1f8b9cf1bb 100644 --- a/aesara/link/numba/dispatch/extra_ops.py +++ b/aesara/link/numba/dispatch/extra_ops.py @@ -19,6 +19,7 @@ Unique, UnravelIndex, ) +from aesara.raise_op import CheckAndRaise @numba_funcify.register(Bartlett) @@ -36,31 +37,57 @@ def numba_funcify_CumOp(op, node, **kwargs): mode = op.mode ndim = node.outputs[0].ndim + if axis < 0: + axis = ndim + axis + if axis < 0 or axis >= ndim: + raise ValueError(f"Invalid axis {axis} for array with ndim {ndim}") + reaxis_first = (axis,) + tuple(i for i in range(ndim) if i != axis) if mode == "add": - np_func = np.add - identity = 0 + + if ndim == 1: + @numba_basic.numba_njit(fastmath=config.numba__fastmath) + def cumop(x): + return np.cumsum(x) + + else: + @numba_basic.numba_njit(boundscheck=False, fastmath=config.numba__fastmath) + def cumop(x): + out_dtype = x.dtype + if x.shape[axis] < 2: + return x.astype(out_dtype) + + x_axis_first = x.transpose(reaxis_first) + res = np.empty(x_axis_first.shape, dtype=out_dtype) + + res[0] = x_axis_first[0] + for m in range(1, x.shape[axis]): + res[m] = res[m - 1] + x_axis_first[m] + + return res.transpose(reaxis_first) + else: - np_func = np.multiply - identity = 1 + if ndim == 1: + @numba_basic.numba_njit(fastmath=config.numba__fastmath) + def cumop(x): + return np.cumprod(x) - @numba_basic.numba_njit(boundscheck=False, fastmath=config.numba__fastmath) - def cumop(x): - out_dtype = x.dtype - if x.shape[axis] < 2: - return x.astype(out_dtype) + else: + @numba_basic.numba_njit(boundscheck=False, fastmath=config.numba__fastmath) + def cumop(x): + out_dtype = x.dtype + if x.shape[axis] < 2: + return x.astype(out_dtype) - x_axis_first = x.transpose(reaxis_first) - res = np.empty(x_axis_first.shape, dtype=out_dtype) + x_axis_first = x.transpose(reaxis_first) + res = np.empty(x_axis_first.shape, dtype=out_dtype) - for m in numba.prange(x.shape[axis]): - if m == 0: - np_func(identity, x_axis_first[m], res[m]) - else: - np_func(res[m - 1], x_axis_first[m], res[m]) + res[0] = x_axis_first[0] + for m in range(1, x.shape[axis]): + res[m] = res[m - 1] * x_axis_first[m] - return res.transpose(reaxis_first) + return res.transpose(reaxis_first) return cumop @@ -346,3 +373,18 @@ def broadcast_to(x, *shape): return np.broadcast_to(x, scalars_shape) return broadcast_to + + +@numba_funcify.register(CheckAndRaise) +def numba_funcify_CheckAndRaise(op, node, **kwargs): + error = op.exc_type + msg = op.msg + + @numba_basic.numba_njit + def check_and_raise(x, *conditions): + for cond in conditions: + if not cond: + raise error(msg) + return x + + return check_and_raise diff --git a/aesara/link/numba/dispatch/nlinalg.py b/aesara/link/numba/dispatch/nlinalg.py index 1ac3823012..7ea528ca7b 100644 --- a/aesara/link/numba/dispatch/nlinalg.py +++ b/aesara/link/numba/dispatch/nlinalg.py @@ -26,30 +26,18 @@ def numba_funcify_SVD(op, node, **kwargs): full_matrices = op.full_matrices compute_uv = op.compute_uv - if not compute_uv: - - warnings.warn( - ( - "Numba will use object mode to allow the " - "`compute_uv` argument to `numpy.linalg.svd`." - ), - UserWarning, - ) + inputs_cast = int_to_float_fn(node.inputs, out_dtype) - ret_sig = get_numba_type(node.outputs[0].type) + if not compute_uv: - @numba_basic.numba_njit + @numba_basic.numba_njit() def svd(x): - with numba.objmode(ret=ret_sig): - ret = np.linalg.svd(x, full_matrices, compute_uv) + _, ret, _ = np.linalg.svd(inputs_cast(x), full_matrices) return ret else: - out_dtype = node.outputs[0].type.numpy_dtype - inputs_cast = int_to_float_fn(node.inputs, out_dtype) - - @numba_basic.numba_njit(inline="always") + @numba_basic.numba_njit() def svd(x): return np.linalg.svd(inputs_cast(x), full_matrices) diff --git a/aesara/link/numba/dispatch/scalar.py b/aesara/link/numba/dispatch/scalar.py index 08dd5f1a10..6d55470650 100644 --- a/aesara/link/numba/dispatch/scalar.py +++ b/aesara/link/numba/dispatch/scalar.py @@ -1,16 +1,18 @@ import math -from functools import reduce from typing import List import numpy as np -import scipy -import scipy.special from aesara import config from aesara.compile.ops import ViewOp from aesara.graph.basic import Variable from aesara.link.numba.dispatch import basic as numba_basic -from aesara.link.numba.dispatch.basic import create_numba_signature, numba_funcify +from aesara.link.numba.dispatch.basic import ( + create_numba_signature, + generate_fallback_impl, + numba_funcify, +) +from aesara.link.numba.dispatch.cython_support import wrap_cython_function from aesara.link.utils import ( compile_function_src, get_name_for_object, @@ -36,69 +38,83 @@ def numba_funcify_ScalarOp(op, node, **kwargs): # TODO: Do we need to cache these functions so that we don't end up # compiling the same Numba function over and over again? - scalar_func_name = op.nfunc_spec[0] + scalar_func_path = op.nfunc_spec[0] + scalar_func_numba = None - if scalar_func_name.startswith("scipy."): - func_package = scipy - scalar_func_name = scalar_func_name.split(".", 1)[-1] - else: - func_package = np + *module_path, scalar_func_name = scalar_func_path.split(".") + if not module_path: + # Assume it is numpy, and numba has an implementation + scalar_func_numba = getattr(np, scalar_func_name) - if "." in scalar_func_name: - scalar_func = reduce(getattr, [scipy] + scalar_func_name.split(".")) - else: - scalar_func = getattr(func_package, scalar_func_name) + input_dtypes = [np.dtype(input.type.dtype) for input in node.inputs] + output_dtypes = [np.dtype(output.type.dtype) for output in node.outputs] + + if len(output_dtypes) != 1: + raise ValueError("ScalarOps with more than one output are not supported") + + output_dtype = output_dtypes[0] + + input_inner_dtypes = None + output_inner_dtype = None + + # Cython functions might have an additonal argument + has_pyx_skip_dispatch = False + + if scalar_func_path.startswith("scipy.special"): + import scipy.special.cython_special + + cython_func = getattr(scipy.special.cython_special, scalar_func_name, None) + if cython_func is not None: + # try: + scalar_func_numba = wrap_cython_function( + cython_func, output_dtype, input_dtypes + ) + has_pyx_skip_dispatch = scalar_func_numba.has_pyx_skip_dispatch + input_inner_dtypes = scalar_func_numba.numpy_arg_dtypes() + output_inner_dtype = scalar_func_numba.numpy_output_dtype() + # except NotImplementedError: + # pass - scalar_op_fn_name = get_name_for_object(scalar_func) + if scalar_func_numba is None: + scalar_func_numba = generate_fallback_impl(op, node, **kwargs) + + scalar_op_fn_name = get_name_for_object(scalar_func_numba) unique_names = unique_name_generator( - [scalar_op_fn_name, "scalar_func"], suffix_sep="_" + [scalar_op_fn_name, "scalar_func_numba"], suffix_sep="_" ) - global_env = {"scalar_func": scalar_func} + global_env = {"scalar_func_numba": scalar_func_numba} - input_tmp_dtypes = None - if func_package == scipy and hasattr(scalar_func, "types"): - # The `numba-scipy` bindings don't provide implementations for all - # inputs types, so we need to convert the inputs to floats and back. - inp_dtype_kinds = tuple(np.dtype(inp.type.dtype).kind for inp in node.inputs) - accepted_inp_kinds = tuple( - sig_type.split("->")[0] for sig_type in scalar_func.types - ) - if not any( - all(dk == ik for dk, ik in zip(inp_dtype_kinds, ok_kinds)) - for ok_kinds in accepted_inp_kinds - ): - # They're usually ordered from lower-to-higher precision, so - # we pick the last acceptable input types - # - # XXX: We should pick the first acceptable float/int types in - # reverse, excluding all the incompatible ones (e.g. `"0"`). - # The assumption is that this is only used by `numba-scipy`-exposed - # functions, although it's possible for this to be triggered by - # something else from the `scipy` package - input_tmp_dtypes = tuple(np.dtype(k) for k in accepted_inp_kinds[-1]) - - if input_tmp_dtypes is None: + if input_inner_dtypes is None and output_inner_dtype is None: unique_names = unique_name_generator( - [scalar_op_fn_name, "scalar_func"], suffix_sep="_" + [scalar_op_fn_name, "scalar_func_numba"], suffix_sep="_" ) input_names = ", ".join( [unique_names(v, force_unique=True) for v in node.inputs] ) - scalar_op_src = f""" + if not has_pyx_skip_dispatch: + scalar_op_src = f""" def {scalar_op_fn_name}({input_names}): - return scalar_func({input_names}) - """ + return scalar_func_numba({input_names}) + """ + else: + scalar_op_src = f""" +def {scalar_op_fn_name}({input_names}): + return scalar_func_numba({input_names}, np.intc(1)) + """ + else: global_env["direct_cast"] = numba_basic.direct_cast - global_env["output_dtype"] = np.dtype(node.outputs[0].type.dtype) + global_env["output_dtype"] = np.dtype(output_inner_dtype) input_tmp_dtype_names = { - f"inp_tmp_dtype_{i}": i_dtype for i, i_dtype in enumerate(input_tmp_dtypes) + f"inp_tmp_dtype_{i}": i_dtype + for i, i_dtype in enumerate(input_inner_dtypes) } global_env.update(input_tmp_dtype_names) unique_names = unique_name_generator( - [scalar_op_fn_name, "scalar_func"] + list(global_env.keys()), suffix_sep="_" + [scalar_op_fn_name, "scalar_func_numba"] + list(global_env.keys()), + suffix_sep="_", ) input_names = [unique_names(v, force_unique=True) for v in node.inputs] @@ -110,10 +126,16 @@ def {scalar_op_fn_name}({input_names}): ) ] ) - scalar_op_src = f""" + if not has_pyx_skip_dispatch: + scalar_op_src = f""" def {scalar_op_fn_name}({', '.join(input_names)}): - return direct_cast(scalar_func({converted_call_args}), output_dtype) - """ + return direct_cast(scalar_func_numba({converted_call_args}), output_dtype) + """ + else: + scalar_op_src = f""" +def {scalar_op_fn_name}({', '.join(input_names)}): + return direct_cast(scalar_func_numba({converted_call_args}, np.intc(1)), output_dtype) + """ scalar_op_fn = compile_function_src( scalar_op_src, scalar_op_fn_name, {**globals(), **global_env} @@ -122,7 +144,7 @@ def {scalar_op_fn_name}({', '.join(input_names)}): signature = create_numba_signature(node, force_scalar=True) return numba_basic.numba_njit( - signature, inline="always", fastmath=config.numba__fastmath + signature, inline="always", fastmath=config.numba__fastmath, cache=False, )(scalar_op_fn) @@ -220,7 +242,7 @@ def clip(_x, _min, _max): @numba_funcify.register(Composite) def numba_funcify_Composite(op, node, **kwargs): - signature = create_numba_signature(node, force_scalar=True) + signature = create_numba_signature(op.fgraph, force_scalar=True) _ = kwargs.pop("storage_map", None) diff --git a/tests/link/numba/test_cython_support.py b/tests/link/numba/test_cython_support.py new file mode 100644 index 0000000000..c119b2fb62 --- /dev/null +++ b/tests/link/numba/test_cython_support.py @@ -0,0 +1,92 @@ +import numpy as np +import pytest +import scipy.special.cython_special +from numba.types import float32, float64, int32, int64 + +from aesara.link.numba.dispatch.cython_support import Signature, wrap_cython_function + + +@pytest.mark.parametrize( + "sig, expected_result, expected_args", + [ + (b"double(double)", np.float64, [np.float64]), + (b"float(unsigned int)", np.float32, [np.uintc]), + (b"unsigned char(unsigned short foo)", np.ubyte, [np.ushort]), + ( + b"unsigned char(unsigned short foo, double bar)", + np.ubyte, + [np.ushort, np.float64], + ), + ], +) +def test_parse_signature(sig, expected_result, expected_args): + actual = Signature.from_c_types(sig) + assert actual.res_dtype == expected_result + assert actual.arg_dtypes == expected_args + + +@pytest.mark.parametrize( + "have, want, should_provide", + [ + (b"double(int)", b"float(int)", True), + (b"float(int)", b"double(int)", False), + (b"double(unsigned short)", b"double(unsigned char)", True), + (b"double(unsigned char)", b"double(short)", False), + (b"short(double)", b"int(double)", True), + (b"int(double)", b"short(double)", False), + (b"float(double, int)", b"float(double, short)", True), + ], +) +def test_signature_provides(have, want, should_provide): + have = Signature.from_c_types(have) + want = Signature.from_c_types(want) + provides = have.provides(want.res_dtype, want.arg_dtypes) + assert provides == should_provide + + +@pytest.mark.parametrize( + "func, output, inputs, expected", + [ + ( + scipy.special.cython_special.agm, + np.float64, + [np.float64, np.float64], + float64(float64, float64, int32), + ), + ( + scipy.special.cython_special.erfc, + np.float64, + [np.float64], + float64(float64, int32), + ), + ( + scipy.special.cython_special.expit, + np.float32, + [np.float32], + float32(float32, int32), + ), + ( + scipy.special.cython_special.expit, + np.float64, + [np.float64], + float64(float64, int32), + ), + ( + # expn doesn't have a float32 implementation + scipy.special.cython_special.expn, + np.float32, + [np.float32, np.float32], + float64(float64, float64, int32), + ), + ( + # We choose the integer implementation if possible + scipy.special.cython_special.expn, + np.float32, + [np.int64, np.float32], + float64(int64, float64, int32), + ), + ], +) +def test_choose_signature(func, output, inputs, expected): + wrapper = wrap_cython_function(func, output, inputs) + assert wrapper.signature() == expected diff --git a/tests/link/numba/test_elemwise.py b/tests/link/numba/test_elemwise.py index d5457a4733..74ff0556a7 100644 --- a/tests/link/numba/test_elemwise.py +++ b/tests/link/numba/test_elemwise.py @@ -57,6 +57,12 @@ lambda x: at.erfc(x), None, ), + ( + [at.vector()], + [rng.standard_normal(100).astype(config.floatX)], + lambda x: at.erfcx(x), + None, + ), ( [at.vector() for i in range(4)], [rng.standard_normal(100).astype(config.floatX) for i in range(4)],