Почему этот скрипт Python работает на 4 раза медленнее на нескольких ядрах, чем на одном ядре

Я пытаюсь понять, как работает GIL CPython и каковы различия между GIL в CPython 2.7.x и CPython 3.4.x. Я использую этот код для бенчмаркинга:

from __future__ import print_function import argparse import resource import sys import threading import time def countdown(n): while n > 0: n -= 1 def get_time(): stats = resource.getrusage(resource.RUSAGE_SELF) total_cpu_time = stats.ru_utime + stats.ru_stime return time.time(), total_cpu_time, stats.ru_utime, stats.ru_stime def get_time_diff(start_time, end_time): return tuple((end-start) for start, end in zip(start_time, end_time)) def main(total_cycles, max_threads, no_headers=False): header = ("%4s %8s %8s %8s %8s %8s %8s %8s %8s" % ("#t", "seq_r", "seq_c", "seq_u", "seq_s", "par_r", "par_c", "par_u", "par_s")) row_format = ("%(threads)4d " "%(seq_r)8.2f %(seq_c)8.2f %(seq_u)8.2f %(seq_s)8.2f " "%(par_r)8.2f %(par_c)8.2f %(par_u)8.2f %(par_s)8.2f") if not no_headers: print(header) for thread_count in range(1, max_threads+1): # We don't care about a few lost cycles cycles = total_cycles // thread_count threads = [threading.Thread(target=countdown, args=(cycles,)) for i in range(thread_count)] start_time = get_time() for thread in threads: thread.start() thread.join() end_time = get_time() sequential = get_time_diff(start_time, end_time) threads = [threading.Thread(target=countdown, args=(cycles,)) for i in range(thread_count)] start_time = get_time() for thread in threads: thread.start() for thread in threads: thread.join() end_time = get_time() parallel = get_time_diff(start_time, end_time) print(row_format % {"threads": thread_count, "seq_r": sequential[0], "seq_c": sequential[1], "seq_u": sequential[2], "seq_s": sequential[3], "par_r": parallel[0], "par_c": parallel[1], "par_u": parallel[2], "par_s": parallel[3]}) if __name__ == "__main__": arg_parser = argparse.ArgumentParser() arg_parser.add_argument("max_threads", nargs="?", type=int, default=5) arg_parser.add_argument("total_cycles", nargs="?", type=int, default=50000000) arg_parser.add_argument("--no-headers", action="store_true") args = arg_parser.parse_args() sys.exit(main(args.total_cycles, args.max_threads, args.no_headers)) 

При запуске этого скрипта на моем четырехъядерном процессоре i5-2500 под Ubuntu 14.04 с Python 2.7.6, я получаю следующие результаты (_r означает реальное время, _c для времени процессора, _u для пользовательского режима, _s для режима ядра):

  #t seq_r seq_c seq_u seq_s par_r par_c par_u par_s 1 1.47 1.47 1.47 0.00 1.46 1.46 1.46 0.00 2 1.74 1.74 1.74 0.00 3.33 5.45 3.52 1.93 3 1.87 1.90 1.90 0.00 3.08 6.42 3.77 2.65 4 1.78 1.83 1.83 0.00 3.73 6.18 3.88 2.30 5 1.73 1.79 1.79 0.00 3.74 6.26 3.87 2.39 

Теперь, если я привяжу все потоки к одному ядру, результаты будут разными:

 taskset -c 0 python countdown.py #t seq_r seq_c seq_u seq_s par_r par_c par_u par_s 1 1.46 1.46 1.46 0.00 1.46 1.46 1.46 0.00 2 1.74 1.74 1.73 0.00 1.69 1.68 1.68 0.00 3 1.47 1.47 1.47 0.00 1.58 1.58 1.54 0.04 4 1.74 1.74 1.74 0.00 2.02 2.02 1.87 0.15 5 1.46 1.46 1.46 0.00 1.91 1.90 1.75 0.15 

Поэтому возникает вопрос: зачем запускать этот код Python на нескольких ядрах на 1,5x-2x медленнее настенные часы и на 4x-5x медленнее, чем на процессоре, чем на одном ядре?

Спросив вокруг и поисковый запрос, он получил две гипотезы:

  1. При работе на нескольких ядрах поток может быть переназначен для запуска на другом ядре, что означает, что локальный кеш становится недействительным, следовательно, замедляется.
  2. Верхняя часть подвески нити на одном сердечнике и активация ее на другом сердечнике больше, чем приостановка и активация потока на одном сердечнике.

Есть ли другие причины? Я хотел бы понять, что происходит, и иметь возможность поддержать мое понимание цифрами (это означает, что если замедление происходит из-за промахов в кеше, я хочу видеть и сравнивать числа для обоих случаев).

2 Solutions collect form web for “Почему этот скрипт Python работает на 4 раза медленнее на нескольких ядрах, чем на одном ядре”

Это из-за того, что GIL избивает, когда несколько собственных потоков конкурируют за GIL. Материалы Дэвида Бизли по этому вопросу расскажут все, что вы хотите знать.

См. Информацию здесь для приятного графического представления о том, что происходит.

Python3.2 представил изменения в GIL, которые помогают решить эту проблему, поэтому вы должны увидеть улучшенную производительность с 3.2 и более поздними версиями.

Следует также отметить, что GIL представляет собой деталь реализации ссылочной реализации языка cpython. Другие реализации, такие как Jython, не имеют GIL и не страдают этой конкретной проблемой.

Остальная информация Д. Бейсли о GIL также будет полезна для вас.

Чтобы конкретно ответить на ваш вопрос о том, почему производительность намного хуже, когда задействованы несколько ядер, см. Слайд 29-41 презентации Inside GIL . В нем подробно обсуждается многоядерное соперничество GIL, в отличие от нескольких потоков на одном ядре. Slide 32 специально показывает, что количество системных вызовов из-за накладных расходов по потоку проходит через крышу при добавлении ядер. Это связано с тем, что потоки теперь работают симулятивно на разных ядрах и позволяют им участвовать в настоящей битве GIL. В отличие от нескольких потоков, использующих один процессор. Хорошая сводная марка из приведенной выше презентации:

С несколькими ядрами потоки, связанные с процессором, получают запланированное одновременное (на разных ядрах), а затем имеют бит GIL.

GIL предотвращает одновременное выполнение нескольких потоков python. Это означает, что каждый раз, когда один поток должен выполнять байт-код Python (внутреннее представление кода), он будет получать блокировку (эффективно останавливая другие потоки на других ядрах). Чтобы это работало, процессору необходимо очистить все строки кэша. В противном случае активный поток будет работать с устаревшими данными.

Когда вы запускаете потоки на одном CPU, не требуется очистка кеша.

Это должно объяснять большую часть замедления. Если вы хотите запускать код Python параллельно, вам нужно использовать процессы и IPC (сокеты, семафоры, IO с отображением памяти). Но это может быть медленным по разным причинам (память должна быть скопирована между процессами).

Другим подходом является перемещение большего количества кода в библиотеке C, которая не удерживает GIL во время ее выполнения. Это позволило бы выполнять больше кода параллельно.

  • Python GIL и многопоточность
  • одновременное выполнение нескольких потоков в python - возможно ли это?
  • Каковы последствия вызова функций API API NumPy из нескольких потоков?
  • Сопутствует ли это лекарство от GIL?
  • Python GIL и глобальные переменные
  • Как избежать предупреждения gcc в расширении Python C при использовании Py_BEGIN_ALLOW_THREADS
  • Как проверить, содержит ли поток текущий GIL?
  • Почему в виртуальной машине Java нет GIL? Почему Python нужно так плохо?
  • Python - лучший язык программирования в мире.