python

Путь к проверке типов 4 миллионов строк Python-кода. Часть 2

  • суббота, 28 сентября 2019 г. в 00:32:47
https://habr.com/ru/company/ruvds/blog/468235/
  • Блог компании RUVDS.com
  • Разработка веб-сайтов
  • Python
  • Отладка
  • Облачные сервисы


Сегодня публикуем вторую часть перевода материала о том, как в Dropbox организовывали контроль типов нескольких миллионов строк Python-кода.



Читать первую часть

Официальная поддержка типов (PEP 484)


Мы провели первые серьёзные эксперименты с mypy в Dropbox во время Hack Week 2014. Hack Week — это мероприятие, проводимое Dropbox в течение одной недели. В это время сотрудники могут работать над чем угодно! Некоторые из самых знаменитых технологических проектов Dropbox начинались именно на подобных мероприятиях. В результате этого эксперимента мы сделали выводы о том, что mypy выглядит многообещающе, хотя этот проект пока ещё не был готов для широкого использования.

В то время в воздухе витала идея о стандартизации систем выдачи подсказок по типам Python. Как я уже говорил, начиная с Python 3.0 можно было пользоваться аннотациями типов для функций, но это были всего лишь произвольные выражения, без определённых синтаксиса и семантики. Во время выполнения программы эти аннотации, по большей части, просто игнорировались. После Hack Week мы начали работать над стандартизацией семантики. Эта работа привела к появлению PEP 484 (над этим документом совместно трудились Гвидо ван Россум, Лукаш Ланга и я).

Наши мотивы можно было рассматривать с двух сторон. Во-первых, мы надеялись, что вся экосистема Python могла бы принять общий подход по использованию подсказок типов (type hints — термин, используемый в Python как аналог «аннотаций типов»). Это, учитывая возможные риски, было бы лучше, чем использование множества взаимно несовместимых подходов. Во-вторых, мы хотели открыто обсудить механизмы аннотирования типов с множеством представителей Python-сообщества. Отчасти это желание было продиктовано тем, что нам не хотелось бы выглядеть «отступниками» от базовых идей языка в глазах широких масс Python-программистов. Это динамически типизированный язык, известный «утиной типизацией». В сообществе, в самом начале, не могло не возникнуть несколько подозрительное отношение к идее статической типизации. Но подобный настрой в итоге ослабел — после того, как стало ясно, что статическую типизацию не планируется делать обязательной (и после того, как люди поняли, что она по-настоящему полезна).

Принятый в итоге синтаксис подсказок по типам был очень похож на тот, что в то время поддерживал mypy. Документ PEP 484 вышел вместе с Python 3.5 в 2015 году. Python больше не был языком, поддерживающим только динамическую типизацию. Мне нравится думать об этом событии как о значительной вехе в истории Python.

Начало миграции


В конце 2015 года в Dropbox, для работы над mypy, была создана команда из трёх человек. Туда входили Гвидо ван Россум, Грег Прайс и Дэвид Фишер. С этого момента ситуация начала развиваться крайне быстро. Первым препятствием на пути роста mypy стала производительность. Как я уже намекал выше, в ранний период развития проекта я размышлял о том, чтобы перевести реализацию mypy на язык C, но эта идея была пока вычеркнута из списков. Мы застряли на том, что для запуска системы использовался интерпретатор CPython, который не отличается скоростью, достаточной для инструментов наподобие mypy. (Проект PyPy, альтернативная реализация Python с JIT-компилятором, тоже нам не помог.)

К счастью, тут нам на помощь пришли некоторые алгоритмические улучшения. Первым мощным «ускорителем» стала реализация инкрементной проверки. Идея этого улучшения была проста: если все зависимости модуля с момента предыдущего запуска mypy не изменились, то мы можем использовать, в ходе работы с зависимостями, данные, кэшированные во время предыдущего сеанса работы. Нам нужно было лишь выполнить проверку типов в изменённых файлах и в тех файлах, которые от них зависели. Mypy пошёл даже немного дальше: если внешний интерфейс модуля не менялся — mypy считал, что другие модули, которые импортируют этот модуль, не нужно проверять повторно.

Инкрементная проверка сильно помогла нам при аннотировании больших объёмов существующего кода. Дело в том, что этот процесс обычно включает в себя множество итеративных запусков mypy, так как аннотации постепенно добавляют в код и постепенно улучшают. Первый запуск mypy всё ещё был очень медленным, так как при его выполнении нужно было проверить множество зависимостей. Тогда мы, ради улучшения ситуации, реализовали механизм удалённого кэширования. Если mypy обнаруживает, что локальный кэш, вероятно, устарел, он загружает текущий снимок кэша для всей кодовой базы из централизованного репозитория. Затем он выполняет, с использованием этого снимка, инкрементальную проверку. Это ещё на один немаленький шаг продвинуло нас на пути увеличения производительности mypy.

Это был период быстрого и естественного внедрения системы проверки типов в Dropbox. У нас, к концу 2016 года, было уже примерно 420000 строк Python-кода с аннотациями типов. Многие пользователи с энтузиазмом отнеслись к проверке типов. В Dropbox mypy пользовались всё больше команд разработчиков.

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

Больше производительности!


Инкрементные проверки ускорили mypy, но этот инструмент всё ещё не был достаточно быстрым. Многие инкрементные проверки длились около минуты. Причиной подобного были циклические импорты. Это, вероятно, не удивит того, кому доводилось работать с большими кодовыми базами, написанными на Python. У нас были наборы из сотен модулей, каждый из которых косвенно импортировал все остальные. Если любой файл в цикле импортов оказывался изменённым, mypy приходилось обрабатывать все файлы, входящие в этот цикл, а часто — ещё и любые модули, импортирующие модули из этого цикла. Одним из таких циклов был печально известный «клубок зависимостей», который стал причиной множества неприятностей в Dropbox. Однажды эта структура содержала в себе несколько сотен модулей, при этом её импортировали, прямо или непрямо, множество тестов, она использовалась и в продакшн-коде.

Мы рассматривали возможность «распутывания» циклических зависимостей, но у нас не было ресурсов для того, чтобы это сделать. Там было слишком много кода, с которым мы не были знакомы. В итоге мы вышли на альтернативный подход. Мы решили сделать так, чтобы mypy работал бы быстро даже при наличии «клубков зависимостей». Мы достигли этой цели с помощью демона mypy. Демон — это серверный процесс, который реализует две интересные возможности. Во-первых — он держит в памяти информацию обо всей кодовой базе. Это означает, что при каждом запуске mypy не нужно загружать кэшированные данные, относящиеся к тысячам импортированных зависимостей. Во-вторых — он тщательно, на уровне мелких структурных единиц, анализирует зависимости между функциями и другими сущностями. Например, если функция foo вызывает функцию bar, то имеется зависимость foo от bar. Когда меняется файл — демон сначала, в изоляции, обрабатывает лишь изменившийся файл. Затем он смотрит на изменения этого файла, видимые извне, на такие, как изменившиеся сигнатуры функций. Демон использует детальную информацию об импортах только для перепроверки тех функций, которые по-настоящему используют изменённую функцию. Обычно при таком подходе проверять приходится совсем немного функций.

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

Ещё больше производительности!


Вместе с удалённым кэшированием, о котором я рассказывал выше, демон mypy практически полностью решил проблемы, возникающие тогда, когда программист часто запускает проверку типов, внося изменения в небольшое количество файлов. Однако производительность системы в наименее благоприятном варианте её использования всё ещё была далека от оптимальной. Чистый запуск mypy мог занять более 15 минут. А это было куда больше, чем нас устроило бы. Каждую неделю ситуация становилась всё хуже, так как программисты продолжали писать новый код и добавлять аннотации к существующему коду. Наши пользователи всё ещё жаждали большей производительности, мы же с удовольствием готовы были пойти им навстречу.

Мы решили вернуться к одной из ранних идей относительно mypy. А именно — к преобразованию Python-кода в C-код. Эксперименты с Cython (это — система, которая позволяет транслировать код, написанный на Python, в C-код) не дали нам какого-то видимого ускорения, поэтому мы решили возродить идею написания собственного компилятора. Так как кодовая база mypy (написанная на Python) уже содержала в себе все необходимые аннотации типов, нам казалось стоящей попытка использовать эти аннотации для ускорения работы системы. Я быстро создал прототип для проверки этой идеи. Он показал на различных микро-бенчмарках более чем 10-кратный рост производительности. Наша идея заключалась в том, чтобы компилировать Python-модули в С-модули средствами Cython, и в том, чтобы превращать аннотации типов в проверки типов, производимые во время выполнения программы (обычно аннотации типов игнорируются во время выполнения программ и используются только системами проверки типов). Мы, фактически, планировали перевести реализацию mypy с Python на язык, который был создан статически типизированным, который бы выглядел (и, по большей части, работал бы) в точности так, как Python. (Эта разновидность межъязыковой миграции стала чем-то вроде традиции проекта mypy. Изначальная реализация mypy была написана на Alore, потом был синтаксический гибрид Java и Python).

Ориентация на API расширений CPython была ключом к тому, чтобы не потерять возможностей по управлению проектом. Нам не нужно было реализовывать виртуальную машину или любые библиотеки, в которых нуждался mypy. Кроме того, нам всё ещё была бы доступна вся экосистема Python, были бы доступны все инструменты (такие, как pytest). Это означало, что мы могли бы продолжить использовать интерпретируемый Python-код в ходе разработки, что позволило бы нам продолжить работать, используя очень быструю схему внесения правок в код и его тестирования, а не ждать компиляции кода. Выглядело это так, будто нам великолепно удаётся, так сказать, усидеть на двух стульях, и нам это нравилось.

Компилятор, который мы назвали mypyc (так как он, в качестве фронтенда, использует для анализа типов mypy), оказался проектом весьма успешным. В целом — мы достигли примерно 4-кратного ускорения частых запусков mypy без использования кэширования. Разработка ядра проекта mypyc заняла у маленькой команды, в которую входили Майкл Салливан, Иван Левкивский, Хью Хан и я, около 4 календарных месяцев. Этот объём работ был куда менее масштабным, чем тот, который бы понадобился для переписывания mypy, например, на C++ или на Go. Да и изменений в проект нам пришлось внести гораздо меньше, чем пришлось бы внести при переписывании его на другом языке. Мы, кроме того, надеялись на то, что сможем довести mypyc до такого уровня, чтобы им смогли бы пользоваться, для компиляции и ускорения своего кода, другие программисты из Dropbox.

Для достижения подобного уровня производительности нам пришлось применить некоторые интересные инженерные решения. Так, компилятор может ускорить выполнение многих операций благодаря использованию быстрых низкоуровневых конструкций C. Например, вызов скомпилированной функции транслируется в вызов C-функции. А такой вызов выполняется гораздо быстрее вызова интерпретированной функции. Некоторые операции, такие, как поиск в словарях, всё ещё сводились к использованию обычных вызовов C-API из CPython, которые после компиляции оказывались лишь немного быстрее. Мы смогли избавиться от добавочной нагрузки на систему, создаваемой интерпретацией, но это в данном случае дало лишь небольшой выигрыш в плане производительности.

Для выявления самых распространённых «медленных» операций мы выполнили профилирование кода. Вооружённые полученными данными, мы попытались либо так «подкрутить» mypyc, чтобы он генерировал бы более быстрый C-код для подобных операций, либо переписать соответствующий Python-код с использованием более быстрых операций (а иногда у нас попросту не было достаточно простого решения для той или иной проблемы). Переписывание Python-кода часто оказывалось более лёгким решением проблемы, чем реализация автоматического выполнения той же самой трансформации в компиляторе. В долгосрочной перспективе нам хотелось автоматизировать многие из этих трансформаций, но в тот момент мы были нацелены на то, чтобы, приложив минимум усилий, ускорить mypy. И мы, двигаясь к этой цели, срезали несколько углов.

Продолжение следует…

Уважаемые читатели! Какие впечатления у вас вызвал проект mypy в то время, когда вы узнали о его существовании?