Python threading или GIL нам почти не помеха

в 9:31, , рубрики: GIL, python, метки: ,

Наверное всем, кто хоть раз интересовался Python, известно про GIL — его одновременно и сильное и слабое место.
Не мешая однопоточным скриптам работать, он ставит изрядные палки в колеса при многопоточной работе на CPU-bound задачах (когда потоки выполняются, а не висят попеременно в ожидании I/O и т.п.).
Подробности хорошо описаны в переводе двухгодичной давности. Побороть GIL в официальной сборке Python для настоящего распараллеливания потоков мы не можем, но можно пойти другим путем — запретить системе перебрасывать потоки Python между ядрами. В общем пост из серии, «если не нужно, но очень хочется» :)
Если вы знаете про processor/cpu affinity, пользовались ctypes и pywin32, то ничего нового не будет.

С чего все начиналось

Возьмем простой код (почти как в статье-переводе):

cnt = 100000000
trying = 2

def count():
    n = cnt
    while n>0:
        n-=1

def test1():
    count()
    count()

def test2():
    t1 = Thread(target=count)
    t1.start()
    t2 = Thread(target=count)
    t2.start()
    t1.join(); t2.join()

seq1 = timeit.timeit( 'test1()', 'from __main__ import test1', number=trying )/trying
print seq1

par1 = timeit.timeit( 'test2()', 'from __main__ import test2', number=trying )/trying
print par1

Запустим на python 2.6.5 (ubuntu 10.04 x64, i5 750):

10.41
13.25

И на python 2.7.2 (win7 x64, i5 750):

19.25
27.41

Сразу отбросим, что win-версия явно медленнее. В обоих случаях видно значительное замедление параллельного варианта.

Если очень хочется, то можно

GIL в любом случае не позволит многопоточному варианту выполняться быстрее, чем линейный. Однако, если реализация некоего функционала упрощается при введении поточности в код, то стоит хотя бы попытаться по возможности сократить это отставание.
При работе многопоточного приложения ОС может произвольно «перебрасывать» разные потоки между ядрами. И когда два (и более) потока одного python-процесса одновременно пытаются захватывать GIL, начинаются тормоза. Переброс выполняется и для однопоточной программы, но там он не сказывается на скорости.

Соответственно, чтобы потоки захватывали GIL поочередно, можно ограничить python-процесс одним ядром. А поможет нам в этом CPU Affinity Mask, позволяющая в формате битовых флагов указывать на каких ядрах/процессорах разрешено выполняться программе.

На разных ОС данная операция выполняется разными средствами, но сейчас рассмотрим Ubuntu Linux и WinXP+. Также изучалась FreeBSD 8.2 на Intel Xeon, но это останется за пределами статьи.

А сколько у нас вариантов?

Прежде чем выбирать ядра, нужно определиться сколько их у нас в распоряжении. Тут стоит плясать от возможностей платформы: multiprocessing.cpu_count() в python 2.6+, os.sysconf('SC_NPROCESSORS_ONLN') по POSIX и т.д. Пример определения можно посмотреть тут.

Непосредственно для работы с processor affinity были выбраны:

Linux Ubuntu

Чтобы достучаться до libc воспользуемся модулем ctypes. Для загрузки нужной библиотеки воспользуемся ctypes.CDLL:

libc = ctypes.CDLL( 'libc.so.6' )
libc.sched_setaffinity # наша функция

Все бы было хорошо, но есть два момента:

  • Жесткое задание имени libc.so.6 не переносимо, а файл libc.so, которому следовало бы являться симлинкой на реальную версию, на Debian/Ubuntu сделан текстовым файлом.
    На данный момент сделан костыль в виде поиска всех файлов, имена которых начинаются с «libc.so» и попытка подгрузить их с обработкой OSError. Загрузили — это наша библиотечка.
    Если кто-то знает лучшее и универсальное решение — буду раз увидеть в комментариях или в личке.
  • Указания имени функции недостаточно. Нужны же еще число параметров и их типы. Для этого воспользуемся заданием «магического» атрибута argtypes для нужных нам функций.

Наши функции:

int sched_setaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);
int sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);

pid_t — это int, cpu_set_t — структура из одного поля размером в 1024 бита (т.е. возможно работать с 1024 ядрами/процессорами).
Воспользуемся cpusetsize, чтобы работать не сразу со всеми ядрами и считать, что cpu_set_t — это unsigned long. В общем случае следует воспользоваться ctypes.Arrays, но это выходит за рамки темы статьи.
Также стоит заметить, что mask передается как указатель, т.е. ctypes.POINTER(<тип самого значения>).
После проведения соответствия типов C и ctypes получаем:

__setaffinity = _libc.sched_setaffinity
__setaffinity.argtypes = [ctypes.c_int, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]

__getaffinity = _libc.sched_getaffinity
__getaffinity.argtypes = [ctypes.c_int, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]

После указания argtypes за типами передаваемых значений следит ctypes. Чтобы модуль не ругался, а делал свою работу, корректно укажем значения при вызове:

def get_affinity(pid=0):
    mask = ctypes.c_ulong(0)                        # инициализируем переменную
    c_ulong_size = ctypes.sizeof(ctypes.c_ulong)    # данные только по первым 32/64 ядрам
    if __getaffinity(pid, c_ulong_size, mask) < 0:
        raise OSError
    return mask.value                               # преобразование ctypes.c_ulong => python int

def set_affinity(pid=0, mask=1):
    mask = ctypes.c_ulong(mask)
    c_ulong_size = ctypes.sizeof(ctypes.c_ulong)
    if __setaffinity(pid, c_ulong_size, mask) < 0:
        raise OSError
    return

Как видно, ctypes сам неявно разобрался с указателем. Также стоит заметить, что вызов с pid=0 выполняется над текущим процессом.

Windows XP+

В документации к нужным нам функциям указано:

Minimum supported client - Windows XP
Minimum supported server - Windows Server 2003
DLL - Kernel32.dll

Теперь мы знаем, когда это будет работать и какую библиотеку нужно грузить.

Делаем по аналогии с Linux версией. Берем заголовки:

BOOL WINAPI SetProcessAffinityMask(
  __in  HANDLE hProcess,
  __in  DWORD_PTR dwProcessAffinityMask
);

BOOL WINAPI GetProcessAffinityMask(
  __in   HANDLE hProcess,
  __out  PDWORD_PTR lpProcessAffinityMask,
  __out  PDWORD_PTR lpSystemAffinityMask
);

В качестве HANDLE нас вполне устроит ctypes.c_uint, а вот с типами out параметров нужно быть аккуратными:
DWORD_PTR — это все тот же ctypes.c_uint, а PDWORD_PTR — это уже ctypes.POINTER(ctypes.c_uint).
Итого получаем:

__setaffinity = ctypes.windll.kernel32.SetProcessAffinityMask
__setaffinity.argtypes = [ctypes.c_uint, ctypes.c_uint]

__getaffinity = ctypes.windll.kernel32.GetProcessAffinityMask
__getaffinity.argtypes = [ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint)]

И кажется, что вот сделаем там и все заработает:

def get_affinity(pid=0):
    mask_proc = ctypes.c_uint(0)
    mask_sys  = ctypes.c_uint(0)
    if not __getaffinity(pid, mask_proc, mask_sys):
        raise ValueError
    return mask_proc.value

def set_affinity(pid=0, mask=1):
    mask_proc = ctypes.c_uint(mask)
    res = __setaffinity(pid, mask_proc)
    if not res:
        raise OSError
    return

Но увы. Функции принимают не pid, а HANDLE процесса. Его еще нужно получить. Для этого воспользуемся функцией OpenProcess ну и «парной» к ней CloseHandle:

PROCESS_SET_INFORMATION   =  512
PROCESS_QUERY_INFORMATION = 1024

__close_handle = ctypes.windll.kernel32.CloseHandle

def __open_process(pid, ro=True):
    if not pid:
        pid = os.getpid()
    
    access = PROCESS_QUERY_INFORMATION
    if not ro:
        access |= PROCESS_SET_INFORMATION
    
    hProc = ctypes.windll.kernel32.OpenProcess(access, 0, pid)
    if not hProc:
        raise OSError
    return hProc

Если не вдаваться в подробности, то мы просто получаем HANDLE нужного нам процесса с доступом на чтение параметров, а при ro=False и на их изменение. Об этом написано в документации по SetProcessAffinityMask и GetProcessAffinityMask:

SetProcessAffinityMask:
hProcess [in]
A handle to the process whose affinity mask is to be set. This handle must have the PROCESS_SET_INFORMATION access right.

GetProcessAffinityMask:
hProcess [in]
A handle to the process whose affinity mask is desired.
Windows Server 2003 and Windows XP:  The handle must have the PROCESS_QUERY_INFORMATION access right.

Так что никакого метода Монте-Карло :)

Переписываем наши get_affinity и set_affinity c учетом изменений:

def get_affinity(pid=0):
    hProc = __open_process(pid)
    
    mask_proc = ctypes.c_uint(0)
    mask_sys  = ctypes.c_uint(0)
    if not __getaffinity(hProc, mask_proc, mask_sys):
        raise ValueError

    __close_handle(hProc)
    
    return mask_proc.value

def set_affinity(pid=0, mask=1):
    hProc = __open_process(pid, ro=False)

    mask_proc = ctypes.c_uint(mask)
    res = __setaffinity(hProc, mask_proc)
    __close_handle(hProc)
    if not res:
        raise OSError
    return

WindowsXP+ для ленивых

Чтобы немного сократить объем кода для Win-реализации можно поставить модуль pywin32. Он избавит нас от необходимости задавать константы и разбираться с библиотеками и параметрами вызова. Наш код выше мог бы выглядеть как-то так:

import win32process, win32con, win32api, win32security
import os

def __open_process(pid, ro=True):
    if not pid:
        pid = os.getpid()
    
    access = win32con.PROCESS_QUERY_INFORMATION
    if not ro:
        access |= win32con.PROCESS_SET_INFORMATION
    
    hProc = win32api.OpenProcess(access, 0, pid)
    if not hProc:
        raise OSError
    return hProc

def get_affinity(pid=0):
    hProc = __open_process(pid)
    mask, mask_sys = win32process.GetProcessAffinityMask(hProc)
    win32api.CloseHandle(hProc)
    return mask

def set_affinity(pid=0, mask=1):
    try:
        hProc = __open_process(pid, ro=False)
        mask_old, mask_sys_old = win32process.GetProcessAffinityMask(hProc)
        res = win32process.SetProcessAffinityMask(hProc, mask)
        win32api.CloseHandle(hProc)
        if res:
            raise OSError
    except win32process.error as e:
        raise ValueError, e
    return mask_old

Кратко, понятно, но это сторонний модуль.

И что в итоге?

Если собрать это все воедино и добавить к нашим первоначальным тестам еще один:

def test3():
    cpuinfo.affinity.set_affinity(0,1) # меняем в своем процессе (pid=0) affinity на первое ядро.
    test2()

par2 = timeit.timeit( 'test3()', 'from __main__ import test3', number=trying )/trying
print par2

то результаты будут следующими:

Linux:
test1 : 10.41 | 102.89
test2 : 13.25 | 135.29
test3 : 10.45 | 104.51

Windows:
test1 : 19.25 | 191.97
test2 : 27.41 | 269.78
test3 : 19.52 | 196.17

Цифры во второй колонке — теже тесты, но с cnt в 10 раз большим.
Мы получили два потока выполнения практически без потери в скорости работы по сравнению с однопоточным вариантом.

Affinity задается битовой маской на обоих ОС. На 4х ядерной машине get_affinity выдает значение 15 (1+2+4+8).

Пример и весь код для статьи выложил на github.
Принимаю любые предложения и претензии.
Также интересуют результаты на процессоре с поддержкой HT и на других версиях Linux.

Всех с первым апреля! Этот код действительно работает :)

Автор: AterCattus

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js