Мы рады представить вашему вниманию заметку, написанную инженером Qrator Labs Дмитрием @dimak24 Камальдиновым. Если вы хотите быть частью нашей команды Core и заниматься подобными задачами — пишите нам на hr@qrator.net.
1 Введение
При реализации потоковых алгоритмов часто возникает задача подсчёта каких-то событий: приход пакета, установка соединения; при этом доступная память может стать узким местом: обычный -битный счётчик позволяет учесть не более событий. Одним из способов обработки большего диапазона значений, используя то же количество памяти, является вероятностный подсчёт. В этой статье будет предложен обзор известного алгоритма Морриса, а также некоторых его обобщений.
Другой способ уменьшить количество бит, необходимое для хранения значения счётчика, — использование распада. Об этом подходе мы рассказываем здесь, а также собираемся в ближайшее время опубликовать ещё одну заметку по теме.
Мы начнём с разбора простейшего алгоритма вероятностного подсчёта, выделим его недостатки (раздел 2). Затем (раздел 3) опишем алгоритм, впервые преложенный Робертом Моррисом в 1978 году, укажем его важнейшие свойства и приемущества. Для большинства нетривиальных формул и утверждений в тексте присутствуют наши доказательства — интересующийся читатель сможет найти их под спойлером. В трёх последующих разделах мы изложим полезные расширения классического алгоритма: вы узнаете, что общего у счётчиков Морриса и экспоненциального распада, как можно уменьшить ошибку, пожертвовав максимальным значением, и как эффективно обрабатывать взвешенные события.
2 Вероятностный подсчёт, проблемы тривиального подхода
Основная идея вероятностного подсчёта — учёт очередного события с некоторой вероятностью. Рассмотрим сначала пример с постоянной вероятностью обновления:
Где через обозначено значение счётчика после -ой попытки обновления. Несложно понять, что задаёт количество успехов среди независимых испытаний Бернулли. Иначе говоря, имеет биномиальное распределение:
В качестве оценки числа событий естественно использовать . Ясно, что такая оценка несмещённая: .
Описанный подход имеет несколько недостатков: во-первых, он позволяет учесть лишь в раз больше событий по сравнению с простым инкрементальным счётчиком, чем заметно уступает счётчикам Морриса, как мы увидим далее. Во-вторых, относительная ошибка оценки велика при маленьких . Например, при она вообще равна 100%. Оценить относительную ошибку можно с помощью коэффициента вариации:
3 Счётчики Морриса
Счётчик Морриса обновляется с вероятностью, зависящей от текущего значения: первое обновление происходит с вероятностью 1, следующее с вероятностью 1/2, потом 1/4 и т.д.:
Как по значению счётчика оценить, сколько было событий? Обновление счётчика с на происходит в среднем за итераций, отсюда оценка числа событий:
Важнейшими свойствами этой оценки являются
её несмещённость, то есть значение оценки после вероятностных обновлений в среднем равно ,
Доказательство
В самом начале значение счётчика равно 0: , тогда , а значит . Итого .
а также независимость её относительной ошибки от .
Доказательство
Сначала посчитаем дисперсию . Согласно известной формуле, . Мы уже знаем, что . Осталось получить . Заметим, что
Таким образом, остаётся узнать, чему равно .
Итого
Таким образом, относительная ошибка оценки числа событий при использовании счётчика Морриса ограничена, примерно постоянна и почти не зависит от .
Оценить вероятность больших отклонений от среднего можно с помощью неравенства Чебышёва:
отметим здесь также, что покрываемый диапазон количества событий при использовании бит памяти будет . Иначе говоря, для учёта событий достаточно бит памяти!
4 Взвешенные обновления
Счётчики Морриса можно использовать для подсчёта взвешенных событий: например, для суммирования размеров приходящих пакетов. Простейший способ — обновление счётчика <вес события> раз — приводит к линейной по весу сложности обновления.
Заметим, что в каждый момент времени мы знаем, как распределено количество неудач до ближайшего изменения счётчика, — это геометрическое рапределение:
Учитывая этот факт, мы можем ускорить алгоритм, сразу пропуская все подряд идущие неудачные попытки обновления:
5 Распад
Часто возникает потребность учитывать самые новые события с большим весом. Например, при поиске интенсивных ключей в потоке интереснее понять, какой ключ интенсивен прямо сейчас, а не имеет высокую среднюю интенсивность за счёт своего бурного прошлого. В статье мы рассказывали о распадных счётчиках, спроектированных специально для решения этой задачи. Другой предпосылкой для некоего распада является то, что несмотря на суперэкономное использование памяти, когда-то и счётчик Морриса переполнится. Оказывается, счётчики Морриса тоже могут распадаться, причём очень похожим на EMA способом, но гораздо эффективнее.
Идея очень проста: будем периодически вычитать из счётчика. Напомним, что значение оценивает количество событий как
Соответственно, после вычитания оценка станет. Иначе говоря, вычитание уменьшает оценку в раза! Мы можем добиться распада ровно в раза, обновив с вероятностью счётчик сразу после вычитания :
6 Мантисса и экспонента
Рассмотрим следующее обобщение счётчика Морриса: вероятность обновления уменьшается в раза не на каждое успешное обновление, а на каждые . Возможной реализацией такого подхода будет разделение битов счётчика на две части: экспоненту, отвечающую за вероятность, и мантиссу, считающую количество успешных обновлений при текущей вероятности (чем-то похоже на представление чисел с плавающей точкой).
Введём обозначения для мантиссы и экспоненты:
Теперь правило обновления и оценка числа событий записываются так:
Например, на рисунке 2 значение счётчика , мантисса и экспонента равны и соответственно. А значит число событий оценивается как .
При таком разбиении биты экспоненты будут отвечать за покрываемый диапазон количества событий, а биты мантиссы за точность оценки (далее мы поясним подробнее, что это значит). Наилучшее распределение битов счётчика зависит от задачи; мы предлагаем отдавать как можно больше мантиссе, но чтобы экспоненциальных битов хватало для покрытия интересующего диапазона значений.
Описанный вариант счётчиков Морриса обладает следующими свойствами:
покрываемый диапазон значений равен
Доказательство
Покрываемый диапазон получить несложно, подставив в формулу для максимальные возможные значения и :
новая оценка также несмещённая: ;
Доказательство
Аналогично доказательству утверждения для обычных счётчиков Морриса посчитаем условное матожидание . Причём сделаем это отдельно для (вероятность обновления изменится в случае успеха) и .
В обоих случаях
коэффициент вариации (и относительная ошибка) становятся ещё меньше! Мы не знаем его точное значение, но имеется такая оценка (сравните её с аналогичной для классического счётчика Морриса):
Алгоритмы взвешенных обновлений и распада тоже немного изменятся.
Аналогично подходу, описанному в разделе 4, чтобы эффективно выполнить взвешенное обновление, нужно понять, через сколько попыток обновления вероятность изменится. При отсутствии мантиссы вероятность меняется после каждого успешного обновления, теперь же после каждых . А значит нам нужно распределение количества испытаний Бернулли (искомое число событий), среди которых ровно успешных. Такое распределение называется отрицательным биномиальным.
Замечание. Существует несколько определений отрицательного биномиального распределения. Будем придерживаться написанного в русскоязычной википедии, где сказано, что это распределение задаёт не общее число событий, а число неудач. Тогда чтобы получить число событий, нужно к случайной величине добавить количество успехов:
Чтобы не генерировать отрицательное биномиальное распределение, можно загрубить алгоритм, всегда выбирая среднее этого распределения:
Чтобы выполнить распад, будем вычитать из экспоненциальной части (иначе говоря, из значения счётчика). Тогда
Заметим, что при (а именно этот случай изучается в этом разделе) . Поэтому исправить оценку можно, вероятностно увеличив счётчик на :
7 Заключение
Счётчики Морриса — это очень круто! Пользуйтесь!
Последний раздел не содержит полезных на практике результатов, но...
8 Обобщённые вероятностные счётчики
В общем виде правило обновления счётчика определяется функцией вероятности :
В случае счётчиков Морриса . При изучении вероятностных счётчиков мы попробовали взять линейную функцию вероятности:
где— некоторая константа, максимальное значение счётчика. Это привело к некоторым интересным результатам, которые будут кратко изложены в этом разделе.
Прежде чем представить эти результаты, напомним о двух математических сущностях. Полный однородный симметрический многочлен степени от переменных — это сумма всех возможных одночленов степени от этих переменных с коэффициентами :
Другая конструкция, которая нам понадобится, — число Стирлинга второго рода. Один из способов определить это число — комбинаторный: число Стирлинга второго рода — количество разбиений -элементного множества на непустых подмножеств. Числа Стирлинга связаны с полными симметрическим многочленами:
С помощью можно записать в общем виде вероятность того, что после попыток обновления значение счётчика равно :
Эту формулу легко доказать по индукции, но также она несложно следует из простых комбинаторных рассуждений: фактически в ней записано, что для того, чтобы получить значение , нужны успешных обновлений: с до , с до и т.д., — за это отвечает множитель , и неудачных, которые разбиваются на групп: неудачных обновлений с до , с до и т.д., что выражено .
Наконец, при использовании линейной функции вероятности вероятность принимает вид
Это и есть анонсированный выше интересный результат. Мы заметили число Стирлинга в значении вероятности достаточно случайно и до сих пор не знаем, можно ли обосновать его присутствие комбинаторынми рассуждениями (опираясь на его комбинаторное определение). Другой вопрос — можно ли красиво записать аналогичную вероятность для счётчиков Морриса.
Математическое ожидание и дисперсия (доказательства опускаем, но их несложно получить, используя формулу для и свойства чисел Стирлинга):
Оценить число событий можно по методу моментов:
или аналогично счётчикам Морриса, учитывая, что :
Список литературы
[1] Morris, R.Counting large numbers of events in small registersCommunications of the ACM, 1978 21(10), 840-842.
[2] http://gregorygundersen.com/blog/2019/11/11/morris-algorithm/ ? хороший обзор счётчиков Морриса на английском языке
[3] https://habr.com/ru/company/qrator/blog/334354/ ? наша статья про EMA-счётчики
[4] https://habr.com/ru/post/208268/ ? сторонний материал про счётчики Морриса
kovserg
Я вот так и не понял где это может пригодиться и почему вам не хватает 64бит счетчиков?
Предположим считаем события появляющиеся со скоростью 3ГГц в течении 100 лет => log2(3e9*100*365*24*60*60)=63.04 бита
И потом что мешает оценить допустимые значения счетчика и выделить нужное количество бит с запасом? И фалг переполнения добавить если уж совсем педантичным быть.
dimak24
Дело в том, что различных типов событий, которые мы хотим считать, очень много. И под каждый тип нужен свой счётчик. Поэтому в итоге с помощью подобных хитростей можно добиться существенной экономии памяти.
Пример: хотим посчитать количество пакетов, приходящих от каждого ipv4-адреса. Если использовать 64-битные счётчики, то потребуется 2^32 * 8 = 32 GiB. Если же 8-битные счётчики Морриса, то всего 4 GiB, а столько уже можно выделить на любом сервере и даже в маршрутизаторе.
Ещё пример: хотим сделать то же самое с ipv6. Там уже 2^128 адресов, и под каждый из них счётчик не завести. Но можно, например, использовать какой-нибудь sketch-based подход типа en.wikipedia.org/wiki/Count%E2%80%93min_sketch (разбиваем все адреса на подмножества, на каждое по счётчику). Ясно, что будет какая-то ошибка, но довольно очевидно, что чем больше счётчиков (мельче подмножества), тем она меньше.
dipsy
dimak24
Ещё раз суть примера: да, адресов очень много, и нет возможности под каждый выделить память. Поэтому приходится прибегать к помощь алгоритмов, лишь оценивающих точные значения. Например, алгоритмы, дробящие все адреса на подмножества (скетчи), или алгоритмы, хранящие счётчики лишь для «интенсивных» адресов (e.g. en.wikipedia.org/wiki/Misra%E2%80%93Gries_summary). Но факт остаётся фактом: чем больше счётчиков мы сможем cодержать единовременно, тем точнее будут наши оценки (намеренно забываю про точность самих счётчиков — естественно есть некий trade-off).
Если я правильно понимаю, Вы предлагаете динамически выделять память под каждый счётчик. Мне совершенно непонятно, как такое организовать, тем более эффективно. Было бы интересно понять идею. Вот допустим у нас есть большой выделенный кусок памяти «под счётчики». Как Вы предлагаете им распорядиться? По описанию я представил такую картинку: первый бит под один адрес, следующие 3 под другой и т.д… А что делать, если битность счётчика посередине нужно увеличить? Как двигать хвос? И под длины счётчиков тоже, наверное, нужно память выделить.
Звучит так, как будто Вы хотите явно считать 2^{-k} (и работать с даблами). Нет же! Мы считаем 2^k (битовый сдвиг) и смотрим на rand()%(2^k). Работает довольно быстро.
dipsy
Да суть примера понятна, выше головы не прыгнешь, что-то придется или урезать или выделять по необходимости.
Есть какое-то интуитивное ощущение, что и со счетчиками так же, нельзя сжать информацию выше определенного предела, с допустимыми потерями или без таковых. А уж способ сжатия, тут возможны варианты, вероятностный счетчик, какие-то таблицы со счетчиками разной размерности для каналов с разной частотой инкремента, динамическое выделение памяти под счетчики,
компрессия в rar массива памяти со счетчикамиetc.yleo
от
rand()
и%
тоже несложно избавиться ;)kovserg
Хм. Странное желание учитывать все ip4 адреса, но не точно. Что мешает учитывать не все но простым счетчиком?
Например сбалансированное бинарное дерево ipv4 адресов 64битными счетчиками в 4Gb сможет однозначно вместить на 214M уникальных адресов. (134M если ipv6)
yleo
Задачи всё-таки немного разные, но главное что "пакеты" считать им нужно на нагруженных 10G интерфейсаx.
Соответственно, дерево не подходит, ибо уловные переходы при поиске и перебалансировка.
kovserg
Да для 10G бинарное дерево не пойдёт.
Но можно сделать таблицу на 2^26 по 7 u64 счетчики и по 7 6bit остатка ip адреса.
То можно хранить 7*2^26 = 469M ipv4 адресов с 64бит счетчиками и займёт это 3.9Gb
Вместо ip можно использовать ip*c1 что бы раскидать адреса (где c1 mod 8 = 5 или 3).
Хотя соглашусь, в данной ситуации (все ipv4 в 4Gb) счетчики Мориса проще, быстрее и больше входит, но с потерей точности. Но если железо 10G и хочется все ipv4 то 32Gb не такая уж и великая проблема.
yleo
Как именно у "куратора" не знаю, но на всякий для полноты картины:
В подобных случаях/задачах важно экономить memory bandwidth.
Например, если вы читаете/пишете одну кэш-линию для одного счетчика-на-пакет, то примерно успеваете, а если две то уже никак.
Поэтому, какие-либо варианты хеш-таблиц часто не подходят.
С другой стороны, "моррисоны", "скетчи" и т.п. можно реализовать через примитивную арифметику и табличные функции. А сами счетчики держать в не-кэшируемой памяти, заранее посчитав достаточность полосы для худшего случая.
Короче, в результате можно посчитать/доказать, что ваш софт полностью обработает корректно весь/любой трафик через входящие N?10G (но отдельная тема чтобы эти N?10G рационально приземлить на другие numa-ноды и т.д.).
dimak24
Спасибо за Ваш интерес к статье!
Да, Вы правы. Экономия памяти — критически важная задача в нашем случае. Делать это можно по-разному. Мы экспериментируем с различными алгоритмами и счётчиками, в них использующимися. Счётчики Морриса — это одно из направлений, и, на мой взгляд, мы получили довольно интересные результаты (например, уменьшение ошибки при введении мантиссы), которыми хотелось поделиться.
janatem
Строго говоря, экономить нужно не пропускную способность памяти (ее как раз хватает с запасом), а задержку на доступ к памяти. Действительно, всего два промаха в кэш в среднем — и мы проиграли по задержке, уже не получится обрабатывать поток на line rate. И да, мы пробуем разные типы счетчиков и разные подходы к их организации, но рассматриваем только то, что «можно реализовать через примитивную арифметику и табличные функции», причем эти таблицы должны быть реально маленькими — с запасом помещаться в кэш.