diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d8ca6af59ec..6281f24e867 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,5 @@ name: Docker builds -on: - push: - branches: - - master +on: workflow_dispatch jobs: base-tests: diff --git a/.pylintrc b/.pylintrc index 982d3901d6c..ecd788f66f5 100644 --- a/.pylintrc +++ b/.pylintrc @@ -2,7 +2,7 @@ # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code -extension-pkg-whitelist=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag +extension-pkg-whitelist=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,packaging,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag [TYPECHECK] @@ -10,12 +10,12 @@ extension-pkg-whitelist=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. -ignored-modules=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy +ignored-modules=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,packaging,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). This supports can work # with qualified names. -ignored-classes=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy,pennylane.numpy.random,pennylane.numpy.linalg,pennylane.numpy.builtins,pennylane.operation,rustworkx,kahypar +ignored-classes=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,packaging,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy,pennylane.numpy.random,pennylane.numpy.linalg,pennylane.numpy.builtins,pennylane.operation,rustworkx,kahypar [MESSAGES CONTROL] diff --git a/codecov.yml b/codecov.yml index 2cb3b30235c..62ee8459868 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,6 +1,7 @@ ignore: - "pennylane/devices/tests/*" - "pennylane/data/base/_lazy_modules.py" + - "pennylane/logging/*" codecov: notify: diff --git a/doc/development/guide/installation.rst b/doc/development/guide/installation.rst index 0437e6caca5..24fd5c44d3e 100644 --- a/doc/development/guide/installation.rst +++ b/doc/development/guide/installation.rst @@ -19,6 +19,7 @@ be installed alongside PennyLane: * `appdirs `_ * `semantic-version `_ >= 2.7 * `autoray `__ >= 0.6.11 +* `packaging `_ The following Python packages are optional: @@ -66,6 +67,14 @@ importing PennyLane in Python. requires ``pip install -e .`` to be re-run in the plugin repository for the changes to take effect. +Apart from the core packages needed to run PennyLane. Some extra packages need +to be installed for several development processes, such as linting, testing, and +pre-commit quality checks. Those can be installed easily via ``pip``: + +.. code-block:: bash + + pip install -r requirements-dev.txt + Docker ------ diff --git a/doc/development/guide/logging.rst b/doc/development/guide/logging.rst index 609107860a4..54f0a0cc349 100644 --- a/doc/development/guide/logging.rst +++ b/doc/development/guide/logging.rst @@ -29,24 +29,36 @@ To add logging support to components of PennyLane, we must define a module logge which will be used within the given module, and track directories, filenames and function names, as we have defined the appropriate types -within the formatter configuration (see :class:`pennylane.logging.DefaultFormatter`). With the logger defined, we can selectively add to the logger by if-else statements, which compare the given module’s log-level to any log record -message it receives. This step is not necessary, as the message will -only output if the level is enabled, though if an expensive function -call is required to build the string for the log-message, it can be -faster to perform this check: +within the formatter configuration (see :class:`pennylane.logging.DefaultFormatter`). With the logger defined, we can selectively add to the logger via two methods: -.. code:: python +#. Using decorators on the required functions and methods in a given module: + + .. code:: python + + # debug_logger can be used to decorate any method or free function + # debug_logger_init can be used to decorate class __init__ methods. + from pennylane.logging import debug_logger, debug_logger_init + + @debug_logger + def my_func(arg1, arg2): + return arg1 + arg2 - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - """Entry with args=(arg_name_1=%s, arg_name_2=%s, ..., arg_name_n=%s)""", - arg_name_1, arg_name_2, ..., arg_name_n, - ) +#. Explicitly by if-else statements, which compare the given module’s log-level to any log record message it receives. This step is not necessary, as the message will + only output if the level is enabled, though if an expensive function + call is required to build the string for the log-message, it can be + faster to perform this check: -The above line can be added below the function/method entry point, -and the provided arguments can be used to populate the log message. This -allows us a way to track the inputs and calls through the stack in the -order they are executed, as is the basis for following a trail of + .. code:: python + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + """Entry with args=(arg_name_1=%s, arg_name_2=%s, ..., arg_name_n=%s)""", + arg_name_1, arg_name_2, ..., arg_name_n, + ) + +Both versions provide similar functionality, though the explicit logger call allows more custom message-formatting, such as expanding functions as string representation, filtering of data, and other useful processing for a valid record. + +These logging options allow us a way to track the inputs and calls through the stack in the order they are executed, as is the basis for following a trail of execution as needed. All debug log-statements currently added to the PennyLane execution @@ -82,6 +94,8 @@ messages based on some criteria, we can add these to the respective handlers. As an example, we can go through the configuration file and explore the options. +For ease-of-development, the function :func:`pennylane.logging.edit_system_config` opens an editor (if the ``EDITOR`` environment variable is set), or a viewer of the existing file configuration, which can be used to modify the existing options. + Modifying the configuration options ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/development/guide/tests.rst b/doc/development/guide/tests.rst index 027973c0aa6..d0b932277f7 100644 --- a/doc/development/guide/tests.rst +++ b/doc/development/guide/tests.rst @@ -3,17 +3,17 @@ Software tests Requirements ~~~~~~~~~~~~ -The PennyLane test suite requires the Python ``pytest`` package, as well as: +The PennyLane test suite requires the Python ``pytest`` package, as well as +some extentions thereof, for example: * ``pytest-cov``: determines test coverage * ``pytest-mock``: allows replacing components with dummy/mock objects * ``flaky``: manages tests with non-deterministic behaviour +* ``pytest-benchmark``: benchmarks the performance of functions, and can be used to ensure consistent runtime +* ``pytest-xdist``: currently used to force some tests to run on the same thread to avoid race conditions -These requirements can be installed via ``pip``: - -.. code-block:: bash - - pip install pytest pytest-cov pytest-mock flaky +If you properly followed the :doc:`installation guide <./installation>`, you should have all of these packages and others installed in your +environment, so you can go ahead and put your code to the test! Creating a test ~~~~~~~~~~~~~~~ diff --git a/doc/introduction/logging.rst b/doc/introduction/logging.rst index fb46f5cb4b2..a8bf0281992 100644 --- a/doc/introduction/logging.rst +++ b/doc/introduction/logging.rst @@ -29,4 +29,7 @@ outputs to the default configured handler, which is directed to the standard out level = "DEBUG" # Set to TRACE for highest verbosity propagate = false + +Viewing the existing logging configuration file is possible by calling the :func:`pennylane.logging.edit_system_config` function which will open the file in an existing browser or editor window. + For more info on the customization of the logging options, please see the logging development guide at :doc:`/development/guide/logging`, and the `Python logging documentation `_. diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index f7c6cfc77db..9826b0d28c9 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -9,6 +9,15 @@

Improvements 🛠

+* The wires for the `default.tensor` device are selected at runtime if they are not provided by user. + [(#5744)](https://github.com/PennyLaneAI/pennylane/pull/5744) + +* Added `packaging` in the required list of packages. + [(#5769)](https://github.com/PennyLaneAI/pennylane/pull/5769). + +* Logging now allows for an easier opt-in across the stack, and also extends control support to `catalyst`. + [(#5528)](https://github.com/PennyLaneAI/pennylane/pull/5528). + * A number of templates have been updated to be valid pytrees and PennyLane operations. [(#5698)](https://github.com/PennyLaneAI/pennylane/pull/5698) @@ -21,6 +30,9 @@ * The sorting order of parameter-shift terms is now guaranteed to resolve ties in the absolute value with the sign of the shifts. [(#5582)](https://github.com/PennyLaneAI/pennylane/pull/5582) +* `qml.transforms.split_non_commuting` can now handle circuits containing measurements of multi-term observables. + [(#5729)](https://github.com/PennyLaneAI/pennylane/pull/5729) +

Mid-circuit measurements and dynamic circuits

* The `dynamic_one_shot` transform uses a single auxiliary tape with a shot vector and `default.qubit` implements the loop over shots with `jax.vmap`. @@ -100,6 +112,7 @@ [(#5564)](https://github.com/PennyLaneAI/pennylane/pull/5564) [(#5511)](https://github.com/PennyLaneAI/pennylane/pull/5511) [(#5708)](https://github.com/PennyLaneAI/pennylane/pull/5708) + [(#5523)](https://github.com/PennyLaneAI/pennylane/pull/5523) * The `decompose` transform has an `error` kwarg to specify the type of error that should be raised, allowing error types to be more consistent with the context the `decompose` function is used in. @@ -187,6 +200,12 @@

Bug fixes 🐛

+* Disable Docker builds on PR merge. + [(#5777)](https://github.com/PennyLaneAI/pennylane/pull/5777) + +* The validation of the adjoint method in `DefaultQubit` correctly handles device wires now. + [(#5761)](https://github.com/PennyLaneAI/pennylane/pull/5761) + * `QuantumPhaseEstimation.map_wires` on longer modifies the original operation instance. [(#5698)](https://github.com/PennyLaneAI/pennylane/pull/5698) @@ -240,6 +259,9 @@ * Fixes a bug in `qml.math.dot` that raises an error when only one of the operands is a scalar. [(#5702)](https://github.com/PennyLaneAI/pennylane/pull/5702) +* `qml.matrix` is now compatible with qnodes compiled by catalyst.qjit. + [(#5753)](https://github.com/PennyLaneAI/pennylane/pull/5753) +

Contributors ✍️

This release contains contributions from (in alphabetical order): @@ -260,4 +282,5 @@ Vincent Michaud-Rioux, Lee James O'Riordan, Mudit Pandey, Kenya Sakka, +Haochen Paul Wang, David Wierichs. diff --git a/doc/requirements.txt b/doc/requirements.txt index 8c7e0927da3..b86201fb9df 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -10,6 +10,7 @@ m2r2 numpy pygments-github-lexers semantic_version==2.10 +packaging scipy docutils==0.16 sphinx~=3.5.0; python_version < "3.10" diff --git a/pennylane/__init__.py b/pennylane/__init__.py index 7524882e84a..80419832ae0 100644 --- a/pennylane/__init__.py +++ b/pennylane/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2018-2021 Xanadu Quantum Technologies Inc. +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import numpy as _np + from semantic_version import SimpleSpec, Version from pennylane.boolean_fn import BooleanFn diff --git a/pennylane/capture/__init__.py b/pennylane/capture/__init__.py index 76b920edf3d..3cbb39a3dfa 100644 --- a/pennylane/capture/__init__.py +++ b/pennylane/capture/__init__.py @@ -82,7 +82,7 @@ def qfunc(a): (where ``cls`` indicates the class) if: * The operator does not accept wires, like :class:`~.SymbolicOp` or :class:`~.CompositeOp`. -* The operator needs to enforce a data/ metadata distinction, like :class:`~.PauliRot`. +* The operator needs to enforce a data / metadata distinction, like :class:`~.PauliRot`. In such cases, the operator developer can override ``cls._primitive_bind_call``, which will be called when constructing a new class instance instead of ``type.__call__``. For example, diff --git a/pennylane/compiler/compiler.py b/pennylane/compiler/compiler.py index 816705bf35c..95a08bf1be8 100644 --- a/pennylane/compiler/compiler.py +++ b/pennylane/compiler/compiler.py @@ -20,7 +20,7 @@ from sys import version_info from typing import List, Optional -from semantic_version import Version +from packaging.version import Version PL_CATALYST_MIN_VERSION = Version("0.6.0") diff --git a/pennylane/devices/default_mixed.py b/pennylane/devices/default_mixed.py index 80661c7faa3..a4a84a19098 100644 --- a/pennylane/devices/default_mixed.py +++ b/pennylane/devices/default_mixed.py @@ -21,6 +21,7 @@ import functools import itertools +import logging from collections import defaultdict from string import ascii_letters as ABC @@ -29,6 +30,7 @@ import pennylane as qml import pennylane.math as qnp from pennylane import BasisState, DeviceError, QubitDensityMatrix, QubitDevice, Snapshot, StatePrep +from pennylane.logging import debug_logger, debug_logger_init from pennylane.measurements import ( CountsMP, DensityMatrixMP, @@ -47,6 +49,9 @@ from .._version import __version__ +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + ABC_ARRAY = np.array(list(ABC)) tolerance = 1e-10 @@ -181,6 +186,7 @@ def _asarray(array, dtype=None): res = qnp.cast(array, dtype=dtype) return res + @debug_logger_init def __init__( self, wires, @@ -252,6 +258,7 @@ def state(self): # User obtains state as a matrix return qnp.reshape(self._pre_rotated_state, (dim, dim)) + @debug_logger def density_matrix(self, wires): """Returns the reduced density matrix over the given wires. @@ -266,12 +273,14 @@ def density_matrix(self, wires): wires = self.map_wires(wires) return qml.math.reduce_dm(state, indices=wires, c_dtype=self.C_DTYPE) + @debug_logger def purity(self, mp, **kwargs): # pylint: disable=unused-argument """Returns the purity of the final state""" state = getattr(self, "state", None) wires = self.map_wires(mp.wires) return qml.math.purity(state, indices=wires, c_dtype=self.C_DTYPE) + @debug_logger def reset(self): """Resets the device""" super().reset() @@ -279,6 +288,7 @@ def reset(self): self._state = self._create_basis_state(0) self._pre_rotated_state = self._state + @debug_logger def analytic_probability(self, wires=None): if self._state is None: return None @@ -706,6 +716,7 @@ def _apply_operation(self, operation): # pylint: disable=arguments-differ + @debug_logger def execute(self, circuit, **kwargs): """Execute a queue of quantum operations on the device and then measure the given observables. @@ -760,6 +771,7 @@ def execute(self, circuit, **kwargs): self.measured_wires = qml.wires.Wires.all_wires(wires_list) return super().execute(circuit, **kwargs) + @debug_logger def apply(self, operations, rotations=None, **kwargs): rotations = rotations or [] diff --git a/pennylane/devices/default_qubit.py b/pennylane/devices/default_qubit.py index 6a1eebd81e1..35dc391a6a5 100644 --- a/pennylane/devices/default_qubit.py +++ b/pennylane/devices/default_qubit.py @@ -16,7 +16,6 @@ """ import concurrent.futures -import inspect import logging from dataclasses import replace from functools import partial @@ -26,6 +25,7 @@ import numpy as np import pennylane as qml +from pennylane.logging import debug_logger, debug_logger_init from pennylane.measurements.mid_measure import MidMeasureMP from pennylane.ops.op_math.condition import Conditional from pennylane.tape import QuantumTape @@ -184,11 +184,12 @@ def adjoint_observables(obs: qml.operation.Operator) -> bool: return obs.has_matrix -def _supports_adjoint(circuit): +def _supports_adjoint(circuit, device_wires, device_name): if circuit is None: return True prog = TransformProgram() + prog.add_transform(validate_device_wires, device_wires, name=device_name) _add_adjoint_transforms(prog) try: @@ -419,6 +420,7 @@ def reset_prng_key(self): """ # pylint:disable = too-many-arguments + @debug_logger_init def __init__( self, wires=None, @@ -439,6 +441,7 @@ def __init__( self._rng = np.random.default_rng(seed) self._debugger = None + @debug_logger def supports_derivatives( self, execution_config: Optional[ExecutionConfig] = None, @@ -472,9 +475,10 @@ def supports_derivatives( ) if execution_config.gradient_method in {"adjoint", "best"}: - return _supports_adjoint(circuit=circuit) + return _supports_adjoint(circuit, device_wires=self.wires, device_name=self.name) return False + @debug_logger def preprocess( self, execution_config: ExecutionConfig = DefaultExecutionConfig, @@ -566,20 +570,13 @@ def _setup_execution_config(self, execution_config: ExecutionConfig) -> Executio updated_values["device_options"][option] = getattr(self, f"_{option}") return replace(execution_config, **updated_values) + @debug_logger def execute( self, circuits: QuantumTape_or_Batch, execution_config: ExecutionConfig = DefaultExecutionConfig, ) -> Result_or_ResultBatch: self.reset_prng_key() - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - """Entry with args=(circuits=%s) called by=%s""", - circuits, - "::L".join( - str(i) for i in inspect.getouterframes(inspect.currentframe(), 2)[1][1:3] - ), - ) max_workers = execution_config.device_options.get("max_workers", self._max_workers) self._state_cache = {} if execution_config.use_device_jacobian_product else None @@ -618,6 +615,7 @@ def execute( return results + @debug_logger def compute_derivatives( self, circuits: QuantumTape_or_Batch, @@ -637,6 +635,7 @@ def compute_derivatives( return res + @debug_logger def execute_and_compute_derivatives( self, circuits: QuantumTape_or_Batch, @@ -659,6 +658,7 @@ def execute_and_compute_derivatives( return tuple(zip(*results)) + @debug_logger def supports_jvp( self, execution_config: Optional[ExecutionConfig] = None, @@ -678,6 +678,7 @@ def supports_jvp( """ return self.supports_derivatives(execution_config, circuit) + @debug_logger def compute_jvp( self, circuits: QuantumTape_or_Batch, @@ -697,6 +698,7 @@ def compute_jvp( return res + @debug_logger def execute_and_compute_jvp( self, circuits: QuantumTape_or_Batch, @@ -724,6 +726,7 @@ def execute_and_compute_jvp( return tuple(zip(*results)) + @debug_logger def supports_vjp( self, execution_config: Optional[ExecutionConfig] = None, @@ -743,6 +746,7 @@ def supports_vjp( """ return self.supports_derivatives(execution_config, circuit) + @debug_logger def compute_vjp( self, circuits: QuantumTape_or_Batch, @@ -810,6 +814,7 @@ def _state(circuit): return res + @debug_logger def execute_and_compute_vjp( self, circuits: QuantumTape_or_Batch, diff --git a/pennylane/devices/default_qubit_tf.py b/pennylane/devices/default_qubit_tf.py index efc2224af6f..48d7c60b788 100644 --- a/pennylane/devices/default_qubit_tf.py +++ b/pennylane/devices/default_qubit_tf.py @@ -1,4 +1,4 @@ -# Copyright 2018-2021 Xanadu Quantum Technologies Inc. +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ import itertools import numpy as np -import semantic_version +from packaging.version import Version import pennylane as qml @@ -29,7 +29,7 @@ from tensorflow.python.framework.errors_impl import InvalidArgumentError - SUPPORTS_APPLY_OPS = semantic_version.match(">=2.3.0", tf.__version__) + SUPPORTS_APPLY_OPS = Version(tf.__version__) >= Version("2.3.0") except ImportError as e: # pragma: no cover raise ImportError("default.qubit.tf device requires TensorFlow>=2.0") from e diff --git a/pennylane/devices/default_qubit_torch.py b/pennylane/devices/default_qubit_torch.py index 29e8455f66f..e0bb276e513 100644 --- a/pennylane/devices/default_qubit_torch.py +++ b/pennylane/devices/default_qubit_torch.py @@ -1,4 +1,4 @@ -# Copyright 2018-2021 Xanadu Quantum Technologies Inc. +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,12 +18,14 @@ import logging import warnings -import semantic_version +from packaging.version import Version try: import torch - VERSION_SUPPORT = semantic_version.match(">=1.8.1", torch.__version__) + VERSION_SUPPORT = Version(torch.__version__) >= Version( + "1.8.1", + ) if not VERSION_SUPPORT: # pragma: no cover raise ImportError("default.qubit.torch device requires Torch>=1.8.1") diff --git a/pennylane/devices/default_qutrit.py b/pennylane/devices/default_qutrit.py index 5e9e346be18..4ac1fe0cbb9 100644 --- a/pennylane/devices/default_qutrit.py +++ b/pennylane/devices/default_qutrit.py @@ -19,16 +19,21 @@ simulation of qutrit-based quantum computing. """ import functools +import logging import numpy as np import pennylane as qml # pylint: disable=unused-import from pennylane import DeviceError, QutritBasisState, QutritDevice from pennylane.devices.default_qubit_legacy import _get_slice +from pennylane.logging import debug_logger, debug_logger_init from pennylane.wires import WireError from .._version import __version__ +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + # tolerance for numerical errors tolerance = 1e-10 @@ -118,6 +123,7 @@ def _asarray(array, dtype=None): res = qml.math.cast(array, dtype=dtype) return res + @debug_logger_init def __init__( self, wires, @@ -165,6 +171,7 @@ def define_wire_map(self, wires): wire_map = zip(wires, consecutive_wires) return dict(wire_map) + @debug_logger def apply(self, operations, rotations=None, **kwargs): # pylint: disable=arguments-differ rotations = rotations or [] @@ -403,6 +410,7 @@ def _create_basis_state(self, index): def state(self): return self._flatten(self._pre_rotated_state) + @debug_logger def density_matrix(self, wires): """Returns the reduced density matrix of a given set of wires. @@ -460,6 +468,7 @@ def _apply_unitary(self, state, mat, wires): inv_perm = np.argsort(perm) # argsort gives inverse permutation return self._transpose(tdot, inv_perm) + @debug_logger def reset(self): """Reset the device""" super().reset() @@ -468,6 +477,7 @@ def reset(self): self._state = self._create_basis_state(0) self._pre_rotated_state = self._state + @debug_logger def analytic_probability(self, wires=None): if self._state is None: return None diff --git a/pennylane/devices/default_qutrit_mixed.py b/pennylane/devices/default_qutrit_mixed.py index 7c264a9d785..0ade57c0c73 100644 --- a/pennylane/devices/default_qutrit_mixed.py +++ b/pennylane/devices/default_qutrit_mixed.py @@ -13,7 +13,6 @@ # limitations under the License. """The default.qutrit.mixed device is PennyLane's standard qutrit simulator for mixed-state computations.""" -import inspect import logging from dataclasses import replace from typing import Callable, Optional, Sequence, Tuple, Union @@ -21,6 +20,7 @@ import numpy as np import pennylane as qml +from pennylane.logging import debug_logger, debug_logger_init from pennylane.ops import _qutrit__channel__ops__ as channels from pennylane.tape import QuantumTape from pennylane.transforms.core import TransformProgram @@ -176,6 +176,7 @@ def name(self): """The name of the device.""" return "default.qutrit.mixed" + @debug_logger_init def __init__( self, wires=None, @@ -192,6 +193,7 @@ def __init__( self._rng = np.random.default_rng(seed) self._debugger = None + @debug_logger def supports_derivatives( self, execution_config: Optional[ExecutionConfig] = None, @@ -238,6 +240,7 @@ def _setup_execution_config(self, execution_config: ExecutionConfig) -> Executio updated_values["device_options"][option] = getattr(self, f"_{option}") return replace(execution_config, **updated_values) + @debug_logger def preprocess( self, execution_config: ExecutionConfig = DefaultExecutionConfig, @@ -283,19 +286,12 @@ def preprocess( return transform_program, config + @debug_logger def execute( self, circuits: QuantumTape_or_Batch, execution_config: ExecutionConfig = DefaultExecutionConfig, ) -> Result_or_ResultBatch: - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - """Entry with args=(circuits=%s) called by=%s""", - circuits, - "::L".join( - str(i) for i in inspect.getouterframes(inspect.currentframe(), 2)[1][1:3] - ), - ) interface = ( execution_config.interface diff --git a/pennylane/devices/default_tensor.py b/pennylane/devices/default_tensor.py index 605f88cc58b..3c88e999370 100644 --- a/pennylane/devices/default_tensor.py +++ b/pennylane/devices/default_tensor.py @@ -34,6 +34,7 @@ from pennylane.tape import QuantumScript, QuantumTape from pennylane.transforms.core import TransformProgram from pennylane.typing import Result, ResultBatch, TensorLike +from pennylane.wires import WireError Result_or_ResultBatch = Union[Result, ResultBatch] QuantumTapeBatch = Sequence[QuantumTape] @@ -257,16 +258,12 @@ def circuit(theta, phi, num_qubits): def __init__( self, - wires, - *, + wires=None, method="mps", dtype=np.complex128, **kwargs, ) -> None: - if wires is None: - raise TypeError("Wires must be provided for the default.tensor device.") - if not has_quimb: raise ImportError( "This feature requires quimb, a library for tensor network manipulations. " @@ -293,28 +290,11 @@ def __init__( self._cutoff = kwargs.get("cutoff", np.finfo(self._dtype).eps) self._contract = kwargs.get("contract", "auto-mps") - device_options = self._setup_execution_config().device_options - - self._init_state_opts = { - "binary": "0" * (len(self._wires) if self._wires else 1), - "dtype": self._dtype.__name__, - "tags": [str(l) for l in self._wires.labels] if self._wires else None, - } - - self._gate_opts = { - "parametrize": None, - "contract": device_options["contract"], - "cutoff": device_options["cutoff"], - "max_bond": device_options["max_bond_dim"], - } - - self._expval_opts = { - "dtype": self._dtype.__name__, - "simplify_sequence": "ADCRS", - "simplify_atol": 0.0, - } - - self._circuitMPS = qtn.CircuitMPS(psi0=self._initial_mps()) + # The `quimb` state is a class attribute so that we can implement methods + # that access it as soon as the device is created without running a circuit. + # The state is reset every time a new circuit is executed, and number of wires + # can be established at runtime to match the circuit. + self._quimb_mps = qtn.CircuitMPS(psi0=self._initial_mps(self.wires)) for arg in kwargs: if arg not in self._device_options: @@ -337,24 +317,39 @@ def dtype(self): """Tensor complex data type.""" return self._dtype - def _reset_state(self) -> None: + def _reset_mps(self, wires: qml.wires.Wires) -> None: """ - Reset the MPS. + Reset the MPS associated with the circuit. + + Internally, it uses `quimb`'s `CircuitMPS` class. - This method modifies the tensor state of the device. + Args: + wires (Wires): The wires to reset the MPS. """ - self._circuitMPS = qtn.CircuitMPS(psi0=self._initial_mps()) + self._quimb_mps = qtn.CircuitMPS( + psi0=self._initial_mps(wires), + max_bond=self._max_bond_dim, + gate_contract=self._contract, + cutoff=self._cutoff, + ) - def _initial_mps(self) -> "qtn.MatrixProductState": + def _initial_mps(self, wires: qml.wires.Wires) -> "qtn.MatrixProductState": r""" Return an initial state to :math:`\ket{0}`. Internally, it uses `quimb`'s `MPS_computational_state` method. + Args: + wires (Wires): The wires to initialize the MPS. + Returns: MatrixProductState: The initial MPS of a circuit. """ - return qtn.MPS_computational_state(**self._init_state_opts) + return qtn.MPS_computational_state( + binary="0" * (len(wires) if wires else 1), + dtype=self._dtype.__name__, + tags=[str(l) for l in wires.labels] if wires else None, + ) def _setup_execution_config( self, config: Optional[ExecutionConfig] = DefaultExecutionConfig @@ -429,10 +424,11 @@ def execute( results = [] for circuit in circuits: - # we need to check if the wires of the circuit are compatible with the wires of the device - # since the initial tensor state is created with the wires of the device - if not self.wires.contains_wires(circuit.wires): - raise AttributeError( + if self.wires is not None and not self.wires.contains_wires(circuit.wires): + # quimb raises a cryptic error if the circuit has wires that are not in the device, + # so we raise a more informative error here + raise WireError( + "Mismatch between circuit and device wires. " f"Circuit has wires {circuit.wires.tolist()}. " f"Tensor on device has wires {self.wires.tolist()}" ) @@ -451,7 +447,9 @@ def simulate(self, circuit: QuantumScript) -> Result: Tuple[TensorLike]: The results of the simulation. """ - self._reset_state() + wires = circuit.wires if self.wires is None else self.wires + + self._reset_mps(wires) for op in circuit.operations: self._apply_operation(op) @@ -472,9 +470,7 @@ def _apply_operation(self, op: qml.operation.Operator) -> None: op (Operator): The operation to apply. """ - self._circuitMPS.apply_gate( - qml.matrix(op).astype(self._dtype), *op.wires, **self._gate_opts - ) + self._quimb_mps.apply_gate(qml.matrix(op).astype(self._dtype), *op.wires, parametrize=None) def measurement(self, measurementprocess: MeasurementProcess) -> TensorLike: """Measure the measurement required by the circuit over the MPS. @@ -555,13 +551,15 @@ def _local_expectation(self, matrix, wires) -> float: Local expectation value of the matrix on the MPS. """ - # We need to copy the MPS to avoid modifying the original state - qc = copy.deepcopy(self._circuitMPS) + # We need to copy the MPS since `local_expectation` modifies the state + qc = copy.deepcopy(self._quimb_mps) exp_val = qc.local_expectation( matrix, wires, - **self._expval_opts, + dtype=self._dtype.__name__, + simplify_sequence="ADCRS", + simplify_atol=0.0, ) return float(np.real(exp_val)) diff --git a/pennylane/devices/qubit/adjoint_jacobian.py b/pennylane/devices/qubit/adjoint_jacobian.py index d5308e55fb6..7f25cb3a858 100644 --- a/pennylane/devices/qubit/adjoint_jacobian.py +++ b/pennylane/devices/qubit/adjoint_jacobian.py @@ -12,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. """Functions to apply adjoint jacobian differentiation""" +import logging from numbers import Number from typing import Tuple import numpy as np import pennylane as qml +from pennylane.logging import debug_logger from pennylane.operation import operation_derivative from pennylane.tape import QuantumTape @@ -27,6 +29,9 @@ # pylint: disable=protected-access, too-many-branches +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + def _dot_product_real(bra, ket, num_wires): """Helper for calculating the inner product for adjoint differentiation.""" @@ -68,6 +73,7 @@ def _adjoint_jacobian_state(tape: QuantumTape): return tuple(jac.flatten() for jac in jacobian) +@debug_logger def adjoint_jacobian(tape: QuantumTape, state=None): """Implements the adjoint method outlined in `Jones and Gacon `__ to differentiate an input tape. @@ -143,6 +149,7 @@ def adjoint_jacobian(tape: QuantumTape, state=None): return tuple(tuple(np.array(j_) for j_ in j) for j in jac) +@debug_logger def adjoint_jvp(tape: QuantumTape, tangents: Tuple[Number], state=None): """The jacobian vector product used in forward mode calculation of derivatives. @@ -316,6 +323,7 @@ def _get_vjp_bras(tape, cotangents, ket): return bras, batch_size, null_batch_indices +@debug_logger def adjoint_vjp(tape: QuantumTape, cotangents: Tuple[Number], state=None): """The vector jacobian product used in reverse-mode differentiation. diff --git a/pennylane/devices/qubit/simulate.py b/pennylane/devices/qubit/simulate.py index 7218449c625..47e8c85f36d 100644 --- a/pennylane/devices/qubit/simulate.py +++ b/pennylane/devices/qubit/simulate.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. """Simulate a quantum script.""" +import logging + # pylint: disable=protected-access from functools import partial from typing import Optional @@ -20,6 +22,7 @@ from numpy.random import default_rng import pennylane as qml +from pennylane.logging import debug_logger from pennylane.measurements import MidMeasureMP from pennylane.typing import Result @@ -28,6 +31,9 @@ from .measure import measure from .sampling import jax_random_split, measure_with_samples +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + INTERFACE_TO_LIKE = { # map interfaces known by autoray to themselves None: None, @@ -107,6 +113,7 @@ def _postselection_postprocess(state, is_state_batched, shots, rng=None, prng_ke return state, shots +@debug_logger def get_final_state(circuit, debugger=None, **execution_kwargs): """ Get the final state that results from executing the given quantum script. @@ -175,6 +182,7 @@ def get_final_state(circuit, debugger=None, **execution_kwargs): # pylint: disable=too-many-arguments +@debug_logger def measure_final_state(circuit, state, is_state_batched, **execution_kwargs) -> Result: """ Perform the measurements required by the circuit on the provided state. @@ -238,6 +246,7 @@ def measure_final_state(circuit, state, is_state_batched, **execution_kwargs) -> return results +@debug_logger def simulate( circuit: qml.tape.QuantumScript, debugger=None, @@ -316,6 +325,7 @@ def simulate_partial(k): return measure_final_state(circuit, state, is_state_batched, rng=rng, prng_key=meas_key) +@debug_logger def simulate_one_shot_native_mcm( circuit: qml.tape.QuantumScript, debugger=None, **execution_kwargs ) -> Result: diff --git a/pennylane/drawer/draw.py b/pennylane/drawer/draw.py index 97ebf3084fb..3655f9b5cc8 100644 --- a/pennylane/drawer/draw.py +++ b/pennylane/drawer/draw.py @@ -18,7 +18,6 @@ """ import warnings from functools import wraps -from importlib.metadata import distribution import pennylane as qml @@ -27,12 +26,8 @@ def catalyst_qjit(qnode): - """The ``catalyst.while`` wrapper method""" - try: - distribution("pennylane_catalyst") - return qnode.__class__.__name__ == "QJIT" - except ImportError: - return False + """A method checking whether a qnode is compiled by catalyst.qjit""" + return qnode.__class__.__name__ == "QJIT" and hasattr(qnode, "user_function") def draw( diff --git a/pennylane/logging/__init__.py b/pennylane/logging/__init__.py index b036d69d66b..f670be47e19 100644 --- a/pennylane/logging/__init__.py +++ b/pennylane/logging/__init__.py @@ -13,6 +13,8 @@ # limitations under the License. """This module enables support for log-level messaging throughout PennyLane, following the native Python logging framework interface. Please see the :doc:`PennyLane logging development guidelines`, and the official Python documentation for details on usage https://docs.python.org/3/library/logging.html""" -from .configuration import TRACE, config_path, enable_logging +from .configuration import TRACE, config_path, edit_system_config, enable_logging +from .decorators import debug_logger, debug_logger_init from .filter import DebugOnlyFilter, LocalProcessFilter from .formatters.formatter import DefaultFormatter, SimpleFormatter +from .utils import _add_logging_all diff --git a/pennylane/logging/configuration.py b/pennylane/logging/configuration.py index 8826146e669..c78ce01beb9 100644 --- a/pennylane/logging/configuration.py +++ b/pennylane/logging/configuration.py @@ -17,8 +17,11 @@ import logging import logging.config import os +import platform +import subprocess from importlib import import_module from importlib.util import find_spec +from typing import Optional has_toml = False toml_libs = ["tomllib", "tomli", "tomlkit"] @@ -53,10 +56,15 @@ def trace(self, message, *args, **kws): lc.trace = trace -def _configure_logging(config_file): +def _configure_logging(config_file: str, config_override: Optional[dict] = None): """ This method allows custom logging configuration throughout PennyLane. - All configurations are read through the ``log_config.toml`` file. + All configurations are read through the ``log_config.toml`` file, with additional custom options provided through the ``config_override`` dictionary. + + Args: + config_file (str): The path to a given log configuration file, parsed as TOML and adhering to the ``logging.config.dictConfig`` end-point. + + config_override (Optional[dict]): A dictionary with keys-values that override the default configuration options in the given ``config_file`` TOML. """ if not has_toml: raise ImportError( @@ -68,21 +76,27 @@ def _configure_logging(config_file): ) with open(os.path.join(_path, config_file), "rb") as f: pl_config = tomllib.load(f) - logging.config.dictConfig(pl_config) + if not config_override: + logging.config.dictConfig(pl_config) + else: + logging.config.dictConfig({**pl_config, **config_override}) -def enable_logging(): +def enable_logging(config_file: str = "log_config.toml"): """ This method allows to selectively enable logging throughout PennyLane, following the configuration options defined in the ``log_config.toml`` file. Enabling logging through this method will override any externally defined logging configurations. + Args: + config_file (str): The path to a given log configuration file, parsed as TOML and adhering to the ``logging.config.dictConfig`` end-point. The default argument uses the PennyLane ecosystem log-file configuration, located at the directory returned from :func:`pennylane.logging.config_path`. + **Example** >>> qml.logging.enable_logging() """ _add_trace_level() - _configure_logging("log_config.toml") + _configure_logging(config_file) def config_path(): @@ -99,3 +113,43 @@ def config_path(): """ path = os.path.join(_path, "log_config.toml") return path + + +def show_system_config(): + """ + This function opens the logging configuration file in the system-default browser. + """ + # pylint:disable = import-outside-toplevel + import webbrowser + + webbrowser.open(config_path()) + + +def edit_system_config(wait_on_close=False): + """ + This function opens the log configuration file using OS-specific editors. + + Setting the ``EDITOR`` environment variable will override ``xdg-open/open`` on + Linux and MacOS, and allows use of ``wait_on_close`` for editor close before + continuing execution. + + .. warning:: + + As each OS configuration differs user-to-user, you may wish to + instead open this file manually with the ``config_path()`` provided path. + """ + if editor := os.getenv("EDITOR"): + # pylint:disable = consider-using-with + with subprocess.Popen((editor, config_path())) as p: + if wait_on_close: # Only valid when editor is known + p.wait() + # pylint:disable = superfluous-parens + elif (s := platform.system()) in ["Linux", "Darwin"]: + f_cmd = None + if s == "Linux": + f_cmd = "xdg-open" + else: + f_cmd = "open" + subprocess.Popen((f_cmd, config_path())) + else: # Windows-only, does not exist on MacOS/Linux + os.startfile(config_path()) # pylint:disable = no-member diff --git a/pennylane/logging/decorators.py b/pennylane/logging/decorators.py new file mode 100644 index 00000000000..66a9a104efa --- /dev/null +++ b/pennylane/logging/decorators.py @@ -0,0 +1,82 @@ +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This file expands the PennyLane logging functionality to allow additions for function entry and exit logging via decorators.""" + +import inspect +import logging +from functools import partial, wraps + +# Stack level allows moving up the stack with the log records, and prevents +# the decorator function names appearing in the resulting messages. +_debug_log_kwargs = {"stacklevel": 2} + + +def log_string_debug_func(func, log_level, use_entry, override=None): + """ + This decorator utility generates a string containing the called function, the passed arguments, and the source of the function call. + """ + lgr = logging.getLogger(func.__module__) + + def _get_bound_signature(*args, **kwargs) -> str: + s = inspect.signature(func) + # pylint:disable = broad-except + try: + ba = s.bind(*args, **kwargs) + except Exception as e: + # If kwargs are concat onto args, attempt to unpack. Fail otherwise + if len(args) == 2 and len(kwargs) == 0 and isinstance(args[1], dict): + ba = s.bind(*args[0], **args[1]) + else: + raise e + ba.apply_defaults() + if override and len(override): + for k, v in override.keys(): + ba[k] = v + + f_string = str(ba).replace("BoundArguments ", func.__name__) + return f_string + + @wraps(func) + def wrapper_entry(*args, **kwargs): + if lgr.isEnabledFor(log_level): # pragma: no cover + f_string = _get_bound_signature(*args, **kwargs) + s_caller = "::L".join( + [str(i) for i in inspect.getouterframes(inspect.currentframe(), 2)[1][1:3]] + ) + lgr.debug( + f"Calling {f_string} from {s_caller}", + **_debug_log_kwargs, + ) + return func(*args, **kwargs) + + @wraps(func) + def wrapper_exit(*args, **kwargs): + output = func(*args, **kwargs) + if lgr.isEnabledFor(log_level): # pragma: no cover + f_string = _get_bound_signature(*args, **kwargs) + s_caller = "::L".join( + [str(i) for i in inspect.getouterframes(inspect.currentframe(), 2)[1][1:3]] + ) + lgr.debug( + f"Calling {f_string}={output} from {s_caller}", + **{"stacklevel": 2}, + ) + return output + + return wrapper_entry if use_entry else wrapper_exit + + +# For ease-of-use ``debug_logger`` is provided for decoration of public methods and free functions, with ``debug_logger_init`` provided for use with class constructors. +debug_logger = partial(log_string_debug_func, log_level=logging.DEBUG, use_entry=True) +debug_logger_init = partial(log_string_debug_func, log_level=logging.DEBUG, use_entry=False) diff --git a/pennylane/logging/formatters/__init__.py b/pennylane/logging/formatters/__init__.py index 33297d98677..ed35942b601 100644 --- a/pennylane/logging/formatters/__init__.py +++ b/pennylane/logging/formatters/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. """This module defines formatting rules for the PennyLane loggers.""" -from .formatter import DefaultFormatter, SimpleFormatter +from .formatter import DefaultFormatter, DynamicFormatter, SimpleFormatter diff --git a/pennylane/logging/formatters/formatter.py b/pennylane/logging/formatters/formatter.py index e3a6e9893c7..09daa5e9570 100644 --- a/pennylane/logging/formatters/formatter.py +++ b/pennylane/logging/formatters/formatter.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """The PennyLane log-level formatters are defined here with default options, and ANSI-terminal color-codes.""" +import inspect import logging from logging import Formatter from typing import NamedTuple, Tuple, Union @@ -77,7 +78,7 @@ def bash_ansi_codes(): class DefaultFormatter(Formatter): """This formatter has the default rules used for formatting PennyLane log messages.""" - fmt_str = '[%(asctime)s][%(levelname)s][] - %(name)s.%(funcName)s()::"%(message)s"' + fmt_str = '[%(asctime)s][%(levelname)s][] - %(name)s.%(funcName)s::"%(message)s"\n' # 0x000000 Background _text_bg = (0, 0, 0) @@ -114,6 +115,55 @@ def format(self, record): return formatter.format(record) +class DynamicFormatter(Formatter): + """This formatter has the default rules used for formatting PennyLane log messages, with a dynamically updated log format rule""" + + # 0x000000 Background + _text_bg = (0, 0, 0) + + cmap = ColorScheme( + debug=(220, 238, 200), # Grey 1 + debug_bg=_text_bg, + info=(80, 125, 125), # Blue + info_bg=_text_bg, + warning=(208, 167, 133), # Orange + warning_bg=_text_bg, + error=(208, 133, 133), # Red 1 + error_bg=_text_bg, + critical=(135, 53, 53), # Red 2 + critical_bg=(210, 210, 210), # Grey 2 + use_rgb=True, + ) + + @staticmethod + def _build_formats(fmt_str): + cmap = DynamicFormatter.cmap + local_formats = { + logging.DEBUG: build_code_rgb(cmap.debug, cmap.debug_bg) + fmt_str + _ANSI_CODES["end"], + logging.INFO: build_code_rgb(cmap.info, cmap.info_bg) + fmt_str + _ANSI_CODES["end"], + logging.WARNING: build_code_rgb(cmap.warning, cmap.warning_bg) + + fmt_str + + _ANSI_CODES["end"], + logging.ERROR: build_code_rgb(cmap.error, cmap.error_bg) + fmt_str + _ANSI_CODES["end"], + logging.CRITICAL: build_code_rgb(cmap.critical, cmap.critical_bg) + + fmt_str + + _ANSI_CODES["end"], + } + + return local_formats + + def format(self, record): + f = inspect.getouterframes(inspect.currentframe())[ + 8 + ] # Stack depth from log-func call to format function + + fmt_str = f'[%(asctime)s][%(levelname)s][] - %(name)s.{f.function}()::"%(message)s"' + + log_fmt = self._build_formats(fmt_str).get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + class SimpleFormatter(Formatter): """This formatter has a simplified layout and rules used for formatting PennyLane log messages.""" diff --git a/pennylane/logging/log_config.toml b/pennylane/logging/log_config.toml index 25f3b980e3f..dc8fcec97f2 100644 --- a/pennylane/logging/log_config.toml +++ b/pennylane/logging/log_config.toml @@ -13,6 +13,9 @@ version = 1 [formatters.qml_default_formatter] "()" = "pennylane.logging.formatters.formatter.DefaultFormatter" +[formatters.qml_dynamic_formatter] +"()" = "pennylane.logging.formatters.formatter.DynamicFormatter" + [formatters.qml_alt_formatter] "()" = "pennylane.logging.formatters.formatter.SimpleFormatter" @@ -46,12 +49,24 @@ formatter = "qml_default_formatter" level = "DEBUG" stream = "ext://sys.stdout" +[handlers.qml_debug_stream_dyn] +class = "logging.StreamHandler" +formatter = "qml_dynamic_formatter" +level = "DEBUG" +stream = "ext://sys.stdout" + [handlers.qml_debug_stream_alt] class = "logging.StreamHandler" formatter = "qml_alt_formatter" level = "DEBUG" stream = "ext://sys.stdout" +[handlers.qml_debug_syslog] +class = "logging.handlers.SysLogHandler" +formatter = "local_standard" +level = "DEBUG" +address = "/dev/log" + [handlers.qml_debug_file] class = "logging.handlers.RotatingFileHandler" formatter = "local_standard" @@ -80,6 +95,12 @@ handlers = ["qml_debug_stream",] level = "WARN" propagate = false +# Control JAXlib logging +[loggers.jaxlib] +handlers = ["qml_debug_stream",] +level = "WARN" +propagate = false + # Control logging across pennylane [loggers.pennylane] handlers = ["qml_debug_stream",] @@ -93,4 +114,10 @@ handlers = ["qml_debug_stream",] level = "DEBUG" # Set to TRACE for highest verbosity propagate = false +# Control logging across catalyst +[loggers.catalyst] +handlers = ["qml_debug_stream",] +level = "DEBUG" # Set to TRACE for highest verbosity +propagate = false + ############################################################################### diff --git a/pennylane/logging/utils.py b/pennylane/logging/utils.py new file mode 100644 index 00000000000..e8da7b0b117 --- /dev/null +++ b/pennylane/logging/utils.py @@ -0,0 +1,38 @@ +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This file provides developer-facing functionality for the PennyLane logging module.""" +import inspect +import sys + +from .decorators import debug_logger + + +def _is_local_fn(f, mod_name): + """ + Predicate that validates if argument ``f`` is a local function belonging to module ``mod_name``. + """ + is_func = inspect.isfunction(f) + is_local_to_mod = inspect.getmodule(f).__name__ == mod_name + return is_func and is_local_to_mod + + +def _add_logging_all(mod_name): + """ + Modifies the module ``mod_name`` to add logging implicitly to all free-functions. + """ + l_func = inspect.getmembers( + sys.modules[mod_name], predicate=lambda x: _is_local_fn(x, mod_name) + ) + for f_name, f in l_func: + globals()[f_name] = debug_logger(f) diff --git a/pennylane/ops/functions/matrix.py b/pennylane/ops/functions/matrix.py index 035fb438a90..eafb8dc38a1 100644 --- a/pennylane/ops/functions/matrix.py +++ b/pennylane/ops/functions/matrix.py @@ -27,6 +27,11 @@ from pennylane.typing import TensorLike +def catalyst_qjit(qnode): + """A method checking whether a qnode is compiled by catalyst.qjit""" + return qnode.__class__.__name__ == "QJIT" and hasattr(qnode, "user_function") + + def matrix(op: Union[Operator, PauliWord, PauliSentence], wire_order=None) -> TensorLike: r"""The matrix representation of an operation or quantum circuit. @@ -177,6 +182,9 @@ def circuit(): wires specified, and this is the order in which wires appear in ``circuit()``. """ + if catalyst_qjit(op): + op = op.user_function + if not isinstance(op, Operator): if isinstance(op, (PauliWord, PauliSentence)): diff --git a/pennylane/ops/qubit/hamiltonian.py b/pennylane/ops/qubit/hamiltonian.py index 0d6a1d96880..3f7ec292392 100644 --- a/pennylane/ops/qubit/hamiltonian.py +++ b/pennylane/ops/qubit/hamiltonian.py @@ -775,8 +775,9 @@ def __rmatmul__(self, H): ops1 = self.ops.copy() if isinstance(H, (Tensor, Observable)): + qml.QueuingManager.remove(H) + qml.QueuingManager.remove(self) terms = [copy(H) @ op for op in ops1] - return qml.simplify(Hamiltonian(coeffs1, terms)) return NotImplemented @@ -790,11 +791,15 @@ def __add__(self, H): return self if isinstance(H, Hamiltonian): + qml.QueuingManager.remove(H) + qml.QueuingManager.remove(self) coeffs = qml.math.concatenate([self_coeffs, copy(H.coeffs)], axis=0) ops.extend(H.ops.copy()) return qml.simplify(Hamiltonian(coeffs, ops)) if isinstance(H, (Tensor, Observable)): + qml.QueuingManager.remove(H) + qml.QueuingManager.remove(self) coeffs = qml.math.concatenate( [self_coeffs, qml.math.cast_like([1.0], self_coeffs)], axis=0 ) diff --git a/pennylane/qnn/keras.py b/pennylane/qnn/keras.py index 675db560f0b..8a6ec4084a1 100644 --- a/pennylane/qnn/keras.py +++ b/pennylane/qnn/keras.py @@ -1,4 +1,4 @@ -# Copyright 2018-2021 Xanadu Quantum Technologies Inc. +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ from collections.abc import Iterable from typing import Optional, Text -from semantic_version import Version +from packaging.version import Version try: import tensorflow as tf diff --git a/pennylane/templates/layers/gate_fabric.py b/pennylane/templates/layers/gate_fabric.py index 7276dc51098..5f3a412477b 100644 --- a/pennylane/templates/layers/gate_fabric.py +++ b/pennylane/templates/layers/gate_fabric.py @@ -281,7 +281,7 @@ def shape(n_layers, n_wires): if n_wires < 4: raise ValueError( - f"This template requires the number of qubits to be greater than four; got 'n_wires' = {n_wires}" + f"This template requires the number of qubits to be at least four; got 'n_wires' = {n_wires}" ) if n_wires % 2: diff --git a/pennylane/templates/state_preparations/mottonen.py b/pennylane/templates/state_preparations/mottonen.py index 9bc5269e599..171a572c2ed 100644 --- a/pennylane/templates/state_preparations/mottonen.py +++ b/pennylane/templates/state_preparations/mottonen.py @@ -308,7 +308,7 @@ def __init__(self, state_vector, wires, id=None): norm = qml.math.sum(qml.math.abs(state) ** 2) if not qml.math.allclose(norm, 1.0, atol=1e-3): raise ValueError( - f"State vectors have to be of norm 1.0, vector {i} has norm {norm}" + f"State vectors have to be of norm 1.0, vector {i} has squared norm {norm}" ) super().__init__(state_vector, wires=wires, id=id) diff --git a/pennylane/templates/subroutines/all_singles_doubles.py b/pennylane/templates/subroutines/all_singles_doubles.py index 9350a9c115c..97d7c53057b 100644 --- a/pennylane/templates/subroutines/all_singles_doubles.py +++ b/pennylane/templates/subroutines/all_singles_doubles.py @@ -145,7 +145,7 @@ def __init__(self, weights, wires, hf_state, singles=None, doubles=None, id=None raise ValueError(f"'weights' tensor must be of shape {exp_shape}; got {weights_shape}.") if hf_state[0].dtype != np.dtype("int"): - raise ValueError(f"Elements of 'hf_state' must be integers; got {hf_state.dtype}") + raise ValueError(f"Elements of 'hf_state' must be integers; got {hf_state[0].dtype}") singles = tuple(tuple(s) for s in singles) doubles = tuple(tuple(d) for d in doubles) diff --git a/pennylane/templates/subroutines/amplitude_amplification.py b/pennylane/templates/subroutines/amplitude_amplification.py index f8119218876..08e050ff73f 100644 --- a/pennylane/templates/subroutines/amplitude_amplification.py +++ b/pennylane/templates/subroutines/amplitude_amplification.py @@ -107,6 +107,10 @@ def _flatten(self): metadata = tuple(item for item in self.hyperparameters.items() if item[0] not in ["O", "U"]) return data, metadata + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata): return cls(*data, **dict(metadata)) diff --git a/pennylane/templates/subroutines/approx_time_evolution.py b/pennylane/templates/subroutines/approx_time_evolution.py index 92d153c1f9c..2272e37c057 100644 --- a/pennylane/templates/subroutines/approx_time_evolution.py +++ b/pennylane/templates/subroutines/approx_time_evolution.py @@ -124,6 +124,10 @@ def _flatten(self): data = (h, self.data[-1]) return data, (self.hyperparameters["n"],) + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata): return cls(data[0], data[1], n=metadata[0]) diff --git a/pennylane/templates/subroutines/basis_rotation.py b/pennylane/templates/subroutines/basis_rotation.py index 16e4dd07cf7..346e8b8370f 100644 --- a/pennylane/templates/subroutines/basis_rotation.py +++ b/pennylane/templates/subroutines/basis_rotation.py @@ -100,6 +100,15 @@ class BasisRotation(Operation): num_wires = AnyWires grad_method = None + @classmethod + def _primitive_bind_call(cls, wires, unitary_matrix, check=False, id=None): + # pylint: disable=arguments-differ + if cls._primitive is None: + # guard against this being called when primitive is not defined. + return type.__call__(cls, wires, unitary_matrix, check=check, id=id) # pragma: no cover + + return cls._primitive.bind(*wires, unitary_matrix, check=check, id=id) + def __init__(self, wires, unitary_matrix, check=False, id=None): M, N = unitary_matrix.shape if M != N: @@ -176,3 +185,18 @@ def compute_decomposition( op_list.append(qml.PhaseShift(phi, wires=wires[indices[0]])) return op_list + + +# Program capture needs to unpack and re-pack the wires to support dynamic wires. For +# BasisRotation, the unconventional argument ordering requires custom def_impl code. +# See capture module for more information on primitives +# If None, jax isn't installed so the class never got a primitive. +if BasisRotation._primitive is not None: # pylint: disable=protected-access + + @BasisRotation._primitive.def_impl # pylint: disable=protected-access + def _(*args, **kwargs): + # If there are more than two args, we are calling with unpacked wires, so that + # we have to repack them. This replaces the n_wires logic in the general case. + if len(args) != 2: + args = (args[:-1], args[-1]) + return type.__call__(BasisRotation, *args, **kwargs) diff --git a/pennylane/templates/subroutines/commuting_evolution.py b/pennylane/templates/subroutines/commuting_evolution.py index 19b983b356d..1cb5ed00322 100644 --- a/pennylane/templates/subroutines/commuting_evolution.py +++ b/pennylane/templates/subroutines/commuting_evolution.py @@ -114,6 +114,10 @@ def _flatten(self): data = (self.data[0], h) return data, (self.hyperparameters["frequencies"], self.hyperparameters["shifts"]) + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata) -> "CommutingEvolution": return cls(data[1], data[0], frequencies=metadata[0], shifts=metadata[1]) diff --git a/pennylane/templates/subroutines/fermionic_double_excitation.py b/pennylane/templates/subroutines/fermionic_double_excitation.py index 26203439814..797189dbe54 100644 --- a/pennylane/templates/subroutines/fermionic_double_excitation.py +++ b/pennylane/templates/subroutines/fermionic_double_excitation.py @@ -504,6 +504,10 @@ def circuit(weight, wires1=None, wires2=None): def _flatten(self): return self.data, (self.hyperparameters["wires1"], self.hyperparameters["wires2"]) + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata) -> "FermionicDoubleExcitation": return cls(data[0], wires1=metadata[0], wires2=metadata[1]) diff --git a/pennylane/templates/subroutines/hilbert_schmidt.py b/pennylane/templates/subroutines/hilbert_schmidt.py index 56987a05b39..0837766e0ec 100644 --- a/pennylane/templates/subroutines/hilbert_schmidt.py +++ b/pennylane/templates/subroutines/hilbert_schmidt.py @@ -105,6 +105,12 @@ def _flatten(self): ) return self.data, metadata + @classmethod + def _primitive_bind_call(cls, *params, v_function, v_wires, u_tape, id=None): + # pylint: disable=arguments-differ + kwargs = {"v_function": v_function, "v_wires": v_wires, "u_tape": u_tape, "id": id} + return cls._primitive.bind(*params, **kwargs) + @classmethod def _unflatten(cls, data, metadata): return cls(*data, **dict(metadata)) diff --git a/pennylane/templates/subroutines/qdrift.py b/pennylane/templates/subroutines/qdrift.py index 6b7577a8ba0..a65152586e0 100644 --- a/pennylane/templates/subroutines/qdrift.py +++ b/pennylane/templates/subroutines/qdrift.py @@ -180,6 +180,10 @@ def my_circ(time): """ + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + def _flatten(self): h = self.hyperparameters["base"] hashable_hyperparameters = tuple( diff --git a/pennylane/templates/subroutines/qmc.py b/pennylane/templates/subroutines/qmc.py index 1ddc2a43301..50a764bc0d1 100644 --- a/pennylane/templates/subroutines/qmc.py +++ b/pennylane/templates/subroutines/qmc.py @@ -344,6 +344,10 @@ def circuit(): num_wires = AnyWires grad_method = None + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata): new_op = cls.__new__(cls) diff --git a/pennylane/templates/subroutines/qpe.py b/pennylane/templates/subroutines/qpe.py index 8daff7e6df0..0e2e0af44ba 100644 --- a/pennylane/templates/subroutines/qpe.py +++ b/pennylane/templates/subroutines/qpe.py @@ -150,6 +150,10 @@ def _flatten(self): metadata = (self.hyperparameters["estimation_wires"],) return data, metadata + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata) -> "QuantumPhaseEstimation": return cls(data[0], estimation_wires=metadata[0]) diff --git a/pennylane/templates/subroutines/qsvt.py b/pennylane/templates/subroutines/qsvt.py index d2e896944fc..fcefba2475b 100644 --- a/pennylane/templates/subroutines/qsvt.py +++ b/pennylane/templates/subroutines/qsvt.py @@ -268,6 +268,10 @@ def _flatten(self): data = (self.hyperparameters["UA"], self.hyperparameters["projectors"]) return data, tuple() + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, _) -> "QSVT": return cls(*data) diff --git a/pennylane/templates/subroutines/qubitization.py b/pennylane/templates/subroutines/qubitization.py index 5e19a9e9faa..ae45487404a 100644 --- a/pennylane/templates/subroutines/qubitization.py +++ b/pennylane/templates/subroutines/qubitization.py @@ -92,6 +92,10 @@ def circuit(): eigenvalue: 0.7 """ + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + def __init__(self, hamiltonian, control, id=None): wires = hamiltonian.wires + qml.wires.Wires(control) diff --git a/pennylane/templates/subroutines/reflection.py b/pennylane/templates/subroutines/reflection.py index 7483f5533aa..2b37ec88533 100644 --- a/pennylane/templates/subroutines/reflection.py +++ b/pennylane/templates/subroutines/reflection.py @@ -104,6 +104,10 @@ def circuit(): """ + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + def _flatten(self): data = (self.hyperparameters["base"], self.parameters[0]) return data, (self.hyperparameters["reflection_wires"],) diff --git a/pennylane/templates/subroutines/select.py b/pennylane/templates/subroutines/select.py index 107657c856e..357cf0113b0 100644 --- a/pennylane/templates/subroutines/select.py +++ b/pennylane/templates/subroutines/select.py @@ -69,6 +69,10 @@ class Select(Operation): def _flatten(self): return (self.ops), (self.control) + @classmethod + def _primitive_bind_call(cls, *args, **kwargs): + return cls._primitive.bind(*args, **kwargs) + @classmethod def _unflatten(cls, data, metadata) -> "Select": return cls(data, metadata) diff --git a/pennylane/templates/tensornetworks/mera.py b/pennylane/templates/tensornetworks/mera.py index 752aea4214f..5a4b6a1ee35 100644 --- a/pennylane/templates/tensornetworks/mera.py +++ b/pennylane/templates/tensornetworks/mera.py @@ -175,6 +175,19 @@ def circuit(template_weights): def num_params(self): return 1 + @classmethod + def _primitive_bind_call( + cls, wires, n_block_wires, block, n_params_block, template_weights=None, id=None + ): # pylint: disable=arguments-differ + return super()._primitive_bind_call( + wires=wires, + n_block_wires=n_block_wires, + block=block, + n_params_block=n_params_block, + template_weights=template_weights, + id=id, + ) + @classmethod def _unflatten(cls, data, metadata): new_op = cls.__new__(cls) diff --git a/pennylane/templates/tensornetworks/mps.py b/pennylane/templates/tensornetworks/mps.py index 40761f4c916..922c5d531fa 100644 --- a/pennylane/templates/tensornetworks/mps.py +++ b/pennylane/templates/tensornetworks/mps.py @@ -163,6 +163,29 @@ def circuit(): num_wires = AnyWires par_domain = "A" + @classmethod + def _primitive_bind_call( + cls, + wires, + n_block_wires, + block, + n_params_block, + template_weights=None, + offset=None, + id=None, + **kwargs, + ): # pylint: disable=arguments-differ + return super()._primitive_bind_call( + wires=wires, + n_block_wires=n_block_wires, + block=block, + n_params_block=n_params_block, + template_weights=template_weights, + id=id, + offset=offset, + **kwargs, + ) + @classmethod def _unflatten(cls, data, metadata): new_op = cls.__new__(cls) diff --git a/pennylane/templates/tensornetworks/ttn.py b/pennylane/templates/tensornetworks/ttn.py index 8e274f71d6c..7bd88ce2e9d 100644 --- a/pennylane/templates/tensornetworks/ttn.py +++ b/pennylane/templates/tensornetworks/ttn.py @@ -146,6 +146,19 @@ def circuit(template_weights): def num_params(self): return 1 + @classmethod + def _primitive_bind_call( + cls, wires, n_block_wires, block, n_params_block, template_weights=None, id=None + ): # pylint: disable=arguments-differ + return super()._primitive_bind_call( + wires=wires, + n_block_wires=n_block_wires, + block=block, + n_params_block=n_params_block, + template_weights=template_weights, + id=id, + ) + @classmethod def _unflatten(cls, data, metadata): new_op = cls.__new__(cls) diff --git a/pennylane/transforms/split_non_commuting.py b/pennylane/transforms/split_non_commuting.py index 6697273f13b..fa161a5eb64 100644 --- a/pennylane/transforms/split_non_commuting.py +++ b/pennylane/transforms/split_non_commuting.py @@ -1,4 +1,4 @@ -# Copyright 2018-2021 Xanadu Quantum Technologies Inc. +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,42 +11,43 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + """ -Contains the tape transform that splits non-commuting terms +Contains the tape transform that splits a tape into tapes measuring commuting observables. """ -from functools import reduce -# pylint: disable=protected-access -from typing import Callable, Sequence +# pylint: disable=too-many-arguments + +from functools import partial +from typing import Callable, Dict, List, Optional, Sequence, Tuple import pennylane as qml +from pennylane.measurements import ExpectationMP, MeasurementProcess, Shots, StateMP +from pennylane.ops import Hamiltonian, LinearCombination, Prod, SProd, Sum from pennylane.transforms import transform - - -def null_postprocessing(results): - """A postprocesing function returned by a transform that only converts the batch of results - into a result for a single ``QuantumTape``. - """ - return results[0] +from pennylane.typing import Result, ResultBatch @transform -def split_non_commuting(tape: qml.tape.QuantumTape) -> (Sequence[qml.tape.QuantumTape], Callable): - r""" - Splits a qnode measuring non-commuting observables into groups of commuting observables. +def split_non_commuting( + tape: qml.tape.QuantumScript, + grouping_strategy: Optional[str] = "default", +) -> Tuple[Sequence[qml.tape.QuantumTape], Callable]: + r"""Splits a circuit into tapes measuring groups of commuting observables. Args: - tape (QNode or QuantumTape or Callable): A circuit that contains a list of - non-commuting observables to measure. + tape (QNode or QuantumScript or Callable): The quantum circuit to be split. + grouping_strategy (str): The strategy to use for computing disjoint groups of + commuting observables, can be ``"default"``, ``"wires"``, ``"qwc"``, + or ``None`` to disable grouping. Returns: - qnode (QNode) or tuple[List[QuantumTape], function]: The transformed circuit as described in - :func:`qml.transform `. + qnode (QNode) or tuple[List[QuantumScript], function]: The transformed circuit as described in :func:`qml.transform `. - **Example** + **Examples:** - This transform allows us to transform a QNode that measures non-commuting observables to - *multiple* circuit executions with qubit-wise commuting groups: + This transform allows us to transform a QNode measuring multiple observables into multiple + circuit executions, each measuring a group of commuting observables. .. code-block:: python3 @@ -55,84 +56,143 @@ def split_non_commuting(tape: qml.tape.QuantumTape) -> (Sequence[qml.tape.Quantu @qml.transforms.split_non_commuting @qml.qnode(dev) def circuit(x): - qml.RX(x,wires=0) - return [qml.expval(qml.X(0)), qml.expval(qml.Z(0))] + qml.RY(x[0], wires=0) + qml.RX(x[1], wires=1) + return [ + qml.expval(qml.X(0)), + qml.expval(qml.Y(1)), + qml.expval(qml.Z(0) @ qml.Z(1)), + qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)), + ] - Instead of decorating the QNode, we can also create a new function that yields the same result - in the following way: + Instead of decorating the QNode, we can also create a new function that yields the same + result in the following way: .. code-block:: python3 @qml.qnode(dev) def circuit(x): - qml.RX(x,wires=0) - return [qml.expval(qml.X(0)), qml.expval(qml.Z(0))] + qml.RY(x[0], wires=0) + qml.RX(x[1], wires=1) + return [ + qml.expval(qml.X(0)), + qml.expval(qml.Y(1)), + qml.expval(qml.Z(0) @ qml.Z(1)), + qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)), + ] circuit = qml.transforms.split_non_commuting(circuit) - Internally, the QNode is split into groups of commuting observables when executed: - - >>> print(qml.draw(circuit)(0.5)) - 0: ──RX(0.50)─┤ - \ - 0: ──RX(0.50)─┤ - - Note that while internally multiple QNodes are created, the end result has the same ordering as - the user provides in the return statement. - Here is a more involved example where we can see the different ordering at the execution level - but restoring the original ordering in the output: + Internally, the QNode is split into multiple circuits when executed: + + >>> print(qml.draw(circuit)([np.pi/4, np.pi/4])) + 0: ──RY(0.79)─┤ ╭ + 1: ──RX(0.79)─┤ ╰ + + 0: ──RY(0.79)─┤ + 1: ──RX(0.79)─┤ + + 0: ──RY(0.79)─┤ ╭ + 1: ──RX(0.79)─┤ ╰ + + Note that the observable ``Y(1)`` occurs twice in the original QNode, but only once in the + transformed circuits. When there are multiple expecatation value measurements that rely on + the same observable, this observable is measured only once, and the result is copied to each + original measurement. + + While internally multiple tapes are created, the end result has the same ordering as the user + provides in the return statement. Executing the above QNode returns the original ordering of + the expectation values. + + >>> circuit([np.pi/4, np.pi/4]) + [0.7071067811865475, -0.7071067811865475, 0.5, 0.5] + + There are two algorithms used to compute disjoint groups of commuting observables: ``"qwc"`` + grouping uses :func:`~pennylane.pauli.group_observables` which computes groups of qubit-wise + commuting observables, producing the fewest number of circuit executions, but can be expensive + to compute for large multi-term Hamiltonians, while ``"wires"`` grouping simply ensures + that no circuit contains two measurements with overlapping wires, disregarding commutativity + between the observables being measured. + + The ``grouping_strategy`` keyword argument can be used to specify the grouping strategy. By + default, qwc grouping is used whenever possible, except when the circuit contains multiple + measurements that includes an expectation value of a ``qml.Hamiltonian``, in which case wires + grouping is used in case the Hamiltonian is very large, to save on classical runtime. To force + qwc grouping in all cases, set ``grouping_strategy="qwc"``. Similarly, to force wires grouping, + set ``grouping_strategy="wires"``: .. code-block:: python3 - @qml.transforms.split_non_commuting + @functools.partial(qml.transforms.split_non_commuting, grouping="wires") @qml.qnode(dev) - def circuit0(x): + def circuit(x): qml.RY(x[0], wires=0) - qml.RX(x[1], wires=0) - return [qml.expval(qml.X(0)), - qml.expval(qml.Z(0)), - qml.expval(qml.Y(1)), - qml.expval(qml.Z(0) @ qml.Z(1)), - ] + qml.RX(x[1], wires=1) + return [ + qml.expval(qml.X(0)), + qml.expval(qml.Y(1)), + qml.expval(qml.Z(0) @ qml.Z(1)), + qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)), + ] - Drawing this QNode unveils the separate executions in the background + In this case, four circuits are created as follows: - >>> print(qml.draw(circuit0)([np.pi/4, np.pi/4])) - 0: ──RY(0.79)──RX(0.79)─┤ - 1: ─────────────────────┤ - \ - 0: ──RY(0.79)──RX(0.79)─┤ - 1: ─────────────────────┤ ╰ + >>> print(qml.draw(circuit)([np.pi/4, np.pi/4])) + 0: ──RY(0.79)─┤ + 1: ──RX(0.79)─┤ + + 0: ──RY(0.79)─┤ ╭ + 1: ──RX(0.79)─┤ ╰ + + 0: ──RY(0.79)─┤ ╭ + 1: ──RX(0.79)─┤ ╰ + + 0: ──RY(0.79)─┤ + 1: ──RX(0.79)─┤ - Yet, executing it returns the original ordering of the expectation values. The outputs - correspond to - :math:`(\langle \sigma_x^0 \rangle, \langle \sigma_z^0 \rangle, \langle \sigma_y^1 \rangle, - \langle \sigma_z^0\sigma_z^1 \rangle)`. + Alternatively, to disable grouping completely, set ``grouping_strategy=None``: - >>> circuit0([np.pi/4, np.pi/4]) - [0.7071067811865475, 0.49999999999999994, 0.0, 0.49999999999999994] + .. code-block:: python3 + @functools.partial(qml.transforms.split_non_commuting, grouping_strategy=None) + @qml.qnode(dev) + def circuit(x): + qml.RY(x[0], wires=0) + qml.RX(x[1], wires=1) + return [ + qml.expval(qml.X(0)), + qml.expval(qml.Y(1)), + qml.expval(qml.Z(0) @ qml.Z(1)), + qml.expval(qml.X(0) @ qml.Z(1) + 0.5 * qml.Y(1) + qml.Z(0)), + ] + + In this case, each observable is measured in a separate circuit execution. + + >>> print(qml.draw(circuit)([np.pi/4, np.pi/4])) + 0: ──RY(0.79)─┤ + 1: ──RX(0.79)─┤ + + 0: ──RY(0.79)─┤ + 1: ──RX(0.79)─┤ + + 0: ──RY(0.79)─┤ ╭ + 1: ──RX(0.79)─┤ ╰ + + 0: ──RY(0.79)─┤ ╭ + 1: ──RX(0.79)─┤ ╰ + + 0: ──RY(0.79)─┤ + 1: ──RX(0.79)─┤ + + Note that there is an exception to the above rules: if the circuit only contains a single + expectation value measurement of a ``Hamiltonian`` or ``Sum`` with pre-computed grouping + indices, the grouping information will be used regardless of the requested ``grouping_strategy`` .. details:: :title: Usage Details - Internally, this function works with tapes. We can create a tape with non-commuting - observables: - - .. code-block:: python3 - - measurements = [qml.expval(qml.Z(0)), qml.expval(qml.Y(0))] - tape = qml.tape.QuantumTape(measurements=measurements) - - tapes, processing_fn = qml.transforms.split_non_commuting(tape) - - Now ``tapes`` is a list of two tapes, each for one of the non-commuting terms: - - >>> [t.observables for t in tapes] - [[expval(Z(0))], [expval(Y(0))]] - - The processing function becomes important when creating the commuting groups as the order - of the inputs has been modified: + Internally, this function works with tapes. We can create a tape with multiple + measurements of non-commuting observables: .. code-block:: python3 @@ -142,18 +202,25 @@ def circuit0(x): qml.expval(qml.Z(0)), qml.expval(qml.X(0)) ] - tape = qml.tape.QuantumTape(measurements=measurements) - + tape = qml.tape.QuantumScript(measurements=measurements) tapes, processing_fn = qml.transforms.split_non_commuting(tape) - In this example, the groupings are ``group_coeffs = [[0,2], [1,3]]`` and ``processing_fn`` - makes sure that the final output is of the same shape and ordering: + Now ``tapes`` is a list of two tapes, each contains a group of commuting observables: + + >>> [t.measurements for t in tapes] + [[expval(Z(0) @ Z(1)), expval(Z(0))], [expval(X(0) @ X(1)), expval(X(0))]] + + The processing function becomes important as the order of the inputs has been modified. - >>> processing_fn([t.measurements for t in tapes]) - (expval(Z(0) @ Z(1)), - expval(X(0) @ X(1)), - expval(Z(0)), - expval(X(0))) + >>> dev = qml.device("default.qubit", wires=2) + >>> result_batch = [dev.execute(t) for t in tapes] + >>> result_batch + [(1.0, 1.0), (0.0, 0.0)] + + The processing function can be used to reorganize the results: + + >>> processing_fn(result_batch) + (1.0, 0.0, 1.0, 0.0) Measurements that accept both observables and ``wires`` so that e.g. ``qml.counts``, ``qml.probs`` and ``qml.sample`` can also be used. When initialized using only ``wires``, @@ -167,75 +234,486 @@ def circuit0(x): qml.probs(wires=[1]), qml.probs(wires=[0, 1]) ] - tape = qml.tape.QuantumTape(measurements=measurements) - + tape = qml.tape.QuantumScript(measurements=measurements) tapes, processing_fn = qml.transforms.split_non_commuting(tape) This results in two tapes, each with commuting measurements: >>> [t.measurements for t in tapes] [[expval(X(0)), probs(wires=[1])], [probs(wires=[0, 1])]] + """ - # Construct a list of observables to group based on the measurements in the tape - obs_list = [] - for obs in tape.observables: - # observable provided for a measurement - if isinstance(obs, qml.operation.Operator): - obs_list.append(obs) - # measurements using wires instead of observables - else: - # create the PauliZ tensor product observable when only wires are provided for the - # measurements - obs_wires = obs.wires if obs.wires else tape.wires - pauliz_obs = qml.prod(*(qml.Z(wire) for wire in obs_wires)) - - obs_list.append(pauliz_obs) - - # If there is more than one group of commuting observables, split tapes - _, group_coeffs = qml.pauli.group_observables(obs_list, range(len(obs_list))) - - if len(group_coeffs) > 1: - # make one tape per commuting group - tapes = [] - for indices in group_coeffs: - new_tape = tape.__class__( - tape.operations, (tape.measurements[i] for i in indices), shots=tape.shots + # Special case for a single measurement of a Sum or Hamiltonian, in which case + # the grouping information can be computed and cached in the observable. + if ( + len(tape.measurements) == 1 + and isinstance(tape.measurements[0], ExpectationMP) + and isinstance(tape.measurements[0].obs, (Hamiltonian, Sum)) + and ( + grouping_strategy in ("default", "qwc") + or tape.measurements[0].obs.grouping_indices is not None + ) + ): + return _split_ham_with_grouping(tape) + + single_term_obs_mps, offsets = _split_all_multi_term_obs_mps(tape) + + if grouping_strategy is None: + measurements = list(single_term_obs_mps.keys()) + tapes = [tape.__class__(tape.operations, [m], shots=tape.shots) for m in measurements] + return tapes, partial( + _processing_fn_no_grouping, + single_term_obs_mps=single_term_obs_mps, + offsets=offsets, + shots=tape.shots, + batch_size=tape.batch_size, + ) + + if ( + grouping_strategy == "wires" + or grouping_strategy == "default" + and any( + isinstance(m, ExpectationMP) and isinstance(m.obs, (LinearCombination, Hamiltonian)) + for m in tape.measurements + ) + ): + # This is a loose check to see whether wires grouping or qwc grouping should be used, + # which does not necessarily make perfect sense but is consistent with the old decision + # logic in `Device.batch_transform`. The premise is that qwc grouping is classically + # expensive but produces fewer tapes, whereas wires grouping is classically faster to + # compute, but inefficient quantum-wise. If this transform is to be added to a device's + # `preprocess`, it will be performed for every circuit execution, which can get very + # expensive if there is a large number of observables. The reasoning here is, large + # Hamiltonians typically come in the form of a `LinearCombination` or `Hamiltonian`, so + # if we see one of those, use wires grouping to be safe. Otherwise, use qwc grouping. + return _split_using_wires_grouping(tape, single_term_obs_mps, offsets) + + return _split_using_qwc_grouping(tape, single_term_obs_mps, offsets) + + +def _split_ham_with_grouping(tape: qml.tape.QuantumScript): + """Splits a tape measuring a single Hamiltonian or Sum and group commuting observables.""" + + obs = tape.measurements[0].obs + if obs.grouping_indices is None: + obs.compute_grouping() + + coeffs, obs_list = obs.terms() + + # The constant offset of the Hamiltonian, typically arising from Identity terms. + offset = 0 + + # A dictionary for measurements of each unique single-term observable, mapped to the + # indices of the original measurements it belongs to, its coefficients, the index of + # the group it belongs to, and the index of the measurement in the group. + single_term_obs_mps = {} + + # A list of lists for each group of commuting measurement processes. + mp_groups = [] + + # The number of measurements in each group + group_sizes = [] + + # obs.grouping_indices is a list of lists, where each list contains the indices of + # observables that belong in each group. + for group_idx, obs_indices in enumerate(obs.grouping_indices): + mp_group = [] + group_size = 0 + for obs_idx in obs_indices: + # Do not measure Identity terms, but track their contribution with the offset. + if isinstance(obs_list[obs_idx], qml.Identity): + offset += coeffs[obs_idx] + else: + new_mp = qml.expval(obs_list[obs_idx]) + if new_mp in single_term_obs_mps: + # If the Hamiltonian contains duplicate observables, it can be reused, + # and the coefficients for each duplicate should be combined. + single_term_obs_mps[new_mp] = ( + single_term_obs_mps[new_mp][0], + [single_term_obs_mps[new_mp][1][0] + coeffs[obs_idx]], + single_term_obs_mps[new_mp][2], + single_term_obs_mps[new_mp][3], + ) + else: + mp_group.append(new_mp) + single_term_obs_mps[new_mp] = ( + [0], + [coeffs[obs_idx]], + group_idx, + group_size, # the index of this measurement in the group + ) + group_size += 1 + + if group_size > 0: + mp_groups.append(mp_group) + group_sizes.append(group_size) + + tapes = [tape.__class__(tape.operations, mps, shots=tape.shots) for mps in mp_groups] + return tapes, partial( + _processing_fn_with_grouping, + single_term_obs_mps=single_term_obs_mps, + offsets=[offset], + group_sizes=group_sizes, + shots=tape.shots, + batch_size=tape.batch_size, + ) + + +def _split_using_qwc_grouping( + tape: qml.tape.QuantumScript, + single_term_obs_mps: Dict[MeasurementProcess, Tuple[List[int], List[float]]], + offsets: List[float], +): + """Split tapes using group_observables in the Pauli module. + + Args: + tape (~qml.tape.QuantumScript): The tape to be split. + single_term_obs_mps (Dict[MeasurementProcess, Tuple[List[int], List[float]]]): A dictionary + of measurements of each unique single-term observable, mapped to the indices of the + original measurements it belongs to, and its coefficients. + offsets (List[float]): Offsets associated with each original measurement in the tape. + + """ + + # The legacy device does not support state measurements combined with any other + # measurement, so each state measurement must be in its own tape. + state_measurements = [m for m in single_term_obs_mps if isinstance(m, StateMP)] + + measurements = [m for m in single_term_obs_mps if not isinstance(m, StateMP)] + obs_list = [_mp_to_obs(m, tape) for m in measurements] + index_groups = [] + if len(obs_list) > 0: + _, index_groups = qml.pauli.group_observables(obs_list, range(len(obs_list))) + + # A dictionary for measurements of each unique single-term observable, mapped to the + # indices of the original measurements it belongs to, its coefficients, the index of + # the group it belongs to, and the index of the measurement in the group. + single_term_obs_mps_grouped = {} + + mp_groups = [[] for _ in index_groups] + group_sizes = [] + for group_idx, obs_indices in enumerate(index_groups): + group_size = 0 + for obs_idx in obs_indices: + new_mp = measurements[obs_idx] + mp_groups[group_idx].append(new_mp) + single_term_obs_mps_grouped[new_mp] = ( + *single_term_obs_mps[new_mp], + group_idx, + group_size, ) + group_size += 1 + group_sizes.append(group_size) + + for state_mp in state_measurements: + mp_groups.append([state_mp]) + single_term_obs_mps_grouped[state_mp] = ( + *single_term_obs_mps[state_mp], + len(mp_groups) - 1, + 0, + ) + group_sizes.append(1) + + tapes = [tape.__class__(tape.operations, mps, shots=tape.shots) for mps in mp_groups] + return tapes, partial( + _processing_fn_with_grouping, + single_term_obs_mps=single_term_obs_mps_grouped, + offsets=offsets, + group_sizes=group_sizes, + shots=tape.shots, + batch_size=tape.batch_size, + ) + + +def _split_using_wires_grouping( + tape: qml.tape.QuantumScript, + single_term_obs_mps: Dict[MeasurementProcess, Tuple[List[int], List[float]]], + offsets: List[float], +): + """Split tapes by grouping observables based on overlapping wires. - tapes.append(new_tape) + Args: + tape (~qml.tape.QuantumScript): The tape to be split. + single_term_obs_mps (Dict[MeasurementProcess, Tuple[List[int], List[float]]]): A dictionary + of measurements of each unique single-term observable, mapped to the indices of the + original measurements it belongs to, and its coefficients. + offsets (List[float]): Offsets associated with each original measurement in the tape. - def reorder_fn(res): - """re-order the output to the original shape and order""" - # determine if shot vector is used - if len(tapes[0].measurements) == 1: - shot_vector_defined = isinstance(res[0], tuple) + """ + + mp_groups = [] + wires_for_each_group = [] + group_sizes = [] + + # A dictionary for measurements of each unique single-term observable, mapped to the + # indices of the original measurements it belongs to, its coefficient, the index of + # the group it belongs to, and the index of the measurement in the group. + single_term_obs_mps_grouped = {} + num_groups = 0 + + for smp, (mp_indices, coeffs) in single_term_obs_mps.items(): + + if len(smp.wires) == 0: # measurement acting on all wires + mp_groups.append([smp]) + wires_for_each_group.append(tape.wires) + group_sizes.append(1) + single_term_obs_mps_grouped[smp] = (mp_indices, coeffs, num_groups, 0) + num_groups += 1 + continue + + group_idx = 0 + added_to_existing_group = False + while not added_to_existing_group and group_idx < num_groups: + wires = wires_for_each_group[group_idx] + if len(wires) != 0 and len(qml.wires.Wires.shared_wires([wires, smp.wires])) == 0: + mp_groups[group_idx].append(smp) + wires_for_each_group[group_idx] += smp.wires + single_term_obs_mps_grouped[smp] = ( + mp_indices, + coeffs, + group_idx, + group_sizes[group_idx], + ) + group_sizes[group_idx] += 1 + added_to_existing_group = True + group_idx += 1 + + if not added_to_existing_group: + mp_groups.append([smp]) + wires_for_each_group.append(smp.wires) + group_sizes.append(1) + single_term_obs_mps_grouped[smp] = (mp_indices, coeffs, num_groups, 0) + num_groups += 1 + + tapes = [tape.__class__(tape.operations, mps, shots=tape.shots) for mps in mp_groups] + return tapes, partial( + _processing_fn_with_grouping, + single_term_obs_mps=single_term_obs_mps_grouped, + offsets=offsets, + group_sizes=group_sizes, + shots=tape.shots, + batch_size=tape.batch_size, + ) + + +def _split_all_multi_term_obs_mps(tape: qml.tape.QuantumScript): + """Splits all multi-term observables in a tape to measurements of single-term observables. + + Args: + tape (~qml.tape.QuantumScript): The tape with measurements to split. + + Returns: + single_term_obs_mps (Dict[MeasurementProcess, Tuple[List[int], List[float]]]): A + dictionary for measurements of each unique single-term observable, mapped to the + indices of the original measurements it belongs to, and its coefficients. + offsets (List[float]): Offsets associated with each original measurement in the tape. + + """ + + # The dictionary for measurements of each unique single-term observable, mapped the indices + # of the original measurements it belongs to, and its coefficients. + single_term_obs_mps = {} + + # Offsets associated with each original measurement in the tape (from Identity) + offsets = [] + + for mp_idx, mp in enumerate(tape.measurements): + obs = mp.obs + offset = 0 + if isinstance(mp, ExpectationMP) and isinstance(obs, (Hamiltonian, Sum, Prod, SProd)): + if isinstance(obs, SProd): + # This is necessary because SProd currently does not flatten into + # multiple terms if the base is a sum, which is needed here. + obs = obs.simplify() + # Break the observable into terms, and construct an ExpectationMP with each term. + for c, o in zip(*obs.terms()): + # If the observable is an identity, track it with a constant offset + if isinstance(o, qml.Identity): + offset += c + # If the single-term measurement already exists, it can be reused by all original + # measurements. In this case, add the existing single-term measurement to the list + # corresponding to this original measurement. + # pylint: disable=superfluous-parens + elif (sm := qml.expval(o)) in single_term_obs_mps: + single_term_obs_mps[sm][0].append(mp_idx) + single_term_obs_mps[sm][1].append(c) + # Otherwise, add this new measurement to the list of single-term measurements. + else: + single_term_obs_mps[sm] = ([mp_idx], [c]) + else: + # For all other measurement types, simply add them to the list of measurements. + if mp not in single_term_obs_mps: + single_term_obs_mps[mp] = ([mp_idx], [1]) else: - shot_vector_defined = isinstance(res[0][0], tuple) + single_term_obs_mps[mp][0].append(mp_idx) + single_term_obs_mps[mp][1].append(1) - res = list(zip(*res)) if shot_vector_defined else [res] + offsets.append(offset) - reorder_indxs = qml.math.concatenate(group_coeffs) + return single_term_obs_mps, offsets - res_ordered = [] - for shot_res in res: - # flatten the results - shot_res = reduce( - lambda x, y: x + list(y) if isinstance(y, (tuple, list)) else x + [y], - shot_res, - [], - ) - # reorder the tape results to match the user-provided order - shot_res = list(zip(range(len(shot_res)), shot_res)) - shot_res = sorted(shot_res, key=lambda r: reorder_indxs[r[0]]) - shot_res = [r[1] for r in shot_res] +def _processing_fn_no_grouping( + res: ResultBatch, + single_term_obs_mps: Dict[MeasurementProcess, Tuple[List[int], List[float]]], + offsets: List[float], + shots: Shots, + batch_size: int, +): + """Postprocessing function for the split_non_commuting transform without grouping. + + Args: + res (ResultBatch): The results from executing the tapes. Assumed to have a shape + of (n_groups [,n_shots] [,n_mps] [,batch_size]) + single_term_obs_mps (Dict[MeasurementProcess, Tuple[List[int], List[float]]]): A dictionary + of measurements of each unique single-term observable, mapped to the indices of the + original measurements it belongs to, and its coefficients. + offsets (List[float]): Offsets associated with each original measurement in the tape. + shots (Shots): The shots settings of the original tape. + + """ + + res_batch_for_each_mp = [[] for _ in offsets] + coeffs_for_each_mp = [[] for _ in offsets] + + for smp_idx, (_, (mp_indices, coeffs)) in enumerate(single_term_obs_mps.items()): + + for mp_idx, coeff in zip(mp_indices, coeffs): + res_batch_for_each_mp[mp_idx].append(res[smp_idx]) + coeffs_for_each_mp[mp_idx].append(coeff) + + result_shape = _infer_result_shape(shots, batch_size) + + # Sum up the results for each original measurement + res_for_each_mp = [ + _sum_terms(_sub_res, coeffs, offset, result_shape) + for _sub_res, coeffs, offset in zip(res_batch_for_each_mp, coeffs_for_each_mp, offsets) + ] + + # res_for_each_mp should have shape (n_mps, [,n_shots] [,batch_size]) + if len(res_for_each_mp) == 1: + return res_for_each_mp[0] + + if shots.has_partitioned_shots: + # If the shot vector dimension exists, it should be moved to the first axis + # Basically, the shape becomes (n_shots, n_mps, [,batch_size]) + res_for_each_mp = [ + tuple(res_for_each_mp[j][i] for j in range(len(res_for_each_mp))) + for i in range(shots.num_copies) + ] + + return tuple(res_for_each_mp) + + +def _processing_fn_with_grouping( + res: ResultBatch, + single_term_obs_mps: Dict[MeasurementProcess, Tuple[List[int], List[float], int, int]], + offsets: List[float], + group_sizes: List[int], + shots: Shots, + batch_size: int, +): + """Postprocessing function for the split_non_commuting transform with grouping. + + Args: + res (ResultBatch): The results from executing the tapes. Assumed to have a shape + of (n_groups [,n_shots] [,n_mps_in_group] [,batch_size]) + single_term_obs_mps (Dict[MeasurementProcess, Tuple[List[int], List[float], int, int]]): + A dictionary of measurements of each unique single-term observable, mapped to the + indices of the original measurements it belongs to, its coefficients, its group + index, and the index of the measurement within the group. + offsets (List[float]): Offsets associated with each original measurement in the tape. + group_sizes (List[int]): The number of tapes in each group. + shots (Shots): The shots setting of the original tape. + + Returns: + The results combined into a single result for each original measurement. + + """ + + res_batch_for_each_mp = [[] for _ in offsets] # ([n_mps] [,n_shots] [,batch_size]) + coeffs_for_each_mp = [[] for _ in offsets] + + for _, (mp_indices, coeffs, group_idx, mp_idx_in_group) in single_term_obs_mps.items(): + + res_group = res[group_idx] # ([n_shots] [,n_mps] [,batch_size]) + group_size = group_sizes[group_idx] + + if group_size > 1 and shots.has_partitioned_shots: + # Each result should have shape ([n_shots] [,batch_size]) + sub_res = [_res[mp_idx_in_group] for _res in res_group] + else: + # If there is only one term in the group, the n_mps dimension would have + # been squeezed out, use the entire result directly. + sub_res = res_group if group_size == 1 else res_group[mp_idx_in_group] + + # Add this result to the result batch for the corresponding original measurement + for mp_idx, coeff in zip(mp_indices, coeffs): + res_batch_for_each_mp[mp_idx].append(sub_res) + coeffs_for_each_mp[mp_idx].append(coeff) + + result_shape = _infer_result_shape(shots, batch_size) + + # Sum up the results for each original measurement + res_for_each_mp = [ + _sum_terms(_sub_res, coeffs, offset, result_shape) + for _sub_res, coeffs, offset in zip(res_batch_for_each_mp, coeffs_for_each_mp, offsets) + ] + + # res_for_each_mp should have shape (n_mps, [,n_shots] [,batch_size]) + if len(res_for_each_mp) == 1: + return res_for_each_mp[0] + + if shots.has_partitioned_shots: + # If the shot vector dimension exists, it should be moved to the first axis + # Basically, the shape becomes (n_shots, n_mps, [,batch_size]) + res_for_each_mp = [ + tuple(res_for_each_mp[j][i] for j in range(len(res_for_each_mp))) + for i in range(shots.num_copies) + ] + + return tuple(res_for_each_mp) + + +def _sum_terms(res: ResultBatch, coeffs: List[float], offset: float, shape: Tuple) -> Result: + """Sum results from measurements of multiple terms in a multi-term observable.""" + + # Trivially return the original result + if coeffs == [1] and offset == 0: + return res[0] + + # The shape of res at this point is (n_terms, [,n_shots] [,batch_size]) + dot_products = [] + for c, r in zip(coeffs, res): + dot_products.append(qml.math.dot(qml.math.squeeze(r), c)) + if len(dot_products) == 0: + return qml.math.ones(shape) * offset + summed_dot_products = qml.math.sum(qml.math.stack(dot_products), axis=0) + return qml.math.convert_like(summed_dot_products + offset, res[0]) + + +def _mp_to_obs(mp: MeasurementProcess, tape: qml.tape.QuantumScript) -> qml.operation.Operator: + """Extract the observable from a measurement process. + + If the measurement process has an observable, return it. Otherwise, return a dummy + observable that is a tensor product of Z gates on every wire. + + """ + + if mp.obs is not None: + return mp.obs - res_ordered.append(tuple(shot_res)) + obs_wires = mp.wires if mp.wires else tape.wires + return qml.prod(*(qml.Z(wire) for wire in obs_wires)) - return tuple(res_ordered) if shot_vector_defined else res_ordered[0] - return tapes, reorder_fn +def _infer_result_shape(shots: Shots, batch_size: int) -> Tuple: + """Based on the result, infer the ([,n_shots] [,batch_size]) shape of the result.""" - # if the group is already commuting, no need to do anything - return [tape], null_postprocessing + shape = () + if shots.has_partitioned_shots: + shape += (shots.num_copies,) + if batch_size and batch_size > 1: + shape += (batch_size,) + return shape diff --git a/pennylane/workflow/qnode.py b/pennylane/workflow/qnode.py index a925a85635a..947a636b08b 100644 --- a/pennylane/workflow/qnode.py +++ b/pennylane/workflow/qnode.py @@ -25,6 +25,7 @@ import pennylane as qml from pennylane import Device +from pennylane.logging import debug_logger from pennylane.measurements import CountsMP, MidMeasureMP, Shots from pennylane.tape import QuantumScript, QuantumTape @@ -577,6 +578,7 @@ def transform_program(self): """The transform program used by the QNode.""" return self._transform_program + @debug_logger def add_transform(self, transform_container): """Add a transform (container) to the transform program. @@ -601,7 +603,7 @@ def _update_gradient_fn(self, shots=None, tape=None): ): diff_method = "parameter-shift" - self.gradient_fn, self.gradient_kwargs, self.device = self.get_gradient_fn( + self.gradient_fn, self.gradient_kwargs, self.device = QNode.get_gradient_fn( self._original_device, self.interface, diff_method, tape=tape ) self.gradient_kwargs.update(self._user_gradient_kwargs or {}) @@ -625,6 +627,7 @@ def _update_original_device(self): # pylint: disable=too-many-return-statements @staticmethod + @debug_logger def get_gradient_fn( device, interface, diff_method="best", tape: Optional["qml.tape.QuantumTape"] = None ): @@ -696,6 +699,7 @@ def get_gradient_fn( ) @staticmethod + @debug_logger def get_best_method(device, interface, tape=None): """Returns the 'best' differentiation method for a particular device and interface combination. @@ -742,6 +746,7 @@ def get_best_method(device, interface, tape=None): return qml.gradients.finite_diff, {}, device @staticmethod + @debug_logger def best_method_str(device, interface): """Similar to :meth:`~.get_best_method`, except return the 'best' differentiation method in human-readable format. @@ -780,6 +785,7 @@ def best_method_str(device, interface): return transform @staticmethod + @debug_logger def _validate_backprop_method(device, interface, tape=None): if isinstance(device, qml.devices.Device): raise ValueError( @@ -912,6 +918,7 @@ def tape(self) -> QuantumTape: qtape = tape # for backwards compatibility + @debug_logger def construct(self, args, kwargs): # pylint: disable=too-many-branches """Call the quantum function with a tape context, ensuring the operations get queued.""" kwargs = copy.copy(kwargs) diff --git a/requirements-ci.txt b/requirements-ci.txt index 932820a266d..102f6521586 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -8,6 +8,7 @@ autograd toml appdirs semantic_version +packaging autoray>=0.6.1,<0.6.10 matplotlib requests diff --git a/requirements.txt b/requirements.txt index 869e241616a..35ffc8d4378 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ autograd~=1.4 toml~=0.10 appdirs~=1.4 semantic_version~=2.10 +packaging autoray>=0.6.11 matplotlib~=3.5 opt_einsum~=3.3 diff --git a/setup.py b/setup.py index 892e962ec14..6466a02abce 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright 2018-2020 Xanadu Quantum Technologies Inc. +# Copyright 2018-2024 Xanadu Quantum Technologies Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ "pennylane-lightning>=0.36", "requests", "typing_extensions", + "packaging", ] info = { diff --git a/tests/.pylintrc b/tests/.pylintrc index e19ecaaf4dd..a60dace1845 100644 --- a/tests/.pylintrc +++ b/tests/.pylintrc @@ -2,7 +2,7 @@ # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code -extension-pkg-whitelist=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag +extension-pkg-whitelist=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,packaging,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag [TYPECHECK] @@ -10,12 +10,12 @@ extension-pkg-whitelist=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. -ignored-modules=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy +ignored-modules=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,packaging,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). This supports can work # with qualified names. -ignored-classes=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy,pennylane.numpy.random,pennylane.numpy.linalg,pennylane.numpy.builtins,pennylane.operation,rustworkx,kahypar +ignored-classes=numpy,scipy,autograd,toml,appdir,autograd.numpy,autograd.numpy.linalg,autograd.numpy.builtins,semantic_version,packaging,torch,tensorflow,tensorflow.contrib,tensorflow.contrib.eager,LazyLoader,networkx,networkx.dag,math,pennylane.numpy,pennylane.numpy.random,pennylane.numpy.linalg,pennylane.numpy.builtins,pennylane.operation,rustworkx,kahypar [MESSAGES CONTROL] diff --git a/tests/capture/test_operators.py b/tests/capture/test_operators.py index d3c096c5c15..12324cba304 100644 --- a/tests/capture/test_operators.py +++ b/tests/capture/test_operators.py @@ -54,6 +54,7 @@ def test_abstract_operator(): # arithmetic dunders integration tested +@pytest.mark.usefixtures("new_opmath_only") def test_operators_constructed_when_plxpr_enabled(): """Test that normal operators can still be constructed when plxpr is enabled.""" @@ -363,6 +364,7 @@ def qfunc(): assert isinstance(eqn.outvars[0].aval, AbstractOperator) + @pytest.mark.usefixtures("new_opmath_only") def test_mul(self): """Test that the scalar multiplication dunder works.""" diff --git a/tests/capture/test_templates.py b/tests/capture/test_templates.py new file mode 100644 index 00000000000..06a8f3139ed --- /dev/null +++ b/tests/capture/test_templates.py @@ -0,0 +1,759 @@ +# Copyright 2024 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Integration tests for the capture of PennyLane templates into plxpr. +""" +import inspect + +# pylint: disable=protected-access +from typing import Any + +import numpy as np +import pytest + +import pennylane as qml +from pennylane.capture.primitives import _get_abstract_operator + +jax = pytest.importorskip("jax") +jnp = jax.numpy + +pytestmark = pytest.mark.jax + +AbstractOperator = _get_abstract_operator() +original_op_bind_code = qml.operation.Operator._primitive_bind_call.__code__ + + +@pytest.fixture(autouse=True) +def enable_disable_plxpr(): + qml.capture.enable() + yield + qml.capture.disable() + + +unmodified_templates_cases = [ + (qml.AmplitudeEmbedding, (jnp.array([1.0, 0.0]), 2), {}), + (qml.AmplitudeEmbedding, (jnp.eye(4)[2], [2, 3]), {"normalize": False}), + (qml.AmplitudeEmbedding, (jnp.array([0.3, 0.1, 0.2]),), {"pad_with": 1.2, "wires": [0, 3]}), + (qml.AngleEmbedding, (jnp.array([1.0, 0.0]), [2, 3]), {}), + (qml.AngleEmbedding, (jnp.array([0.4]), [0]), {"rotation": "X"}), + (qml.AngleEmbedding, (jnp.array([0.3, 0.1, 0.2]),), {"rotation": "Z", "wires": [0, 2, 3]}), + (qml.BasisEmbedding, (jnp.array([1, 0]), [2, 3]), {}), + (qml.BasisEmbedding, (), {"features": jnp.array([1, 0]), "wires": [2, 3]}), + (qml.BasisEmbedding, (6, [0, 5, 2]), {"id": "my_id"}), + (qml.BasisEmbedding, (jnp.array([1, 0, 1]),), {"wires": [0, 2, 3]}), + (qml.IQPEmbedding, (jnp.array([2.3, 0.1]), [2, 0]), {}), + (qml.IQPEmbedding, (jnp.array([0.4, 0.2, 0.1]), [2, 1, 0]), {"pattern": [[2, 0], [1, 0]]}), + (qml.IQPEmbedding, (jnp.array([0.4, 0.1]), [0, 10]), {"n_repeats": 3, "pattern": None}), + (qml.QAOAEmbedding, (jnp.array([1.0, 0.0]), jnp.ones((3, 3)), [2, 3]), {}), + (qml.QAOAEmbedding, (jnp.array([0.4]), jnp.ones((2, 1)), [0]), {"local_field": "X"}), + ( + qml.QAOAEmbedding, + (jnp.array([0.3, 0.1, 0.2]), jnp.zeros((2, 6))), + {"local_field": "Z", "wires": [0, 2, 3]}, + ), + (qml.BasicEntanglerLayers, (jnp.ones((5, 2)), [2, 3]), {}), + (qml.BasicEntanglerLayers, (jnp.ones((2, 1)), [0]), {"rotation": "X", "id": "my_id"}), + ( + qml.BasicEntanglerLayers, + (jnp.array([[0.3, 0.1, 0.2]]),), + {"rotation": "Z", "wires": [0, 2, 3]}, + ), + # Need to fix GateFabric positional args: Currently have to pass init_state as kwarg if we want to pass wires as kwarg + # https://github.com/PennyLaneAI/pennylane/issues/5521 + (qml.GateFabric, (jnp.ones((3, 1, 2)), [2, 3, 0, 1]), {"init_state": [0, 1, 1, 0]}), + ( + qml.GateFabric, + (jnp.zeros((2, 3, 2)),), + {"include_pi": False, "wires": list(range(8)), "init_state": jnp.ones(8)}, + ), + # (qml.GateFabric, (jnp.zeros((2, 3, 2)), jnp.ones(8)), {"include_pi": False, "wires": list(range(8))}), # Can't even init + # (qml.GateFabric, (jnp.ones((5, 2, 2)), list(range(6)), jnp.array([0, 0, 1, 1, 0, 1])), {"include_pi": True, "id": "my_id"}), # Can't trace + # https://github.com/PennyLaneAI/pennylane/issues/5522 + # (qml.ParticleConservingU1, (jnp.ones((3, 1, 2)), [2, 3]), {}), + (qml.ParticleConservingU1, (jnp.ones((3, 1, 2)), [2, 3]), {"init_state": [0, 1]}), + ( + qml.ParticleConservingU1, + (jnp.zeros((5, 3, 2)),), + {"wires": [0, 1, 2, 3], "init_state": jnp.ones(4)}, + ), + # https://github.com/PennyLaneAI/pennylane/issues/5522 + # (qml.ParticleConservingU2, (jnp.ones((3, 3)), [2, 3]), {}), + (qml.ParticleConservingU2, (jnp.ones((3, 3)), [2, 3]), {"init_state": [0, 1]}), + ( + qml.ParticleConservingU2, + (jnp.zeros((5, 7)),), + {"wires": [0, 1, 2, 3], "init_state": jnp.ones(4)}, + ), + (qml.RandomLayers, (jnp.ones((3, 3)), [2, 3]), {}), + (qml.RandomLayers, (jnp.ones((3, 3)),), {"wires": [3, 2, 1], "ratio_imprim": 0.5}), + (qml.RandomLayers, (), {"weights": jnp.ones((3, 3)), "wires": [3, 2, 1]}), + (qml.RandomLayers, (jnp.ones((3, 3)),), {"wires": [3, 2, 1], "rotations": (qml.RX, qml.RZ)}), + (qml.RandomLayers, (jnp.ones((3, 3)), [0, 1]), {"rotations": (qml.RX, qml.RZ), "seed": 41}), + (qml.SimplifiedTwoDesign, (jnp.ones(2), jnp.zeros((3, 1, 2)), [2, 3]), {}), + (qml.SimplifiedTwoDesign, (jnp.ones(3), jnp.zeros((3, 2, 2))), {"wires": [0, 1, 2]}), + (qml.SimplifiedTwoDesign, (jnp.ones(2),), {"weights": jnp.zeros((3, 1, 2)), "wires": [0, 2]}), + ( + qml.SimplifiedTwoDesign, + (), + {"initial_layer_weights": jnp.ones(2), "weights": jnp.zeros((3, 1, 2)), "wires": [0, 2]}, + ), + (qml.StronglyEntanglingLayers, (jnp.ones((3, 2, 3)), [2, 3]), {"ranges": [1, 1, 1]}), + ( + qml.StronglyEntanglingLayers, + (jnp.ones((1, 3, 3)),), + {"wires": [3, 2, 1], "imprimitive": qml.CZ}, + ), + (qml.StronglyEntanglingLayers, (), {"weights": jnp.ones((3, 3, 3)), "wires": [3, 2, 1]}), + (qml.ArbitraryStatePreparation, (jnp.ones(6), [2, 3]), {}), + (qml.ArbitraryStatePreparation, (jnp.zeros(14),), {"wires": [3, 2, 0]}), + (qml.ArbitraryStatePreparation, (), {"weights": jnp.ones(2), "wires": [1]}), + (qml.BasisStatePreparation, (jnp.array([0, 1]), [2, 3]), {}), + (qml.BasisStatePreparation, (jnp.ones(3),), {"wires": [3, 2, 0]}), + (qml.BasisStatePreparation, (), {"basis_state": jnp.ones(1), "wires": [1]}), + (qml.CosineWindow, ([2, 3],), {}), + (qml.CosineWindow, (), {"wires": [2, 0, 1]}), + (qml.MottonenStatePreparation, (jnp.ones(4) / 2, [2, 3]), {}), + ( + qml.MottonenStatePreparation, + (jnp.ones(8) / jnp.sqrt(8),), + {"wires": [3, 2, 0], "id": "your_id"}, + ), + (qml.MottonenStatePreparation, (), {"state_vector": jnp.array([1.0, 0.0]), "wires": [1]}), + # Need to fix AllSinglesDoubles positional args: Currently have to pass hf_state as kwarg + # if we want to pass wires as kwarg, see issue #5521 + ( + qml.AllSinglesDoubles, + (jnp.ones(3), [2, 3, 0, 1]), + { + "singles": [[0, 2], [1, 3]], + "doubles": [[2, 3, 0, 1]], + "hf_state": np.array([0, 1, 1, 0]), + }, + ), + ( + qml.AllSinglesDoubles, + (jnp.zeros(3),), + { + "singles": [[0, 2], [1, 3]], + "doubles": [[2, 3, 0, 1]], + "wires": list(range(8)), + "hf_state": np.ones(8, dtype=int), + }, + ), + # (qml.AllSinglesDoubles, (jnp.ones(3), [2, 3, 0, 1], np.array([0, 1, 1, 0])), {"singles": [[0, 2], [1, 3]], "doubles": [[2,3,0,1]]}), # Can't trace + (qml.AQFT, (1, [0, 1, 2]), {}), + (qml.AQFT, (2,), {"wires": [0, 1, 2, 3]}), + (qml.AQFT, (), {"order": 2, "wires": [0, 2, 3, 1]}), + (qml.QFT, ([0, 1],), {}), + (qml.QFT, (), {"wires": [0, 1]}), + (qml.ArbitraryUnitary, (jnp.ones(15), [2, 3]), {}), + (qml.ArbitraryUnitary, (jnp.zeros(15),), {"wires": [3, 2]}), + (qml.ArbitraryUnitary, (), {"weights": jnp.ones(3), "wires": [1]}), + (qml.FABLE, (jnp.eye(4), [2, 3, 0, 1, 5]), {}), + (qml.FABLE, (jnp.ones((4, 4)),), {"wires": [0, 3, 2, 1, 9]}), + ( + qml.FABLE, + (), + {"input_matrix": jnp.array([[1, 1], [1, -1]]) / np.sqrt(2), "wires": [1, 10, 17]}, + ), + (qml.FermionicSingleExcitation, (0.421,), {"wires": [0, 3, 2]}), + (qml.FlipSign, (7,), {"wires": [0, 3, 2]}), + (qml.FlipSign, (np.array([1, 0, 0]), [0, 1, 2]), {}), + ( + qml.kUpCCGSD, + (jnp.ones((1, 6)), [0, 1, 2, 3]), + {"k": 1, "delta_sz": 0, "init_state": [1, 1, 0, 0]}, + ), + (qml.Permute, (np.array([1, 2, 0]), [0, 1, 2]), {}), + (qml.Permute, (np.array([1, 2, 0]),), {"wires": [0, 1, 2]}), + ( + qml.TwoLocalSwapNetwork, + ([0, 1, 2, 3, 4],), + {"acquaintances": lambda index, wires, param=None: qml.CNOT(index)}, + ), + (qml.GroverOperator, (), {"wires": [0, 1]}), + (qml.GroverOperator, ([0, 1],), {}), + ( + qml.UCCSD, + (jnp.ones(3), [2, 3, 0, 1]), + {"s_wires": [[0], [1]], "d_wires": [[[2], [3]]], "init_state": [0, 1, 1, 0]}, + ), +] + + +@pytest.mark.parametrize("template, args, kwargs", unmodified_templates_cases) +def test_unmodified_templates(template, args, kwargs): + """Test that templates with unmodified primitive binds are captured as expected.""" + + # Make sure the input data is valid + template(*args, **kwargs) + + # Make sure the template actually is not modified in its primitive binding function + assert template._primitive_bind_call.__code__ == original_op_bind_code + + def fn(*args): + template(*args, **kwargs) + + jaxpr = jax.make_jaxpr(fn)(*args) + + # Check basic structure of jaxpr: single equation with template primitive + assert len(jaxpr.eqns) == 1 + eqn = jaxpr.eqns[0] + assert eqn.primitive == template._primitive + + # Check that all arguments are passed correctly, taking wires parsing into account + # Also, store wires for later + if "wires" in kwargs: + # If wires are in kwargs, they are not invars to the jaxpr + num_invars_wo_wires = len(eqn.invars) - len(kwargs["wires"]) + assert eqn.invars[:num_invars_wo_wires] == jaxpr.jaxpr.invars + wires = kwargs.pop("wires") + else: + # If wires are in args, they are also invars to the jaxpr + assert eqn.invars == jaxpr.jaxpr.invars + wires = args[-1] + + # Check outvars; there should only be the DropVar returned by the template + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + # Check that `n_wires` is inferred correctly + if isinstance(wires, int): + wires = (wires,) + assert eqn.params.pop("n_wires") == len(wires) + # Check that remaining kwargs are passed properly to the eqn + assert eqn.params == kwargs + + +# Only add a template to the following list if you manually added a test for it to +# TestModifiedTemplates below. +tested_modified_templates = [ + qml.TrotterProduct, + qml.AmplitudeAmplification, + qml.ApproxTimeEvolution, + qml.BasisRotation, + qml.CommutingEvolution, + qml.ControlledSequence, + qml.FermionicDoubleExcitation, + qml.HilbertSchmidt, + qml.LocalHilbertSchmidt, + qml.QDrift, + qml.QSVT, + qml.QuantumMonteCarlo, + qml.QuantumPhaseEstimation, + qml.Qubitization, + qml.Reflection, + qml.Select, + qml.MERA, + qml.MPS, + qml.TTN, +] + + +class TestModifiedTemplates: + """Test that templates with custom primitive binds are captured as expected.""" + + @pytest.mark.parametrize( + "template, kwargs", + [ + (qml.TrotterProduct, {"order": 2}), + (qml.ApproxTimeEvolution, {"n": 2}), + (qml.CommutingEvolution, {"frequencies": (1.2, 2)}), + (qml.QDrift, {"n": 2, "seed": 10}), + ], + ) + def test_evolution_ops(self, template, kwargs): + """Test the primitive bind call of Hamiltonian time evolution templates.""" + + coeffs = [0.25, 0.75] + ops = [qml.X(0), qml.Z(0)] + H = qml.dot(coeffs, ops) + + def qfunc(Hi): + template(Hi, time=2.4, **kwargs) + + # Validate inputs + qfunc(H) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(H) + + assert len(jaxpr.eqns) == 6 + + # due to flattening and unflattening H + assert jaxpr.eqns[0].primitive == qml.X._primitive + assert jaxpr.eqns[1].primitive == qml.ops.SProd._primitive + assert jaxpr.eqns[2].primitive == qml.Z._primitive + assert jaxpr.eqns[3].primitive == qml.ops.SProd._primitive + assert jaxpr.eqns[4].primitive == qml.ops.Sum._primitive + + eqn = jaxpr.eqns[5] + assert eqn.primitive == template._primitive + assert eqn.invars == jaxpr.eqns[4].outvars # the sum op + + assert eqn.params == kwargs | {"time": 2.4} + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, coeffs[0], coeffs[1]) + + assert len(q) == 1 + assert q.queue[0] == template(H, time=2.4, **kwargs) + + def test_amplitude_amplification(self): + """Test the primitive bind call of AmplitudeAmplification.""" + + U = qml.Hadamard(0) + O = qml.FlipSign(1, 0) + iters = 3 + + kwargs = {"iters": iters, "fixed_point": False, "p_min": 0.4} + + def qfunc(U, O): + qml.AmplitudeAmplification(U, O, **kwargs) + + # Validate inputs + qfunc(U, O) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(U, O) + + assert len(jaxpr.eqns) == 3 + + assert jaxpr.eqns[0].primitive == qml.Hadamard._primitive + assert jaxpr.eqns[1].primitive == qml.FlipSign._primitive + + eqn = jaxpr.eqns[2] + assert eqn.primitive == qml.AmplitudeAmplification._primitive + assert eqn.invars[0] == jaxpr.eqns[0].outvars[0] # Hadamard + assert eqn.invars[1] == jaxpr.eqns[1].outvars[0] # FlipSign + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts) + + assert len(q) == 1 + assert q.queue[0] == qml.AmplitudeAmplification(U, O, **kwargs) + + def test_basis_rotation(self): + """Test the primitive bind call of BasisRotation.""" + + mat = np.eye(4) + wires = [0, 5] + + def qfunc(wires, mat): + qml.BasisRotation(wires, mat, check=True) + + # Validate inputs + qfunc(wires, mat) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(wires, mat) + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == qml.BasisRotation._primitive + assert eqn.invars == jaxpr.jaxpr.invars + assert eqn.params == {"check": True, "id": None} + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, *wires, mat) + + assert len(q) == 1 + assert q.queue[0] == qml.BasisRotation(wires=wires, unitary_matrix=mat, check=True) + + def test_controlled_sequence(self): + """Test the primitive bind call of ControlledSequence.""" + + assert ( + qml.ControlledSequence._primitive_bind_call.__code__ + == qml.ops.op_math.SymbolicOp._primitive_bind_call.__code__ + ) + + base = qml.RX(0.5, 0) + control = [1, 5] + + def fn(base): + qml.ControlledSequence(base, control=control) + + # Validate inputs + fn(base) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(fn)(base) + + assert len(jaxpr.eqns) == 2 + assert jaxpr.eqns[0].primitive == qml.RX._primitive + + eqn = jaxpr.eqns[1] + assert eqn.primitive == qml.ControlledSequence._primitive + assert eqn.invars == jaxpr.eqns[0].outvars + assert eqn.params == {"control": control} + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, 0.5) + + assert len(q) == 1 # One for each control + assert q.queue[0] == qml.ControlledSequence(base, control) + + def test_fermionic_double_excitation(self): + """Test the primitive bind call of FermionicDoubleExcitation.""" + + weight = 0.251 + + kwargs = {"wires1": [0, 6], "wires2": [2, 3]} + + def qfunc(weight): + qml.FermionicDoubleExcitation(weight, **kwargs) + + # Validate inputs + qfunc(weight) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(weight) + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == qml.FermionicDoubleExcitation._primitive + assert eqn.invars == jaxpr.jaxpr.invars + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, weight) + + assert len(q) == 1 + assert q.queue[0] == qml.FermionicDoubleExcitation(weight, **kwargs) + + @pytest.mark.parametrize("template", [qml.HilbertSchmidt, qml.LocalHilbertSchmidt]) + def test_hilbert_schmidt(self, template): + """Test the primitive bind call of HilbertSchmidt and LocalHilbertSchmidt.""" + + v_params = np.array([0.6]) + + kwargs = { + "u_tape": qml.tape.QuantumScript([qml.Hadamard(0)]), + "v_function": lambda params: qml.RZ(params[0], wires=1), + "v_wires": [1], + "id": None, + } + + def qfunc(v_params): + template(v_params, **kwargs) + + # Validate inputs + qfunc(v_params) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(v_params) + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == template._primitive + assert eqn.invars == jaxpr.jaxpr.invars + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, v_params) + + assert len(q) == 1 + assert qml.equal(q.queue[0], template(v_params, **kwargs)) + + @pytest.mark.parametrize("template", [qml.MERA, qml.MPS, qml.TTN]) + def test_tensor_networks(self, template): + """Test the primitive bind call of MERA, MPS, and TTN.""" + + def block(weights, wires): + return [ + qml.CNOT(wires), + qml.RY(weights[0], wires[0]), + qml.RY(weights[1], wires[1]), + ] + + wires = list(range(4)) + n_block_wires = 2 + n_blocks = template.get_n_blocks(wires, n_block_wires) + + kwargs = { + "wires": wires, + "n_block_wires": n_block_wires, + "block": block, + "n_params_block": 2, + "template_weights": [[0.1, -0.3]] * n_blocks, + } + + def qfunc(): + template(**kwargs) + + # Validate inputs + qfunc() + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)() + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == template._primitive + + expected_params = { + "n_block_wires": n_block_wires, + "block": block, + "n_params_block": 2, + "template_weights": kwargs["template_weights"], + "id": None, + "n_wires": 4, + } + if template is qml.MPS: + expected_params["offset"] = None + assert eqn.params == expected_params + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts) + + assert len(q) == 1 + assert qml.equal(q.queue[0], template(**kwargs)) + + def test_qsvt(self): + """Test the primitive bind call of QSVT.""" + + A = np.array([[0.1]]) + block_encode = qml.BlockEncode(A, wires=[0, 1]) + shifts = [qml.PCPhase(i + 0.1, dim=1, wires=[0, 1]) for i in range(3)] + + def qfunc(block_encode): + qml.QSVT(block_encode, projectors=shifts) + + # Validate inputs + qfunc(block_encode) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(block_encode) + + assert len(jaxpr.eqns) == 2 + + # due to flattening and unflattening BlockEncode + assert jaxpr.eqns[0].primitive == qml.BlockEncode._primitive + + eqn = jaxpr.eqns[1] + assert eqn.primitive == qml.QSVT._primitive + assert eqn.invars == jaxpr.eqns[0].outvars + assert eqn.params == {"projectors": shifts} + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, A) + + assert len(q) == 1 + assert q.queue[0] == qml.QSVT(block_encode, shifts) + + def test_quantum_monte_carlo(self): + """Test the primitive bind call of QuantumMonteCarlo.""" + + # This test follows the docstring example + + from scipy.stats import norm + + m = 5 + M = 2**m + n = 10 + + xs = np.linspace(-np.pi, np.pi, M) + probs = np.array([norm().pdf(x) for x in xs]) + probs /= np.sum(probs) + + def func(i): + return np.sin(xs[i]) ** 2 + + target_wires = range(m + 1) + estimation_wires = range(m + 1, n + m + 1) + + kwargs = {"func": func, "target_wires": target_wires, "estimation_wires": estimation_wires} + + def qfunc(probs): + qml.QuantumMonteCarlo(probs, **kwargs) + + # Validate inputs + qfunc(probs) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(probs) + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == qml.QuantumMonteCarlo._primitive + assert eqn.invars == jaxpr.jaxpr.invars + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, probs) + + assert len(q) == 1 + assert q.queue[0] == qml.QuantumMonteCarlo(probs, **kwargs) + + @pytest.mark.usefixtures("new_opmath_only") + def test_qubitization(self): + """Test the primitive bind call of Qubitization.""" + + hamiltonian = qml.dot([0.5, 1.2, -0.84], [qml.X(2), qml.Hadamard(3), qml.Z(2) @ qml.Y(3)]) + kwargs = {"hamiltonian": hamiltonian, "control": [0, 1]} + + def qfunc(): + qml.Qubitization(**kwargs) + + # Validate inputs + qfunc() + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)() + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == qml.Qubitization._primitive + assert eqn.invars == jaxpr.jaxpr.invars + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts) + + assert len(q) == 1 + assert qml.equal(q.queue[0], qml.Qubitization(**kwargs)) + + @pytest.mark.parametrize( + "template, kwargs", + [ + (qml.QuantumPhaseEstimation, {"estimation_wires": range(2, 4)}), + (qml.Reflection, {"alpha": np.pi / 2, "reflection_wires": [0]}), + ], + ) + def test_quantum_phase_estimation_and_reflection(self, template, kwargs): + """Test the primitive bind call of QuantumPhaseEstimation and Reflection.""" + + op = qml.RX(np.pi / 2, 0) @ qml.Hadamard(1) + + def qfunc(op): + template(op, **kwargs) + + # Validate inputs + qfunc(op) + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)(op) + + assert len(jaxpr.eqns) == 4 + + assert jaxpr.eqns[0].primitive == qml.RX._primitive + assert jaxpr.eqns[1].primitive == qml.Hadamard._primitive + assert jaxpr.eqns[2].primitive == qml.ops.op_math.Prod._primitive + + eqn = jaxpr.eqns[3] + assert eqn.primitive == template._primitive + assert eqn.invars == jaxpr.eqns[2].outvars + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts, np.pi / 2) + + assert len(q) == 1 + assert qml.equal(q.queue[0], template(op, **kwargs)) + + def test_select(self): + """Test the primitive bind call of Select.""" + + ops = [qml.X(2), qml.RX(0.2, 3), qml.Y(2), qml.Z(3)] + kwargs = {"ops": ops, "control": [0, 1]} + + def qfunc(): + qml.Select(**kwargs) + + # Validate inputs + qfunc() + + # Actually test primitive bind + jaxpr = jax.make_jaxpr(qfunc)() + + assert len(jaxpr.eqns) == 1 + + eqn = jaxpr.eqns[0] + assert eqn.primitive == qml.Select._primitive + assert eqn.invars == jaxpr.jaxpr.invars + assert eqn.params == kwargs + assert len(eqn.outvars) == 1 + assert isinstance(eqn.outvars[0], jax.core.DropVar) + + with qml.queuing.AnnotatedQueue() as q: + jax.core.eval_jaxpr(jaxpr.jaxpr, jaxpr.consts) + + assert len(q) == 1 + assert qml.equal(q.queue[0], qml.Select(**kwargs)) + + +def filter_fn(member: Any) -> bool: + """Determine whether a member of a module is a class and genuinely belongs to + qml.templates.""" + return inspect.isclass(member) and member.__module__.startswith("pennylane.templates") + + +_, all_templates = zip(*inspect.getmembers(qml.templates, filter_fn)) + +unmodified_templates = [template for template, *_ in unmodified_templates_cases] +unsupported_templates = [ + qml.CVNeuralNetLayers, + qml.DisplacementEmbedding, + qml.Interferometer, + qml.QutritBasisStatePreparation, + qml.SqueezingEmbedding, +] +modified_templates = [ + t for t in all_templates if t not in unmodified_templates + unsupported_templates +] + + +@pytest.mark.parametrize("template", modified_templates) +def test_templates_are_modified(template): + """Test that all templates that are not listed as unmodified in the test cases above + actually have their _primitive_bind_call modified.""" + # Make sure the template actually is modified in its primitive binding function + assert template._primitive_bind_call.__code__ != original_op_bind_code + + +def test_all_modified_templates_are_tested(): + """Test that all templates in `modified_templates` (automatically computed and + validated above) also are in `tested_modified_templates` (manually created and + expected to resemble all tested templates.""" + assert set(modified_templates) == set(tested_modified_templates) diff --git a/tests/conftest.py b/tests/conftest.py index 8f8cf90cc8a..1b90d1f20f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ Pytest configuration file for PennyLane test suite. """ # pylint: disable=unused-import +import contextlib import os import pathlib diff --git a/tests/devices/default_qubit/test_default_qubit.py b/tests/devices/default_qubit/test_default_qubit.py index e1fd668f504..4af0d167ebb 100644 --- a/tests/devices/default_qubit/test_default_qubit.py +++ b/tests/devices/default_qubit/test_default_qubit.py @@ -127,15 +127,24 @@ def test_supports_backprop(self): assert dev.supports_jvp(config) is True assert dev.supports_vjp(config) is True - def test_supports_adjoint(self): + @pytest.mark.parametrize( + "device_wires, measurement", + [ + (None, qml.expval(qml.PauliZ(0))), + (2, qml.expval(qml.PauliZ(0))), + (2, qml.probs()), + (2, qml.probs([0])), + ], + ) + def test_supports_adjoint(self, device_wires, measurement): """Test that DefaultQubit says that it supports adjoint differentiation.""" - dev = DefaultQubit() + dev = DefaultQubit(wires=device_wires) config = ExecutionConfig(gradient_method="adjoint", use_device_gradient=True) assert dev.supports_derivatives(config) is True assert dev.supports_jvp(config) is True assert dev.supports_vjp(config) is True - qs = qml.tape.QuantumScript([], [qml.expval(qml.PauliZ(0))]) + qs = qml.tape.QuantumScript([], [measurement]) assert dev.supports_derivatives(config, qs) is True assert dev.supports_jvp(config, qs) is True assert dev.supports_vjp(config, qs) is True diff --git a/tests/devices/default_tensor/test_default_tensor.py b/tests/devices/default_tensor/test_default_tensor.py index 0de7a0c00c1..58cfd5b5c85 100644 --- a/tests/devices/default_tensor/test_default_tensor.py +++ b/tests/devices/default_tensor/test_default_tensor.py @@ -21,6 +21,7 @@ from scipy.sparse import csr_matrix import pennylane as qml +from pennylane.wires import WireError quimb = pytest.importorskip("quimb") @@ -125,42 +126,36 @@ def test_name(): """Test the name of DefaultTensor.""" - assert qml.device("default.tensor", wires=0).name == "default.tensor" + assert qml.device("default.tensor").name == "default.tensor" def test_wires(): """Test that a device can be created with wires.""" - assert qml.device("default.tensor", wires=0).wires is not None + assert qml.device("default.tensor").wires is None assert qml.device("default.tensor", wires=2).wires == qml.wires.Wires([0, 1]) assert qml.device("default.tensor", wires=[0, 2]).wires == qml.wires.Wires([0, 2]) with pytest.raises(AttributeError): - qml.device("default.tensor", wires=0).wires = [0, 1] + qml.device("default.tensor").wires = [0, 1] -def test_wires_error(): - """Test that an error is raised if the wires are not provided.""" - with pytest.raises(TypeError): - qml.device("default.tensor") +def test_wires_runtime(): + """Test that this device can execute a tape with wires determined at runtime if they are not provided.""" + dev = qml.device("default.tensor") + ops = [qml.Identity(0), qml.Identity((0, 1)), qml.RX(2, 0), qml.RY(1, 5), qml.RX(2, 1)] + measurements = [qml.expval(qml.PauliZ(15))] + tape = qml.tape.QuantumScript(ops, measurements) + assert dev.execute(tape) == 1.0 - with pytest.raises(TypeError): - qml.device("default.tensor", wires=None) - - -def test_wires_execution_error(): - """Test that this device cannot execute a tape if its wires do not match the wires on the device.""" - dev = qml.device("default.tensor", wires=3) - ops = [ - qml.Identity(0), - qml.Identity((0, 1)), - qml.RX(2, 0), - qml.RY(1, 5), - qml.RX(2, 1), - ] + +def test_wires_runtime_error(): + """Test that this device raises an error if the wires are provided by user and there is a mismatch.""" + dev = qml.device("default.tensor", wires=1) + ops = [qml.Identity(0), qml.Identity((0, 1)), qml.RX(2, 0), qml.RY(1, 5), qml.RX(2, 1)] measurements = [qml.expval(qml.PauliZ(15))] tape = qml.tape.QuantumScript(ops, measurements) - with pytest.raises(AttributeError): + with pytest.raises(WireError): dev.execute(tape) @@ -192,7 +187,7 @@ def test_invalid_kwarg(): def test_method(): """Test the device method.""" - assert qml.device("default.tensor", wires=0).method == "mps" + assert qml.device("default.tensor").method == "mps" def test_invalid_method(): @@ -272,12 +267,12 @@ class TestSupportsDerivatives: def test_support_derivatives(self): """Test that the device does not support derivatives yet.""" - dev = qml.device("default.tensor", wires=0) + dev = qml.device("default.tensor") assert not dev.supports_derivatives() def test_compute_derivatives(self): """Test that an error is raised if the `compute_derivatives` method is called.""" - dev = qml.device("default.tensor", wires=0) + dev = qml.device("default.tensor") with pytest.raises( NotImplementedError, match="The computation of derivatives has yet to be implemented for the default.tensor device.", @@ -286,7 +281,7 @@ def test_compute_derivatives(self): def test_execute_and_compute_derivatives(self): """Test that an error is raised if `execute_and_compute_derivative` method is called.""" - dev = qml.device("default.tensor", wires=0) + dev = qml.device("default.tensor") with pytest.raises( NotImplementedError, match="The computation of derivatives has yet to be implemented for the default.tensor device.", @@ -295,12 +290,12 @@ def test_execute_and_compute_derivatives(self): def test_supports_vjp(self): """Test that the device does not support VJP yet.""" - dev = qml.device("default.tensor", wires=0) + dev = qml.device("default.tensor") assert not dev.supports_vjp() def test_compute_vjp(self): """Test that an error is raised if `compute_vjp` method is called.""" - dev = qml.device("default.tensor", wires=0) + dev = qml.device("default.tensor") with pytest.raises( NotImplementedError, match="The computation of vector-Jacobian product has yet to be implemented for the default.tensor device.", @@ -309,7 +304,7 @@ def test_compute_vjp(self): def test_execute_and_compute_vjp(self): """Test that an error is raised if `execute_and_compute_vjp` method is called.""" - dev = qml.device("default.tensor", wires=0) + dev = qml.device("default.tensor") with pytest.raises( NotImplementedError, match="The computation of vector-Jacobian product has yet to be implemented for the default.tensor device.", diff --git a/tests/devices/qutrit_mixed/test_qutrit_mixed_tracking.py b/tests/devices/qutrit_mixed/test_qutrit_mixed_tracking.py index 8312e5d0fef..a735157784f 100644 --- a/tests/devices/qutrit_mixed/test_qutrit_mixed_tracking.py +++ b/tests/devices/qutrit_mixed/test_qutrit_mixed_tracking.py @@ -14,8 +14,6 @@ """ Tests for the tracking capabilities of default qutrit mixed. """ -import logging - import numpy as np import pytest @@ -222,48 +220,3 @@ def test_multiple_expval_with_prods(self): assert dev.tracker.totals["executions"] == expected_exec assert dev.tracker.totals["simulations"] == 1 assert dev.tracker.totals["shots"] == expected_shots - - -@pytest.mark.logging -def test_execution_debugging(caplog): - """Test logging of QNode forward pass from default qutrit mixed.""" - - qml.logging.enable_logging() - - pl_logger = logging.root.manager.loggerDict["pennylane"] - plqn_logger = logging.root.manager.loggerDict["pennylane.qnode"] - - # Ensure logs messages are propagated for pytest capture - pl_logger.propagate = True - plqn_logger.propagate = True - - with caplog.at_level(logging.DEBUG): - dev = qml.device("default.qutrit.mixed", wires=2) - params = qml.numpy.array(0.1234) - - @qml.qnode(dev, diff_method=None) - def circuit(params): - qml.TRX(params, wires=0) - return qml.expval(qml.GellMann(0, 3)) - - circuit(params) - - assert len(caplog.records) == 3 - - log_records_expected = [ - ( - "pennylane.workflow.qnode", - ["Creating QNode(func=