Чому компіляція програм на C++ займає так багато часу?
Липень 18th, 2011

Переклад статті “C++ Compilation Speed“. Кроспост на розробці
Люди часто скаржаться на те, що написаний на C++ код вимагає надто багато часу для компіляції. Настільки багато, що іноді повну збірку великих проєктів доводиться відкладати на ніч.
Повільна компіляція навіть стала однією з причин появи мови Go. Я уже досить давно займаюсь розробкою компіляторів і ця проблема не давала мені спокою. Чому все так довго? Розробники компіляторів C++ — професіонали своєї справи, тому, скоріше за все, причину слід шукати у самій мові програмування. Швидкість роботи різних компіляторів дуже відрізняється, але програми, написані на інших мовах програмування, компілюються однозначно швидше.
Я займаюсь розробкою компіляторів для C++ з 1987. Комп’ютери у ті часи були значно повільніші, ніж зараз, і проблема швидкості компіляції стояла дуже гостро. Чимало часу було витрачено на профілювання на дослідження причин повільної
роботи.
Отже, основними причинами є:
1. Сім фаз трансляції. Вони перераховані у стандарті C++98, пункт 2.1:
- Конвертування триграфів та кодів символів
- Обробка екранованих символів
- Виділення частин, що підлягають обробці (preprocessing tokens, C++98, пункт 2.4)
- Виконання директив препроцесора; розгортання макросів;
- Читання підключених (#include) файлів та пропускання їх чераз попередні фази.
- Конвертування символів всередині строкових та символьних констант
- Поєднання строкових констант
- Виділення мовних одиниць (ідентифікаторів, операторів, ключових слів тощо; англ tokens, C++98 2.6)
Деякі фази можна комбінувати, але все одно вихідний код доводиться проходити не менше трьох разів. Принаймі, мені ніколи не вдавалося впоратись менше, ніж за три проходи. Водночас, для правильно спроектованих мов досить лише одного проходу.
У C++0X ситуація стала ще гірше: там додали так звані “чисті” рядки (raw string literals) всередині яких триграфи та екрановані за допомогою ‘\’ символи мають бути збережені.
У C++0x Standard 2.14.5-4 наведено наступний приклад:
const char *p = R"(a\ b c)"; assert(std::strcmp(p, "a\\\nb\nc") == 0);
2. Кожна фаза залежить від результатів попередньої. Наприклад, немає надійного способу виділення і пошуку підключених файлів, для того щоб асинхронно їх читати. Також компілятор не може глянути вперед, щоб визначити, чи знаходиться даний конкретний триграф всередині чистого рядка. Усі триграфи потрібно конвертувати, при цьому запам’ятавши їх позиції на випадок, якщо щось треба буде повернути назад. Єдиний можливий спосіб розпаралелити процес компіляції — на найвищому рівні, як це робить make з опцією -j.
3. #include передбачає саме вставку тексту, а не результатів обробки, тому компілятору доводиться весь час
обробляти ті самі файли, якщо їх було включено повторно (навіть якщо там був #ifdef). Можливо, десь у стандарті і була згадка про те, що це необов’язково, але я не знаю жодного компілятора, який би так працював.
4. Зазвичай люди включають усі можливі заголовочні файли, що додає компілятору купу зайвої роботи. Підключення стандартної бібліотеки, наприклад, означає, що до вашого коду буде додано ще 37687 рядків з 74 файлів (це не враховуючи повторні включення). Шаблони та розповсюдження ідей узагальненого програмування ще більш погіршують ситуацію.
5. Значення будь-якої синтаксичної та семантичної одиниці залежить від коду, що був до неї. Немає жодного елементу, незалежного від контексту. Відповідно, немає можливості проводити попередню обробку заголовочних файлів — для цього треба обробити інші підключені файли. Хедери можуть мати інший зміст, коли їх підключають повторно (ця особливість іноді використовується)
6. Через причину, вказану у попередньому пункті, компілятор не може використати результат обробки підключеного файлу для компіляції інших одиниць трансляції (одиниця трансляції — це один cpp-файл; в результаті його обробки eтворюється один об’єктний файл. Згодом з купи об’єктних файлів лінкер ліпить програму) з таким самим #include. Завжди доводиться усе повторювати спочатку.
7. Оскільки різні одиниці трансляції компілюються окремо, часто вживані однакові шаблони генеруються багато разів. Лінкер потім прибирає увесь код, що дублюється, але краще було б взагалі не витрачати час на його генерування.
Прекомпільовані заголовочні файли (precompiled headers) частково вирішують вказані проблеми, але при цьому порушуються вимоги стандарту. Наприклад, передбачається, що при повторному включенні хедер матиме той самий зміст (що насправді не гарантується, варто про це пам’ятати).
Буде дуже важко виправити усі ці вади і при цьому не порушити сумісність з уже написаним кодом. Можливо, вдасться чогось досягти у стандарті, що прийде на зміну C++0X, але це буде ще років через десять.
А поки що все дуже сумно. Експортовані шаблони (export templates) застаріли і не підтримуються, прекомпільовані хедери не відповідають стандарту, imports не затверджені у C++0X… Тому єдиним дієвим способом прискорення є використання make -j.





Цікаво! Також завжди цікавило цепитання.