November 13, 2013

Python, processor affinity или как существенно ускорить некоторые программы, ничего не делая

Всем, кто увидел первую версию поста - цифры были кривые из-за turbo boost

Возьмем простой пример tcp клиента и сервера на python. Сервер создает пул из N потоков и ждет соединений с клиентом. Получив соединение передает его на обработку в пул. На каждом соединении сервер ждет от клиента строку данных и имитирует некую обработку. При получении 'bye\n' сервер завершает обработку клиента.

Клиент открывает N соединений с сервером и генерирует на них нагрузку. Общий объем нагрузки за один запуск клиента фиксирован.

Без подсветки синтаксиса
data = ' ' * 100 + '\x0A'
def client(th_count):
    sockets = []
    for i in range(th_count):
        sock = socket.socket()

        for cnt in range(3):
            try:
                sock.connect(host_port)
                break
            except socket.error:
                if cnt == 2:
                    raise
                time.sleep(0.1)

        sockets.append(sock)

    for i in range(NUM_PACKETS):
        sock = random.choice(sockets)
        sock.send(data)

    for sock in sockets:
        sock.send('bye\x0A')
Без подсветки синтаксиса
def server(th_count):
    def process_client(sock):
        num = 0
        while True:
            msg = ""
            while not msg.endswith('\n'):
              msg += sock.recv(1)

            if msg == 'bye\n':
                break

            for i in range(serv_tout):
                pass

            num += 1

    s = socket.socket()
    s.bind(host_port)
    s.listen(5)
    with ThreadPoolExecutor(max_workers=th_count) as pool:
        fts = []

        for i in range(th_count):
            sock,_ = s.accept()
            fts.append(pool.submit(process_client, sock))

        for ft in fts:
            ft.result()

Замеряем сколько времени нужно для одного прогона этой системы с N=4

Без подсветки синтаксиса
$ python mt_test.py client 4 & time python mt_test.py server 4

real    0m8.342s
user    0m7.789s
sys     0m6.587s

А теперь почти то же самое, но разрешим операционной системе исполнять все потоки сервера только на одном ядре из 8ми доступных

Без подсветки синтаксиса
$ python mt_test.py client 4 & time taskset 0x00000001 python mt_test.py server 4

real    0m4.663s
user    0m3.186s
sys     0m0.762s

Уличная магия в действии - многопоточная программа исполнилась в 2 раза быстрее, когда мы разрешили использовать только одно ядро процессора.

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

Таким образом для этой программы(как и для 99% программ на питоне) никакого заметного ускорения от использования более одного ядра ожидать и не приходится. Все чисто питоновские программы конкурентны, но не параллельны. А конкурентной такой системе от изменения количества ядер в процессоре не холодно и не жарко (почти).

Почему же скорость исполнения падает, если использовать более одного ядра? Причин две:

  • Излишние переключения между потоками
  • Постоянная война за кеш с другими потоками в системе и друг с другом

Итак что происходит: пусть у нас есть два потока, один из которых(первый) сейчас обрабатывает принятые данные, а второй ожидает данных от сокета. Наконец второй поток получает данные и ОС готова продолжить его исполнение. Она смотрит на доступные ядра, видит что первое ядро занято первым потоком и запускает второй поток на исполнение на втором ядре. Второй поток запускается и первым делом пытается захватить GIL. Неудача - GIL захвачен первым потоком. Второй поток снова засыпает, ожидая освобождения GIL.

В итоге операционная система, которая понятия не имеет ни о каких GIL, сделала кучу лишней работы (переключение контекста достаточно дорогая операция). Правда заметная часть этой работы делалась вторым ядром, так что происходила параллельно и почти не мешала исполняться первому потоку. Почти - потому что второе ядро все равно занимало шину памяти. Ситуация становится хуже, если в системе есть HT - в этом случае второе ядро может делить с первым исполняемые блоки процессора и все эти лишние переключения будут серьезно замедлять исполнение первого потока.

Вторая проблема состоит в том, что второй поток переброшен на исполнение на второе ядро процессора. Когда первый поток освободит GIL, то второй поток продолжит исполнение на втором ядре, потому что ОС знает, что кеши первого и второго уровня у каждого ядра свои и старается без причин не гонять потоки между ядрами. В итоге все имеющиеся потоки "размазываются" по доступным ядрам. Съедая в сумме 100% одного ядра, они превращают это в 12.5% на каждом из 8ми ядер. При этом в промежутках пока питоновские потоки ждут GIL на эти ядра вклиниваются другие потоки из системы, постоянно вытесняя наши данные из кеша.

В итоге питоновские потоки постоянно "бегают" по ядрам. Данные копируются в кеш и из кеша, а каждый кеш-промах стоит до тысяч тактов на обращение к RAM. Даже по меркам питона - серьезные нагрузки.

Выставив привязку к одному ядру мы убиваем сразу двух зайцев. Во-первых сокращаем количество переключений контекста, поскольку ОС будет заметно реже запустить на исполнение второй поток, если единственное доступное ему ядро сейчас занято. Во-вторых другие потоки не будут вклиниваться на это ядро, тем самым мы уменьшим интенсивность обмена данными между кешем и ОЗУ (питоновские потоки в одном процессе используют заметную часть данных совместно).

Итоги тестирования.

  • SUM - общее затраченное время
  • SYS - время, затраченное операционной системой
  • USR - время, затраченное в пользовательском режиме
  • XXX_AF - XXX в случае, если выставлена привязка к одному ядру
  • DIFF - отличие в процентах между XXX и XXX_AF

Все измерения сделаны на Core i7-2630QM@800MHz, python 2.7.5, x64, ubuntu 13.10 с усреднением по 7ми выборкам. Долгая война с turbo boost окончилась принудительным переводом процессора в режим минимальных частот.

    -------------------------------------------------------------------------
    | Потоки | SUM   SUM_AF %DIFF | SYS   SYS_AF %DIFF | USR   USR_AF %DIFF |    
    -------------------------------------------------------------------------
    | 1      | 3.35   3.55   -5   | 0.54   0.52   4    | 2.78   3.03    -8  |
    | 2      | 7.26   4.63   36   | 4.91   0.67  86    | 5.10   2.95    42  |
    -------------------------------------------------------------------------
    | 4      | 8.28   4.90   41   | 6.58   0.76  88    | 7.37   3.14    57  |
    | 8      | 7.96   5.00   37   | 6.49   0.84  87    | 7.32   3.15    57  |
    -------------------------------------------------------------------------
    | 16     | 9.77   5.88   40   | 6.53   0.73  89    | 7.01   3.15    55  |
    | 32     | 9.73   6.84   30   | 6.54   0.81  88    | 7.06   3.04    57  |
    -------------------------------------------------------------------------

Прогон теста по VTune показывает, что после выставления привязки количество кеш промахов уменьшается примерно в 5 раз, а количество переключений контекста - в 40. В ходе экспериментов обнаружилась еще одна интересная вещь - при выставлении привязки к одному ядру более эффективно используется turbo boost, что тоже ускорит вашу программу, если больше никто не грузит систему. Для этого теста turbo boost был заблокирован.

Будет ли что-то подобное в других случаях? Хотя данная программа и обрабатывает данные, приходящие из сокета, но данные приходят быстрее, чем она может их обработать. Таким образом она является CPU bounded. Если программа будет в основном занята ожиданием данных, то выставления привязки к ядру даст меньше ускорения - ОС будет меньше перебрасывать потоки между ядрами. Чем выше нагрузка на процессор, тем больше будет выигрыш.

Когда мы можем получить замедление:

  • если в программе есть места, которые действительно параллельны, например часть работы делается С/С++ библиотекой, которая отпускает GIL
  • Или вы используете jython или ironpython
  • Если вы используете multiprocessing/ProcessPoolExecutor, которые запускают отдельные процессы и не имеют проблем с GIL. Привязка в линуксе наследуется потоками/процессами. Так что для дочерних процессов ее нужно или отменить, или выделить по своему ядру на каждый процесс.
  • В некоторых однопоточных системах, например при использовании gevent

P.S. В 3.3 поведение все то-же.

P.S.S. Нашел уже готовую статью про тоже самое.

Ссылки:
          habrahabr.ru/post/84629
          habrahabr.ru/post/141181
          vimeo.com/49718712
          stackoverflow.com/questions/868568/cpu-bound-and-i-o-bound
          ru.wikipedia.org/wiki/Hyper-threading

Исходники этого и других постов со скриптами лежат тут - github.com/koder-ua. При использовании их, пожалуйста, ссылайтесь на koder-ua.blogspot.com.

2 comments:

Unknown said...

Костя, я зачитался, хотя плохо представляю о чем идет речь в деталях. Не хочешь через годик ещё в Москве силы попробовать?

Vignesh said...
This comment has been removed by the author.