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

  1. SSA форма

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

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

  4. Циклы

  5. CSE

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

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

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

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

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

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 -- это вычурный частный случай этой оптимизации, выполненный на цикле.

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


  1. Panzerschrek
    06.11.2023 05:49
    +3

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


    1. vkni
      06.11.2023 05:49

      Можно пример? В C++ же всё равно, с итераторами или без, но вам так или иначе надо делать проверки на выход за пределы. Ну, если это не foreach цикл.

      Кроме того, тут примеры сложнее, код из линейной алгебры со свёртками и т.д.


      1. Panzerschrek
        06.11.2023 05:49
        +1

        В C++ нету штатной проверки на диапазон при обращении к массивам. Так что там в значительной мере всё равно, что через итераторы обращаться, что через цикл со счётчиком и [].
        В Rust же есть встроенные типы стандартной библиотеки для итерации, которые вызываются при использовании оператора for. И вот внутри них проверок никаких всё ещё нету. Но вот в штатном операторе [] проверки уже есть.

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


        1. perfect_genius
          06.11.2023 05:49

          В C++ нету штатной проверки на диапазон при обращении к массивам.

          std::vector::at не оно?


          1. Panzerschrek
            06.11.2023 05:49

            Его использование опционально. Можно использовать [], который индексы не проверяет и (потенциально) стрелять тем самым в ногу. В Rust же [] проверяет корректность индекса, а методы обращения без проверки - только unsafe.


    1. xortator Автор
      06.11.2023 05:49

      Ну она не то чтобы утрачивает смысл. Она просто гарантированно сделается на каноническом коде. Или они прямо в парсере не вставляют эти чеки? Код парсера Раста я не читал.


  1. vkni
    06.11.2023 05:49

    Как всегда, огромное спасибо за цикл! Прочёл с огромным интересом.


    А вы можете ещё про MLIR рассказать немного?

    И вот в LLVM используется подход одного промежуточного представления между проходами. В этом есть и своя прелесть, и недостатки. А как вы смотрите на подход Nanopass, где промежуточные представления слегка различаются? (недавно пробежала статья о том, как это эффективно реализовывать в OCaml (статически-типизированном языке).


    1. xortator Автор
      06.11.2023 05:49

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

      Про Nanopass тоже мало что могу сказать, ибо руками не трогал. Судя по гитхабу, проект мёртв. Думаете, стоит изучить?


      1. vkni
        06.11.2023 05:49

        Про Nanopass тоже мало что могу сказать, ибо руками не трогал. Судя по гитхабу, проект мёртв. Думаете, стоит изучить?

        Nanopass — это Крузенштерн, «человек и пароход». Есть библиотека Nanopass, а есть подход, когда ваш компилятор от начала и до конца — это конвейер из очень простых проходов с разными IR. При этом в не очень оптимизирующем компиляторе их до полусотни. Соседние IR слегка различаются, но хорошо специфицированы как отдельные языки. Вот, собственно, и вся наука.

        https://github.com/IUCompilerCourse/Essentials-of-Compilation — вот курс, базирующийся на подходе Nanopass. Там для каждого прохода есть тестирующий интерпретатор (разумееся, похожие IR включены в группы, чтобы писать один интерпретатор для группы)


  1. no404error
    06.11.2023 05:49

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

    Ну и пускай. Разве, когда мы что-то делаем, мы не обязаны думать о том что мы делаем? Если выход за пределы диапазона не предусмотрен, то это не имеет значения, а если предусмотрен и может вызвать ошибки, то кодер чудак и сам виноват. Больше смахивает на "линейкой по рукам".


    1. xortator Автор
      06.11.2023 05:49
      +1

      Я имел в виду, что если вы хотите повторить на C++ что-то типа перехвата sigsegv для корректной обработки таких ситуаций (не "кодер чудак", а "эй, чудак, ты в такой-то массив полез по такому-то индексу"), то сделать этого вот так в лоб нельзя. Придётся вставлять ручные проверки.


  1. perfect_genius
    06.11.2023 05:49

    Вроде бы, в старых и уже заброшенных инструкциях x86, или же в каком-то другом процессоре пытались проверять границы аппаратно. Знать бы, почему не взлетело.


    1. xortator Автор
      06.11.2023 05:49

      Я о таком не слышал, но как минимум если два массива лежат подряд, то нет способа понять, вылезли вы за пределы или нет. Память-то там выделена.


      1. perfect_genius
        06.11.2023 05:49
        +1

        Похоже, это инструкция Bound:

        While the BOUND instruction does provide hardware bounds-checking, I understand it indirectly caused performance issues because it breaks the hardware branch predictor (according to a Reddit thread, but I'm unsure why), but also because it requires the bounds to be specified in a tuple in memory - that's terrible for performance - I understand at runtime it's no faster than manually having the instructions to do an "if index not in range [x,y] then signal the BR exception to the program or OS" (so you might imagine the BOUND instruction was added for the convenience of people who coded assembly by-hand, which was quite common in the 1980s).

        The BOUND instruction is still present in today's processors, but it was not included in AMD64 (x64) - likely for the performance reasons I explained above, and also because likely very few people were using it (and compilers could trivially replace it with a manual bounds check, that might have better performance anyway, as that could use registers).

        MPX is superior to BOUND in that it provides dedicated registers to store the bounds range so the bounds-check should be almost zero-cost compared to BOUND which required a memory access. On the Intel 486 the BOUND instruction takes 7 cycles (compare to CMP which takes only 2 cycles even if the operand was a memory address). In Skylake the MPX equivalent (BNDMKBNDCL and BNDCU) are all 1-cycle instructions and BNDMK can be amortized as it only needs to be called once for each new pointer).

        I cannot find any information on wherever or not AMD has implemented their own version of MPX yet (as of June 2017).