Многопоточный NumPy на сборке без GIL был в 7 раз медленнее процессов, стал в 4 раза быстрее
Всё началось с вопроса на Stack Overflow: пользователь пожаловался, что на сборке CPython без GIL расчёт через
Нагрузка простая и типичная для ufunc: каждый работник берёт свой массив, гоняет по нему
🔘
🔘 кэш диспетчеризации ufunc, который сопоставляет типам аргументов конкретную реализацию, жил под
🔘 указатель на аллокатор памяти NumPy хранит в глобальном объекте
🔘 ради этого в CPython появился публичный
🔘 запись
🔘 массивы NumPy выделял системным
После всех правок та же задача на 32 ядрах занимает около 1,5 секунды: примерно в 30 раз быстрее, чем было, и вчетверо быстрее варианта с процессами. Автор оговаривает, что замеры сделаны на одной 32-ядерной машине с Linux и на конкретной ufunc-нагрузке без общего состояния между потоками.
Полная статья: https://labs.quansight.org/blog/scaling-numpy-on-free-threaded-python
@zen_of_python
Всё началось с вопроса на Stack Overflow: пользователь пожаловался, что на сборке CPython без GIL расчёт через
ThreadPoolExecutor заметно медленнее того же расчёта через ProcessPoolExecutor. Кумар Адитья из Quansight Labs описал 29 июля, как несколько месяцев вычищал причины этого в NumPy и самом CPython.Нагрузка простая и типичная для ufunc: каждый работник берёт свой массив, гоняет по нему
np.sin и np.cos в цикле и сворачивает результат. Общего изменяемого состояния между потоками нет, так что масштабироваться оно должно линейно. На деле до 18 потоков росло, а дальше резко деградировало: на 32 работниках 44 секунды против 6 у процессов. Профилирование через samply показало три класса проблем: конкуренция за блокировки, конкуренция за счётчики ссылок общих объектов и конкуренция в аллокаторе.tracemalloc выключен по умолчанию, но всё равно брал глобальную блокировку на каждом выделении и освобождении памяти, просто чтобы проверить, включён ли он. Теперь проверка идёт атомарной операцией без блокировки;std::shared_mutex. Записи в нём неизменяемы, поэтому чтение сделали полностью свободным от блокировок, мьютекс остался только на редкие вставки;PyCapsule. Без GIL каждое обращение к нему меняет счётчик ссылок атомарно, и кэш-линия со счётчиком начинает метаться между ядрами. Объект сделали бессмертным, то есть вообще без подсчёта ссылок;PyUnstable_SetImmortal: с 3.15 он доступен всем, на 3.14 его можно взять через pythoncapi-compat;np.sin — это поиск атрибута в модуле, а специализация байткода для таких поисков не работала, если модуль определяет __getattr__. Каждый вызов уходил на медленный путь с захватом импортной блокировки;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
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥3❤2
На маленьком проекте подмена времени стоит 1406,7 микросекунды против 1,5, на большом — 40 971,3 против тех же 1,5
Адам Джонсон, автор
Стенд простой: генерируются пустые модули по 25 атрибутов, каждый десятый держит ссылки на
🔘 259 модулей, то есть почти голый интерпретатор: 1406,7 мкс против 1,5 мкс, разница в 940 раз;
🔘 1259 модулей, размер небольшого проекта на Django: разница в 2490 раз;
🔘 16 259 модулей, крупный проект с обвесом зависимостей: 40 971,3 мкс против неизменных 1,5 мкс, разница в 27 300 раз;
🔘 время
🔘 в наборе из 2000 тестов с подменой времени это 82 секунды чистой замены ссылок против 3 миллисекунд;
🔘 причина в том, что
У обхода есть и содержательная плата, помимо скорости: он не находит ссылки в атрибутах классов, значениях по умолчанию, замыканиях и C-расширениях, и они продолжают отдавать настоящее время. Плюс подставленные объекты видны по типу,
Автор оговаривает, что модули в стенде синтетические, это
Полная статья: https://adamj.eu/tech/2026/08/03/python-time-machine-o1-freezegun-on/
@zen_of_python
Адам Джонсон, автор
time-machine, замерил 3 августа, как две библиотеки подмены текущего времени ведут себя с ростом проекта. Прошлый такой замер он делал в 2021 году и получил разницу в 100–200 раз, теперь захотел показать не отношение, а саму зависимость от размера кодовой базы.Стенд простой: генерируются пустые модули по 25 атрибутов, каждый десятый держит ссылки на
date, datetime и time, как будто их импортировали по имени. Замеряется цикл включения и выключения подмены. Python 3.15, MacBook на M1, freezegun 1.5.5 и time-machine 3.3.0.freezegun укладывается в прямую: около 1,4 мс постоянных расходов плюс 2,5 мкс на каждый модуль;freeze_time.start() обходит весь sys.modules и подменяет каждую найденную ссылку на настоящие функции; даже при попадании в кэш приходится перечислить и захешировать имена атрибутов всех модулей.У обхода есть и содержательная плата, помимо скорости: он не находит ссылки в атрибутах классов, значениях по умолчанию, замыканиях и C-расширениях, и они продолжают отдавать настоящее время. Плюс подставленные объекты видны по типу,
datetime.__name__ внутри подмены становится FakeDatetime, и код, который смотрит на типы, может повести себя иначе.time-machine вместо обхода переписывает указатель ml_meth в структуре PyMethodDef у встроенных функций, читающих часы. Таких функций десять, и это ровно десять записей в память независимо от размера проекта.Автор оговаривает, что модули в стенде синтетические, это
types.ModuleType, положенные прямо в sys.modules, а не настоящие импорты, и что замеряется только цикл подмены, из нескольких прогонов берётся минимальное время.Полная статья: https://adamj.eu/tech/2026/08/03/python-time-machine-o1-freezegun-on/
@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
❤2
Процесс дорос до 57 гигабайт памяти и был убит ядром, потому что к каждой задаче прикреплялся живой стек вызовов
Хорхе Эскобар описал 27 июля в трекере playwright-python редкий вид утечки, где виноваты сразу две стороны. Синхронная обёртка библиотеки на каждый вызов создаёт задачу asyncio и вешает на неё атрибут со значением
Сама по себе такая привязка живёт ровно до конца вызова. Но на Python с 3.14.0 по 3.14.6 была регрессия:
🔘 нагрузка простая: скриншот на каждый кадр, PNG 3840×2160 декодируется через PIL прямо в той функции, которая вызывает
🔘 на итерацию оставалось около 40 МБ: примерно 33 МБ распакованного изображения и ещё около 8 МБ уменьшенной копии, обе как локальные переменные удержанного кадра;
🔘 после примерно 1900 итераций процесс занял около 57 ГБ и получил SIGKILL;
🔘
🔘 цепочка ссылок читается целиком: изображение, кадр вызывающей функции,
🔘 перестройка своего кода так, чтобы во время вызова в кадрах не было больших локальных переменных, снизила утечку с 40 МБ до 0,94 МБ на итерацию.
Обе стороны уже починены: регрессию в CPython закрыли 2 июля, а в playwright-python убрали хранение живых кадров, и 30 июля обсуждение закрыли. Автор замерял на конкретной нагрузке и на macOS с Python 3.14.6, но структурно то же поведение видел и на 3.12.
Вывод из этой истории шире одной библиотеки: пока задача жива, живо и всё, на что смотрят её кадры. Прикреплять
Обсуждение целиком: https://github.com/microsoft/playwright-python/issues/3157
@zen_of_python
Хорхе Эскобар описал 27 июля в трекере playwright-python редкий вид утечки, где виноваты сразу две стороны. Синхронная обёртка библиотеки на каждый вызов создаёт задачу asyncio и вешает на неё атрибут со значением
inspect.stack(0). Это не строки, а объекты FrameInfo, внутри которых лежат живые кадры стека вместе со всеми локальными переменными вызывающего кода.Сама по себе такая привязка живёт ровно до конца вызова. Но на Python с 3.14.0 по 3.14.6 была регрессия:
asyncio.wait с FIRST_COMPLETED навсегда оставлял вызывающую задачу в множестве ожидающих у того будущего, которое так и не завершилось. Playwright задевает это на каждом обращении к браузеру, потому что гонит ответ на команду наперегонки с долгоживущим будущим ошибки транспорта. В итоге каждая завершённая операция утаскивала за собой задачу, кадры и всё их содержимое.page.screenshot();gc.collect() не помогает вообще: запись в множестве ожидающих является сильным корнем, а не циклической ссылкой;FrameInfo, завершённая задача скриншота, множество ожидающих у будущего ошибки транспорта, которое живёт всю сессию браузера;Обе стороны уже починены: регрессию в CPython закрыли 2 июля, а в playwright-python убрали хранение живых кадров, и 30 июля обсуждение закрыли. Автор замерял на конкретной нагрузке и на macOS с Python 3.14.6, но структурно то же поведение видел и на 3.12.
Вывод из этой истории шире одной библиотеки: пока задача жива, живо и всё, на что смотрят её кадры. Прикреплять
inspect.stack() к объектам, время жизни которых вы не контролируете, означает подписаться на удержание чужих локальных переменных.Обсуждение целиком: https://github.com/microsoft/playwright-python/issues/3157
@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
❤1