Во время ежегодного спринта разработчиков ядра Python мы встретились с Сэмом Гроссом, автором nogil — fork в Python 3.9, который удаляет GIL. Ниже — итоги встречи.

Содержание:

tl;dr

Работа Сэма демонстрирует возможность удалить GIL таким образом, чтобы полученный интерпретатор Python был производительным и хорошо масштабировался на все ядра CPU. Чтобы получить прирост производительности, требуются, на первый взгляд, несвязанные по изменению работы интерпретатора задачи.

В настоящее время добавить изменения Сэма в CPython невозможно, потому что они сделаны в легаси-ветке 3.9. Это сделано, чтобы пользователи могли протестировать итоговый nogil-интерпретатор в версии 3.9 с помощью доступных pip-пакетов и С-расширений. Чтобы смёржить nogil, необходимо внести изменения в main-ветку, которая скоро должна стать 3.11.

Не ожидайте, что Python 3.11 уже откажется от GIL. Даже само добавление работы Сэма в CPython будет трудоемким процессом, а ведь это только часть того, что нужно. Прежде, чем CPython удалит GIL, потребуется очень хорошая обратная совместимость и план миграции для сообщества. Пока что ничего из этого не запланировано. Мы все еще можем отказаться от этого решения в теории.

Когда говорят об изменениях такого масштаба, некоторые люди думают про Python 4. Но разработчики ядра пока не очень-то планируют выход Python 4. По правде говоря, все наоборот: мы очень стараемся не выпускать Python 4 — с тех пор, как переход со 2-й версии на 3-ю оказался сложным для сообщества. Определенно, еще слишком рано строить предположения и беспокоиться о 4-й версии.

Введение в nogil

Сэм опубликовал свой код вместе с детальным описанием, где объясняет необходимость и основные мысли своего форка.

Главные пункты всей идеи можно объяснить так:

  • замена встроенного в Python аллокатора pymalloc на mimalloc для:

    • обеспечения потокобезопасности, включая взаимодействие, необходимое для неблокирующего доступа на чтение словарей и других коллекций 

    • эффективности — конструкция heap-памяти позволяет находить отслеживаемые GC-объекты без необходимости поддерживать их явный список

  • Замена неатомарного активного подсчета ссылок в Python на смещенный подсчет ссылок (англ. — biased reference counting). Этот вариант:

    • связывает каждый объект с потоком-владельцем, который его создал — owner thread

    • обеспечивает эффективный неатомарный подсчет локальных ссылок в owner thread

    • разрешает более медленный, но атомарный подсчет общих ссылок в других потоках

  • для ускорения доступа к объектам между процессами — который в ином случае замедлен атомарным подсчетом общих ссылок — используются два метода:

    • некоторые особые объекты сделаны бессмертными: количество их ссылок никогда не будет вычислено, и они никогда не будут деаллоцированы: это относится к таким синглтонам, как None, True, False, другим небольшим целым числам и интернированным строкам, а также статично аллоцированым PyTypeObjects для встроенных типов

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

  • изменения циклического сборщика мусора, чтобы он стал однопоточным stop-the-world сборщиком мусора, который:

    • ожидает остановки всех потоков на безопасной точке, на любой границе байт-кода

    • не ожидает потоков, заблокированных на вводе-выводе, и использующих PyEval_ReleaseThread, аналог отпускания GIL в текущей версии Python

    • эффективно создает список объектов для just-in-time деаллокации. Благодаря использованию mimalloc, GC-отслеживаемые объекты все хранятся в отдельной легковесной куче

  • перемещение MRO-кэша глобального процесса в локальный поток во избежание конфликта при поиске MRO. Инвалидации кэша по-прежнему глобальны

  • усовершенствование встроенных коллекций, чтобы сделать их потокобезопасными

Полная документация концепции Сэма включает детальное описание работы этих элементов, а также информацию по состоянию потоков и GIL API, другие модификации интерпретатора и байт-кода: замена стековой виртуальной машины на регистровую виртуальную машину с накопителем; оптимизирование вызовов функций без создания С стек-фреймов; другие изменения в ceval.c; использование указателей с тегами; потокобезопасные метаданные для опкодов LOAD_ATTR, LOAD_METHOD, LOAD_GLOBAL. Это не полный список, поэтому я советую прочесть документацию целиком.

Ранние бенчмарки

В наборе тестов pyperformance Proof-of-concept no-GIL версия оказалась на 10% быстрее, чем 3.9. По оценкам, удаление GIL в комбинации с изменениями в интерпретаторе, большая часть которых связана со смещенным и отложенным подсчетами ссылок, снизит скорость примерно на 9%. Другими словами, Python 3.9 со всеми другими изменениями, но без удаления GIL, будет быстрее на 19%. Однако это не решит проблему многоядерной масштабируемости.

Кстати, некоторые из этих изменений, например отделение стека вызовов С от стека вызовов Python, уже добавлены в Python 3.11. У нас уже есть предварительные тесты для текущей main-ветки, которые показывают, что изменения в Python 3.11, которые касаются производительности, делают его на 16% быстрее, чем nogil в однопоточном режиме.

Необходимо больше тестов, особенно с использованием того, что Ларри Гастингс использовал при тестировании Gilectomy — сначала на основе Python 3.5, позже портированное на версию 3.6 alpha 1.

Сэм напоминает нам, что масштабируемость конечного приложения на Python без GIL сильно зависит от собственно кода. Без тестирования в реальных условиях невозможно предсказать, как поведет себя ваш код без GIL. Следовательно, невозможно ответственно заявлять, во сколько раз быстрее будет Python без GIL.

Вопросы к Сэму на встрече

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

Какова вероятность того, что nogil в итоге окажется нежизнеспособным для включения в CPython?

Кодовая база в сегодняшнем виде уже доказывает, что это технически возможно. Проект работает, и он более производительный и масштабируемый, чем исходный CPython и проект Gilectomy. На текущий момент внедрение можно оценить по времени примерно в 2 года фуллтайм-работы.

Все зависит от того, насколько хорошо сообщество адаптирует С-расширения, чтобы они не вызывали сбоев в интерпретаторе. Еще важно, чтобы люди использовали потоки в приложениях одновременно правильным и хорошо масштабируемым образом. Это главные проблемы, но следует быть оптимистами.

Как вы планируете синхронизировать свою работу с main? Есть ли какие-то советы по порядку коммитов?

Сейчас Сэм работает над обновлением своей версии, первоначально созданной под версию 3.9.0а3. Новая версия должна совпадать с финально выпущенной 3.9.7. Часть этой работы — рефакторинг коммитов в логические юниты, которые могут лучше объяснить, что нужно заменить и зачем.

В настоящее время нет плана по переносу всей работы в main, будущую версию 3.11. Причина в том, что эта ветка слишком быстро меняется. К версии 3.9, напротив, существует много выпущенных pip-библиотек и C-расширений для тестирования. Это позволяет Сэму оценить, как проект ведет себя в реальной жизни со сторонним кодом. Rebase до main отнимет время, которое можно потратить на улучшение интерпретатора без GIL. Так что сейчас, возможно, слишком рано фокусироваться на синхронизации форка с апстримом.

Разделение работы, чтобы ее можно было смёржить, выполнимо, но вы должны помнить, что многие изменения положительно влияют на производительность в совокупности. По отдельности они — возможно, временно — только снижают производительность.

Примечание команды разработчиков ядра: сейчас мы не можем интегрировать изменения, внесенные в версию 3.9. На этом этапе может иметь смысл использовать 3.9, но критически важно разбить ее на составные части, которые можно интегрировать в main одну за другой. Такое внедрение шаг за шагом может повредить производительности, но это единственный возможный путь к интеграции.

Можем ли мы просто извлечь регистровую виртуальную машину и компилятор без изменений? Предвидите ли вы какие-то специфические сложности, связанные с использованием регистровой виртуальной машины без подсчета ссылок или изменений в GIL?

Виртуальная машина использует deferred/immortal подсчет ссылок. Теоретически его можно просто конвертировать в классический подсчет, но неясно, насколько эффективным будет результат. Например, все объекты в стеке используют отложенный подсчет ссылок из-за соображений производительности.

И обратный вопрос: насколько сложно будет внедрить nogil без новой виртуальной машины на основе регистров?

Хотя новая виртуальная машина на основе регистров улучшит только производительность, но не корректность, она также улучшит масштабируемость, что позволит Python использовать доступные ядра без конфликтов. Возможно также использовать версию 3.11, но с некоторыми дополнениями из ВМ на основе регистров, которые важны для масштабируемости и потокобезопасности. Это довольно значительный объем работы. Но обновление ВМ на основе регистров для согласования с main-веткой — плюс исправление оставшихся ошибок — тоже потребует большого труда. Оба варианта возможны.

Каковы ваши рекомендации для С-расширений, чей код не предполагает параллельного запуска с основными потоками? Потребуется ли дополнительная поддержка API от CPython для восполнения пробелов, пока они не будут адаптированы для работы в новой среде со свободными потоками?

На это потребуется время. Конечная цель — постепенное внедрение большинством С-расширений. GIL по-прежнему будет доступен как опция при запуске интерпретатора. Если он не включен, и С-расширение не знает об этом режиме, оно может выдать предупреждение или не выполнить импорт. Сообществу понадобится адаптировать расширения и включить их в режим без GIL.

PoC уже сейчас запускается без GIL и принимает любые С-расширения, потому что это то, чего ожидают пользователи, скачивая nogil. Если это общепринято, имеет смысл начать с противоположного: требовать от Python запуска с флагом -X nogil, чтобы дать время сторонним библиотеками адаптироваться. Затем, после нескольких релизов, значение по умолчанию может быть изменено на обратное.

Хотя перенести все будет непросто (параллельность — это сложно), во многих случаях это не должно быть большой проблемой, особенно для С-расширений, оборачивающих внешние библиотеки.

Примечание команды разработчиков ядра: существует большое количество не open-source кода Python и С-расширений в формате «темной материи». Нам необходимо быть осторожными, чтобы не сломать его, потому что для пользователей этих продуктов может быть невозможно внести требуемые изменения, или послать нам отчет об ошибке. В частности, некоторые С-расширения защищают собственное внутреннее состояние с помощью GIL. Это — причина серьезного беспокойства, и может стать крупным препятствием для принятия Python без GIL.

Добавите ли вы «слот» PEP 489, который расширения смогут использовать для индикации поддержки nogil, и фейлиться при импорте в nogil, если не поддерживают?

Об этом много говорили, но все же не совсем понятно, что это значит. Включение режима без GIL не гарантирует отсутствие ошибок. Вместо этого мы можем разрешить запуск всех расширений по умолчанию — именно это сейчас происходит с nogil. Несовместимое расширение может вместо этого использовать код модуля PyInit для упреждающего запроса интерпретатора, включен ли GIL, и вызова предупреждения или даже исключения во время импорта, если нет.

Включение nogil во время запуска — это долгосрочная опция, или только на переходное время?

В идеале конечным результатом должен стать CPython без GIL, точка. Однако ожидается также длинный период адаптации сообщества. Мы хотим избежать раскола, как во время перехода с Python 2 на Python 3. Мы хотим облегчить переход, даже если его придется растянуть по времени.

Уточните, в финале предполагается исключительно nogil, без вариантов вернуть GIL обратно?

Мы пока не знаем. Лучшим вариантом будет только Python без GIL, но неясно, возможно ли это вообще.

Если эти фича-флаги продолжат существовать долгое время, потребуется ли сильное увеличение матрицы тестирования?

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

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

Что вы думаете о параллельном запуске нескольких интерпретаторов Python с одним GIL для каждого?

В каком-то смысле это дополняет мое предложение без GIL, в каком-то — конкурирует с ним. В интерпретаторе без GIL должна быть возможность запуска субинтерпретаторов.

Непонятно, получит ли работа с субинтерпретаторами завершение. Без GIL меньше беспокойства насчет передачи объектов между потоками и совместимости С-расширений. С субинтерпретаторами нет по-настоящему глобального состояния, следовательно, их нужно специально изолировать. Передача объектов через субинтерпретаторы требует некоторой формы сериализации/десериализации для мутабельных объектов. Для неизменяемых в интерпретатор может быть добавлена специальная поддержка, но код пользователя должен сообщить о таких объектах, если они не встроены. Об этом говорится в работе на схожую тему от PyTorch, в которой используется этот вид субинтерпретаторов.

Те сценарии использования, которые больше всего интересовали Сэма, были по своей природе научными данными — обучение моделей PyTorch. Поэтому возможность прямого эффективного обмена данными имела решающее значение для многопоточной производительности. С субинтерпретаторами такое совместное использование можно было включить только на уровне C-расширений, вместо Python без GIL.

Вы очень подробно описали словари и списки. А что насчет других изменяемых типов, таких как очереди, множества, массивы?

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

Очередь очень важна, потому что используется для связи между потоками с concurrent.futures и asyncio. Очереди проще словарей и списков, они используют обычные локи вместо lock-free чтений. Некоторые другие объекты, возможно, потребуют комбинации подходов.

Это хитрая работа, поскольку вам нужно быть осторожными, когда захватываете и снимаете блокировки. Например, в Py_DECREF можно войти несколько раз. Для подобных вариантов можно рассмотреть варианты более «грубых» блокировок, хотя они, конечно, подвержены риску дэдлоков.

Как nogil зависит от mimalloc? Если он нужен нам в качестве необязательной опции компиляции, есть ли возможность менее производительной сборки, которая вместо mimalloc использует платформу malloc без C-preprocessor-hell?

mimalloc используется больше, чем просто для безопасности потоков. Его поддержка необходима для возможности чтения словарей без блокировки и эффективного отслеживания GC-объектов.

Мейнтейнер mimalloc заинтересован в поддержке CPython и открыт к необходимым изменениям, которые позволят это осуществить.

Остальные реализации malloc считаются стабильными при работе с Cpython: jemalloc, используемый в Facebook, tcmalloc, используемый в Google, хотя и с меньшей интеграцией, которая больше похожа на простую замену встроенного аллокатора.

Примечание разработчиков ядра: Кристиан Хеймс и Пабло Галиндо Сальгадо проводят оценку использования mimalloc для CPython. Ранние тесты не показывают спада производительности в среднем (по среднему геометрическому). Большинство тестов работает лучше, небольшое количество тестов работает немного хуже. Некоторые возможные проблемы при оценке:

— API mimalloc и стабильность ABI
— лицензирование
— переносимость на CPython-поддерживаемые платформы — например,
stdatomic.h доступен только на С11
— интеграция с профилировщиками и санитайзерами: Valgrind, asan, ubsan
— и т.д.

Что общего у вашего проекта с проектом Ларри Гастингса Gilectomy? Удалось ли вам применить какие-то из его работ?

Верхнеуровнево наши проекты похожи: отложенный подсчет ссылок, блокировки, борьба с проблемами возврата заимствованных ссылок. В этом проекте нет переиспользования кода Gilectomy.

Вы сказали, что верхнеуровнево ваши работы с Gilectomy похожи. Работа Ларри также основана на отложенном подсчете ссылок. Хотя в Gilectomy он наблюдал исключительно спад производительности, а ваш nogil, наоборот, обещает рост. Как вы думаете, откуда такая разница?

В росте производительности и масштабируемости nogil сыграли критическую роль переключение на компилятор на основе регистров и другие оптимизации. Например, чтение словарей без блокировки, основанное на mimalloc, и отсутствие конфликтов с отложенным подсчетом ссылок. Также в некоторых случаях Python и сам хорошо вырос в этом плане. Например, вызов функций в Python 3.9 требует гораздо меньше ресурсов, чем в Python 3.5. 

Определенно, чтобы его масштабировать, потребовалось значительно больше усилий, чем ожидалось.

Возможно ли включение С-расширения в режим без GIL и отключение обратно?

Как и говорится в заголовке, GIL — просто глобальная блокировка. Чтобы он защищал все части общих данных, он должен быть включен на всех потоках, а не только на одном с несовместимым расширением.

В уже запущенных процессах довольно сложно переключить интерпретатор с режима без GIL на запуск с GIL и наоборот. Скорее, нужен переключатель при запуске, который разрешает или не разрешает GIL внутри процесса. С-расширения, не помеченные как совместимые, вызовут предупреждение или ошибку импорта.

Другой вариант — всегда делать stop the world при доступе С-расширения, но это не решает задачу удаления GIL с точки зрения производительности.

Примечание команды разработчиков ядра: есть несколько других вариантов, которые пока не до конца изучены. Один — конвертировать GIL в блокировку «много читателей — один писатель». При таком сценарии режим без GIL получает блокировку на чтение, без блокирования другого нового кода от того, чтобы сделать то же самое. Легаси-код получает блокировку на запись, блокируя все выполняющиеся остальные потоки до их освобождения. Эта схема потребовала бы сохранения API получения/отпускания GIL (что уже делает nogil), чтобы сказать GC о том, что поток заблокирован при вводе-выводе.

Возможно ли пометить функции как небезопасные для потоков (например, используя декоратор), чтобы nogil принял это и учел во время запуска кода, чтобы не мешать другим потокам? Что-то вроде временного GIL?

Если речь о состоянии, доступ к которому получают извне, каждый доступ должен быть заблокирован. Это не особенно важно на уровне декораторов. Как я уже говорил, было бы сложно реализовать условное включение GIL для частей, включающих небезопасный код.

Самостоятельная работа с блокировками вместо того, чтобы положиться на GIL, может быть трудоемкой. Ожидаете ли вы вспышки проблем с nogil?

Сложно сказать. Для API C-расширений существует по меньшей мере один хороший паттерн проектирования: они часто имеют схожую структуру и сохраняют общее состояние в единой структуре. Pybind11 выглядит на данный момент совершенно непохожим на этот паттерн, так что большинство изменений могут потребоваться в С-расширениях, написанных с ним.

Много сложных С-расширений уже должны работать с блокировками и многопоточностью, потому что их цель — отпускать GIL как можно чаще. Например, numpy. Это неожиданно, но перенести их будет проще.

Следующие шаги

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

Прежде чем мы решимся на это, кажется правильным сначала представить часть кода. В частности, mimalloc выглядит интересным, и на его включение уже есть открытые пулл-реквесты . По этой ссылке можно найти бенчмарки.

На личном уровне мы впечатлены работой Сэма и пригласили его присоединиться к проекту CPython. С удовольствием сообщаю, что он заинтересован. Чтобы помочь ему стать core-разработчиком, я буду его наставником. Гвидо и Нил Шеменауэр помогут мне ревьюить код для тех частей интерпретатора, с которыми я не знаком.

Комментарии (15)


  1. daniilgorbenko
    27.01.2022 12:07
    +3

    Чтож... Через 20 лет ждем C++ версии 128, Java верссии 114 и Python версии 4 без GIL...:)


    1. masai
      28.01.2022 01:34
      +1

      На который ещё 20 лет будут переходить. :)


  1. KivApple
    27.01.2022 12:26

    А если защищать с помощью блокировок конкретное расширение? Типа если расширение не сообщает, что поддерживает no GIL, то все именно вызовы его функций из Python будут защищены блокировкой, но не глобальной, а отдельной для расширения. Тогда если только один поток использует это расширение (или хотя бы большую часть времени), то производительность всё так же будет в выигрыше. А между тем со временем авторы расширений добавят поддержку.


    1. pda0
      27.01.2022 16:14
      +2

      Не получится. Есть ещё обратные вызовы. Т.е. расширение может читать/писать в объекты python.


    1. Ztare
      27.01.2022 16:14

      а я думал, что там главная проблема — изменяемые объекты переданные в расширение и доступные уже изнутри расширения. Такое простым локом не победить с лету


  1. mr_bag
    27.01.2022 14:50
    -2

    А смысл? В той нише, где живёт Python, он прекрасно себя чувствует. Заменить JS? Ну так это невозможно по понятным причинам.


    1. pda0
      27.01.2022 15:51

      Смысл в том, что GIL мешает использовать python как встраиваемый интерпретатор, т.к. ради его «многопоточного» использования приходится выносить его в отдельные процессы.


      1. masai
        28.01.2022 01:36
        -1

        Не совсем понял. Как GIL мешает использовать Python в качестве встраиваемого интерпретатора? Прекрасно встраивается же.


        1. pda0
          28.01.2022 15:17
          +2

          Встраивается, если вас устраивает что только один интерпретатор будет работать в вашей программе одновременно. Сегодня, если python используется в качестве скрипт-языка в каком-нибудь сервисе или движке игры (сейчас это не очень популярно, но было время, когда его использовали), то вам может захотеться чтобы несколько интерпретаторов работали одновременно на разных ядрах. Собственно python этого не позволяет. awasu.com/weblog/embedding-python/threads


          1. masai
            28.01.2022 22:47

            А, теперь понял. Спасибо!


    1. tetelevm
      28.01.2022 11:02

      Как сказать прекрасно - контейнеры с джанговскими серверами дублируются в несколько воркеров, тк нельзя распараллелить питоновскими средствами. Некоторая простая статистика в мльных расчётах (которая запускается один раз и нет смысла переписывать на си) тоже числодробится в один поток.


      1. masai
        28.01.2022 22:50

        Числа на голом Python мало кто дробит, а Numpy вполне себе многопоточный. Кстати, можно Numba или Cython использовать, если не хочется на другом языке писать, но нужно побыстрее.


    1. Hivemaster
      28.01.2022 13:36
      +1

      В каком смысле "заменить JS"? Если что, у JS тоже есть свой GIL.


  1. piratarusso
    28.01.2022 11:07

    Ядер с каждым годом становится всё больше, так что GIL со временем будет большой занозой для фреймворков на python и ruby на многоядерных процессорах. Интересно насколько mimalloc такая панацея? В любом случае питон хуже не станет.


    1. pda0
      28.01.2022 15:24

      Я думаю, что mimalloc тут не панацея сам по себе, а просто менеджеры памяти по разному реагируют на ситуацию «программа вразнобой выделяет и освобождает память из разных потоков». Одни довольно плохо реагируют на такое, сильно просаживая производительность, т.к. по сути однопоточны и защищают вызовы внутренней блокировкой, т.е. сами содержат аналог GIL, а другие справляются с этой ситуацией вполне неплохо. Вероятно mimalloc как раз из последних и выбран ради того, чтобы не пришлось перелопачивать уже написанный код.