Как мы ускоряли работу отладчика Swift
Привет! Меня зовут Александр Скворцов, я работаю в команде Яндекс.Браузера для iOS. Это очень большой проект, который насчитывает около тысячи clang-модулей и примерно 600 Swift-модулей. Наверное, из-за таких масштабов мы чаще других наталкиваемся на проблемы инструментов разработки, например, находим критические ошибки в компиляторе, неработающую подсветку и автодополнение. Это бывает неприятно, но жить можно.
Самая серьёзная проблема возникла с отладкой. В худшем случае с момента запуска до остановки в отладчике на точке входа в приложение проходило больше 20 минут. И это на свежем MacBook Pro 16! С таким «быстродействием» инструментов разработки невозможно эффективно развивать проект, поэтому мы решили разобраться в причинах и поискать возможные решения. В результате получилось не только снять остроту проблемы у себя, но и внести правки в код отладчика Swift — со временем описанные в статье неприятности перестанут беспокоить всех пользователей Xcode. А теперь расскажу подробнее, как это было.
История уходит корнями в появление замечательного языка Swift, задуманного как замена Objective-C для разработки в экосистеме Apple. Много воды утекло с момента выхода первой версии: значительно изменился синтаксис, расширились возможности стандартной библиотеки, добавлено много синтаксического сахара, облегчающего жизнь, появился стабильный ABI. Но кое-что (надёжность инструментов разработки) сохранилось практически в первозданном виде…
Механизмы интеграции Swift с кодом на других языках
Небольшой, но важный спойлер: во время диагностики мы выяснили, что основную часть времени при запуске занимает компиляция clang-модулей, которые требуются для интерпретации выражений на Swift в консоли отладчика. Чтобы описанные ниже решения стали понятнее, следует сказать пару слов о механизмах интеграции Swift с кодом, написанным на других языках. Если вы хорошо знакомы с этой темой, можете смело переходить к следующей части статьи.
Bridging Header
Тривиальный способ, он не требует предусловий от импортируемого кода. Bridging Header — привычный для разработчиков на Objective-C заголовочный файл, в котором с помощью директивы #import
перечисляется список заголовков, содержимое которых нужно использовать в коде на Swift. Однако, у этого способа есть существенный недостаток: Swift-модуль, в котором используется Bridging Header, нельзя импортировать в другой Swift-модуль (на самом деле можно, если вы компилируете без Xcode, но цена этого — сложнорешаемые проблемы со сборкой и отладкой). Поэтому он подходит только для модулей, содержащих точку входа в приложение, а мы из-за этого ограничения полностью отказались от Bridging Header-ов.
Clang-модули
Альтернативным способом интеграции с C и Objective-C являются clang-модули. Это более универсальный подход, так как, в отличие от Bridging Header-ов, clang-модули не накладывают ограничений на Swift-код. Что важно, ведь Swift уже стал основным языком разработки под iOS.
Для оптимизации по времени при компиляции Swift для clang-модулей используется аналог предварительно откомпилированных заголовков: создаётся неявно управляемый компилятором кеш из предварительно откомпилированных модулей, который позволяет не интерпретировать clang-модуль заново каждый раз при обработке директивы import
в Swift-коде. Компиляция clang-модуля занимает нетривиальное количество времени (до нескольких секунд), поэтому при большом количестве модулей в проекте наполнение кеша бывает довольно долгим. Сейчас (в версии Swift 5.3) единственная настройка поведения кеша модулей — путь к нему, но уже анонсирован механизм явной сборки.
В поисках проблемы
К счастью, в сборке lldb
, которая поставляется в составе Xcode, есть названия символов, поэтому процесс lldb-rpc-server
можно информативно профилировать. В результате профилировки, как я уже писал выше, выяснилось, что основную часть времени при запуске занимает как раз компиляция clang-модулей:
14.15 min 100.0% 0 s lldb-rpc-server (42247)
14.15 min 99.9% 0 s thread_start
14.15 min 99.9% 0 s _pthread_start
12.99 min 91.7% 0 s threadFuncSync(void*)
12.99 min 91.7% 0 s RunSafelyOnThread_Dispatch(void*)
12.99 min 91.7% 0 s llvm::CrashRecoveryContext::RunSafely(llvm::function_ref<void ()>)
12.99 min 91.7% 0 s void llvm::function_ref<void ()>::callback_fn<compileModuleImpl(clang::CompilerInstance&, clang::SourceLocation, llvm::StringRef, clang::FrontendInputFile, llvm::StringRef, llvm::StringRef, llvm::function_ref<void (clang::CompilerInstance&)>, llvm::function_ref<void (clang::CompilerInstance&)>)::$_3>(long)
На первый взгляд, всё логично: в проекте много clang-модулей, они долго собираются, на этом можно расходиться.
Но нас всё ещё не устраивает время запуска отладчика, поэтому копаем дальше. Включаем логи отладчика по инструкции и смотрим на них в надежде увидеть хоть что-то необычное… А вот и результат — натыкаемся на очень интересную строчку:
Extra clang arguments : (28814 items)
После неё идёт длинный список из аргументов, состоящий в основном из флагов -I
, -F
и -fmodule-map-file=
, значения которых повторяются. Такая находка наводит на мысль о возможной неэффективности процесса сборки clang-модулей, который и занимает львиную долю времени запуска отладчика. Дело за малым — разобраться, насколько это предположение близко к истине.
Об особенностях работы отладчика Swift
Прежде чем приступить к проверке гипотезы, следует немного углубиться в детали работы отладчика Swift. Формат DWARF, использующийся для C, Objective-C и C++, не позволяет записать всю необходимую для вычисления выражений информацию. Поэтому частично она хранится в файлах swiftmodule
, которые являются одним из артефактов сборки Swift-кода. Абсолютный путь до каждого swiftmodule
записывается линковщиком в исполняемый файл, чтобы отладчик мог читать из него нужную информацию.
Интересующая нас часть отладочной информации — список аргументов clang. Он формируется на основе параметров компилятора Swift, передаваемых при сборке модуля. Найденная аномалия заключается в дублировании аргументов clang, поэтому важно убедиться, что оно происходит не по нашей вине — такое дублирование может происходить в том числе из-за неправильной работы системы сборки. Смотрим подробный отчёт вызываемых при компиляции команд и видим, что значения параметров -I
, -F
и -fmodule-map-file=
уникальны внутри каждого вызова компилятора, поэтому переходим к изучению отладчика.
We need to go deeper
Первым делом скачиваем исходный код и находим место, в котором печатается интересующая нас строка лога:
swift::ClangImporterOptions &clang_importer_options =
GetClangImporterOptions();
log->Printf(" Extra clang arguments : (%llu items)",
(unsigned long long)clang_importer_options.ExtraArgs.size());
for (std::string &extra_arg : clang_importer_options.ExtraArgs) {
log->Printf(" %s", extra_arg.c_str());
}
Путём нехитрого анализа окружающего кода выясняем, что дублирующиеся аргументы добавляются в этом методе, который в цикле вызывается вот отсюда:
std::function<void(ModuleSP &&)> process_one_module =
[&](ModuleSP &&module_sp) {
<...>
SwiftASTContext *ast_context =
llvm::dyn_cast_or_null<SwiftASTContext>(&*type_system_or_err);
if (ast_context && !ast_context->HasErrors()) {
<...>
swift_ast_sp->AddExtraClangArgs(ast_context->GetClangArguments());
}
}
};
for (size_t mi = 0; mi != num_images; ++mi) {
process_one_module(target.GetImages().GetModuleAtIndex(mi));
}
В итерациях цикла используются данные, прочитанные из файлов swiftmodule
, механизм работы которых мы обсудили в предыдущей части.
Как видно из кода, при формировании конечного списка аргументы, прочитанные изswiftmodule
, предварительно обрабатываются:
отбрасывается флаг
-Werror
, чтобы не вызывать лишних ошибок при интерпретации выражений в консоли отладчика;относительные пути дополняются до абсолютных;
и, наконец, устраняются дублирующие определения макросов.
Последний пункт наиболее интересен: мы предполагаем, что проблема заключается в повторении одинаковых аргументов, значит, можно просто дополнить код, чтобы убрать дублирование не только определений макросов, но и других флагов.
В этом плане есть серьёзные изъяны:
во-первых, чтобы проверить гипотезу путём внесения изменений в код отладчика, нужно его скачать и скомпилировать, что займёт немало времени и места на SSD;
во-вторых, даже если наша версия подтвердится, применить результаты на практике получится очень нескоро из-за длинного релизного цикла инструментов разработки.
Что ж, нам ничего не остаётся, кроме как искать обходной путь.
Обходной путь
Для интерпретации выражений в консоли отладчика создаётся некий контекст компиляции Swift. Его параметры должны разрешать использование любых типов или функций из любого модуля в исполняемом файле. Как мы уже выяснили, информация о clang-модулях попадает в контекст через пути поиска, прочитанные из файлов swiftmodule
, которые, в свою очередь, добавляются в исполняемый файл линковщиком. Фрагмент аргументов линковщика выглядит так:
-add_ast_path path/to/swift/module1.swiftmodule
-add_ast_path path/to/swift/module2.swiftmodule
...
-add_ast_path path/to/swift/moduleN.swiftmodule
Мы хотим избежать конкатенации путей поиска из всех N
файлов swiftmodule
, которая приводит к дублированию и неконтролируемому росту списка аргументов clang в контексте компиляции Swift. Для этого можно попробовать передать линковщику только один swiftmodule
. Здесь нас поджидает небольшая проблема: хочется не только ускорить запуск отладчика, но и не потерять его работоспособность.
Значит, единственный swiftmodule
должен предоставлять объём отладочной информации, эквивалентный тому, что даёт полный набор. Проанализировав код отладчика, обнаруживаем, что из swiftmodule
берутся только аргументы clang, поэтому сформировать нужный нам «супермодуль» не составит труда — он должен состоять из одного файла, где будут перечислены директивы import
каждого clang-модуля, входящего в исполняемый файл:
import ClangModule1
import ClangModule2
...
import ClangModuleN
Такое содержимое позволяет убедиться, что мы используем полный список путей поиска. В противном случае при обработке проблемной директивы import
возникнет ошибка компиляции.
Как мы уже знаем, внутри отдельно взятого swiftmodule
список аргументов clang уникален, поэтому заменяем наши многочисленные -add_ast_path
на единственный -add_ast_path path/to/swift/supermodule.swiftmodule
, и наблюдаем искомый результат:
Extra clang arguments : (962 items)
Новая профилировка запуска отладчика показывает, что гипотеза о неэффективности компиляции clang-модулей подтвердилась:
3.99 min 100.0% 0 s lldb-rpc-server (44423)
3.99 min 99.9% 0 s thread_start
3.99 min 99.9% 0 s _pthread_start
2.96 min 74.3% 0 s threadFuncSync(void*)
2.96 min 74.3% 0 s RunSafelyOnThread_Dispatch(void*)
2.96 min 74.3% 0 s llvm::CrashRecoveryContext::RunSafely(llvm::function_ref<void ()>)
2.96 min 74.3% 0 s void llvm::function_ref<void ()>::callback_fn<compileModuleImpl(clang::CompilerInstance&, clang::SourceLocation, llvm::StringRef, clang::FrontendInputFile, llvm::StringRef, llvm::StringRef, llvm::function_ref<void (clang::CompilerInstance&)>, llvm::function_ref<void (clang::CompilerInstance&)>)::$_3>(long)
При этом отладчик остаётся исправным, а время старта сокращается примерно до 4 минут! Конечно, это всё ещё невыносимо долго, но уже намного лучше, чем было раньше, поэтому создаём пул-реквест и радуем коллег.
Вместо заключения
Описанный способ невозможно реализовать, если вы используете встроенный xcodebuild
для сборки проекта. К счастью, это не наш случай — размеры проекта не позволяют ограничиться стандартными инструментами. Мы работаем с нестандартным тулчейном, основанным на gn, благодаря которому удалось относительно легко провернуть манёвр с «отладочным»swiftmodule
.
Решив проблему локально, мы не забыли внести правки (раз, два) в код отладчика. Со временем дублирование аргументов clang останется в прошлом для всех пользователей Xcode. Однако наша война ещё не окончена — 4 минуты на запуск отладчика (напомню, что это худший случай: после очистки DerivedData и перезапуска Xcode) нас не устраивают. А значит, возможно не менее увлекательное продолжение истории. Stay tuned!