Всё началось с вопроса на Stack Overflow: пользователь пожаловался, что на сборке CPython без GIL расчёт через
ThreadPoolExecutor заметно медленнее того же расчёта через ProcessPoolExecutor. Кумар Адитья из Quansight Labs описал 29 июля, как несколько месяцев вычищал причины этого в NumPy и самом CPython.Нагрузка простая и типичная для ufunc: каждый работник берёт свой массив, гоняет по нему
np.sin и np.cos в цикле и сворачивает результат. Общего изменяемого состояния между потоками нет, так что масштабироваться оно должно линейно. На деле до 18 потоков росло, а дальше резко деградировало: на 32 работниках 44 секунды против 6 у процессов. Профилирование через samply показало три класса проблем: конкуренция за блокировки, конкуренция за счётчики ссылок общих объектов и конкуренция в аллокаторе.🔘
tracemalloc выключен по умолчанию, но всё равно брал глобальную блокировку на каждом выделении и освобождении памяти, просто чтобы проверить, включён ли он. Теперь проверка идёт атомарной операцией без блокировки;🔘 кэш диспетчеризации ufunc, который сопоставляет типам аргументов конкретную реализацию, жил под
std::shared_mutex. Записи в нём неизменяемы, поэтому чтение сделали полностью свободным от блокировок, мьютекс остался только на редкие вставки;🔘 указатель на аллокатор памяти NumPy хранит в глобальном объекте
PyCapsule. Без GIL каждое обращение к нему меняет счётчик ссылок атомарно, и кэш-линия со счётчиком начинает метаться между ядрами. Объект сделали бессмертным, то есть вообще без подсчёта ссылок;🔘 ради этого в CPython появился публичный
PyUnstable_SetImmortal: с 3.15 он доступен всем, на 3.14 его можно взять через pythoncapi-compat;🔘 запись
np.sin — это поиск атрибута в модуле, а специализация байткода для таких поисков не работала, если модуль определяет __getattr__. Каждый вызов уходил на медленный путь с захватом импортной блокировки;🔘 массивы NumPy выделял системным
malloc, который плохо переносит параллельные выделения, особенно на macOS. Сырой аллокатор CPython в сборке без GIL перевели на mimalloc, а NumPy переключили на этот сырой аллокатор.После всех правок та же задача на 32 ядрах занимает около 1,5 секунды: примерно в 30 раз быстрее, чем было, и вчетверо быстрее варианта с процессами. Автор оговаривает, что замеры сделаны на одной 32-ядерной машине с Linux и на конкретной ufunc-нагрузке без общего состояния между потоками.
Полная статья: https://labs.quansight.org/blog/scaling-numpy-on-free-threaded-python
@zen_of_python