Привет! Меня зовут Александр Скворцов, я работаю в команде Яндекс.Браузера для 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!
iago
Апплодирую стоя, спасибо вам, ребята! У меня было смутное ощущение на протяжении последних пяти лет, что каждый подключаемый objc модуль увеличивает время начала работы отладчика, да и каждой линковки приложения по хорошо если квадратичной зависимости, но вы все блестяще раскопали.
Apple хорошо бы провести оптимизацию компилятора, линковщика и всей системы сборки, а не гнаться за «революционными» изменениями в UI и прочими «инновациями», процессорами на 1% быстрее и 0.5% тоньше и т.п.
Был у меня коллега-индус, а у него друг-индус, работающий в Apple — так тот очень чертыхался, что в Apple никто не хочет браться за фикс даже критичных старых багов — руководство по голове не погладит, всем все равно. Вот если делаешь что-то, что пойдет на WWDC — сразу и премии, и карьерный рост, и прочие атрибуты хорошей жизни.
У меня на одном проекте секунд 20-30 проходило и то я страдал, а тут минуты… десятки минут… сочувствую всей iOS-девелоперской душой.
P.S. Я правильно понял, что чем больше либ на objc содержит твой podfile, тем хорошо если квадратично больше время запуска дебаггера? К сожалению, в каждом втором приложении нужна интеграция с Facebook и Google-либами, а они сплошь и рядом Objc на Objc и Objc погоняют.
domix32
Им некогда, надо скорее выкатить новые апи на новые айфоны.
iago
Некогда странно слышать, когда мы говорим о компании с капитализацией в миллиарды. С такими возможностями всегда можно найти десяток технарей-энтузиастов, поселить их в центре Купертино, и их работа будет заключаться в таких оптимизациях. Да, сложная, муторная работа, но какая полезная для сообщества.
Да и для самих Apple — реально, в больших проектах поправил строчку, нажал перебилд — и открываешь браузер, потому что ждать долго. Быстрее выходят апдейты — быстрее платят юзеры — больше денег. Уверен, на дистанции зарплаты тех ребят окупятся сторицей, а уж сколько нервов можно сэкономить разработчикам…
мечты, мечты…
ilammy
Корпорацию всегда больше волнует то, насколько работа полезна для капитализации, чем насколько она полезна для общества. Для неё это просто приятный бонус в карму. Люди платят деньги за полезную работу и не платят за бесполезную, а не наоборот: если платят, то работа получается полезная, а если не платят — то приходится делать бесполезную работу.
Аналогично, у корпорации всегда будут расходы, а также приемлемый уровень этих расходов. Замена разработчиков, отхвативших нервный срыв — это не проблема, а просто расходы, когда люди в очередь стоят за большой честью работать на вас. И готовы сами платить за право причаститься к благодетели App Store.
iago
Капитализация зависит в том числе и от качества инструментов для сторонних разработчиков, на минуточку главный доход Appstore — это 30% прибыли от всех продаж, которые там совершаются.
Просто зависимость эта не такая явная, как от новых «революционных» фич, и проявляется на длинной дистанции. Если долго забивать на такие вещи, то получим Nokia, или архитектуру MS Windows, да мало ли еще примеров — просто не так быстро.
Джобс об этом думал, поэтому и был Джобсом, а что там сейчас — одному индийскому богу известно
domix32
ну, меня в целом удивляет отношение яблок к разработчикам — все сделано так чтобы разработчик, который не пользуется Xcode — страдал. А если пользуется, то страдал от Xcode эксклюзивно от Apple™. И я совершенно не понимаю почему такая крупная кампания продолжает публиковать обновы по принципу ХХП. Казалось бы, за легаси они почти не держатся, в отличие от тех же MS, бета версии отдают покатать, только почему-то критичные баги выливают в стабильной версии системы.
Обычно, все потенциально полезные изменения Xcode кроются под "Stability improvement" в "Что нового", которое значит почти все что угодно — от "мы пофиксили тот баг с подсветкой скобочки которую вы просили в версии 5 в 2007 году" до "мы накинули вам +5 минут к запуску вашего приложения, чтобы вы успели сходить перекурить, пока течёт (по памяти) наш отладочный кетчуп". Мне кажется они платят своим разрабом кучу бабла только для того чтобы те прикинулись фрилансерами, сделали новую фичу в кодовой базе или пофиксили один баг при помощи костыля и не отсвечивали.
iago
уверен, есть доля правды в ваших словах. Тоже этого не понимаю, но тут без хорошего пинка сверху ничего не сдвинется с места, нужен новый Джобс от разработки. Хотя xCode и при Джобсе был икскодом.
P.S. Кстати, я пришел в iOS разработку в 2009 году, был еще xCode 3 :) IB еще был отдельным приложением, с 4-й версии году в 10-м они сделали его уже встроенным
ASkvortsov Автор
Не совсем так, взрывной рост флажков
clang
-а вызывает как раз рост количестваSwift
-модулей, а неObjC
. Как можно понять из нашего локального решения, позволившего смягчить проблему, если бы у вас был одинSwift
-модуль на все приложение, описанное в статье на вас бы не повлияло :)Тем не менее, это не делает ваше наблюдение неправильным, ведь у
cocoapods
своя атмосфера. Вот пара фактов:1. Даже если под написан только на
Swift
без примесейObjC
, поды добавляют к нему сгенерированные заголовок иm
-файл.2. Каждому поду в header search paths записываются пути поиска до всех остальных подов, вне зависимости от того, от чего он на самом деле зависит (простите за каламбур).
Вследствие этих фактов получаем такую картину: допустим, у нас были поды
A
,B
,C
, все наSwift
. Поды сгенерируют проект таким образом, что, в числе прочих, уSwift
-модулей будут такие флажки:A
:-Ipath/to/B -Ipath/to/C
B
:-Ipath/to/A -Ipath/to/C
C
:-Ipath/to/A -Ipath/to/B
Теперь мы хотим добавить еще один под
D
, и картина будет выглядеть уже таким образом:A
:-Ipath/to/B -Ipath/to/C -Ipath/to/D
B
:-Ipath/to/A -Ipath/to/C -Ipath/to/D
C
:-Ipath/to/A -Ipath/to/B -Ipath/to/D
D
:-Ipath/to/A -Ipath/to/B -Ipath/to/C
И, таким образом, количество флажков возросло с 6 до 12 (в асимптотике тут будет квадратичный рост: всего их будет
N * (N - 1)
). Напомню, что, как мы уже выяснили в статье, все эти флажки склеятся, несмотря на то, что они дублируют друг друга.Давайте добавим еще один под
E
, но уже наObjC
. Для него не будетSwift
-модуля, пагубным образом влияющего на дебаггер, поэтому картина будет такая:A
:-Ipath/to/B -Ipath/to/C -Ipath/to/D -Ipath/to/E
B
:-Ipath/to/A -Ipath/to/C -Ipath/to/D -Ipath/to/E
C
:-Ipath/to/A -Ipath/to/B -Ipath/to/D -Ipath/to/E
D
:-Ipath/to/A -Ipath/to/B -Ipath/to/C -Ipath/to/E
Количество флажков возросло с 12 до 16, но, если бы модуль
E
содержалSwift
(или был бы полностью на нем, это уже никакой роли не играет), то мы бы увидели не 16, а целых 20 флажков (т.к. добавилась бы еще такая же строчка дляE
).tl;dr: количество флажков при использовании подов растет как
M * N
, гдеM
— количество подов, использующихSwift
(неважно при этом содержат ли ониObjC
), аN
— общее количество подов (причем во всем этом учитываются не только те поды, которые вы используете непосредственно в вашем приложении, но и их зависимости, которые вы, возможно, напрямую не используете). Таким образом, как ни парадоксально, чем больше доля подов на чистомObjC
, тем меньше взрыв флажков при сборке контекста дебаггера.iago
Спасибо большое за такой развернутый комментарий и проделанную работу! Я знал конечно по своим друзьям, которые работают в Яндексе, что у вас уровень, но это прям целое расследование. Не понимаю почему так мало лайков и комментов, это лучшая статья за последний год точно, но уверен — все сообщество iOS разработчиков вам очень благодарно!
DoubleW
да у меня тоже давно ощущение что xcode тима это такой отстойник-лепрезорий куда сгоняют всех девов кто запороли продуктовые проекты, и оттуда уже не увольняют потому что никто не хочет вообще что либо контрибьютить в xcode
iago
я еще лет 10 назад говорил — я не люблю продукты MS, но у них есть два хороших продукта — это Office и Outlook. По крайней мере я лучше не видел ни у Apple, ни у Google, при всех их проблемах — они ок.
И также — я очень люблю все продукты Apple, но у них есть 2 отстойных продукта — xCode и Apple developer portal (который сейчас developer.apple.com и appstoreconnect.apple.com). Еще iTunes был тем еще Nero burning rom, но слава богу RIP, больше не надо.
Прошло 10 лет, ничего в целом не изменилось.
Помню, как году в 2013 мне ньюкамеры в iOS, особенно дизайнеры, религиозники от Apple, лили в уши — это же Apple, скоро допилят, это же Apple, это все временно. Я тогда усмехался — как же, допилят. Воз и ныне там, к сожалению…