Почему несколько процессов замедляют импорт пакетов python?

#python #import #multiprocessing

Вопрос:

Если я импортирую numpy за один процесс, это займет примерно 0,0749 секунды: python -c "import time; s=time.time(); import numpy; print(time.time() - s)"

Теперь, если я запускаю один и тот же код в нескольких процессах, все они импортируются значительно медленнее:

 import subprocess
cmd = 'python -c "import time; s=time.time(); import numpy; print(time.time() - s)"'

for n in range(5):
    m = 2**n
    print(f"Importing numpy on {m} Process(es):")
    processes = []
    for i in range(m):
        processes.append(subprocess.Popen(cmd, shell=True))
    for p in processes:
        p.wait()
    print()
 

дает результат:

 Importing numpy on 1 Process(es):
0.07726049423217773

Importing numpy on 2 Process(es):
0.110260009765625
0.11645245552062988

Importing numpy on 4 Process(es):
0.13133740425109863
0.1264667510986328
0.13683867454528809
0.153900146484375

Importing numpy on 8 Process(es):
0.13650751113891602
0.15682148933410645
0.17088770866394043
0.1705784797668457
0.1690073013305664
0.18076491355895996
0.18901371955871582
0.18936467170715332

Importing numpy on 16 Process(es):
0.24082279205322266
0.24885773658752441
0.25356197357177734
0.27071142196655273
0.29327893257141113
0.2999141216278076
0.297823429107666
0.31664466857910156
0.20108580589294434
0.33217334747314453
0.24672770500183105
0.34597229957580566
0.24964046478271484
0.3546409606933594
0.26511287689208984
0.2684178352355957
 

Время импорта для каждого процесса, по-видимому, растет почти линейно с увеличением числа процессов (особенно по мере увеличения числа процессов), похоже, что мы тратим в общей сложности около O(n^2) времени на импорт. Я знаю, что есть блокировка импорта, но не уверен, почему она там есть. Есть ли какие-нибудь обходные пути? И если я работаю на сервере со многими пользователями, выполняющими множество задач, может ли меня замедлить кто-то, создающий тонны рабочих, которые просто импортируют общие пакеты?

The pattern is clearer for larger n , here’s a script that shows that more clearly by just reporting the average import time for n workers:

 import multiprocessing
import time

def f(x):
    s = time.time()
    import numpy as np
    return time.time() - s

ps = []
for n in range(10):
    m = 2**n
    with multiprocessing.Pool(m) as p:
        print(f"importing with {m} worker(s): {sum(p.map(f, range(m)))/m}")
 

output:

 importing with 1 worker(s): 0.06654548645019531
importing with 2 worker(s): 0.11186492443084717
importing with 4 worker(s): 0.11750376224517822
importing with 8 worker(s): 0.14901494979858398
importing with 16 worker(s): 0.20824094116687775
importing with 32 worker(s): 0.32718323171138763
importing with 64 worker(s): 0.5660803504288197
importing with 128 worker(s): 1.034045523032546
importing with 256 worker(s): 1.8989756992086768
importing with 512 worker(s): 3.558808562345803
 

extra details about environment in which I ran this:

  • python version: 3.8.6
  • pip list:
 Package    Version
---------- -------
numpy      1.20.1
pip        21.0.1
setuptools 53.0.0
wheel      0.36.2
 

os:

  • NAME=»Pop!_OS»
  • VERSION=»20.10″

Is it just reading from filesystem that is the problem?

I’ve added this simple test where instead of importing, I now just read the numpy files and do some sanity check calculations:

 import subprocess

cmd = 'python read_numpy.py'

for n in range(5):
    m = 2**n
    print(f"Running on {m} Process(es):")
    processes = []
    for i in range(m):
        processes.append(subprocess.Popen(cmd, shell=True))
    for p in processes:
        p.wait()
    print()
 

with read_numpy.py :

 import os
import time

file_path = "/home/.virtualenvs/multiprocessing-import/lib/python3.8/site-packages/numpy"
t1 = time.time()
parity = 0
for root, dirs, filenames in os.walk(file_path):
    for name in filenames:
        contents = open(os.path.join(root, name), "rb").read()
        parity = (parity   sum([x%2 for x in contents]))%2

print(parity, time.time() - t1)
 

Запуск этого дает мне следующий результат:

 Running on 1 Process(es):
1 0.8050086498260498

Running on 2 Process(es):
1 0.8164374828338623
1 0.8973987102508545

Running on 4 Process(es):
1 0.8233649730682373
1 0.81931471824646
1 0.8731539249420166
1 0.8883578777313232

Running on 8 Process(es):
1 0.9382946491241455
1 0.9511561393737793
1 0.9752676486968994
1 1.0584545135498047
1 1.1573944091796875
1 1.163221836090088
1 1.1602907180786133
1 1.219961166381836

Running on 16 Process(es):
1 1.337137222290039
1 1.3456192016601562
1 1.3102262020111084
1 1.527071475982666
1 1.5436983108520508
1 1.651414394378662
1 1.656200647354126
1 1.6047494411468506
1 1.6851506233215332
1 1.6949374675750732
1 1.744239330291748
1 1.798882246017456
1 1.8150532245635986
1 1.8266475200653076
1 1.769331455230713
1 1.8609044551849365
 

Наблюдается некоторое замедление: 0,805 секунды для 1 работника и от 0,819 до 0,888 секунды для 4 работников. По сравнению с import : 0,07 секунды для 1 работника и от 0,126 до 0,153 секунды для 4 работников. Похоже, что может быть что-то другое, кроме замедления чтения файловой системы import

Комментарии:

1. для каждого import из них требуется поиск в файловой системе загружаемого модуля (в указанных местах sys.path ). Это означает доступ к диску, что в большинстве случаев означает попадание на шину SATA, которая практически не выигрывает от параллельного доступа (особенно если она вращается).

2. Кстати, вы на самом деле создаете вдвое больше # процессов python, потому что вы fork дочерний процесс только для вызова подпроцесса, который spawn является еще одним дочерним процессом. Технически fork это намного дешевле , чем spawn , но просто для справки. Кроме того, редко имеет смысл создавать больше процессов, чем у вас есть логических процессорных ядер на вашем процессоре. Потоки намного дешевле с точки зрения затрат памяти и процессора.

3. @Aaron почему каждый импорт должен выполнять поиск/чтение с диска? Я бы ожидал, что все будет кэшироваться после первого чтения и будет быстрее для последующего импорта.

4. @Аарон, хорошая мысль. Я отредактировал сценарий, чтобы избежать разветвления, а также создания нового процесса. Похоже, это ничего не изменило.

5. ну, просто переход от 1 процесса к 4 уже имеет довольно значительное замедление. Вычислительная мощность, которую я ожидал бы, будет постоянной, потому что у меня более 4 ядер процессора. Я также ожидал бы только 1 чтение из файловой системы, так как все они должны читать одни и те же файлы, так что это кажется идеальным приложением для кэширования. Я действительно не знаю, как работает кэширование, поэтому я могу ошибаться. Вы хотите сказать , что я бы увидел такое же замедление, если бы вместо import этого я просто прочитал все соответствующие файлы и вычислил некоторый хэш содержимого? Это были бы те же 2 шага «чтение» «некоторые вычисления».