diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 373c80d9c55..88d3c8d3873 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -60,6 +60,29 @@

Improvements

+* QNode caching has been introduced, allowing the QNode to keep track of the results of previous + device executions and reuse those results in subsequent calls. + [(#817)](https://github.com/PennyLaneAI/pennylane/pull/817) + + Caching is available by passing a ``caching`` argument to the QNode: + + ```python + from pennylane.beta.tapes import qnode + from pennylane.beta.queuing import expval + + dev = qml.device("default.qubit", wires=2) + + @qnode(dev, caching=10) # cache up to 10 evaluations + def qfunc(x): + qml.RX(x, wires=0) + qml.RX(0.3, wires=1) + qml.CNOT(wires=[0, 1]) + return expval(qml.PauliZ(1)) + + qfunc(0.1) # first evaluation executes on the device + qfunc(0.1) # second evaluation accesses the cached result + ``` + * Sped up the application of certain gates in `default.qubit` by using array/tensor manipulation tricks. The following gates are affected: `PauliX`, `PauliY`, `PauliZ`, `Hadamard`, `SWAP`, `S`, `T`, `CNOT`, `CZ`. diff --git a/pennylane/beta/tapes/qnode.py b/pennylane/beta/tapes/qnode.py index 3184eafed19..b51694c8dd9 100644 --- a/pennylane/beta/tapes/qnode.py +++ b/pennylane/beta/tapes/qnode.py @@ -15,6 +15,7 @@ This module contains the QNode class and qnode decorator. """ from collections.abc import Sequence +from collections import OrderedDict from functools import lru_cache, update_wrapper import numpy as np @@ -98,6 +99,11 @@ class QNode: * ``"finite-diff"``: Uses numerical finite-differences for all quantum operation arguments. + caching (int): Number of device executions to store in a cache to speed up subsequent + executions. A value of ``0`` indicates that no caching will take place. Once filled, + older elements of the cache are removed and replaced with the most recent device + executions to keep the cache up to date. + Keyword Args: h=1e-7 (float): step size for the finite difference method order=1 (int): The order of the finite difference method to use. ``1`` corresponds @@ -114,9 +120,11 @@ class QNode: >>> qnode = QNode(circuit, dev) """ - # pylint:disable=too-many-instance-attributes + # pylint:disable=too-many-instance-attributes,too-many-arguments - def __init__(self, func, device, interface="autograd", diff_method="best", **diff_options): + def __init__( + self, func, device, interface="autograd", diff_method="best", caching=0, **diff_options + ): if interface is not None and interface not in self.INTERFACE_MAP: raise qml.QuantumFunctionError( @@ -140,6 +148,17 @@ def __init__(self, func, device, interface="autograd", diff_method="best", **dif self.dtype = np.float64 self.max_expansion = 2 + self._caching = caching + """float: number of device executions to store in a cache to speed up subsequent + executions. If set to zero, no caching occurs.""" + + if caching != 0 and self.diff_method == "backprop": + raise ValueError('Caching mode is incompatible with the "backprop" diff_method') + + self._cache_execute = OrderedDict() + """OrderedDict[int: Any]: A copy of the ``_cache_execute`` dictionary from the quantum + tape""" + @staticmethod def get_tape(device, interface, diff_method="best"): """Determine the best QuantumTape, differentiation method, and interface @@ -343,7 +362,7 @@ def _get_parameter_shift_tape(device): def construct(self, args, kwargs): """Call the quantum function with a tape context, ensuring the operations get queued.""" - self.qtape = self._tape() + self.qtape = self._tape(caching=self._caching) with self.qtape: measurement_processes = self.func(*args, **kwargs) @@ -406,6 +425,12 @@ def __call__(self, *args, **kwargs): # construct the tape self.construct(args, kwargs) + if self._caching: + # Every time the QNode is called, it creates a new tape. We want the tape cache to + # persist over multiple tapes, so hence keep track of it as a QNode attribute and + # load it into the new tape + self.qtape._cache_execute = self._cache_execute + # execute the tape res = self.qtape.execute(device=self.device) @@ -420,6 +445,9 @@ def __call__(self, *args, **kwargs): # 'squeeze' does not exist in the top-level of the namespace return anp.squeeze(res) + if self._caching: + self._cache_execute = self.qtape._cache_execute + return __import__(res_type_namespace).squeeze(res) def to_tf(self, dtype=None): @@ -495,10 +523,16 @@ def to_autograd(self): if self.qtape is not None: AutogradInterface.apply(self.qtape) + @property + def caching(self): + """float: number of device executions to store in a cache to speed up subsequent + executions. If set to zero, no caching occurs.""" + return self._caching + INTERFACE_MAP = {"autograd": to_autograd, "torch": to_torch, "tf": to_tf} -def qnode(device, interface="autograd", diff_method="best", **diff_options): +def qnode(device, interface="autograd", diff_method="best", caching=0, **diff_options): """Decorator for creating QNodes. This decorator is used to indicate to PennyLane that the decorated function contains a @@ -569,6 +603,11 @@ def qnode(device, interface="autograd", diff_method="best", **diff_options): * ``"finite-diff"``: Uses numerical finite-differences for all quantum operation arguments. + caching (int): Number of device executions to store in a cache to speed up subsequent + executions. A value of ``0`` indicates that no caching will take place. Once filled, + older elements of the cache are removed and replaced with the most recent device + executions to keep the cache up to date. + Keyword Args: h=1e-7 (float): Step size for the finite difference method. order=1 (int): The order of the finite difference method to use. ``1`` corresponds @@ -588,7 +627,14 @@ def qnode(device, interface="autograd", diff_method="best", **diff_options): @lru_cache() def qfunc_decorator(func): """The actual decorator""" - qn = QNode(func, device, interface=interface, diff_method=diff_method, **diff_options) + qn = QNode( + func, + device, + interface=interface, + diff_method=diff_method, + caching=caching, + **diff_options, + ) return update_wrapper(qn, func) return qfunc_decorator diff --git a/pennylane/beta/tapes/tape.py b/pennylane/beta/tapes/tape.py index 979b40aa896..6eb158773a2 100644 --- a/pennylane/beta/tapes/tape.py +++ b/pennylane/beta/tapes/tape.py @@ -15,6 +15,7 @@ This module contains the base quantum tape. """ # pylint: disable=too-many-instance-attributes,protected-access,too-many-branches,too-many-public-methods +from collections import OrderedDict import contextlib import numpy as np @@ -133,6 +134,7 @@ def expand_tape(tape, depth=1, stop_at=None, expand_measurements=False): return new_tape +# pylint: disable=too-many-public-methods class QuantumTape(AnnotatedQueue): """A quantum tape recorder, that records, validates, executes, and differentiates variational quantum programs. @@ -145,6 +147,13 @@ class QuantumTape(AnnotatedQueue): >>> from pennylane.beta.queuing import expval, var, sample, probs + Args: + name (str): a name given to the quantum tape + caching (int): Number of device executions to store in a cache to speed up subsequent + executions. A value of ``0`` indicates that no caching will take place. Once filled, + older elements of the cache are removed and replaced with the most recent device + executions to keep the cache up to date. + **Example** .. code-block:: python @@ -216,7 +225,7 @@ class QuantumTape(AnnotatedQueue): [[-0.45478169]] """ - def __init__(self, name=None): + def __init__(self, name=None, caching=0): super().__init__() self.name = name @@ -248,6 +257,14 @@ def __init__(self, name=None): self._stack = None + self._caching = caching + """float: number of device executions to store in a cache to speed up subsequent + executions. If set to zero, no caching occurs.""" + + self._cache_execute = OrderedDict() + """OrderedDict[int: Any]: Mapping from hashes of the circuit to results of executing the + device.""" + def __repr__(self): return f"<{self.__class__.__name__}: wires={self.wires.tolist()}, params={self.num_params}>" @@ -929,6 +946,12 @@ def execute_device(self, params, device): # temporarily mutate the in-place parameters self.set_parameters(params) + if self._caching: + circuit_hash = self.graph.hash + if circuit_hash in self._cache_execute: + self.set_parameters(saved_parameters) + return self._cache_execute[circuit_hash] + if isinstance(device, qml.QubitDevice): res = device.execute(self) else: @@ -953,6 +976,12 @@ def execute_device(self, params, device): # restore original parameters self.set_parameters(saved_parameters) + + if self._caching and circuit_hash not in self._cache_execute: + self._cache_execute[circuit_hash] = res + if len(self._cache_execute) > self._caching: + self._cache_execute.popitem(last=False) + return res # interfaces can optionally override the _execute method @@ -1344,3 +1373,9 @@ def jacobian(self, device, params=None, **options): jac[:, idx] = g.flatten() return jac + + @property + def caching(self): + """float: number of device executions to store in a cache to speed up subsequent + executions. If set to zero, no caching occurs.""" + return self._caching diff --git a/tests/beta/interfaces/test_tape_autograd.py b/tests/beta/interfaces/test_tape_autograd.py index 7c7a5256394..75d9a6f22ad 100644 --- a/tests/beta/interfaces/test_tape_autograd.py +++ b/tests/beta/interfaces/test_tape_autograd.py @@ -34,8 +34,8 @@ def test_interface_str(self): assert isinstance(tape, AutogradInterface) def test_get_parameters(self): - """Test that the get_parameters function correctly sets and returns the - trainable parameters""" + """Test that the get_parameters function correctly gets the trainable parameters and all + parameters, depending on the trainable_only argument""" a = np.array(0.1, requires_grad=True) b = np.array(0.2, requires_grad=False) c = np.array(0.3, requires_grad=True) @@ -48,7 +48,8 @@ def test_get_parameters(self): expval(qml.PauliX(0)) assert tape.trainable_params == {0, 2} - assert np.all(tape.get_parameters() == [a, c]) + assert np.all(tape.get_parameters(trainable_only=True) == [a, c]) + assert np.all(tape.get_parameters(trainable_only=False) == [a, b, c, d]) def test_execution(self): """Test execution""" diff --git a/tests/beta/tapes/test_caching.py b/tests/beta/tapes/test_caching.py new file mode 100644 index 00000000000..f1a8b509d4a --- /dev/null +++ b/tests/beta/tapes/test_caching.py @@ -0,0 +1,305 @@ +# Copyright 2018-2020 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. +"""Tests for caching executions of the quantum tape and QNode.""" +import numpy as np +import pytest + +import pennylane as qml +from pennylane.beta.queuing import expval +from pennylane.beta.tapes import QuantumTape, qnode +from pennylane.devices import DefaultQubit +from pennylane.devices.default_qubit_autograd import DefaultQubitAutograd + + +def get_tape(caching): + """Creates a simple quantum tape""" + with QuantumTape(caching=caching) as tape: + qml.QubitUnitary(np.eye(2), wires=0) + qml.RX(0.1, wires=0) + qml.RX(0.2, wires=1) + qml.CNOT(wires=[0, 1]) + expval(qml.PauliZ(wires=1)) + return tape + + +def get_qnode(caching, diff_method="finite-diff", interface="autograd"): + """Creates a simple QNode""" + dev = qml.device("default.qubit.autograd", wires=3) + + @qnode(dev, caching=caching, diff_method=diff_method, interface=interface) + def qfunc(x, y): + qml.RX(x, wires=0) + qml.RX(y, wires=1) + qml.CNOT(wires=[0, 1]) + return expval(qml.PauliZ(wires=1)) + + return qfunc + + +class TestTapeCaching: + """Tests for caching when using quantum tape""" + + def test_set_and_get(self): + """Test that the caching attribute can be set and accessed""" + tape = QuantumTape() + assert tape.caching == 0 + + tape = QuantumTape(caching=10) + assert tape.caching == 10 + + def test_no_caching(self, mocker): + """Test that no caching occurs when the caching attribute is equal to zero""" + dev = qml.device("default.qubit", wires=2) + tape = get_tape(0) + + spy = mocker.spy(DefaultQubit, "execute") + tape.execute(device=dev) + tape.execute(device=dev) + + assert len(spy.call_args_list) == 2 + assert len(tape._cache_execute) == 0 + + def test_caching(self, mocker): + """Test that caching occurs when the caching attribute is above zero""" + dev = qml.device("default.qubit", wires=2) + tape = get_tape(10) + + tape.execute(device=dev) + spy = mocker.spy(DefaultQubit, "execute") + tape.execute(device=dev) + + spy.assert_not_called() + assert len(tape._cache_execute) == 1 + + def test_add_to_cache_execute(self): + """Test that the _cache_execute attribute is added to when the tape is executed""" + dev = qml.device("default.qubit", wires=2) + tape = get_tape(10) + + result = tape.execute(device=dev) + cache_execute = tape._cache_execute + hashed = tape.graph.hash + + assert len(cache_execute) == 1 + assert hashed in cache_execute + assert np.allclose(cache_execute[hashed], result) + + def test_fill_cache(self): + """Test that the cache is added to until it reaches its maximum size (in this case 10), + and then maintains that size upon subsequent additions.""" + dev = qml.device("default.qubit", wires=2) + tape = get_tape(10) + + tape.trainable_params = {1} + args = np.arange(20) + + for i, arg in enumerate(args[:10]): + tape.execute(params=[arg], device=dev) + assert len(tape._cache_execute) == i + 1 + + for arg in args[10:]: + tape.execute(params=[arg], device=dev) + assert len(tape._cache_execute) == 10 + + def test_drop_from_cache(self): + """Test that the first entry of the _cache_execute dictionary is the first to be dropped + from the dictionary once it becomes full""" + dev = qml.device("default.qubit", wires=2) + tape = get_tape(2) + + tape.trainable_params = {1} + tape.execute(device=dev) + first_hash = list(tape._cache_execute.keys())[0] + + tape.execute(device=dev, params=[0.2]) + assert first_hash in tape._cache_execute + tape.execute(device=dev, params=[0.3]) + assert first_hash not in tape._cache_execute + + def test_caching_multiple_values(self, mocker): + """Test that multiple device executions with different params are cached and accessed on + subsequent executions""" + dev = qml.device("default.qubit", wires=2) + tape = get_tape(10) + + tape.trainable_params = {1} + args = np.arange(10) + + for arg in args[:10]: + tape.execute(params=[arg], device=dev) + + spy = mocker.spy(DefaultQubit, "execute") + for arg in args[:10]: + tape.execute(params=[arg], device=dev) + + spy.assert_not_called() + + +class TestQNodeCaching: + """Tests for caching when using the QNode""" + + def test_set_and_get(self): + """Test that the caching attribute can be set and accessed""" + qnode = get_qnode(caching=0) + assert qnode.caching == 0 + + qnode = get_qnode(caching=10) + assert qnode.caching == 10 + + def test_backprop_error(self): + """Test if an error is raised when caching is used with the backprop diff_method""" + with pytest.raises(ValueError, match="Caching mode is incompatible"): + get_qnode(caching=10, diff_method="backprop") + + def test_caching(self, mocker): + """Test that multiple device executions with different params are cached and accessed on + subsequent executions""" + qnode = get_qnode(caching=10) + args = np.arange(10) + + for arg in args: + qnode(arg, 0.2) + + assert qnode.qtape.caching == 10 + + spy = mocker.spy(DefaultQubitAutograd, "execute") + for arg in args: + qnode(arg, 0.2) + + spy.assert_not_called() + + def test_gradient_autograd(self, mocker): + """Test that caching works when calculating the gradient method using the autograd + interface""" + qnode = get_qnode(caching=10, interface="autograd") + d_qnode = qml.grad(qnode) + args = [0.1, 0.2] + + d_qnode(*args) + spy = mocker.spy(DefaultQubitAutograd, "execute") + d_qnode(*args) + spy.assert_not_called() + + @pytest.mark.usefixtures("skip_if_no_tf_support") + def test_gradient_tf(self, mocker): + """Test that caching works when calculating the gradient method using the TF interface""" + import tensorflow as tf + + qnode = get_qnode(caching=10, interface="tf") + args0 = tf.Variable(0.1) + args1 = tf.Variable(0.2) + + with tf.GradientTape() as tape: + res = qnode(args0, args1) + + grad = tape.gradient(res, args0) + assert grad is not None + + spy = mocker.spy(DefaultQubitAutograd, "execute") + with tf.GradientTape() as tape: + res = qnode(args0, args1) + + tape.gradient(res, args0) + spy.assert_not_called() + + @pytest.mark.usefixtures("skip_if_no_torch_support") + def test_gradient_torch(self, mocker): + """Test that caching works when calculating the gradient method using the Torch interface""" + import torch + + qnode = get_qnode(caching=10, interface="torch") + args0 = torch.tensor(0.1, requires_grad=True) + args1 = torch.tensor(0.2) + + res = qnode(args0, args1) + res.backward() + assert args0.grad is not None + + spy = mocker.spy(DefaultQubitAutograd, "execute") + res = qnode(args0, args1) + res.backward() + spy.assert_not_called() + + def test_mutable_circuit(self, mocker): + """Test that caching is compatible with circuit mutability. Caching should take place if + the circuit and parameters are the same, and should not take place if the parameters are + the same but the circuit different.""" + dev = qml.device("default.qubit", wires=3) + + @qnode(dev, caching=10) + def qfunc(x, y, flag=1): + if flag == 1: + qml.RX(x, wires=0) + qml.RX(y, wires=1) + else: + qml.RX(x, wires=1) + qml.RX(y, wires=1) + qml.CNOT(wires=[0, 1]) + return expval(qml.PauliZ(wires=1)) + + spy = mocker.spy(DefaultQubit, "execute") + qfunc(0.1, 0.2) + qfunc(0.1, 0.2) + assert len(spy.call_args_list) == 1 + + qfunc(0.1, 0.2, flag=0) + assert len(spy.call_args_list) == 2 + + def test_classical_processing_in_circuit(self, mocker): + """Test if caching is compatible with QNodes that include classical processing""" + + dev = qml.device("default.qubit", wires=3) + + @qnode(dev, caching=10) + def qfunc(x, y): + qml.RX(x ** 2, wires=0) + qml.RX(x / y, wires=1) + qml.CNOT(wires=[0, 1]) + return expval(qml.PauliZ(wires=1)) + + spy = mocker.spy(DefaultQubit, "execute") + qfunc(0.1, 0.2) + qfunc(0.1, 0.2) + assert len(spy.call_args_list) == 1 + + qfunc(0.1, 0.3) + assert len(spy.call_args_list) == 2 + + def test_grad_classical_processing_in_circuit(self, mocker): + """Test that caching is compatible with calculating the gradient in QNodes which contain + classical processing""" + + dev = qml.device("default.qubit", wires=3) + + @qnode(dev, caching=10) + def qfunc(x, y): + qml.RX(x ** 2, wires=0) + qml.RX(x / y, wires=1) + qml.CNOT(wires=[0, 1]) + return expval(qml.PauliZ(wires=1)) + + d_qfunc = qml.grad(qfunc) + + spy = mocker.spy(DefaultQubit, "execute") + g = d_qfunc(0.1, 0.2) + calls1 = len(spy.call_args_list) + d_qfunc(0.1, 0.2) + calls2 = len(spy.call_args_list) + assert calls1 == calls2 + + d_qfunc(0.1, 0.3) + calls3 = len(spy.call_args_list) + assert calls3 == 2 * calls1 + + assert g is not None diff --git a/tests/beta/tapes/test_tape.py b/tests/beta/tapes/test_tape.py index 1f9e813cf85..61bfc53bd95 100644 --- a/tests/beta/tapes/test_tape.py +++ b/tests/beta/tapes/test_tape.py @@ -515,8 +515,6 @@ def test_inverse(self): assert ops[2].inverse # check that parameter order has reversed - print(tape.get_parameters()) - print([init_state, p[1], p[2], p[3], p[0]]) assert tape.get_parameters() == [init_state, p[1], p[2], p[3], p[0]] def test_parameter_transforms(self): @@ -1320,8 +1318,6 @@ def test_numeric_unknown_order(self): qml.RZ(1, wires=[2]) qml.CNOT(wires=[0, 1]) expval(qml.PauliZ(0) @ qml.PauliX(1) @ qml.PauliZ(2)) - for k, v in tape._queue.items(): - print(k, v) with pytest.raises(ValueError, match="Order must be 1 or 2"): tape.jacobian(dev, order=3) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3f6f55cc790..5922acd2eb8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,7 +17,6 @@ # pylint: disable=no-self-use,too-many-arguments,protected-access import functools import itertools -from unittest.mock import MagicMock import pytest import numpy as np