Unitary Simulator

The Unitary Simulator uses the same underlying techniques as the Circuit Simulator, but instead of computing the final state vector, it computes the unitary matrix that represents the (functionality of the) quantum circuit. Specifically, given a quantum circuit \(G=g_0g_1\ldots g_{|G|-1}\), the unitary simulator computes the matrix \(U=U_{{|G|-1}}\ldots U_{1}U_{0}\), where \(U_g\) is the unitary matrix that represents the functionality of the gate \(g\).

To this end, it starts off with the decision diagram representation of the identity \(I\) (which is maximally compact as a decision diagram) and then applies the gates of the circuit one by one. The DD representation of the unitary is updated in each step. The final result is a decision diagram that represents the unitary matrix \(U\). Note that, by definition, this simulator can only handle circuits composed of unitary operations.

In general, the unitary matrix for an \(n\)-qubit circuit is a \(2^n \times 2^n\) matrix. The decision diagram representation of such a matrix can be exponentially more compact than the full matrix representation. Hence, as the other simulators, the unitary simulator can take advantage of the decision diagram representation to efficiently compute a representation of the functionality of the quantum circuit, even in cases where the full matrix representation would be infeasible due to its exponential size.

Computing a simple unitary

Let us start by computing the unitary matrix of a simple quantum circuit. Out of convenience, the following will use the QuantumCircuit class from Qiskit to define the circuit. However, the unitary simulator generally accepts the same input types as all other simulators (e.g., OpenQASM).

[1]:
from qiskit import QuantumCircuit

qc = QuantumCircuit(1)
qc.x(0)

qc.draw(output="mpl", style="iqp")
[1]:
../_images/simulators_UnitarySimulator_1_0.svg
[2]:
import graphviz

from mqt.ddsim import UnitarySimulator

# Create the simulator
sim = UnitarySimulator(qc)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=True, classic=False)

graphviz.Source(source=dot)
[2]:
../_images/simulators_UnitarySimulator_2_0.svg
[3]:
import numpy as np

from mqt.ddsim import get_matrix

# Get the matrix representation of the unitary
unitary = np.zeros((2**qc.num_qubits, 2**qc.num_qubits), dtype=np.complex128)
get_matrix(sim, unitary)

print(unitary)
[[0.+0.j 1.+0.j]
 [1.+0.j 0.+0.j]]

Examples

The following examples demonstrate a couple of different aspects about the unitary simulator.

Multiple qubits and qubit ordering

[4]:
from qiskit import QuantumCircuit

qc = QuantumCircuit(2)
qc.x(0)

qc.draw(output="mpl", style="iqp", wire_order=[1, 0])
[4]:
../_images/simulators_UnitarySimulator_5_0.svg
[5]:
import graphviz

from mqt.ddsim import UnitarySimulator

# Create the simulator
sim = UnitarySimulator(qc)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=True, classic=False)

graphviz.Source(source=dot)
[5]:
../_images/simulators_UnitarySimulator_6_0.svg
[6]:
import numpy as np

from mqt.ddsim import get_matrix

unitary = np.zeros((2**qc.num_qubits, 2**qc.num_qubits), dtype=np.complex128)

get_matrix(sim, unitary)

unitary
[6]:
array([[0.+0.j, 1.+0.j, 0.+0.j, 0.+0.j],
       [1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j],
       [0.+0.j, 0.+0.j, 0.+0.j, 1.+0.j],
       [0.+0.j, 0.+0.j, 1.+0.j, 0.+0.j]])

Now, consider applying the gate to the other qubit instead.

[7]:
from qiskit import QuantumCircuit

qc = QuantumCircuit(2)
qc.x(1)

qc.draw(output="mpl", style="iqp", wire_order=[1, 0])
[7]:
../_images/simulators_UnitarySimulator_9_0.svg
[8]:
import graphviz

from mqt.ddsim import UnitarySimulator

# Create the simulator
sim = UnitarySimulator(qc)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=True, classic=False)

graphviz.Source(source=dot)
[8]:
../_images/simulators_UnitarySimulator_10_0.svg
[9]:
import numpy as np

from mqt.ddsim import get_matrix

unitary = np.zeros((2**qc.num_qubits, 2**qc.num_qubits), dtype=np.complex128)

get_matrix(sim, unitary)

unitary
[9]:
array([[0.+0.j, 0.+0.j, 1.+0.j, 0.+0.j],
       [0.+0.j, 0.+0.j, 0.+0.j, 1.+0.j],
       [1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j],
       [0.+0.j, 1.+0.j, 0.+0.j, 0.+0.j]])

Multi-controlled quantum operations

The following shows an example of how efficiently decision diagrams can represent multi-controlled quantum operations.

[10]:
from qiskit import QuantumCircuit

num_qubits = 8
qc = QuantumCircuit(num_qubits)
qc.mcx(control_qubits=list(reversed(range(1, num_qubits))), target_qubit=0)

qc.draw(output="mpl", style="iqp", wire_order=list(reversed(range(num_qubits))))
[10]:
../_images/simulators_UnitarySimulator_13_0.svg
[11]:
import graphviz

from mqt.ddsim import UnitarySimulator

# Create the simulator
sim = UnitarySimulator(qc)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=True, classic=False)

graphviz.Source(source=dot)
[11]:
../_images/simulators_UnitarySimulator_14_0.svg
[12]:
import numpy as np

from mqt.ddsim import get_matrix

unitary = np.zeros((2**qc.num_qubits, 2**qc.num_qubits), dtype=np.complex128)

get_matrix(sim, unitary)

print(unitary)
[[1.+0.j 0.+0.j 0.+0.j ... 0.+0.j 0.+0.j 0.+0.j]
 [0.+0.j 1.+0.j 0.+0.j ... 0.+0.j 0.+0.j 0.+0.j]
 [0.+0.j 0.+0.j 1.+0.j ... 0.+0.j 0.+0.j 0.+0.j]
 ...
 [0.+0.j 0.+0.j 0.+0.j ... 1.+0.j 0.+0.j 0.+0.j]
 [0.+0.j 0.+0.j 0.+0.j ... 0.+0.j 0.+0.j 1.+0.j]
 [0.+0.j 0.+0.j 0.+0.j ... 0.+0.j 1.+0.j 0.+0.j]]

Unitary of a complete circuit

The following computes the unitary for a circuit consisting of multiple gates.

[13]:
from qiskit import QuantumCircuit

num_qubits = 5
qc = QuantumCircuit(num_qubits)
qc.h(num_qubits - 1)
for i in reversed(range(num_qubits - 1)):
    qc.cx(num_qubits - 1, i)

qc.draw(output="mpl", style="iqp", wire_order=list(reversed(range(num_qubits))))
[13]:
../_images/simulators_UnitarySimulator_17_0.svg
[14]:
import graphviz

from mqt.ddsim import UnitarySimulator

# Create the simulator
sim = UnitarySimulator(qc)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=True, classic=False)

graphviz.Source(source=dot)
[14]:
../_images/simulators_UnitarySimulator_18_0.svg
[15]:
import numpy as np

from mqt.ddsim import get_matrix

unitary = np.zeros((2**qc.num_qubits, 2**qc.num_qubits), dtype=np.complex128)

get_matrix(sim, unitary)

unitary
[15]:
array([[0.70710678+0.j, 0.        +0.j, 0.        +0.j, ...,
        0.        +0.j, 0.        +0.j, 0.        +0.j],
       [0.        +0.j, 0.70710678+0.j, 0.        +0.j, ...,
        0.        +0.j, 0.        +0.j, 0.        +0.j],
       [0.        +0.j, 0.        +0.j, 0.70710678+0.j, ...,
        0.        +0.j, 0.        +0.j, 0.        +0.j],
       ...,
       [0.        +0.j, 0.        +0.j, 0.70710678+0.j, ...,
        0.        +0.j, 0.        +0.j, 0.        +0.j],
       [0.        +0.j, 0.70710678+0.j, 0.        +0.j, ...,
        0.        +0.j, 0.        +0.j, 0.        +0.j],
       [0.70710678+0.j, 0.        +0.j, 0.        +0.j, ...,
        0.        +0.j, 0.        +0.j, 0.        +0.j]])

Decision diagrams are not always compact

The following example aims to demonstrate that decision diagrams are not a holy grail to constructing unitaries for circuits. In the worst case, they are still exponentially large. At that point, a plain array representation most likely becomes more performant.

[16]:
import numpy as np
from qiskit import QuantumCircuit

qc = QuantumCircuit(3)
qc.h(2)
qc.cp(np.pi / 2, 1, 2)
qc.cp(np.pi / 4, 0, 2)
qc.h(1)
qc.cp(np.pi / 2, 0, 1)
qc.h(0)
qc.swap(0, 2)

qc.draw(output="mpl", style="iqp", wire_order=[2, 1, 0])
[16]:
../_images/simulators_UnitarySimulator_21_0.svg
[17]:
import graphviz

from mqt.ddsim import UnitarySimulator

# Create the simulator
sim = UnitarySimulator(qc)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=False, classic=False)

graphviz.Source(source=dot)
[17]:
../_images/simulators_UnitarySimulator_22_0.svg
[18]:
import numpy as np

from mqt.ddsim import get_matrix

unitary = np.zeros((2**qc.num_qubits, 2**qc.num_qubits), dtype=np.complex128)

get_matrix(sim, unitary)

unitary
[18]:
array([[ 0.35355339+0.j        ,  0.35355339+0.j        ,
         0.35355339+0.j        ,  0.35355339+0.j        ,
         0.35355339+0.j        ,  0.35355339+0.j        ,
         0.35355339+0.j        ,  0.35355339+0.j        ],
       [ 0.35355339+0.j        ,  0.25      +0.25j      ,
         0.        +0.35355339j, -0.25      +0.25j      ,
        -0.35355339+0.j        , -0.25      -0.25j      ,
         0.        -0.35355339j,  0.25      -0.25j      ],
       [ 0.35355339+0.j        ,  0.        +0.35355339j,
        -0.35355339+0.j        , -0.        -0.35355339j,
         0.35355339+0.j        ,  0.        +0.35355339j,
        -0.35355339+0.j        , -0.        -0.35355339j],
       [ 0.35355339+0.j        , -0.25      +0.25j      ,
        -0.        -0.35355339j,  0.25      +0.25j      ,
        -0.35355339+0.j        ,  0.25      -0.25j      ,
         0.        +0.35355339j, -0.25      -0.25j      ],
       [ 0.35355339+0.j        , -0.35355339+0.j        ,
         0.35355339+0.j        , -0.35355339+0.j        ,
         0.35355339+0.j        , -0.35355339+0.j        ,
         0.35355339+0.j        , -0.35355339+0.j        ],
       [ 0.35355339+0.j        , -0.25      -0.25j      ,
         0.        +0.35355339j,  0.25      -0.25j      ,
        -0.35355339+0.j        ,  0.25      +0.25j      ,
         0.        -0.35355339j, -0.25      +0.25j      ],
       [ 0.35355339+0.j        ,  0.        -0.35355339j,
        -0.35355339+0.j        ,  0.        +0.35355339j,
         0.35355339+0.j        ,  0.        -0.35355339j,
        -0.35355339+0.j        ,  0.        +0.35355339j],
       [ 0.35355339+0.j        ,  0.25      -0.25j      ,
        -0.        -0.35355339j, -0.25      -0.25j      ,
        -0.35355339+0.j        , -0.25      +0.25j      ,
         0.        +0.35355339j,  0.25      +0.25j      ]])

Usage as a Qiskit backend

Similar to the circuit simulator, the unitary simulator can be conveniently used via a Qiskit backend.

[19]:
from qiskit import QuantumCircuit

from mqt.ddsim import DDSIMProvider

qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)

# get the DDSIM provider
provider = DDSIMProvider()

# get the backend
backend = provider.get_backend("unitary_simulator")

# submit the job
job = backend.run(qc)

# get the result
result = job.result()
print(result.get_unitary(qc))
[[ 0.70710678+0.j  0.70710678+0.j  0.        +0.j  0.        +0.j]
 [ 0.        +0.j  0.        +0.j  0.70710678+0.j -0.70710678+0.j]
 [ 0.        +0.j  0.        +0.j  0.70710678+0.j  0.70710678+0.j]
 [ 0.70710678+0.j -0.70710678+0.j  0.        +0.j  0.        +0.j]]

Note that this only gives access to the final unitary and not the underlying decision diagram representing the unitary. As a consequence, this approach is inherently limited by the amount of memory available on your system. If you need access to the underlying decision diagram and/or do not need the final unitary matrix, consider using the standalone UnitarySimulator as described above.

Alternative construction sequence

It is well known, that the sequence in which the individual operations of a quantum circuit are applied can have a significant impact on the efficiency of the decision diagram representation. As a result, the straight-forward, sequential application of gates may not always yield the most compact intermediate decision diagram representations. The unitary simulator also supports an alternative construction sequence, which recursively groups gates and applies them in a tree-like fashion as described in [5].

Using the alternative construction sequence is as simple as setting mode="recursive" when creating the simulator or passing the mode argument to the backend.run method when using the Qiskit backend.

[20]:
import graphviz
from qiskit import QuantumCircuit

from mqt.ddsim import ConstructionMode, UnitarySimulator

qc = QuantumCircuit(3)
qc.h(2)
qc.h(1)
qc.h(0)
qc.cx(2, 1)

# Create the simulator
sim = UnitarySimulator(qc, mode=ConstructionMode.recursive)

# Construct the decision diagram representation of the unitary
sim.construct()

# Get the decision diagram representation of the unitary
dot = sim.export_dd_to_graphviz_str(colored=True, edge_labels=True, classic=False)

graphviz.Source(source=dot)
[20]:
../_images/simulators_UnitarySimulator_29_0.svg