Сжатие целых чисел

Цель статьи осветить state of the art методы сжатия целых чисел, чтобы сэкономить в будущем время исследования алгоритмов и терминологии. При этом описание части алгоритмов может быть упрощено для понимания. Сравнение алгоритмов тоже находится вне рамках этой статьи. Подробнее можно почитать в ссылках.

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

Введение

Различные языки программирования предлагают типы целых чисел.

Типы данных:

8 бит

byte

int8

uint8

16 бит

short

int16

uint16

32 бита

integer

int32

uint32

64 бита

long

int64

uint64

128 бит

long long

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

Диапазоны значений:

Биты

signed

unsigned

8

-128 ... 0 … 127

0 … 255

16

-32768 … 0 … 32767

0 … 65535

32

-2147483648 … 2147483647

0 … 4294967295

64

-9223372036854775808 … 9223372036854775807

0 … 18446744073709551615

Современные компьютеры имеют 64х разрядные шины данных, соответственно за один цикл можно передавать 64 бита.

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

Рассмотрим пример массива:

[1, 2, 3, 4, 5, 65536, 10]

Видно, что большинство чисел занимают тип int8, но из-за шестого элемента - 65536, тип массива должен быть int32.

Какие алгоритмы сжимают данные?

Run-length encoding

Если в массиве часто встречаются повторяющиеся значения, можно запомнить их количество. В магазинных чеках пишут одну строку - 10 яблок, а не десять строк “яблоко”.

Пример с оценками студентов по предметам:

Студент

Урок 1

Урок 2

1

5

4

2

5

4

3

5

3

4

5

5

5

5

5

6

5

5

7

5

5

Массив

[5,5,5,5,5,5,5]

[4,4,3,5,5,5,5]

Размер массива

28 байт

28 байт

RLE

(5, 7)

[(4, 2), (3, 1), (5, 4)]

Размер после RLE

8 байт

24 байта

Delta encoding

Бывает такое, что числа большие, но близкие, такие как 1000001 и 1000002. Разница между ними равна 1. Значит, можно сохранить первое число в массиве без изменений, а потом записать разницу от предыдущего числа. Для первого элемента используется тип, который необходим. Для остальных элементов берется тип поменьше (int8,…).

Пример:

Массив до

Размер до

Массив после

Размер после

[10000000001, 10000000002, 10000000003, 10000000004, 10000000007, 10000000008, 10000000010, 10000000011]

int64[8], получаем 64 байта

10000000001, [1, 2, 3, 6, 7, 9, 10]

int64, int8[7], получаем 15 байт

Так же можно использовать Delta of delta encoding. Такой подход используется для кодирования времени: временные метки в системах мониторинга часто отличаются на какое-то фиксированное число с небольшими отклонениями, потому что период сбора данных примерно одинаковый.

Идея битового сжатия

Когда 32 бита много, использовать меньше.

Примеры:

Число

Биты int32

Удалили предшествующие нули

1

00000000000000000000000000000001

1

2

00000000000000000000000000000010

10

3

00000000000000000000000000000011

11

4

00000000000000000000000000000100

100

5

00000000000000000000000000000101

101

65536

00000000000000010000000000000000

10000000000000000

10

00000000000000000000000000001010

1010

28 bytes

28 bytes

4 bytes

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

Variable Byte (Varint, Group Varing Encoding, …)

История берет свое начало с 1990 года и Variable byte (varint) алгоритма. Базовый алгоритм использует 7 бит для значимой информации. Восьмой (старший) бит используется, как индикатор, является ли следующий байт продолжением предыдущего.

Примеры:

Число

Битовое представление (int32)

VB encoded

Размер после

16385

00000000000000000100000000000001

10000001 10000000 00000001

3 байта

1

00000000000000000000000000000001

00000001

1 байт

1956

00000000000000000000011110100100

10100100 00001111

2 байта

Подробнее рассмотрим первый пример. Последние 7 бит: 0000001 (младшие биты справа) становятся первыми битами в закодированном значении. Затем следующие 7 бит: 0000000, - вторым байтом. Оба байта в закодированном виде начинаются с единичного старшего бита, поэтому следующий байт продолжение предыдущего. Наконец старший значащий бит исходного числа находится на пятнадцатой позиции. Он запишется в младший бит третьего байта закодированного значения. У этого байта старший бит будет 0, значит, число закончилось.

Этот алгоритм широко известен. В частности, используется в формате Protobuf, который применяется в gRPC.

PforDelta (NewPforDelta, OptPforDelta)

Алгоритм состоит из нескольких шагов:

  1. Посчитать все дельты.

  2. Для каждого блока из 128 дельт найти такое количество бит B, которым можно закодировать до 90% всех чисел.

  3. Перекодировать все числа, используя B.

  4. Числа, которые нельзя закодировать B битами, записываются, как исключения в конце.

Пример:

Массив до

Массив дельт

Бинарный вид после алгоритма: 4 бита на дельту и одно 8 байтное исключение в конце.

[10000000001, 10000000002, 10000000003, 10000000004, 10000000007, 10000000008, 10000000010, 10000000011]

[10000000001, 1, 2, 3, 6, 7, 9, 10]

100000000001001000110110011110011010, 0000000000000000000000000000001001010100000010111110010000000001

Для краткости рассмотрим блок из 8 дельт. После преобразования достаточно 4х бит (десять - самое большое, это 1010) для хранения всех чисел, кроме первого. Первые 4 бита использовано, чтобы закодировать одно исключение на позиции 0. Дальше 4 бита пустые - как раз те, что на позиции ноль. Следующие четыре бита: 0001 - это единица из второй позиции исходного массива, затем двойка 0010 и т.д. Это иллюстрация, а не точная имплементация. Итого получим 13 байт вместо 64 байт.

Расширения алгоритма оптимизируют хранение исключений и поиск B.

Simple9b (Simple16b, Simple8b)

Этот алгоритм записывает как можно большее количество чисел в 32-битном слове. 4 бита используются для хранения состояния, 28 бит для данных.

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

Заранее составляются 9 комбинаций состояний: 281 бит (0001), 142 бита (0010), 93 бита (0011), 74 бита (0100), 5*5 бит (0101), и т.д. Комбинации фиксированы для алгоритма.

Пример:

Массив

Слово после Simple9b

[5,5,5,5,5,5,5]

01000101010101010101010101010101

Массив состоит из 7 значений по 3 бита, т.к. 5 - это 101. Используя допустимые комбинации, сохраняем 7 значений по 4 бита с маской 0100 в начале. Итого 4 байта против 28 байт.

В 4 битах можно закодировать 16 комбинаций, этим пользуется Simple16b. Simple8b использует 64х разрядное слово.

Roaring

Комбинированный алгоритм сжатия разделяет пространство положительных целых чисел на диапазоны [0, 65535], [65536, 65536 × 2 − 1], [65536 × 2, 65536 × 3 − 1] и т.д. По построению целые числа в каждом диапазоне имеют одинаковые два старших байта. В зависимости от того сколько чисел N попадает в диапазон выбирается один из двух вариантов хранения:

  1. N > 4096, используется 65536 разрядная битмапа.

  2. Иначе, сортированный массив int16.

Особенностью реализации roaring-алгоритма является то, что она поддерживает операции пересечения и объединения, которые можно использовать в поиске.

Выводы

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

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

Ссылки

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


  1. Jury_78
    13.01.2024 12:06
    +10

    Сжатие данных обозначает декомпрессию на чтениях

    Может я совсем старый, но это точно по русски?


    1. TIEugene
      13.01.2024 12:06
      +4

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

      ChatGPT - он такой. Бред несет Загадочный.


    1. venicum Автор
      13.01.2024 12:06

      Уверен, что не на английском! :)


    1. sci_nov
      13.01.2024 12:06

      Кажется, это разговорный стиль иностранных R&D :)


  1. shasoftX
    13.01.2024 12:06
    +6

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

    Это на эльфийском?

    В статье не столько алгоритмы сжатия, сколько алгоритмы трансформации данных.

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

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


    1. venicum Автор
      13.01.2024 12:06

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

      Не всегда есть необходимость распаковывать весь поток, можно указать смещения и распаковать часть данных.

      Тут пример https://github.com/lemire/JavaFastPFOR/blob/master/src/main/java/me/lemire/longcompression/SkippableLongCODEC.java#L67


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


      1. shasoftX
        13.01.2024 12:06
        +2

        Это значит что данные пакуют блоками. Как раз чтобы не распаковывать с самого начала из-за пары байт в конце.
        Сам так делал когда писал читалку для мобильного. Чтобы можно было произвольно перемещаться по тексту разбивал весь текст на блоки по 4096 байт и каждый блок упаковывал отдельно + в начале индекс на начало каждого блока.


  1. MaxAkaAltmer
    13.01.2024 12:06
    +4

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


    1. venicum Автор
      13.01.2024 12:06

      Спасибо, что упомянули. Не наткнулся на них, когда искал обзоры. Подскажете материал?


  1. in_heb
    13.01.2024 12:06
    +2

    При каких условиях long long имеет 128 бит (язык, компилятор, cpu)? Ну и в целом ощущение что статья написана chat gpt (но зачем?)


    1. venicum Автор
      13.01.2024 12:06
      +1

      При каких условиях long long имеет 128 бит (язык, компилятор, cpu)?

      Спасибо, что заметили. Такие компиляторы похоже необщеприняты.
      При этом long long в плюсах может быть >= 64 бит: https://stackoverflow.com/questions/2143157/is-the-type-long-long-always-64-bits?rq=3.
      Пожалуй, лучше было упомянуть long double, тут есть известные 128битные реализации: https://www.ibm.com/docs/ru/aix/7.1?topic=sepl-128-bit-long-double-floating-point-data-type


    1. adeshere
      13.01.2024 12:06

      При каких условиях long long имеет 128 бит (язык, компилятор, cpu)?

      В фортране вместо всяких "long long" обычно пишут числом (1, 2, 4, 8, 16...). Это полезно

      для переносимости программ

      число байт - оно ведь на всех архитектурах число, причем можно быть уверенным, что почти всегда то же самое ;-)

      На фоне современных языков такой стиль может показаться архаичным, зато можно быть почти уверенным, что если программа компилируется под какую-то архитектуру без грязных ругательств, то скорее всего, она будет под ней работать так, как задумано. То есть 4-байтовый real/integer не превратится прозрачно для программиста в 8-байтовый, и наоборот ;-)

      Однако, насколько я знаю, поддержка типа integer(kind=16) все еще зависит от компилятора. В частности, GNU фортран вроде бы поддерживает, начиная с G95. А вот мой Intel фортран 2013г - не поддерживает. Хотя real(kind=16) у меня есть и вполне себе работает.

      Что интересно,

      несмотря на заявления о полной совместимости компилятора со Студией 2008 (в которую Intel Fortran XE 13 действительно интегрируется), встроенный в VS2008 отладчик на моих real(16) конкретно глючит. Я сперва не мог понять, что происходит: открываю в отладчике переменную, а там бред какой-то. Но если не смотреть под отладчиком, а просто напечатать значение или присвоить его куда-то, то все Ок. Т.е. глюк именно в VS..


      1. in_heb
        13.01.2024 12:06
        +2

        В фортране вместо всяких "long long" обычно пишут числом (1, 2, 4, 8, 16...). Это полезно

        Ну да, в C для этого придумали всякие uint32_t (https://en.cppreference.com/w/c/types/integer ), конечно стало удобнее чем до этого когда каждый разработчик кросс-платформенных приложений изобретал свои костыли чтобы получить целое числа фиксированного количества бит


  1. Almighty_Goose
    13.01.2024 12:06
    +1

    Сжатие данных обозначает декомпрессию на чтениях - это потеря времени.

    Вселенная не заканчивается на процессорах общего назначения. В проектах FPGA и микроконтроллерах, где ОЗУ измеряется в мегабитах (а то и в килобитах) есть место рационального использования ресурсов.

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


    1. venicum Автор
      13.01.2024 12:06
      -1

      Вселенная не заканчивается на процессорах общего назначения. В проектах
      FPGA и микроконтроллерах, где ОЗУ измеряется в мегабитах (а то и в
      килобитах) есть место рационального использования ресурсов.

      Имел ввиду, время процессора. Конечно, для этого бывают свои причины.

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

      Далек от микроконтроллеров, но рад, что навело на какие-то мысли!


      1. Almighty_Goose
        13.01.2024 12:06

        Я больше по FPGA, но рад, что вы теперь заметили, что есть MCU.


  1. ahdenchik
    13.01.2024 12:06
    +1

    ZIgZag забыли


  1. Paulus
    13.01.2024 12:06

    protobuf что-то такое умел, только кому оно на самом деле надо?


    1. ornic
      13.01.2024 12:06

      А вы чем мегабайты структур гоняете между сервисами? Джысоном?