Repository: Qiskit/qiskit

Issue:
Speedup python local_unitary_simulator
@chriseclectic noted in #893

### What is the expected enhancement?
An important speedup by using Einstein summation for enlarging one and two qubit operations.

An example of runtime for a random circuit of 100 gates on my laptop (Macbook pro 3.1 GHz i7, 16 GB Ram):

Timing results for new implementation are:

```
2 qubit circuit, 100 gates, time taken: 0.073 s
3 qubit circuit, 100 gates, time taken: 0.072 s
4 qubit circuit, 100 gates, time taken: 0.071 s
5 qubit circuit, 100 gates, time taken: 0.075 s
6 qubit circuit, 100 gates, time taken: 0.087 s
7 qubit circuit, 100 gates, time taken: 0.08 s
8 qubit circuit, 100 gates, time taken: 0.11 s
9 qubit circuit, 100 gates, time taken: 0.352 s
10 qubit circuit, 100 gates, time taken: 1.346 s
11 qubit circuit, 100 gates, time taken: 4.931 s
12 qubit circuit, 100 gates, time taken: 28.891 s
```

Timing results for old implementation are

```
2 qubit circuit, 100 gates, time taken: 0.24 s
3 qubit circuit, 100 gates, time taken: 0.123 s
4 qubit circuit, 100 gates, time taken: 0.178 s
5 qubit circuit, 100 gates, time taken: 0.114 s
6 qubit circuit, 100 gates, time taken: 0.138 s
7 qubit circuit, 100 gates, time taken: 0.238 s
8 qubit circuit, 100 gates, time taken: 0.486 s
9 qubit circuit, 100 gates, time taken: 2.835 s
10 qubit circuit, 100 gates, time taken: 15.714 s
11 qubit circuit, 100 gates, time taken: 115.502 s
12 qubit circuit, 100 gates, time taken: ?
```
(I wasn't patient enough for the 12 qubit circuit)

The python file used for printing benchmarks was:

```python
# Simple benchmark
import numpy as np
import time
import qiskit

# Create a test circuit
for num_qubits in range(2, 13):
    # Create random test circuit
    num_gates = 100
    qr = qiskit.QuantumRegister(num_qubits)
    circ = qiskit.QuantumCircuit(qr)
    for j in range(num_gates):
        q = np.random.randint(0, num_qubits)
        r = np.random.randint(0, 4)
        if r == 0:       
            circ.cx(qr[q], qr[(q + 1) % num_qubits])
        elif r == 1:
            circ.h(qr[q])
        elif r == 2:
            circ.s(qr[q])
        elif r == 3:
            circ.t(qr[q])
    # Execute with timer
    start = time.time()
    result = qiskit.execute(circ, 'local_unitary_simulator').result()
    stop = time.time()
    print("{} qubit circuit, {} gates, time taken: {} s".format(num_qubits, num_gates, np.round(stop-start,3)))
```




Discussion:

Resolving patch:
diff --git a/qiskit/backends/local/_simulatortools.py b/qiskit/backends/local/_simulatortools.py
--- a/qiskit/backends/local/_simulatortools.py
+++ b/qiskit/backends/local/_simulatortools.py
@@ -13,13 +13,9 @@
     index2 -- Takes a bitstring k and inserts bits b1 as the i1th bit
     and b2 as the i2th bit
 
-    enlarge_single_opt(opt, qubit, number_of_qubits) -- takes a single qubit
-    operator opt to a opterator on n qubits
-
-    enlarge_two_opt(opt, q0, q1, number_of_qubits) -- takes a two-qubit
-    operator opt to a opterator on n qubits
-
 """
+
+from string import ascii_uppercase, ascii_lowercase
 import numpy as np
 from sympy import Matrix, pi, E, I, cos, sin, N, sympify
 
@@ -65,48 +61,6 @@ def index2(b1, i1, b2, i2, k):
     return retval
 
 
-def enlarge_single_opt(opt, qubit, number_of_qubits):
-    """Enlarge single operator to n qubits.
-
-    It is exponential in the number of qubits.
-
-    Args:
-        opt (array): the single-qubit opt.
-        qubit (int): the qubit to apply it on counts from 0 and order
-            is q_{n-1} ... otimes q_1 otimes q_0.
-        number_of_qubits (int): the number of qubits in the system.
-
-    Returns:
-        array: enlarge single operator to n qubits
-    """
-    temp_1 = np.identity(2**(number_of_qubits-qubit-1), dtype=complex)
-    temp_2 = np.identity(2**qubit, dtype=complex)
-    enlarge_opt = np.kron(temp_1, np.kron(opt, temp_2))
-    return enlarge_opt
-
-
-def enlarge_two_opt(opt, q0, q1, num):
-    """Enlarge two-qubit operator to n qubits.
-
-    It is exponential in the number of qubits.
-    opt is the two-qubit gate
-    q0 is the first qubit (control) counts from 0
-    q1 is the second qubit (target)
-    returns a complex numpy array
-    number_of_qubits is the number of qubits in the system.
-    """
-    enlarge_opt = np.zeros([1 << (num), 1 << (num)])
-    for i in range(1 << (num-2)):
-        for j in range(2):
-            for k in range(2):
-                for jj in range(2):
-                    for kk in range(2):
-                        enlarge_opt[index2(j, q0, k, q1, i),
-                                    index2(jj, q0, kk, q1, i)] = opt[j+2*k,
-                                                                     jj+2*kk]
-    return enlarge_opt
-
-
 def single_gate_params(gate, params=None):
     """Apply a single qubit gate to the qubit.
 
@@ -149,6 +103,62 @@ def single_gate_matrix(gate, params=None):
                       np.exp(1j*phi+1j*lam)*np.cos(theta/2)]])
 
 
+def einsum_matmul_index(gate_indices, number_of_qubits):
+    """Return the index string for Numpy.eignsum matrix multiplication.
+
+    The returned indices are to perform a matrix multiplication A.B where
+    the matrix A is an M-qubit matrix, matrix B is an N-qubit matrix, and
+    M <= N, and identity matrices are implied on the subsystems where A has no
+    support on B.
+
+    Args:
+        gate_indices (list[int]): the indices of the right matrix subsystems
+                                   to contract with the left matrix.
+        number_of_qubits (int): the total number of qubits for the right matrix.
+
+    Returns:
+        str: An indices string for the Numpy.einsum function.
+
+    Raises:
+        QISKitError: if the total number of qubits plus the number of
+        contracted indices is greater than 26.
+    """
+
+    # Since we use ASCII alphabet for einsum index labels we are limited
+    # to 26 total free left (lowercase) and 26 right (uppercase) indexes.
+    # The rank of the contracted tensor reduces this as we need to use that
+    # many characters for the contracted indices
+    if len(gate_indices) + number_of_qubits > 26:
+        raise QISKitError("Total number of free indexes limited to 26")
+
+    # Right indices for the N-qubit input and output tensor
+    idx_right = ascii_uppercase[:number_of_qubits]
+
+    # Left ndicies for N-qubit input tensor
+    idx_left_in = ascii_lowercase[:number_of_qubits]
+
+    # Left indices for the N-qubit output tensor
+    idx_left_out = list(idx_left_in)
+
+    # Left and right indices for the M-qubit multiplying tensor
+    mat_left = ""
+    mat_right = ""
+
+    # Update left indices for mat and output
+    for pos, idx in enumerate(reversed(gate_indices)):
+        mat_left += ascii_lowercase[-1 - pos]
+        mat_right += idx_left_in[-1 - idx]
+        idx_left_out[-1 - idx] = ascii_lowercase[-1 - pos]
+    idx_left_out = "".join(idx_left_out)
+
+    # Combine indices into matrix multiplication string format
+    # for numpy.einsum function
+    return "{mat_l}{mat_r}, ".format(mat_l=mat_left, mat_r=mat_right) + \
+           "{tens_lin}{tens_r}->{tens_lout}{tens_r}".format(tens_lin=idx_left_in,
+                                                            tens_lout=idx_left_out,
+                                                            tens_r=idx_right)
+
+
 # Functions used by the sympy simulators.
 
 def regulate(theta):
diff --git a/qiskit/backends/local/unitary_simulator_py.py b/qiskit/backends/local/unitary_simulator_py.py
--- a/qiskit/backends/local/unitary_simulator_py.py
+++ b/qiskit/backends/local/unitary_simulator_py.py
@@ -5,7 +5,7 @@
 # This source code is licensed under the Apache License, Version 2.0 found in
 # the LICENSE.txt file in the root directory of this source tree.
 
-"""Contains a (slow) Python simulator that returns the unitary of the circuit.
+"""Contains a Python simulator that returns the unitary of the circuit.
 
 It simulates a unitary of a quantum circuit that has been compiled to run on
 the simulator. It is exponential in the number of qubits.
@@ -89,7 +89,8 @@
 from qiskit.result._utils import copy_qasm_from_qobj_into_result, result_from_old_style_dict
 from qiskit.backends import BaseBackend
 from qiskit.backends.local.localjob import LocalJob
-from ._simulatortools import enlarge_single_opt, enlarge_two_opt, single_gate_matrix
+from qiskit import QISKitError
+from ._simulatortools import single_gate_matrix, einsum_matmul_index
 
 logger = logging.getLogger(__name__)
 
@@ -128,19 +129,38 @@ def _add_unitary_single(self, gate, qubit):
             is q_{n-1} ... otimes q_1 otimes q_0.
         number_of_qubits is the number of qubits in the system.
         """
-        unitary_add = enlarge_single_opt(gate, qubit, self._number_of_qubits)
-        self._unitary_state = np.dot(unitary_add, self._unitary_state)
-
-    def _add_unitary_two(self, gate, q_0, q_1):
+        # Convert to complex rank-2 tensor
+        gate_tensor = np.array(gate, dtype=complex)
+        # Compute einsum index string for 1-qubit matrix multiplication
+        indexes = einsum_matmul_index([qubit], self._number_of_qubits)
+        # Apply matrix multiplication
+        self._unitary_state = np.einsum(indexes,
+                                        gate_tensor,
+                                        self._unitary_state,
+                                        dtype=complex,
+                                        casting='no')
+
+    def _add_unitary_two(self, gate, qubit0, qubit1):
         """Apply the two-qubit gate.
 
         gate is the two-qubit gate
-        q0 is the first qubit (control) counts from 0
-        q1 is the second qubit (target)
+        qubit0 is the first qubit (control) counts from 0
+        qubit1 is the second qubit (target)
         returns a complex numpy array
         """
-        unitaty_add = enlarge_two_opt(gate, q_0, q_1, self._number_of_qubits)
-        self._unitary_state = np.dot(unitaty_add, self._unitary_state)
+
+        # Convert to complex rank-4 tensor
+        gate_tensor = np.reshape(np.array(gate, dtype=complex), 4 * [2])
+
+        # Compute einsum index string for 2-qubit matrix multiplication
+        indexes = einsum_matmul_index([qubit0, qubit1], self._number_of_qubits)
+
+        # Apply matrix multiplication
+        self._unitary_state = np.einsum(indexes,
+                                        gate_tensor,
+                                        self._unitary_state,
+                                        dtype=complex,
+                                        casting='no')
 
     def run(self, qobj):
         """Run qobj asynchronously.
@@ -189,14 +209,25 @@ def run_circuit(self, circuit):
 
         Returns:
             dict: A dictionary of results.
+
+        Raises:
+            QISKitError: if the number of qubits in the circuit is greater than 24.
+            Note that the practical qubit limit is much lower than 24.
         """
         self._number_of_qubits = circuit.header.number_of_qubits
+        if self._number_of_qubits > 24:
+            raise QISKitError("np.einsum implementation limits local_unitary_simulator" +
+                              " to 24 qubit circuits.")
         result = {
             'data': {},
             'name': circuit.header.name
         }
-        self._unitary_state = np.identity(2 ** self._number_of_qubits,
-                                          dtype=complex)
+
+        # Initilize unitary as rank 2*N tensor
+        self._unitary_state = np.reshape(np.eye(2 ** self._number_of_qubits,
+                                                dtype=complex),
+                                         self._number_of_qubits * [2, 2])
+
         for operation in circuit.instructions:
             if operation.name in ('U', 'u1', 'u2', 'u3'):
                 params = getattr(operation, 'params', None)
@@ -222,7 +253,9 @@ def run_circuit(self, circuit):
             else:
                 result['status'] = 'ERROR'
                 return result
-        result['data']['unitary'] = self._unitary_state
+        # Reshape unitary rank-2n tensor back to a matrix
+        result['data']['unitary'] = np.reshape(self._unitary_state,
+                                               2 * [2 ** self._number_of_qubits])
         result['status'] = 'DONE'
         result['success'] = True
         result['shots'] = 1


Test patch:
diff --git a/test/python/test_unitary_simulator_py.py b/test/python/test_unitary_simulator_py.py
--- a/test/python/test_unitary_simulator_py.py
+++ b/test/python/test_unitary_simulator_py.py
@@ -63,56 +63,70 @@ def test_unitary_simulator(self):
                                     rtol=1e-3))
 
     def test_local_unitary_simulator(self):
-        """Test unitary simulator.
-
-        If all correct should return the hxh and cx.
-        """
-        qr = QuantumRegister(2)
-        cr = ClassicalRegister(2)
-        qc1 = QuantumCircuit(qr, cr)
-        qc2 = QuantumCircuit(qr, cr)
-        qc1.h(qr)
-        qc2.cx(qr[0], qr[1])
+        """Test unitary simulator."""
+        circuits = self._test_circuits()
         backend = UnitarySimulatorPy()
-        qobj = compile([qc1, qc2], backend=backend)
+        qobj = compile(circuits, backend=backend)
         job = backend.run(qobj)
-        unitary1 = job.result().get_unitary(qc1)
-        unitary2 = job.result().get_unitary(qc2)
-        unitaryreal1 = np.array([[0.5, 0.5, 0.5, 0.5], [0.5, -0.5, 0.5, -0.5],
-                                 [0.5, 0.5, -0.5, -0.5],
-                                 [0.5, -0.5, -0.5, 0.5]])
-        unitaryreal2 = np.array([[1, 0, 0, 0], [0, 0, 0, 1],
-                                 [0., 0, 1, 0], [0, 1, 0, 0]])
-        norm1 = np.trace(np.dot(np.transpose(np.conj(unitaryreal1)), unitary1))
-        norm2 = np.trace(np.dot(np.transpose(np.conj(unitaryreal2)), unitary2))
-        self.assertAlmostEqual(norm1, 4)
-        self.assertAlmostEqual(norm2, 4)
-
-    def test_local_unitary_simulator_single_thread(self):
-        """test running two circuits
-
-        Identical to the above test, but does not use multiprocessing.
-        """
-        qr = QuantumRegister(2)
-        cr = ClassicalRegister(1)
+        sim_unitaries = [job.result().get_unitary(circ) for circ in circuits]
+        reference_unitaries = self._reference_unitaries()
+        norms = [np.trace(np.dot(np.transpose(np.conj(target)), actual))
+                 for target, actual in zip(reference_unitaries, sim_unitaries)]
+        for norm in norms:
+            self.assertAlmostEqual(norm, 8)
+
+    def _test_circuits(self):
+        """Return test circuits for unitary simulator"""
+        qr = QuantumRegister(3)
+        cr = ClassicalRegister(3)
         qc1 = QuantumCircuit(qr, cr)
         qc2 = QuantumCircuit(qr, cr)
+        qc3 = QuantumCircuit(qr, cr)
+        qc4 = QuantumCircuit(qr, cr)
+        qc5 = QuantumCircuit(qr, cr)
+        # Test circuit 1:  HxHxH
         qc1.h(qr)
+        # Test circuit 2: IxCX
         qc2.cx(qr[0], qr[1])
-        backend = UnitarySimulatorPy()
-        qobj = compile([qc1, qc2], backend=backend)
-        job = backend.run(qobj)
-        unitary1 = job.result().get_unitary(qc1)
-        unitary2 = job.result().get_unitary(qc2)
-        unitaryreal1 = np.array([[0.5, 0.5, 0.5, 0.5], [0.5, -0.5, 0.5, -0.5],
-                                 [0.5, 0.5, -0.5, -0.5],
-                                 [0.5, -0.5, -0.5, 0.5]])
-        unitaryreal2 = np.array([[1, 0, 0, 0], [0, 0, 0, 1],
-                                 [0., 0, 1, 0], [0, 1, 0, 0]])
-        norm1 = np.trace(np.dot(np.transpose(np.conj(unitaryreal1)), unitary1))
-        norm2 = np.trace(np.dot(np.transpose(np.conj(unitaryreal2)), unitary2))
-        self.assertAlmostEqual(norm1, 4)
-        self.assertAlmostEqual(norm2, 4)
+        # Test circuit 3:  CXxY
+        qc3.y(qr[0])
+        qc3.cx(qr[1], qr[2])
+        # Test circuit 4: (CX.I).(IxCX).(IxIxX)
+        qc4.h(qr[0])
+        qc4.cx(qr[0], qr[1])
+        qc4.cx(qr[1], qr[2])
+        # Test circuit 5 (X.Z)x(Z.Y)x(Y.X)
+        qc5.x(qr[0])
+        qc5.y(qr[0])
+        qc5.y(qr[1])
+        qc5.z(qr[1])
+        qc5.z(qr[2])
+        qc5.x(qr[2])
+        return [qc1, qc2, qc3, qc4, qc5]
+
+    def _reference_unitaries(self):
+        """Return reference unitaries for test circuits"""
+        # Gate matrices
+        gate_h = np.array([[1, 1], [1, -1]]) / np.sqrt(2)
+        gate_x = np.array([[0, 1], [1, 0]])
+        gate_y = np.array([[0, -1j], [1j, 0]])
+        gate_z = np.array([[1, 0], [0, -1]])
+        gate_cx = np.array([[1, 0, 0, 0],
+                            [0, 0, 0, 1],
+                            [0., 0, 1, 0],
+                            [0, 1, 0, 0]])
+        # Unitary matrices
+        target_unitary1 = np.kron(np.kron(gate_h, gate_h), gate_h)
+        target_unitary2 = np.kron(np.eye(2), gate_cx)
+        target_unitary3 = np.kron(gate_cx, gate_y)
+        target_unitary4 = np.dot(np.kron(gate_cx, np.eye(2)),
+                                 np.dot(np.kron(np.eye(2), gate_cx),
+                                        np.kron(np.eye(4), gate_h)))
+        target_unitary5 = np.kron(np.kron(np.dot(gate_x, gate_z),
+                                          np.dot(gate_z, gate_y)),
+                                  np.dot(gate_y, gate_x))
+        return [target_unitary1, target_unitary2, target_unitary3,
+                target_unitary4, target_unitary5]
 
 
 if __name__ == '__main__':