Repository: Qiskit/qiskit

Issue:
10 second overhead for batch jobs
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues -->

### Informations

- **Qiskit Aer version**: 0.7.0
- **Python version**: 3.8.5
- **Operating system**: OSX Catalina

### What is the current behavior?

A single call to `execute` with a list of circuits takes around 10s longer than calling it for each circuit sequentially.

### Steps to reproduce the problem

Example code

```
from qiskit import execute, Aer, __qiskit_version__
from qiskit.circuit.random import random_circuit
import time
# make two random circuits
qcs = [random_circuit(2, 2, measure=True) for _ in range(2)]
# run them all at once and see how long it takes
t0 = time.time()
execute(qcs,Aer.get_backend('qasm_simulator'))
print('Time when batched',time.time()-t0)
# run them sequentially and see how long it takes
t0 = time.time()
for qc in qcs:
    execute(qc,Aer.get_backend('qasm_simulator'))
print('Time when run sequentially',time.time()-t0)
```

Similar results are found for different numbers 

### What is the expected behavior?

They should take around the same time. But when running the above I get the following

```
Time when batched 11.500309944152832
Time when run sequentially 0.00656580924987793
```

I do not see the same problem when running on IQX. There I instead get

```
Time when batched 0.6284997463226318
Time when run sequentially 0.2993638515472412
```

Which still isn't perfect, but it's better. So maybe it is an OSX problem?

### Suggested solutions

I should install Linux ;)



Discussion:
I expect this is actually overhead from `parallel_map` in terra for transpile and assembly. For python 3.8 multiprocessing on osx (especially 10.15) is problematic as 3.8 switched to spawn by default instead of fork. This causes both buggy operation and way more overhead. Can you try setting the env variable `QISKIT_IN_PARALLEL` to  `TRUE` and running? That will disable `parallel_map` in terra so the transpilation will be executed serially. Also you can try splitting the transpilation and assemble out from `backend.run` and see the overhead there. Basically do:

```python3
from qiskit import transpile
from qiskit import assemble

backend = Aer.get_backend('qasm_simulator')
qcs = [random_circuit(2, 2, measure=True) for _ in range(2)]
qobj = assemble([transpile(qc, backend) for qc in qcs], backend)
t0 = time.time()
backend.run(qobj)
print(time.time() - t0)
```

Or you could install Linux :)
Thanks. That certainly seems to speed up running the circuits, though assemble seems to have a bit of an overhead too.
Yeah, assemble also runs in `parallel_map` for assembling multiple circuits (it was added in 0.13.0 with Qiskit/qiskit-terra#3971). So it will suffer from the same issues with macOS.

 I'm going to transfer this issue to terra and push up a PR that will make parallel execution opt-in for macOS. It's a big performance hit for people on older versions of python and/or macOS, but I don't think I've had a single week go by since we added python 3.8 support that I've had to help someone fight these issues.

Resolving patch:
diff --git a/qiskit/tools/parallel.py b/qiskit/tools/parallel.py
--- a/qiskit/tools/parallel.py
+++ b/qiskit/tools/parallel.py
@@ -49,18 +49,23 @@
 """
 
 import os
-import platform
 from concurrent.futures import ProcessPoolExecutor
 from qiskit.exceptions import QiskitError
 from qiskit.util import local_hardware_info
 from qiskit.tools.events.pubsub import Publisher
+from qiskit import user_config
+
+CONFIG = user_config.get_config()
 
 # Set parallel flag
 if os.getenv('QISKIT_IN_PARALLEL') is None:
     os.environ['QISKIT_IN_PARALLEL'] = 'FALSE'
 
 # Number of local physical cpus
-CPU_COUNT = local_hardware_info()['cpus']
+if 'num_processes' in CONFIG:
+    CPU_COUNT = CONFIG['num_processes']
+else:
+    CPU_COUNT = local_hardware_info()['cpus']
 
 
 def _task_wrapper(param):
@@ -113,8 +118,8 @@ def _callback(_):
         Publisher().publish("terra.parallel.done", nfinished[0])
 
     # Run in parallel if not Win and not in parallel already
-    if platform.system() != 'Windows' and num_processes > 1 \
-       and os.getenv('QISKIT_IN_PARALLEL') == 'FALSE':
+    if num_processes > 1 and os.getenv('QISKIT_IN_PARALLEL') == 'FALSE' \
+            and CONFIG.get('parallel_enabled', user_config.PARALLEL_DEFAULT):
         os.environ['QISKIT_IN_PARALLEL'] = 'TRUE'
         try:
             results = []
diff --git a/qiskit/user_config.py b/qiskit/user_config.py
--- a/qiskit/user_config.py
+++ b/qiskit/user_config.py
@@ -14,6 +14,7 @@
 
 import configparser
 import os
+import sys
 from warnings import warn
 
 from qiskit import exceptions
@@ -21,6 +22,14 @@
 DEFAULT_FILENAME = os.path.join(os.path.expanduser("~"),
                                 '.qiskit', 'settings.conf')
 
+if os.getenv('QISKIT_PARALLEL', None) is not None:
+    PARALLEL_DEFAULT = os.getenv('QISKIT_PARALLEL', None).lower() == 'true'
+else:
+    if sys.platform in {'darwin', 'win32'}:
+        PARALLEL_DEFAULT = False
+    else:
+        PARALLEL_DEFAULT = True
+
 
 class UserConfig:
     """Class representing a user config file
@@ -33,6 +42,8 @@ class UserConfig:
     circuit_mpl_style_path = ~/.qiskit:<default location>
     transpile_optimization_level = 1
     suppress_packaging_warnings = False
+    parallel = False
+    num_processes = 4
 
     """
     def __init__(self, filename=None):
@@ -111,6 +122,21 @@ def read_config_file(self):
             if package_warnings:
                 self.settings['suppress_packaging_warnings'] = package_warnings
 
+            # Parse parallel
+            parallel_enabled = self.config_parser.getboolean(
+                'default', 'parallel', fallback=PARALLEL_DEFAULT)
+            self.settings['parallel_enabled'] = parallel_enabled
+
+            # Parse num_processes
+            num_processes = self.config_parser.getint(
+                'default', 'num_processes', fallback=-1)
+            if not num_processes == -1:
+                if num_processes <= 0:
+                    raise exceptions.QiskitUserConfigError(
+                        "%s is not a valid number of processes. Must be "
+                        "greater than 0")
+                self.settings['num_processes'] = num_processes
+
 
 def get_config():
     """Read the config file from the default location or env var
@@ -124,7 +150,7 @@ def get_config():
     """
     filename = os.getenv('QISKIT_SETTINGS', DEFAULT_FILENAME)
     if not os.path.isfile(filename):
-        return {}
+        return {'parallel_enabled': PARALLEL_DEFAULT}
     user_config = UserConfig(filename)
     user_config.read_config_file()
     return user_config.settings


Test patch:
diff --git a/test/python/test_user_config.py b/test/python/test_user_config.py
--- a/test/python/test_user_config.py
+++ b/test/python/test_user_config.py
@@ -28,7 +28,8 @@ def setUpClass(cls):
     def test_empty_file_read(self):
         config = user_config.UserConfig(self.file_path)
         config.read_config_file()
-        self.assertEqual({}, config.settings)
+        self.assertEqual({},
+                         config.settings)
 
     def test_invalid_suppress_packaging_warnings(self):
         test_config = """
@@ -79,7 +80,8 @@ def test_circuit_drawer_valid(self):
             file.flush()
             config = user_config.UserConfig(self.file_path)
             config.read_config_file()
-            self.assertEqual({'circuit_drawer': 'latex'},
+            self.assertEqual({'circuit_drawer': 'latex',
+                              'parallel_enabled': user_config.PARALLEL_DEFAULT},
                              config.settings)
 
     def test_optimization_level_valid(self):
@@ -93,8 +95,10 @@ def test_optimization_level_valid(self):
             file.flush()
             config = user_config.UserConfig(self.file_path)
             config.read_config_file()
-            self.assertEqual({'transpile_optimization_level': 1},
-                             config.settings)
+            self.assertEqual(
+                {'transpile_optimization_level': 1,
+                 'parallel_enabled': user_config.PARALLEL_DEFAULT},
+                config.settings)
 
     def test_valid_suppress_packaging_warnings_false(self):
         test_config = """
@@ -107,8 +111,9 @@ def test_valid_suppress_packaging_warnings_false(self):
             file.flush()
             config = user_config.UserConfig(self.file_path)
             config.read_config_file()
-            self.assertEqual({},
-                             config.settings)
+            self.assertEqual(
+                {'parallel_enabled': user_config.PARALLEL_DEFAULT},
+                config.settings)
 
     def test_valid_suppress_packaging_warnings_true(self):
         test_config = """
@@ -121,8 +126,54 @@ def test_valid_suppress_packaging_warnings_true(self):
             file.flush()
             config = user_config.UserConfig(self.file_path)
             config.read_config_file()
-            self.assertEqual({'suppress_packaging_warnings': True},
-                             config.settings)
+            self.assertEqual(
+                {'parallel_enabled': user_config.PARALLEL_DEFAULT,
+                 'suppress_packaging_warnings': True},
+                config.settings)
+
+    def test_invalid_num_processes(self):
+        test_config = """
+        [default]
+        num_processes = -256
+        """
+        self.addCleanup(os.remove, self.file_path)
+        with open(self.file_path, 'w') as file:
+            file.write(test_config)
+            file.flush()
+            config = user_config.UserConfig(self.file_path)
+            self.assertRaises(exceptions.QiskitUserConfigError,
+                              config.read_config_file)
+
+    def test_valid_num_processes(self):
+        test_config = """
+        [default]
+        num_processes = 31
+        """
+        self.addCleanup(os.remove, self.file_path)
+        with open(self.file_path, 'w') as file:
+            file.write(test_config)
+            file.flush()
+            config = user_config.UserConfig(self.file_path)
+            config.read_config_file()
+            self.assertEqual(
+                {'parallel_enabled': user_config.PARALLEL_DEFAULT,
+                 'num_processes': 31},
+                config.settings)
+
+    def test_valid_parallel(self):
+        test_config = """
+        [default]
+        parallel = False
+        """
+        self.addCleanup(os.remove, self.file_path)
+        with open(self.file_path, 'w') as file:
+            file.write(test_config)
+            file.flush()
+            config = user_config.UserConfig(self.file_path)
+            config.read_config_file()
+            self.assertEqual(
+                {'parallel_enabled': False},
+                config.settings)
 
     def test_all_options_valid(self):
         test_config = """
@@ -132,6 +183,8 @@ def test_all_options_valid(self):
         circuit_mpl_style_path = ~:~/.qiskit
         transpile_optimization_level = 3
         suppress_packaging_warnings = true
+        parallel = false
+        num_processes = 15
         """
         self.addCleanup(os.remove, self.file_path)
         with open(self.file_path, 'w') as file:
@@ -143,5 +196,7 @@ def test_all_options_valid(self):
                               'circuit_mpl_style': 'default',
                               'circuit_mpl_style_path': ['~', '~/.qiskit'],
                               'transpile_optimization_level': 3,
+                              'num_processes': 15,
+                              'parallel_enabled': False,
                               'suppress_packaging_warnings': True},
                              config.settings)