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

от автора

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

  1. SSA форма

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

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

  4. Циклы

  5. CSE

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

  7. Проверки диапазонов

  8. Размотка циклов

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

Проверки диапазонов в массивах

Предположим, что у нас есть код:

int array[LEN]; ... int x = array[idx];

Если 0 <= idx < LEN, то этот код прочитает соответствующий элемент массива. Однако что должно происходить, если idx < 0 или idx >= LEN? Тут каждый язык даёт свой ответ. В языках типа С или С++ это — неопределённое поведение. При исполнении такого кода нельзя ожидать, что случится что-то конкретное. В зависимости от того, что сделает компилятор, вы можете получить:

  • Краш

  • Зависание

  • Успешное прочтение какого-нибудь случайного значения

  • Всё вышеперечисленное в зависимости от каких-то малопонятных факторов

  • Любые другие неожиданные эффекты

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

int array[LEN]; ... if (idx < 0 || idx >= LEN) {   // Код обработки ошибки } int x = array[idx];

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

Теперь мы надёжно защищены от неопределённого поведения. Но чего нам это стоит? Давайте разберёмся.

Безопасно… И медленно

Давайте посмотрим, как выглядит ассемблерный код (на х86) доступа к массиву с и без проверки диапазона (на свежем clang).

#include <cstddef>  int read_without_check(int *array, int idx, unsigned LEN) {     __builtin_assume(LEN >= 0);     return array[idx]; }  int read_with_check(int *array, int idx, int LEN) {     __builtin_assume(LEN >= 0);     if (idx < 0 || idx >= LEN)         throw "out of bounds";     return array[idx]; }
read_without_check(int*, int, unsigned int):             # @read_without_check(int*, int, unsigned int)         movslq  %esi, %rax         movl    (%rdi,%rax,4), %eax         retq read_with_check(int*, int, int):                # @read_with_check(int*, int, int)         cmpl    %edx, %esi         jae     .LBB1_2         movl    %esi, %eax         movl    (%rdi,%rax,4), %eax         retq .LBB1_2:         pushq   %rax         movl    $8, %edi         callq   __cxa_allocate_exception@PLT         leaq    .L.str(%rip), %rcx         movq    %rcx, (%rax)         movq    typeinfo for char const*@GOTPCREL(%rip), %rsi         movq    %rax, %rdi         xorl    %edx, %edx         callq   __cxa_throw@PLT .L.str:         .asciz  "out of bounds"

Фактически добавилось две инструкции: cmpl и jae. Стоит объяснить, почему jae: если мы знаем, что LEN >= 0, то две проверки

idx < 0 || idx >= LEN

можно заменить на

(unsigned) idx >= (unsigned) LEN

Если idx был отрицательный, то при преобразовании в беззнаковый тип это будет огромное положительное число, заведомо больше LEN. На практике компиляторы обычно знают, что размер массива — неотрицательное число.

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

#include <cstddef>  int read_loop_without_check(int *array, int idx, unsigned LEN) {     __builtin_assume(LEN >= 0);     int sum = 0;     for (int idx = 0; idx < 8; idx++) {         sum += array[idx];     }     return sum; }  int read_loop_with_check(int *array, int idx, int LEN) {     __builtin_assume(LEN >= 0);     int sum = 0;     for (int idx = 0; idx < 8; idx++) {         if (idx < 0 || idx >= LEN)             throw "out of bounds";         sum += array[idx];     }     return sum; }

Здесь мы читаем восемь элементов массива за раз. В первом случае можно сразу сделать это одной векторной инструкцией:

define dso_local noundef i32 @read_loop_without_check(int*, int, unsigned int)(ptr nocapture noundef readonly %0, i32 noundef %1, i32 noundef %2) local_unnamed_addr #0 {   %4 = load <8 x i32>, ptr %0, align 4   %5 = tail call i32 @llvm.vector.reduce.add.v8i32(<8 x i32> %4)   ret i32 %5 }

Если реальная длина массива окажется меньше 8, то и исходный код, и новый имели неопределённое поведение, а значит, такое преобразование валидно.

Однако во втором случае при длине массива меньше 8 изначально поведение было определённое: вылетало исключение. Поэтому зачитать 8 элементов одной векторной инструкции с риском вылезти за границы аллоцированной памяти в этом случае нельзя. Собственно, вариантов действий тут всего два:

  • Честно исполнить код как есть, выполнив проверку до 8 раз

  • Каким-то образом соптимизировать проверку

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

Удаление на основе доминирующих проверок

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

for (int idx = 0; idx < 10000; idx++) {   sum += read_with_check(array, idx + 1, LEN);   sum += read_with_check(array, idx, LEN); }

Здесь мы имеем две проверки диапазона против idx + 1 и потом против idx, причём первая исполняется раньше второй (строго говоря — доминирует над второй).

Если мы уже дошли до исполнения второй проверки, то это значит, что первая благополучно прошла. Поэтому нам уже известно, что 0 <= idx + 1 < LEN. Также, из условий цикла, мы знаем, что 0 <= idx < 10000. И теперь нам нужно избавиться от проверки 0 <= idx < LEN. Давайте немного преобразуем известные факты:

 0 <= idx + 1 < LEN  && 0 <= idx < 10000 // Отнимем 1 от первого неравенства -1 <= idx < LEN - 1  && 0 <= idx < 10000 // объединим оба неравенства max(-1, 0) <= idx < min(LEN - 1, 10000) // сократим 0 <= idx < min(LEN - 1, 10000)

Теперь нам осталось доказать импликацию искомого утверждения из известного. 0 <= idx у нас уже есть, осталось доказать, что

idx < min(LEN - 1, 10000) <= LEN - 1 && LEN >= 0 // LEN - 1 вычисляется без переполнения idx < min(LEN - 1, 10000) <= LEN - 1 < LEN

Таким образом, имея некоторые известные факты на момент исполнения проверки, мы доказали, что условие этой проверки с учётом этих фактов всегда истинно. Таким образом, исходный пример

for (int idx = 0; idx < 10000; idx++) {   sum += read_with_check(array, idx + 1, LEN);   sum += read_with_check(array, idx, LEN); }

можно упростить до

for (int idx = 0; idx < 10000; idx++) {   sum += read_with_check(array, idx + 1, LEN);   sum += read_without_check(array, idx, LEN); }

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

  • Берём очередную проверку, которую требуется удалить

  • Находим все проверки, которые над ней доминируют

  • Пытаемся доказать, что из истинности доминирующего утверждения следует истинность и того утверждения, которое хотим доказать, и если это так — удаляем проверку

В LLVM этим занимаются такие оптимизационные пассы, как Constraint Elimination (для любых проверок) и Ind Var Simplify (для проверок против индукционных переменных).

Замена на инвариантную проверку

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

for (int idx = start; idx >= 0; idx--) {     <много кода>     sum += read_with_check(array, idx, LEN);     <много кода> }

Здесь мы можем упасть прямо на первой итерации, если start >= LEN. Но интересно то, что если на первой итерации оказалось, что 0 <= idx = start < LEN, то и на любой другой итерации 0 <= idx < start < LEN. Чтобы понять логику оптимизации, давайте мысленно произведём полную размотку цикла:

sum += read_with_check(array, start, LEN); sum += read_with_check(array, start - 1, LEN); sum += read_with_check(array, start - 2, LEN); ... sum += read_with_check(array, 1, LEN); sum += read_with_check(array, 0, LEN);

Теперь видно, что ситуация похожа на ту, что мы рассматривали в предыдущем примере: для каждой проверки (кроме первой) мы проверяем диапазон сначала для idx + 1, а потом для idx. Значит, последнюю проверку диапазона можно удалить на основании предпоследней, предпоследнюю — на основании той, что перед ней, и так далее. В сухом остатке удалить можно все проверки, кроме самой первой, которая действительно может упасть.

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

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

for (int idx = start; idx >= start - 1; idx--) {     <много кода>     sum += read_with_check(array, start, LEN);     <много кода> } for (int idx = start - 1; idx >= 0; idx--) {     <много кода>     sum += read_without_check(array, idx, LEN);     <много кода> }

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

    for (int idx = start; idx >= 0; idx--) {       <много кода>       if (idx < 0 || idx >= LEN)           throw "out of bounds";       sum += array[idx];       <много кода>     }

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

    for (int idx = start; idx >= 0; idx--) {         <много кода>         if (start < 0 || start >= LEN)             throw "out of bounds";         sum += array[idx];         <много кода>     }

В LLVM заменой таких проверок на инвариантные занимается Ind Var Simplify, пилинг выполняет Loop Unroll.

Как избавиться от инвариантной проверки в цикле, мы рассматривали в предыдущей статье.

Разбиение диапазона индукционной переменной

В примерах выше мы уже рассматривали проверки, совершаемые против индукционной переменной. Индукционная переменная — это переменная, изменяющаяся в цикле по формуле

iv(N) = start + step * N

здесь start и step — цикловые инварианты, а N — номер итерации цикла. Типичный пример индукционной переменной — цикловой счётчик, индукционными переменными также являются их производные (например, в цикле по i переменная 2 * i + 3 также является индукционной, потому что представима в виде 3 + 2 * N.

Давайте рассмотрим ситуацию, когда проверка диапазона выполняется против индукционной переменной. Например:

for (int idx = start; idx < end; idx += step) {   <много кода>   sum += read_with_check(array, idx, LEN);   <много кода> }

Давайте для простоты считать step > 0, а также считать доказанным, что idx не переполняется в ходе этих вычислений (в противном случае логика усложнится, но принципиально не изменится). Идея оптимизации состоит в следующем: давайте проанализируем проверку диапазона и поймём, при каких idx она не упадёт. В нашем случае проверка не упадёт, если 0 <= idx < LEN. Мы ничего не знаем о связи start и end с этими величинами, но тем не менее можем посчитать пересечение безопасного диапазона и пространства итерации индукционной переменной:

[0, LEN) ∩ [start, end) = [max(0, start), min(LEN, end))

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

[start, max(0, start)) [max(0, start), min(LEN, end)) // здесь проверку можно не делать [min(LEN, end), end)

С точки зрения модификации кода, мы получим

int idx = start; for (; idx < max(0, start); idx += step) {   <много кода>   sum += read_with_check(array, idx, LEN);   <много кода> } for (; idx < min(LEN, end); idx += step) {   <много кода>   sum += read_without_check(array, idx, LEN);   <много кода> } for (; idx < end; idx += step) {   <много кода>   sum += read_with_check(array, idx, LEN);   <много кода> }

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

В LLVM этим занимается пасс Inductive Range Check Elimination.

Удаление с помощью спекулятивной проверки

Данный класс оптимизаций применим тогда, когда нам известна семантика кода, который выполняется в случае ошибки. Например, если мы имеем дело с Java (так, например, поступает основанный на LLVM компилятор Falcon в Azul Prime VM), обработка ошибки заключается в том, что мы уходим из скомпилированного кода в интерпретатор, или совершаем так называемую деоптимизацию.

   for (int idx = start; idx < end; idx += step) {       <много кода>       if (idx < 0 || idx >= LEN)           call deopt(); // мы уйдём в интерпретатор и будем там заново исполнять эту операцию       sum += array[idx];       <много кода>     }

Что интересно, уход в интерпретатор — это корректное действие в любой точке программы. Можно ведь вообще не исполнять JIT-компилированный код, а вместо этого всё считать в интерпретаторе. Это медленно, но корректно.

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

В нашем примере несложно выписать достаточные условия, при которых проверка не упадёт:

0 <= start < LEN && 0 <= end <= LEN

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

Важно понять, что это условие избыточное. Например, если end = 103, LEN = 101, а step = 4, то условие не будет выполнено, хотя в реальности выхода за границу массива не произойдёт, потому что idx всегда будет делиться на 4, и последним индексом, по которому произойдёт реальное чтение, будет 100, а не 101.

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

  for (int idx = start; idx < end; idx += step) {       <много кода>       if (start < 0 || start >= LEN || end < 0 || end > LEN)           call deopt(); // мы уйдём в интерпретатор и будем там заново исполнять эту операцию       sum += array[idx];       <много кода>     }

Мы снова получили инвариантную проверку, не зависящую от индукционной переменной. Как с ними работать — описано в предыдущей статье.

В LLVM такими вещами занимается пасс Loop Predication.

Заключение

Уже по тому, сколько существует разных способов борьбы с проверками диапазонов (наверняка есть и такие, которые я в этой статье не описал), ясно, что проблема достаточно серьёзная. Неудалённые проверки в цикле — помеха не только для векторизации, но и для множества других оптимизаций (например, в статье про UB я объяснял, почему нельзя переносить некоторые инструкции сквозь проверки). Интересующимся, помимо прочего, рекомендую прочитать про оптимизацию Guard Widening в LLVM. Она не то чтобы целится именно на проверки диапазонов, но даёт интересный взгляд на спекулятивные оптимизации. В некотором смысле, Loop Predication — это вычурный частный случай этой оптимизации, выполненный на цикле.


ссылка на оригинал статьи https://habr.com/ru/articles/771992/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *