Repository: Qiskit/qiskit

Issue:
building wide random circuits scales worse than deep ones.
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues to confirm this idea does not exist. -->

When using `random_circuit` from qiskit/circuit/random/utils.py to generate random circuits, scaling width appears to be worse than scaling depth. This was profiled using cProfile for running,

```python
    circuit = random_circuit(width, depth)
    dag = circuit_to_dag(circuit)
```
where we use
1. width = 2, depth =  10, 100, 1000, 10000, 100000, 1000000
or 
2. depth = 2, width = 10, 100, 1000, 10000, 100000

Ranking the top functions by total time spent within the function, and not sub-functions, for the first case shows linear scaling,

![image](https://user-images.githubusercontent.com/25756217/133529229-f837308b-0724-4917-874a-f6f5393e411e.png)

For the second case, however, we get superlinear scaling.

![image](https://user-images.githubusercontent.com/25756217/133529905-c59461e7-4ee8-4ab3-830b-327f05d61e27.png)

Looking at the function call dependency it seems that a greater proportion of time is spent in `rng.shuffle` and the list comprehensions.

deep circuit:
![image](https://user-images.githubusercontent.com/25756217/133530454-7bf8dfda-6c41-4d2a-978f-8870a78d09e9.png)

wide circuit
![image](https://user-images.githubusercontent.com/25756217/133531396-faddd775-7d7a-4ec6-b87d-26d6490c7c6b.png)

qiskit version: 0.19.0.dev0+7ea4d6e


### What is the expected enhancement?





Discussion:
Yeah, looking at the code this definitely makes sense:

https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/random/utils.py#L121-L129

In the worst case the selection of random qubits for each layer it basically scales n^2 since in the worst case it does a random shuffle then instead of a popping from the front it looks at the first element and iterates over the full list of qubits and checks for it's presence in the operands. There are a bunch of simple fixes for this, but the easiest might be to do something like:

```python3
for _ in range(depth):
        # choose either 1, 2, or 3 qubits for the operation
        used_qubits = set()
        while len(used_qubits) < num_qubits:
            remaining_qubits = num_qubits - len(used_qubits) 
            max_possible_operands = min(remaining_qubits, max_operands)
            num_operands = rng.choice(range(max_possible_operands)) + 1
            operands = []
            for _ in range(num_operands):
                qubit = rng.integers(0, num_qubits - 1, dtype=int)
                while qubit in used_qubits:
                    qubit = rng.integers(0, num_qubits - 1, dtype=int)
                operands.append(qubit)
                used_qubits.add(qubit)
```

I put this suggestion so we still rely on numpy's rng for operand selection. The more natural fix in my head was to just use remaining qubits as a set and do:

```python3
operands = []
for _ in range(num_operands):
    operands.append(remaining_qubits.pop())
```

which would perform even better, But then instead of the randomization being dependent on the user specified seed it would depend on python's hash randomization seed not necessarily deterministic with a fixed seed parameter.

Resolving patch:
diff --git a/qiskit/circuit/random/utils.py b/qiskit/circuit/random/utils.py
--- a/qiskit/circuit/random/utils.py
+++ b/qiskit/circuit/random/utils.py
@@ -121,12 +121,11 @@ def random_circuit(
     for _ in range(depth):
         # choose either 1, 2, or 3 qubits for the operation
         remaining_qubits = list(range(num_qubits))
+        rng.shuffle(remaining_qubits)
         while remaining_qubits:
             max_possible_operands = min(len(remaining_qubits), max_operands)
             num_operands = rng.choice(range(max_possible_operands)) + 1
-            rng.shuffle(remaining_qubits)
-            operands = remaining_qubits[:num_operands]
-            remaining_qubits = [q for q in remaining_qubits if q not in operands]
+            operands = [remaining_qubits.pop() for _ in range(num_operands)]
             if num_operands == 1:
                 operation = rng.choice(one_q_ops)
             elif num_operands == 2:


Test patch:
diff --git a/test/qpy_compat/test_qpy.py b/test/qpy_compat/test_qpy.py
--- a/test/qpy_compat/test_qpy.py
+++ b/test/qpy_compat/test_qpy.py
@@ -65,10 +65,19 @@ def generate_unitary_gate_circuit():
 def generate_random_circuits():
     """Generate multiple random circuits."""
     random_circuits = []
-    for i in range(10):
-        random_circuits.append(
-            random_circuit(10, 10, measure=True, conditional=True, reset=True, seed=42 + i)
-        )
+    for i in range(1, 15):
+        qc = QuantumCircuit(i)
+        qc.h(0)
+        if i > 1:
+            for j in range(i - 1):
+                qc.cx(0, j + 1)
+        qc.measure_all()
+        for i in range(i):
+            qc.reset(i)
+        qc.x(0).c_if(qc.cregs[0], i)
+        for i in range(i):
+            qc.measure(i, i)
+        random_circuits.append(qc)
     return random_circuits