Наверное всем, кто хоть раз интересовался 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: sched_setaffinity / sched_getaffinity из libc
- Windows: SetProcessAffinityMask / GetProcessAffinityMask из kernel32
- POSIX на правах офтопика: pthread_setaffinity_np / pthread_getaffinity_np
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