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

Add decomposition for MultiControlledX gate #1287

Merged
merged 43 commits into from
May 14, 2021
Merged

Conversation

trbromley
Copy link
Contributor

@trbromley trbromley commented May 7, 2021

The MultiControlledX gate can now be decomposed, allowing for it to potentially be implemented on hardware or other devices that don't support it.

ctrl_wires = [f"c{i}" for i in range(5)]
work_wires = ["w1"]
target_wires = ["t1"]
all_wires = ctrl_wires + work_wires + target_wires

dev = qml.device("default.qubit", wires=all_wires)

with qml.tape.QuantumTape() as tape:
    qml.MultiControlledX(control_wires=ctrl_wires, wires=target_wires, work_wires=work_wires)
>>> tape = tape.expand(depth=2)
>>> print(tape.draw(wire_order=Wires(all_wires)))
 c0: ──────────╭C──────────────╭C──────────────────╭C──────────────╭C──────────┤  
 c1: ──────────├C──────────────├C──────────────────├C──────────────├C──────────┤  
 c2: ──────╭C──│───╭C──────╭C──│───╭C──────────╭C──│───╭C──────╭C──│───╭C──────┤  
 c3: ──╭C──│───│───│───╭C──│───│───│───────╭C──│───│───│───╭C──│───│───│───────┤  
 c4: ──│───├C──╰X──├C──│───├C──╰X──├C──╭C──│───├C──╰X──├C──│───├C──╰X──├C──╭C──┤  
 w0: ──├X──│───────│───├X──│───────│───├C──├X──│───────│───├X──│───────│───├C──┤  
 t1: ──╰C──╰X──────╰X──╰C──╰X──────╰X──╰X──╰C──╰X──────╰X──╰C──╰X──────╰X──╰X──┤ 

@@ -2051,6 +2080,107 @@ def __init__(self, control_wires=None, wires=None, control_values=None, do_queue
do_queue=do_queue,
)

# pylint: disable=unused-argument
def decomposition(self, *args, **kwargs):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that decomposition is not a static method. We have access to self and also don't care about *args, **kwargs because we read the relevant data from the object's state (e.g., self.control_wires).

This is going against the standard, but it seems to work.

Comment on lines +2086 to +2087
if len(self.control_wires) > 2 and len(self._work_wires) == 0:
raise ValueError(f"At least one work wire is required to decompose operation: {self}")
Copy link
Contributor Author

@trbromley trbromley May 7, 2021

Choose a reason for hiding this comment

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

We could add other decompositions (e.g., without work wires) in future if we find alternatives.

Copy link
Contributor

Choose a reason for hiding this comment

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

Having fun with f-strings in this PR, right Tom? 😉

pennylane/ops/qubit.py Outdated Show resolved Hide resolved
@trbromley trbromley changed the title [WIP] Add decomposition for MultiControlledX gate Add decomposition for MultiControlledX gate May 7, 2021
@trbromley trbromley added the review-ready 👌 PRs which are ready for review by someone from the core team. label May 7, 2021
@trbromley trbromley marked this pull request as ready for review May 7, 2021 21:31
@codecov
Copy link

codecov bot commented May 7, 2021

Codecov Report

Merging #1287 (04ebbf5) into master (7680a48) will increase coverage by 0.01%.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1287      +/-   ##
==========================================
+ Coverage   98.15%   98.16%   +0.01%     
==========================================
  Files         148      148              
  Lines       11359    11430      +71     
==========================================
+ Hits        11149    11220      +71     
  Misses        210      210              
Impacted Files Coverage Δ
...ennylane/circuit_drawer/representation_resolver.py 99.35% <100.00%> (+0.01%) ⬆️
pennylane/ops/qubit.py 98.47% <100.00%> (+0.11%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7680a48...04ebbf5. Read the comment docs.

.github/CHANGELOG.md Outdated Show resolved Hide resolved
.github/CHANGELOG.md Show resolved Hide resolved
return flips1 + decomp + flips2

@staticmethod
def _decomposition_with_many_workers(control_wires, target_wire, work_wires):
Copy link
Contributor

Choose a reason for hiding this comment

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

Very cool that decomposition works for one or multiple work wires!

Copy link
Contributor

Choose a reason for hiding this comment

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

In some cases with multiple work wires, it may be possible to decrease the circuit depth:

ctrl_wires = [f"c{i}" for i in range(5)]
work_wires = [f"w{i}" for i in range(3)]
target_wires = ["t1"]
all_wires = ctrl_wires + work_wires + target_wires
dev = qml.device("default.qubit", wires=all_wires)

with qml.tape.QuantumTape() as tape:
    qml.MultiControlledX(control_wires=ctrl_wires, wires=target_wires, work_wires=work_wires)

Gives:

c0: ──────────────╭C──────────────────────╭C──────────┤  
c1: ──────────────├C──────────────────────├C──────────┤  
c2: ──────────╭C──│───╭C──────────────╭C──│───╭C──────┤  
c3: ──────╭C──│───│───│───╭C──────╭C──│───│───│───╭C──┤  
c4: ──╭C──│───│───│───│───│───╭C──│───│───│───│───│───┤  
w0: ──│───│───├C──╰X──├C──│───│───│───├C──╰X──├C──│───┤  
w1: ──│───├C──╰X──────╰X──├C──│───├C──╰X──────╰X──├C──┤  
w2: ──├C──╰X──────────────╰X──├C──╰X──────────────╰X──┤  
t1: ──╰X──────────────────────╰X──────────────────────┤

I think it's possible to remove the first three and last two gates.

Copy link
Contributor

Choose a reason for hiding this comment

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

How so? Not clear to me that they can be removed

Copy link
Contributor

Choose a reason for hiding this comment

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

My thoughts are that since w0,w1, and w2 are work qubits that start in |0> these CCX gates in the beginning will not have an effect on t1 or the work qubits. Then we wouldn't need to uncompute them at the end either.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should leave them in because it is not necessarily the case that the work qubits will start in |0>; while they do in the examples because a separate work register was created, it is often the case that you are re-purposing "regular" qubits that would otherwise be idle while the MCX is executed. We'd need a way of checking whether or not the qubits are actually in state |0> before doing the simplification and I'm actually not sure that's possible in PL (?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thoughts are that since w0,w1, and w2 are work qubits that start in |0> these CCX gates in the beginning will not have an effect on t1 or the work qubits. Then we wouldn't need to uncompute them at the end either.

That's a nice spot! But I agree with @glassnotes that we'll typically be using non-|0> qubits for the workers. One example is in the _decomposition_with_one_worker, which relies on _decomposition_with_many_workers but can't assume the workers are all in |0>. Another example is QMC, where we will be able to use other phase estimation qubits as the worker(s).

Copy link
Contributor

Choose a reason for hiding this comment

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

I hadn't realized that the work wires can be in a non-|0> state and still give the right results! That's exciting resource-wise!

return qml.probs(wires=range(n_ctrl_wires + 1))

u = np.array([f(b) for b in itertools.product(range(2), repeat=n_ctrl_wires + 1)]).T
spy.assert_called()
Copy link
Contributor

Choose a reason for hiding this comment

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

Very interesting. I didn't know about spy.assert_called()

Copy link
Contributor

@ixfoduap ixfoduap left a comment

Choose a reason for hiding this comment

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

Very clean and thorough PR! Everything was correct to the best of my ability. The tests are a clever way to test decompositions; I wonder if this trick can be a standard way of writing such tests that could even be somewhat automated. All we're doing is applying the decomposition followed by the inverse operation and checking that the output is the identity, right? Seems easy to reproduce as a check for decompositions.

I'm requesting changes just because I would like to see the docstrings be more clear regarding the use of work qubits. How many are needed? What are the consequences in terms of the different decompositions?

@@ -2021,6 +2028,15 @@ class MultiControlledX(ControlledQubitUnitary):
wires (Union[Wires or int]): a single target wire the operation acts on
control_values (str): a string of bits representing the state of the control
qubits to control on (default is the all 1s state)
work_wires (Union[Wires, Sequence[int], or int]): optional work wires used to decompose
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it clear to the user how many work wires need to be passed to decompose the operation? Unless I'm mistaken, it's one per control qubit right? Maybe good to clarify this in the docstring

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, yes good idea to clear that up. Have had a go here: 863fc04

Copy link
Contributor Author

@trbromley trbromley May 10, 2021

Choose a reason for hiding this comment

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

image

Comment on lines +2086 to +2087
if len(self.control_wires) > 2 and len(self._work_wires) == 0:
raise ValueError(f"At least one work wire is required to decompose operation: {self}")
Copy link
Contributor

Choose a reason for hiding this comment

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

Having fun with f-strings in this PR, right Tom? 😉

if len(self.control_wires) > 2 and len(self._work_wires) == 0:
raise ValueError(f"At least one work wire is required to decompose operation: {self}")

flips1 = [
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! So we can control on zeros too?

Copy link
Contributor

Choose a reason for hiding this comment

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

You can control on any combination by passing a bit string! control_values='0110', for example 😁

self.control_wires, self._target_wire, work_wire
)

flips2 = [
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't this the same code as flips1? Is it really needed or can the return just be flips1 + decomp + flips2?

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 had a similar thought - initially just had a single flips and returned flips + decomp + flips. However, this didn't work because although the second application of flips was added to the list, it was not queued.

return flips1 + decomp + flips2

@staticmethod
def _decomposition_with_many_workers(control_wires, target_wire, work_wires):
Copy link
Contributor

Choose a reason for hiding this comment

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

How so? Not clear to me that they can be removed


gates = []

for i in range(len(work_wires)):
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a really nice and symmetric decomposition!

second_part = control_wires[partition:]

gates = [
MultiControlledX(
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm confused. Don't these MultiControlledX() also need to be decomposed, potentially using many work qubits? Or is the idea that this method is fed recursively on itself such that each MultiControlledX is forced to use only one work qubit? If so, then we end up with a tradeoff between total number of qubits and circuit depth between both decomposition methods, right?

Copy link
Contributor

Choose a reason for hiding this comment

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

They do need to be decomposed, but the n-controlled operation is broken down into 4 smaller m-controlled operations such that there are always enough unused qubits that they can be used as work qubits. Like in the example:
image
With the extra work qubit there, the 7-controlled gate turns into a 5-controlled gate and there are conveniently 3 leftover qubits that could be used as scratch space.

That said, yes, there is a tradeoff because you can tinker with the relative sizes of the four smaller MultiControlledX gates. I think the optimum is ceil(n / 2) (which is what gets used here).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @glassnotes! Yes, we're roughly splitting in half and alternating between (worker, controller).

The key thing that helped me understand is that the state of the worker wires is unperturbed.

return qml.probs(wires=range(n_ctrl_wires + 1))

u = np.array([f(b) for b in itertools.product(range(2), repeat=n_ctrl_wires + 1)]).T
assert np.allclose(u, np.eye(2 ** (n_ctrl_wires + 1)))
Copy link
Contributor

Choose a reason for hiding this comment

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

Took me a while to understand, but I finally got it. Very clever!

matrix-based version by checking if U^dagger U applies the identity to each basis
state. This test focuses on using custom wire labels."""
n_ctrl_wires = 4
control_wires = [-1, "alice", 42, 3.14]
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice!

Copy link
Contributor

@glassnotes glassnotes left a comment

Choose a reason for hiding this comment

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

This looks great! Just left some small questions and comments.

.github/CHANGELOG.md Outdated Show resolved Hide resolved
```pycon
>>> tape = tape.expand(depth=2)
>>> print(tape.draw(wire_order=Wires(all_wires)))
c0: ──────────╭C──────────────╭C──────────────────╭C──────────────╭C──────────┤
Copy link
Contributor

Choose a reason for hiding this comment

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

This is minor, but since this is a front-facing example, consider using the decomposition of Barenco et al. Lemma 7.2 that uses 2 extra work qubits. (It looks like the Lemma 7.3 decomposition is used here?) The reason for using the other decomposition is that it can be visually matched to the circuit in the paper, whereas for the circuit here it takes more work to see that the decomposition is correct (since the paper doesn't show the full decomposition down to Toffolis)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea, done that here: 9399bdb

Copy link
Contributor Author

Choose a reason for hiding this comment

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

image
^ For reference

pennylane/ops/qubit.py Show resolved Hide resolved
If ``MultiControlledX`` is not supported on the targeted device, PennyLane will decompose
``MultiControlledX`` into Toffoli gates using the methods described in Lemma 7.2 and 7.3 of
`Barenco et al. <https://arxiv.org/abs/quant-ph/9503016>`__ For control on 3 or more wires,
at least one work wire will be required.
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree with JM's comment above; here would be good to specify that Lemma 7.2 decomposition can only be used when n - 2 extra wires are present for an n-controlled gate, and unless they provide that, Lemma 7.3 is used.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, have done here: 863fc04

pennylane/ops/qubit.py Outdated Show resolved Hide resolved
if len(self.control_wires) > 2 and len(self._work_wires) == 0:
raise ValueError(f"At least one work wire is required to decompose operation: {self}")

flips1 = [
Copy link
Contributor

Choose a reason for hiding this comment

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

You can control on any combination by passing a bit string! control_values='0110', for example 😁

return flips1 + decomp + flips2

@staticmethod
def _decomposition_with_many_workers(control_wires, target_wire, work_wires):
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should leave them in because it is not necessarily the case that the work qubits will start in |0>; while they do in the examples because a separate work register was created, it is often the case that you are re-purposing "regular" qubits that would otherwise be idle while the MCX is executed. We'd need a way of checking whether or not the qubits are actually in state |0> before doing the simplification and I'm actually not sure that's possible in PL (?)

num_work_wires_needed = len(control_wires) - 2
work_wires = work_wires[:num_work_wires_needed]

work_wires_reversed = list(reversed(work_wires))
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the reversal necessary here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The motivation is to capture the first part of the decomposition here:
image

You can see the workers (6, 7, 8) are applied in the order 8, 7, 6. We could probably remove the reversal here and use for i in reversed(range(len(work_wires))).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Edit: started trying this but it made my brain hurt 😆

second_part = control_wires[partition:]

gates = [
MultiControlledX(
Copy link
Contributor

Choose a reason for hiding this comment

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

They do need to be decomposed, but the n-controlled operation is broken down into 4 smaller m-controlled operations such that there are always enough unused qubits that they can be used as work qubits. Like in the example:
image
With the extra work qubit there, the 7-controlled gate turns into a 5-controlled gate and there are conveniently 3 leftover qubits that could be used as scratch space.

That said, yes, there is a tradeoff because you can tinker with the relative sizes of the four smaller MultiControlledX gates. I think the optimum is ceil(n / 2) (which is what gets used here).

"""Test that the state of the worker wires is unperturbed after the decomposition has used
them. To do this, a random state over all the qubits (control, target and workers) is
loaded and U^dagger U(decomposed) is applied. If the workers are uncomputed, the output
state will be the same as the input."""
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a great test to have!

Copy link
Contributor Author

@trbromley trbromley left a comment

Choose a reason for hiding this comment

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

Thanks @ixfoduap @DSGuala @glassnotes for the useful comments! I have tried to explain a bit more the choice of worker wires in the docstrings.

.github/CHANGELOG.md Show resolved Hide resolved
```pycon
>>> tape = tape.expand(depth=2)
>>> print(tape.draw(wire_order=Wires(all_wires)))
c0: ──────────╭C──────────────╭C──────────────────╭C──────────────╭C──────────┤
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea, done that here: 9399bdb

```pycon
>>> tape = tape.expand(depth=2)
>>> print(tape.draw(wire_order=Wires(all_wires)))
c0: ──────────╭C──────────────╭C──────────────────╭C──────────────╭C──────────┤
Copy link
Contributor Author

Choose a reason for hiding this comment

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

image
^ For reference

Comment on lines +379 to +383
elif base_name == "MultiControlledX":
if wire in op.control_wires:
return self.charset.CONTROL
representation = "X"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@DSGuala, this is what was needed to support drawing of MultiControlledX

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One caveat is that I couldn't think of a way to represent control_values 🤔 But I think this can be left as a follow-up question.

@@ -2021,6 +2028,15 @@ class MultiControlledX(ControlledQubitUnitary):
wires (Union[Wires or int]): a single target wire the operation acts on
control_values (str): a string of bits representing the state of the control
qubits to control on (default is the all 1s state)
work_wires (Union[Wires, Sequence[int], or int]): optional work wires used to decompose
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, yes good idea to clear that up. Have had a go here: 863fc04

self.control_wires, self._target_wire, work_wire
)

flips2 = [
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 had a similar thought - initially just had a single flips and returned flips + decomp + flips. However, this didn't work because although the second application of flips was added to the list, it was not queued.

return flips1 + decomp + flips2

@staticmethod
def _decomposition_with_many_workers(control_wires, target_wire, work_wires):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thoughts are that since w0,w1, and w2 are work qubits that start in |0> these CCX gates in the beginning will not have an effect on t1 or the work qubits. Then we wouldn't need to uncompute them at the end either.

That's a nice spot! But I agree with @glassnotes that we'll typically be using non-|0> qubits for the workers. One example is in the _decomposition_with_one_worker, which relies on _decomposition_with_many_workers but can't assume the workers are all in |0>. Another example is QMC, where we will be able to use other phase estimation qubits as the worker(s).

num_work_wires_needed = len(control_wires) - 2
work_wires = work_wires[:num_work_wires_needed]

work_wires_reversed = list(reversed(work_wires))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The motivation is to capture the first part of the decomposition here:
image

You can see the workers (6, 7, 8) are applied in the order 8, 7, 6. We could probably remove the reversal here and use for i in reversed(range(len(work_wires))).

num_work_wires_needed = len(control_wires) - 2
work_wires = work_wires[:num_work_wires_needed]

work_wires_reversed = list(reversed(work_wires))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Edit: started trying this but it made my brain hurt 😆

second_part = control_wires[partition:]

gates = [
MultiControlledX(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @glassnotes! Yes, we're roughly splitting in half and alternating between (worker, controller).

The key thing that helped me understand is that the state of the worker wires is unperturbed.

@trbromley trbromley requested a review from ixfoduap May 10, 2021 22:19
Copy link
Contributor

@ixfoduap ixfoduap left a comment

Choose a reason for hiding this comment

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

:shipit:

@trbromley trbromley merged commit 8e099b4 into master May 14, 2021
@trbromley trbromley deleted the multi_controlled_x_decomp branch May 14, 2021 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
review-ready 👌 PRs which are ready for review by someone from the core team.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants