README.md

Экстремально-быстрая “одно-переходная” (или без-переходная) реализация двоичного поиска для случаев дешевых сравнений:

  • В сравнении с std::lower_bound<>:
    • до 4-х раз быстрее на последних x86_64 при использовании GCC/CLANG/MSVC;
    • более 2-х раз быстрее на Apple M1;
    • как правило, ускорение составляет около 30%.
  • Может служить примером обхода ошибки оптимизатора CLANG≥5 на x86, приводящей к использованию условных переходов вместо CMOV-инструкций.
  • Прекрасно подходит для E2K/Эльбрусов. Разработано для libmdbx и Positive Technologies.

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

Изначально эта реализация (алгоритм, если угодно) была создана в конце 90-х, сугубо из интереса по мотивам каких-то олимпиадных и тренировочных задач по информатике. Но практический смысл потеряла очень быстро, так как Intel вскоре зарубил производительность CMOV.

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

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

In English

Extremmely fast “single-branch” (aka branchless) implementation of binary search for cheap-comparison cases:

  • In comparison with std::lower_bound<>:
    • up to 4 times faster on modern x86_64 with GCC/CLANG/MSVC;
    • more than 2 times faster on Apple M1;
    • usually the speedup is about 30%.
  • Сan serve as an example of workaround for the CLANG≥5 optimizer “eternal” bug on x86, leading to emit the conditional jumps instead of a CMOV-instructions.
  • Perfect for VLIW E2K/Elbrus. Designed for libmdbx & Positive Technologies.

I want to note that I do not claim any primacy in the invention of the algorithm. Moreover, in my understanding there is nothing algorithmically/mathematically new here, contrary we should only talking about the implementation of a well-known binary search algorithm using whole actual capabilities and features of an equipment.

Initially, this implementation (algorithm, if you like) was created independently in the late 90s, purely of interest on some Olympiad and training tasks in computer science. But it lost its practical meaning very quickly, as Intel soon breaks CMOV performance.

For each evaluated function, the benchmark measures the total time spent searching for different stochastic values in vectors of different stochastic size, and then normalizes and outputs sorted results. Therefore, the results correspond to the cumulative average performance of each function. If necessary, using options on the command line, you can vary most of the parameters, including the distribution of the size of vectors, control the skew in the distribution of values, the number of duplicates, etc. All other details are in the source code.

The benchmark and all the auxiliary code are written in C++, but most of the evaluated search functions are written in C syntax, as I needed for libmdbx. For the same reason, most of the tested implementations do not assume a zero-size search. More precisely, when the size is zero, one element is read, i.e. beyond the end of the vector of zero size. In my target scenarios, this does not matter.


Как использовать бенчмарк / How to use benchmark

$ git clone https://gitflic.ru/project/erthink/bsearch-try.git
$ cd bsearch-try
$ make bench && make bench-native

Для более точных замеров (может потребовать много времени) / For more accurate measurements (may take a long time):

$ make all
$ ./bsearch-bench-native --enough-stable 9 --threshold-ppm 99

Для прочих экспериментов / For deep digging:

$ ./bsearch-bench --help
  --min SIZE                The minimum size of the array where a search is performed,
                                0 by default.
  --max SIZE                The maximal size of the array where a search is performed,
                                99999 by default.
  --steps NUMBER            The number of bench steps with a permanent size of where a search is performed,
                                111 by default.
  --repeat NUMBER           The number of sequentially passes with the each size of an array,
                                9 by default.
  --probes NUMBER           The whole number of search probes for each implementation,
                                999999 by default.
  --seed NUMBER             Initial seed for PRNG (linear congruential),
                                42 by default.
  --loops-limit NUMBER      The limit of evaluation attempts for benchmark each implementation,
                                999 by default.
  --enough-stable NUMBER    The number of stable evaluations to be enough,
                                5 by default.
  --threshold-ppm NUMBER    The threshold value of the difference in ppm so that an evaluation is considered stable,
                                999 by default.
  --uniq-factor NUMBER      The uniqueness factor for filling test array (duplicates: 0→⅜, ..., 9→1‰),
                                2 by default.
  --skew2first              Forces the statistical skew of a searched values to the begin,
                                No by default.
  --skew2last               Forces the statistical skew of a searched values to the end,
                                No by default.
  --skew-factor NUMBER      Skew intensity factor for --skew2first and --skew2last,
                                1 by default.
  --dump                    Print test vectors of values and size-steps,
                                No by default.
  --debug-bench             FIXME,
                                No by default.
  --debug-test              FIXME,
                                No by default.

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

  • При поиске в больших массивах (больше размера кэша ЦПУ) основную задержку будет вносить чтение данных, поэтому экономия на условных переходах становится менее заметной.

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

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

С точки зрения алгоритма/математики тут ничего нового. Напротив всё тривиально — только убрать лишнее, как говаривал Буонаротти. Соответственно, всё “чудо” оптимизации сводится в основном к шагу двоичного поиска: сравнению и делению отрезка пополам, с сохранением возможности использования инструкций условных пересылок, aka CMOV, вместо условных переходов.

В частности, поэтому в коде нет “арифметического CMOV” (распространение знака, and + xor с маской и т.п.), так как: с одной стороны, это мешает компилятору использовать CMOV там где они доступны, а с другой стороны, компилятор (или программист) сам может решить когда это выгоднее.

В практической же плоскости ещё нужно убедить компилятор ничего не испортить. После небольшой магии с LCC, MSVC и GCC всё оказалось прекрасно со всеми версиями, а CLANG≥5 упрямо портит код — пришлось добавить костыль:

По всей видимости в оптимизаторе CLANG для x86 явно какая-то глупая ошибка, наподобие пропущенного ! в условии. Упрощенно:

  • Если внутри цикла две зависимости от результата сравнения. то CLANG использует две CMOV-инструкции.
  • Если же зависимость одна, то вместо одной CMOV используются переходы.

Эта логика явно ошибочная, ибо:

  • Если одна CMOV дороже переходов, то две точно ещё дороже,
  • А если же CMOV не дороже переходов, то вообще тут незачем использовать переходы (ибо загрязнение стека предсказателя).

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

Поэтому вместо ассемблерных вставок с CMOV-инструкциями я решил подсунуть CLANG вторую зависимость, которая подталкивает оптимизатор к отказу от ненужных условных переходов. Это выглядит более рациональным решением, ибо накладные расходы сводятся к одной инструкции наподобие setl cl без исходящих зависимостей по данным. При этом такая ассемблерная вставка не требует обслуживания и не мешает компилятору, а бенчмарк подтверждает ликвидацию отставания от GCC.


Benchmark results

x86_64, i7-1280P, gcc 12.2 (0-3ubuntu1~22.04), -Ofast

Function Time Diff Ratio
ly_bl_goto2 0.133779 -77.51% 4.45
ly_bl_mini2 0.135237 -77.27% 4.40
ly_bl_goto1 0.136045 -77.13% 4.37
ly_bl_mini3 0.137973 -76.81% 4.31
ly_bl_clz_goto 0.139421 -76.56% 4.27
ly_bl_mini1 0.139858 -76.49% 4.25
ly_bl_mini0 0.140676 -76.35% 4.23
PVKaPM 0.140975 -76.30% 4.22
Malte_Skarupke 0.167712 -71.81% 3.55
ly_bl_switch2 0.192168 -67.69% 3.10
ly_bl_switch1 0.232220 -60.96% 2.56
ly_bl_clz_switch 0.252210 -57.60% 2.36
old_libmdbx 0.558661 -6.08% 1.06
bsearch_linux 0.582732 -2.04% 1.02
bsearch_stdlib 0.590339 -0.76% 1.01
std::lower_bound 0.594852 +0.00% 1.00

x86_64, i7-1280P, clang 14, -Ofast

Function Time Diff Ratio
ly_bl_clz_goto 0.143664 -58.14% 2.39
ly_bl_clz_switch 0.145500 -57.61% 2.36
ly_bl_mini0 0.269466 -21.49% 1.27
ly_bl_mini1 0.282908 -17.58% 1.21
ly_bl_mini2 0.292304 -14.84% 1.17
ly_bl_mini3 0.300824 -12.36% 1.14
ly_bl_switch2 0.322479 -6.05% 1.06
ly_bl_goto2 0.322766 -5.96% 1.06
ly_bl_switch1 0.324525 -5.45% 1.06
ly_bl_goto1 0.325048 -5.30% 1.06
std::lower_bound 0.343240 +0.00% 1.00
old_libmdbx 0.368298 +7.30% 0.93
bsearch_linux 0.396773 +15.60% 0.87
Malte_Skarupke 0.536484 +56.30% 0.64
bsearch_stdlib 0.549804 +60.18% 0.62
PVKaPM 0.574339 +67.33% 0.60

x86_64, Xeon Gold 5317, gcc 12.2 (0-3ubuntu1~22.04), -march=native -Ofast

Function Time Diff Ratio
ly_bl_mini1 0.019255 -76.36% 4.23
PVKaPM 0.019313 -76.29% 4.22
ly_bl_mini0 0.019573 -75.97% 4.16
ly_bl_mini2 0.019633 -75.89% 4.15
ly_bl_goto1 0.019643 -75.88% 4.15
ly_bl_goto2 0.019747 -75.75% 4.12
ly_bl_mini3 0.019778 -75.72% 4.12
ly_bl_clz_goto 0.020537 -74.78% 3.97
Malte_Skarupke 0.021458 -73.65% 3.80
ly_bl_switch2 0.026591 -67.35% 3.06
ly_bl_switch1 0.032520 -60.07% 2.50
ly_bl_clz_switch 0.033333 -59.07% 2.44
old_libmdbx 0.077419 -4.94% 1.05
bsearch_linux 0.077650 -4.66% 1.05
bsearch_stdlib 0.080141 -1.60% 1.02
std::lower_bound 0.081444 +0.00% 1.00

x86_64, Xeon Gold 5317, clang 17 (++20230504031411+edcdc81e2bb3-1~exp1~20230504151525.909), -march=native -Ofast

Function Time Diff Ratio
ly_bl_switch1 0.020763 -71.12% 3.46
ly_bl_goto1 0.020845 -71.00% 3.45
ly_bl_mini2 0.020882 -70.95% 3.44
ly_bl_switch2 0.020895 -70.93% 3.44
ly_bl_clz_goto 0.020913 -70.91% 3.44
ly_bl_goto2 0.020926 -70.89% 3.44
ly_bl_mini0 0.020989 -70.80% 3.42
ly_bl_mini1 0.021007 -70.78% 3.42
ly_bl_mini3 0.021579 -69.98% 3.33
ly_bl_clz_switch 0.022115 -69.24% 3.25
old_libmdbx 0.044428 -38.19% 1.62
bsearch_linux 0.046568 -35.22% 1.54
Malte_Skarupke 0.068822 -4.26% 1.04
bsearch_stdlib 0.071818 -0.09% 1.00
std::lower_bound 0.071882 +0.00% 1.00
PVKaPM 0.073235 +1.88% 0.98

VLIW E2Kv5, Эльбрус-8СВ, LCC 1.26.17, -Ofast

Function Time Diff Ratio
ly_bl_mini1 0.070699 -27.38% 1.38
PVKaPM 0.072306 -25.73% 1.35
ly_bl_mini2 0.072711 -25.32% 1.34
ly_bl_goto2 0.072742 -25.29% 1.34
ly_bl_mini0 0.072876 -25.15% 1.34
ly_bl_goto1 0.072951 -25.07% 1.33
ly_bl_mini3 0.075904 -22.04% 1.28
Malte_Skarupke 0.076360 -21.57% 1.28
ly_bl_switch2 0.082021 -15.76% 1.19
old_libmdbx 0.090078 -7.48% 1.08
ly_bl_clz_switch 0.092872 -4.61% 1.05
ly_bl_switch1 0.093329 -4.14% 1.04
ly_bl_clz_goto 0.096936 -0.44% 1.00
std::lower_bound 0.097360 +0.00% 1.00
bsearch_linux 0.120391 +23.66% 0.81
bsearch_stdlib 0.135673 +39.35% 0.72
Описание

Экстремально-быстрая “одно-переходная” реализация двоичного поиска для случаев дешевых сравнений

Конвейеры
0 успешных
0 с ошибкой