Представим задачу: хайлоад-сервис гонит поток данных — логи, IP-адреса, ID пользователей, миллиарды записей в сутки. Ваша задача — посчитать количество уникальных посетителей за неделю.

Первым решением может показаться завести HashSet и кидать туда ключи, а в конце посмотреть размер. Решение неплохое, но когда речь заходит о миллиардах записей — память будет слабым местом. Один IP-адрес (4 байта) как ключ в HashSet потянет за собой накладные расходы на ноды, указатели и хеши. На практике один элемент сжирает не меньше 50–100 байт. Поток в миллиард уникальных записей потребует под сотню гигабайт оперативной памяти. Это дорого, а если инстансов десять — то просто нереально.

Но существует алгоритм, который способен решить эту задачу примерно в 1.5 килобайта памяти с погрешностью около 2%? Без хранения самих данных и гигантских кластеров. Достаточно одного прохода по потоку и пары битовых трюков — именно так и работает HyperLogLog, алгоритм родом из математической статистики, который перевернул подход к подсчёту уникальности в Big Data.

HyperLogLog используют в Redis, BigQuery, ClickHouse, Presto. В этой статье мы разберем и реализуем этот алгоритм на C, а также узнаем его предысторию.


❯ Probabilistic Counting

Прежде чем нырять в код HyperLogLog, сделаем шаг назад. В основе всех вероятностных алгоритмов подсчёта лежит удивительно простая идея, которую в 1985 году предложили Флажоле и Мартин.

Если мы хешируем каждый элемент в случайное 64-битное число, то чем больше уникальных элементов мы уже увидели, тем выше вероятность, что среди их хешей найдется число с очень длинной последовательностью ведущих нулей. И эта суть сохранена до сих пор, HyperLogLog будет использовать идею, что вероятность того, что случайный хеш начинается как минимум с n нулей, равна 2^{-n}. Например, шанс встретить хеш с 30 ведущими нулями — примерно одна миллиардная (2^{-30} \approx 9.3 \times 10^{-10}).

Обычно как пример берут подбрасывание монетки. Я не буду изменять традициям, и также давайте представим что мы бросаем монету. Вероятность выпадения орла — 1/2, двух орлов подряд — 1/4, девяти орлов подряд — 1/512. Если вы увидели последовательность из 9 орлов, вы с высокой вероятностью сделали больше 500 бросков.

С хешами — та же история. Вероятность встретить хеш, начинающийся с 9 нулей, равна 2^{-9} \approx 1/512. Если мы нашли такой хеш — скорее всего, уникальных элементов не меньше нескольких сотен. Если нашли 30 нулей подряд — их уже миллиард.

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

Этот наивный метод называется Probabilistic Counting. У него есть одна огромная проблема: чудовищная дисперсия. Если вам случайно попался хеш с 30 нулями, ваша оценка улетит в миллиарды, даже если вы обработали всего сто элементов.

Нужно усреднять! Но как усреднять то, что может взлетать на порядки? Ответ пришёл через 18 лет — в 2003 году.

❯ LogLog

В 2003 году Дюран и Флажоле предложили весьма красивое решение проблемы выбросов. Все гениальное просто, так что и здесь решение оказалось несложным — разделение хеша на две части и использование корзин (регистров). Когда счетчик не один, а много, каждый из них наблюдает за своей частью данных, а в конце мы просто приводим среднее арифметическое.

Представьте, что 64-битный хеш — это длинная полоска битов. Вы режете её на две части:

  1. Первые P битов — это номер корзины (регистра). Всего у вас будет M = 2^P корзин.

  2. Оставшиеся 64 - P битов — это некая «полезная нагрузка», на которой вы будете искать ведущие нули.

Давайте возьмем, что P = 3 (8 корзин). Пусть хеш элемента равен 101100101110...:

Каждая корзина хранит одно число — максимальную длину ведущих нулей, которую она когда-либо видела в своём остатке. Только максимум, потому что нас интересует самая редкая случайность.

К концу обработки в корзине №5 будет лежать число 5 — максимальная длина ведущих нулей, встреченная среди всех элементов, чей хеш начинался с 101.

Но почему это работает?

У нас есть M независимых наблюдателей, и если в одной корзине произошла аномалия (скажем, rank = 40), остальные M-1 корзин дают нормальные значения. При усреднении выброс потеряет свою разрушительную силу дисперсии.

Формула оценки LogLog выглядит так:

\hat{n} = \alpha_M \cdot M \cdot 2^{\frac{1}{M} \sum_{j=1}^{M} R_j}

Где:

  • R_j — значение в j-й корзине (максимальный ранг)

  • M — количество корзин

  • \alpha — корректирующий множитель (\approx 0.7)

Разберём формулу по частям. Допустим, вы усреднили все ранги и получили 10, и тогда 2^{10} = 1024 — это ваша «типичная» кардинальность в расчёте на одну корзину. Умножаете на M корзин — получаете общую оценку.

Но почему алгоритм называется LogLog? Почему логарифм, и почему двойной? Если просто, в алгоритме фигурирует логарифм от логарифма.

Вдумайтесь: если у вас миллиард уникальных элементов, ожидаемый максимальный ранг будет около \log_2(10^9) \approx 30. А само значение в регистре — это и есть ранг. То есть вы храните не сами элементы и даже не их количество, а логарифм количества.

Но ведь 30 — это число. Какой тут второй логарифм? А вот какой: количество регистров M обычно выбирают от 16 до 65536. Это 2^4 до 2^{16}. Число бит, нужное для хранения одного регистра — это \log_2(\text{максимальный ранг}) \approx \log_2(64) = 6 бит.

Получается:

  1. Первый логарифм: сам ранг — это \log_2(\text{количество элементов в корзине})

  2. Второй логарифм: количество бит на регистр — это \log_2(\log_2(\text{максимальный ранг}))

Итоговая память: M \cdot \log_2(\log_2(n_{\text{max}})) бит. Для M = 16384 и n до миллиарда это около 16384 \times 6 бит \approx 12 КБ. Алгоритм использует память, которая растёт как двойной логарифм от кардинальности.

Вроде бы все хорошо и прекрасно. Но внимательный читатель заметил, что мы разбираем HyperLogLog, значит где-то LogLog имеет погрешность.

И эта погрешность таится в арифметическом среднем. Вроде все логично — сложили все R_j, поделили на M, но здесь как раз и есть скрытый недостаток.

Вспомните, что ранг — это степень двойки в оценке. Если вы ошиблись на 1 в ранге, то оценка изменится в два раза. Арифметическое среднее на таких экспоненциальных величинах работает плохо.

Представьте две корзины со значениями 10 и 20. Арифметическое среднее = 15, 2^{15} = 32768. Но правильное усреднение должно быть ближе к геометрическому: \sqrt{2^{10} \cdot 2^{20}} = 2^{15} = 32768 — совпало? Это случайность. А вот если значения 14 и 30: среднее = 22, 2^{22} = 4.2 миллиона. Настоящее среднее должно быть около \sqrt{16384 \cdot 10^9} \approx 4 миллиона — близко, но теперь представьте, что одна корзина показывает 30, а остальные 16383 показывают 10.

Арифметическое среднее: (16383 \cdot 10 + 30)/16384 \approx 10.0012, 2^{10.0012} \approx 1025. Умножаем на M = 16384 \rightarrow около 16.8 миллионов. А реальная кардинальность, если 16383 корзины видят 10 (\sim1000 элементов каждая) — примерно 16 миллионов. Оценка завышена на 5% из-за одного выброса.

Звучит не страшно? Но если выброс будет 40 вместо 30, искажение вырастет. А в Probabilistic Counting с одной корзиной выброс 40 означал бы оценку 2^{40} \approx 1 триллион вместо реальной тысячи. LogLog уже намного лучше, но всё ещё чувствителен.

Проще говоря, недостаток LogLog — это систематическая ошибка при малых кардинальностях (\le 2.5M).

Проблема остаётся: арифметическое среднее и экспоненциальные величины — плохие друзья. Нужно что-то более устойчивое к выбросам. Именно эту проблему решил HyperLogLog в 2007 году, заменив арифметическое среднее на гармоническое в пространстве обратных степеней двойки.

❯ HyperLogLog

LogLog сделал большой шаг вперед, уменьшив дисперсию в \sqrt{M} раз, но проблема арифметического среднего осталась. Если одна корзина получает аномально высокий ранг, это все еще искажает оценку из-за арифметического среднего. Как в том анекдоте — «я ем мясо, ты ешь капусту, а в среднем мы едим голубцы».

В 2007 году Флажоле с соавторами опубликовали HyperLogLog — алгоритм с небольшим изменением, которое сильно бустануло точность. Ключевое открытие звучит неожиданно просто: замените арифметическое среднее на гармоническое в пространстве обратных степеней двойки.

В чём здесь магия? Когда регистр получает аномально высокий ранг (например, 30), его вклад в арифметическое среднее — это 30, что заметно сдвигает сумму. А в гармоническом варианте его вклад — это 2^{-30}, то есть примерно одна миллиардная. Такой регистр просто перестаёт влиять на итоговую сумму, словно его и не было. Выбросы автоматически нейтрализуются самой природой гармонического усреднения.

Формула оценки HyperLogLog выглядит так:

Z = \sum_{j=1}^{M} 2^{-R_j}\hat{n} = \alpha_M \cdot \frac{M^2}{Z}

Где \alpha_M — корректирующий множитель, который зависит от количества регистров и вычисляется по формуле:

\alpha_M = 0.7213 \cdot \left(1 + \frac{1.079}{M}\right)^{-1}

Для разных значений M используются заранее вычисленные константы, но в общем случае подходит эта формула.

Однако гармоническое усреднение — это только половина решения. Вторая проблема LogLog заключалась в систематической ошибке при малых кардинальностях (когда количество уникальных элементов меньше 2.5M). Причина проста: если многие регистры ещё пусты (их значение равно 0), то 2^{-0} = 1, и такие регистры дают одинаковый вклад в сумму, искажая оценку.

HyperLogLog решает и эту проблему с помощью трёхуровневой коррекции:

  1. Малые кардинальности (оценка \le 2.5M): если есть пустые регистры, используется линейная оценка n = M \cdot \ln(M / V), где V — количество пустых регистров. Это даёт значительно более точный результат, чем сырая формула.

  2. Средние кардинальности: сырая оценка \alpha M^2 / Z работает отлично.

  3. Очень большие кардинальности (близкие к 2^{32}): применяется дополнительная коррекция, учитывающая, что пространство хешей конечно.

Благодаря этим улучшениям стандартная ошибка HyperLogLog составляет:

\text{StdError} = \frac{1.04}{\sqrt{M}}

Сравните с LogLog, у которого ошибка была примерно 1.3 / \sqrt{M}. Разница кажется небольшой, но на практике она означает, что для достижения той же точности HyperLogLog требует почти на треть меньше памяти. При M = 16384 (16 КБ) ошибка составляет около 0.81% — то есть на 100 миллионах уникальных элементов вы ошибётесь примерно на 800 тысяч, а не на пару миллионов.

В итоге, HyperLogLog использует гармоническое среднее против выбросов, а коррекцию — против систематической ошибки при малых n.

И теперь, когда мы поняли концепцию, можно перейти к реализации на C.

❯ Реализация на C

Сам алгоритм (без учета хеш-алгоритма) обходится примерно в 100 строк кода, а памяти требуется ровно столько, сколько нужно для хранения регистров — 16 384 байта при выбранной нами точности p=14.

Прежде чем говорить о самом HyperLogLog, нужно уделить внимание хеш-функции. От её качества зависит всё: если хеш-функция распределяет биты неравномерно или имеет зависимости между битами, теоретические гарантии алгоритма рушатся.

Я взял MurmurHash3 — современную некриптографическую хеш-функцию, которая известна своей скоростью и отличным распределением. В реализации представлен 64-битный вариант этой функции, адаптированный специально для HyperLogLog.

static inline uint64_t murmur3_fmix64(uint64_t k) {
    k ^= k >> 33;
    k *= 0xff51afd7ed558ccdULL;
    k ^= k >> 33;
    k *= 0xc4ceb9fe1a85ec53ULL;
    k ^= k >> 33;
    return k;
}

static inline uint64_t murmur3_64(const void* key, size_t len, uint64_t seed) {
    const uint8_t* data = (const uint8_t*)key;
    const int nblocks = len / 16;
    uint64_t h1 = seed;
    uint64_t h2 = seed;

    const uint64_t c1 = 0x87c37b91114253d5ULL;
    const uint64_t c2 = 0x4cf5ad432745937fULL;

    const uint64_t* blocks = (const uint64_t*)data;

    for (int i = 0; i < nblocks; i++) {
        uint64_t k1 = blocks[i * 2];
        uint64_t k2 = blocks[i * 2 + 1];

        k1 *= c1;
        k1 = (k1 << 31) | (k1 >> 33);
        k1 *= c2;
        h1 ^= k1;
        h1 = (h1 << 27) | (h1 >> 37);
        h1 += h2;
        h1 = h1 * 5 + 0x52dce729;

        k2 *= c2;
        k2 = (k2 << 33) | (k2 >> 31);
        k2 *= c1;
        h2 ^= k2;
        h2 = (h2 << 31) | (h2 >> 33);
        h2 += h1;
        h2 = h2 * 5 + 0x38495ab5;
    }

    const uint8_t* tail = data + nblocks * 16;
    uint64_t k1 = 0;
    uint64_t k2 = 0;

    switch (len & 15) {
        case 15:
            k2 ^= (uint64_t)tail[14] << 48;
        case 14:
            k2 ^= (uint64_t)tail[13] << 40;
        case 13:
            k2 ^= (uint64_t)tail[12] << 32;
        case 12:
            k2 ^= (uint64_t)tail[11] << 24;
        case 11:
            k2 ^= (uint64_t)tail[10] << 16;
        case 10:
            k2 ^= (uint64_t)tail[9] << 8;
        case 9:
            k2 ^= (uint64_t)tail[8];
            k2 *= c2;
            k2 = (k2 << 33) | (k2 >> 31);
            k2 *= c1;
            h2 ^= k2;
        case 8:
            k1 ^= (uint64_t)tail[7] << 56;
        case 7:
            k1 ^= (uint64_t)tail[6] << 48;
        case 6:
            k1 ^= (uint64_t)tail[5] << 40;
        case 5:
            k1 ^= (uint64_t)tail[4] << 32;
        case 4:
            k1 ^= (uint64_t)tail[3] << 24;
        case 3:
            k1 ^= (uint64_t)tail[2] << 16;
        case 2:
            k1 ^= (uint64_t)tail[1] << 8;
        case 1:
            k1 ^= (uint64_t)tail[0];
            k1 *= c1;
            k1 = (k1 << 31) | (k1 >> 33);
            k1 *= c2;
            h1 ^= k1;
    }

    h1 ^= len;
    h2 ^= len;
    h1 += h2;
    h2 += h1;

    h1 = murmur3_fmix64(h1);
    h2 = murmur3_fmix64(h2);
    h1 += h2;
    h2 += h1;

    return h1;
}

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

Кстати, этот алгоритм в виде ГПСЧ я описывал в одной из своих статей.

Сама структура HyperLogLog удивительно проста — это массив 8-битных регистров:

#define HLL_P 14
#define HLL_M (1 << HLL_P)  // 16384
#define HLL_SEED 0xadc83b19adc83b19ULL

typedef struct {
    uint8_t registers[HLL_M];
} HyperLogLog;

Почему uint8_t? Максимальный ранг, который мы можем получить, — это 64 минус количество битов, отданных под индекс регистра. При p=14 максимальный ранг равен 50. Восьми битов (диапазон 0–255) более чем достаточно. Если бы мы захотели использовать p=16, то максимальный ранг стал бы 48 — всё равно влезает в uint8_t.

Инициализация сводится к обнулению всех регистров и подготовке таблицы обратных степеней двойки:

static double* inv_pow2_table = NULL;

static void hll_init_inv_pow2(void) {
    static int initialized = 0;
    if (initialized) return;
    for (int i = 0; i < 64; i++) {
        inv_pow2_table[i] = 1.0 / (double)(1ULL << i);
    }
    initialized = 1;
}

void hll_init(HyperLogLog* hll) {
    memset(hll->registers, 0, HLL_M);
    hll_init_inv_pow2();
}

Таблица inv_pow2_table хранит предвычисленные значения 2^{-i} для i от 0 до 63. Это небольшое ускорение: вместо того чтобы каждый раз вычислять степень двойки при оценке, мы просто берём готовое значение из массива.

Функция ранга — это то место, где битовые трюки встречаются с математикой (версия у меня упрощенная, rho(w) = 1 + clz((w << p) | (1ULL << (p-1)))):

static inline uint8_t hll_rank(uint64_t hash, uint8_t p) {
    uint64_t rest = hash << p;
    if (rest == 0) {
        return 64 - p + 1;
    }
    return __builtin_clzll(rest) + 1;
}

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

Функция __builtin_clzll (count leading zeros) — это встроенная инструкция процессора, которая считает количество ведущих нулей в 64-битном числе. Большинство современных компиляторов (GCC, Clang) поддерживают её. Если бы мы писали код для Visual Studio, пришлось бы использовать __lzcnt64 или реализовывать эту операцию через битовые операции, но встроенная инструкция работает за один такт и значительно ускоряет алгоритм.

Особый случай — когда после сдвига все биты обнулились. Это означает, что в оставшихся 64-p битах не было ни одной единицы. Тогда ранг равен 64-p+1 (все биты нулевые, плюс единица за то, что мы «перешли» за границу).

Добавление элемента — это три простых шага: хеширование, определение индекса, обновление регистра.

void hll_add(HyperLogLog* hll, const char* element) {
    uint64_t hash = murmur3_64_string(element, HLL_SEED);
    uint32_t index = hash >> (64 - HLL_P);
    uint8_t rank = hll_rank(hash, HLL_P);
    if (rank > hll->registers[index]) {
        hll->registers[index] = rank;
    }
}

Обратите внимание на вычисление индекса: hash >> (64 - HLL_P). Это эффективный способ взять старшие p битов хеша. При p=14 мы сдвигаем 64-битное число вправо на 50 позиций, оставляя только 14 старших битов. Получается число от 0 до 16383 — идеальный индекс в массиве.

Функция hll_rank получает тот же самый хеш, но работает с ним иначе: она использует биты после сдвига, как мы разобрали выше.

Функция оценки — самая объёмная часть, но она просто реализует трёхуровневую коррекцию, о которой мы говорили в теоретической части:

uint64_t hll_estimate(const HyperLogLog* hll) {
    double sum = 0.0;
    int zero_registers = 0;

    for (int i = 0; i < HLL_M; i++) {
        sum += inv_pow2_table[hll->registers[i]];
        if (hll->registers[i] == 0) zero_registers++;
    }

    double raw = hll_alpha() * (double)((uint64_t)HLL_M * HLL_M) / sum;

    if (raw <= 2.5 * HLL_M) {
        if (zero_registers > 0) {
            return (uint64_t)((double)HLL_M * log((double)HLL_M / (double)zero_registers));
        }
        return (uint64_t)raw;
    }

    return (uint64_t)raw;
}

Обратите внимание на использование inv_pow2_table[registers[i]] — мы заранее вычислили 2^{-R} для всех возможных R. Это ускоряет подсчёт суммы, избавляя от вычисления степеней на лету.

Множитель hll_alpha() зависит от точности p и вычисляется по формуле или по таблице:

static double hll_alpha(uint8_t p, uint32_t m) {
    switch (p) {
    case 4:  return 0.673;
    case 5:  return 0.697;
    case 6:  return 0.709;
    case 7:  return 0.715;
    case 8:  return 0.718;
    case 9:  return 0.719;
    case 10: return 0.720;
    case 11: return 0.7205;
    default: return 0.7213 / (1.0 + 1.079 / (double)m);
    }
}

Для p=14 (M = 16384) формула даёт:

\alpha_{16384} = \frac{0.7213}{1 + 1.079/16384} \approx 0.72125

Это значение близко к пределу 0.7213, к которому стремится \alpha_M при росте M.

Одно из важнейших свойств HyperLogLog — возможность объединять результаты. Если у вас есть два потока данных, обработанных независимо, вы можете слить их структуры и получить оценку для объединённого потока. Операция предельно проста: для каждого регистра берётся максимум из двух значений.

void hll_merge(HyperLogLog* dest, const HyperLogLog* src) {
    for (int i = 0; i < HLL_M; i++) {
        if (src->registers[i] > dest->registers[i]) {
            dest->registers[i] = src->registers[i];
        }
    }
}

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

Для проверки работы алгоритма нам понадобилась функция генерации случайных строк и тестовое приложение. Генератор использует стандартный rand() для создания строк заданной длины из букв и цифр:

static char* gen_random_string(size_t length) {
    static const char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    char* str = malloc(length + 1);
    for (size_t i = 0; i < length; i++) {
        str[i] = charset[rand() % (sizeof(charset) - 1)];
    }
    str[length] = '\0';
    return str;
}

В функции main мы генерируем 100 000 уникальных строк, добавляем их в структуру HyperLogLog, а затем выводим оценку и сравниваем её с реальным количеством. Это позволяет нам убедиться, что алгоритм работает в пределах заявленной погрешности.

Наша реализация использует __builtin_clzll — это расширение GCC и Clang. Для компиляции под Visual Studio потребуется заменить эту функцию на __lzcnt64 или реализовать подсчёт ведущих нулей через битовые операции. Также мы используем memset и математические функции из стандартной библиотеки.

Выбор точности: размер структуры и погрешность. Мы выбрали p=14 как хороший компромисс: 16 КБ памяти и погрешность около 1–2%. Если вам нужна большая точность, можно увеличить p до 16 (64 КБ, погрешность около 0.8%), а если память критична — уменьшить до 10 (1 КБ, но погрешность вырастет до 3–4%).

Теперь, когда у нас есть работающая реализация, давайте посмотрим на неё в деле. Мы протестируем алгоритм на синтетических данных и убедимся, что он действительно выдаёт оценку с погрешностью в пределах заявленной.

Итоговая реализация доступна по этой ссылке в моем репозитории.

❯ Вывод

Итак, запускаем код

Precision (p): 14
Registers (m): 16384
Memory: 16384 bytes
Actual unique:  100000
HLL estimate:   99777
Error:          0.22%

Idempotency check: 99777
Merge estimate: 99777

Погрешность составила всего 0.22% — это даже меньше теоретической оценки в 0.81% для данного количества регистров. Такой результат объясняется тем, что теоретическая ошибка — это среднеквадратичное отклонение, а на конкретной выборке случайных данных можно получить как меньшее, так и большее значение. В нашем случае случайность сыграла в пользу точности.

Обратите внимание на проверку идемпотентности: повторный вызов hll_estimate дал тот же результат, что и первый. Это важное свойство — оценка детерминирована и не зависит от того, сколько раз вы её запрашиваете.

Проверка слияния также прошла успешно: мы разделили 100 000 элементов на две равные части, обработали их независимо в двух структурах HyperLogLog, а затем слили их в одну. Оценка после слияния (99777) совпала с оценкой исходной структуры, в которую добавлялись все элементы последовательно. Это подтверждает, что операция слияния корректна и не вносит дополнительной ошибки.

Теперь посмотрим, как алгоритм ведёт себя на миллионе уникальных элементов:

Precision (p): 14
Registers (m): 16384
Memory: 16384 bytes
Actual unique:  1000000
HLL estimate:   991202
Error:          0.88%

Idempotency check: 991202
Merge estimate: 991202

При увеличении кардинальности на порядок (со 100 тысяч до 1 миллиона) погрешность выросла до 0.88%, что всё ещё находится в пределах теоретической оценки в 0.81% с учётом естественной вариативности. Важно отметить, что структура данных при этом не изменилась — те же 16 384 регистра, те же 16 КБ памяти. Алгоритм одинаково эффективно работает как для сотен тысяч, так и для миллионов элементов, и даже для миллиардов — размер структуры остаётся фиксированным.

Это и есть главная сила HyperLogLog: память не зависит от количества обработанных элементов. Вы можете пропустить через него терабайт логов, и он всё так же будет занимать 16 КБ. Единственное, что влияет на размер, — это желаемая точность, которая определяется количеством регистров.

На практике при p=14 и M=16384 вы можете рассчитывать на погрешность около 1–2% для большинства реальных данных. Этого более чем достаточно для задач аналитики, мониторинга и многих других приложений, где важна не абсолютная точность, а относительная оценка порядка величин.

❯ Графики

Я решил дополнительно провести тесты с измененными константами и нарисовать графики. Я провел серию тестов с изменением параметра точности p (от 10 до 16) и размера входного множества (unique_count от 10k до 1M). Полный набор сырых данных доступен по этой ссылке.

Для p=11 ошибка чуть выше теоретической из-за случайности, для p=12 — аномально низкая

Графики наглядно показывают свойства HyperLogLog вместе с особенностями реализации. Слияние корректное, ошибка при объединении двух структур идентична ошибке прямого подсчета. Также есть классическая зависимость: удвоение памяти (рост p на 1) снижает стандартную ошибку примерно в \sqrt{2} раз. При p \ge 14 (16 KB) ошибка стабильно уходит ниже 1% для любых размеров множеств.

При p \ge 14 кривые ошибок становятся пологими и близкими к нулю, независимо от кардинальности входных данных (будь то 10k или 1M элементов). Это подтверждает, что HyperLogLog действительно масштабируется, используя фиксированный объем памяти.

В итоге лучшим выбором для продакшена будет p=14 или p=16.


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


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

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


  1. Void-Cowboy
    24.06.2026 16:26

    звучит как какая-то магия)

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

    разумом я понимаю что апельсины и арбузы действительно можно сравнивать одинаковыми метриками но все равно в голове дисонанс от того где степень выпадения ключа к общему количественному потоку (потому как 2% это чертовски точно для настолько примерной величины)

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

    надо будет поексперементировать


    1. Sap_ru
      24.06.2026 16:26

      Чистая статистика и тероия вероятноси -никакой магии. Всё достаточно логично и понятно. Небольшая магия есть в выборе количества и корзин и разрядности хэша, но и там есть вполне предсказуемый оптимум.


      1. Void-Cowboy
        24.06.2026 16:26

        да понятно, меня удивляет гарантийность вероятности

        и как раз магия именно на хеше и отсечения по корзинам и работает

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


        1. Quintanar
          24.06.2026 16:26

          Гарантийность дает хорошая хорошая хэш функция.


  1. Mingun
    24.06.2026 16:26

    Если в каждом байте хранить 4 оценки (максимальный ранг каждой – 64), то можно просто всегда использовать p = 16.


    1. Persik1
      24.06.2026 16:26

      Идея рабочая, 6 бит на регистр хватает за глаза. Только придется написать кучу битовых сдвигов для упаковки и распаковки


  1. endpoints
    24.06.2026 16:26

    вот бы все это добро использовать для весов нейронок, чтобы занимали как можно меньше места на диске


    1. Persik1
      24.06.2026 16:26

      Этот алгоритм вообще не про сжатие данных, он просто считает количество уникальных элементов, а сами элементы навсегда отбрасывает


  1. DandyDan
    24.06.2026 16:26

    Теперь понятно, почему просмотры на некоторых видеохостингах растут скачкообразно – пока человек с "особым" IP-адресом не посмотрит, статистика не изменится. И люди с такими вот хитрыми IP-адресами и есть настоящие инфлюенсеры.


  1. ggeorgiykolpakov
    24.06.2026 16:26

    Круто, что автор не только понятно объяснил суть, но и подкрепил это всё хорошей реализацией


  1. jorgvonfrundsberg
    24.06.2026 16:26

    с погрешностью около 2%

    А в чем выражаются эти 2%? В 2% случаев поиска уникальных обьектов я получу лажу вместо валидного ответа?


    1. Quintanar
      24.06.2026 16:26

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


  1. Persik1
    24.06.2026 16:26

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


  1. Quintanar
    24.06.2026 16:26

    Вероятностные алгоритмы - это топ. Поскольку люди не понимают инстинктивно вероятность, они всегда выглядят как магия.