Multiprocessing serialisation overheads are abysmal. With enough OS support you can attempt to mitigate that via shared memory mechanisms (which Davin added to the standard library), but it's impossible to get the overhead of doing that as low as actually using the address space of one OS process.

For the rest of the email... multiprocessing isn't going anywhere.

Within-process parallelism is just aiming to provide another trade-off point in design space for CPU bound workloads (one roughly comparable to the point where JS web workers sit).

Cheers,
Nick.

On Sat., 6 Jun. 2020, 12:39 am Mark Shannon, <mark@hotpy.org> wrote:
Hi,

There have been a lot of changes both to the C API and to internal
implementations to allow multiple interpreters in a single O/S process.

These changes cause backwards compatibility changes, have a negative
performance impact, and cause a lot of churn.

While I'm in favour of PEP 554, or some similar model for parallelism in
Python, I am opposed to the changes we are currently making to support it.


What are sub-interpreters?
--------------------------

A sub-interpreter is a logically independent Python process which
supports inter-interpreter communication built on shared memory and
channels. Passing of Python objects is supported, but only by copying,
not by reference. Data can be shared via buffers.


How can they be implemented to support parallelism?
---------------------------------------------------

There are two obvious options.
a) Many sub-interpreters in a single O/S process. I will call this the
many-to-one model (many interpreters in one O/S process).
b) One sub-interpreter per O/S process. This is what we currently have
for multiprocessing. I will call this the one-to-one model (one
interpreter in one O/S process).

There seems to be an assumption amongst those working on PEP 554 that
the many-to-one model is the only way to support sub-interpreters that
can execute in parallel.
This isn't true. The one-to-one model has many advantages.


Advantages of the one-to-one model
----------------------------------

1. It's less bug prone. It is much easier to reason about code working
in a single address space. Most code assumes

2. It's more secure. Separate O/S processes provide a much stronger
boundary between interpreters. This is why some browsers use separate
processes for browser tabs.

3. It can be implemented on top of the multiprocessing module, for
testing. A more efficient implementation can be developed once
sub-interpreters prove useful.

4. The required changes should have no negative performance impact.

5. Third party modules should continue to work as they do now.

6. It takes much less work :)


Performance
-----------

Creating O/S processes is usually considered to be slow. Whilst
processes are undoubtedly slower to create than threads, the absolute
time to create a process is small; well under 1ms on linux.

Creating a new sub-interpreter typically requires importing quite a few
modules before any useful work can be done.
The time spent doing these imports will dominate the time to create an
O/S process or thread.

If sub-interpreters are to be used for parallelism, there is no need to
have many more sub-interpreters than CPU cores, so the overhead should
be small. For additional concurrency, threads or coroutines can be used.

The one-to-one model is faster as it uses the hardware for interpreter
separation, whereas the many-to-one model must use software.
Process separation by the hardware virtual memory system has zero cost.
Separation done in software needs extra memory reads when doing
allocation or deallocation.

Overall, for any interpreter that runs for a second or more, it is
likely that the one-to-one model would be faster.


Timings of multiprocessing & threads on my machine (6-core 2019 laptop)
-----------------------------------------------------------------------

#Threads

def foo():
     pass

def spawn_and_join(count):
     threads = [ Thread(target=foo, args=()) for _ in range(count) ]
     for t in threads:
         t.start()
     for t in threads:
         t.join()

spawn_and_join(1000)

# Processes

def spawn_and_join(count):
     processes = [ Process(target=foo, args=()) for _ in range(count) ]
     for p in processes:
         p.start()
     for p in processes:
         p.join()

spawn_and_join(1000)

Wall clock time for threads:
86ms. Less than 0.1ms per thread.

Wall clock time for processes:
370ms. Less than 0.4ms per process.

Processes are slower, but plenty fast enough.


Cheers,
Mark.




_______________________________________________
Python-Dev mailing list -- python-dev@python.org
To unsubscribe send an email to python-dev-leave@python.org
https://mail.python.org/mailman3/lists/python-dev.python.org/
Message archived at https://mail.python.org/archives/list/python-dev@python.org/message/5YNWDIYECDQDYQ7IFYJS6K5HUDUAWTT6/
Code of Conduct: http://python.org/psf/codeofconduct/