- PVSM.RU - https://www.pvsm.ru -

Развенчиваем популярные мифы и заблуждения о компиляторах

Развенчиваем популярные мифы и заблуждения о компиляторах - 1

▍ Введение

1 [1]

компиляторных оптимизациях [2]. Я перечислю некоторые заблуждения, с которыми я сталкивался за долгие годы (многие из них были моими), и постараюсь развеять все мифы. Заранее скажу, что эта статья посвящена только крупным популярным компиляторам общего назначения наподобие LLVM, GCC и ICX. Некоторые из сделанных здесь утверждений не относятся, например, к специализированным компиляторам2 [3], а также к мелким и средним компиляторам3 [4].

▍ Оптимизация создаёт оптимальные программы

Во-первых, нужно определиться: оптимальные по какой метрике? Например, метрикой может быть размер кода. Но обычно в качестве метрики компиляторы выбирают время выполнения (то есть задержки единичного прогона программы). Впрочем, как бы то ни было, «оптимизация» — это неверный термин. Компиляторы даже не пытаются найти оптимальную программу. Вместо этого они сначала опускают уровень абстракции программы очень простым способом [5], а затем просто пытаются улучшить программу. «Улучшение» — гораздо более слабый термин, чем «оптимизация», зато он точнее.

Но почему мы не получаем оптимальную программу? Это очень сложная и очень медленная задача. При оптимизации под размер кода у нас есть хоть какая-то надежда получить результат за разумное время, потому что размер кода как метрика обладает множеством удобных свойств. Например, мы можем измерять размер кода чрезвычайно точно и надёжно; по сути, это размер файла сгенерированной программы. Кроме того, разные измерения дают в точности одинаковое значение (например, если мы измерим размер сейчас и через минуту). Наконец, размер кода обладает оптимальной подструктурой [6]. ДОПОЛНЕНИЕ: на самом деле, похоже, я здесь ошибся.
См. комментарий [7]. Надеюсь, у меня будет время вернуться к этому вопросу.

1 [8], 2 [9]] показала, что нечто, кажущееся невинным, например, структура программы, способно вызывать колебания результатов бенчмарков в пределах 40%). Более того, улучшение времени выполнения не обладает оптимальной подструктурой. Например, у вас есть оптимальная версия с двумя отдельными циклами, но глобальный минимум может потребовать слияния этих двух циклов. Наконец, «у нас совершенно нет надлежащих моделей машин, для которых мы выполняем компиляцию» [10]. В последнее время исследователи выяснили это на собственной шкуре. goSLP [11] генерирует глобально оптимальный SLP-векторизованный код. Но оптимизированный согласно чему? Согласно моделям оборудования. А поскольку эти модели плохи4 [12], авторы обнаружили, что сгенерированные программы не только неоптимальны, но и могут быть медленнее, чем LLVM [13]!

▍ Веса ветвей и блок предсказания ветвления CPU

В большинстве современных компиляторов, в том числе в GCC, LLVM и ICX, условное ветвление может иметь веса. По сути, это вероятности того, будет ли выбрана ветвь. Такие веса добавляются или оптимизацией на основе профилей (PGO), или программистом при помощи встроенных функций наподобие __builtin_expect:

if (__builtin_expect(x == 0, 1)) { // Вероятность перехода по этой ветви велика
}

5 [14]. Вы можете добавлять branch hint в ассемблерный код, но CPU просто их игнорирует.

Так для чего же используются эти hint/веса? Это подсказки для «блока предсказания ветвления» компилятора, который в основном применяется для эффективного размещения блоков кода. Например, если вероятность ветвления высока, компилятор помещает целевой блок непосредственно под текущим, чтобы выполнение «опустилось» к нему, повышая локальность кэша команд.

6 [15]. То есть теоретически компилятор может генерировать такие подсказки для этой архитектуры.

7 [16]:

; Source C code:
;
; void foo(int x, int y, int *p) {
;   if (__builtin_expect(x == 0, 1)) {
;     *p = y;
;   }
; }

define void @foo(i32 %x, ptr %p) {
entry:
  %cmp = icmp eq i32 %x, 0
  br i1 %cmp, label %if.then, label %if.end, !prof !0

if.then:                                          ; preds = %entry
  store i32 %x, ptr %p
  br label %if.end

if.end:                                           ; preds = %if.then, %entry
  ret void
}

!0 = !{!"branch_weights", i32 49, i32 51}

8 [17] следующей командой:

llc < test.ll -mtriple=x86_64 -mattr=+branch-hint -enable-branch-hint

то мы получим (godbolt [18]):

foo:                                    # @foo
        test    edi, edi
        ds  # <--- Это branch hint
        jne     .LBB0_2
        mov     dword ptr [rsi], edi
.LBB0_2:                                # %if.end
        ret

В строке 3 есть branch hint.

Для других архитектур ситуация может быть другой, но я в основном знаком только с x86.

▍ -O3 генерирует код, который гораздо быстрее кода с -O2

Stabilizer [19]): в Clang нет статистической разницы между -O2 и -O39 [20]! Я тоже ощутил это на своём опыте, особенно с Clang. -O2 компилятора GCC менее агрессивен, чем у Clang, поэтому разница здесь есть, но не особо большая. Кроме того, -O3 почти полностью игнорирует размер кода, и это может вызывать проблемы с кэшем команд.

Мораль такова: это не чёткое правило, а один из тех случаев, когда требуется бенчмарк.

▍ Интерпретаторы Javascript являются JIT во время выполнения, потому что они заранее не знают, какие пути выполнения будут «горячими»

Знания о том, какие пути в байт-коде Javascript будут «горячими», недостаточно для генерации оптимизированного кода; нужно ещё знать и типы. А типы мы узнаём только во время выполнения. Это одна из причин того, что JIT-компиляторы компилируют код в среде выполнения.

▍ Если у вас есть компилятор, то вам не нужен интерпретатор

Это сильно зависит от ситуации. Да, вероятно, в конвейерах C/C++ интерпретатор будет не особо полезен, к тому же попробуйте-ка написать его. Но существуют и случаи, когда интерпретатор может быть полезен. Один из таких интересных случаев — WebAssembly.

Недавно Бен Титцер опубликовал статью A Fast In-Place Interpreter for WebAssembly [21]. В этой статье Титцер рассказывает нам историю о реализациях WebAssembly.

Для ненативных языков обычно первым разрабатывается интерпретатор, а уже затем (JIT)-компилятор. Это вызвано тем, что интерпретатор обычно проще разрабатывать и использовать, а компилятор нужен для обеспечения производительности. Однако любопытно, что в экосистеме WebAssembly первыми были разработаны JIT-компиляторы. Насколько я понимаю, на это повлияло три аспекта, о которых мы поговорим ниже.

Спроектирован для компиляции. WebAssembly был спроектирован для компиляции, потому что производительность была базовой целью, а не поставленной позже. Под производительностью в данном случае подразумевается и производительность создаваемого кода, и эффективность компиляции. Например, в WebAssembly можно выполнять проверку типов за один проход.

10 [22], проектировавшие язык всё равно хотели убедить разработчиков и предоставить доказательство того, что WebAssembly и в самом деле реалистичный вариант для тех, кого волнует производительность. А такое доказательство не получить без компилятора.

Неидеальность для интерпретации. Проектирование для компиляции привнесло особенности, усложнившие реализацию интерпретатора. Самой важной из них стал структурированный поток управления. В своей статье Титцер очень подробно описывает основные болевые точки.

Тем не менее, проектировщики в конечном итоге осознали, что интерпретатор имеет множество преимуществ. Титцер рассмотрел шесть преимуществ в разделе 1.1 статьи. Если вкратце, то хотя обычно производительность скомпилированного двоичного файла превзойти сложно, есть другие практические причины для наличия компилятора.

▍ Промежуточный слой независим от целевой платформы

LLVM имеет самый популярный промежуточный слой, но для него это неверно. Я написал целую статью [23] по этой теме.

▍ Компилятор выполняет оптимизацию для обеспечения локальности данных

11 [24] Да, компиляторы выполняют оптимизацию с целью обеспечения локальности кэша команд; мы говорили об этом выше. У компиляторных исследований по оптимизации кэша есть длинная история12 [25]; но ни одна из них не добралась до популярных компиляторов. Самый серьёзный пример (из известных мне) фреймворка оптимизации локальности данных — это Polly [26]13 [27], становящийся частью большого популярного компилятора. Тем не менее, он всё равно не включён в LLVM по умолчанию.

Data-Oriented Design and C++ [28] набрал 690 тысяч просмотров не в последнюю очередь потому, что он донёс мысль о том, что компилятор C++ не оптимизирует локальность кэша. Этот доклад, по сути, положил начало14 [29] движению data-oriented design, основная цель которого заключается в написании кода, качественного использующего кэш.

Но вы можете задаться вопросом, почему же компилятор этого не делает. К сожалению, он не может. Во-первых, оптимизация с целью обеспечения локальности данных требует внесения крупномасштабных изменений в программу. Допустим, у нас есть хэш-таблица открытой адресации. В частности, вот как выглядит схема данных DenseMap LLVM:

class DenseMap {
  struct BucketT { KeyT Key; ValueT Value; };
  unsigned NumBuckets;
  BucketT *Buckets;
...

То есть у нас есть один массив, в котором каждый элемент имеет спаренные ключ и значение. Но при поиске нас интересуют только ключи. При таком структурировании данных мы загружаем значения в кэш, впустую тратя его пространство и такты. Улучшить ситуацию можно, разбив массив на два: один для ключей, второй для значений.

15 [30].

16 [31]. Проще говоря, компилятор имеет слишком неточный обзор операций доступа к памяти программы, чтобы хорошо понимать, какие оптимизации пойдут на пользу.

▍ -O0 обеспечивает быструю компиляцию

Популярные компиляторы наподобие Clang и GCC очень медленные, поэтому точнее было бы сказать, что -O0 обеспечивает предсказуемый код, который можно отлаживать, а не то, что он выдаёт код быстро.

zstd [32] уходит примерно 34 секунды, а сборка Debug требует примерно 11 секунд. Нечто подобное истинно и для одного из моих проектов [33] на пять тысяч строк, в котором используется unity-сборка. Однако даже утверждение о том, что -O0 всегда быстрее, чем -O2, не является истиной. Например, на моём десктопе для компиляции Debug-версии LLVM требуется примерно 25 минут, но примерно 23 минуты на компиляцию Release-версии17 [34].

18 [35]о компиляции запросов19 [36] авторы говорят:

[Компиляции IR LLVM] обычно требуется несколько миллисекунд на компиляцию запросов, а компиляторам C или C++ для этого требуются секунды.

А компиляция запросов — это одна из ситуаций, в которых быстрая компиляция важна, поскольку она происходит онлайн.

Легко понять, почему эта цитата верна, когда мы используем в качестве компилятора Clang, потому что ему всё равно нужно пройти через IR LLVM. Кроме того, у C/C++ нет двоичного формата, который бы ускорил обработку, только текстовый. А у IR LLVM он есть.

▍ Шаблоны медленно компилируются

20 [37]. Разумеется, шаблоны повышают сложность компиляции, но не настолько, чтобы это делало их использование непрактичным.

Phobos [38]. На моём не особо быстром ноутбуке компиляция Phobos в оптимизированной сборке занимает примерно 12 секунд, хотя она состоит из более чем 250 тысяч строк кода с кучей шаблонов21 [39]. Стоит отметить, что DMD, основной компилятор D, не делает ничего особо сложного. Разумеется, главный разработчик D и DMD Уолтер Брайт — это один из лучших проектировщиков компиляторов в мире, и он точно знает, как заставить компилятор работать быстро. Но DMD не делает ничего безумного, он даже не многопоточный.

▍ Отдельная компиляция всегда себя оправдывает

Отдельная компиляция (separate compilation) — это принцип компиляции отдельного объектного файла для каждого файла исходников22 [40]. Довод о её пользе звучит так: если вы меняете один файл исходников, то вам придётся заново компилировать только один файл, и вы просто «скомпонуете» его с другими объектными файлами; заново компилировать весь проект не придётся.

23 [41], а оттуда проникла в среды продакшена. Проблема в том, что на практике компоновка невероятно медленная. К сожалению, я не изучал компоновку достаточно глубоко, чтобы рассуждать, является ли эта проблема неотъемлемой24 [42]. Но это и неважно, в реальности всё именно так.

То есть во многих проектах отдельная компиляция себя не оправдывает. Обычно гораздо более оптимальным вариантом оказывается unity-сборка, при которой мы просто включаем всё в один файл. Покажу несколько примеров. Во-первых, я убедился, что это оправдывает себя не во всех написанных мной проектах на C/C++. Допустим, до 20 тысяч строк в одном проекте. Например, minijava-cpp [33] — это написанный на C++ компилятор MiniJava, в котором есть достаточно тяжёлый код. На моём ноутбуке он компилируется примерно за 0,7 с, а компоновка всех этих отдельных файлов занимает гораздо больше времени. Если вкратце, я в своих проектах никогда не сталкивался с кодом, для которого отдельная компиляция оказывалась лучше unity-сборки.

сообщила [43] о том, что использование unity-сборок улучшает время компиляции её игр25 [44]. SQLite тоже компилирует всё в единую программу .c.

Наряду со временем компиляции, у unity-сборок есть другие преимущества. Например, мы получаем оптимизацию для программы целиком без необходимости применения оптимизации времени компоновки (см. ниже). Кроме того, всё компилируются только один раз, что сильно положительно влияет и на ускорение компиляции, и на удобство логов ошибок. Если при модели компиляции C++ (см. выше раздел про шаблоны) включить один и тот же файл в разные единицы трансляции, то он будет компилироваться по одному разу для каждой единицы. Если поверх этого добавить ещё и то, что шаблоны генерируют множество версий одного и того же, то мы получим, что один код заново компилируются снова и снова.

В заключение скажу, что не могу заявлять о том, что отдельная компиляция бесполезна всегда, потому что у меня нет доказательств этому. Но в моих проектах она никогда не давала никакой выгоды, и, похоже, в некоторых крупных проектах тоже. Так что попробуйте воспользоваться unity-сборками.

▍ Почему оптимизация времени компоновки (link-time optimization, LTO) происходит во время компоновки?

26 [45] «Advanced Compiler Construction». Однажды Викрам задал вопрос на миллион долларов: почему оптимизация времени компоновки происходит во время компоновки? Это может показаться оксимороном, но на самом деле вопрос таков: почему оптимизация программы целиком происходит на этапе компоновки?

Это очень разумный вопрос, потому что на самом деле никого не волнует оптимизация времени компоновки; людям важна оптимизация всей программы. А в общем случае компоновщики не являются оптимизаторами, это не их работа. На самом деле, теоретически гораздо логичнее выполнять оптимизацию всей программы на промежуточном этапе (middle-end). Во-первых, задача middle-end и заключается в оптимизации. Во-вторых, middle-end работает с IR, предназначенным для оптимизации, в отличие от ассемблерного кода, обычно содержащегося в объектных файлах. Кроме того, ничего не мешает компилятору иметь глобальную картину всей программы. Он может транслировать все входные файлы C/C++ в IR LLVM, а затем получить обзор всей программы. Тогда почему же, чёрт возьми, это происходит во время компоновки? Я должен был бы сказать, что никто не знает ответа.

Викрам объяснил (изложу своими словами), что ответ кроется в печальной реальности систем сборки C/C++. Удачи вам искать файлы исходников на C/C++ в огромных проектах и разбираться, кто кого вызывает. Инструментарий C++ просто для этого не предназначен. С другой стороны, компоновщик обязан найти все объектные файлы. То есть инструментарий устроен так, что компоновщик может находить весь код для проекта. Разумеется, обычно объектные файлы содержат ассемблированный код, с которым никто не хочет возиться. Поэтому компилятор, например, просто вставляет в объектный файл IR LLVM, чтобы компилятор получал к нему доступ.

▍ Встраивание в первую очередь полезно тем, что избавляет от команды вызова

Устранение команды вызова (последовательности) идёт на пользу, но оно частично полезно из-за локальности кэша команд, а не потому, что эта команда такая уж медленная. Однако важнее всего то, что самое большое преимущество (с большим отрывом от других) встраивания (inlining) заключается в том, что оно позволяет обеспечивать трансформацию, то есть позволяет использовать другие оптимизации. Более подробное объяснение представлено здесь [46], но позвольте мне подчеркнуть один часто не замечаемый эффект встраивания.

Встраивание позволяет обеспечивать межпроцедурную оптимизацию без необходимости каких-либо межпроцедурных компиляторных техник (за исключением самого встраивания). Межпроцедурные техники оптимизации затрагивают несколько процедур (например, функций), то есть они выполняют оптимизацию между функциями. Межпроцедурные оптимизации сложно реализовать чётко, потому что они затрагивают множество функций. Оптимизации в целом сложно расширить на что-то большее, нежели малое множество базовых блоков или простой цикл, не говоря уже о выходе за пределы функций. Однако встраивание позволяет происходить межпроцедурной оптимизации. Например, допустим, у нас есть следующий код:

int sat_div(int num, int denom) {
  if (denom == 0) {
    return (num > 0) ? INT_MAX : INT_MIN;
  }
  return num / denom;
}

int foo(int a) {
  int b = sat_div(a, 3);
  return b;
}

После оптимизации код foo выглядит так (godbolt [47]):

// ... sat_div не меняется

int foo(int a) {
  // Сгенерированный код сбивает с толку, потому что
  // компилятор превратил деление в умножение.
  return a / 3;
}

27 [48], устранения мёртвого кода или комбинирования команд. Нет, этот эффект возник благодаря тому, что компилятор сначала встроил вызов sat_div (godbolt [49]), который затем обеспечил возможность интрапроцедурных28 [50] версий этих трансформаций. Иными словами, встраивание объединило всё в одну функцию, что позволило выполнить обычные трансформации. Именно поэтому встраивание многими (например, Чендлером Карретом [51]) самой важной оптимизацией оптимизирующих компиляторов29 [52].

30 [53]. С другой стороны, в большинстве популярных компиляторов почти вся межпроцедурная работа выполняется встраиванием. Именно поэтому практически все последние исследования моделей затрат компиляторов связаны с встраиванием межпроцедурных оптимизаций целевых платформ31 [1].

▍ Роль ключевого слова inline

Я имею в виду следующее (cppreference [54]):

#ifndef EXAMPLE_H
#define EXAMPLE_H
 
// function included in multiple source files must be inline
inline int sum(int a, int b) {
  return a + b;
}

#endif

Основная часть документации по этому ключевому слову посвящена его роли в компоновке. И действительно, семантически оно важно именно в ней. Но ответить я хочу на следующий вопрос: связано ли это как-то с оптимизацией встраиванием?

рассказал нам [55], что «ключевое слово inline в C++, вероятно, никак не связано с оптимизацией»32 [56]. Но на самом деле история не так проста. С одной стороны, cppreference говорит нам, что:

Изначально ключевое слово inline задумывалось как индикатор для оптимизатора того, что встраиваемая подстановка функции предпочтительнее, чем вызов функции.

а если посмотреть последний [57] драфт стандарта C++33 [58], то в нём говорится следующее34 [59]:

Описатель inline указывает реализации, что встраиваемая подстановка тела функции в точке вызова предпочтительнее, чем обычный механизм вызова функции. От реализации не требуется выполнять эту встраиваемую подстановку в месте вызова.

С другой стороны, в cppreference также говорится:

[...] ключевое слово inline для функций стало скорее значить «допустимы множественные определения», чем «предпочтительно встраивание» с C++98, это значение было расширено до переменных.

35 [60], вполне можно запутаться. Так в чём же истина? Как и во многих случаях с языком C++, «истина» — это то, что выбрала реализация. Так как я больше всего знаком с LLVM, давайте разберём его.

36 [61], который Clang добавит к вашему определению функции, если вы добавите ключевое слово inline37 [62]. Например, в этом примере [63] Clang добавляет атрибут inlinehint к int_run. Согласно справке IR LLVM по inlinehint:

Этот атрибут указывает, что исходный код содержит подсказку, что желательно встраивание этой функции (аналогично ключевому слову «inline» в C/C++). Это просто подсказка; она ни к чему не обязует инструмент встраивания.

И в самом деле, оптимизатор повышает порог встраивания для функций, имеющих атрибут inlinehint38 [64]. Однако изменение относительно мало. Так что да, ключевое слово inline как-то связано с оптимизацией, и да, оптимизатор учитывает его добавление; но не особо сильно (простите!). Думаю, если вы сильно хотите, чтобы функция была встроенной, то нужно добавить указатель always_inline, с которым ситуация совершенно иная: компилятор всегда будет встраивать такие функции, если это возможно.

▍ Больше всего для обучения среди продакшен-компиляторов подходит LLVM

39 [65]. Во многих смыслах, из него можно многому научиться. Но он огромен! LLVM применим к куче сценариев использования. LLVM используется и во встроенных системах, и в дата-центрах Google, и в JIT-компиляторах, и в сценариях агрессивной высокой производительности, от Zig и Rust до <название нового крутого языка>. Поэтому при изучении LLVM вы столкнётесь с множеством пограничных и особых случаев, которые он пытается обработать и которые не имеют ценности для образования. Также вы встретите множество сложных алгоритмов. Например, стандартный алгоритм Ленгауера-Тарджана [66] уже достаточно сложно понять. А в LLVM используется ещё более сложный алгоритм [67], который реализован только недавно.

Так что хоть я и рекомендую когда-нибудь серьёзно изучить внутреннее устройство LLVM, но начинать с этого не стоит. Уточню: это в случае, если вам интересно обучение разработке компиляторов. Если вы пользователь, то LLVM, вероятно — лучший выбор. Если вы изучаете компиляторы, то, как и в моём комментарии [68] на Reddit, посоветую взглянуть на компилятор Go [67]. Это продакшен-компилятор, который разрабатывают очень серьёзные люди, но он не настолько чудовищен, как LLVM. Также я многому научился у разработчиков LDC [69] и DMD [70].

▍ Неопределённое поведение только открывает возможности для оптимизаций

Нет, неопределённое поведение может и закрывать возможности оптимизации. Я написал об этом статью [71].

▍ Компилятор может «просто» определить неопределённое поведение

40 [72]. Например, компилятору разрешено определять разыменование из указателя int*, указывающего на NULL, как 0. Однако это влияет на производительность, например, потребует, чтобы компилятор вставлял if в каждом разыменовании указателя.

Более тонкий аспект заключается в том, что компилятор может определять все неопределённые поведения как поведения платформы. Так мы убиваем одним выстрелом двух зайцев. Во-первых, компилятору не придётся добавлять никакого лишнего кода, потому что поведение, которое он выберет, всё равно будет поведением целевой платформы. Поэтому не возникнет никакой лишней траты ресурсов. Во-вторых, программист внезапно оказывается способен точно узнать, что произойдёт. Хотя этот аргумент кажется замечательным, на самом деле всё не так просто. Я написал статью [73], в которой серьёзно и подробно рассматривается это предположение.

▍ Показатель корректности в 99% — это нормально

Взрывное развитие искусственного интеллекта затронуло и компиляторы. Во многих случаях на то были веские причины. Например, одним из реалистичных применений машинного обучения могут быть модели затрат. Вот, как работает модель затрат в упрощённом виде: ей передаётся пространство возможных решений, и она выбирает одно из них. Например, допустим, у нас есть цикл, разбитый на блоки с коэффициентом 16, 32 или 64. Можно создать модель затрат и попросить её выбрать один из четырёх вариантов: вообще не разбивать цикл на блоки или разбивать с одним из трёх коэффициентов.

Очень долгое время модели затрат писались и поддерживались вручную. Однако настраиваемые вручную модели часто очень скучно создавать и сложно масштабировать (особенно с увеличением количества архитектур); к тому же разработчики оборудования так мало говорят нам о своих архитектурах, что настраиваемые вручную модели затрат оказываются (как и основанные на машинном обучении) «чёрной магией»; их невозможно интерпретировать и понять интуитивно. Иными словами, это подходящий случай для использования машинного обучения и нейросетей, потому что они способны автоматизировать процесс. За последнее десятилетие появилось множество исследований моделей затрат на основе машинного обучения, и некоторые из таких моделей даже добрались до продакшена. Например, недавно Google начала использовать нейросетевую модель затрат [74] для своего компилятора XLA.

41 [75]! Так что, несмотря на то, что у нейросетей сейчас есть «небольшая» проблема, заключающаяся в том, что мы понятия не имеем, как они работают, и они не дают гарантий, в случае моделей затрат это нас устраивает. Однако нас это не устроит в случае генерации кода42 [76]! Когда-то это было очевидным, но похоже, что сегодня это уже не так. Например, недавно мы получили комментарий от одного из ревьюеров Dias [77], в котором, по сути, говорилось «разве нельзя просто использовать LLM?» Нет! Сейчас я объясню, почему использовать LLM для генерации кода совершенно неприемлемо (по крайней мере, на текущий момент).

Всё это сводится к следующему вопросу: устроит ли нас, если сгенерированный компилятором код корректен, допустим, в 99% случаев? Здесь требуется уточнение. Значит ли то, что из 100 сгенерированных программ 99 полностью корректны и одна оказывается почти полным мусором? Очевидно, что нет, это нереалистично. Более реалистичным было бы такое предположение: из 100 строк кода компилятор генерирует корректный код для 99 из них. Я знаю, что это всё равно слишком упрощённая картина, но она вполне подходит для рассуждений. Итак, устроит ли нас это?

Надеюсь, что вы ответили «нет». С точки зрения разработчика компиляторов, это полный отстой. По сути, таким компилятором невозможно пользоваться. В лучшем случае это некий исследовательский артефакт, позволяющий изучить концепцию. Но о продакшене забудьте.
Это не подходит даже для отладки. Чтобы понять, почему, рассмотрим небольшой проект, например, из 5000 строк кода. При показателе корректности в 99% при каждой компиляции 50 строк кода будут некорректными. Пятьдесят! А хуже всего то, что вы не знаете, какие, и они будут разными при каждом изменении в коде. Вероятно, вам доводилось выискивать баг даже в одной строке кода, это может быть утомительным и долгим процессом. Представьте теперь, каково будет отлаживать 50 меняющихся строк кода! А теперь представьте всё это в крупномасштабном проекте, в котором может быть несколько миллионов строк кода43 [78]. Нет уж, спасибо.

И последнее: даже если бы LLM генерировали корректный код, пока они крайне медленные по сравнению с компиляторами. Да, я говорил выше, что компиляторы не особо быстрые. Но по сравнению с LLM популярные компиляторы невероятно быстры. Так что, несмотря на многообещающее будущее и светлое настоящее LLM, есть задачи, которые они пока не могут решить. Одна из них — (онлайн-)генерация кода.

▍ Примечания

  1. ↩ [79]
  2. ↩ [80]
  3. ↩ [81]
  4. ↩ [82]
  5. исходный код GCC [83]. ↩ [84]
  6. комментарии [85] в исходном коде GCC, а также раздел 2.1.1.1 руководства Intel по оптимизации [86]. ↩ [87]
  7. тестового случая [88] LLVM. ↩ [89]
  8. ↩ [90]
  9. фрагмент видео [91] (на 23:58). ↩ [92]
  10. исходную статью [93]. Авторы разработают в компаниях-разработчиках всех популярных браузеров. ↩ [94]
  11. ↩ [95]
  12. A data locality optimizing algorithm [96]. ↩ [97]
  13. ↩ [98]
  14. ↩ [99]
  15. ↩ [100]
  16. ↩ [101]
  17. ↩ [102]
  18. ↩ [103]
  19. Efficiently Compiling Efficient Query Plans for Modern Hardware [104]. ↩ [105]
  20. Walter Bright: C++ Compilation Speed [106]. ↩ [107]
  21. ↩ [108]
  22. ↩ [109]
  23. ↩ [110]
  24. ↩ [111]
  25. Handmade Hero [112] Кейси тоже выяснил, что лучше использовать unity-сборки. ↩ [113]
  26. ↩ [114]
  27. проход [115] межпроцедурной (разреженно условной) подстановки констант, но в этом случае он ничего не делал. ↩ [116]
  28. ↩ [117]
  29. ↩ [118]
  30. фреймворк Attributor [119] в LLVM (хоть на момент написания статьи он не был включён по умолчанию даже в -O3), а также все межпроцедурные оптимизации GCC, описанные здесь [120]. ↩ [121]
  31. MLGO: a Machine Learning Guided Compiler Optimizations Framework [122]. ↩ [123]
  32. 2010 года [124] (когда сообщения LLVM всё ещё были крутыми и когда атрибут inlinehint был реализован по-настоящему)! ↩ [125]
  33. ↩ [126]
  34. ↩ [127]
  35. ↩ [128]
  36. 2010 года [124]. ↩ [129]
  37. Исходник [130]. ↩ [131]
  38. Исходник [132]. ↩ [133]
  39. терминологии циклов LLVM [134]. ↩ [135]
  40. ↩ [136]
  41. ↩ [137]
  42. ↩ [138]
  43. ↩ [139]

Автор: ru_vds

Источник [140]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/optimizatsiya-koda/405295

Ссылки в тексте:

[1] 1: #anchorid1

[2] компиляторных оптимизациях: https://sbaziotis.com/compilers/compiler-opt.html

[3] 2: #anchorid2

[4] 3: #anchorid3

[5] очень простым способом: https://sbaziotis.com/compilers/compiler-opt.html#unoptimized-builds

[6] оптимальной подструктурой: https://en.wikipedia.org/wiki/Optimal_substructure

[7] комментарий: https://news.ycombinator.com/item?id=42375191

[8] 1: https://doi.org/10.1145/1508284.1508275

[9] 2: https://users.cs.northwestern.edu/~robby/courses/322-2013-spring/mytkowicz-wrong-data.pdf

[10] «у нас совершенно нет надлежащих моделей машин, для которых мы выполняем компиляцию»: https://youtu.be/p15qRHZ_Leg?t=951

[11] goSLP: https://dl.acm.org/doi/abs/10.1145/3276480

[12] 4: #anchorid4

[13] медленнее, чем LLVM: https://youtu.be/wiwloa0g02g?t=1946

[14] 5: #anchorid5

[15] 6: #anchorid6

[16] 7: #anchorid7

[17] 8: #anchorid8

[18] godbolt: https://godbolt.org/z/95KE4eTYY

[19] Stabilizer: https://dl.acm.org/doi/10.1145/2490301.2451141

[20] 9: #anchorid9

[21] A Fast In-Place Interpreter for WebAssembly: https://dl.acm.org/doi/pdf/10.1145/3563311

[22] 10: #anchorid10

[23] статью: https://sbaziotis.com/compilers/how-target-independent-is-your-ir.html

[24] 11: #anchorid11

[25] 12: #anchorid12

[26] Polly: https://polly.llvm.org/

[27] 13: #anchorid13

[28] Data-Oriented Design and C++: https://www.youtube.com/watch?v=rX0ItVEVjHc

[29] 14: #anchorid14

[30] 15: #anchorid15

[31] 16: #anchorid16

[32] zstd: https://github.com/facebook/zstd

[33] одного из моих проектов: https://github.com/baziotis/minijava-cpp

[34] 17: #anchorid17

[35] 18: #anchorid18

[36] 19: #anchorid19

[37] 20: #anchorid20

[38] Phobos: https://github.com/dlang/phobos

[39] 21: #anchorid21

[40] 22: #anchorid22

[41] 23: #anchorid23

[42] 24: #anchorid24

[43] сообщила: https://youtu.be/qYN6eduU06s?t=601

[44] 25: #anchorid25

[45] 26: #anchorid26

[46] здесь: https://youtu.be/I4Iv-HefknA?t=1084

[47] godbolt: https://godbolt.org/z/Gfr1rjMvj

[48] 27: #anchorid27

[49] godbolt: https://godbolt.org/z/4TdaG9Wz7

[50] 28: #anchorid28

[51] Чендлером Карретом: https://youtu.be/FnGCDLhaxKU?t=1580

[52] 29: #anchorid29

[53] 30: #anchorid30

[54] cppreference: https://en.cppreference.com/w/cpp/language/inline

[55] рассказал нам: https://youtu.be/FnGCDLhaxKU?t=1560

[56] 32: #anchorid32

[57] последний: https://open-std.org/jtc1/sc22/wg21/docs/papers/2024/n4993.pdf

[58] 33: #anchorid33

[59] 34: #anchorid34

[60] 35: #anchorid35

[61] 36: #anchorid36

[62] 37: #anchorid37

[63] примере: https://godbolt.org/z/jfo35vvzf

[64] 38: #anchorid38

[65] 39: #anchorid39

[66] алгоритм Ленгауера-Тарджана: https://sbaziotis.com/compilers/semidominators-proof.html

[67] алгоритм: https://www.youtube.com/watch?v=bNV18Wy-J0U

[68] комментарии: https://www.reddit.com/r/Compilers/comments/1h4qscz/comment/m02plue/

[69] LDC: https://github.com/ldc-developers/ldc

[70] DMD: https://github.com/dlang/dmd

[71] статью: https://sbaziotis.com/compilers/definedness-removing-optimization-limitations-of-undefined-behavior.html

[72] 40: #anchorid40

[73] статью: https://sbaziotis.com/compilers/defining-all-undefined-behavior-and-leveraging-compiler-transformation-apis.html

[74] модель затрат: https://proceedings.mlsys.org/paper_files/paper/2021/file/6bcfac823d40046dca25ef6d6d59cc3f-Paper.pdf

[75] 41: #anchorid41

[76] 42: #anchorid42

[77] Dias: https://sbaziotis.com/compilers/dias.html

[78] 43: #anchorid43

[79] ↩: #anchorid01

[80] ↩: #anchorid02

[81] ↩: #anchorid03

[82] ↩: #anchorid04

[83] исходный код GCC: https://github.com/gcc-mirror/gcc/blob/83f22c1c526368cafc3d32f03462ecd7847f054f/gcc/config/i386/x86-tune.def#L730

[84] ↩: #anchorid05

[85] комментарии: https://github.com/gcc-mirror/gcc/blob/83f22c1c526368cafc3d32f03462ecd7847f054f/gcc/config/i386/x86-tune.def#L718

[86] руководства Intel по оптимизации: https://www.intel.com/content/www/us/en/content-details/821612/intel-64-and-ia-32-architectures-optimization-reference-manual-volume-1.html

[87] ↩: #anchorid06

[88] тестового случая: https://github.com/llvm/llvm-project/blob/356df2dd72e8299b5de58e9390283110c19f7c76/llvm/test/CodeGen/X86/branch-hint.ll

[89] ↩: #anchorid07

[90] ↩: #anchorid08

[91] фрагмент видео: https://youtu.be/r-TLSBdHe1A?t=1438

[92] ↩: #anchorid09

[93] исходную статью: https://people.mpi-sws.org/~rossberg/papers/Haas,%20Rossberg,%20Schuff,%20Titzer,%20Gohman,%20Wagner,%20Zakai,%20Bastien,%20Holman%20-%20Bringing%20the%20Web%20up%20to%20Speed%20with%20WebAssembly.pdf

[94] ↩: #anchorid010

[95] ↩: #anchorid011

[96] A data locality optimizing algorithm: https://doi.org/10.1145/113446.113449

[97] ↩: #anchorid012

[98] ↩: #anchorid013

[99] ↩: #anchorid014

[100] ↩: #anchorid015

[101] ↩: #anchorid016

[102] ↩: #anchorid017

[103] ↩: #anchorid018

[104] Efficiently Compiling Efficient Query Plans for Modern Hardware: https://www.vldb.org/pvldb/vol4/p539-neumann.pdf

[105] ↩: #anchorid019

[106] Walter Bright: C++ Compilation Speed: https://digitalmars.com/articles/b54.html

[107] ↩: #anchorid020

[108] ↩: #anchorid021

[109] ↩: #anchorid022

[110] ↩: #anchorid023

[111] ↩: #anchorid024

[112] Handmade Hero: https://mollyrocket.com/#handmade

[113] ↩: #anchorid025

[114] ↩: #anchorid026

[115] проход: https://github.com/llvm/llvm-project/blob/041e5c96c4bd247a3dd6099f49143ee66d9205d8/llvm/lib/Transforms/IPO/SCCP.cpp

[116] ↩: #anchorid027

[117] ↩: #anchorid028

[118] ↩: #anchorid029

[119] фреймворк Attributor: https://youtu.be/I4Iv-HefknA?t=1632

[120] здесь: https://www.youtube.com/watch?v=m09feQ5h08o

[121] ↩: #anchorid030

[122] MLGO: a Machine Learning Guided Compiler Optimizations Framework: https://arxiv.org/abs/2101.04808

[123] ↩: #anchorid031

[124] 2010 года: https://github.com/llvm/llvm-project/commit/74bb06c0f02e650ea9ec73729f41a620fc55a8ee

[125] ↩: #anchorid032

[126] ↩: #anchorid033

[127] ↩: #anchorid034

[128] ↩: #anchorid035

[129] ↩: #anchorid036

[130] Исходник: https://github.com/llvm/llvm-project/blob/026fbe519e16a4993601d2bac509e182081fc068/clang/lib/CodeGen/CodeGenModule.cpp#L2590

[131] ↩: #anchorid037

[132] Исходник: https://github.com/llvm/llvm-project/blob/026fbe519e16a4993601d2bac509e182081fc068/llvm/lib/Analysis/InlineCost.cpp#L1983

[133] ↩: #anchorid038

[134] терминологии циклов LLVM: https://llvm.org/docs/LoopTerminology.html

[135] ↩: #anchorid039

[136] ↩: #anchorid040

[137] ↩: #anchorid041

[138] ↩: #anchorid042

[139] ↩: #anchorid043

[140] Источник: https://habr.com/ru/companies/ruvds/articles/866972/?utm_source=habrahabr&utm_medium=rss&utm_campaign=866972