Это цикл статей об оптимизирующих компиляторах вообще и LLVM в частности. Смотри все статьи данного цикла:

  1. SSA форма

  2. Доминирование

  3. Неопределённое поведение

  4. Циклы

  5. CSE

  6. Цикловые инварианты

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

По традиции, картинка с котами от Кандинского
По традиции, картинка с котами от Кандинского

Что такое цикловой инвариант

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

int foo(int n) {
  int sum = 0;
  for (int i = 0; i < n; i++) {
    sum += i + n * 2;
  }
  return sum;
}

Как несложно заметить, на каждой итерации мы вычисляем n * 2, чтобы потом добавить это выражение к сумме. Поскольку значение n на протяжении функции не меняется, выражение n * 2 также не будет меняться и зависеть от номера итерации цикла. Вполне очевидно, что его можно бы было посчитать однажды, произведя простейшую оптимизацию:

int foo(int n) {
  int sum = 0;
  int tmp = n * 2;
  for (int i = 0; i < n; i++) {
    sum += i + tmp;
  }
  return sum;
}

Тогда при исполнении программы мы сэкономим n - 1 умножение, что выгодно при n > 1. Итак, начнём с определений.

Цикловым инвариантом называется выражение, значение которого не зависит от номера итерации цикла.

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

Теперь рассмотрим другой пример:

int foo(int n) {
  int sum = 0;
  for (int i = 0; i < n; i++) {
    if ((n / 8) > 100) {
      sum += i + n * 2;
    } else {
      sum -= i;
    }
  }
  return sum;
}

Как мы видим, выражение (n / 8) > 100 является инвариантом, поэтому ветвление по нему называется инвариантным ветвлением. Его особенность в том, что, войдя в цикл и однажды пройдя по одной из веток, мы на каждой итерации будем идти в ту же ветку.

В дальнейших разделах я расскажу, как современные компиляторы умеют оптимизировать такой код.

Вынос инвариантных выражений из цикла

Давайте рассмотрим наш первый пример и сразу перепишем его в SSA-форме:

define i32 @foo(i32 %n) {
entry:
  br label %header

header:                                           ; preds = %backedge, %entry
  %sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
  %i = phi i32 [ 0, %entry ], [ %i.next, %backedge ]
  %loop.cond = icmp slt i32 %i, %n
  br i1 %loop.cond, label %backedge, label %done

backedge:                                         ; preds = %header
  %n2 = mul nsw i32 %n, 2
  %i.plus.n2 = add nsw i32 %i, %n2
  %sum.next = add nsw i32 %sum, %i.plus.n2
  %i.next = add nsw i32 %i, 1
  br label %header

done:                                             ; preds = %header
  ret i32 %sum
}

Выражение в SSA-форме является цикловым инвариантом, если выполнено одно из двух условий:

  • Оно строго доминирует над заголовком цикла (т.е. вычислено до входа в цикл);

  • Все операнды данного выражения являются цикловыми инвариантами.

В примере выше видно, что выражения %n и 2 доминируют над заголовком цикла (т.е. вычислены ранее), а выражение %n2 = mul nsw i32 %n, 2 соответствует второму случаю (все операнды -- инварианты)

Понятно, что если цикл делает хотя бы две итерации, то вычислять инвариантные выражения за его пределами -- скорее всего выгодно. Естественным образом встаёт вопрос -- если не в цикле, то где?

В зависимости от обстоятельств, может быть выгодно вычислять инвариантные выражения как до входа в цикл (например, в предзаголовочном блоке), так и после выхода из цикла (например, в выходном блоке). В зависимости от того, что мы выбираем, слегка меняется и алгоритм оптимизации, которая занимается выносом. Обычно разделяют подъём (hoisting) и спуск (sinking) инвариантных выражений по дереву доминирования. В LLVM обеими преобразованиями целенаправленно занимается оптимизационный пасс LICM (Loop Invariant Code Motion, Движение инвариантного циклового кода), однако в разных формах подобные преобразования делают и другие оптимизации. Рассмотрим, как это делается.

В обоих случаях алгоритм можно описать следующей схемой:

  • Обходим блоки цикла в определённом порядке

  • Обходим инструкции внутри блока в определённом порядке

  • Всякий раз, когда мы видим инструкцию, которая является цикловым инвариантом, перемещаем её в целевой блок и продолжаем

Порядок, в котором обходятся блоки и инструкции, определяются соображениями оптимальности. Желательно, чтобы алгоритм сошёлся за один проход, после чего в цикле не должно остаться инвариантных выражений. В случае подъёма инструкций (т.е. выноса из тела цикла в предзаголовок) мы хотим обеспечить, чтобы на момент обработки очередной инструкции все её инвариантные операнды уже были вынесены из цикла, чтобы проверка данной инструкции была дешёвой, а её вынос -- законным (напомним, что все операнды инструкции обязаны над ней доминировать). С другой стороны, в случае спуска мы хотим, чтобы все инвариантные пользователи данной инструкции к моменту её обработки уже были перемещены вниз, поскольку мы обязаны поддерживать свойство SSA-формы, согласно которому операнд доминирует своих пользователей. Рассмотрим это на примере:

define i32 @example_hoist_sink(i32 %a, i32 %b, i32 %c) {
entry:
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  %sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
  %cond = icmp slt i32 %iv, 12
  %x = mul i32 %a, %b
  br i1 %cond, label %if.true, label %backedge

if.true:                                          ; preds = %header
  %y = mul i32 %x, %c
  %z = mul i32 %y, %c
  %sum.incremented = add i32 %sum, %x
  %if.true.cond = icmp eq i32 %x, 1000
  br i1 %if.true.cond, label %backedge, label %side.exit

backedge:                                         ; preds = %if.true, %header
  %sum.next = phi i32 [ %sum, %header ], [ %sum.incremented, %if.true ]
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 %sum

side.exit:                                        ; preds = %if.true
  ret i32 %z
}

Здесь значения %a, %b и доминируют над заголовком цикла (и потому являются инвариантами), а значения %x, %y, %z и %if.true.cond вычислены внутри цикла, но зависят только от инвариантных аргументов. Давайте сначала рассмотрим, как выглядит подъём инвариантов в этом случае. Алгоритм подъёма обходит блоки в порядке RPOT (то есть в топологическом порядке) и инструкции в блоке сверху вниз. Такой обход обеспечивает требуемое свойство, чтобы инвариантные аргументы были обработаны до своих пользователей. В данном случае, алгоритм сработает так:

  • RPOT для блоков данного цикла - header, if.true, backedge. Они будут обрабатываться в этом порядке, инструкции в блоках обрабатываются сверху вниз.

  • При обработке блока header выяснится, что все аргументы инструкции %x -- инварианты, поэтому она будет перемещена в блок %entry.

  • При обработке блока if.true сначала будет обработана инструкция %y и перемещена вверх, поскольку к этому моменту её операнды %x и уже оба инварианты, а потом -- инструкция %z. Потом будет вынесено также условие %if.true.cond.

В результате мы получим следующий код:

define i32 @example_hoist_sink(i32 %a, i32 %b, i32 %c) {
entry:
  %x = mul i32 %a, %b
  %y = mul i32 %x, %c
  %z = mul i32 %y, %c
  %if.true.cond = icmp eq i32 %x, 1000
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  %sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
  %cond = icmp slt i32 %iv, 12
  br i1 %cond, label %if.true, label %backedge

if.true:                                          ; preds = %header
  %sum.incremented = add i32 %sum, %x
  br i1 %if.true.cond, label %backedge, label %side.exit

backedge:                                         ; preds = %if.true, %header
  %sum.next = phi i32 [ %sum, %header ], [ %sum.incremented, %if.true ]
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 %sum

side.exit:                                        ; preds = %if.true
  ret i32 %z
}

Довольны ли мы такой оптимизацией? С одной стороны, мы существенно снизили объём вычислений во время исполнения цикла. С другой -- могло статься, что мы никогда не ходили в блок if.true, и таким образом, все вынесенные значения нам на самом деле не пригождались никогда. В этом случае выгоднее было бы просто переместить %x в блок %if.true, убрав его из заголовка, и мы бы получили больший выигрыш. Как же быть?

На самом деле, вынос %x в предзаголовок -- это всё равно выгоднее, чем считать его на каждой итерации в заголовке, даже если это и не оптимальное решение. Что касается %y и %z, то они нужны только в выходном блоке %side.exit, поэтому к ним разумнее было бы применить не подъём, а спуск. Оставлю вопрос о том, какой обход блоков и инструкций нужно использовать при спуске, для самостоятельного решения читателем. В оптимальном решении %y и %z будут спущены в выходной блок. Что касается инструкций %x и %if.true.cond, то их выгодно либо поднять в предзаголовок, либо опустить в блок %if.true, в зависимости от того, какой из блоков горячее. Узнать это можно, если есть профилировочная информация или какие-то дополнительные знания относительно условия цикла. В данном случае можно статически доказать, что блок if.true выполнится целых 12 раз, и поэтому выгодно поднять их в заголовок. Итак, оптимальное решение для данной задачи выглядит следующим образом:

define i32 @example_hoist_sink(i32 %a, i32 %b, i32 %c) {
entry:
  %x = mul i32 %a, %b
  %if.true.cond = icmp eq i32 %x, 1000
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  %sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
  %cond = icmp slt i32 %iv, 12
  br i1 %cond, label %if.true, label %backedge

if.true:                                          ; preds = %header
  %sum.incremented = add i32 %sum, %x
  br i1 %if.true.cond, label %backedge, label %side.exit

backedge:                                         ; preds = %if.true, %header
  %sum.next = phi i32 [ %sum, %header ], [ %sum.incremented, %if.true ]
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 %sum

side.exit:                                        ; preds = %if.true
  %y = mul i32 %x, %c
  %z = mul i32 %y, %c
  ret i32 %z
}

Инвариантные загрузки из памяти

Стоит сделать отдельную оговорку про случай, когда мы имеем дело с памятью. Работа с ней не в полной мере отражается SSA-формой, и в LLVM IR моделируется с помощью свойств чтения и записи памяти. В настоящее время есть попытка исправить ситуацию при помощи Memory SSA, но это всё равно надстройка над IR, а не его часть.

Рассмотрим простой пример:

define void @example_hoist_load(ptr dereferenceable(32) %p1, ptr dereferenceable(32) %p2) {
entry:
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  %cond = call i1 @cond()
  br i1 %cond, label %if.true, label %if.false

if.true:                                          ; preds = %header
  store i32 %iv, ptr %p1, align 4
  br label %backedge

if.false:                                         ; preds = %header
  %loaded.value = load i32, ptr %p2, align 4
  br label %backedge

backedge:                                         ; preds = %if.false, %if.true
  %phi = phi i32 [ %iv, %if.true ], [ %loaded.value, %if.false ]
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret void
}

declare i1 @cond() #0

attributes #0 = { memory(read) }

Здесь в зависимости от условия %cond мы либо пишем память по указателю %p1, либо читаем по указателю %p2. Можем ли мы вынести загрузку из %p2 в предзаголовок?

На самом деле, ответ на этот вопрос -- сложный, и зависит от множества факторов. Такой вынос возможен, если всякий раз, когда мы читаем значение из этой памяти, оно одно и то же. Как минимум это возможно, если мы докажем один из фактов:

  • Все записи в цикле не делаются в эту память (т.е. области памяти, куда указывают %p1 и %p2, не пересекаются)

  • Функция cond() обладает свойством монотонности: она может вернуть сначала false, потом true, но не наоборот. Если это так, то все чтения %p2 будут сделаны до записей в %p1, поэтому вынести чтение можно.

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

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

Ветвления по инвариантным условиям

Итак, с выносом инструкций всё более или менее ясно. Но можно ли выносить из цикла сложный код с передачей потока управления, если все переходы в нём -- инвариантные? На самом деле, есть несколько способов это сделать, и я попробую их перечислить, хотя и не обещаю, что покрою здесь все нюансы. В LLVM этим явно занимаются тот же LICM и Loop Unswitching, и подозреваю, что какие-то элементы подобного толка иногда могут выполнять и другие трансформации.

Давайте рассмотрим самый простой пример:

define i32 @example_hoist_cfg_simple(i32 %n, i32 %m) {
entry:
  %cond = icmp eq i32 %n, %m
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  br i1 %cond, label %backedge, label %side.exit

backedge:                                         ; preds = %header
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 -1

side.exit:                                        ; preds = %header
  ret i32 %iv
}

Здесь в цикле присутствует ветвление по инвариантному условию, причём никакой важный код до этого ветвления не исполняется. На самом деле, мы либо на первой итерации цикла сразу выйдем в %side.exit, если %cond == false, либо же не попадём в этот блок никогда. В данном случае можно поднять ветвление так же, как мы ранее поднимали выражения, в предзаголовок. Давайте механически проделаем эту операцию:

define i32 @example_hoist_cfg_simple(i32 %n, i32 %m) {
entry:
  %cond = icmp eq i32 %n, %m
  br i1 %cond, label %preheader, label %side.exit

preheader:
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %preheader ], [ %iv.next, %backedge ]
  br label %backedge

backedge:                                         ; preds = %if.true, %header
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 -1

side.exit:
  ret i32 %iv ; ТАК НЕЛЬЗЯ!
}

Итак, мы превратили условный переход в цикле в безусловный и подняли ветвление вверх. В этом коде есть проблема: если раньше мы могли использовать значение %iv в блоке %side.exit, потому что между ними существовало отношение доминирования, то теперь так делать нельзя. При переходе entry -> side.exit мы никогда не вычисляли значение %iv! Поэтому перед тем, как выполнять такое преобразование, нужно убедиться, что все значения, используемые в блоке %side.exit, будут там доступны после оптимизации. Например, здесь можно использовать знание о том, что мы могли попасть в блок %side.exit только на нулевой итерации, и заменить ret i32 %iv на ret i32 0. Также, чтобы два раза не вставать, мы можем избавиться и от безусловного перехода, склеив блоки header и backedge в один. Итоговый оптимизированный код выглядит так:

define i32 @example_hoist_cfg_simple(i32 %n, i32 %m) {
  %cond = icmp eq i32 %n, %m
  br i1 %cond, label %preheader, label %side.exit

preheader:                                        ; preds = %entry
  br label %header

header:                                           ; preds = %header, %preheader
  %iv = phi i32 [ 0, %preheader ], [ %iv.next, %header ]
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %header
  ret i32 -1

side.exit:                                        ; preds = %entry
  ret i32 0
}

Давайте слегка усложним пример. Теперь перед ветвлением, которое мы хотим вынести, существует инструкция со сторонним эффектом:

define i32 @example_hoist_cfg_side_effects(i32 %n, i32 %m) {
entry:
  %cond = icmp eq i32 %n, %m
  br label %header

header:                                           ; preds = %backedge, %entry
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  call void @side_effect_1()
  call void @side_effect_2()
  call void @side_effect_3()
  br i1 %cond, label %backedge, label %side.exit

backedge:                                         ; preds = %header
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 -1

side.exit:                                        ; preds = %header
  ret i32 %iv
}

declare void @side_effect_1()

declare void @side_effect_2()

declare void @side_effect_3()

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

define i32 @example_hoist_cfg_side_effects(i32 %n, i32 %m) {
entry:
  %cond = icmp eq i32 %n, %m
  br i1 %cond, label %preheader, label %header.copy

preheader:                                        ; preds = %entry
  br label %header

header:                                           ; preds = %backedge, %preheader
  %iv = phi i32 [ 0, %preheader ], [ %iv.next, %backedge ]
  call void @side_effect_1()
  call void @side_effect_2()
  call void @side_effect_3()
  br label %backedge

backedge:                                         ; preds = %header
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %backedge
  ret i32 -1

side.exit:                                        ; preds = %header.copy
  ret i32 %iv.copy

header.copy:                                      ; preds = %entry
  %iv.copy = phi i32 [ 0, %entry ]
  call void @side_effect_1()
  call void @side_effect_2()
  call void @side_effect_3()
  br label %side.exit
}

declare void @side_effect_1()

declare void @side_effect_2()

declare void @side_effect_3()

Или, после упрощений и склеиваний безусловных переходов:

define i32 @example_hoist_cfg_side_effects(i32 %n, i32 %m) {
entry:
  %cond = icmp eq i32 %n, %m
  br i1 %cond, label %header, label %header.copy

header:                                           ; preds = %header, %entry
  %iv = phi i32 [ %iv.next, %header ], [ 0, %entry ]
  call void @side_effect_1()
  call void @side_effect_2()
  call void @side_effect_3()
  %iv.next = add i32 %iv, 1
  %loop.cond = icmp slt i32 %iv.next, 1000
  br i1 %loop.cond, label %header, label %done

done:                                             ; preds = %header
  ret i32 -1

header.copy:                                      ; preds = %entry
  call void @side_effect_1()
  call void @side_effect_2()
  call void @side_effect_3()
  ret i32 0
}

declare void @side_effect_1()

declare void @side_effect_2()

declare void @side_effect_3()

Но иногда бывает так, что ветвление по инвариантному условию не выходит из цикла, или объём кода, который придётся раскопировать для его удаления, очень велик. В этом случае делается полная версия данной трансформации, которую проще всего описать схематично. Изначальный код:

cond = ...
for (...) {
  // A
  if (cond) {
    // B
  } else {
    // C
  }
  // D
}

превращается в

cond = ...
if (cond) {
  for (...) {
    // A
    if (true) {
      // B
    } else {
      // C
    }
    // D
  }
} else {
  for (...) {
    // A
    if (false) {
      // B
    } else {
      // C
    }
    // D
  }
}

В этом случае объём и сложность фрагментов кода A, B, C, D не имеют особого значения. Трансформация выполняется чисто механически, дальнейшие трансформации избавятся от ветвлений по константным условиям и соответствующим образом преобразуют все финоды. Единственным недостатоком здесь является удвоение размера кода, что негативно сказывается на времени компиляции и объёме сгенерированного кода. Поэтому прибегать к этой технике (она называется non-trivial unswitching) стоит только в крайнем случае и ради больших выигрышей в производительности.

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

Заключение

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

Комментарии (11)


  1. Rsa97
    01.10.2023 11:36
    +1

    Примеры не самые удачные. В обоих случаях цикл вообще не нужен,


    int foo(int n) {
        return (n - 1) * n / 2 + n * n * 2;
    }

    int foo(int n) {
        return n > 807
            ? (n - 1) * n / 2 + n * n * 2
            : (1 - n) * n / 2;
    }


    1. xortator Автор
      01.10.2023 11:36

      Это так, но оптимизации, которые к этому приводят, мы в рамках этой статьи не рассматриваем. Это игрушечные примеры, которые нужны только чтобы показать, как идёт работа с инвариантами. Я же не ставил цели показать, как именно эти программы заоптимизировать до максимума.


  1. aamonster
    01.10.2023 11:36
    +2

    Практический вопрос: насколько можно закладываться на эту оптимизацию и писать инвариантные выражения внутри цикла? (Разумеется, речь про случаи, где ручное вытаскивание делает исходник менее читабельным, а не про случай, когда этот инвариант имеет свой смысл и запись его в переменную заведомо оправдана)

    При использовании любого компилятора на llvm?


    1. xortator Автор
      01.10.2023 11:36
      +1

      Если речь не про вынос ветвлений и не про память, то практически любой LLVM-ный компилятор всё вынесет (в режиме -O2 и выше). В таких случаях я бы рекомендовал не приносить читаемость в жертву.

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

      К тому же, у человека может быть знание, которого у компилятора нет в принципе. Если вы посмотрите на пример про load/store по двум разным указателям, то у компилятора нет способа доказать, что они разные. Но если вы по какой-то причине знаете, что эту функцию вызывают только таким образом, что p1 и p2 никогда не перекрываются, то вы можете соптимизировать её вручную, используя это знание.

      В любой ситуации можно использовать чудо-сайт https://godbolt.org/, чтобы посмотреть, что на самом деле происходит.


      1. Cerberuser
        01.10.2023 11:36
        +1

        Если вы посмотрите на пример про load/store по двум разным указателям, то у компилятора нет способа доказать, что они разные.

        Если, конечно, нет аналога restrict из C или &mut T из Rust.


        1. xortator Автор
          01.10.2023 11:36
          +2

          Ну да, есть всякие способы сообщить что-то про алиасинг, но пока что я ограничиваюсь формулировкой "всё сложно", имея в виду, что их реально много и там хватит на отдельную статью.


  1. vk6677
    01.10.2023 11:36

    Для чего циклы, если их можно заменить арифметическими рядами ?


    1. punzik
      01.10.2023 11:36

      А если цикл с сайд-эффектами?


    1. Cerberuser
      01.10.2023 11:36

      Чтобы сначала повыносить из них всё, что выносится, а потом уже то, что осталось, свернуть в замкнутую формулу, по идее.


    1. vanxant
      01.10.2023 11:36

      Да это примеры синтетические, без внешних зависимостей. В реальности вы будете итерироваться по какому-то внешнему контейнеру, и ваши x с y - это будут поля объектов в этом контейнере


    1. xortator Автор
      01.10.2023 11:36
      +2

      Дело в том, что в LLVM (да и большинстве других компиляторов) каждая оптимизация отвечает только за одну часть работы. LICM не умеет сокращать сумму прогрессий, GVN не умеет дуплицировать циклы, а векторизатор не пытается решать квадратные уравнения. Если оптимизации на вход пришёл какой-то IR, она будет пытаться с ним сделать то, что умеет она, поскольку каждая отдельная оптимизация не знает (да и не может знать) всего, что ещё можно с ним сделать.

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