Как стать автором
Обновить

Комментарии 60

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

Можно пример? И что делать, если нет малых значений?

Разбиваем пространство значений на блоки по 256.
Если в один блок попало больше восьми чисел, то выгоднее представить блок как битовую карту, занимающую 32 байта, вместо 4*количество чисел.

А если меньше, то плохо выходит. + начало блока надо кодировать. В общем, при плотных данных это хороший вариант. Я подобный вариант описал в начале статьи, только ограничил всё числами от 0 до 255.
Я в статье его упоминал.
Еще варианты
— RLE кодирование
— список диапазонов, но тут на сами данные нужно смотреть, подойдет ли.
ЗЫ Да и вообще для такого, сначала статистику нужно собрать, а что там будет храниться.
RLE на мой взгляд больше для битмапов подойдёт, или для очень специфичных данных, для которых можно и другие варианты подобрать. Что-то пока не могу придумать, чем RLE тут помогло.
Про статистику я как раз писал — что всё зависит от данных, поэтому и расписал разные варианты.
RLE поможет если представить числа в виде последовательной цепочки бит. возможно придумать какую-то сортировку для увеличения эффективности. ну и далее, если каждое число представляет собой 64 бита, то к примеру у мелких цыфр RLE закодирует множественные нули вначале числа. или так же неплохо зайдет для чисел побольше, у которых внезапно всередине образовалось 10 (или 20) нулей или единиц подряд. не каждое число так упаковать получится правда.

В случае когда известно распределение чисел и мы храним данные в байтах, наиболее эффективной кодировкой будет следующий подход:


  • Создаём биективное отображение, которое отображает самое частое число в 0, следующее по частоте в 1, и т.д. (можно отображать не отдельные числа, а диапазоны, например, 128 наиболее частых чисел получают индексы 0-127, и т.д.)
  • Кодируем полученные индексы используя вариант VLQ использующийся в git-е.

Это если мы заботимся только о плотности хранения. Если же нам важна ещё скорость упаковки/распаковки, то тут уже начинается поиск компромиссов. Например, Group Varint Encoding (вариант VLQ) менее плотен по сравнению с git VLQ, но за счёт меньшего количества ветвлений он быстрее на современных процессорах.

Для известного распределения — надо по идее использовать для информации все множества (в одном каждое число уникально и ничего мы не добьёмся). По идее тут Хаффман может подойти. Можете дать ссылку на VLQ в git? потому что попытки узнать что это, привели к коду, реализующему VLQ, опубликованному на гитхабе :)
Group varint — вариант для скорости, надо тут тестировать на C#, возможно ли получить преимущество, или невозможность делать некоторые вещи — сожрёт возможную выгоду.

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

Выглядит как решение соседа по палате :) Надо будет попробовать. К сожалению, реализация на C# через нативный враппер. Он может быть быстрее, а может быть проблемным из-за маршаллинга. Надо посмотреть на память и скорость, может его стоит использовать в качестве основной реализации сета, а не хранилища, которое будет в него преобразовано.
Совершенно верно — «сосед по палате» ибо решение там именно из тех же исходных целей что у вас в задаче. Но соседей этих уже много было (как и методов так и людей которые эти методики прорабатывали) и Roaring Bitmaps это довольно глубоко проработанное решение. Так что в качестве стандартной алтернативы велосипеду — вполне годное решение.

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

Он то тут причем? Речь идёт об упаковке больших данных в оперативной памяти. На ассемблере такую же проблему пришлось бы решать.

Помнится на вики была статья про альтернативу хэшмапам — емнип Джулию, с дико сложным внутренним устройством. Но на большинстве тестов она показывала результаты не лучше хэшмапа.
Нашёл только язык программирования с таким названием. :(
Judy array

Judy arrays are designed to minimize the number of expensive cache-line fills from RAM, and so the algorithm contains much complex logic to avoid cache misses as often as possible
Judy arrays are extremely complicated. The smallest implementations are thousands of lines of code.[5] In addition, Judy arrays are optimized for machines with 64 byte cache lines, making them essentially unportable without a significant rewrite.

hash table performance is strictly superior to Judy for under 10,000 items. Beyond that, hash table performance slows (as mentioned before, presumably due to secondary cacheing effects). Judy on sequential data maintains the same performance regardless of size, and is superior beyond 20,000 entries (on this machine, and tuning the hash table might change this crossover point). However, the hash table is strictly superior on all the random data presented here, generally by a factor of 2.
Как я понимаю — тут всё-таки перфоманс главное, а не размер памяти. Но спасибо за информацию, записал.
Мой мессендж был скорее — не надо усложнять, большинство усложнений себя не оправдывают. Скорее всего и у вашей задачи есть относительно простое решение, если найдете, будет интересно услышать.
Давайте я поделюсь идеей. Точнее, идей у меня оригинальных, к сожалению, нет. Зато есть отправная точка.
Эта тема довольно хорошо изучена и начать можно, например, с https://arxiv.org/abs/1401.6399.
Или прямо с кода для C#:
https://github.com/Genbox/CSharpFastPFOR
Решение выглядит похожим на Group varint (может оно и есть). Надо будет глянуть. Спасибо.
может таки на диск свопнуть?)000)

А ещё можно множества в формулами кодировать или их смещение. Ну или хранить в double после запятой
Пожалуй это лучшая КДПВ, которая полностью отражает суть заголовка

Если числа в последовательности уникальные и не повторяются, и последовательность в диапазоне от 0 до UINT_MAX. Создадим массив от 0 до UINT_MAX типа Boolean. Обходим всю входящую последовательность. Устанавливаем True элементам массива с тем индексами, значения элементов из последовательности которых получаем. Например, число в последовательности равно 265, значит, элементу массива с индексом 265 ставим True, следующее число в последовательности равно 621, значит элементу массива с индексом 621 ставим True, и так далее. Когда мы пройдём всю последовательность, то у нас в массиве будут отмечены элементы, индексы которых были в последовательности.


Затем мы можем обходить массив и смотреть. Элемент с индексом 0 равен True — значит число 0 было в последовательности. Элемент с индексом 1 равен True — значит число 1 было в последовательности. И так далее.


В этом случае нам понадобится UINT_MAX × SizeOf(Boolean) = 4 294 967 296 × 1 байт = 4 294 967 296 байт памяти. Четыре гигабайта — это меньше, чем 10 гигабайт из вступительной части статьи, но пока ещё много.


Если вместо массива Boolean использовать массив из бит, то достаточно выделить памяти 4 294 967 296 байт / 8 = 536 870 912 байт, это уже 500 мегабайт и возможно, что вполне достаточно. Только нужно будет организовать установку и сбрасывание нужного бита в такой битовой карте.

500Мб на один сет? Это вы неплохо сэкономили. :) Умножьте теперь на их количество.
Алгоритмам сжатия очень нравятся повторения, а тут их не очень много.

Если хранить отсортированный массив разность между отсортированными числами 1,2,3,4,8 => 1,1,1,1,4 => 1,0,0,0,3, то число повторений вырастет. Возможно, значительно (зависит от данных, да-да), что позволит "заархивировать" данные с существенным выигрышем по памяти.

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

И при создании способов упаковки не надо забывать про накладные расходы по упаковке-распаковке. Сэкономите гигабайт памяти, но программа ваши 10 ГБ будет обрабатывать в десятки раз дольше… Можно, конечно, порекомендовать пользователям купить новый компьютер, но программист тоже должен уметь соблюдать баланс.
У плавучки на 32 бита точность 7 значащих цифр. У инта — почти 10. Т.е. мы тысячу разных значений сольём в одно. Эффективно, да. Но как-то потеря данных — не то решение, которое хотелось бы сделать.

Советую ознакомиться с работами Daniel Lemire, Leonid Boytsov, Nathan Kurz и т.д. по данной теме.
Соответственно, недостает сравнения с https://www.roaringbitmap.org/ и https://github.com/lemire/FastPFor.


Без этого плюсануть статью совесть не позволяет.

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

Daniel Lemire с коллегами занимался этой темой (Integer Compression) более 6 лет. Собрал, наработал и опробовал массу идей, опубликовал около десятка работ и довел до ума несколько реализаций. А в https://github.com/lemire/FastPFor/blob/master/README.md пожалуй лучший набор ссылок по теме, как на научные работы (т.е. идеи с глубокой проработкой и выкладками), так и на реализации (с оптимизацией и т.д.).


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

Я с вами не соглашусь. Для того, чтобы понимать оптимальные реализации, надо понимать в целом какие варианты есть, и что конкретная реализация оптимизирует какой-то конкретный фактор (скорость, память, использование аппаратных возможностей). Если тупо читать реализации — то всё будет казаться магией, которую сделали, какие-то абстрактные «умные люди». А люди решали свою проблему, возможно отсекли кучу неудачных вариантов. Но они могли быть неудачными для них, а не для вас.
А потом другие люди начинают по этим статьям делать аналогичные реализации на других языках. Но для них это магия, и получается такой код (пример из C# реализации Roaring Bitmap):
var groupbyHb = values.Distinct().OrderBy(t => t).GroupBy(Util.HighBits).OrderBy(t => t.Key).ToList();


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

Пардон, ну так вы для начала просто прочитайте публикации Daniel Lemire по ссылкам.
Причем если Дэниеля внятно спросить что-то beyond RTFM (тем более по-французски), то он очень хорошо отвечает.

Давайте ещё раз. Я не сомневаюсь в способностях Даниэля. Но он решает свои конкретные задачи. И реализуют его работы другие люди, которые могут написать фуфло. Пример я привёл. Доверять ли конкретной реализации?
Использовать ли алгоритм, только потому что его написал какой-то известный Дядька?
Возьмём Roaring Bitmap — структура, предназначена для эффективной манипуляции со множествами. У меня постановка — хранение. Разные задачи. Будет ли эта структура волшебной и хранить эффективнее? Сомневаюсь.

Мы можем продолжать дискуссию, но я могу взять реализацию на Roaring Bitmap на C#, и первый попавшийся велосипед и сравнить на каких-то данных. Если Roaring Bitmap окажется хуже, вы признаете, что другие решения имеют право на жизнь, или скажете, что я всё неправильно понял, и надо всё равно использовать существующие решения? :) Т.е. стоит мне тратить время на сравнение, или вас не переубедить?

Да, давайте еще раз:


  • Есть ряд научных работ (разных ученых/исследователей) по теме сжатия целочисленных последовательностей, в которых собрано и проанализировано немало различных идей/подходов.
  • Есть FastPFOR (включая https://github.com/Genbox/CSharpFastPFOR), streamvbyte, TurboPFor и немало других реализаций под разные задачи/требования, где использованы результаты этих научных работ.
  • Кроме этого есть и упомянутые Roaring Bitmap (в том числе на C#, на Java и даже на JS).

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

Я вас понял. Если у меня нет учёной степени, то иду я нафиг.

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


  1. Вы со знанием дела теоретически показываете что предложили новый подход или улучшили уже известный.
  2. Вы практически (бенчмарком) показываете ценность вашего подхода и/или идеи. Например, можно померятся силами с TurboPFor (там в README достаточно таблиц с результатам различных реализаций).

А пока вы просто не понимаете как смешно выглядите, уж извините за прямоту.

Ну да, сдаюсь. Я не буду переписывать TurboPFor с плюсов на C#, чтобы проводить бенчмарки. Может вы согласитесь на Roaring Bitmaps или CSharpFastPFOR? Я выставлю против них наивный и дилетантский VLQ

Roaring Bitmaps не совсем то что вам нужно (не считая проблем шарповой реализации). А вот с CSharpFastPFOR померятся вполне уместно. Собственно этого достаточно чтобы соотнести результат вашего VLQ с остальными competitors в score-таблицах у TurboPFor и т.п.


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


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

Статью — возможно сделаю. Но позднее. Всё-таки это большая работа всё проанализировать. Пока быстрый тест на части моих данных (синтетика это круто, но есть реальные данные, так что синтетику оставлю на потом). Сравнивать буду потребление памяти по мнению самого .NET и выделенной памяти. Разница в том, что в .NET объекты больше 80КБ помещаются в LOH, который очень плохо оптимизируется сборщиком мусора. Т.е. с одной стороны, память может быть заиспользована дальше, с другой — некий показатель внутренней работы (выделения блоков и тому подобного). На самом деле, второй столбец просто для информации и реализации на плюсах, т.к. по-другому не посчитать память.

Итак, 1670118 хешсета, 234965944 суммарное количество интов.
Сохраняем тупо в интовых массивах. 955Мб/981Мб примерно сходится. Некоторые накладные расходы не очень существенны.

Для начала быстро глянул на Roaring Bitmaps
Попробуем CRoaring (это враппер, его нельзя использовать в продакшене, можно получить жёсткую утечку памяти): 67/864 (тут всё ушло в unmanaged память, прироста особо нет).

RoaringBitmap — нативная реализация на C#: 676/712

Да, вы правы, не очень подходит.

Переключаемся на CSharpFastPFOR: 639/954.
Отсортируем массивы: результат аналогичный.
Возьмём разницу между числами (вроде можно для этого алгоритма): 218/360

Тупой VLQ: 749/1041
VLQ с разницей: 307/509

В итоге CSharpFastPFOR с доработками победил. Да я проиграл. Буду ли я использовать CSharpFastPFOR? Его точно нет, его надо переписать полностью. Буду ли я использовать что-то аналогичное (если есть) — не знаю. VLQ пишется за 5 минут и всем понятен, тут — нужна production-ready реализация.

Буду ли играться дальше — а почему бы и нет. :)
НЛО прилетело и опубликовало эту надпись здесь
Поиграйся ещё и с github.com/lemire/SIMDCompressionAndIntersection тогда — там и Intersect есть, который тебе пригодится ;)
Понятно, что в чистом виде не подойдёт, вопрос самого подхода — насколько память сэкономит.
На мой взгляд, если массивы сильно разряженные, лучше использовать список с указателем на следующий (и предыдущий) элемент.
Если списки достаточно плотные от 50% заполнения то самый выгодный вариант будет использовать массив подсчёта. 1 в каждом бите, если элемент есть и 0, если не существует. Как уже писали выше если int значение, то потребуется 500Мбайт накладных расходов, если только положительные числа — в два раза меньше.

Всё что Вы описали выше интересно с академической точки, но сомневаюсь, что в реальном проекте даже 2х-4х кратная экономия по памяти ценой таких накладных расходов оправдана.
В одном проекте где потребовалось подсчитывать все int значения со счётчиком, я просто докупил памяти до 16 гб.
500Мб на один сет. У вас их миллион. Вы тут ничего не сэкономили.
10Гб — не очень много, да. Но, есть другие задачи. Например, та же перестройка с нуля подобных сетов уже займёт ещё 10Гб (например, чтобы не париться с многопоточностью иногда проще пересобрать с нуля, чем править существующие). Я специально не рассказывал про проект, чтобы не отвлекаться, но тут 10Гб, там 10Гб, а потом Out of Memory :)
Вы бы подробнее рассказали про проект, в котором нужно искать пересечение миллиона множеств.
И при таких объёмах данных разумнее мне кажется разделить множества на подмножества, и обрабатывать их по лимиту оперативной памяти.
Да, делить можно, но как бы эта задача не связана с той. Т.е. если ресурсы по реализации ограничены, то можно или шардировать, или подгружать с диска, будет проще и быстрее. Но вот эти варианты не отменяют ещё возможности оптимизировать хранение. И эта задача гораздо веселее чем скучное шардирование :)
На счёт веселее тут Вы правы. Но вся индустрия разработки давит на скорость обработки, в основном выравниванием стека, улучшением предсказания ветвлений, реже векторизацией, зачастую сильно завышенными затратами памяти.
Я когда смотрю на современные языки типа Python, которые насилуют память, сверх не эффективно её используя, просто сердце щемит. Однако согласитесь, работает все очень быстро, на типовых задачах.
А потом задача не типовая и всё разваливается :)
Меня немного печалит современная тенденция, с другой стороны — я понимаю, время разработчика дорогое, а железо дешёвое. Но ведь всегда можно сделать быстро, а потом улучшать, можно даже в свободное от работы время. Просто потому что это не абстрактный pet project, а реальная задача. Иначе перестаёшь чувствовать себя программистом, а просто каким-то маляром, красящим от забора до обеда.

А вот интересно, что это за задача такая, которая для миллиона множеств с миллионом элементов в каждом требует одновременного присутствия в оперативной памяти их всех сразу?

А если не секрет, какого рода задача и какое распределение чисел получается?
Хороший пример решения задачи такого рода — исходный код Люсены: ее индекс, собственно, это набор файлов, в котором хранятся числа разных распределений. Соответственно, для каждого типа файлов они придумывают свой собственный кодек, их можно посмотреть, например, здесь.
Мне приходилось решать похожие задачи (хотя, скорее, для оптимизации хранения), я использовал комбинацию уже упоминавшихся тут приемов:
— отсортировать числа
— взять разницу между соседними (если числа разные, можно отнять 1)
— закодировать Varint encoding (7 бит на значение, восьмой бит — флаг последнего байта)
— разделить на куски и каждый кусок сжать deflate-ом (либо lz4, если нужна скорость).
Вы угадали, с полнотекстом задача связана. Поиск по документам.
Можно хранить числа в фильтре Блума. Да это звучит странно. Но он позволяет хранить числа очень компактно. Такое примеряется в системе CRLite для проверки наличия сертификата в базе. Да это позволяет добавить число в структуру, но не позволяет прочитать его обратно. Уровень компактности можно варьировать в широких пределах, регулируя вероятность ложноположительного срабатывания.
А какие недостатки будут, если завести 256 массивов, и отделить и хранить первый байт из четырёхбайтного числа в качестве индекса массива? Таким образом действительно храня в памяти только 3 байта из 4.
Но обычно на практике чаще применяется дельта-кодирование с последующим сжатием компрессором словарным методом. И если позволяется сортировка чисел, то можно разбить набор на бакеты и к ним применять дельта-кодирование по отдельности. А так как первое число в дельта-кодировании хранится как есть и все числа не превышают первого числа следующего бакета, это позволит понять какой диапазон хранится в данном бакете. Для более быстрого доступа без распаковки всего объёма данных.
То что вы упомянули что числа более случайны и вряд ли повторяются, это можно исправить с помощью BWT преобразования, которое добавит повторяемости даже без потери порядка элементов. Это довольно хитрый алгоритм, который позволяет добавить повторяемости и выявить закономерности для последующего применения словарных методов сжатия.
Фильтр Блума так и не понял, куда тут применить. Он для оптмизиации подходит, но не для хранения.
Поможет ли тут BWT — тоже не ясно, т.е. так-то он поможет, но потом дожимать надо опять, и он очень медленный. Проще уж сразу в bzip2 сжимать :)

Если есть значительные промежутки между числами, можно сделать аналогично тому, как хранятся таблицы страниц для защищенного режима процессора.
Для 32 бит адрес делится на 3 части 10+10+12 бит. Для первых 10 бит одна таблица, для вторых 10 бит набор других таблиц, остальное это сама страница размером 4kB. То есть первая таблица разбивает адресное пространство на диапазоны по 4MB. Если в таблице первого уровня в какой-то строке стоит бит 0, то страницы в этом диапазоне точно нет, и таблица второго уровня тоже отсутствует.


Для чисел, в отличие от страниц, надо также хранить сами числа, можно хранить их не целиком, а только последние 12 бит. Например, если у нас всего 2 числа 1 и 232-2, то таблица первого уровня будет выглядеть как
0x123000, NULL, NULL, ..., NULL, NULL, 0x124000
(размером 1024*4 байт, присутствуют элементы из первого и последнего диапазона 4MB, числа в которых начинаются на 0x00?.. и 0xFF?...)


по указанным адресам будут 2 таблицы второго уровня
0x128000, NULL, NULL, ...
NULL, NULL, ..., 0x129000
(числа начинаются на 0x00000… и 0xFFFFF...)


по этим адреса будут 2 таблицы с данными
[1, 0x001] и [1, 0xFFE]
(первый элемент длина, далее 12-битовые числа).
Можно адреса таблиц на биты заменить, но тогда вторые таблицы надо размещать в одном большом массиве и подсчитывать биты в главной таблице, чтобы вычислить смещение.


Как-то так. Для 2 чисел конечно есть оверхед, но для большего количества может быть экономия.

Насколько я понимаю, уже упоминавшийся выше Roaring Bitmap примерно так и работает: разбивает диапазон на под-диапазоны, и хранит только младшие биты в каждом, но у него (в оригинальном варианте) только один уровень вложенности: 64K страниц по максимум 64K элементов, причем каждая страница сжимается своим способом (битовый массив или набор чисел, в зависимости от количества элементов).
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории