В предыдущих сериях

Микрооптимизации:

  1. Сказка про Method as Parameter #dotnet #methods #gc

  2. Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview

  3. Пародия на замыкания #dotnet #methods #gc

  4. yield return #dotnet #il-code

Про тредпул:

  1. ThreadPool.Intro #dotnet #threadpool

  2. ThreadPool. async/await #dotnet #threadpool #il_code

Про низкоуровневое:

  1. Reciprocal throughput #microoptimization #low-level

Разное:

  1. Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization

Конвейер трудится изо всех сил, чтобы повысить производительность твоей программы. А злобные «if»'ы нагло врываются посреди его работы и всё портят!

Насколько полезен конвейер в современных ЭВМ? Как сильно мешаются ветвления в коде, которые ты написал? И как архитекторы процессоров сглаживают ущерб, который «if»'ы наносят по производительности программ?

Художественное отступление, шутка на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом совершенно не обязательно.

— Тамара Ивановна-а! — кричал долговязый парень через весь цех.

— Чего тебе — недовольная вахтёрша уже уходила со своего очень важного поста и не желала долго тут задерживаться.

— Какая бригада сегодня в плавильном цеху?

— Перед твоей линией Рыжий сегодня со своими оболтусами — Тамара Ивановна закрыла на клюшку свой пост и вышла из конвейерного зала.

Работа на конвейере не сложная. Надо осматривать детали, которые поступают на конвейере. Если деталь качественная, она должна попасть на левую ленту. А если некачественная, то на правую.

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

Долговязый парень открыл свою засаленную тетрадь. Напротив графы «Рыжий» была неаккуратная запись: «пьяницы, много брака, по статистике больше 60%». У него, казалось, всё было расписано и зафиксировано. Он ко всему готовился заранее, любил порядок и предсказуемость. Над его рабочим местом красовались четыре грамоты: «Самому эффективному работнику конвейерного цеха!». И он очень хотел получить в этом месяце пятую.

Раз уже было известно, что по статистике сегодня брака будет больше 60%, тугую педаль пришлось бы нажимать слишком часто. Пара минут работы с гаечным ключом и молотком инвертировали конвейер — теперь по умолчанию детали будут попадать на правый конвейер, с браком. А значит, сегодня тугую педаль придётся нажимать всего в примерно 40% случаев.

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

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

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

Про предсказание переходов, или branch prediction, написано очень много статей. А ещё, написано и всё ещё продолжают писать очень много научных работ. А архитекторы процессоров до сих пор умудряются его улучшать на практике. Поэтому, перед тем как продемонстрировать что-то своё и весёлое, я просто обязан отправить вас прочитать какую-нибудь готовую и хорошую статью, подробно рассказывающую, как работают предсказатели переходов. Я счел вот эту достаточно старую статью на Хабре простой и понятной для любого программирующего человека, достаточно качественной и полной, чтобы хорошенько погрузиться в эту тему, красивой и весёлой, с хорошими картинками. Это не полная суровая научная статья. Это так, чтобы ознакомиться «на пальцах».

А в этой статье давайте просто убедимся, что конвейер и предсказатель переходов существуют, и что они реально полезны. А ещё, внезапно, рассмотрим, как можно извлекать практическую пользу из branch prediction'а!

А теперь давайте сделаем что-нибудь весёлое

В коде, который мы пишем, обязательно присутствуют условия. Самый популярный вид этой операции в большинстве ЯП, наверное, такой: if ... else ....

А в ЭВМ, на которых исполняются наши программы, есть конвейер. Дак вот, конвейер и if друг с другом не дружат. If частенько вставляет палки в колёса конвейеру. И архитекторы ЭВМ изощрятся очень интересными способами, чтобы как можно сильнее подружить if'ы с конвейером.

Вы думаете, что это шутки? Что в вашем прикладном коде это «всё равно не заметно»? Что настолько низкоуровневый механизм, как предсказатель переходов, невозможно отследить в прикладных приложениях? Или что это какая-то очередная малополезная фигня на эпсилон производительности программ? Ха. Сейчас я вам покажу всю мощь конвейера!

Пусть у нас есть массив случайных чисел. И мы хотим посчитать, сколько в нём чётных. Код, который это делает, можно написать так:

public static int GetEvensCount(int[] arr)
{
    var evens = 0;
    for (var i = 0; i < arr.Length; i++)
        if (arr[i] % 2 == 0)
            evens++;
 
    return evens;
}

А что, если предварительно просто отсортировать тот же массив чисел по критерию чётности, и выполнить точно такой же код? Да, в рамках задачи «посчитать число четных чисел» достаточно глупо сортировать массив по критерию четности. Но мы не решаем эту задачу, мы пытаемся нащупать предсказатель переходов!

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

private int[] orderedArray = new int[N];
private int[] unorderedArray = new int[N];
private const int N = 100_000;
 
[GlobalSetup]
public void Setup()
{
    var random = new Random();
    for (var i = 0; i < N; i++)
    {
        unorderedArray[i] = random.Next();
    }
 
    orderedArray = unorderedArray.OrderBy(x => x % 2).ToArray();
}
 
[Benchmark]
public int Ordered()
{
    return GetEvensCount(orderedArray);
}
 
[Benchmark]
public int Unordered()
{
    return GetEvensCount(unorderedArray);
}
 
public static int GetEvensCount(int[] arr)
{
    var evens = 0;
    for (var i = 0; i < arr.Length; i++)
        if (arr[i] % 2 == 0)
            evens++;
 
    return evens;
}

И вот такой результат:

|        Method |      Mean |
|-------------- |----------:|
|       Ordered |  56.08 us |
|     Unordered | 403.58 us |

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

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

Проморгали? Давайте разбираться.

Как выглядит цепочка «прохождения или непрохождения» if'а в случае отсортированного массива? Как-то так (T — true, F — false):

TTTTTTT....TTTFFF...FFFFFFF

А как она выглядит в случае неотсортированного массива? Совершенно случайно. Ну, например так:

TFFTFTTFTTFFT...TFFTFTFTFTT

В первом случае, в случае отсортированного массива, уже на второй-третьей итерации цикла предсказатель переходов делает предположение, что всегда будет побеждать ветка, которая следует за результатом True, то есть конвейер готовится выполнять код под if'ом и всё, что пойдёт дальше. Потом пару раз ошибётся, когда начнутся F (False), снова переобучится, и теперь всегда будет готовить конвейер для ветки False, то есть сразу под следующую итерацию цикла.

Во втором случае у него нет никаких шансов что-то предсказать. Наверное, примерно с вероятностью 50% он будет выбирать «неправильную» ветку для конвейера. А спотыкаясь об неправильный выбор процессору придётся сбрасывать состояние конвейера, начинать накапливать его сначала, теряя десятки тактов за каждый такой раз!

И, как мы уже отметили ранее, отношение скорости этих методов чуть менее, чем на порядок. И это притом, что помимо if'а в коде есть ещё переходы в цикле, сложения и обращения к памяти.

Кстати, вы можете самостоятельно поиграться с разными комбинациями и посмотреть, насколько мощен предсказатель переходов:

  • Например, проверить наш код на случае, когда четные и нечетные числа строго чередуются — распознает ли предсказатель этот паттерн?

  • Поискать не чётные, а нечётные числа. Или инвертировать if. Или попробовать if с else веткой.

  • Посмотреть, как будет отличаться результат в зависимости от размера массива чисел и его попадания\непопадания в L1\L2 кеш.

  • Проверить соотношение производительности алгоритма на отсортированном и не отсортированном массиве по остатку от деления на других числах. Например, если хочется поискать все числа, дающие остаток 3 при делении на 5.

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

Про извлечение практической пользы

Логично задаться вопросом, а можно ли извлекать практическую пользу из знания о существовании предсказателя переходов и его проблем с ветвлениями в коде? О да, и ещё как!

Арифметика

Например, как на счёт того, чтобы не писать if'ы там, где мы можем так сделать? Чаще всего такое возможно где-то вокруг арифметики и работы с числами. Например, наш с вами код про четные числа можно переписать таким образом (сначала предыдущая реализация, чтобы была перед глазами, затем новая):

//Первая реализация с if
public static int GetEvensCount(int[] arr)
{
    var evens = 0;
    for (var i = 0; i < arr.Length; i++)
        if (arr[i] % 2 == 0)
            evens++;
 
    return evens;
}
 
//Новая реализация без if
public static int GetEvensCountNoIf(int[] arr)
{
    var evens = 0;
    for (var i = 0; i < arr.Length; i++)
        evens += 1 ^ (arr[i] % 2);
 
    return evens;
}

Давайте запустим бенчмарк и сравним новую и старую версию кода на отсортированном и не отсортированном массиве:

|        Method |      Mean |
|-------------- |----------:|
|       Ordered |  56.08 us |
|     Unordered | 403.58 us |
|   OrderedNoIf |  86.82 us |
| UnorderedNoIf |  86.85 us |

Отлично видно, что версия кода без if'ов работает одинаково и на отсортированном, и на не отсортированном массиве. В случае, когда мы не контролируем источник данных, когда элементы в массиве не отсортированы, версия кода без if'ов почти в 5 раз быстрее! И это не потому, что сам if медленный, а XOR быстрый. Как видно на отсортированных массивах, реализация без if всё-таки чуть-чуть медленнее, чем с if'ами. Это ещё одно подтверждение, на сколько необходим и как крут branch prediction!

Не арифметика

Хотите чего-то более «натурального», чем магия с арифметикой? Сортировать пузырьком умеете? Спорим, что мой пузырёк быстрее? (признаюсь, я подсмотрел этот пример в интернете):

[Benchmark]
public void Sort1()
{
    for (var i = 0; i < N; i++)
    for (var j = 0; j < N - (i + 1); j++)
        if (array[j] > array[j + 1])
            Swap(j, j + 1);
}
 
[Benchmark]
public void Sort2()
{
    for (var i = 0; i < N - 1; i++)
    for (var j = i; j >= 0; j--)
        if (array[j] > array[j + 1])
            Swap(j, j + 1);
}
 
public void Swap(int x, int y)
{
    var z = array[x];
    array[x] = array[y];
    array[y] = z;
}

В чём разница? Да так, ничего особенного (N = 100_000):

| Method |     Mean |
|------- |---------:|
|  Sort1 | 14.545 s |
|  Sort2 |  5.846 s |

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

Здесь тоже постарался branch prediction.

В первом случае, особенно на первых итерациях по внешнему циклу, элементы «всплывают» преимущественно через «неотсортированную» часть массива, а значит выполнится ветка if или нет — достаточно случайное явление, не поддающееся предсказанию.

Во втором случае элементы «тонут» к началу массива, где он уже отсортирован. А значит, сначала постоянно выполняется условие «true», пока элемент не встанет на «своё место», а потом постоянно случается условие «false», ведь массив уже отсортирован на этом участке. А последовательность TTT…TTTFFF…FFF очень даже хорошо предсказывается.

Чтобы продемонстрировать, что это так, можно изменить код, чтобы он считал после каждого if'а, совпал ли результат сравнения с предыдущим, и посчитать число «изменений состояния», то есть число ситуаций, когда предсказатель переходов имел большие шансы ошибиться.

Пусть будет такой плохой код, зато всё понятно:

public void Sort1CountTransitions()
{
    var prev = false;
    long transitions = 0;
 
    for (var i = 0; i < N; i++)
    for (var j = 0; j < N - (i + 1); j++)
        if (array[j] > array[j + 1])
        {
            Swap(j, j + 1);
            if (prev == false)
            {
                prev = true;
                transitions++;
            }
        }
        else
        {
            if (prev == true)
            {
                prev = false;
                transitions++;
            }
        }
 
    Console.WriteLine(transitions);
}
 
public void Sort2CountTransitions()
{
    var prev = false;
    long transitions = 0;
 
    for (var i = 0; i < N - 1; i++)
    for (var j = i; j >= 0; j--)
        if (array[j] > array[j + 1])
        {
            Swap(j, j + 1);
            if (prev == false)
            {
                prev = true;
                transitions++;
            }
        }
        else
        {
            if (prev == true)
            {
                prev = false;
                transitions++;
            }
        }
 
    Console.WriteLine(transitions);
}

Я вызвал два этих метода на одинаковом случайном массиве размера 100_000 и получил такие числа: 1659142346 и 199942. То есть 1.6 миллиарда шансов ошибиться предсказателю переходов в первом случае против 199 тысяч во втором случае. Оттуда и разница в три раза в эффективности второго пузырька относительно первого.

Вместо выводов

  • Не используйте if'ы! (шутка)

  • Не сортируйте пузырьком!

  • Уважайте конвейер и его труды!

  • Branch prediction работает вне зависимости от вашего языка программирования. Хоть на ассемблере пишите, работать будет одинаково!

  • Знайте, как работают ЭВМ и извлекайте из этих знаний пользу. Ну или хотя бы удовольствие!

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

P.S.

А в видеокартах проблема branch prediction'а стоит ещё острее. Их скалярность и требовательность к производительности накладывают куда более жёсткие требования к линейности и предсказуемости. На видеокартах даже специально ради этого присутствует куча встроенных инструкций типа min, max, и арифметической магии, которые минимизируют число if'ов. Ведь на видеокарте проще сделать кучу лишней арифметики, чем сделать один if и сбросить конвейер.

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


  1. webhamster
    13.06.2023 05:51
    +1

    Ммм, а про какие процессоры в этой статье идет речь? Если в процессоре есть предикатные регистры, результаты же будут совсем другие, разве нет?


    1. deniaa Автор
      13.06.2023 05:51
      +1

      В данной статье рассматривались только обычные промышленные Intel'ы.

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


      1. unreal_undead2
        13.06.2023 05:51
        +1

        На обычных промышленных Intel есть маски в AVX512 - для рассматриваемого примера они, конечно, не очень годятся, но попробовать использовать можно.


      1. webhamster
        13.06.2023 05:51

        Но на ARM давно уже вовсю используются предикатные регистры, это же не специальное железо. И ARM-овские десктопы тоже стали обыденностью.


        1. deniaa Автор
          13.06.2023 05:51
          +2

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

          И согласен с тем, что ARM это действительно не специальное железо, а обыденность в индустрии, как и те же видеокарты.

          Спасибо, что сделали это важное замечание.

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


  1. sophist
    13.06.2023 05:51

    А чем второй пузырёк от сортировки вставкой отличается?


    1. deniaa Автор
      13.06.2023 05:51

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

      Иначе можно начать придираться к тому, что даже в рамках текущей complexity можно сделать ещё кучу оптимизаций.

      Или можно начать погружаться в огромную и очень интересную тему сортировок :)


      1. sophist
        13.06.2023 05:51

        Я не придираюсь, мне действительно интересно, есть ли между ними какие-то различия


        1. deniaa Автор
          13.06.2023 05:51

          Да, второй "пузырёк" ничем не отличается от сортировки "вставкой".

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


          1. sophist
            13.06.2023 05:51

            Любопытно, никогда не думал раньше, что "вставку" можно рассматривать как "пузырёк" в обратную сторону.

            (P.S. Если пузырёк не всплывает, а тонет, то это не пузырёк, а осадок :)


    1. event1
      13.06.2023 05:51
      +2

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


      1. sophist
        13.06.2023 05:51
        +1

        Позвольте, но ведь не обменивают, а именно вставляют. А это в случае индексированного доступа требует сдвига на элемент (что, по сути, и есть цепочка обменов соседних).


        1. event1
          13.06.2023 05:51
          +1

          Истинно так. А когда обменивают это сортировка выбором, оказывается. 20 лет коту под хвост.


  1. Tsimur_S
    13.06.2023 05:51

    TTTTTTT....TTTFFF...FFFFFFF

    А разве не TFTFTFTF...?


    1. AxeFizik
      13.06.2023 05:51
      +1

      Нет, в статье числа отсортированы по критерию четности, а не по возрастанию/убыванию, так что сначала будут идти все четные числа, а потом все нечетные.


      Хотя судя по статье на которую регулярно ссылается автор, вариант TFTFTFTF… тоже будет оптимизирован


      1. unreal_undead2
        13.06.2023 05:51

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


      1. Tsimur_S
        13.06.2023 05:51

        Спасибо, пропустил этот момент.


  1. DrSmile
    13.06.2023 05:51
    +2

    В видеокартах стоит не проблема предсказателя переходов, а проблема низкой утилизации варпов. Насколько я понимаю, там, вообще, предсказателя переходов нет, вместо него толстый SMT с десятками потоков на ядро (именно настоящими потоками — варпами, каждый из которых состоит из 32/64 суб-потоков, которые по сути элементы SIMD вектора). Не известно, какую инструкцию делать следующей, — не беда, в очереди на исполнение стоит еще десяток потоков, можно исполнить инструкцию того, у которого все известно. Проблема с ветвлениями на GPU связана с тем, что это SIMD архитектуры и процессор физически не может исполнить разные инструкции для разных частей варпа, поэтому выполняет обе ветки if по очереди.


    1. unreal_undead2
      13.06.2023 05:51

      Да, в целом как то так

      не беда, в очереди на исполнение стоит еще десяток потоков

      Вы пишете как будто проблем вообще нет - реально всё таки есть компромисс между числом SMT потоков и размером регистрового файла на поток. Но да, SMT на GPU - основная техника борьбы c latency, заменяющая предсказание переходов и OoO.


      1. DrSmile
        13.06.2023 05:51

        Нет проблем конкретно с предсказателем переходов (за его отсутствием). В общем, "модель угроз" там другая и бороться с бранчами надо по другому (например, если предсказатель переходов CPU без проблем распознает шаблон TFTFTFTF, то для GPU нужно строго все T или все F).


        1. unreal_undead2
          13.06.2023 05:51

          Согласен. Другое дело, что в реальной жизни divergence совсем убрать нельзя, не всем же нужно множить большие dense матрицы )


  1. LittleAlien
    13.06.2023 05:51
    +1

    "реализация GetEvensCount без if всё-таки чуть-чуть медленнее, чем с if'ами"

    Хотя это и несколько оффтоп, но попиарю в очередной раз компилятор Clang:
    https://gcc.godbolt.org/z/WoMbrx8zx
    SIMD, развёртка, считаем 32 числа за итерацию - эта версия уж точно не медленнее.
    Значит, в общем случае избавляться от if выгоднее.