Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache tape executions #817

Merged
merged 32 commits into from
Sep 25, 2020
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e653628
Add to qnode
trbromley Sep 21, 2020
62f3582
Add to tape
trbromley Sep 21, 2020
9686447
Add to utils
trbromley Sep 21, 2020
67c7f61
Add caching test
trbromley Sep 21, 2020
625e3b7
Add to tape tests
trbromley Sep 21, 2020
5fea812
Add to utils tests
trbromley Sep 21, 2020
f084d9d
Fix typo
trbromley Sep 21, 2020
fe453d8
Fix pylint
trbromley Sep 21, 2020
e6740ce
Fix pylint
trbromley Sep 21, 2020
bf064a8
Merge branch 'master' into add_caching_to_tape
trbromley Sep 22, 2020
db32341
Remove get_all_parameters
trbromley Sep 22, 2020
b1160bf
Add to test
trbromley Sep 22, 2020
5bd1d37
Clarify filled cache behaviour
trbromley Sep 22, 2020
f86adc7
Make caching default 0
trbromley Sep 22, 2020
4dbee74
Improve warning test
trbromley Sep 22, 2020
651e802
Use circuit hash as hash
trbromley Sep 22, 2020
b890922
Use circuit hash
trbromley Sep 22, 2020
a640e26
Add to tests
trbromley Sep 22, 2020
0be4262
Tidy up imports
trbromley Sep 22, 2020
950e8f4
Apply suggestions from code review
trbromley Sep 22, 2020
c6010bd
Merge branch 'master' into add_caching_to_tape
antalszava Sep 23, 2020
32077e2
Merge branch 'master' into add_caching_to_tape
trbromley Sep 23, 2020
8ca4e0b
Merge branch 'add_caching_to_tape' of github.com:XanaduAI/pennylane i…
trbromley Sep 23, 2020
c3a56fc
Add to changelog
trbromley Sep 23, 2020
d043a1a
Merge branch 'master' into add_caching_to_tape
trbromley Sep 24, 2020
567639b
Merge branch 'master' into add_caching_to_tape
trbromley Sep 24, 2020
6a66066
Merge branch 'master' into add_caching_to_tape
josh146 Sep 25, 2020
289acc3
Merge branch 'master' into add_caching_to_tape
trbromley Sep 25, 2020
c0e10b3
Apply suggestions
trbromley Sep 25, 2020
c438d5a
Remove caching setter
trbromley Sep 25, 2020
0b0d9d4
Apply suggestion
trbromley Sep 25, 2020
9765a13
Merge branch 'master' into add_caching_to_tape
trbromley Sep 25, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pennylane/beta/interfaces/autograd.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ def get_parameters(self, trainable_only=True): # pylint: disable=missing-functi
for idx, p in enumerate(self._all_parameter_values)
if idx in self.trainable_params
]
else:
params = self._all_parameter_values
trbromley marked this conversation as resolved.
Show resolved Hide resolved

return autograd.builtins.list(params)

Expand Down
68 changes: 62 additions & 6 deletions pennylane/beta/tapes/qnode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 warnings

Expand Down Expand Up @@ -97,6 +98,12 @@ 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
trbromley marked this conversation as resolved.
Show resolved Hide resolved
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. In caching mode, the quantum circuit
being executed must have a constant structure and only its parameters can be varied.

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
Expand All @@ -113,9 +120,11 @@ class QNode:
>>> qnode = QNode(circuit, dev)
"""

# pylint:disable=too-many-instance-attributes
# pylint:disable=too-many-instance-attributes,too-many-arguments
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆


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 QuantumFunctionError(
Expand All @@ -137,6 +146,22 @@ 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:
warnings.warn(
"Caching mode activated. The quantum circuit being executed by the QNode must have "
"a fixed structure.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way a user would receive this warning even if the quantum circuit was fine, right? Could an error be raised if a user creates a mutable QNode? (E.g. hashing the circuit operations: name of the operation and wire they act on and raising an error if the latest hash differs from the stored hash for the circuit.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is now done: instead of hashing the tape arguments, we use the hash of the circuit graph.

There are performance hits for both approaches: we need to use set_parameters() for the former and we need to serialize for the latter. I hope to summarize more the relative performance in a follow up comment.

)
if 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
Expand Down Expand Up @@ -318,7 +343,7 @@ def _get_parameter_shift_method(device, interface):
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)

# apply the interface (if any)
if self.interface is not None:
Expand Down Expand Up @@ -362,8 +387,16 @@ def __call__(self, *args, **kwargs):
# construct the tape
self.construct(args, kwargs)

if self._caching:
self.qtape._cache_execute = self._cache_execute
trbromley marked this conversation as resolved.
Show resolved Hide resolved

# execute the tape
return self.qtape.execute(device=self.device)
res = self.qtape.execute(device=self.device)

if self._caching:
self._cache_execute = self.qtape._cache_execute
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could only the qtape have the _cache_execute attribute? Since a QNode can access the attributes of a qtape

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, but unfortunately the self.construct() method wipes the previous QTape and starts with a fresh one, so this line allows the cache to persist across multiple QTapes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this answers my question above! Probably a good idea to add a line comment, since Antal and I both independently had the same question

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If caching is on, can we avoid redundant tape constructions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just had another quick try of: when caching is on, tape is only constructed first time.
This results in the tests related to mutability and classical processing to fail. For example, the cache is still being used when parameters are the same but the circuit differs. Although, I'm not sure if the problem is even deeper deeper, since isn't construction the place where the input arguments are fixed to the gate ops?


return res

def to_tf(self, dtype=None):
"""Apply the TensorFlow interface to the internal quantum tape.
Expand Down Expand Up @@ -435,10 +468,20 @@ 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

@caching.setter
def caching(self, value):
self._caching = value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these two properties needed? It doesn't seem that they are used anywhere. Is this to allow the user to modify the cache size dynamically on an existing QNode?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I decided to remove the setter but keep the property, just in case users want to see the current caching value.

With the setter there, I was half thinking to let users dynamically set the cache. This is fine if they set it to a bigger number than the current size, but if setting smaller we need to drop multiple parts of the existing cache. I thought this was a bit of an overcomplication for now, so just got rid of the setter.


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
Expand Down Expand Up @@ -509,6 +552,12 @@ 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
trbromley marked this conversation as resolved.
Show resolved Hide resolved
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. In caching mode, the quantum circuit
being executed must have a constant structure and only its parameters can be varied.

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
Expand All @@ -528,7 +577,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
43 changes: 42 additions & 1 deletion pennylane/beta/tapes/tape.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
This module contains the base quantum tape.
"""
# pylint: disable=too-many-instance-attributes,protected-access,too-many-branches
from collections import OrderedDict
import contextlib

import numpy as np

import pennylane as qml
from pennylane.utils import _hash_iterable

from pennylane.beta.queuing import AnnotatedQueue, QueuingContext
from pennylane.beta.queuing import mock_operations
Expand Down Expand Up @@ -132,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.
Expand All @@ -144,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
trbromley marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -215,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

Expand Down Expand Up @@ -247,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 input parameters to results of
executing the device."""

def __repr__(self):
return f"<{self.__class__.__name__}: wires={self.wires.tolist()}, params={self.num_params}>"

Expand Down Expand Up @@ -912,6 +930,13 @@ def execute_device(self, params, device):
# temporarily mutate the in-place parameters
self.set_parameters(params)

if self._caching:
all_parameters = self.get_parameters(trainable_only=False)
hashed_params = _hash_iterable(all_parameters)
if hashed_params in self._cache_execute:
self.set_parameters(saved_parameters)
return self._cache_execute[hashed_params]

if isinstance(device, qml.QubitDevice):
res = device.execute(self)
else:
Expand All @@ -936,6 +961,12 @@ def execute_device(self, params, device):

# restore original parameters
self.set_parameters(saved_parameters)

if self._caching and hashed_params not in self._cache_execute:
self._cache_execute[hashed_params] = res
if len(self._cache_execute) > self._caching:
self._cache_execute.popitem(last=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to document this behaviour: users could also think that once the caching limit is reached, then nothing happens to the cache. However, this seems to be not the case as each time there's a new execution, the very first cached result is dropped and we're adding the latest result.

Overall also might be worth considering which of the two options is more beneficial.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I've added it to the caching part of the docstring: 5bd1d37
I think the current behaviour makes the most sense, else you might end up with a cache that is very stale. Moreover, we always want to be caching the last execution since that is the most likely one to be repeated.

trbromley marked this conversation as resolved.
Show resolved Hide resolved

return res

# interfaces can optionally override the _execute method
Expand Down Expand Up @@ -1324,3 +1355,13 @@ 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

@caching.setter
def caching(self, value):
self._caching = value
19 changes: 19 additions & 0 deletions pennylane/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,22 @@ def expand_vector(vector, original_wires, expanded_wires):
expanded_tensor = np.moveaxis(expanded_tensor, original_indices, wire_indices)

return expanded_tensor.reshape(2 ** M)


def _hash_iterable(iterable):
"""Returns a single hash of an input iterable.

The iterable must be flat and can contain only numbers and NumPy arrays.

Args:
iterable (Iterable): the iterable to generate a hash for

Returns:
int: the resulting hash
"""
hashes = []
for obj in iterable:
to_hash = (obj.tobytes(), obj.shape) if isinstance(obj, np.ndarray) else obj
obj_hash = hash(to_hash)
hashes.append(obj_hash)
return hash(tuple(hashes))
7 changes: 4 additions & 3 deletions tests/beta/interfaces/test_tape_autograd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"""
Expand Down
Loading