Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»