При работе с современными CPU устранение ошибочного предсказания ветвления — ключевой способ повышения скорости программ. Один из самых эффективных способов снижения количества ошибочных предсказаний — полное устранение ветвлений.

Возьмём для примера простую задачу: итеративный обход массива и копирование всех чисел меньше 500 в новый массив. Прямолинейная реализация на C выглядит так:

smlen = 0;
for (int i = 0; i < 1000; i++) {
    if (numbers[i] < 500) {
        small_numbers[smlen] = numbers[i];
        smlen += 1;
    }
}

Если числа распределены случайно, то результат условия if становится непредсказуемым для блока предсказания ветвления CPU. Из-за этого показатель ошибочного предсказания будет высоким, существенно препятствуя производительности, потому что процессору многократно приходится сбрасывать конвейер и начинать исполнение повторно.

Можно избежать этой проблемы при помощи решения без ветвления:

smlen = 0;
for (int i = 0; i < 1000; i++) {
    small_numbers[smlen] = numbers[i];
    smlen += (numbers[i] < 500);
}

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

Сравнение производительности

Чтобы получить существенные результаты, выполним по 1000 итераций обеих программ с использованием 100000 случайных чисел.

// SPDX-License-Identifier: MIT
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>

#define SIZE 100000
int numbers[SIZE];
int small_numbers[SIZE];
int smlen;

double ts(void) {
    static double t0;
    struct timeval tv;
    gettimeofday(&tv, NULL);
    double h = t0;
    t0 = tv.tv_sec + tv.tv_usec / 1000000.0;
    return t0 - h;
}
void init(void) {
    srand(1);
    for (int i = 0; i < SIZE; i++) numbers[i] = rand() % 1000;
    ts();
}
void small_if(void) {
    smlen = 0;
    for (int i = 0; i < SIZE; i++) {
        if (numbers[i] < 500) {
            small_numbers[smlen] = numbers[i];
            smlen += 1;
        }
    }
}
void small_bl(void) {
    smlen = 0;
    for (int i = 0; i < SIZE; i++) {
        small_numbers[smlen] = numbers[i];
        smlen += (numbers[i] < 500);
    }
}
int main(void) {
    init();
    for (int i = 0; i < 1000; i++) small_if();
    printf("Time if: %.3f\n", ts());
    init();
    for (int i = 0; i < 1000; i++) small_bl();
    printf("Time bl: %.3f\n", ts());
}

Результаты бенчмарка получены на Apple M1 с оптимизацией -O1. В этом бенчмарке -O3 не давал дополнительного повышения скорости, а лишь увеличивал объём генерируемого кода.

Time if: 0.345
Time bl: 0.036

Анализ ассемблерного кода

При помощи objdump -d --no-show-raw-insn мы можем увидеть разницу в обработке этих циклов CPU.

В случае версии с if компьютер генерирует условное ветвление b.gt.

ldr     w13, [x10], #0x4           ; Загрузка numbers[i]
cmp     w13, #0x1f3                ; Сравнение с 499 (0x1f3)
b.gt    0x100000678                ; Ветвление, если больше (узкое место)
str     w13, [x12, w8, sxtw #2]    ; Сохранение значения в small_numbers[j]
add     w8, w8, #0x1               ; j += 1

В версии без ветвления компилятор использует функцию cinc (условный инкремент).

ldr     w13, [x10], #0x4           ; Загрузка numbers[i]
str     w13, [x12, w8, uxtw #2]    ; Безусловное сохранение в small_numbers[j]
cmp     w13, #0x1f4                ; Сравнение с 500
cinc    w8, w8, lt                 ; Условный инкремент (без ветвления)

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

Почему компилятор автоматически не оптимизирует случай с if?

Компилятор не знает природу ваших данных. Если бы меньше 500 была лишь крошечная доля чисел, то версия с ветвлением была бы быстрее, потому что блок предсказания почти всегда бы угадывал правильно.

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

На x86_64

Intel Xeon (E5-2680)

Time if: 0.576
Time bl: 0.110

В версии с if компилятор генерирует условное ветвление jg.

mov    edx, DWORD PTR [rax]     ; Загрузка numbers[i]
cmp    edx, 0x1f3               ; Сравнение значения с 499 (0x1f3)
jg     1260                     ; Ветвление, если больше
movsxd rdi, ecx                 ; Тело 'if'
mov    DWORD PTR [r9+rdi*4],edx ; Сохранение значения в small_numbers[j]
add    ecx, 0x1                 ; j += 1

В версии без ветвления компилятор использует команду setle.

mov    ecx, DWORD PTR [rax]     ; Загрузка numbers[i]
movsxd rsi, edx                 ; Подготовка текущего индекса j
mov    DWORD PTR [rdi+rsi*4],ecx; Безусловное сохранение в small_numbers[j]
cmp    ecx, 0x1f3               ; Сравнение значения с 499
setle  cl                       ; Если условие выполняется, то cl = 1, иначе 0 (без ветвления)
movzx  ecx, cl                  ; Добавление к cl нуля до ecx
add    edx, ecx                 ; j += (0 или 1)

Память предсказателя переходов

Снижение размера массива до 10000 элементов или меньшего количества уменьшает разрыв в производительности, потому что таблицы истории предсказателя переходов достаточно велики, чтобы отследить паттерн. При многократной обработке одного и того же массива CPU узнаёт результат каждого условия if, достигая почти идеальной точности предсказания. При увеличении размера до 100000 элементов его память переполняется.

Quicksort

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

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


  1. roudakov
    13.05.2026 09:35

    Выглядит как натягивание совы на глобус. Да, в ассемблере есть удобная инструкция SETLE очень хорошо подходящая именно под этот случай. Ну а если надо не посчитать числа меньше 500, а просуммировать? А если что-то другое, для чего нет подходящей инструкции в ассемблере?


    1. SaihonFox
      13.05.2026 09:35

      если надо просуммировать, то где то на хабре был пост о том, как компиляторы оптимизируют ваш код и там примерно такой алгоритм выходил: (n * (n + 1)) / 2. сумма от 1 до n включительно


      1. roudakov
        13.05.2026 09:35

        Не о том речь.

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

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


        1. domix32
          13.05.2026 09:35

          Весьма локальное решение

          применение branchless в принципе довольно локальная история, да. иногда полезно, но обычно пайплайн ни разу не настолько линейный и скорость появляется при превращении в filter-map-reduce пайплайн - чисто за счёт cache locality.

          Сам приём в среднем вроде не привязан к архитектуре процессоров, поэтому в теории может везде работать. В случае байткода - как повезёт. Java/С#/Swift наверняка запрятали это где-нибудь в JIT-оптимизациях, CPython или Lua - может быть, но с большим шансом только в каких-нибудь более мажорных версиях.


          1. roudakov
            13.05.2026 09:35

            А будет ли так же работать в Java и Swift? Там нет прямого приведения булево к целому. Толь через ?:

            ?: оптимизируется лучше чем if?


            1. vvdev
              13.05.2026 09:35

              ?: оптимизируется лучше чем if?

              Зависит от компилятора/JITа, но на уровне команд процессоров Интела, вполне может компилироваться как бранчлесс.
              Однако тут есть нюанс: сначала оба операнда читаются из памяти, потом один из них выбирается. Если чтение не из кешей - то бранч может оказаться выгоднее.


            1. domix32
              13.05.2026 09:35

              Я не настолько силён в этих языках. Выбрал как представителей использующих виртуальные машины с собственными инструкциями. Робот уточняет

              Swift - благодаря llvm бэкэнду снативится до setle.

              Go - есть поддержка на уровне IR, и соотвественно нативно.

              Wasm - поддерживает типизированные варианты инструкции, так что зависит от оптимизатора. Рантайм Node, deno, bun и браузеры вполне могут их использовать, если JIT соизволит.

              C# - может соптимизировать нативно, в CIL нет прямой поддержки,

              JVM - kotlin, java - не могёт.

              OCaml, Haskell - оба имеют и на уровне байткода и при компиляции в натив.

              Nim - зависит от бэкэнда для транспиляции - для си понятно дело заведётся, для JS - зависит от финального рантайма.

              Lua и Luajit - оба имеют на уровне байткода

              Cpython - не умеет, PyPi - если JIT соизволит.


    1. Sixshaman
      13.05.2026 09:35

      Ну а если надо не посчитать числа меньше 500, а просуммировать?

      for (int i = 0; i < 1000; i++)
      {
        smlen += (numbers[i] < 500) * numbers[i];
      }


      1. roudakov
        13.05.2026 09:35

        Любопытно. Но работать будет только на Си? А на более сложных условиях эффект будет?


    1. spirit1984
      13.05.2026 09:35

      Вообще был один интересный вопрос на Stackoverflow - почему обработка с if отсортированного массива куда быстрей? И шикарный ответ.

      Вкратце - если последовательность данных упорядочена, то предиктор процессора работает как по маслу в таких случаях. Фактически if убирают за вас.

      Можно возразить "Так а если у меня неотсортированная последовательность?". Однако если мы говорим не про лабораторный эксперимент с использованием ГСЧ, как правило, эти последовательности не берутся из воздуха. Они хранятся в какой-нибудь базе и уже упорядочены.


      1. vvdev
        13.05.2026 09:35

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

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


  1. Krinopotam
    13.05.2026 09:35

    Суть примера понятна, спасибо! Но разве примеры с ветвлением и без ветвления всегда дадут одинаковый результат? В примере с ветвлением массив `small_numbers` никогда не будет содержать числа, больше 499, в вот пример без ветвления может, если последний элемент в исходном массиве будет больше 499. А значит у них разная логика и их производительность нельзя сравнивать.


    1. Gromilo
      13.05.2026 09:35

      С одной стороны я согласен. С другой: а нам не без разницы что там лежит? Всё что за заполненным размером - мусор.


      1. Krinopotam
        13.05.2026 09:35

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


        1. Gromilo
          13.05.2026 09:35

          А что за вычисления?


          1. Krinopotam
            13.05.2026 09:35

            В итоговом массиве small_numbers последним элементом может казаться число, больше 499, чего быть не должно. А может и не оказаться, если в исходном массиве значений последнее число меньше 500. А следовательно нужно проверить, что последний элемент не больше 499 и убрать его, если мы собираемся использовать массив small_numbers где-то дальше. Или я ошибаюсь?


            1. Gromilo
              13.05.2026 09:35

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

              Например, если мы нашли одно маленькое число, то оно будет лежать по индексу 0, а по индексу 1 может лежать что угодно.

              Пример на шарпах
              Во втором массиве отличается второй элемент, но это всё-равно, т.к. smlen==1 и мы нигде не должны обращаться к small_numbers[1]
              Во втором массиве отличается второй элемент, но это всё-равно, т.к. smlen==1 и мы нигде не должны обращаться к small_numbers[1]


              1. Krinopotam
                13.05.2026 09:35

                Если в таком смысле, то все верно. Но в реальной практике эти алгоритмы ведь должны быть для каких-то целей и обычно внутри функций. И результаты их вычислений также нужны не просто так, а где-то должны использоваться, и скорее всего должны быть переданы в другую функцию, которая уже не ожидает значения больше 499. Вот и получается, что первый алгоритм в качестве результата может вернуть готовый чистый массив small_numbers, в котором точно нет значений, больше 499. А branchless алгоритм должен либо подчистить массив small_numbers (для чего потребуется хоть и незначительная, но доп. логика), либо должен возвращать грязный массив small_numbers и индекс для "отсечения хвоста", и тогда доп. логику придется реализовывать позже.


                1. Gromilo
                  13.05.2026 09:35

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

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


    1. event1
      13.05.2026 09:35

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


  1. unreal_undead2
    13.05.2026 09:35

    Интеловский компилятор, кстати, генерирует из прямолинейной реализации векторизованный код без ветвлений внутри цикла (используя VPCOMPRESS).


    1. domix32
      13.05.2026 09:35

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


      1. unreal_undead2
        13.05.2026 09:35

        Да, похоже он тогда боится, что запись в small_numbers может перекрыться с smlen (хотя по идее это UB и изменение поведения в такой ситуации можно проигнорировать ради оптимизации). Но да, в любом случае использование локальных переменных и restrict может отсечь потенциальные побочные эффекты, из за которых приходится генерировать странный код.


  1. Dark_Purple
    13.05.2026 09:35

    Как минимум результат выполнения кода из примеров может быть разный. Преждевременная оптимизация или более понятный код - выбор очевиден.


  1. domix32
    13.05.2026 09:35

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

    оно конечно повышает скорость программы, но вот к ключевым способам я бы это не отнёс. Применение кода без ветвлений - довольно нетривиальная задача и имеет довольно узкую область применимости. И упоминание quicksort особенно забавно, ибо branchless вариант нашёл не человек, а робот, что ещё раз подчёркивает нетривиальность этой методики в большинстве реального кода.

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


  1. MaxMxMz
    13.05.2026 09:35

    (numbers[i] < 500) Вот не могу вспомнить - стандарт языка нам точно и всегда гарантирует что результат операции 1, а не любое отличное от 0 ?


    1. domix32
      13.05.2026 09:35

      Гарантировано как минимум в C99

      6.3.1.2 Boolean type

      1. When any scalar value is converted to _Bool, the result is 0 if the value compares equal to 0; otherwise, the result is 1.

      в более ранних стандартах оператор сравнения выплевывал гарантированное значение 0 или 1.


  1. event1
    13.05.2026 09:35

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

    Это было безусловно верно 40 лет назад. Сегодня цена ошибки предсказания ветвления — пара тактов, а лишний доступ в память (даже в кэш второго уровня, даже на чтение) — десятки и сотни тактов. Через это, подобные оптимизации имеют смысл только для очень маленьких и горячих циклов, либо из академического интереса.

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


    1. unreal_undead2
      13.05.2026 09:35

      цена ошибки предсказания ветвления — пара тактов

      Скорее десяток, можно глянуть скажем в исходниках компиляторов - хотя там некое "среднее по больнице", реальное влияние зависит от кода вокруг.

      а лишний доступ в память (даже в кэш второго уровня, даже на чтение)

      Примерно столько же (даже если важна именно latency доступа). В интеловском мануале цифры в районе 10-15.


      1. event1
        13.05.2026 09:35

        Скорее десяток

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

        Примерно столько же (даже если важна именно latency доступа). В интеловском мануале цифры в районе 10-15.

        Опять же зависит от архитектуры, но это если действительно повезло попасть в L2-кэш. А если нужных данных нет ни в одном кэше?


        1. vvdev
          13.05.2026 09:35

          справедливости ради в этом примере оба массива практически с гарантией окажутся в кешах.


        1. unreal_undead2
          13.05.2026 09:35

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


    1. vvdev
      13.05.2026 09:35

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

      На х86/64 тоже, чего бы ему не сэкономить.


      1. event1
        13.05.2026 09:35

        там в коде ассемблера сравнение с константой. Не уверен, есть ли какая-то разница между сравнением с константой и сравнением с нулём.


        1. vvdev
          13.05.2026 09:35

          Есть. Даже c# код под Интел экономит регистр на обратных циклах.

          Типа такого будет:

          L0027	dec	r8d
          L002a	jns	short L0020


          1. supernastos2013
            13.05.2026 09:35

            а обратный цикл -- не сломает кеш предсказуемость?


            1. vvdev
              13.05.2026 09:35

              Отличный вопрос, меня он тоже тревожит. По опыту - нет, хотя объяснить я себе этого не могу.
              Но: с обратным циклом и пойнтер-арифметикой (ref var curr + MemoryMarshal + Unsafe.Add) можно и от регистра для exit condition избавиться, и доступ прямой последовательный сохранить. И JIT именно так и делает, когда решает, что может поменять направление счётчика.


  1. vvdev
    13.05.2026 09:35

    дел


  1. RootTool
    13.05.2026 09:35

    По своей природе очень похоже на написание кода для GPU, где обычно условия не приветствуются :)

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

    Да, довольно таки ужасно, но зато без ветвлений + предсказуемо...


  1. MountainGoat
    13.05.2026 09:35

    с ифами много приколов связано. С тем, как процессор реагирует на ветвления.

    Допустим, у нас есть структура Кот {цвет, вес}. И надо посчитать суммарный вес всех чёрных котов.

    Казалось бы, надо так:

    пусть сумма = 0;
    Для каждого кота в массиве: {
      если (кот.цвет == чёрный) { сумма += кот.вес; }
    }

    Но вот фигуёки. Если компилятор только за вас не перепишет. Потому что вот так будет в разы быстрее.

    пусть сумма = 0;
    Для каждого кота в массиве: {
      пусть временная_сумма = сумма + кот.вес;
      если (кот.цвет == чёрный) { сумма = временная_сумма; }
    }

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


    1. MountainGoat
      13.05.2026 09:35

      Хммм. Сейчас на LLMил пример, и что-то у меня улучшение не такое сильное, как в прошлый раз. 35 ms против 42. Но всё равно есть. Может, LLVM улучшили?

      Короче, вот кот код.

      Вот
      use std::time::Instant;
      use rand::prelude::*;
      
      // The Cat struct with two fields.
      #[derive(Debug, Clone)]
      struct Cat {
          is_black: bool,
          weight: f32,
      }
      
      /// Sum the weights of black cats using a plain `for` loop.
      fn sum_black_cats_for(cats: &[Cat]) -> f32 {
          let mut total = 0.0;
          for cat in cats {
              if cat.is_black {
                  total += cat.weight;
              }
          }
          total
      }
      
      /// Sum the weights with unconditional sum.
      fn sum_black_cats_for2(cats: &[Cat]) -> f32 {
          let mut total = 0.0;
          for cat in cats {
              let temp_total = total + cat.weight;
              if cat.is_black {
                  total = temp_total;
              }
          }
          total
      }
      
      /// Sum the weights of black cats using iterator adapters.
      fn sum_black_cats_iter(cats: &[Cat]) -> f32 {
          cats.iter()
          .filter(|cat| cat.is_black)
          .map(|cat| cat.weight)
          .sum()
      }
      
      fn main() {
          let mut rng = rand::rng();
          let n = 10_000_000;
      
          // Generate a vector of one million random cats.
          let cats: Vec<Cat> = (0..n)
          .map(|_| Cat {
              is_black: rng.random(),       // 50% chance of being black
               weight: rng.random_range(3.0..10.0), // random weight between 0 and 100
          })
          .collect();
      
          // ---- Time the for-loop version ----
          let start = Instant::now();
          let sum_for = sum_black_cats_for(&cats);
          let duration_for = start.elapsed();
          println!("For-loop sum: {:.2}", sum_for);
          println!("For-loop time: {:?}", duration_for);
      
          // ---- Time the always sum version ----
          let start = Instant::now();
          let sum_for = sum_black_cats_for2(&cats);
          let duration_for = start.elapsed();
          println!("Always sum sum: {:.2}", sum_for);
          println!("Always sum time: {:?}", duration_for);
      
          // ---- Time the iterator version ----
          let start = Instant::now();
          let sum_iter = sum_black_cats_iter(&cats);
          let duration_iter = start.elapsed();
          println!("Iterator sum: {:.2}", sum_iter);
          println!("Iterator time: {:?}", duration_iter);
      }
      


    1. degor
      13.05.2026 09:35

      Это вы заблуждаетесь. Компилятор VS2026 создает одинаковый код за одним исключением: во втором варианте он все суммирует в один регистр, а потом копирует его значение в регистр результата.

      То есть вторая функция медленнеe на одно копирование из регистра в регистр (это сарказм, если чо). В разы, да.


    1. sci_nov
      13.05.2026 09:35

      Для этого этот цикл надо хотя бы на 4 линии заunrollить. Я предпочитаю пользоваться оператором ? при этом.