Небольшое исследование BitOperations и последовательностей де Брёйна

Статьи  /  dotNET  /  Производительность dotNET  /  Performance

Давно я сюда не писал, обходился Telegram и Twitter, но поля их слишком малы, чтобы вместить эту статью :)

Для небольшого исследования в области упаковки чисел мне понадобилось получить минимальное количество бит, требуемое для записи числа. Его, конечно, математически можно получить с помощью двоичного логарифма “округлённого к большему целому” (буду использовать обозначение lg n). Как вы, вероятно, знаете, в .NET Core 3.0 появились intrinsic’и[1], в частности, LZCNT. Она, конечно, возвращает leading zeroes, но вычесть-то из 31 (для индекса в каком-нибудь массиве) или из 32 (для количества бит на число) недолго. Однако, мне было интересно узнать и универсальное решение. В .NET Core 3.0, к счастью, добавили для этого класс BitOperations[2]. А мне, конечно же, захотелось посмотреть его исходники…

Исходники BitOperations

Ожидаемо, используются статические методы с AggressiveInlining, которые проверяют доступность intrinsic’ов и, если их нет, пользуются софтовой реализацией. А вот здесь начинается интересное. Есть семейство алгоритмов, основанных на последовательностях де Брёйна (de Bruijn), которые позволяют делать оптимизации для некоторых битовых операций.

Использование последовательностей де Брёйна для поиска установленных битов

Есть статья 1998 года[3], хорошо описывающая алгоритм поиска установленных битов в числе. Далее будет краткий пересказ, но с пояснениями для тех, кто, как и я, не особо плотно занимается подобными алгоритмами (они, обычно, используются в компьютерной графике или шахматных программах).

1. Сводим задачу к числу с ровно одним установленным битом

Это можно сделать за счёт того, что отрицательные числа (почти на всех процессорах) хранятся как «дополнительный код»[4]. Например, если сложить два 32-битных int’a x и -x (x > 0), как без-знаковые 64-битные целые, то получим число с нулями в младших 32-х битах, а в 33-ем будет 1. Понятно, что для 32-битных операций 33 бит просто отбрасывается и получаем, внезапно, 0 :)

Из этого следует, что у -x, по сравнению с x, все биты должны быть «перевёрнуты», кроме самой младшей единички (тогда сложение и даст все нули). Например, если x = 01101000, то -x = 10011000. Поэтому для получения самого младшего бита достаточно просто сделать так: y = x & (-x). А для получения следующего установленного бита достаточно посчитать x-y и, если получился не ноль, повторить алгоритм.

2. А не захешить ли это?

Очевидно, что для n-битного целого есть ровно n чисел с единственным установленным битом. N маленькое (32, ну, максимум, 64), поэтому напрашивается вариант использовать хеш-функцию, но не простую, а идеальную (без коллизий). Да, я уже немного спойлернул, что для этого будут использоваться…

3. Последовательности де Брёйна

Не буду выпендриваться и говорить, что они всем известны — лично я про них узнал из этой статьи. Если совсем кратко и для алфавита из 0 и 1, то это последовательность из n символов, каждая подстрока длины lg n которой уникальна. Подробнее можно прочитать в википедии[5]. Максимальная последовательность, содержащая все возможные подстроки, называется циклом де Брёйна. Например, для n = 8 это могут быть 00011101 или 00010111, для которых окно из 3 символов даёт уникальные значения (в т.ч. со сдвигом за границы).

За счёт их свойств, циклы де Брёйна идеально подходят для нашей хеш-функции — нужно всего лишь умножить на целочисленную константу и сдвинуть биты на константу, чтобы получить хеш-таблицу размера n без коллизий:

h(x) = (x * deBruijn) >> (n - lg n)

За счёт чего работает магия? Спрятал под спойлер, чтобы была возможность сначала ответить самостоятельно.

Напоминаю, x — это число с единственным установленным битом (допустим, на k-ом месте). Значит, умножив его на цикл де Брёйна, мы сдвигаем последовательность де Брёйна на k влево. Теперь, раз уж мы взяли цикл де Брёйна с lg n лидирующими нулями, сдвигаем его вправо на (n - lg n) бит. Вуаля! Мы получили гарантированно уникальное число размером lg n бит (в случае с байтами — число от 0 до 7).

4. Выводы

В статье делаются выводы о том, что алгоритм весьма хорош для софтовых реализаций и, разумеется, немного проигрывает нативной реализации (когда она есть в процессоре). На всякий случай, напоминаю — статья 1998 года.

Вернёмся к BitOperations

Если вы посмотрите на исходный код BitOperations.Log2(uint)[2], то увидите, что:

Давайте немного посмотрим на Log2SoftwareFallback. Сначала, изящным хаком, все биты справа от лидирующих нулей заполняются единицами (если нулей слева нет вообще, получим 0xFFFFFFFF):

// Fill trailing zeros with ones, eg 00010010 becomes 00011111 value |= value >> 01; value |= value >> 02; value |= value >> 04; value |= value >> 08; value |= value >> 16;

Если вдруг этот хак не очень понятен, попробуйте нарисовать в уме или на бумаге какую-нибудь строку из рандомных 0 и 1, а на второй строке сдвинуть её вправо (для начала, на 1 бит) и применить битовый OR.

Если вы помните, раньше речь шла о точной степени двойки, а теперь мы получили число 2k-1, для которого некий Марк Дикинсон придумал специальный цикл де Брёйна, чтобы не делать лишних операций по получению 2k (value-- в начале и value++ в конце). Как работает этот хак я уже сходу не смог разобраться (да и не только я[7]). Если кто-то разберётся — прошу поделиться в твиттере. Хотя, конечно, всегда есть вариант, что это число найдено брутфорсом…

Эпилог

Для моей конкретной задачи получение lg n пока не очень критично по скорости, а сильно заранее экономить на спичках неохота, поэтому я просто возьму BitOperations.Log2. Если когда-то придётся разворошить этот спичечный коробок, я учту, в том числе, что мне не нужно проверять на 0 (особенность распределения) и можно сделать отдельный класс (или сборку), заточенный под поддерживаемые процессоры. Я же не MS, мне не надо поддерживать все возможные конфигурации :)

Спасибо за внимание!

Ссылки на статьи и документацию

  1. Hardware Intrinsics in .NET Core (.NET Blog), Bit Manipulation Instruction Sets (Wikipedia).
  2. BitOperations Class (документация), BitOperations.cs (GitHub).
  3. Using de Bruijn Sequences to Index a 1 in a Computer Word (PDF).
  4. Two’s complement (Wikipedia).
  5. De Bruijn sequence (Wikipedia).
  6. Bit Twiddling Hack (stanford.edu) — если захочется узнать больше хаков с битами.
  7. Using the de Bruijn sequence to find the $\lceil\log_2 v \rceil$ of an integer $v$ (StackExchange).

Статьи  /  dotNET  /  Производительность dotNET  /  Performance