У чому насправді проблема, коли твій проєкт повільно компілюється на сучасному «залізі»
Всім привіт! Мене звати Павло, я розробник, і маю щастя спіпрацювати з компанією MacPaw. Я в більшості маю справу з проектом CleanMyMac X, який є доволі великим macOS-застосунком, написаним, в основному, на Objective-C та Swift. Це складний, достатньо зрілий проєкт з багатьма залежностями.
Як і будь-який інший великий проєкт, він має свої власні проблеми, такі, наприклад, як повільна швидкість компіляції. Свого часу ми в команді реалізували різні рішення для прискорення процесу компіляції: інкрементальні збірки, попередньо зібрані фреймворки, оптимізацію налаштувань збірки та структури проєкту, максимізацію паралельної компіляції тощо. Мушу сказати, що це дозволило скоротити час збірки до задовільного рівня.
Однак, в проєкті була дуже цікава проблема, яка достатньо довго турбувала мене. Компіляція декількох конкретних файлів у проєкті займала невиправдано довгий час (так, розумію, все відносно). Я витратив досить багато часу на пошук причини. І ось, що я знайшов.
Ця історія почалася в наприкінці 2019 року...
Проблема
CleanMyMac X розбитий на декілька модулів, відповідно до функціональності. У нас є утиліти, прошарок рівня моделей, а також функціональні модулі, які побудовані на основі базових. На самому верхньому рівні ми маємо декілька застосунків, у яких власний UI та бізнес-логіка.
Ось тут трошки спрощена діаграма структури проєкту:
Так ось, коли збирався проєкт, більшість таргетів з нижніх рівнів збиралися дуже швидко, як і має бути, але на на рівнях повище, десь в районі модулів функціонального рівня (Functional Module X), з’являлися декілька файлів, які компілювалися занадто довго.
Ну, скажіть, хто очікує, що один .m файл буде компілюватися 40(!) секунд на сучасних (на той момент) лептопах? Особливо, коли в файлі менше 10 рядків коду, і половина — коментарі. В той самий час, коли інші модулі компілюються
Швидкість компіляції деяких файлів з Functional Module X
Мабуть, єдине, що в цій ситуації було позитивного — це те, що це траплялося виключно на чистих збірках. Інкрементальні збірки були швидкими, і таких проблем не виникало. Але кожен розробник Xcode знає, що дуже багато речей можна виправити, виключно почистивши DerivedData.
Розслідування, частина 1. Спляча Красуня
З того, що мені було відомо, — clang (компілятор, що використовує Xcode для Objective-C коду) для компіляції кожного .m файлу створює окремий процес. Тому я вирішив подивитися, що саме відбувається під час збірки з цими процесами.
І от те, що я побачив, було дуже неочікуваним. 16 процесів clang з гордістю висіли в менеджері задач і виконували належну роботу. Якщо бути точнішим, то вони спали. Всі, крім одного. Той самий, «обраний», виконував якусь роботу і навантажував лище одне ядро CPU на ~66%.
|
|
Нутрощі Clang
Я зробив sample clang-процесів і перевірив стек викликів. Зі стеку було зрозуміло (ха-ха), що більшість процесів на щось чекають і, відповідно, поки нема що робити — спокійно сплять.
Було досить складно зрозуміти, що саме відбувається, оскільки в стеку викликів не було імен функій та методів — лише їх адреси. А, зважаючи на те, що Xcode — це closed source, то файли з дебаг-інформацією було годі й шукати. Тому через деякий час, після довгого гугління, я вирішив, що мені потрібно мати свій власний clang з БлекДжеком та дебаг-логами.
Я знав, що clang — це open source проєкт, тому, знову після гугління, я склонував репозиторій з GitHub і почав пробувати його зібрати.
Після декількох днів гри з документацію, запитів на StackOverflow та обговорень в приватних повідомленнях, мені нарешті вдалося зібрати свою власну версію clang. Ох, це був той ще досвід.
Найскладнішим з усього було зрозуміти, які параметри треба «згодувати» cmake, щоб зібрати проєкт. LLVM — це дуже великий проєкт, який підтримує дуже багато різних систем збірки, архітектур та платформ. Тому моїм основним завданням було прибрати все непотрібне і залишити тільки важливі для мене речі (власне clang).
Десь в глибинах мого попереднього досвіду я пам’ятав, що для того, щоб підмінити компілятор для проєкту в Xcode, достатньо змінити дві змінні в `xcconfig` файлі:
CC=~/Projects/llvm/install/bin/clang CCX=~/Projects/llvm/install/bin/clang++
Звичайно, з першого разу нічого не спрацювало ¯\_(ツ)_/¯. Мене завалило десятками помилок на кожен файл, до того ж помилки не були достатньо інформативними (з першого погляду, звичайно). Компілятор лаявся на те, що він не розуміє якихось прапорців, що йому підсовує Xcode. Щось завертілося в моїй голові, і я зрозумів, що «Це не ̶ті̶ ̶д̶р̶о̶ї̶д̶и̶ той, clang який я шукаю».
Я згадав, що у Apple є власна версія clang. І, чесно кажучи, в той момент ця думка мене дещо засмутила, оскільки Apple часто не ділиться своїми кодом. Але, на щастя, версія clang, яку використовує Xcode, була з відкритим кодом, тому я просто повторив все з новим репозиторієм.
Через ще декілька годин мені нарешті вдалося зробити так, щоб проєкт успішно збирався з моєю версією clang. Настав час перевіряти, хто там і чому вирішив товкти воду в ступі.
Блокування, блокування, блокування
З власним компілятором справи пішли трошки швидше. Тепер я міг бачити назви функцій в стеку викликів процесів. Швидко стало зрозуміло, що всі процеси чекають на одне й те саме блокування (lock).
Трошки детальніше, про те, що відбувалося. Коли Xcode починав збирати Module X, він запускав багато clang процесів — по одному для кожного .m файла в модулі. Щоб зібрати Module X, спочатку треба було зібрати Module1 та Module2.
Загалом, мої очікування були такими, що коли Module X почне збиратися, всі його залежності будуть вже зібрані, і не треба буде їх збирати знову. Але щось було не так.
Коли clang бачить залежність файлу, який він компілює, наприклад @import Module1, то він спочатку перевіряє, чи вже є необхідна інформація про інтерфейс Module1. Якщо така інформація є, то clang одразу її використовує. Якщо ж такої інформації немає, то доводиться чекати, поки хтось її збере, або збирати самому. Щоб не збирати цю інформаціюю багато разів, clang використовує блокування на рівні файлів.
Cхема блокування при доступі до загального ресурсу була зрозуміла, але в реалізації було декілька нюансів. Кожен раз, коли процес не міг отримати доступ до залежності через блокування, він спав. І час цього сну збільшувався з кожною невдалою перевіркою.
Ось декілька рядків з коду LockFileManager.cpp:
/// For a shared lock, wait until the owner releases the lock. /// Total timeout for the file to appear is ~1.5 minutes. /// \param MaxSeconds the maximum total wait time in seconds. WaitForUnlockResult waitForUnlock(const unsigned MaxSeconds = 90); WaitMultiplier *= 2; if (WaitMultiplier > MaxWaitMultiplier) { WaitMultiplier = MaxWaitMultiplier; }
Це призводило до того, що навіть коли залежність була розблокована, процес міг продовжувати спати ще декілька секунд. А через те, що таких залежностей було багато, і вони могли мати дуже велику вкладеність, це призводило до того, що час очікування збільшувався, а за ним і час на сон.
Мені тоді, чомусь, ця ситуація нагадала ситуацію з фільму Braveheart.
Рішення
Що ж, єдине рішення, яке мені прийшло того часу на думку, — створити Pull Request до репозиторію llvm. Я, звичайно, очікував, що буде достатньо просто створити PR на Github і перевірити, а потім змержити. Але `llvm` має досить складний процес внесення змін: треба створити патч і надіслати його на поштову розсилку, потім знайти того, хто цей патч подивиться, потім чекати на ревью, і потім ще чекати, поки хтось його, можливо, змеджить.
Не зважаючи на це, я все ж таки вирішив спробувати. Я подумав, як можна зменшити час сну, при очікуванні. Через те що в llvm використовувалися блокування на рівнів файлів, я подумав, що має бути спосіб підписатися на системні повідомлення при видаленні файлу блокування.
Я знайшов бібліотеку kqueue, яка, власне, і реалізовувала цю функціональність. Вона дозволяла підписатися на повідомлення при змінах у файловій системі. І, що не менш важливо, ця бібліотека була доступна на macOS, тому не треба було тягнути додаткові залежності.
Мені вдалося створити патч до репозиторію llvm. В ньому, замість того, щоб спати при очікуванні на розблокування, clang підписувався на системні повідомлення про файл блокування. Як тільки файл блокування зникав, clang відразу ж продовжував роботу.
Маю сказати, що це був досить важкий процес для мене з декількох причин:
- llvm написаний на C++, а я от зовсім, ніяк не C++ розробник. Ніколи й ніде не вивчав С++;
- llvm — це велетенський проєкт, тому пошуки потрібних місць в коді були досить складними;
- я це робив у свій вільний час, і це накладало деякі обмеження;
- процес внесення змін до репозиторію був для мене новим і, маю сказати, не зовсім прозорим і зрозумілим.
Хоч би там як, я дотиснув себе і залив патч на llvm reviews site. Якимось чином я знайшов потрібних людей, і навіть зробив пару підходів з оптимізацією і рефакторингом.
Стан на сьогодні
На жаль, мої змінні не були втягнуті до проєкту. Виявилося, що був інший PR, який також вирішував проблему, але трохи інакше. Один з розробників, Ladd, запропонував просто обмежити максимальний час, коли компілятор буде спати, поки очікує на розблокування.
// Since we don't yet have an event-based method to wait for the lock file, // implement randomized exponential backoff, similar to Ethernet collision // algorithm. This improves performance on machines with high core counts // when the file lock is heavily contended by multiple clang processes const unsigned long MinWaitDurationMS = 10; const unsigned long MaxWaitMultiplier = 50; // 500ms max wait unsigned long WaitMultiplier = 1; unsigned long ElapsedTimeSeconds = 0;
Ну, що ж, маю сказати, що це — гарне рішення. Просте, зрозуміле і не вимагає використання складної логіки з використанням системних файлових повідомлень. На поточний момент саме це рішення і використовується в llvm.
Що стосується мого патчу, то він все ще відкритий. І ви спокійно можете його переглянути, а може, якщо буде бажання, пропушити 🙂. Я найближчим часом лізти до
Висновки
Впродовж цієї історії я отримав багато досвіду, багато що побачив і потицяв паличкою. Можливо, навіть чомусь навчився. Коли справді хочеться докопатися до суті, тебе не зупиняють складнощі на кшталт незнайомої мови програмування, великого проєкту, якихось невідомих раніше складних процесів. А відчуття, коли насправді вдається докопатися до суті (хє-хє) — складно з чимось порівняти.
Тоді я продовжив використовувати свою версію clang для себе. Я запропонував її використовувати в нашій команді, але підтримка такого рішення була досить складною, тому ми вирішили просто почекати, поки Apple не оновить clang в новій версії Xcode. Profit.
Кінець...?
Розслідування, частина 2. Це має бути швидко, правда?
Швидко переносимося в
Проєкт ріс, більшість частин переписані, Apple випустила нову систему збірки. Я тепер мав M1 Macbook Pro, який просто неймовірно швидкий. Очікувано, що компіляція проєкту мала б теж стати швидше, правда?
Нове «залізо», старі проблеми
Зараз XXI століття. Скільки часу ви готові витратити на очікування компіляції проєкту на топовому «залізі»? 5 хвилин? Може, 10 хвилин? Чи годину? Звичайно, все залежить від розміру проєкту, але є якась верхня межа, досягаючи якої ви розумієте, що треба щось міняти.
В цілому ми, як розробники, завжди хочемо мати швидкий feedback loop. Чим швидше ми можемо побачити результати своїх змін, тим швидше ми можемо піти пити каву. Я, наприклад, коли бачу, що цей feedback loop занадто довгий — лізу розбиратися.
Пам’ятаєте, скільки часу clang витрачав на компіляцію деяких файлів в проєкті в далекому 2019? Так ось, ця проблема все ще залишалася. Звичайно, розмір проблеми зменшився, за рахунок нового «заліза», але проблема була.
В CleanMyMac X є багато таргетів, і більшість з них компілювалася швидко, але деякі з них пригальмовували, і досить сильно. При компіляції деяких файлів компілятор наче підвисав і витрачав багато часу (ніколи такого не було, і ось — на тобі). Це було дивно, тому що більшість файлів в схожих таргетах компілювалися майже моментально, як 20 файлів за секунду, але ці — компілювалися декілька десятків секунд.
Десятки секунд на один файл? На Apple M1 в 2023? Серйозно?
Час компіляції деяких файлів на M1 Pro
Вузьке місце
Через те, що я був знайомий з проблемою і вже знав, куди дивитися, я відновив проєкт і почав розслідування. Не витративши багато зусиль, я відносно швидко знайшов метод, який був відповідальним за проблему з підвисанням.
compileModuleAndReadAST( CompilerInstance &ImportingInstance, SourceLocation ImportLoc, SourceLocation ModuleNameLoc, Module *Module, StringRef ModuleFileName) {
Тепер я знав, що компілятор намагався зібрати модуль. Але що таке модуль і чому його збірка займає так багато часу?
Модулі в clang
Офіційна документація досить гарно пояснює, що є модуль, але хто ж її читає. Якщо коротко, то модуль — це спосіб організації вашого інтерфейсу бібліотеки. Бібліотека або фреймворк можуть надавати модуль. Якщо це велика бібліотека, Вона надає деклька модулів. Якщо дуже спростити, то модуль для clang — це структурована інформація про інтерфейс бібліотеки.
Коли файл залежить від якогось фреймоврка і ви пишете @import ModuleX, то компілятор шукає відповідний модуль (інтерфейс фреймворка) і не просто препроцесить його, як було раніше у випадку з файлами .h, а прекомпілює його у спеціальний формат, який потім використовується як є, без додаткових перетворень, при генерації коду вашого файлу, який компілюється.
Прекомпільовані модулі зберігаються в кеші в PCM форматі, і перевикористовуються для всіх файлів, які залежать від відповідного фреймворка або бібліотеки. Такі PCM-файли ствоюються для кожної залежності, байдуже, чи це системний фреймворк, чи ні.
.build/DerivedData/ModuleCache.noindex/3GVC60OTD7R0S/Foundation-1XMN11U6GYNC8.pcm .build/DerivedData/ModuleCache.noindex/3GVC60OTD7R0S/AppKit-1XMN11U6GYNC8.pcm
Чому ж так довго
На той момент, коли збираються таргети верхнього рівня, наприклад, CleanMyMac X, всі модулі залежностей вже мали б бути зібрані і лежати в кеші. Що ж тоді збирає компілятор, і чому це займає так багато часу? І чому це відбувається не в усіх таргетах, а тільки в деяких?
В мене було декілька основних припущень, які я вирішив перевірити:
- Якимось чином кеш інвалідується при збірці таргетів верхнього рівня. Наприклад, через якісь дивні рейс-кондішини. Через це доводиться робити повну або часткову перезбірку модулів.
- Деякі таргети верхніх рівней не перевикористовують кеш.
Інвалідація кешу
Спочатку я поліз розбиратися, що може стати причиною інвалдації PCM-кешу, і чому він не може (а мав би) бути перевикористаним. Я поліз в код і побачив, що при генерації PCM-файлу формується унікальний підпис, який використовується для інвалідації кешу. Цей підпис по факту генерується на основі кожного біту інтерфейсу модуля. Тобто навіть найменша зміна в модулі може призвести до інвалідації кешу.
Копнувши глибше, я зрозумів, що якщо я почну лізти в алгоритм формування бінарного коду модуля, то мені доведеться осягнути величезну частинку кодової бази. Тому я спробував підійти до проблеми з іншого боку. Я розмірковував так: можна просто дивитися на PCM-файли під час збірки і якщо вони поміняються, то проблема в інвалідації кешу.
Різниця в PCM files
Дивитися різницю двох бінарних PCM-файлів мені здалося не найкращою ідеєю, і тому, покопавшись в документації, я знайшов команду, яка дозволяла вивести інформацію про PCM-файл в читабельному форматі.
clang -module-file-info <path to PCM file>
Ось приклад gist виводу для AppKit модуля.
Я написав (так-то запитав у ChatGPT) скріпт, який перевіряв, чи змінюється PCM-файл або інформація про нього при збірці. Перезібрав проєкт декілька раз і побачив, що PCM-файли не змінюються. Як тільки вони були зібрані, більше вони не мінялися.
Однак, я знайшов, що різні таргети використовували PCM-файли з різних директорій. Назви файлів в цих директоріях були однакові, але от вміст файлів дещо різнився.
./.build/DerivedData/ModuleCache.noindex/ 25WGJ3ZHJ6332 /Foundation-1XMN11U6GYNC8.pcm ./.build/DerivedData/ModuleCache.noindex/ 3UU6V472V783N /Foundation-1XMN11U6GYNC8.pcm ./.build/DerivedData/ModuleCache.noindex/ H6DQB240ZJHB /Foundation-1XMN11U6GYNC8.pcm ./.build/DerivedData/ModuleCache.noindex/ 3B85C81QBGQUK /Foundation-1XMN11U6GYNC8.pcm ./.build/DerivedData/ModuleCache.noindex/ 3MW80CL1WV63C /Foundation-1XMN11U6GYNC8.pcm ./.build/DerivedData/ModuleCache.noindex/ 3GVC60OTD7R0S /Foundation-1XMN11U6GYNC8.pcm
Порівнюючи файли, побачив наступне:
< -DNDEBUG=1 < -DUSE_DEBUG_LOGGING=1 --- > -DNDEBUG=0 > -DUSE_DEBUG_LOGGING=0
Там були й інші зміни, але вони здебільшого просто включали в себе шляхи до директорій, де ці файли знаходилися. В цей момент мій мозок мені спробував натякнути, що можливо, я колись чув про PCM і прапорці компіляції в одному реченні.
Я пішов читати документацію і знайшов, що шукав. Нарешті.
Нотатка стосовно особливостей коміляції модулів
Знідно з документацією, PCM-файли будуть використовуватися як кеш, якщо прапорці препроцесора не змінюються. В нашому ж випадку, майже кожен таргет використовував свої прапорці препроцесора. Ми їх використовували для деяких констант, для перемикання функціоналу, для деяких інших речей. В результаті, у нас було дуже багато різних кешів — під кожен унікальний набір прапорців препроцесора.
Пояснення
Що ж, в результаті вібувалося? Більшість таргетів використовували один і той же набір прапорців препроцесора. І, відповідно, всі залежності цих таргетів попадали в один і той самий кеш. Але коли процесор доходив до таргетів, які використовували інший набір прапорців, то доводилося перебудовувати весь кеш PCM для всіх залежностей, починаючи з системних фреймворків, і закінчуючи нашими модулями.
Директорії в ModuleCache
Вміст однієї з директорій (всі залежності)
Рішення
Рішення було досить простим. Довелося «просто» перевірити всі таргети і їх прапорці препроцесора, і зробити їх максимально ідентичними для всіх. Додатково виявилося, що в Xcode для цього є спеціальний прапорець GCC_PREPROCESSOR_DEFINITIONS_NOT_USED_IN_PRECOMPS. Цей прапорець працює так само, як і GCC_PREPROCESSOR_DEFINITIONS, але його значення не впливають на те, який PCM кеш використовувати. Якщо копнути ще, глибше, то можна побачити, що Xcode додає прапорець fmodules-ignore-macro= для кожного прапорця препроцесора, який вказаний в GCC_PREPROCESSOR_DEFINITIONS_NOT_USED_IN_PRECOMPS.
Результати
Вирішив перевірити, чи це дійсно допомогло. Я запустив бенчмарк на всьому проєкті CleanMyMac X за допомогою інструменту hyperfine.
Чесно кажучи, я був дуже приємно здивований результатами. В 2 рази швидше в Debug конфігурації, і майже на 25% швидше в Release!
Приблизно так виклядає графік з часом компіляції файлів в Xcode, до і після змін (однакові модулі підсвічені однаковим кольором).
Графік з часом компіляції
Висновки
Це була дуже цікава подорож. Вона почалася з простого запитання 4 роки тому і закінчилася (сподіваюся) цією статтею. Сподіваюся, вам сподобалося, і ви, може, знайшли для себе щось нове, а може навіть зарядилися для пошуку причин багів на ваших проєктах.
Сам баг був цікавий тим, що не мав жодних очевидних симптомів. Проєкт якось збирався, а ми звикли до часу збірки, нас вона влаштовувала. Ну, майже всіх :)
Сучасне «залізо» душе швидке. Тож якщо ваш проєкт компілюється за 10 хвилин, тому що «завжди було так», то ви точно повинні спробувати перевірити, чи це дійсно так. Навіть якщо це вимагатиме від вас дебагу компілятора.
Маю подякувати пану Олексію Денисову за всю допомогу і підтримку. Навряд чи ця стаття була б написана без його допомоги.
Якщо у вас є питаннячка, пишіть в Twitter @PaulTaykalo.
Цікаві посилання
* Clang Modules — тут документація.
* Hyperfine — тут інструмент для бенчмарку.
* MacPaw — тут компанія, яку я люблю, і яка розробляє CleanMyMac X.
29 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів