Pull to refresh

Comments 18

Повторю, немного перефразируя, свою старую мысль: Для того, чтобы "оптимизация" считалась "оптимизацией", компилятор должен считать ее таковой. Препроцессор должен считать ее таковой. И не на какой ступени никто не должен мешать друг-другу.

По факту. Автоматическая оптимизация работает для очень незначительной части кода. Всякие оптимизаторы считают, что "правильное = быстрое", а это, для большей части, нефига не синонимы.

P.S. И только не надо про нейросети. Еще ни одна нормально обычные циклы в примитивных скриптах разбирать не научилась, а вложенные циклы это просто конфетка.

Я пытался сформулировать какой-то ответ, но, наверное, самым лучшим будет "вы вообще о чём?"

Перефразируя, доминирование можно описать так:

Узел A доминирует над узлом B тогда и только тогда, когда любой путь из начала графа в узел B проходит через узел A

Имхо, так проще понять о чем говорится

А чем вам не нравится определение в статье?

Узел A доминирует над узлом (является доминатором узла B), если любой путь, ведущий из входного узла в B, проходит также через A.

Кажется несколько запутанным для неподготовленного глаза

Но вы же то же самое написали, только длиннее. О_о

Ваш вариант короче, вариант nikto_b удачнее (более понятен).

Раскрою мысль, ув@xortator, до этого, прошу прощения, не было сил. Нам очень полезен этот взгляд «зрелого промышленного практика» компиляции с хорошим слогом. В теории, конечно, всё изложено в книжках, но там всё в хорошем смысле академично, а у вас очень условно, но «текущий срез».

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

Ну, у меня и не было цели описывать алгоритм SROA (речь ведь о нём?) во всех деталях и тем более доказывать его корректность. Я просто привёл общую схему для понимания того, что происходит.

Что касается полурешёток -- это вкусовщина, но я не люблю описывать графовые алгоритмы в алгебраических терминах, и я бы не стал этого делать даже при формальном изложении. Сходимость следует из того факта, что каждый раз, переходя в новое поддерево доминирования, мы удаляем оттуда все лоады и заменяем их на изначальное значение или какую-то финоду и/или добавляем вход финоде. Число таких поддеревьев всегда конечно, как и число входов у финод (т.е. число таких необработанных поддеревьев -- полуинвариант), поэтому алгоритм точно сойдётся.

Хотел задать вопрос по LLVM не связанный непосредственно с темой поста.

А как там решается проблема того, что некоторые фронтэнды разрешают неэквивалентные преобразования (например С и С++ UB таковыми являются)?
(как my best guess - должен быть флаг, в зависимости от которого запускать дополнительный набор peep-hole правил, но хотелось бы послушать как оно на самом деле).

Ведь если обрабатывать С \ С++ UB строгим образом - получите просадку производительности.
Но закладываться на С \ С++ UB очевидно тоже нельзя (иначе нельзя было бы использовать LLVM как бэкэнд для более строгих языков, того же Rust \ Haskell \ D \ ...).

============================================================
Т.е. условно если у вас есть:
a: i32;
if (a + 1 > a) { do_1() } else {do_2() };

gcc -O2 - скорее всего всегда будет выполняться do_1().
cargo run --release (rust) - будет делаться честная проверка и при переполнении выполняться do_2().

Я как раз сейчас в процессе написания статьи про UB. Надеюсь выпустить на этой неделе.

Если коротко, то изначально генерится максимально топорный IR, который работает "как написано", без каких-либо попыток эксплуатировать UB. А дальше запускается оптимизирующий пайплайн, который может эксплуатировать те или иные факты.

В данном конкретном примере происходит следующее: поскольку знаковое переполнение в С++ это UB, то на уровне LLVM у всех арифметических инструкций стоит флаг nsw. Пример можно посмотреть здесь: https://godbolt.org/z/j3n77no7r

Не вдаваясь в подробности, наличие флага nsw говорит компилятору: "работай с этой инструкцией так, как будто там никогда не происходит знаковое переполнение". Зная это, какая-нибудь оптимизация (скорее всего, InstCombine) может решить, что "add nsw x, 1 > x", поскольку случай переполнения его в этом случае не волнует, а без переполнения это всегда так.

В rust, видимо, семантика сложения такова, что оно всегда делается по модулю 2^32. Поэтому фронтенд флаг nsw на инструкции add не поставит, и описанная выше трансформация применяться не будет.

Спасибо.
Получается, хардкорно заоптимизировали.
На уровне ad-hoc типов данных в IR (обойтись просто запуском доп фазы компиляции не получилось).

Ну без nsw, который тут только фронт и может поставить, не обойтись. Языки, где переполнение работает на кольце, обязаны здесь вызвать do_2 при a=SINT_MAX, а C++ не обязан.

  • Для начала, из графа удаляются все обратные рёбра циклов. Они нам не потребуются. Дальше рассматриваем граф без обратных рёбер.

На всякий случай, если кто тоже споткнулся, то это определение дано через одну статью :)

Можно я буду пользоваться комментариями в качестве личных заметок при чтении ваших статей? Вы дали ссылку на файл SROA.cpp, и выясняется, что SROA - это Scalar Replacement Of Aggregates. Очень интересное и говорящее название! Давайте отвлечёмся от финод, и посмотрим на более простой случай.

Мне кажется, что инлайнинг функций - это вид SROA (даже если в том .cpp его нет), только оно работает над кодом, а не над памятью. Если мы возьмём такой код, то инлайнинг позволяет избавиться от вызова print:

Hidden text
typedef struct {
  int x, y;
} point;

void print(point *p) {
  printf("(%d, %d)", p->x, p->y);
}

int main () {
  point p;
  p.x = 1;
  p.y = 2;
  print (&p);
  return 0;
}

То есть, после инлайнинга мы получим следующий код:

Hidden text
typedef struct {
  int x, y;
} point;

int main () {
  point p;
  p.x = 1;
  p.y = 2;
  printf("(%d, %d)", p.x, p.y);
  return 0;
}

Причём в данном случае основная польза не в том, что мы избавились от накладных расходов на вызов функции. Компилятору трудно что-то доказать про кусок памяти, когда работа с ним разбросана по множеству функций. А ведь если нет доказательства, нет и оптимизации (мы же не хотим, чтобы компилятор нам всё поломал?)

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

То есть, польза в том, что мы не обязаны использовать load/store для доступа к полям структуры: SROA позволяет убрать агрегатную структуру point, и заменить её на две локальных переменных p_x и p_y:

Hidden text
int main () {
  int p_x = 1;
  int p_y = 2;
  printf("(%d, %d)", p_x, p_y);
  return 0;
}

А дальше проталкивание констант позволяет убрать и их:

Hidden text
int main () {
  printf("(%d, %d)", 1, 2);
  return 0;
}

Возвращаясь к данной статье, автор рассматривает фрейм функции как агрегатную структуру с параметрами и локальными переменными, и заменяет где можно доступ к этой структуре через load/store на SSA-значения. А это, в свою очередь, уже позволит компилятору делать рассуждения.

Sign up to leave a comment.

Articles