DSA DSA In Depth
0%
lesson hyperloglogcardinalitystreaming

HyperLogLog: Harmonic Mean & Stochastic Averaging in 1.5 KB

7 min read Module 2.1: Hash Tables, Modern Probing & Probabilistic Sketches Updated Oct 23, 2026
📄 Cheat Sheet
HyperLogLog & Stochastic Cardinality Estimation Internals
Arpit Bhayani HyperLogLog & Stochastic Cardinality Estimation Internals Watch on YouTube ↗

Erik Demaine explains the Flajolet-Martin leading-zero bit-pattern estimator, stochastic averaging across m buckets, and why the harmonic mean suppresses outlier variance.

0:00 / --:--

From Flajolet-Martin to HyperLogLog Harmonic Mean

Given a 64-bit uniform hash $h(x)$ and precision parameter $p \in [4, 18]$ (so $m = 2^p$ registers $M[0..m-1]$ initialized to $0$):

  1. Substream Bucketing (Stochastic Averaging): Extract the top $p$ bits of $h(x)$ as the register index $j = h(x) \gg (64 - p) \in [0, m-1]$.
  2. Leading-Zero Rank $\rho(w)$: Let $w$ be the remaining $64 - p$ bits of $h(x)$. Define $\rho(w) = \text{clz}(w) + 1 \in [1, 64 - p + 1]$ as the 1-based position of the most significant 1-bit in $w$. Update register $M[j] \gets \max(M[j], \rho(w))$.
  3. Harmonic Mean Estimator: Why did earlier LogLog (2003) use the geometric mean ($1.30 / \sqrt{m}$ error) while HyperLogLog (2007) uses the harmonic mean ($1.04 / \sqrt{m}$ error)? Because $2^{M[j]}$ has a heavy right-skewed tail ($\mathbb{E}[2^{M[j]}] = \infty$), an arithmetic mean explodes if a single register hits $\rho = 40$. The harmonic mean averages $2^{-M[j]}$, completely neutralizing large outlier registers:

    $$E_{\text{raw}} = \alpha_m \cdot m^2 \cdot \left( \sum_{j=0}^{m-1} 2^{-M[j]} \right)^{-1}$$

    where $\alpha_m = \left(m \int_0^\infty \left(\log_2\left(\frac{2+u}{1+u}\right)\right)^m du\right)^{-1} \approx \frac{0.7213}{1 + 1.079/m}$ corrects asymptotic multiplicative bias.
  4. Small-Range LinearCounting & Sparse Representations: When $n$ is small and $V > 0$ registers are still $0$, and $E_{\text{raw}} \le \frac{5}{2} m$, HLL switches to LinearCounting (Whang et al., 1990) based on the coupon-collector occupancy rate: $E = m \ln(m / V)$.
Cardinality Algorithm Register Count $m = 2^p$ Memory Footprint (6b/reg) Relative Error $\sigma$ Distributed Merge (PFMERGE)
Exact std::unordered_set<u64> $N$ heap nodes $\ge 24\text{ GB}$ (for $10^9$ keys) $0\%$ (Exact) $O(N)$ set union
LinearCounting (Bitset) $m \approx N / 10$ bits $~12.5\text{ MB}$ (for $10^9$ keys) Saturates when $N \gg m \ln m$ Bitwise OR in $O(m/64)$
HyperLogLog ($p = 11$, Redis compact) $m = 2{,}048$ $1.5\text{ KB}$ $1.04 / \sqrt{2048} \approx 2.30\%$ Elementwise $\max(M_A[j], M_B[j])$
HyperLogLog ($p = 14$, Redis standard) $m = 16{,}384$ $12.0\text{ KB}$ $1.04 / \sqrt{16384} \approx 0.81\%$ Elementwise $\max(M_A[j], M_B[j])$

Formal Specification: Commutative-Idempotent Semilattice Merge

Monotonic Register Semilattice: For any multiset $S$, the state of a HyperLogLog sketch is uniquely determined by $M_S[j] = \max_{x \in S,\, \text{bucket}(x)=j} \rho(x)$. Because $\max$ is associative, commutative, and idempotent:

$$M_{A \cup B}[j] = \max(M_A[j], M_B[j]) \quad \forall j \in \{0, \dots, m-1\}$$

Consequently, merging two HLL sketches from different shards produces the exact same register state and cardinality estimate as if all items in $A \cup B$ had been fed into a single HLL in any order, with zero loss of accuracy regardless of how many times sketches are merged!

Visualizing 64-Bit Hash Splitting ($p = 14$) & Harmonic Mean Aggregation

64-Bit Uniform Hash h(x) Decomposition (Precision p = 14 → m = 16,384 Registers): Top p = 14 Bits [63..50] j = 0b01011001001011 = 5707 Remaining 50 Bits w [49..0]: 0000010110110... ρ(w) = clz(w) + 1 = 5 + 1 = 6 (Fits in 6-bit register ∈ [0..51]) Register Update & Harmonic Mean (16,384 × 6 bits = 12 KB Total): • Update: M[5707] = max(M[5707], 6) (Branchless single-instruction update) • Harmonic Sum: Z = ∑_{j=0}^{16383} 2^{-M[j]} → E = α_m · m² / Z • If E ≤ 2.5·m and V = count(M[j] == 0) > 0: Return LinearCounting m · ln(m / V)
flowchart LR Item["Element x"] --> H["h = hash64(x)"] H --> Split["j = h >> (64 - p) w = (h << p) | (1 << (p - 1))"] Split --> Rank["rho = countl_zero(w) + 1 M[j] = max(M[j], rho)"] Rank --> Est["Query: Z = sum(2^-M[j]) E = alpha_m * m^2 / Z"] Est --> LC{"E <= 2.5m AND ZeroRegs V > 0?"} LC -- "Yes" --> OutLC["Return m * ln(m / V)"] LC -- "No" --> OutHLL["Return E"]
Count-Min Sketch d × w Matrix & HyperLogLog Register Harmonic Mean
O(1) Stream Sketch · 1.5 KB HLL

Step through Count-Min Sketch d × w counter updates and point query f̂(x) = min_j C[j, h_j(x)] (eliminating additive collision noise), or trace HyperLogLog leading-zero registers M[j] = max ρ(w) and Harmonic Mean cardinality estimation.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Update("ip_A", 5): increment C[j, h_j("ip_A")] += 5 across all d = 4 pairwise-independent hash rows.
h_1(x) 0 0 5 0 0 0 0 0 h_2(x) 0 0 0 0 0 5 0 0 h_3(x) 0 5 0 0 0 0 0 0 h_4(x) 0 0 0 0 0 0 5 0 f̂("ip_A") = min(C[0,2], C[1,5], C[2,1], C[3,6]) = 5 (Exact = 5)
Operation
ADD("ip_A", +5)
Dimensions (d × w)
4 × 8 Counters
Probed Cells
[(0,2), (1,5), (2,1), (3,6)]
Query Estimate f̂(x)
5 (Exact = 5)
[Step 1] Added +5 for "ip_A" at cells (0,2), (1,5), (2,1), (3,6).

Hardware Reality: Hardware LZCNT / std::countl_zero & Sentinel Bit Trick

Every x86-64 (ABM/BMI1 LZCNT) and ARM64 (CLZ) processor computes the number of leading zeros in a 64-bit register in 1 to 3 clock cycles via std::countl_zero(w). To prevent $\rho(w)$ from exceeding $64 - p + 1$ when all $64 - p$ bits of $w$ are zero, shift $w$ left by $p$ bits and bitwise-OR a sentinel 1 at bit $p - 1$: w_shifted = (h << p) | (1ULL << (p - 1)). Then std::countl_zero(w_shifted) + 1 is branchless, well-defined even when $w = 0$, and never invokes undefined behavior on legacy BSR instructions!

Production C++20 Implementation: 64-Bit HyperLogLog with LinearCounting & Merge

// C++20: HyperLogLog (64-bit hash, p in [4..18]) with Sentinel CLZ & LinearCounting
#include <vector>
#include <cstdint>
#include <bit>
#include <cmath>
#include <algorithm>

class HyperLogLog {
    uint32_t p_;
    uint32_t m_;
    double alphaMM_;
    std::vector<uint8_t> regs_;

    static uint64_t hash64(uint64_t x) noexcept {
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return x ^ (x >> 31);
    }

public:
    explicit HyperLogLog(uint32_t p = 14)
        : p_(std::clamp(p, 4U, 18U)), m_(1U << p_), regs_(m_, 0) {
        double alpha = 0.7213 / (1.0 + 1.079 / static_cast<double>(m_));
        if (m_ == 16) alpha = 0.673;
        else if (m_ == 32) alpha = 0.697;
        else if (m_ == 64) alpha = 0.709;
        alphaMM_ = alpha * static_cast<double>(m_) * static_cast<double>(m_);
    }

    void add(uint64_t item) noexcept {
        uint64_t h = hash64(item);
        uint32_t j = static_cast<uint32_t>(h >> (64 - p_));
        // Shift remaining (64 - p) bits to MSB and set sentinel bit at (p - 1)
        uint64_t w = (h << p_) | (1ULL << (p_ - 1));
        uint8_t rho = static_cast<uint8_t>(std::countl_zero(w) + 1);
        if (rho > regs_[j]) regs_[j] = rho;
    }

    double count() const noexcept {
        double sumInv = 0.0;
        uint32_t zeroRegs = 0;
        for (uint8_t r : regs_) {
            sumInv += std::ldexp(1.0, -static_cast<int>(r)); // 2^{-r}
            if (r == 0) ++zeroRegs;
        }
        double est = alphaMM_ / sumInv;
        if (est <= 2.5 * static_cast<double>(m_) && zeroRegs > 0) {
            return static_cast<double>(m_) * std::log(static_cast<double>(m_) / zeroRegs);
        }
        return est;
    }

    // Lossless Commutative Semilattice Merge (Redis PFMERGE)
    void merge(const HyperLogLog& other) noexcept {
        for (uint32_t j = 0; j < m_; ++j) {
            regs_[j] = std::max(regs_[j], other.regs_[j]);
        }
    }
};

Failure Modes & Adversarial Pitfalls

  • 32-Bit Hash Saturation at $N > 10^8$: The original 2007 HLL paper used 32-bit hashes, which saturate above $2^{32} \approx 4.29 \times 10^9$ and require complex high-range correction formulas. Always use a 64-bit hash function (HyperLogLog++, Heule et al., Google 2013): with 64-bit hashes and $p = 14$, the remaining $50$ bits measure cardinalities up to $2^{50} \approx 10^{15}$ with zero high-range saturation!
  • Set Intersection Traps ($|A \cap B| = |A| + |B| - |A \cup B|$): While union $|A \cup B|$ via register-wise max has relative error $\sigma \approx 1.04/\sqrt{m}$, estimating a small intersection $|A \cap B|$ via Inclusion-Exclusion magnifies the additive error of $|A \cup B|$ by the ratio $\frac{|A \cup B|}{|A \cap B|}$! For small overlap ratios, use MinHash or Theta Sketches instead of HLL inclusion-exclusion.
Check yourself Question 1 of 4
Concept Check Why HyperLogLog Uses the Harmonic Mean Instead of the Arithmetic or Geometric Mean recall

Flajolet's original LogLog algorithm aggregated $m = 2^p$ register values $M[j]$ (tracking the maximum position of the leftmost `1`-bit, $\rho(w)$) using the geometric mean $2^{\frac{1}{m}\sum M[j]}$, achieving standard error $1.30 / \sqrt{m}$.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does this inclusion-exclusion intersection estimate fail catastrophically in production, and what should be used instead?

Key Takeaways

HyperLogLog partitions a 64-bit hash into a p-bit bucket index (m = 2^p registers) and a (64-p)-bit suffix whose leading-zero rank ρ = clz(w) + 1 estimates log₂(N/m).
Aggregating the m registers via the Harmonic Mean suppresses heavy-tailed outlier variance, achieving standard error σ ≈ 1.04 / √m (0.81% with 16,384 6-bit registers in 12 KB).
For small cardinalities (E ≤ 2.5m with V > 0 empty registers), HLL transitions to LinearCounting E = m ln(m / V) to eliminate small-sample bias.
HLL sketches form a bounded join-semilattice under register-wise max(M_A[j], M_B[j]), enabling lossless O(m) distributed unions across arbitrary shards.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue