Почему этот скрипт 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,5-2 раза медленнее по настенным часам и в 4-5 раз медленнее по тактовой частоте ЦП, чем его выполнение на одном ядре?

Спросив и погуглив, я выдвинул две гипотезы:

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

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


person user108884    schedule 13.07.2015    source источник
comment
Да, я знаю о GIL, и меня не удивляет, что запуск обратного отсчета в параллельных потоках на самом деле медленнее, чем в одном потоке. Что меня удивляет и чего я не понимаю, так это почему запуск этого скрипта на нескольких ядрах намного медленнее, чем на одном ядре.   -  person user108884    schedule 13.07.2015
comment
Я заметил, что при добавлении времени, указанного в первой версии (то есть без набора задач), сумма не соответствовала времени, указанному time. Если time.clock() изменить на time.time(), это несоответствие исчезнет. Однако при использовании подхода taskset все же есть небольшое преимущество, не уверен, что все это значит...   -  person brm    schedule 13.07.2015
comment
В *nix time.clock() сообщает время процессора, а не время настенных часов (docs.python.org/2.7/library/time.html#time.clock). Таким образом, результаты следует интерпретировать следующим образом: для запуска этого кода на нескольких ядрах требуется гораздо больше ресурсов процессора, чем на одном ядре. Я не первый, кто наткнулся на эти результаты (например, youtu.be/Obt-vMVdM8s? t=55s), но объяснение меня не устраивает. Но вы правы, я также должен измерять и сообщать в реальном времени. Я обновлю код.   -  person user108884    schedule 14.07.2015


Ответы (2)


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

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

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

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

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

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

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

person mshildt    schedule 14.07.2015
comment
Да, я натолкнулся на это объяснение, когда впервые начал копаться в GIL. Дальнейшие раскопки показали, что этого объяснения недостаточно. Взгляните на Python-2.7.6/Modules/threadmodule.c:603 static void t_bootstrap(void *boot_raw). Это функция, которую Python передает потоку ОС при запуске потока Python (путем thread_PyThread_start_new_thread). Если я правильно понял Дэйва Бизли, его точка зрения состоит в том, что потоки ОС продолжают работать и все время пытаются получить GIL, но это не то, что происходит. Поток ОС приостанавливается, когда он пытается получить GIL, и не пробуждается до тех пор, пока GIL не будет освобожден. - person user108884; 14.07.2015
comment
И работающий поток освобождает GIL при потенциально блокирующих операциях ввода-вывода, и каждый тик sys.getcheckinterval(). Таким образом, в случае обратного отсчета потоки ОС переключаются очень часто - отсюда и замедление. Чего я не совсем понимаю, так это почему замедление становится более значительным, если потоки работают на нескольких ядрах. - person user108884; 14.07.2015
comment
На самом деле думал, что информация Бизли где-то касается этой разницы. Правда, надо будет потом поискать. Сейчас в пути на работу. - person mshildt; 14.07.2015
comment
@user108884 user108884 См. слайд 29-38 или около того презентации Inside the GIL со страницы, на которую есть ссылка в конце моего ответа. На слайде 32 показано, что количество системных вызовов из-за накладных расходов на сигнализацию резко возрастает по мере добавления ядер. Затем он переходит к обсуждению многоядерной конкуренции GIL в отличие от нескольких потоков на одном ядре. - person mshildt; 14.07.2015

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

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

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

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

person Aaron Digulla    schedule 14.07.2015
comment
Есть ли способ на *nix подтвердить эту гипотезу (о кеше) цифрами? И можете ли вы предложить какие-либо хорошие источники, чтобы узнать больше об этом? - person user108884; 14.07.2015
comment
Одновременно или параллельно? Многопоточный код считается параллельным даже в однопроцессорных системах. - person mike3996; 14.07.2015