Часова складність компілятора


54

Мене цікавить часова складність компілятора. Зрозуміло, що це дуже складне питання, оскільки існує багато компіляторів, варіантів компілятора та змінних. Зокрема, мене цікавить LLVM, але мене зацікавлять будь-які думки у людей чи місця для початку досліджень. Досить гугл, здається, мало виводить на світ.

Я гадаю, що є кілька кроків оптимізації, які є експоненціальними, але мало впливають на фактичний час. Наприклад, експоненціальна кількість, заснована на кількості, є аргументами функції.

Я б сказав, що генерування дерева AST буде лінійним. Генерація ІЧ потребує переходу через дерево під час пошуку значень у постійно зростаючих таблицях, тому або . Генерація коду та зв'язування були б аналогічним типом операцій. Отже, моєю здогадкою було б , якби ми видалили експоненти змінних, які реально не зростають.O ( n log n ) O ( n 2 )O(n2)О(нжурналн)О(н2)

Я можу бути абсолютно помиляюся. Хтось має на це думки?


7
Ви повинні бути обережними, коли ви заявляєте, що що-небудь є "експоненціальним", "лінійним", або O ( n log n ) . Принаймні для мене, це зовсім не очевидно, як ви вимірюєте свій внесок (Експонентність у чому? Що означає n ?)О(н2)О(нжурналн)н
Juho

2
Коли ви говорите LLVM, ви маєте на увазі Кланг? LLVM - це великий проект з кількома різними підпроектами компілятора, тому це трохи неоднозначно.
Нейт СК

5
Для C # це, щонайменше, експоненціально для найгірших випадків (ви можете закодувати NP повну проблему SAT в C #). Це не просто оптимізація, вона потрібна для вибору правильного перевантаження функції. Для такої мови, як C ++, це не можна визначити, оскільки шаблони закінчуються.
CodesInChaos

2
@Zane, я не розумію твою думку. Ідентифікація шаблону відбувається під час компіляції. Ви можете кодувати важкі проблеми в шаблони таким чином, що змушує компілятор вирішити цю проблему для отримання правильного результату. Ви можете вважати компілятор інтерпретатором цілої мови програмування шаблонів.
CodesInChaos

3
Роздільна здатність перевантаження C # досить складна, коли ви поєднуєте кілька перевантажень з лямбда-виразами. Ви можете використовувати це для кодування булевої формули таким чином, що визначення, чи є застосовна перевантаження, вимагає проблеми NPS-3SAT. Щоб фактично скласти проблему, компілятор повинен насправді знайти рішення для цієї формули, що може бути навіть складніше. Ерік Ліпперт докладно розповідає про це у своєму дописі до блогу « Lambda Expressions vs. Anonymous Methods», частина п'ята
CodesInChaos

Відповіді:


50

Найкращою книгою для відповіді на ваше питання, напевно, були б: Купер і Торксон, "Інженерія компілятора", 2003. Якщо у вас є доступ до університетської бібліотеки, ви повинні мати можливість запозичити копію.

У виробничому компіляторі на зразок llvm чи gcc дизайнери докладають усіх зусиль, щоб усі алгоритми збереглися нижче де n - розмір вводу. Для деяких аналізів для етапів "оптимізації" це означає, що вам потрібно використовувати евристику, а не виробляти справді оптимальний код.О(н2)н

Лексер - це машина з кінцевим станом, тому розміром вхідного сигналу (в символах) і виробляє потік O ( n ) лексем, який передається в парсер.О(н)О(н)

Для багатьох компіляторів для багатьох мов аналізатор є LALR (1) і, таким чином, обробляє потік токенів за часом у кількості вхідних лексем. Під час розбору вам, як правило, слід відслідковувати таблицю символів, але для багатьох мов це можна обробляти стеком хеш-таблиць ("словники"). Кожен доступ до словника - O ( 1 ) , але, можливо, вам доведеться час від часу ходити стеком, щоб шукати символ. Глибина штабелю дорівнює O ( s ), де s - глибина вкладення областей. (Отже, на мовах подібних С, скільки шарів фігурних брекетів ви перебуваєте всередині.)О(н)О(1)О(с)с

Тоді дерево розбору, як правило, "сплющується" в графіку контрольного потоку. Вузли графіка потоку управління можуть бути інструкціями з 3-ма адресами (аналогічно мові збірки RISC), і розмір графіку потоку управління зазвичай буде лінійним за розміром дерева аналізу.

О(г)гО(н)н

О(н2)час у розмірі діаграми потоку всієї програми, але це означає, що вам потрібно обійтися без інформації (та покращення програми перетворень), що може бути дорого довести. Класичним прикладом цього є аналіз псевдоніму, де для деякої пари записів пам'яті ви хочете довести, що два записи ніколи не можуть націлюватись на одне місце пам’яті. (Ви можете зробити аналіз псевдоніму, щоб побачити, чи можете ви перенести одну інструкцію над іншою.) Але для отримання точної інформації про псевдоніми вам може знадобитися проаналізувати кожен можливий шлях управління через програму, який є експоненціальним у кількості гілок в програмі (і, таким чином, експоненціальна в кількості вузлів на графіку потоку управління.)

Далі ви потрапляєте в розподіл реєстру. Виділення регістрів може бути виражене як проблема забарвлення графіків, а забарвлення графіка з мінімальною кількістю кольорів, як відомо, є NP-Hard. Тож більшість компіляторів використовують якусь жадібну евристику в поєднанні з розливом регістру з метою максимально скоротити кількість розливів у регістрі за розумні часові рамки.

Нарешті ви переходите до генерації коду. Генерація коду, як правило, виконується максимальним базовим блоком у той час, коли базовий блок - це набір лінійно пов'язаних вузлів діаграми керуючого потоку з одним записом та одним виходом. Це можна переформулювати як графік, що охоплює проблему, де граф, який ви намагаєтеся прикрити, - це графік залежності набору 3-адресних інструкцій у базовому блоці, а ви намагаєтесь накрити набором графіків, які представляють наявну машину інструкції. Ця проблема є експоненціальною в розмірі найбільшого основного блоку (який, в принципі, може бути такого ж порядку, як і розмір всієї програми), тому це, як правило, робиться з евристикою, де лише невеликий підмножина можливих покриттів обстежували.


4
Третє! Між іншим, багато проблем, які компілятори намагаються вирішити (наприклад, розподіл реєстру), є важкими NP, але інші формально не можна визначити. Припустимо, наприклад, у вас є дзвінок p (), за яким слідує виклик q (). Якщо p - чиста функція, то ви можете сміливо упорядкувати виклики до тих пір, поки p () не буде безмежно циклічно. Доведення цього вимагає вирішення проблеми зупинки. Як і у випадку з важкими проблемами, пов'язаними з NP, автор-компілятор міг би докласти стільки ж, скільки мало зусиль для наближення рішення, наскільки це можливо.
Псевдонім

4
О, ще одне: Є деякі типи систем, які сьогодні використовуються, які є теоретично дуже складними. Висновки типу Хіндлі-Мілнера відомі для завершення DEXPTIME, і мови, схожі на ML, повинні правильно його реалізувати. Однак час запуску на практиці лінійний, оскільки: а) патологічні випадки ніколи не трапляються в реальних програмах; б) програмісти реального світу, як правило, вводять анотації типу, якщо тільки для отримання кращих повідомлень про помилки.
Псевдонім

1
Чудова відповідь, єдине, що здається відсутнім, - це проста частина пояснення, прописана простими словами: Складання програми можна зробити в O (n). Оптимізація програми перед компіляцією, як це робив би будь-який сучасний компілятор, - це завдання, яке практично не обмежене. Час, який він насправді потребує, не регулюється жодним властивим обмеженням завдання, а скоріше практичною потребою компілятора закінчити в якийсь момент, перш ніж люди втомиться чекати. Це завжди компроміс.
aaaaaaaaaaaa

@ Псевдонім, той факт, що багато разів компілятору доводиться вирішувати проблему зупинки (або дуже неприємні важкі проблеми НП), є однією з причин, за якими стандарти дають автору компілятора свободу, припускаючи, що не визначена поведінка не відбувається (як нескінченна кількість циклів і таке ).
vonbrand

15

Насправді деякі мови (наприклад, C ++, Lisp та D) під час компіляції закінчують Тьюрінг, тому їх компілювання взагалі не можна визначити. Для C ++ це відбувається через рекурсивне інстанціювання шаблону. Для Lisp і D ви можете виконати майже будь-який код під час компіляції, тому ви можете кинути компілятор у нескінченний цикл, якщо хочете.


3
Системи типу Haskell (з розширеннями) і Scala типу також є повним Тьюрінгом, що означає, що перевірка типу може зайняти нескінченну кількість часу. Тепер Scala також має макроси, що мають повний Тьюрінг.
Йорг W Міттаг

5

Зі свого фактичного досвіду компілятора C #, я можу сказати, що для певних програм розмір вихідного двійкового файлу зростає в експоненціальному відношенні до розміру вхідного джерела (це фактично потрібно специфікацією C # і не може бути зменшено), тому складність у часі повинні бути принаймні експоненціальними.

Загальна задача щодо вирішення перевантаження в C #, як відомо, є важкою для NP (а реальна складність реалізації - принаймні експоненціальна).

Обробка коментарів документації XML в джерелах C # також вимагає оцінки довільних виразів XPath 1.0 під час компіляції, тобто також експоненціальної, AFAIK.


Що змушує двійкові файли C # підірватися таким чином? Мені звучить мовна помилка ...
vonbrand

1
Це спосіб кодування загальних типів у метаданих. class X<A,B,C,D,E> { class Y : X<Y,Y,Y,Y,Y> { Y.Y.Y.Y.Y.Y.Y.Y.Y y; } }
Володимир Решетніков

-2

Виміряйте це за допомогою реалістичних баз коду, таких як набір проектів з відкритим кодом. Якщо результати проектуються як (codeSize, FinishTime), ви можете побудувати ці графіки. Якщо ваші дані f (x) = y є O (n), то графік g = f (x) / x повинен дати вам пряму лінію після того, як дані почнуть набирати великі розміри.

Діаграма f (x) / x, f (x) / lg (x), f (x) / (x * lg (x)), f (x) / (x * x) тощо. Графік буде або зануренням відключити до нуля, збільшити без прив’язки або вирівняти. Ця ідея корисна для таких ситуацій, як вимірювання часу вставки, починаючи з порожньої бази даних (тобто: шукати "витік продуктивності" протягом тривалого періоду).


1
Емпіричне вимірювання тривалості роботи не встановлює складності обчислень. По-перше, обчислювальна складність найчастіше виражається через найгірший час роботи. По-друге, навіть якщо ви хочете виміряти якийсь середній випадок, вам потрібно буде встановити, що ваші входи в цьому сенсі "середні".
Девід Річербі

Я впевнений, що це лише оцінка. Але прості емпіричні тести з великою кількістю реальних даних (кожне зобов’язання за купу git repos) цілком можуть перемогти ретельну модель. У будь-якому випадку, якщо функцією дійсно є O (n ^ 3), і ви побудуєте f (n) / (n n n), вам слід отримати шумну лінію зі нахилом приблизно нуля. Якби ви побудували лише графік O (n ^ 3) / (n * n), ви побачили б, що це лінійно зростає. Це дійсно очевидно, якщо ви завищуєте і спостерігаєте, як лінія швидко піде на нуль.
Роб

1
Θ(нжурналн)Θ(н2)Θ(нжурналн)Θ(н2)

Я погоджуюся, що це те, що вам потрібно знати, якщо ви турбуєтесь про те, щоб отримати відмову в сервісі від зловмисника, який дасть вам поганий внесок, роблячи критичний аналіз введення в реальному часі. Справжня функція, яка вимірює час компіляції, буде дуже галасливою, і справа, яка нас хвилює, буде знаходитись у реальних сховищах коду.
Роб

1
Ні. Питання задає часову складність проблеми. Це, як правило, інтерпретується як найгірший час запуску, який, очевидно, не є часом роботи коду в сховищах. Запропоновані вами тести дозволяють зрозуміти, як довго ви можете розраховувати на те, що компілятор прийме певний код, що добре і корисно знати. Але вони майже нічого не говорять про обчислювальну складність проблеми.
Девід Річербі
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.