Zen of Python
19K subscribers
1.37K photos
202 videos
38 files
3.5K links
Полный Дзен Пайтона в одном канале

Разместить рекламу: @tproger_sales_bot

Правила общения: https://tprg.ru/rules

Другие каналы: @tproger_channels

Сайт: https://tprg.ru/site

Регистрация в перечне РКН: https://tprg.ru/xZOL
Download Telegram
Шесть проверщиков типов для Python на одних и тех же исходниках

Справочник pydevtools прогнал mypy, pyright, Basedpyright, ty, Pyrefly и Zuban по скорости, строгости, выводу типов, поддержке в редакторах и лицензиям. Замеры делались на Apple Silicon, по пять запусков с отброшенным прогревом и очищенными кэшами, дата последнего обновления страницы 30 июля.

На библиотеке Rich, 38 тысяч строк: mypy 0,90 с, pyright 2,64 с, Basedpyright 2,35 с, ty 0,10 с, Pyrefly 0,17 с, Zuban 0,13 с. На SQLGlot, 76 тысяч строк: mypy 2,58 с, pyright 4,04 с, Basedpyright 4,33 с, ty 1,28 с, Pyrefly 0,32 с, Zuban 0,49 с.

🔘 в этом наборе ty обогнал mypy в 9 раз на Rich и в 2 раза на SQLGlot, Pyrefly быстрее в 5–8 раз, Zuban в 5–7 раз, pyright же оказался в 2–3 раза медленнее mypy;
🔘 параллельный режим mypy на проектах такого размера дал 1,2x и 1,3x вместо обещанных пятикратных;
🔘 в наборе тестов на соответствие спецификации типов из 141 пункта: Zuban 140, pyright 134, Pyrefly 130, ty 100, mypy 83;
🔘 mypy по умолчанию вообще не заглядывает внутрь функций без аннотаций, остальные разбирают их выводом типов;
🔘 расхождение видно на двух строках: после x = [] и x.append(1) pyright, Basedpyright и ty считают тип list[Unknown], а mypy, Pyrefly и Zuban выводят list[int].

Автор предупреждает, что процент прохождения тестов на соответствие не означает практической пользы: пункты в наборе неравноценны по важности, а инструменты в бете меняются быстро, так что цифры стареют.

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

@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
👍3
Разбор HTML-страницы за 272 микросекунды вместо 15,3 миллисекунды

Бернат Габор собрал turbohtml, набор инструментов для HTML целиком на C: экранирование, разэкранирование, токенизация, запросы по селекторам, сериализация и операции с URL. Разбор страницы на 92 КБ занимает 272 мкс против 15,3 мс у BeautifulSoup, запрос по селектору 1,3 мкс против 20,8 мкс у lxml и 99,9 мкс у BeautifulSoup, токенизация 34,9 мкс против 435 мкс у html.parser и 836 мкс у html5lib. На плотном четырёхмегабайтном тексте escape отрабатывает за 4,98 мс против 12,7 мс у html.escape. Публикация от 18 июня обновлена 10 июля.

Скорость складывается из того, как обрабатываются байты. Обычный код ищет спецсимволы посимвольно, и процессор на каждом байте выполняет ветвление. Здесь байты обрабатываются пачками: SWAR складывает восемь байт в одно машинное слово и проверяет их арифметикой, SIMD делает то же самое сразу по шестнадцать байт одной инструкцией. Ветвлений в горячем цикле почти не остаётся.

🔘 длина результата сначала считается точно, потом выделяется одним куском и заполняется массовым копированием, вместо дописывания по мере разбора;
🔘 токенизатор сохраняет исходную ширину строки и компилируется в три варианта под UCS-1, UCS-2 и UCS-4;
🔘 чистые текстовые куски возвращаются как срезы без копирования, копия делается только когда она правда нужна;
🔘 переписывание css_path() с квадратичного перебора на индекс сократило пример со 112 мс до 0,9 мс;
🔘 список переиспользуемых обёрток ускорил find_all() с 1,9 до 1,4 мкс, но в сборке без GIL он отключён.

Автор оговаривает, что Callgrind считает инструкции, а не реальное время по часам, поэтому на него опираются только при сравнении вариантов. Корректность проверяется побайтовым сравнением с эталонными библиотеками, сборками с ASan и UBSan и фаззингом.

Что у вас парсит HTML в проде и сколько времени на это уходит?

@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥8🤣1
Как в CPython 3.15 убрали проверку из горячего цикла интерпретатора

JIT в CPython нужен поток исполняемых инструкций, значит интерпретатор должен уметь записывать всё, что выполняет. Ключевой вопрос в том, сколько за эту возможность платят программы, которым запись не нужна. Кен Джин описал 1 июля путь к решению, попавшему в 3.15.

Первый вариант: два отдельных интерпретатора, обычный и записывающий. Для интерпретатора на хвостовых вызовах это работало приемлемо, а на варианте с computed goto дало около 6% замедления на pyperformance. Одна из причин в том, что код интерпретатора на C фактически удвоился и перестал помещаться в кэш процессора. Второй вариант, флаг режима записи, убирает раздувание кода, но возвращает проверку ветвления в самый горячий путь, на каждую выполняемую инструкцию.

Рабочее решение обходится без проверок вообще. Интерпретатор прыгает к обработчику инструкции через таблицу переходов, и таблиц делают две: адрес нужной лежит в локальной переменной.

🔘 наивная реализация с двумя полноценными таблицами упирается в ту же проблему, это снова два интерпретатора;
🔘 трюк в том, что все записи второй таблицы указывают на одну-единственную инструкцию записи;
🔘 она пишет trace и передаёт управление обычной таблице, получается сужение потока и обратное расширение;
🔘 включение и выключение режима сводится к подмене указателя на таблицу, ENTER_TRACING() и LEAVE_TRACING();
🔘 в игрушечном замере медиана по 40 запускам составила 1,72 микросекунды без записи и 7,47 микросекунды с записью и JIT.

Автор оценивает накладные расходы максимум в 4,5x и сразу оговаривает, что сравнивать это с накладными расходами 900–1000x у PyPy некорректно.

Чем профилируете горячий Python-код сейчас и во сколько раз он от этого замедляется?

@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥31👀1
Python 3.15 добрался до rc1: ленивые импорты, frozendict и UTF-8 по умолчанию

4 августа вышел первый релиз-кандидат. Финал ожидается в октябре, так что смотреть, что придётся чинить, стоит уже сейчас. Самое заметное:

— PEP 810, ленивые импорты. Новый синтаксис lazy import json и lazy from json import dumps. Модуль не грузится, пока имя реально не тронули. Строго opt-in: меняется поведение только помеченных строк. Главные бенефициары — CLI-утилиты и тест-сьюты с тяжёлым деревом зависимостей.

— PEP 686, UTF-8 по умолчанию. Кодировка для I/O больше не зависит от системной локали. Откатить можно через PYTHONUTF8=0 или -X utf8=0 — и вот это стоит проверить заранее, если у вас Windows и legacy-файлы в cp1251.

— PEP 814, встроенный frozendict. Наконец-то неизменяемый словарь в ядре, а не в трёх конкурирующих пакетах на PyPI.

— PEP 798, распаковка в comprehensions. [*L for L in lists] для плоского списка, {**d for d in dicts} для слияния словарей — синтаксическая дыра, которая раздражала лет десять.

— PEP 799, семплирующий профайлер Tachyon прямо в стандартной библиотеке. Другой класс инструмента, чем cProfile: тот инструментирует каждый вызов и искажает картину на горячем коде, семплирующий периодически снимает стек и почти не мешает.

— JIT прибавил 6–7% среднего геометрического на x86-64 Linux и 12–13% на AArch64 macOS.

Плюс по мелочи: sentinel из PEP 661, TypedDict с типизированными extra-полями (PEP 728), TypeForm (PEP 747) и более внятные подсказки в AttributeError.

Полный список — в whatsnew, там же примеры кода и раздел с несовместимостями.

#python315 #cpython
🔥3
Многопоточный NumPy на сборке без GIL был в 7 раз медленнее процессов, стал в 4 раза быстрее

Всё началось с вопроса на 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
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥32
На маленьком проекте подмена времени стоит 1406,7 микросекунды против 1,5, на большом — 40 971,3 против тех же 1,5

Адам Джонсон, автор time-machine, замерил 3 августа, как две библиотеки подмены текущего времени ведут себя с ростом проекта. Прошлый такой замер он делал в 2021 году и получил разницу в 100–200 раз, теперь захотел показать не отношение, а саму зависимость от размера кодовой базы.

Стенд простой: генерируются пустые модули по 25 атрибутов, каждый десятый держит ссылки на date, datetime и time, как будто их импортировали по имени. Замеряется цикл включения и выключения подмены. Python 3.15, MacBook на M1, freezegun 1.5.5 и time-machine 3.3.0.

🔘 259 модулей, то есть почти голый интерпретатор: 1406,7 мкс против 1,5 мкс, разница в 940 раз;
🔘 1259 модулей, размер небольшого проекта на Django: разница в 2490 раз;
🔘 16 259 модулей, крупный проект с обвесом зависимостей: 40 971,3 мкс против неизменных 1,5 мкс, разница в 27 300 раз;
🔘 время freezegun укладывается в прямую: около 1,4 мс постоянных расходов плюс 2,5 мкс на каждый модуль;
🔘 в наборе из 2000 тестов с подменой времени это 82 секунды чистой замены ссылок против 3 миллисекунд;
🔘 причина в том, что 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🤔1
Процесс дорос до 57 гигабайт памяти и был убит ядром, потому что к каждой задаче прикреплялся живой стек вызовов

Хорхе Эскобар описал 27 июля в трекере playwright-python редкий вид утечки, где виноваты сразу две стороны. Синхронная обёртка библиотеки на каждый вызов создаёт задачу asyncio и вешает на неё атрибут со значением inspect.stack(0). Это не строки, а объекты FrameInfo, внутри которых лежат живые кадры стека вместе со всеми локальными переменными вызывающего кода.

Сама по себе такая привязка живёт ровно до конца вызова. Но на Python с 3.14.0 по 3.14.6 была регрессия: asyncio.wait с FIRST_COMPLETED навсегда оставлял вызывающую задачу в множестве ожидающих у того будущего, которое так и не завершилось. Playwright задевает это на каждом обращении к браузеру, потому что гонит ответ на команду наперегонки с долгоживущим будущим ошибки транспорта. В итоге каждая завершённая операция утаскивала за собой задачу, кадры и всё их содержимое.

🔘 нагрузка простая: скриншот на каждый кадр, PNG 3840×2160 декодируется через PIL прямо в той функции, которая вызывает page.screenshot();
🔘 на итерацию оставалось около 40 МБ: примерно 33 МБ распакованного изображения и ещё около 8 МБ уменьшенной копии, обе как локальные переменные удержанного кадра;
🔘 после примерно 1900 итераций процесс занял около 57 ГБ и получил SIGKILL;
🔘 gc.collect() не помогает вообще: запись в множестве ожидающих является сильным корнем, а не циклической ссылкой;
🔘 цепочка ссылок читается целиком: изображение, кадр вызывающей функции, FrameInfo, завершённая задача скриншота, множество ожидающих у будущего ошибки транспорта, которое живёт всю сессию браузера;
🔘 перестройка своего кода так, чтобы во время вызова в кадрах не было больших локальных переменных, снизила утечку с 40 МБ до 0,94 МБ на итерацию.

Обе стороны уже починены: регрессию в 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
2
Проверка одной группы объектов стоит времени, пропорционального размеру группы, а полный проход сборщика — всему содержимому процесса

28 июля на discuss.python.org появилось предложение научить сборщик мусора принимать подсказки: разработчик или анализатор кода говорит, что вот эта группа объектов, скорее всего, больше никому не нужна, а сборщик проверяет и освобождает её, не запуская полный обход поколения.

Смысл в том, как в CPython устроено освобождение памяти. Основную работу делает подсчёт ссылок, он мгновенный и точный. Но объекты, ссылающиеся друг на друга по кругу, счётчиками не убираются: у запроса лежит ссылка на его статус, у статуса обратная ссылка на запрос, оба недостижимы снаружи, а счётчики у обоих равны единице. За такие случаи отвечает отдельный сборщик циклов, и он останавливает все потоки, чтобы обойти память.

🔘 предлагаемая проверка простая: просуммировать счётчики ссылок объектов группы и посчитать, сколько ссылок на них идёт изнутри самой группы. Совпало — снаружи на группу никто не смотрит, можно освобождать;
🔘 в примере с запросом и статусом обе суммы равны двум, поэтому пара удаляется без обхода кучи;
🔘 подсказка ничего не гарантирует и всегда проверяется, а если из какой-то категории приходит слишком много ложных подсказок, её можно перестать слушать;
🔘 автор ссылается на известный случай Instagram, где сборщик отключали и просто перезапускали машины по мере роста памяти, и на статистику, по которой сборка мусора съедает от 5 до 30 процентов процессорного времени;
🔘 в обсуждении сразу возразили: Терри Ридди спрашивает, насколько часто цикл нельзя просто разорвать руками, а Грег Юинг — есть ли алгоритм, который окажется дешевле честной сборки;
🔘 ещё одно возражение практическое: подсказки придётся обновлять при каждой правке структур данных и как-то проверять тестами, а если уж писать такой тест, проще искать сами циклы и разрывать их.

Замеров у автора нет, работающего кода тоже: это предложение и спор вокруг него. Но читать его полезно из-за самого разбора механики: видно, что подсчёт ссылок и сборщик циклов решают разные задачи, и что цена второго определяется размером всей кучи, а не количеством мусора.

Обсуждение: https://discuss.python.org/t/improving-python-garbage-collection-performance-by-providing-unreachable-cycles-hints/108307

@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
👍41
Бинарный поиск ускорили в 8 раз, не меняя ни алгоритм, ни язык: 16,6 процента промахов предсказателя ветвлений превратились в ноль

Итамар Тёрнер-Трауринг разобрал шаг из градиентного бустинга в scikit-learn: миллион чисел с плавающей точкой нужно разложить по 255 корзинам, для чего по массиву границ гоняется бинарный поиск. Код уже скомпилированный и уже параллелится по ядрам, алгоритм оптимальный. Статья опубликована 11 июля и обновлена 18-го, работа сделана в рамках Quansight.

Остался запас, который не виден на уровне алгоритма. Современное ядро процессора выполняет несколько инструкций одновременно и угадывает, куда пойдёт ветвление. В бинарном поиске сравнение по определению непредсказуемо: каждая итерация с равной вероятностью идёт влево или вправо, и предсказатель ошибается примерно в половине случаев, а конвейер каждый раз сбрасывается.

🔘 исходная версия: 45 200,4 микросекунды, 16,6 процента неверных предсказаний, 0,7 инструкции за такт, около 27 инструкций ветвления на одно значение;
🔘 первая переделка убирает ветвление, заменяя его условным присваиванием через select_unpredictable: 9 685,2 мкс, промахов ноль, 3,2 инструкции за такт, ветвлений 19 на значение;
🔘 предвычисление половины диапазона и доступ без проверки границ дают 7 280,4 мкс и обрушивают число инструкций ветвления до 6 020 571 на весь прогон;
🔘 финальная версия обрабатывает значения кусками по 16 штук с внешним циклом по шагам поиска: 5 453,4 мкс и 4,9 инструкции за такт;
🔘 любопытно, что она выполняет больше инструкций, чем предыдущая, но идёт быстрее: независимые куски работы позволяют процессору выполнять их одновременно;
🔘 итог — примерно восьмикратное ускорение на том же алгоритме, том же языке и одном ядре.

Автор оговаривает, что это упрощённый пример, а не полная реализация из scikit-learn, и что статья не заменяет учебник по устройству процессора. В обновлении он честно пишет, что раньше ошибся со сравнением чисел с плавающей точкой, и после исправления SIMD перестал давать выигрыш, хотя код от этого стал только быстрее. Дальнейший запас автор видит в параллелизме по ядрам.

Польза для питониста прямая: когда расчёт уже переписан на расширение и всё равно кажется медленным, следующий уровень — не алгоритм, а поведение процессора на этом коде.

Полная статья: https://pythonspeed.com/articles/branchless-binary-search/

@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM
42
Новые модели выходят быстрее, чем все успевают их внедрять, а прогнозы про ИИ устаревают ещё быстрее, поэтому на кафедре техпреда МФТИ запустили «Точку сборки».

Это серия открытых вебинаров, на которых спикеры делятся опытом внедрения агентов, быстрой сборки MVP и конкретными кейсами своих команд.

Больше о «Точке сборки», о первом вебинаре и о том, как попасть на следующие читайте в новом материале на Tproger: https://tproger.ru/articles/pochemu-opyt-razrabotchikov-luchwe-prognozov-pro-ii
1
Технически всё верно...
😁17
Django научился превращать классический N+1 в два запроса без единой правки в коде цикла

Django 6.1 вышел 5 августа. Главное изменение в релизе — режимы дозагрузки полей, то есть настройка того, что происходит в момент обращения к полю, которое из базы не приехало.

Раньше поведение было одно: подтягиваем недостающее поле для этого объекта. Теперь оно называется FETCH_ONE и остаётся по умолчанию, а рядом появились ещё два. FETCH_PEERS подтягивает поле сразу для всех объектов, приехавших из того же запроса, то есть работает как prefetch_related по требованию. FETCH_RAISE запрещает неявные обращения к базе вовсе.

🔘 режим ставится методом QuerySet.fetch_mode(), и цикл, который в каждой итерации трогает внешний ключ, начинает укладываться в два запроса вместо N+1;
🔘 FETCH_RAISE полезен в критичных по скорости участках: любой незамеченный поход в базу превращается в ошибку, а не в тихий лишний запрос;
🔘 у ForeignKey.on_delete появились варианты на стороне базы: DB_CASCADE, DB_SET_NULL и DB_SET_DEFAULT работают через SQL ON DELETE, и объекты для удаления загружать не нужно;
🔘 плата за это честно описана: DB_CASCADE не вызывает сигналы pre_delete и post_delete, потому что Python в удалении не участвует;
🔘 настройка MAILERS позволяет описать несколько почтовых движков с разными параметрами, как это давно сделано для кэшей и баз; в Django 7.0 она заменит EMAIL_BACKEND, пока старая настройка работает с предупреждением;
🔘 число итераций в хешировании паролей PBKDF2 поднято с 1 200 000 до 1 500 000.

По мелочи: добавлены функции UUID4 и UUID7, вычисляемые поля получили виртуальные колонки на PostgreSQL 18 и выше, RedirectView с сохранением метода запроса теперь отвечает кодами 307 и 308 вместо 302 и 301, а OpenLayers в админке обновлён с 7.2.2 до 10.9.0.

Поддерживаются Python 3.12, 3.13 и 3.14. Обычная поддержка релиза продлится примерно до апреля 2027 года, расширенная до декабря. В заметках отдельно перечислены несовместимости, так что перед обновлением их стоит прочитать: смена семантики сигналов при каскадном удалении на стороне базы как раз тот случай, когда код продолжает работать, а побочные действия молча исчезают.

@zen_of_python
Please open Telegram to view this post
VIEW IN TELEGRAM