qp.specs¶
- specs(qnode, level=None, compute_depth=None)[source]¶
Provides the specifications of a quantum circuit.
This transform converts a QNode into a callable that provides resource information about the circuit after applying the specified transforms, expansions, and/or compilation passes.
- Parameters:
qnode (
QNode|QJIT) – the QNode to calculate the specifications for.- Keyword Arguments:
level (str | int | slice | iter[int | str] | None) – An indication of which transforms, expansions, and passes to apply before computing the resource information. See
get_compile_pipeline()for more details on the available levels withoutqjit. Forqjit-compiled workflows, see the sections below for more information. When set toNone(the default), this is treated as"device"forqjit-compiled workflows or"gradient"otherwise.compute_depth (bool) – Whether to compute the depth of the circuit. If
False, circuit depth will not be included in the output. By default,specswill always attempt to calculate circuit depth (behaves asTrue), except where not available, such as in pass-by-pass analysis forqjit-compiled workflows.
- Returns:
A function that has the same argument signature as
qnode. This function returns aCircuitSpecsobject containing theqnodespecifications, including gate and measurement data, wire allocations, device information, shots, and more.
Warning
Computing circuit depth is computationally expensive and can lead to slower
specscalculations. If circuit depth is not needed, setcompute_depth=False.Note
The available options for
levelsare different for circuits which have been compiled using Catalyst. There are two broad ways to usespecsonqjit-compiled QNodes:Runtime resource tracking via mock circuit execution
Pass-by-pass resource collection for user applied compilation passes
See related sections below for details regarding use with Catalyst.
Example
from pennylane import numpy as pnp dev = qp.device("default.qubit", wires=2) x = pnp.array([0.1, 0.2]) Hamiltonian = qp.dot([1.0, 0.5], [qp.X(0), qp.Y(0)]) gradient_kwargs = {"shifts": pnp.pi / 4} @qp.qnode(dev, diff_method="parameter-shift", gradient_kwargs=gradient_kwargs) def circuit(x, add_ry=True): qp.RX(x[0], wires=0) qp.CNOT(wires=(0,1)) qp.TrotterProduct(Hamiltonian, time=1.0, n=4, order=2) if add_ry: qp.RY(x[1], wires=1) qp.TrotterProduct(Hamiltonian, time=1.0, n=4, order=4) return qp.probs(wires=(0,1))
>>> print(qp.specs(circuit)(x, add_ry=False)) Device: default.qubit Device wires: 2 Shots: Shots(total=None) Level: gradient Wire allocations: 2 Total gates: 98 Gate counts: - RX: 1 - CNOT: 1 - Evolution: 96 Measurements: - probs(all wires): 1 Depth: 98
The
SpecsResourcescan be accessed using the.resourcesattribute, which provides more direct access to the data fields. For example:>>> qp.specs(circuit)(x, add_ry=False).resources.gate_counts {'RX': 1, 'CNOT': 1, 'Evolution': 96}
Specs with Tape Transforms
Here you can see how the number of gates and their types change as we apply different amounts of transforms through the
levelargument:dev = qp.device("default.qubit") gradient_kwargs = {"shifts": pnp.pi / 4} @qp.transforms.merge_rotations @qp.transforms.undo_swaps @qp.transforms.cancel_inverses @qp.qnode(dev, diff_method="parameter-shift", gradient_kwargs=gradient_kwargs) def circuit(x): qp.RandomLayers(pnp.array([[1.0, 2.0]]), wires=(0, 1)) qp.RX(x, wires=0) qp.RX(-x, wires=0) qp.SWAP((0, 1)) qp.X(0) qp.X(0) return qp.expval(qp.X(0) + qp.Y(1))
First, we can inspect the unmodified QNode by setting
level=0. Note thatlevel="top"is equivalent:>>> print(qp.specs(circuit, level=0)(0.1).resources) Wire allocations: 2 Total gates: 6 Gate counts: - RandomLayers: 1 - RX: 2 - SWAP: 1 - PauliX: 2 Measurements: - expval(Sum(num_wires=2, num_terms=2)): 1 Depth: 6
We can analyze the effects of, for example, applying the first two transforms (
cancel_inverses()andundo_swaps()) by settinglevel=2. The result will show thatSWAPandPauliXare not present in the circuit:>>> print(qp.specs(circuit, level=2)(0.1).resources) Wire allocations: 2 Total gates: 3 Gate counts: - RandomLayers: 1 - RX: 2 Measurements: - expval(Sum(num_wires=2, num_terms=2)): 1 Depth: 3
We can then check the resources after applying all user transforms with
level="user"(which, in this particular example, would be equivalent tolevel=3). The two rotations merge and cancel out, leaving us with onlyRandomLayers:>>> print(qp.specs(circuit, level="user")(0.1).resources) Wire allocations: 2 Total gates: 1 Gate counts: - RandomLayers: 1 Measurements: - expval(Sum(num_wires=2, num_terms=2)): 1 Depth: 1
After the user transforms, additional transforms for device compatibility and gradient support may be applied. To see the resources after all transforms are applied, we can use
level="device". In this case,RandomLayersis not device-compatible and is further decomposed before handing the circuit off to the device:>>> print(qp.specs(circuit, level="device")(0.1).resources) Wire allocations: 2 Total gates: 2 Gate counts: - RY: 1 - RX: 1 Measurements: - expval(Sum(num_wires=2, num_terms=2)): 1 Depth: 1
If a QNode with a tape-splitting transform is supplied to the function, the output will provide resource information separately for each tape:
dev = qp.device("default.qubit") H = qp.Hamiltonian([0.2, -0.543], [qp.X(0) @ qp.Z(1), qp.Z(0) @ qp.Y(2)]) gradient_kwargs = {"shifts": pnp.pi / 4} @qp.transforms.split_non_commuting @qp.qnode(dev, diff_method="parameter-shift", gradient_kwargs=gradient_kwargs) def circuit(): qp.RandomLayers(qp.numpy.array([[1.0, 2.0]]), wires=(0, 1)) return qp.expval(H)
>>> print(qp.specs(circuit, level="user")()) Device: default.qubit Device wires: None Shots: Shots(total=None) Level: user Batched tape a: Wire allocations: 2 Total gates: 1 Gate counts: - RandomLayers: 1 Measurements: - expval(Prod(num_wires=2, num_terms=2)): 1 Depth: 1 Batched tape b: Wire allocations: 3 Total gates: 1 Gate counts: - RandomLayers: 1 Measurements: - expval(Prod(num_wires=2, num_terms=2)): 1 Depth: 1
In this case, the
.resourcesattribute of the returnedCircuitSpecsis a list containing aSpecsResourcesfor each resulting tape:>>> qp.specs(circuit, level="user")().resources [SpecsResources(gate_types={'RandomLayers': 1}, gate_sizes={2: 1}, measurements={'expval(Prod(num_wires=2, num_terms=2))': 1}, num_allocs=2, depth=1), SpecsResources(gate_types={'RandomLayers': 1}, gate_sizes={2: 1}, measurements={'expval(Prod(num_wires=2, num_terms=2))': 1}, num_allocs=3, depth=1)]
Runtime Specs with Catalyst
Note
This functionality is specific to workflows with
qjit.Runtime resource tracking (specified by
level="device") works by mock-executing the desired workflow and tracking the number of times a given gate has been applied. This mock-execution happens after all compilation steps, and should be highly accurate to the final gate counts of running on a real device.dev = qp.device("lightning.qubit", wires=3) @qp.qjit @qp.transforms.merge_rotations @qp.transforms.cancel_inverses @qp.qnode(dev) def circuit(x): qp.RX(x, wires=0) qp.RX(x, wires=0) qp.X(0) qp.X(0) qp.CNOT([0, 1]) return qp.probs()
>>> print(qp.specs(circuit, level="device")(1.23)) Device: lightning.qubit Device wires: 3 Shots: Shots(total=None) Level: device Wire allocations: 3 Total gates: 2 Gate counts: - CNOT: 1 - RX: 1 Measurements: - probs(all wires): 1 Depth: 2
Note
The resources shown when using
level="device"may reflect changes to the circuit beyond those applied by the user transforms added to the QNode. Theses changes are a result of additional passes applied to ensure compatibility with lowering to MLIR and/or execution on the chosen device.Pass-by-pass Specs with Catalyst
Note
This functionality is specific to workflows with
qjit.Pass-by-pass specs analyze the intermediate representations of compiled circuits. This can be helpful for determining how circuit resources change after a given transform or compilation pass.
Warning
Some resource information from pass-by-pass specs may be estimated, since it is not always possible to determine exact resource usage from intermediate representations. For example, resources contained in a
forloop with a non-static range or awhileloop will be counted as if only one iteration occurred. Additionally, resources contained in conditional branches fromiforswitchstatements will take a union of resources over all branches, providing a tight upper-bound.Due to similar technical limitations, depth computation is not available for pass-by-pass specs.
Pass-by-pass specs can be obtained by providing one of the following values for the
levelargument:An
int: the desired pass level of a user-applied pass, see the note belowA marker name (str): The name of an applied
qp.markerpassAn iterable: A
list,tuple, or similar containing ints and/or marker names. Should be sorted in ascending pass order with no duplicatesThe string
"all": To provide information at each stage of compilation with respect to user-specified transformsThe string
"all-mlir": To provide information at each stage of compilation with respect to user-specified transforms exclusively at the MLIR levelThe string
"user": To provide information after all user-specified transforms have been applied
Note
The
levelargument is based on user-applied transforms and compilation passes. Level0always corresponds to the original circuit before any user-specified tape transforms or compilation passes have been applied, and incremental levels correspond to the aggregate of user-specified transforms and passes in the order in which they are applied.In addition to the user-passes, pass-by-pass inspection will indicate where the MLIR “lowering” occurs with the
Before MLIR Passesstage. This will be placed after all tape transforms, but before all other MLIR passes. Note that this may be at level0if there are no tape transforms. In some cases, the pass to lower to MLIR will apply additional transforms to the circuit to ensure compatibility with the MLIR representation and/or with the device, so resources may change as a result of this pass.Consider the following circuit:
dev = qp.device("lightning.qubit", wires=3) @qp.qjit @qp.transforms.merge_rotations @qp.transforms.cancel_inverses @qp.qnode(dev) def circuit(x): qp.RX(x, wires=0) qp.RX(x, wires=0) qp.X(0) qp.X(0) qp.CNOT([0, 1]) return qp.probs()
We can get a pass-by-pass overview of the resources using
level="all":>>> all_specs = qp.specs(circuit, level="all")(1.23) >>> print(all_specs) Device: lightning.qubit Device wires: 3 Shots: Shots(total=None) Levels: - 0: Before MLIR Passes - 1: cancel-inverses - 2: merge-rotations ↓Metric Level→ | 0 | 1 | 2 --------------------------------- Wire allocations | 3 | 3 | 3 Total gates | 5 | 3 | 2 Gate counts: | - CNOT | 1 | 1 | 1 - PauliX | 2 | 0 | 0 - RX | 2 | 2 | 1 Measurements: | - probs(all wires) | 1 | 1 | 1
When invoked with an iterable of levels, or
"all"as above, the resources at different levels can be accessed from the the returnedCircuitSpecsobject’s.resourcesattribute, using the name of a pass or marker. For example:>>> print(all_specs.resources['merge-rotations']) Wire allocations: 3 Total gates: 2 Gate counts: - CNOT: 1 - RX: 1 Measurements: - probs(all wires): 1 Depth: Not computed
A shortcut to access the resources after all user-specified transforms and passes have been applied is to use the
"user"level. For example, the following will also return the resources after themerge-rotationspass:>>> print(qp.specs(circuit, level="user")(1.23).resources) Wire allocations: 3 Total gates: 2 Gate counts: - CNOT: 1 - RX: 1 Measurements: - probs(all wires): 1 Depth: Not computed
Warning
Certain transforms, like the
split_non_commutingtransform, can result in splitting a single execution into multiple executions. In this case, the resources for that level will be returned as a list ofSpecsResourcesobjects. When printed, these split tapes will be shown as individual columns.dev = qp.device("lightning.qubit", wires=3) @qp.qjit @qp.transforms.cancel_inverses @qp.transforms.split_non_commuting @qp.qnode(dev) def circuit(): qp.X(0) qp.X(0) return qp.expval(qp.PauliZ(0)), qp.expval(qp.PauliX(0))
>>> print(qp.specs(circuit, level="all")()) Device: lightning.qubit Device wires: 3 Shots: Shots(total=None) Levels: - 0: Before Tape Transforms - 1: split_non_commuting - 2: Before MLIR Passes - 3: cancel-inverses ↓Metric Level→ | 0 | 1-a | 1-b | 2-a | 2-b | 3-a | 3-b ----------------------------------------------------------------- Wire allocations | 1 | 1 | 1 | 3 | 3 | 3 | 3 Total gates | 2 | 2 | 2 | 2 | 2 | 0 | 0 Gate counts: | - PauliX | 2 | 2 | 2 | 2 | 2 | 0 | 0 Measurements: | - expval(PauliZ) | 1 | 1 | 0 | 1 | 0 | 1 | 0 - expval(PauliX) | 1 | 0 | 1 | 0 | 1 | 0 | 1
Note that in the above example, the
split_non_commutingtransform results in two separate executions, which are labeled with the suffixes-aand-bin the output. The resources for these executions are returned and displayed separately, though the level name for both is the same, since they come from the same transform.