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