Skip to content

Commit a453475

Browse files
astralcaiisaacdevlugtmudit2812
authored
DecompositionGraph can be solved with work wire constraint (#7963)
**Context:** This is a follow-up to #7861, updating the decomposition graph so that it can be solved under a maximum number of work wires constraint. **Assumptions** - The same number of work wires is available to every operator at the top level of the circuit. - All work wires will be allocated from this initial pool of wires, no borrowing algorithmic wires (whatever that means) or reallocation of wires not yet deallocated. - If a decomposition rule requests work wires, these wires are kept until the end of the decomposition. Even if the decomposition rule deallocates these wires early in its internal implementation, since there is currently no way to specify this, we must assume that these wires cannot be used in further decompositions of operators within. **Algorithm** Before dynamic wire allocation, work wires are specified as an operator property, and included in an operator's resource params. Two operators with different work wire budgets (I use the term work wire budget to mean how many work wires is available for the decomposition of an operator) are stored as two separate nodes in the graph. With dynamic wire allocation, the number of available work wires is no longer an operator property, so two operators with different work wire budgets will have identical resource representations. Therefore, we cannot use `CompressedResourceOp` as graph nodes anymore, because it's now missing a crucial piece of information. The first step is to define a `_OperatorNode` class, which contains both the `CompressedResourceOp` and the number of available work wires (previously a part of the `CompressedResourceOp`), and use this as nodes in the graph. The number of work wires available to each operator is tracked during the graph construction process. Imagine passing a `work_wires` argument to every operator at the top level of the circuit, and each decomposition takes the wires that it needs, and pass the rest of the work wires to every op within. **Optimizations** There are a few inefficiencies with this approach: 1. The graph must be constructed with a maximum work wire budget. If it changes, the entire graph must be reconstructed with a new work wire budget. **Solution**: throughout the graph, we track not the number of work wires available to an operator, but the number of work wires NOT available to an operator. The top level operators all have `num_work_wires_not_available=0`, indicating that they have access to all the available work wires. If a decomposition of a top level operator uses 2 work wires, then all operators produced from this decomposition has `num_work_wires_not_available=2`. This allows the graph to be constructed once and solved multiple times with different work wire budgets. 2. The number of nodes in the graph grows significantly. Imagine every operator now comes with a `work_wires` property. Sometimes, two operators with different work wire budgets do not necessarily need to be two separate nodes in the graph. For example, an `RX` with 1 work wire available and an `RX` with 2 work wires available should not be two separate nodes. **Solution**: during graph construction, we track not only the number of work wires available, but also whether any decomposition rules of an operator, or any operator produced from any of the decompositions, depend on work wires. Since the graph construction is recursive and depth-first, when we first encounter an operator, and build the sub-graph with all of its decompositions, we label this operator node as either work-wire dependent or not. The next time we encounter the same operator with a different work wire budget, we create a new node for this operator only if this operator was previously marked as work-wire dependent. This significantly reduces the number of nodes. **Related GitHub Issues:** [sc-94400] --------- Co-authored-by: Isaac De Vlugt <[email protected]> Co-authored-by: Mudit Pandey <[email protected]>
1 parent c077269 commit a453475

File tree

8 files changed

+442
-102
lines changed

8 files changed

+442
-102
lines changed

doc/releases/changelog-dev.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@
255255

256256
* With :func:`~.decomposition.enable_graph()`, dynamically allocated wires are now supported in decomposition rules. This provides a smoother overall experience when decomposing operators in a way that requires auxiliary/work wires.
257257
[(#7861)](https://github.com/PennyLaneAI/pennylane/pull/7861)
258+
[(#7963)](https://github.com/PennyLaneAI/pennylane/pull/7963)
258259

259260
* A :class:`~.decomposition.decomposition_graph.DecompGraphSolution` class is added to store the solution of a decomposition graph. An instance of this class is returned from the `solve` method of the :class:`~.decomposition.decomposition_graph.DecompositionGraph`.
260261
[(#8031)](https://github.com/PennyLaneAI/pennylane/pull/8031)

pennylane/decomposition/decomposition_graph.py

Lines changed: 284 additions & 83 deletions
Large diffs are not rendered by default.

pennylane/decomposition/decomposition_rule.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ class WorkWireSpec:
4848
garbage: int = 0
4949
"""Garbage wires could be allocated in any state, and can be deallocated in any state."""
5050

51+
@property
52+
def total(self) -> int:
53+
"""The total number of work wires."""
54+
return self.zeroed + self.borrowed + self.burnable + self.garbage
55+
5156

5257
@overload
5358
def register_condition(condition: Callable) -> Callable[[Callable], DecompositionRule]: ...
@@ -402,7 +407,7 @@ def is_applicable(self, *args, **kwargs) -> bool:
402407
return True
403408
return self._condition(*args, **kwargs)
404409

405-
def work_wire_spec(self, *args, **kwargs) -> WorkWireSpec:
410+
def get_work_wire_spec(self, *args, **kwargs) -> WorkWireSpec:
406411
"""Gets the work wire requirements of this decomposition rule"""
407412
if isinstance(self._work_wire_spec, dict):
408413
return WorkWireSpec(**self._work_wire_spec)

pennylane/decomposition/resources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class Resources:
3434
"""
3535

3636
gate_counts: dict[CompressedResourceOp, int] = field(default_factory=dict)
37-
weighted_cost: float | None = field(default=None)
37+
weighted_cost: float = field(default=None)
3838

3939
def __post_init__(self):
4040
"""Verify that all gate counts are non-zero."""

pennylane/decomposition/symbolic_decomposition.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ def _resource_fn(base_class, base_params): # pylint: disable=unused-argument
3737
for decomp_op, count in base_resources.gate_counts.items()
3838
}
3939

40+
# pylint: disable=protected-access
4041
@register_condition(_condition_fn)
41-
@register_resources(_resource_fn)
42+
@register_resources(_resource_fn, work_wires=base_decomposition._work_wire_spec)
4243
def _impl(*params, wires, base, **__):
4344
# pylint: disable=protected-access
4445
qml.adjoint(base_decomposition._impl)(*params, wires=wires, **base.hyperparameters)
@@ -183,7 +184,7 @@ def decompose_to_base(*params, wires, base, **__):
183184
self_adjoint: DecompositionRule = decompose_to_base
184185

185186

186-
def make_controlled_decomp(base_decomposition):
187+
def make_controlled_decomp(base_decomposition: DecompositionRule):
187188
"""Create a decomposition rule for the control of a decomposition rule."""
188189

189190
def _condition_fn(base_params, **_):
@@ -209,9 +210,9 @@ def _resource_fn(
209210
gate_counts[resource_rep(qml.PauliX)] = num_zero_control_values * 2
210211
return gate_counts
211212

212-
# pylint: disable=too-many-arguments
213+
# pylint: disable=protected-access,too-many-arguments
213214
@register_condition(_condition_fn)
214-
@register_resources(_resource_fn)
215+
@register_resources(_resource_fn, work_wires=base_decomposition._work_wire_spec)
215216
def _impl(*params, wires, control_wires, control_values, work_wires, work_wire_type, base, **_):
216217
zero_control_wires = [w for w, val in zip(control_wires, control_values) if not val]
217218
for w in zero_control_wires:
@@ -249,8 +250,9 @@ def _resource_fn(**resource_params):
249250
gate_counts[resource_rep(qml.X)] = gate_counts.get(resource_rep(qml.X), 0) + num_x * 2
250251
return gate_counts
251252

253+
# pylint: disable=protected-access
252254
@register_condition(_condition_fn)
253-
@register_resources(_resource_fn)
255+
@register_resources(_resource_fn, work_wires=inner_decomp._work_wire_spec)
254256
def _impl(*params, wires, control_wires, control_values, **kwargs):
255257
zero_control_wires = [w for w, val in zip(control_wires, control_values) if not val]
256258
for w in zero_control_wires:

tests/decomposition/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ def _t_ps(wires, **__):
147147

148148
decompositions["T"] = [_t_ps]
149149

150+
151+
@qml.register_resources({qml.RZ: 3, qml.RY: 2, qml.CNOT: 2})
152+
def _crot(*_, **__):
153+
raise NotImplementedError
154+
155+
156+
decompositions["CRot"] = [_crot]
157+
150158
################################################
151159
# Custom Decompositions For Symbolic Operators #
152160
################################################

tests/decomposition/test_decomposition_graph.py

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from pennylane.decomposition.decomposition_graph import _to_name
3333
from pennylane.exceptions import DecompositionError
34+
from pennylane.operation import Operation
3435

3536
# pylint: disable=protected-access,no-name-in-module
3637

@@ -153,7 +154,7 @@ def test_graph_construction(self, _):
153154
def test_graph_construction_non_applicable_rules(self, _):
154155
"""Tests rules which are not applicable are skipped."""
155156

156-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
157+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
157158
"""A custom op"""
158159

159160
resource_keys = {"num_wires"}
@@ -190,7 +191,7 @@ def some_other_rule(*_, **__):
190191
def test_gate_set(self, _):
191192
"""Tests that graph construction stops at the target gate set."""
192193

193-
class CustomOp(qml.operation.Operator): # pylint: disable=too-few-public-methods
194+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
194195
"""A custom operation."""
195196

196197
resource_keys = set()
@@ -247,6 +248,9 @@ def test_graph_solve(self, _):
247248
{qml.RY: 1, qml.GlobalPhase: 1, qml.RZ: 1},
248249
)
249250

251+
# verify that is_solved_for returns False for non-existent operators
252+
assert not solution.is_solved_for(qml.Toffoli(wires=[0, 1, 2]))
253+
250254
def test_decomposition_not_found(self, _):
251255
"""Tests that the correct error is raised if a decomposition isn't found."""
252256

@@ -258,7 +262,7 @@ def test_decomposition_not_found(self, _):
258262
def test_lazy_solve(self, _):
259263
"""Tests the lazy keyword argument."""
260264

261-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
265+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
262266
"""A custom operation."""
263267

264268
resource_keys = set()
@@ -267,7 +271,7 @@ class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-metho
267271
def resource_params(self):
268272
return {}
269273

270-
class AnotherOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
274+
class AnotherOp(Operation): # pylint: disable=too-few-public-methods
271275
"""Another custom operation."""
272276

273277
resource_keys = set()
@@ -311,7 +315,7 @@ def _another_decomp(*_, **__):
311315
def test_decomposition_with_resource_params(self, _):
312316
"""Tests operators with non-empty resource params."""
313317

314-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
318+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
315319
"""A custom operation."""
316320

317321
resource_keys = {"num_wires"}
@@ -358,6 +362,125 @@ def _custom_decomp(*_, **__):
358362
{qml.RZ: 2, qml.RX: 1, qml.GlobalPhase: 1},
359363
)
360364

365+
def test_work_wire_requirement(self, _):
366+
"""Tests that the graph respects the work wire requirement."""
367+
368+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
369+
"""A custom operation."""
370+
371+
resource_keys = set()
372+
373+
@property
374+
def resource_params(self):
375+
return {}
376+
377+
@qml.register_resources({qml.Toffoli: 2, qml.CRot: 1}, work_wires={"zeroed": 1})
378+
def _decomp_with_work_wire(*_, **__):
379+
raise NotImplementedError
380+
381+
@qml.register_resources({qml.Toffoli: 2, qml.CRot: 3})
382+
def _decomp_without_work_wire(*_, **__):
383+
raise NotImplementedError
384+
385+
graph = DecompositionGraph(
386+
[CustomOp(wires=[0, 1, 2])],
387+
gate_set={qml.Toffoli, qml.CRot},
388+
alt_decomps={CustomOp: [_decomp_without_work_wire, _decomp_with_work_wire]},
389+
)
390+
391+
solution = graph.solve(num_work_wires=0)
392+
assert solution.decomposition(CustomOp(wires=[0, 1, 2])) is _decomp_without_work_wire
393+
394+
solution = graph.solve(num_work_wires=1)
395+
assert (
396+
solution.decomposition(CustomOp(wires=[0, 1, 2]), num_work_wires=1)
397+
is _decomp_with_work_wire
398+
)
399+
400+
def test_multiple_nodes_with_different_work_wire_budget(self, _):
401+
"""Tests that the same operator produced under different work wire budgets
402+
are stored as different nodes in the graph, and results can be queried."""
403+
404+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
405+
"""A custom operation."""
406+
407+
resource_keys = set()
408+
409+
@property
410+
def resource_params(self):
411+
return {}
412+
413+
@qml.register_resources({qml.Toffoli: 2, qml.CRot: 1}, work_wires={"zeroed": 2})
414+
def _decomp_with_work_wire(*_, **__):
415+
raise NotImplementedError
416+
417+
@qml.register_resources({qml.Toffoli: 4, qml.CRot: 3})
418+
def _decomp_without_work_wire(*_, **__):
419+
raise NotImplementedError
420+
421+
class LargeOp(Operation): # pylint: disable=too-few-public-methods
422+
"""A larger custom operation."""
423+
424+
resource_keys = set()
425+
426+
@property
427+
def resource_params(self):
428+
return {}
429+
430+
@qml.register_resources({qml.Toffoli: 2, CustomOp: 2}, work_wires={"zeroed": 1})
431+
def _decomp2_with_work_wire(*_, **__):
432+
raise NotImplementedError
433+
434+
@qml.register_resources({qml.Toffoli: 4, CustomOp: 2})
435+
def _decomp2_without_work_wire(*_, **__):
436+
raise NotImplementedError
437+
438+
op = LargeOp(wires=[0, 1, 2, 3])
439+
small_op = CustomOp(wires=[0, 1, 2])
440+
441+
graph = DecompositionGraph(
442+
[op, small_op],
443+
gate_set={qml.Toffoli, qml.RZ, qml.RY, qml.CNOT},
444+
alt_decomps={
445+
CustomOp: [_decomp_without_work_wire, _decomp_with_work_wire],
446+
LargeOp: [_decomp2_without_work_wire, _decomp2_with_work_wire],
447+
},
448+
)
449+
450+
# 1 node for LargerOp, 2 nodes for CustomOp, 1 for Toffoli, 1 for CRot, 1 for RZ,
451+
# 1 for RY, 1 for CNOT, and 1 dummy starting node, 1 decomposition from CRot,
452+
# node, 2 decomposition nodes from LargerOp, 2 decompositions from each CustomOp
453+
assert len(graph._graph.nodes()) == 16
454+
assert len(graph._graph.edges()) == 26
455+
456+
solution = graph.solve(num_work_wires=0)
457+
assert solution.decomposition(op) is _decomp2_without_work_wire
458+
assert solution.decomposition(small_op) is _decomp_without_work_wire
459+
460+
solution = graph.solve(num_work_wires=1)
461+
assert solution.decomposition(op, num_work_wires=1) is _decomp2_with_work_wire
462+
assert solution.decomposition(small_op, num_work_wires=0) is _decomp_without_work_wire
463+
464+
solution = graph.solve(num_work_wires=2)
465+
# When there are only 2 work wires available, by construction, it is more
466+
# resource efficient to use them on the CustomOp, so even where there are
467+
# enough work wires to use the more efficient decomposition for the LargeOp,
468+
# we should still choose the less efficient one to achieve better overall
469+
# resource efficiency. Because if we use one of the work wires to decompose
470+
# the LargeOp, there won't be enough work wires left to further decompose
471+
# the 2 CustomOp and it would result in significantly more gates.
472+
assert solution.decomposition(op, num_work_wires=2) is _decomp2_without_work_wire
473+
assert solution.decomposition(small_op, num_work_wires=2) is _decomp_with_work_wire
474+
475+
solution = graph.solve(num_work_wires=3)
476+
assert solution.decomposition(op, num_work_wires=3) is _decomp2_with_work_wire
477+
assert solution.decomposition(small_op, num_work_wires=2) is _decomp_with_work_wire
478+
assert solution.decomposition(small_op, num_work_wires=3) is _decomp_with_work_wire
479+
480+
solution = graph.solve(num_work_wires=None)
481+
assert solution.decomposition(op, num_work_wires=None) is _decomp2_with_work_wire
482+
assert solution.decomposition(small_op, num_work_wires=None) is _decomp_with_work_wire
483+
361484

362485
@pytest.mark.unit
363486
@patch(
@@ -413,7 +536,7 @@ def test_custom_controlled_op(self, _):
413536
def test_controlled_base_decomposition(self, _):
414537
"""Tests applying control on the decomposition of the target operator."""
415538

416-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
539+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
417540
"""A custom operation."""
418541

419542
resource_keys = set()
@@ -432,7 +555,7 @@ def second_decomp(wires):
432555
qml.Z(wires=wires[0])
433556
qml.GlobalPhase(np.pi / 2, wires=wires)
434557

435-
class CustomControlledOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
558+
class CustomControlledOp(Operation): # pylint: disable=too-few-public-methods
436559
"""A custom operation."""
437560

438561
resource_keys = set()
@@ -575,7 +698,7 @@ def test_adjoint_custom(self, _):
575698
def test_adjoint_general(self, _):
576699
"""Tests decomposition of a generalized adjoint operation."""
577700

578-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
701+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
579702
"""A custom operation."""
580703

581704
resource_keys = set()
@@ -688,7 +811,7 @@ def my_adjoint_rx(theta, wires, **__):
688811
def test_special_pow_decomps(self, _):
689812
"""Tests special cases for decomposing a power."""
690813

691-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
814+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
692815
"""A custom operation."""
693816

694817
resource_keys = set()
@@ -724,7 +847,7 @@ def resource_params(self):
724847
def test_general_pow_decomps(self, _):
725848
"""Tests the more general power decomposition rules."""
726849

727-
class CustomOp(qml.operation.Operation): # pylint: disable=too-few-public-methods
850+
class CustomOp(Operation): # pylint: disable=too-few-public-methods
728851
"""A custom operation."""
729852

730853
resource_keys = set()

tests/decomposition/test_decomposition_rule.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ def test_register_work_wires(self):
298298
def custom_decomp(*_, **__):
299299
raise NotImplementedError
300300

301-
assert custom_decomp.work_wire_spec() == WorkWireSpec(1, 3, 4, 2)
301+
assert custom_decomp.get_work_wire_spec() == WorkWireSpec(1, 3, 4, 2)
302302

303303
@register_resources(
304304
lambda num_wires: {qml.CNOT: num_wires},
@@ -311,4 +311,4 @@ def custom_decomp(*_, **__):
311311
def custom_decomp_2(*_, **__):
312312
raise NotImplementedError
313313

314-
assert custom_decomp_2.work_wire_spec(num_wires=5) == WorkWireSpec(zeroed=2, borrowed=3)
314+
assert custom_decomp_2.get_work_wire_spec(num_wires=5) == WorkWireSpec(zeroed=2, borrowed=3)

0 commit comments

Comments
 (0)