Repository: Qiskit/qiskit

Issue:
building circuits with many Parameters per gate scales superlinearly
<!-- ⚠️ 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. -->

Creating a circuit with many Parameter objects per gate scales worse than instead creating many gates with fixed parameters per gate. For this test the following function was profiled,

```python
def build_parameterized_circuit(width, depth, param_per_gate):
    qr = QuantumRegister(width)
    qc = QuantumCircuit(qr)
    counter = itertools.count()
    
    for i in range(depth):
        for j in range(width):
            expr = Parameter(f'p{next(counter)}')
            for k in range(1, param_per_gate):
                expr += Parameter(f'p{next(counter)}')
            qc.rx(expr, j)
    return qc
```
This function was run with two of the arguments fixed at 4 and the third varied from the list [8, 32, 128, 512, 2048, 8192]. In all cases the total number of parameters in the circuit remains the same but when the argument scaled is `param_per_gate` the scaling is worse.

![image](https://user-images.githubusercontent.com/25756217/133595356-c3ba48e8-63a5-4509-ae30-d110685a918c.png)
![image](https://user-images.githubusercontent.com/25756217/133595406-38d93d1f-3f12-4728-807c-9997274c9494.png)
![image](https://user-images.githubusercontent.com/25756217/133595457-ef407fa9-b705-450e-9e77-a7e995b8ce05.png)

Here is the function call dependency where one can see most of the time is taken in `parameterexpression.py`,
__add__ ---> _apply_operation ---> _raise_if_parameter_names_conflict ---> dictcomp ---> name

![image](https://user-images.githubusercontent.com/25756217/133596225-fd4fbc5c-9c20-4f4b-8917-1a81ad1a1e46.png)
In the diagram the format is,
+------------------------------+
|        function name         |
| total time % ( self time % ) |
|         total calls          |
+------------------------------+
where:
total time % is the percentage of the running time spent in this function and all its children;
self time % is the percentage of the running time spent in this function alone;
total calls is the total number of times this function was called (including recursive calls).


### What is the expected enhancement?
Improve performance of adding many parameters to a gate.




Discussion:
Which OS are you testing on? If it's spending most of the time in `__add__` my first thought was this could be all time in symengine or sympy (sympy would scale terribly as the number of variables in the expression grows). If you were on windows it would use sympy instead of symengine by default.

But I don't think that's what's going on. After looking at the profile call graph and the code, the issue is in: https://github.com/Qiskit/qiskit-terra/blob/4030839248237c223ce857c861f1762716798157/qiskit/circuit/parameterexpression.py#L205-L222
is iterating over the parameters in the expression multiple times which gets worse as the number of parameters in the expression grows. I feel like we need to leverage the `self._names` cache a bit more there so we're not traversing over all the params on every operation and just doing set lookups. Especially since we know the names of all the parameters when we create the output object we can prepopulate that and make the conflict detection scale better
@mtreinish I was running on linux with qiskit version 0.19.0.dev0+7ea4d6e

Resolving patch:
diff --git a/qiskit/circuit/parameterexpression.py b/qiskit/circuit/parameterexpression.py
--- a/qiskit/circuit/parameterexpression.py
+++ b/qiskit/circuit/parameterexpression.py
@@ -38,7 +38,7 @@
 class ParameterExpression:
     """ParameterExpression class to enable creating expressions of Parameters."""
 
-    __slots__ = ["_parameter_symbols", "_parameters", "_symbol_expr", "_names"]
+    __slots__ = ["_parameter_symbols", "_parameters", "_symbol_expr", "_name_map"]
 
     def __init__(self, symbol_map: Dict, expr):
         """Create a new :class:`ParameterExpression`.
@@ -55,13 +55,20 @@ def __init__(self, symbol_map: Dict, expr):
         self._parameter_symbols = symbol_map
         self._parameters = set(self._parameter_symbols)
         self._symbol_expr = expr
-        self._names = None
+        self._name_map = None
 
     @property
     def parameters(self) -> Set:
         """Returns a set of the unbound Parameters in the expression."""
         return self._parameters
 
+    @property
+    def _names(self) -> Dict:
+        """Returns a mapping of parameter names to Parameters in the expression."""
+        if self._name_map is None:
+            self._name_map = {p.name: p for p in self._parameters}
+        return self._name_map
+
     def conjugate(self) -> "ParameterExpression":
         """Return the conjugate."""
         if HAS_SYMENGINE:
@@ -154,13 +161,15 @@ def subs(self, parameter_map: Dict) -> "ParameterExpression":
         Returns:
             A new expression with the specified parameters replaced.
         """
-
-        inbound_parameters = {
-            p for replacement_expr in parameter_map.values() for p in replacement_expr.parameters
-        }
+        inbound_parameters = set()
+        inbound_names = {}
+        for replacement_expr in parameter_map.values():
+            for p in replacement_expr.parameters:
+                inbound_parameters.add(p)
+                inbound_names[p.name] = p
 
         self._raise_if_passed_unknown_parameters(parameter_map.keys())
-        self._raise_if_parameter_names_conflict(inbound_parameters, parameter_map.keys())
+        self._raise_if_parameter_names_conflict(inbound_names, parameter_map.keys())
         if HAS_SYMENGINE:
             new_parameter_symbols = {p: symengine.Symbol(p.name) for p in inbound_parameters}
         else:
@@ -205,17 +214,16 @@ def _raise_if_passed_nan(self, parameter_values):
     def _raise_if_parameter_names_conflict(self, inbound_parameters, outbound_parameters=None):
         if outbound_parameters is None:
             outbound_parameters = set()
-
-        if self._names is None:
-            self._names = {p.name: p for p in self._parameters}
-
-        inbound_names = {p.name: p for p in inbound_parameters}
-        outbound_names = {p.name: p for p in outbound_parameters}
-
-        shared_names = (self._names.keys() - outbound_names.keys()) & inbound_names.keys()
-        conflicting_names = {
-            name for name in shared_names if self._names[name] != inbound_names[name]
-        }
+            outbound_names = {}
+        else:
+            outbound_names = {p.name: p for p in outbound_parameters}
+
+        inbound_names = inbound_parameters
+        conflicting_names = []
+        for name, param in inbound_names.items():
+            if name in self._names and name not in outbound_names:
+                if param != self._names[name]:
+                    conflicting_names.append(name)
         if conflicting_names:
             raise CircuitError(
                 f"Name conflict applying operation for parameters: {conflicting_names}"
@@ -246,8 +254,7 @@ def _apply_operation(
         self_expr = self._symbol_expr
 
         if isinstance(other, ParameterExpression):
-            self._raise_if_parameter_names_conflict(other._parameter_symbols.keys())
-
+            self._raise_if_parameter_names_conflict(other._names)
             parameter_symbols = {**self._parameter_symbols, **other._parameter_symbols}
             other_expr = other._symbol_expr
         elif isinstance(other, numbers.Number) and numpy.isfinite(other):
@@ -261,7 +268,12 @@ def _apply_operation(
         else:
             expr = operation(self_expr, other_expr)
 
-        return ParameterExpression(parameter_symbols, expr)
+        out_expr = ParameterExpression(parameter_symbols, expr)
+        out_expr._name_map = self._names.copy()
+        if isinstance(other, ParameterExpression):
+            out_expr._names.update(other._names.copy())
+
+        return out_expr
 
     def gradient(self, param) -> Union["ParameterExpression", complex]:
         """Get the derivative of a parameter expression w.r.t. a specified parameter expression.
@@ -496,7 +508,7 @@ def __setstate__(self, state):
             self._symbol_expr = state["expr"]
             self._parameter_symbols = state["symbols"]
             self._parameters = set(self._parameter_symbols)
-        self._names = state["names"]
+        self._name_map = state["names"]
 
     def is_real(self):
         """Return whether the expression is real"""


Test patch: