From ca350832042ed5fc6ce2196dbdd7c4c3e2b4f953 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Tue, 16 Jun 2026 19:11:53 +0200 Subject: [PATCH 01/18] Remove dead frame-walking debug print in Validator.validate The except branch reached into the caller's frame locals to print a validation-failure message. It was a no-op for every in-tree caller and the real message comes from replace_all_validate. --- pytensor/graph/features.py | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/pytensor/graph/features.py b/pytensor/graph/features.py index 29c0b99dc8..df0e8d19ca 100644 --- a/pytensor/graph/features.py +++ b/pytensor/graph/features.py @@ -1,4 +1,3 @@ -import inspect import sys import time import warnings @@ -693,32 +692,8 @@ def on_attach(self, fgraph): ) def validate(self, fgraph): - """ - If the caller is replace_all_validate, just raise the - exception. replace_all_validate will print out the - verbose output. Or it has to be done here before raise. - """ t0 = time.perf_counter() - try: - ret = fgraph.execute_callbacks("on_validate") - except Exception as e: - cf = inspect.currentframe() - uf = cf.f_back - uf_info = inspect.getframeinfo(uf) - - # If the caller is replace_all_validate, just raise the - # exception. replace_all_validate will print out the - # verbose output. - # Or it has to be done here before raise. - if uf_info.function == "replace_all_validate": - raise - else: - verbose = uf.f_locals.get("verbose", False) - if verbose: - r = uf.f_locals.get("r", "") - reason = uf_info.function - print(f"validate failed on node {r}.\n Reason: {reason}, {e}") # noqa: T201 - raise + ret = fgraph.execute_callbacks("on_validate") t1 = time.perf_counter() if fgraph.profile: fgraph.profile.validate_time += t1 - t0 From f679d60cf9abdfabe9b1ce30a786dd2cf807c174 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Tue, 16 Jun 2026 19:16:12 +0200 Subject: [PATCH 02/18] Don't mutate global config.openmp from OpenMPOp codegen The class-level gxx_support_openmp cache already disables OpenMP on every op when the compiler lacks support, so the global write was redundant. --- pytensor/link/c/op.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pytensor/link/c/op.py b/pytensor/link/c/op.py index 2a0170f98d..b67fb9048e 100644 --- a/pytensor/link/c/op.py +++ b/pytensor/link/c/op.py @@ -217,7 +217,6 @@ def update_self_openmp(self) -> None: ) if OpenMPOp.gxx_support_openmp is False: self.openmp = False - config.openmp = False def prepare_node(self, node, storage_map, compute_map, impl): if impl == "c": From 5cd94a50b938fc932493548c73ff0cdee683ac9c Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Tue, 16 Jun 2026 19:35:27 +0200 Subject: [PATCH 03/18] Remove dead, broken profile-sum-at-exit block The 'Sum of all printed profiles at exit' path referenced a nonexistent attribute and called .items() on a string, so it raised whenever more than one profile was summed; it never worked. --- pytensor/compile/debug/profiling.py | 58 ----------------------------- 1 file changed, 58 deletions(-) diff --git a/pytensor/compile/debug/profiling.py b/pytensor/compile/debug/profiling.py index d2556ac486..8770a27432 100644 --- a/pytensor/compile/debug/profiling.py +++ b/pytensor/compile/debug/profiling.py @@ -9,7 +9,6 @@ # import atexit -import copy import logging import operator import sys @@ -54,8 +53,6 @@ def extended_open(filename, mode="r"): def _atexit_print_fn(): """Print `ProfileStat` objects in `_atexit_print_list` to `_atexit_print_file`.""" if config.profile: - to_sum = [] - if config.profiling__destination == "stderr": destination_file = "" elif config.profiling__destination == "stdout": @@ -78,64 +75,9 @@ def _atexit_print_fn(): n_ops_to_print=config.profiling__n_ops, n_apply_to_print=config.profiling__n_apply, ) - - if ps.show_sum: - to_sum.append(ps) else: # TODO print the name if there is one! print("Skipping empty Profile") # noqa: T201 - if len(to_sum) > 1: - # Make a global profile - cum = copy.copy(to_sum[0]) - msg = f"Sum of all({len(to_sum)}) printed profiles at exit." - cum.message = msg - for ps in to_sum[1:]: - for attr in [ - "compile_time", - "fct_call_time", - "fct_callcount", - "vm_call_time", - "rewriter_time", - "linker_time", - "validate_time", - "import_time", - "linker_node_make_thunks", - ]: - setattr(cum, attr, getattr(cum, attr) + getattr(ps, attr)) - - # merge dictionary - for attr in [ - "apply_time", - "apply_callcount", - "apply_cimpl", - "variable_shape", - "variable_strides", - "variable_offset", - "linker_make_thunk_time", - ]: - cum_attr = getattr(cum, attr) - for key, val in getattr(ps, attr.items()): - assert key not in cum_attr, (key, cum_attr) - cum_attr[key] = val - - if cum.rewriter_profile and ps.rewriter_profile: - try: - merge = cum.rewriter_profile[0].merge_profile( - cum.rewriter_profile[1], ps.rewriter_profile[1] - ) - assert len(merge) == len(cum.rewriter_profile[1]) - cum.rewriter_profile = (cum.rewriter_profile[0], merge) - except Exception as e: - print(e) # noqa: T201 - cum.rewriter_profile = None - else: - cum.rewriter_profile = None - - cum.summary( - file=f, - n_ops_to_print=config.profiling__n_ops, - n_apply_to_print=config.profiling__n_apply, - ) if config.print_global_stats: print_global_stats() From 9846ee82038a023c91d78a3a5deb163855b8dd20 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Tue, 16 Jun 2026 19:40:04 +0200 Subject: [PATCH 04/18] Remove GPU-backend leftovers from scan Delete the dead 'traverse' helper (only its GPU branch did anything, and nothing called it) and the unreachable gpudata else-branches in perform; scan outputs are always TensorVariable (asserted in make_thunk). --- pytensor/scan/op.py | 14 ++------------ pytensor/scan/utils.py | 32 -------------------------------- 2 files changed, 2 insertions(+), 44 deletions(-) diff --git a/pytensor/scan/op.py b/pytensor/scan/op.py index a0cb073f54..a2a38f827d 100644 --- a/pytensor/scan/op.py +++ b/pytensor/scan/op.py @@ -1960,12 +1960,8 @@ def perform(self, node, inputs, output_storage): if var is None: old_inner_output_data[idx] = None - elif isinstance(self.fn.maker.fgraph.outputs[idx], TensorVariable): - old_inner_output_data[idx] = var.data else: - raise RuntimeError( - "FIXME: old_inner_output_data[idx] = var.gpudata" - ) + old_inner_output_data[idx] = var.data # 4.6. Keep a reference to the variables (ndarrays, # etc) associated with mitmot inputs currently in the @@ -2142,14 +2138,8 @@ def perform(self, node, inputs, output_storage): if old_var is new_var: if old_data is None: output_reused = False - elif isinstance( - self.fn.maker.fgraph.outputs[offset_out + j], TensorVariable - ): - output_reused = new_var.data == old_data else: - raise RuntimeError( - "FIXME: output_reused = new_var.gpudata == old_data" - ) + output_reused = new_var.data == old_data else: output_reused = False diff --git a/pytensor/scan/utils.py b/pytensor/scan/utils.py index a999979f12..afcd245f68 100644 --- a/pytensor/scan/utils.py +++ b/pytensor/scan/utils.py @@ -169,38 +169,6 @@ def summary_function(self, file): print("", file=file) -def traverse(out, x, x_copy, d, visited=None): - """ - Function used by scan to parse the tree and figure out which nodes - it needs to replace. - - There are two options : - 1) x and x_copy or on host, then you would replace x with x_copy - - """ - # ``visited`` is a set of nodes that are already known and don't need to be - # checked again, speeding up the traversal of multiply-connected graphs. - # if a ``visited`` set is given, it will be updated in-place so the callee - # knows which nodes we have seen. - if visited is None: - visited = set() - if out in visited: - return d - visited.add(out) - - if out == x: - # assert isinstance(x.type, GpuArrayType) - # d[out] = GpuFromHost(x.type.context_name)(x_copy) - # return d - raise RuntimeError("Not supported") - elif out.owner is None: - return d - else: - for inp in out.owner.inputs: - d = traverse(inp, x, x_copy, d, visited) - return d - - def expand_empty(tensor_var, size): """ Transforms the shape of a tensor from (d1, d2 ... ) to ( d1+size, d2, ..) From e00b1b731ba97b8aa7dad067f80a691d7d2886ac Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Tue, 16 Jun 2026 19:44:37 +0200 Subject: [PATCH 05/18] Remove dead GPU-era Type.filter_inplace hook No Type overrode filter_inplace; it only ever raised NotImplementedError, so Container.__set__ raised and caught it on every container value set (e.g. shared.set_value) before falling back to filter. Call filter directly. --- pytensor/graph/type.py | 28 ---------------------------- pytensor/link/basic.py | 8 +------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/pytensor/graph/type.py b/pytensor/graph/type.py index a9a961564c..f165d2f258 100644 --- a/pytensor/graph/type.py +++ b/pytensor/graph/type.py @@ -96,34 +96,6 @@ def filter( """ - def filter_inplace( - self, - value: Any, - storage: Any, - strict: bool = False, - allow_downcast: bool | None = None, - ): - """Return data or an appropriately wrapped/converted data by converting it in-place. - - This method allows one to reuse old allocated memory. If this method - is implemented, it will be called instead of `Type.filter`. - - As of now, this method is not implemented and was previously used for transferring memory to and from GPU. - - Parameters - ---------- - value: array-like - storage: array-like - The old value (e.g. the old NumPy array) - strict: bool - allow_downcast: bool (optional) - - Raises - ------ - NotImplementedError - """ - raise NotImplementedError() - def filter_variable( self, other: Variable | D, allow_convert: bool = True ) -> variable_type: diff --git a/pytensor/link/basic.py b/pytensor/link/basic.py index f42e8faf94..80e57afff1 100644 --- a/pytensor/link/basic.py +++ b/pytensor/link/basic.py @@ -97,13 +97,7 @@ def __set__(self, value: Any) -> None: if self.allow_downcast is not None: kwargs["allow_downcast"] = self.allow_downcast - try: - # Use in-place filtering when/if possible - self.storage[0] = self.type.filter_inplace( - value, self.storage[0], **kwargs - ) - except NotImplementedError: - self.storage[0] = self.type.filter(value, **kwargs) + self.storage[0] = self.type.filter(value, **kwargs) except Exception as e: e.args = (*e.args, f'Container name "{self.name}"') From 7379854fc2a077b2def61d0d7fb695f5a61a66be Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Tue, 16 Jun 2026 21:13:10 +0200 Subject: [PATCH 06/18] Remove dead gcc_llvm detector and Python-2 'SO' suffix fallback gcc_llvm() had no callers (along with its GCCLLVMType typing scaffold); EXT_SUFFIX is always present on supported Python versions, so the 'SO' fallback in _get_ext_suffix is dead. PyPy branch left untouched. --- pytensor/link/c/cmodule.py | 41 +------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/pytensor/link/c/cmodule.py b/pytensor/link/c/cmodule.py index 5004df93fe..e60b16d63d 100644 --- a/pytensor/link/c/cmodule.py +++ b/pytensor/link/c/cmodule.py @@ -58,15 +58,6 @@ def is_StdLibDirsAndLibsType( return cast(StdLibDirsAndLibsType, fn) -class GCCLLVMType(Protocol): - is_llvm: bool | None - __call__: Callable[[], bool | None] - - -def is_GCCLLVMType(fn: Callable[[], bool | None]) -> GCCLLVMType: - return cast(GCCLLVMType, fn) - - _logger = logging.getLogger("pytensor.link.c.cmodule") METH_VARARGS = "METH_VARARGS" @@ -262,10 +253,7 @@ def _get_ext_suffix(): """Get the suffix for compiled extensions""" from setuptools._distutils.sysconfig import get_config_var - dist_suffix = get_config_var("EXT_SUFFIX") - if dist_suffix is None: - dist_suffix = get_config_var("SO") - return dist_suffix + return get_config_var("EXT_SUFFIX") def add_gcc_dll_directory() -> AbstractContextManager[None]: @@ -1817,33 +1805,6 @@ def std_lib_dirs(): return std_lib_dirs_and_libs()[1] -@is_GCCLLVMType -def gcc_llvm() -> bool | None: - """ - Detect if the g++ version used is the llvm one or not. - - It don't support all g++ parameters even if it support many of them. - - """ - if gcc_llvm.is_llvm is None: - try: - p_out = output_subprocess_Popen([config.cxx, "--version"]) - output = p_out[0] + p_out[1] - except OSError: - # Typically means g++ cannot be found. - # So it is not an llvm compiler. - - # Normally this should not happen as we should not try to - # compile when g++ is not available. If this happen, it - # will crash later so supposing it is not llvm is "safe". - output = b"" - gcc_llvm.is_llvm = b"llvm" in output - return gcc_llvm.is_llvm - - -gcc_llvm.is_llvm = None - - class Compiler: """ Meta compiler that offer some generic function. From 5baeb4642f17860cac1044be0b8df008e518a064 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 10:45:42 +0200 Subject: [PATCH 07/18] Remove dead scalar and printing helpers Delete unused scalar helpers (int_out_nocomplex, float_out_nocomplex, unary_out_lookup, as_common_dtype, Cast.clone_float32 from the old float16/GPU codegen protocol) and the dead printing trio (var_descriptor/position_independent_str/hex_digest), which only called each other and were referenced nowhere. --- pytensor/printing.py | 81 ---------------------------------------- pytensor/scalar/basic.py | 60 ----------------------------- 2 files changed, 141 deletions(-) diff --git a/pytensor/printing.py b/pytensor/printing.py index cd9b9bab1f..3da5c6a121 100644 --- a/pytensor/printing.py +++ b/pytensor/printing.py @@ -1,6 +1,5 @@ """Functions for printing PyTensor graphs.""" -import hashlib import logging import sys from abc import ABC, abstractmethod @@ -2532,86 +2531,6 @@ def min_informative_str( return rval -def var_descriptor(obj, _prev_obs: dict | None = None, _tag_generator=None) -> str: - """ - Returns a string, with no endlines, fully specifying - how a variable is computed. Does not include any memory - location dependent information such as the id of a node. - """ - if _prev_obs is None: - _prev_obs = {} - - if id(obj) in _prev_obs: - tag = _prev_obs[id(obj)] - - return "<" + tag + ">" - - if _tag_generator is None: - _tag_generator = _TagGenerator() - - cur_tag = _tag_generator.get_tag() - - _prev_obs[id(obj)] = cur_tag - - if hasattr(obj, "__array__"): - # hashlib hashes only the contents of the buffer, but - # it can have different semantics depending on the strides - # of the ndarray - name = "" - elif hasattr(obj, "owner") and obj.owner is not None: - name = str(obj.owner.op) + "(" - name += ",".join( - var_descriptor(ipt, _prev_obs=_prev_obs, _tag_generator=_tag_generator) - for ipt in obj.owner.inputs - ) - name += ")" - elif hasattr(obj, "name") and obj.name is not None: - # Only print the name if there is no owner. - # This way adding a name to an intermediate node can't make - # a deeper graph get the same descriptor as a shallower one - name = obj.name - else: - name = str(obj) - if " at 0x" in name: - # The __str__ method is encoding the object's id in its str - name = position_independent_str(obj) - if " at 0x" in name: - raise AssertionError(name) - - prefix = cur_tag + "=" - - rval = prefix + name - - return rval - - -def position_independent_str(obj) -> str: - if isinstance(obj, Variable): - rval = "pytensor_var" - rval += "{type=" + str(obj.type) + "}" - else: - raise NotImplementedError() - - return rval - - -def hex_digest(x: np.ndarray) -> str: - """ - Returns a short, mostly hexadecimal hash of a numpy ndarray - """ - assert isinstance(x, np.ndarray) - rval = hashlib.sha256(x.tobytes()).hexdigest() - # hex digest must be annotated with strides to avoid collisions - # because the buffer interface only exposes the raw data, not - # any info about the semantics of how that data should be arranged - # into a tensor - rval += "|strides=[" + ",".join(str(stride) for stride in x.strides) + "]" - rval += "|shape=[" + ",".join(str(s) for s in x.shape) + "]" - return rval - - def get_node_by_id( graphs: Variable | Sequence[Variable] | Function | FunctionGraph, target_var_id: str, diff --git a/pytensor/scalar/basic.py b/pytensor/scalar/basic.py index 08375ac0bc..682af9dbf2 100644 --- a/pytensor/scalar/basic.py +++ b/pytensor/scalar/basic.py @@ -90,14 +90,6 @@ def make_array(dt): return rval -def as_common_dtype(*vars): - """ - For for pytensor.scalar.ScalarType and TensorVariable. - """ - dtype = upcast(*[v.dtype for v in vars]) - return (v.astype(dtype) for v in vars) - - class NumpyAutocaster: """ This class is used to cast python ints and floats to numpy arrays. @@ -1139,53 +1131,6 @@ def same_out_nocomplex(type): return (type,) -def int_out_nocomplex(*types): - for type in types: - if type in complex_types: - raise TypeError("complex argument not supported") - return (int64,) - - -def float_out_nocomplex(*types): - for type in types: - if type in complex_types: - raise TypeError("complex argument not supported") - return (float64,) - - -class unary_out_lookup(MetaObject): - """ - Get a output_types_preference object by passing a dictionary: - - unary_out_lookup({int8:int32, float32:complex128}) - - The result is an op that maps in8 to int32 and float32 to - complex128 and other input types lead to a TypeError. - - """ - - def __init__(self, type_table): - self.tbl = type_table - - def __call__(self, *types): - if len(types) == 1: - types = types[0] - try: - rval = self.tbl[types] - except Exception: - raise TypeError(types) - if isinstance(types, list | tuple): - return rval - else: - return [rval] - - def __eq__(self, other): - return type(self) is type(other) and self.tbl == other.tbl - - def __hash__(self): - return hash(type(self)) # ignore hash of table - - def real_out(type): if type == complex64: return (float32,) @@ -2499,11 +2444,6 @@ def __init__(self, o_type, name=None): def __str__(self): return f"{self.__class__.__name__}{{{self.o_type.dtype}}}" - def clone_float32(self): - if self.o_type == float16: - return convert_to_float32 - return self - def impl(self, input): return self.ctor(input) From cc9621ec588b5366e13929e174bd1e98b772ded8 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 10:51:59 +0200 Subject: [PATCH 08/18] Remove dead config flags warn__ignore_bug_before and metaopt__verbose Neither was wired up: _warn_default (the only reader of warn__ignore_bug_before) had no callers, metaopt__verbose was never registered, and the cast_policy 'numpy' branch was unreachable since that enum value is disabled. Drop the helpers, annotations, the dead cast branch, and the stale docs. --- .github/workflows/test.yml | 4 +-- conftest.py | 2 +- doc/internal/how_to_release.rst | 4 --- doc/library/config.rst | 33 ------------------ pytensor/configdefaults.py | 61 --------------------------------- pytensor/configparser.py | 3 -- pytensor/scalar/basic.py | 4 +-- 7 files changed, 4 insertions(+), 107 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b7e27eb6a..9ab5172598 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -193,7 +193,7 @@ jobs: run: | if [[ $DEFAULT_MODE == "FAST_COMPILE" ]]; then export PYTENSOR_FLAGS=$PYTENSOR_FLAGS,mode=FAST_COMPILE; fi if [[ $DEFAULT_MODE == "CVM" ]]; then export PYTENSOR_FLAGS=$PYTENSOR_FLAGS,linker=cvm; fi - export PYTENSOR_FLAGS=$PYTENSOR_FLAGS,warn__ignore_bug_before=all,on_opt_error=raise,on_shape_error=raise,gcc__cxxflags=-pipe + export PYTENSOR_FLAGS=$PYTENSOR_FLAGS,on_opt_error=raise,on_shape_error=raise,gcc__cxxflags=-pipe python -m pytest -r A --verbose --runslow --durations=50 --cov=pytensor/ --cov-report=xml:coverage/coverage-${MATRIX_ID}.xml --no-cov-on-fail $PART --benchmark-skip env: MATRIX_ID: ${{ steps.matrix-id.outputs.id }} @@ -248,7 +248,7 @@ jobs: - name: Run benchmarks shell: micromamba-shell {0} run: | - export PYTENSOR_FLAGS=warn__ignore_bug_before=all,on_opt_error=raise,on_shape_error=raise,gcc__cxxflags=-pipe + export PYTENSOR_FLAGS=on_opt_error=raise,on_shape_error=raise,gcc__cxxflags=-pipe python -m pytest --runslow --benchmark-only --benchmark-json output.json - name: Store benchmark result uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 diff --git a/conftest.py b/conftest.py index 74efb33e44..14ceca72fd 100644 --- a/conftest.py +++ b/conftest.py @@ -11,7 +11,7 @@ def pytest_sessionstart(session): os.environ["PYTENSOR_FLAGS"] = ",".join( [ os.environ.setdefault("PYTENSOR_FLAGS", ""), - "warn__ignore_bug_before=all,on_opt_error=raise,on_shape_error=raise,cmodule__warn_no_version=True", + "on_opt_error=raise,on_shape_error=raise,cmodule__warn_no_version=True", ] ) os.environ["NUMBA_BOUNDSCHECK"] = "1" diff --git a/doc/internal/how_to_release.rst b/doc/internal/how_to_release.rst index 0b481bbbbf..aaa7798ff9 100644 --- a/doc/internal/how_to_release.rst +++ b/doc/internal/how_to_release.rst @@ -13,10 +13,6 @@ Update the version number Update the year in the ``PyTensor/LICENSE.txt`` file too, if necessary. -Update the code and the documentation for the pytensor flags -``warn__ignore_bug_before`` to accept the new version. You must modify the -file ``pytensor/configdefaults.py`` and ``doc/library/config.txt``. - Tag the release =============== diff --git a/doc/library/config.rst b/doc/library/config.rst index 6053ce107f..535fe405f9 100644 --- a/doc/library/config.rst +++ b/doc/library/config.rst @@ -405,31 +405,6 @@ import ``pytensor`` and print the config variable, as in: raise the exception (i.e. ``'raise'``). -.. attribute:: config.warn__ignore_bug_before - - String value: ``'None'``, ``'all'``, ``'0.3'``, ``'0.4'``, ``'0.4.1'``, - ``'0.5'``, ``'0.6'``, ``'0.7'``, ``'0.8'``, ``'0.8.1'``, ``'0.8.2'``, - ``'0.9'``, ``'0.10'``, ``'1.0'``, ``'1.0.1'``, ``'1.0.2'``, ``'1.0.3'``, - ``'1.0.4'``,``'1.0.5'`` - - Default: ``'0.9'`` - - When we an PyTensor bug that generated a bad result is fixed, we also make - PyTensor raise a warning when it encounters the same circumstances again. This - helps users determine whether or not said bug has affected past runs, since - one only needs to perform the same runs again with the new version, and one - does not have to understand the PyTensor internals that triggered the bug. - - This flag lets users ignore warnings about old bugs that were - fixed before their first checkout of PyTensor. - You can set its value to the first version of PyTensor - that you used (probably 0.3 or higher) - - ``'None'`` means that all warnings will be displayed. - ``'all'`` means all warnings will be ignored. - - This flag's value cannot be modified during program execution. - .. attribute:: base_compiledir Default: On Windows: ``$LOCALAPPDATA\\PyTensor`` if ``$LOCALAPPDATA`` is defined, @@ -734,11 +709,3 @@ import ``pytensor`` and print the config variable, as in: The number of traceback stack levels to keep for variables during PyTensor compilation. When this value is greater than zero, it will make PyTensor keep internal stack traces. - -.. attribute:: config.metaopt__verbose - - Int value, default: 0 - - The verbosity level of the meta-rewriter: ``0`` for silent, ``1`` to only - warn when PyTensor cannot meta-rewrite an :class:`Op`, ``2`` for full output (e.g. - timings and the rewrites selected). diff --git a/pytensor/configdefaults.py b/pytensor/configdefaults.py index e5a761fd5f..ff6fe05838 100644 --- a/pytensor/configdefaults.py +++ b/pytensor/configdefaults.py @@ -65,26 +65,6 @@ def _filter_mode(val): ) -def _split_version(version): - """ - Take version as a dot-separated string, return a tuple of int - """ - return tuple(int(i) for i in version.split(".")) - - -def _warn_default(version): - """ - Return True iff we should warn about bugs fixed after a given version. - """ - if config.warn__ignore_bug_before == "None": - return True - if config.warn__ignore_bug_before == "all": - return False - if _split_version(config.warn__ignore_bug_before) >= _split_version(version): - return False - return True - - def _is_valid_check_preallocated_output_param(param): if not isinstance(param, str): return False @@ -591,47 +571,6 @@ def add_traceback_configvars(): def add_error_and_warning_configvars(): - ### - # To disable some warning about old bug that are fixed now. - ### - config.add( - "warn__ignore_bug_before", - ( - "If 'None', we warn about all PyTensor bugs found by default. " - "If 'all', we don't warn about PyTensor bugs found by default. " - "If a version, we print only the warnings relative to PyTensor " - "bugs found after that version. " - "Warning for specific bugs can be configured with specific " - "[warn] flags." - ), - EnumStr( - "0.9", - [ - "None", - "all", - "0.3", - "0.4", - "0.4.1", - "0.5", - "0.6", - "0.7", - "0.8", - "0.8.1", - "0.8.2", - "0.9", - "0.10", - "1.0", - "1.0.1", - "1.0.2", - "1.0.3", - "1.0.4", - "1.0.5", - ], - mutable=False, - ), - in_c_key=False, - ) - # Note to developers: # Generally your exceptions should use an apply node's __str__ # method when exception_verbosity == 'low'. When exception_verbosity diff --git a/pytensor/configparser.py b/pytensor/configparser.py index 4d2f4b98b1..c08c8a9f71 100644 --- a/pytensor/configparser.py +++ b/pytensor/configparser.py @@ -101,7 +101,6 @@ class PyTensorConfigParser: traceback__limit: int traceback__compile_limit: int # add_error_and_warning_configvars - warn__ignore_bug_before: int exception_verbosity: str # add_testvalue_and_checking_configvars check_input: bool @@ -138,8 +137,6 @@ class PyTensorConfigParser: optdb__max_use_ratio: float cycle_detection: str check_stack_trace: str - # add_metaopt_configvars - metaopt__verbose: int # add_vm_configvars profile: bool profile_optimizer: bool diff --git a/pytensor/scalar/basic.py b/pytensor/scalar/basic.py index 682af9dbf2..af54c41e07 100644 --- a/pytensor/scalar/basic.py +++ b/pytensor/scalar/basic.py @@ -130,9 +130,7 @@ def __call__(self, x): isinstance(x, np.ndarray) and x.ndim == 0 ) - if config.cast_policy == "numpy": - return np.asarray(x) - elif config.cast_policy == "numpy+floatX": + if config.cast_policy == "numpy+floatX": rval = np.asarray(x) if ( not hasattr(x, "dtype") From 4451c413e243a863ffc645e440272f22472e43ec Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 10:57:37 +0200 Subject: [PATCH 09/18] Remove deprecated end/steps arguments from linspace/geomspace/logspace They were deprecated in July 2024 (gh #748) and have no in-repo callers; drop the end/steps parameters, the _check_deprecated_inputs helper, and the related docstrings/tests. --- pytensor/tensor/extra_ops.py | 65 ------------------------------------ 1 file changed, 65 deletions(-) diff --git a/pytensor/tensor/extra_ops.py b/pytensor/tensor/extra_ops.py index 34542e9bdb..52fab8672f 100644 --- a/pytensor/tensor/extra_ops.py +++ b/pytensor/tensor/extra_ops.py @@ -1,4 +1,3 @@ -import warnings from collections.abc import Collection, Iterable, Sequence from textwrap import dedent @@ -1586,23 +1585,6 @@ def broadcast_shape_iter( return tuple(result_dims) -def _check_deprecated_inputs(stop, end, num, steps): - if end is not None: - warnings.warn( - "The 'end' parameter is deprecated and will be removed in a future version. Use 'stop' instead.", - DeprecationWarning, - ) - stop = end - if steps is not None: - warnings.warn( - "The 'steps' parameter is deprecated and will be removed in a future version. Use 'num' instead.", - DeprecationWarning, - ) - num = steps - - return stop, num - - def _linspace_core( start: TensorVariable, stop: TensorVariable, @@ -1673,8 +1655,6 @@ def linspace( retstep: bool = False, dtype: str | None = None, axis: int = 0, - end: TensorLike | None = None, - steps: TensorLike | None = None, ) -> TensorVariable | tuple[TensorVariable, TensorVariable]: """ Return evenly spaced numbers over a specified interval. @@ -1709,19 +1689,6 @@ def linspace( Axis along which to generate samples. Ignored if both `start` and `end` have dimension 0. By default, axis=0 will insert the samples on a new left-most dimension. To insert samples on a right-most dimension, use axis=-1. - end: int, float or TensorVariable - .. warning:: - The "end" parameter is deprecated and will be removed in a future version. Use "stop" instead. - The end value of the sequence, unless `endpoint` is set to False. - In that case, the sequence consists of all but the last of `num + 1` evenly spaced samples, such that `end` is - excluded. - - steps: float, int, or TensorVariable - .. warning:: - The "steps" parameter is deprecated and will be removed in a future version. Use "num" instead. - - Number of samples to generate. Must be non-negative - Returns ------- samples: TensorVariable @@ -1732,7 +1699,6 @@ def linspace( """ if dtype is None: dtype = pytensor.config.floatX - end, num = _check_deprecated_inputs(stop, end, num, steps) start, stop = broadcast_arrays(start, stop) ls = _linspace_core( @@ -1755,8 +1721,6 @@ def geomspace( endpoint: bool = True, dtype: str | None = None, axis: int = 0, - end: TensorLike | None = None, - steps: TensorLike | None = None, ) -> TensorVariable: """ Return numbers spaced evenly on a log scale (a geometric progression). @@ -1796,19 +1760,6 @@ def geomspace( Axis along which to generate samples. Ignored if both `start` and `end` have dimension 0. By default, axis=0 will insert the samples on a new left-most dimension. To insert samples on a right-most dimension, use axis=-1. - end: int, float or TensorVariable - .. warning:: - The "end" parameter is deprecated and will be removed in a future version. Use "stop" instead. - The end value of the sequence, unless `endpoint` is set to False. - In that case, the sequence consists of all but the last of `num + 1` evenly spaced samples, such that `end` is - excluded. - - steps: float, int, or TensorVariable - .. warning:: - The "steps" parameter is deprecated and will be removed in a future version. Use "num" instead. - - Number of samples to generate. Must be non-negative - Returns ------- samples: TensorVariable @@ -1817,7 +1768,6 @@ def geomspace( """ if dtype is None: dtype = pytensor.config.floatX - stop, num = _check_deprecated_inputs(stop, end, num, steps) start, stop = broadcast_arrays(start, stop) start, stop, base = _broadcast_base_with_inputs(start, stop, base, axis) @@ -1856,8 +1806,6 @@ def logspace( endpoint: bool = True, dtype: str | None = None, axis: int = 0, - end: TensorLike | None = None, - steps: TensorLike | None = None, ) -> TensorVariable: """ Return numbers spaced evenly on a log scale. @@ -1892,18 +1840,6 @@ def logspace( Axis along which to generate samples. Ignored if both `start` and `end` have dimension 0. By default, axis=0 will insert the samples on a new left-most dimension. To insert samples on a right-most dimension, use axis=-1. - end: int float or TensorVariable - .. warning:: - The "end" parameter is deprecated and will be removed in a future version. Use "stop" instead. - The end value of the sequence, unless `endpoint` is set to False. - In that case, the sequence consists of all but the last of `num + 1` evenly spaced samples, such that `end` is - excluded. - - steps: int or TensorVariable - .. warning:: - The "steps" parameter is deprecated and will be removed in a future version. Use "num" instead. - Number of samples to generate. Must be non-negative - Returns ------- samples: TensorVariable @@ -1912,7 +1848,6 @@ def logspace( """ if dtype is None: dtype = pytensor.config.floatX - stop, num = _check_deprecated_inputs(stop, end, num, steps) start, stop = broadcast_arrays(start, stop) start, stop, base = _broadcast_base_with_inputs(start, stop, base, axis) From dbb45c98d129d02956874a7c1fcc0cd7f8268f41 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 11:00:02 +0200 Subject: [PATCH 10/18] Fix CachedEquilibrimDB cache never invalidating on register register() cleared self.cached_default_query (a junk attribute) instead of self._cached_default_query, so rewrites registered after the first default_query access were silently dropped from the cached query. --- pytensor/tensor/basic.py | 2 +- tests/tensor/test_basic.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pytensor/tensor/basic.py b/pytensor/tensor/basic.py index d2da548148..2921d8e0c3 100644 --- a/pytensor/tensor/basic.py +++ b/pytensor/tensor/basic.py @@ -1466,7 +1466,7 @@ def __init__(self, default_query): def register(self, *args, **kwargs): # If new rewrites are registered, the default cached query is void - self.cached_default_query = None + self._cached_default_query = None super().register(*args, **kwargs) @property diff --git a/tests/tensor/test_basic.py b/tests/tensor/test_basic.py index 2b250b6284..7ac972268c 100644 --- a/tests/tensor/test_basic.py +++ b/tests/tensor/test_basic.py @@ -18,6 +18,8 @@ from pytensor.graph.basic import Apply, equal_computations from pytensor.graph.op import Op from pytensor.graph.replace import clone_replace, vectorize_graph +from pytensor.graph.rewriting.basic import node_rewriter +from pytensor.graph.rewriting.db import RewriteDatabaseQuery from pytensor.graph.traversal import apply_ancestors from pytensor.link.numba import NumbaLinker from pytensor.raise_op import Assert @@ -965,6 +967,28 @@ def test_infer_static_shape(): assert static_shape == (1,) +def test_cached_equilibrium_db_invalidates_on_register(): + # Registering after the default query is cached must drop the cache, so the + # newly registered rewrite is picked up on the next query. + db = ptb.CachedEquilibrimDB(default_query=RewriteDatabaseQuery(include=("tag",))) + + @node_rewriter(None) + def noop(fgraph, node): + return None + + db.register("noop", noop, "tag") + # Populate the cache. + assert db.default_query is not None + assert db._cached_default_query is not None + + @node_rewriter(None) + def noop2(fgraph, node): + return None + + db.register("noop2", noop2, "tag") + assert db._cached_default_query is None + + class TestEye: # This is slow for the ('int8', 3) version. def test_basic(self): From 6e08896689897d2ebc280e28c0d04cc254c0ff9d Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 11:01:53 +0200 Subject: [PATCH 11/18] Fix Elemwise.perform falling through with >32 operands The >32-operand guard called the no-op super().perform() without returning, so execution fell through to the ufunc path that segfaults or raises with that many operands. Raise NotImplementedError instead. --- pytensor/tensor/elemwise.py | 13 +++++++------ tests/tensor/test_elemwise.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/pytensor/tensor/elemwise.py b/pytensor/tensor/elemwise.py index 6366ec42d5..2a3de0a167 100644 --- a/pytensor/tensor/elemwise.py +++ b/pytensor/tensor/elemwise.py @@ -679,12 +679,13 @@ def prepare_node(self, node, storage_map, compute_map, impl): def perform(self, node, inputs, output_storage): if (len(node.inputs) + len(node.outputs)) > 32: - # Some versions of NumPy will segfault, other will raise a - # ValueError, if the number of operands in an ufunc is more than 32. - # In that case, the C version should be used, or Elemwise fusion - # should be disabled. - # FIXME: This no longer calls the C implementation! - super().perform(node, inputs, output_storage) + # Some versions of NumPy will segfault, others will raise a + # ValueError, if the number of operands in a ufunc is more than 32, + # so the Python implementation cannot handle this case. + raise NotImplementedError( + "Elemwise.perform (Python mode) does not support more than 32 " + "operands. Use the C backend or disable Elemwise fusion." + ) self._check_runtime_broadcast(node, inputs) diff --git a/tests/tensor/test_elemwise.py b/tests/tensor/test_elemwise.py index 6b72864763..b161c2b8bc 100644 --- a/tests/tensor/test_elemwise.py +++ b/tests/tensor/test_elemwise.py @@ -1193,3 +1193,20 @@ def test_nfunc_view_workaround(linker): out_eval = f(a_test) np.testing.assert_allclose(out_eval, out_expected) + + +def test_perform_raises_with_too_many_operands(): + # NumPy ufuncs segfault or raise with more than 32 operands, so the Python + # perform must raise cleanly instead of falling through to the ufunc path. + ins = [float64() for _ in range(40)] + out = ins[0] + for v in ins[1:]: + out = scalar_add(out, v) + op = Elemwise(ps.Composite(ins, [out])) + + xs = [vector(f"x{i}") for i in range(40)] + node = op.make_node(*xs) + assert len(node.inputs) + len(node.outputs) > 32 + + with pytest.raises(NotImplementedError, match="more than 32 operands"): + op.perform(node, [np.ones(3) for _ in range(40)], [[None]]) From 95f0b508daa0203782ae2889dd8b2494dc5518aa Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 11:08:38 +0200 Subject: [PATCH 12/18] Fix linker=vm returning a tuple instead of a Mode fast_run_linkers_to_mode['vm'] wrapped the Mode in a 1-tuple, so get_mode('FAST_RUN') with linker=vm returned a tuple. --- pytensor/compile/mode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytensor/compile/mode.py b/pytensor/compile/mode.py index 69e0b1cb85..4c5671e0d7 100644 --- a/pytensor/compile/mode.py +++ b/pytensor/compile/mode.py @@ -455,7 +455,7 @@ def clone(self, link_kwargs=None, optimizer="", **kwargs): C = Mode("c", "fast_run") CVM = Mode("cvm", "fast_run") -VM = (Mode("vm", "fast_run"),) +VM = Mode("vm", "fast_run") NUMBA = Mode( NumbaLinker(), From 1f2255aaee468e48fb363e8813ef6660418d876c Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 11:08:39 +0200 Subject: [PATCH 13/18] Fix misspelled DisconnectedType.filter_variable override It was defined as 'fiter_variable', so the base Type.filter_variable ran instead of the intended guard that rejects assigning to a DisconnectedType. --- pytensor/gradient.py | 2 +- tests/test_gradient.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pytensor/gradient.py b/pytensor/gradient.py index 7eb7150fef..c076fe908d 100644 --- a/pytensor/gradient.py +++ b/pytensor/gradient.py @@ -139,7 +139,7 @@ def filter(self, data, strict=False, allow_downcast=None): " a symbolic placeholder." ) - def fiter_variable(self, other): + def filter_variable(self, other): raise AssertionError( "If you're assigning to a DisconnectedType you're" " doing something wrong. It should only be used as" diff --git a/tests/test_gradient.py b/tests/test_gradient.py index c4ad6b3b22..eb6557f8b1 100644 --- a/tests/test_gradient.py +++ b/tests/test_gradient.py @@ -1191,3 +1191,10 @@ def test_scalar_pullback(): dout_dxtm1 = pullback(xt, wrt=xtm1, cotangents=dout_dxt) assert dout_dxtm1.type == dout_dxt.type assert dout_dxtm1.eval({xtm1: 3.0, dout_dxt: 1.5}) == 2 * 3.0 * 1.5 + + +def test_disconnected_type_filter_variable_raises(): + # Regression: the override was misspelled (fiter_variable), so the base + # Type.filter_variable was used and assignment silently went unguarded. + with pytest.raises(AssertionError, match="DisconnectedType"): + DisconnectedType().filter_variable(vector("x")) From c2bd825af2ac2d87366142920204e1d75f704e5e Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 11:08:40 +0200 Subject: [PATCH 14/18] Fix OpToRewriterTracker double-registering OpPatterns The second isinstance check used 'if' instead of 'elif', so an OpPattern (already handled) also fell into the tracked_instances branch. --- pytensor/graph/rewriting/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytensor/graph/rewriting/basic.py b/pytensor/graph/rewriting/basic.py index 001ccc8162..b181b26b17 100644 --- a/pytensor/graph/rewriting/basic.py +++ b/pytensor/graph/rewriting/basic.py @@ -1104,7 +1104,7 @@ def add_tracker(self, rw: NodeRewriter): else: # An OpPattern without parameters behaves like a regular tracked_type self.tracked_types[c.op_type].append(rw) - if isinstance(c, type): + elif isinstance(c, type): self.tracked_types[c].append(rw) else: self.tracked_instances[c].append(rw) From 6632db9f477da1c3e4f7b5473371274990f6fec9 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Mon, 22 Jun 2026 11:08:40 +0200 Subject: [PATCH 15/18] Fix print_global_stats writing to the filename string print(..., file=destination_file) passed the destination string instead of the open file handle f. --- pytensor/compile/debug/profiling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytensor/compile/debug/profiling.py b/pytensor/compile/debug/profiling.py index 8770a27432..5cf5d64155 100644 --- a/pytensor/compile/debug/profiling.py +++ b/pytensor/compile/debug/profiling.py @@ -101,7 +101,7 @@ def print_global_stats(): destination_file = config.profiling__destination with extended_open(destination_file, mode="w") as f: - print("=" * 50, file=destination_file) + print("=" * 50, file=f) print( ( "Global stats: ", From d30d6ba7fb3bf21269096c0a5dd5ee0eb42e8723 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Thu, 25 Jun 2026 16:36:36 +0200 Subject: [PATCH 16/18] Remove is_same_graph in favor of equal_computations is_same_graph ran two graph-comparison implementations (a deepcopy + MergeOptimizer path and equal_computations) and asserted they agree, with a docstring noting one should eventually be dropped. It was test-only; replace its call sites with equal_computations and delete the function, its merge-based twin, and the dedicated TestIsSameGraph tests. --- pytensor/graph/rewriting/utils.py | 156 ---------------------------- tests/graph/rewriting/test_utils.py | 137 +----------------------- tests/tensor/rewriting/test_math.py | 8 +- tests/tensor/test_subtensor.py | 3 +- 4 files changed, 6 insertions(+), 298 deletions(-) diff --git a/pytensor/graph/rewriting/utils.py b/pytensor/graph/rewriting/utils.py index ea8d8cfd0d..708678aec0 100644 --- a/pytensor/graph/rewriting/utils.py +++ b/pytensor/graph/rewriting/utils.py @@ -1,16 +1,12 @@ -import copy from collections.abc import Generator, Iterable, Sequence from typing import TYPE_CHECKING, Optional, cast -import pytensor from pytensor.graph.basic import ( Apply, Variable, - equal_computations, ) from pytensor.graph.fg import FunctionGraph, Output from pytensor.graph.rewriting.db import RewriteDatabaseQuery -from pytensor.graph.traversal import graph_inputs, vars_between if TYPE_CHECKING: @@ -105,158 +101,6 @@ def rewrite_subgraph( v.index = idx -def is_same_graph_with_merge( - var1: Variable, - var2: Variable, - givens: ( - list[tuple[Variable, Variable]] - | tuple[tuple[Variable, Variable], ...] - | dict[Variable, Variable] - | None - ) = None, -) -> bool: - """ - Merge-based implementation of `pytensor.graph.basic.is_same_graph`. - - See help on `pytensor.graph.basic.is_same_graph` for additional documentation. - - """ - from pytensor.graph.rewriting.basic import MergeOptimizer - - givens = {} if givens is None else dict(givens) - - # Copy variables since the MergeOptimizer will modify them. - *vars, givens = copy.deepcopy((var1, var2, givens)) - # Create FunctionGraph. - inputs = list(graph_inputs(vars)) - # The clone isn't needed as we did a deepcopy and we cloning will - # break the mapping in givens. - fgraph = pytensor.graph.fg.FunctionGraph(inputs, vars, clone=False) - # Perform Variable substitution. - for to_replace, replace_by in givens.items(): - fgraph.replace(to_replace, replace_by) - # Perform merge optimization. - MergeOptimizer().rewrite(fgraph) - # When two variables perform the same computations, they will have the same - # owner in the rewritten graph. - # We need to be careful with the special case where the owner is None, - # which happens when the graph is made of a single Variable. - # We also need to make sure we replace a Variable if it is present in - # `givens`. - vars_replaced = [givens.get(v, v) for v in fgraph.outputs] - o1, o2 = (v.owner for v in vars_replaced) - if o1 is None and o2 is None: - # Comparing two single-Variable graphs: they are equal if they are - # the same Variable. - return vars_replaced[0] == vars_replaced[1] - return o1 is o2 - - -def is_same_graph( - var1: Variable, - var2: Variable, - givens: ( - list[tuple[Variable, Variable]] - | tuple[tuple[Variable, Variable], ...] - | dict[Variable, Variable] - | None - ) = None, -) -> bool: - """ - Return True iff Variables `var1` and `var2` perform the same computation. - - By 'performing the same computation', we mean that they must share the same - graph, so that for instance this function will return False when comparing - (x * (y * z)) with ((x * y) * z). - - The current implementation is not efficient since, when possible, it - verifies equality by calling two different functions that are expected to - return the same output. The goal is to verify this assumption, to - eventually get rid of one of them in the future. - - Parameters - ---------- - var1 - The first Variable to compare. - var2 - The second Variable to compare. - givens - Similar to the `givens` argument of `pytensor.function`, it can be used - to perform substitutions in the computational graph of `var1` and - `var2`. This argument is associated to neither `var1` nor `var2`: - substitutions may affect both graphs if the substituted variable - is present in both. - - Examples - -------- - - ====== ====== ====== ====== - var1 var2 givens output - ====== ====== ====== ====== - x + 1 x + 1 {} True - x + 1 y + 1 {} False - x + 1 y + 1 {x: y} True - ====== ====== ====== ====== - - """ - givens = {} if givens is None else dict(givens) - - # Get result from the merge-based function. - rval1 = is_same_graph_with_merge(var1=var1, var2=var2, givens=givens) - - if not givens: - rval2 = equal_computations(xs=[var1], ys=[var2]) - assert rval1 == rval2 - return rval1 - - # We need to build the `in_xs` and `in_ys` lists. To do this, we need - # to be able to tell whether a variable belongs to the computational - # graph of `var1` or `var2`. - # The typical case we want to handle is when `to_replace` belongs to - # one of these graphs, and `replace_by` belongs to the other one. In - # other situations, the current implementation of `equal_computations` - # is probably not appropriate, so we do not call it. - use_equal_computations = True - in_xs = [] - in_ys = [] - # Compute the sets of all variables found in each computational graph. - inputs_var1 = graph_inputs([var1]) - inputs_var2 = graph_inputs([var2]) - all_vars1 = set(vars_between(inputs_var1, [var1])) - all_vars2 = set(vars_between(inputs_var2, [var2])) - - for to_replace, replace_by in givens.items(): - # Map a substitution variable to the computational graphs it - # belongs to. - inside = {v: [v in all_vars1, v in all_vars2] for v in (to_replace, replace_by)} - if ( - inside[to_replace][0] - and not inside[to_replace][1] - and inside[replace_by][1] - and not inside[replace_by][0] - ): - # Substitute variable in `var1` by one from `var2`. - in_xs.append(to_replace) - in_ys.append(replace_by) - elif ( - inside[to_replace][1] - and not inside[to_replace][0] - and inside[replace_by][0] - and not inside[replace_by][1] - ): - # Substitute variable in `var2` by one from `var1`. - in_xs.append(replace_by) - in_ys.append(to_replace) - else: - use_equal_computations = False - break - - if use_equal_computations: - rval2 = equal_computations(xs=[var1], ys=[var2], in_xs=in_xs, in_ys=in_ys) - assert rval2 == rval1 - return rval1 - - def get_clients_at_depth( fgraph: FunctionGraph, node: Apply, depth: int ) -> Generator[Apply, None, None]: diff --git a/tests/graph/rewriting/test_utils.py b/tests/graph/rewriting/test_utils.py index 37e63e66e4..365c8c1c24 100644 --- a/tests/graph/rewriting/test_utils.py +++ b/tests/graph/rewriting/test_utils.py @@ -1,144 +1,9 @@ from pytensor.graph.fg import FunctionGraph from pytensor.graph.rewriting.basic import graph_rewriter -from pytensor.graph.rewriting.utils import is_same_graph, rewrite_graph -from pytensor.tensor.math import neg +from pytensor.graph.rewriting.utils import rewrite_graph from pytensor.tensor.type import vectors -class TestIsSameGraph: - def check(self, expected): - """ - Core function to perform comparison. - - :param expected: A list of tuples (v1, v2, ((g1, o1), ..., (gN, oN))) - with: - - `v1` and `v2` two Variables (the graphs to be compared) - - `gj` a `givens` dictionary to give as input to `is_same_graph` - - `oj` the expected output of `is_same_graph(v1, v2, givens=gj)` - - This function also tries to call `is_same_graph` by inverting `v1` and - `v2`, and ensures the output remains the same. - """ - for v1, v2, go in expected: - for gj, oj in go: - r1 = is_same_graph(v1, v2, givens=gj) - assert r1 == oj - r2 = is_same_graph(v2, v1, givens=gj) - assert r2 == oj - - def test_single_var(self): - # Test `is_same_graph` with some trivial graphs (one Variable). - - x, y, _z = vectors("x", "y", "z") - self.check( - [ - (x, x, (({}, True),)), - ( - x, - y, - ( - ({}, False), - ({y: x}, True), - ), - ), - (x, neg(x), (({}, False),)), - (x, neg(y), (({}, False),)), - ] - ) - - def test_full_graph(self): - # Test `is_same_graph` with more complex graphs. - - x, y, z = vectors("x", "y", "z") - t = x * y - self.check( - [ - (x * 2, x * 2, (({}, True),)), - ( - x * 2, - y * 2, - ( - ({}, False), - ({y: x}, True), - ), - ), - ( - x * 2, - y * 2, - ( - ({}, False), - ({x: y}, True), - ), - ), - ( - x * 2, - y * 3, - ( - ({}, False), - ({y: x}, False), - ), - ), - ( - t * 2, - z * 2, - ( - ({}, False), - ({t: z}, True), - ), - ), - ( - t * 2, - z * 2, - ( - ({}, False), - ({z: t}, True), - ), - ), - (x * (y * z), (x * y) * z, (({}, False),)), - ] - ) - - def test_merge_only(self): - # Test `is_same_graph` when `equal_computations` cannot be used. - - x, y, z = vectors("x", "y", "z") - t = x * y - self.check( - [ - (x, t, (({}, False), ({t: x}, True))), - ( - t * 2, - x * 2, - ( - ({}, False), - ({t: x}, True), - ), - ), - ( - x * x, - x * y, - ( - ({}, False), - ({y: x}, True), - ), - ), - ( - x * x, - x * y, - ( - ({}, False), - ({y: x}, True), - ), - ), - ( - x * x + z, - x * y + t, - (({}, False), ({y: x}, False), ({y: x, t: z}, True)), - ), - ], - ) - - def test_rewrite_graph(): x, y = vectors("xy") diff --git a/tests/tensor/rewriting/test_math.py b/tests/tensor/rewriting/test_math.py index 8c2192096a..81f7cc8186 100644 --- a/tests/tensor/rewriting/test_math.py +++ b/tests/tensor/rewriting/test_math.py @@ -27,7 +27,7 @@ out2in, ) from pytensor.graph.rewriting.db import RewriteDatabaseQuery -from pytensor.graph.rewriting.utils import is_same_graph, rewrite_graph +from pytensor.graph.rewriting.utils import rewrite_graph from pytensor.graph.traversal import ancestors from pytensor.printing import debugprint, pprint from pytensor.scalar import PolyGamma, Psi, TriGamma @@ -4417,7 +4417,7 @@ def check(expr1, expr2): trees = [parse_mul_tree(e) for e in (expr1, expr2)] perform_sigm_times_exp(trees[0]) trees[0] = simplify_mul(trees[0]) - good = is_same_graph(compute_mul(trees[0]), compute_mul(trees[1])) + good = equal_computations([compute_mul(trees[0])], [compute_mul(trees[1])]) # if not good: # print(trees[0]) # print(trees[1]) @@ -4587,7 +4587,7 @@ def test_compute_mul(self): tree = (x * y) * -z mul_tree = parse_mul_tree(tree) assert parse_mul_tree(compute_mul(mul_tree)) == mul_tree - assert is_same_graph(compute_mul(parse_mul_tree(tree)), tree) + assert equal_computations([compute_mul(parse_mul_tree(tree))], [tree]) def test_parse_mul_tree(self): x, y, z = vectors("x", "y", "z") @@ -4609,7 +4609,7 @@ def test_is_1pexp(self): is_1pexp(x, only_process_constants=False) for x in [(1 + exp_op(-x)), (exp_op(-x) + 1)] ): - assert not neg_ and is_same_graph(exp_arg, -x) + assert not neg_ and equal_computations([exp_arg], [-x]) assert is_1pexp(1 - exp_op(x), False) is None assert is_1pexp(2 + exp_op(x), False) is None assert is_1pexp(exp_op(x) + 2, False) is None diff --git a/tests/tensor/test_subtensor.py b/tests/tensor/test_subtensor.py index 31a4eb9d90..6def0b3bf8 100644 --- a/tests/tensor/test_subtensor.py +++ b/tests/tensor/test_subtensor.py @@ -18,7 +18,6 @@ from pytensor.configdefaults import config from pytensor.gradient import grad from pytensor.graph.basic import equal_computations -from pytensor.graph.rewriting.utils import is_same_graph from pytensor.link.numba import NumbaLinker from pytensor.printing import pprint from pytensor.scalar.basic import as_scalar, int16 @@ -1470,7 +1469,7 @@ def test_adv_constant_arg(self): s1 = m[gv, i] s2 = m[g, i] - assert is_same_graph(s1, s2) + assert equal_computations([s1], [s2]) def test_adv1_inc_sub_notlastdim(self): # Test that taking 1-dimensional advanced indexing From 5dcf3b6b9d9c6bd3ed9dbed362e073b56424c5a9 Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Thu, 25 Jun 2026 16:43:46 +0200 Subject: [PATCH 17/18] Remove dead full_tree debug param from perform_sigm_times_exp full_tree was documented 'Used for debugging only' and read solely by a commented-out debug block; drop the parameter and the dead block. --- pytensor/tensor/rewriting/math.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/pytensor/tensor/rewriting/math.py b/pytensor/tensor/rewriting/math.py index 1a98962a1e..0a7d0dac7e 100644 --- a/pytensor/tensor/rewriting/math.py +++ b/pytensor/tensor/rewriting/math.py @@ -3698,7 +3698,6 @@ def perform_sigm_times_exp( sigm_minus_x=None, parent=None, child_idx=None, - full_tree=None, ): """ Core processing of the `local_sigm_times_exp` rewrite. @@ -3728,9 +3727,6 @@ def perform_sigm_times_exp( child_idx Index of `tree` in its parent's inputs (``None`` if `tree` is the global root). - full_tree - The global multiplication tree (should not be set except by recursive - calls to this function). Used for debugging only. Returns ------- @@ -3747,16 +3743,6 @@ def perform_sigm_times_exp( sigm_x = [] if sigm_minus_x is None: sigm_minus_x = [] - if full_tree is None: - full_tree = tree - # if False: # Debug code. - # print("") - # print(f" full_tree = {full_tree}") - # print(f" tree = {tree}") - # print(f" exp_x = {exp_x}") - # print(f" exp_minus_x = {exp_minus_x}") - # print(f" sigm_x = {sigm_x}") - # print(f" sigm_minus_x= {sigm_minus_x}") neg, inputs = tree if isinstance(inputs, list): # Recurse through inputs of the multiplication. @@ -3770,7 +3756,6 @@ def perform_sigm_times_exp( exp_minus_x=exp_minus_x, sigm_x=sigm_x, sigm_minus_x=sigm_minus_x, - full_tree=full_tree, ) return rval else: From 03d34b2bfedda534efd3d5f6a87e8eca349100dc Mon Sep 17 00:00:00 2001 From: Ricardo Vieira Date: Thu, 25 Jun 2026 17:01:59 +0200 Subject: [PATCH 18/18] Collapse three near-identical Unique lift rewriters into one local_Unique_Alloc_lift/Repeat_lift/second were structurally identical (same flag bail-out + make_node/as_tensor tail), differing only in the producer op checked and which input to unwrap. Merge into local_Unique_lift and update the test rewrite-name references. --- pytensor/tensor/rewriting/extra_ops.py | 88 +++++------------------- tests/tensor/rewriting/test_extra_ops.py | 10 +-- 2 files changed, 22 insertions(+), 76 deletions(-) diff --git a/pytensor/tensor/rewriting/extra_ops.py b/pytensor/tensor/rewriting/extra_ops.py index cbf82fc53d..4f409cb6b5 100644 --- a/pytensor/tensor/rewriting/extra_ops.py +++ b/pytensor/tensor/rewriting/extra_ops.py @@ -27,10 +27,13 @@ def local_Unique_scalar(fgraph, node): @register_useless @register_canonicalize @node_rewriter([Unique]) -def local_Unique_Alloc_lift(fgraph, node): - """Convert ``unique(alloc(x, ...), axis=None)`` to ``unique(x, axis=None)``. +def local_Unique_lift(fgraph, node): + """Convert ``unique(f(x, ...), axis=None)`` to ``unique(x, axis=None)``. - This isn't really so much a lift as a "reduction/consumption". + ``Alloc``, ``Repeat`` and ``second`` only broadcast/tile their input, so + they don't change the set of unique values and can be dropped from the + input of an axis-less ``unique`` (a "reduction/consumption" rather than a + true lift). """ if ( node.op.return_index @@ -40,78 +43,21 @@ def local_Unique_Alloc_lift(fgraph, node): ): return False - alloc_var = node.inputs[0] - - if not (alloc_var.owner and isinstance(alloc_var.owner.op, Alloc)): - return False - - alloced_var, *_alloc_shape = alloc_var.owner.inputs - - new_unique, *_ = node.op.make_node(alloced_var).outputs - - old_out = node.outputs[0] - new_x = as_tensor_variable(new_unique, ndim=old_out.ndim, dtype=old_out.dtype) - return [new_x] - - -@register_useless -@register_canonicalize -@node_rewriter([Unique]) -def local_Unique_Repeat_lift(fgraph, node): - """Convert ``unique(repeat(x, ...), axis=None)`` to ``unique(x, axis=None)``. - - This isn't really so much a lift as a "reduction/consumption". - """ - if ( - node.op.return_index - or node.op.return_inverse - or node.op.return_counts - or node.op.axis is not None - ): + var = node.inputs[0] + owner = var.owner + if owner is None: return False - repeat_var = node.inputs[0] - - if not (repeat_var.owner and isinstance(repeat_var.owner.op, Repeat)): + if isinstance(owner.op, Alloc | Repeat): + # The value being broadcast/tiled is the first input. + inner_var = owner.inputs[0] + elif isinstance(owner.op, Elemwise) and isinstance(owner.op.scalar_op, ps.Second): + # ``second(shape, x)`` fills the shape with the second input. + inner_var = owner.inputs[1] + else: return False - repeated_var, *_repeat_shape = repeat_var.owner.inputs - - new_unique, *_ = node.op.make_node(repeated_var).outputs - - old_out = node.outputs[0] - new_x = as_tensor_variable(new_unique, ndim=old_out.ndim, dtype=old_out.dtype) - return [new_x] - - -@register_useless -@register_canonicalize -@node_rewriter([Unique]) -def local_Unique_second(fgraph, node): - """Convert ``unique(second(x, ...), axis=None)`` to ``second(x, axis=None)``. - - This isn't really so much a lift as a "reduction/consumption". - """ - if ( - node.op.return_index - or node.op.return_inverse - or node.op.return_counts - or node.op.axis is not None - ): - return False - - second_var = node.inputs[0] - - if not ( - second_var.owner - and isinstance(second_var.owner.op, Elemwise) - and isinstance(second_var.owner.op.scalar_op, ps.Second) - ): - return False - - _shape_var, seconded_var = second_var.owner.inputs - - new_unique, *_ = node.op.make_node(seconded_var).outputs + new_unique, *_ = node.op.make_node(inner_var).outputs old_out = node.outputs[0] new_x = as_tensor_variable(new_unique, ndim=old_out.ndim, dtype=old_out.dtype) diff --git a/tests/tensor/rewriting/test_extra_ops.py b/tests/tensor/rewriting/test_extra_ops.py index ebdc9560df..5f6f363218 100644 --- a/tests/tensor/rewriting/test_extra_ops.py +++ b/tests/tensor/rewriting/test_extra_ops.py @@ -75,7 +75,7 @@ def test_local_Unique_Alloc_lift( y_rewritten_fg = rewrite_graph( y_fg, clone=False, - include=["canonicalize", "local_Unique_Alloc_lift"], + include=["canonicalize", "local_Unique_lift"], exclude=["local_Unique_scalar"], ) y_rewritten = y_rewritten_fg.outputs[0] @@ -92,7 +92,7 @@ def test_local_Unique_Alloc_lift( # The remaining exclusions simply allow us to perform the check below that # makes sure the original `Alloc` is present in our reference (sub)graph. rewrite_mode = default_mode.excluding( - "local_useless_alloc", "local_alloc_sink_dimshuffle", "local_Unique_Alloc_lift" + "local_useless_alloc", "local_alloc_sink_dimshuffle", "local_Unique_lift" ) y_fn = function([x], [y, y_rewritten], mode=rewrite_mode) # Make sure that the original `Alloc` is used to compute the reference `y` @@ -138,7 +138,7 @@ def test_local_Unique_Repeat( y_rewritten_fg = rewrite_graph( y_fg, clone=False, - include=["canonicalize", "local_Unique_Repeat_lift"], + include=["canonicalize", "local_Unique_lift"], exclude=["local_Unique_scalar"], ) y_rewritten = y_rewritten_fg.outputs[0] @@ -152,7 +152,7 @@ def test_local_Unique_Repeat( # The rewrite has already been applied to `y_rewritten`, so we can--and # should--exclude it from the compilation of both our reference, `y`, and # the rewritten result, `y_rewritten`. - rewrite_mode = default_mode.excluding("local_Unique_Repeat_lift") + rewrite_mode = default_mode.excluding("local_Unique_lift") y_fn = function([x], [y, y_rewritten], mode=rewrite_mode) # Make sure that the original `BroadcastTo` is used to compute the # reference `y` result @@ -194,7 +194,7 @@ def test_local_Unique_second( y_rewritten_fg = rewrite_graph( y_fg, clone=False, - include=["canonicalize", "local_Unique_second_lift"], + include=["canonicalize", "local_Unique_lift"], exclude=["local_Unique_scalar", "topo_constant_folding"], ) y_rewritten = y_rewritten_fg.outputs[0]