Skip to content
About
Main content

HyperLogLog: error, merge and the intersection trap

Simulate a modelNeeds Python

Problem

estimates how many distinct items a stream has in a few kilobytes: it hashes each item, keeps in each of m registers the longest run of leading zeros it has seen, and combines the registers into an estimate with a standard error of about 1.04/√m. Its registers merge by taking the element-wise maximum, so the of a union is exact to compute from the sketches of its parts. Intersections are another matter: HLL has no intersection operation, and the usual workaround, |A| + |B| − |A ∪ B|, subtracts large estimates whose errors do not cancel.

Implement HLL with 2¹⁴ registers. Feed 10 million distinct integers and compare the estimate with the truth. Run 20 times with different data. Then build HLLs for two sets A and B of 5 million each, overlapping in 50,000 items (1%). Merge them with element-wise max and check that the result equals the HLL of the union. Estimate |A ∩ B| by inclusion-exclusion. Repeat with and compare.

The question. How large is HLL's error over 20 runs, does the merge equal the HLL of the union, and how wrong is an intersection by inclusion-exclusion compared with a Theta sketch?


Predict

Intersecting two 5-million sets with a true overlap of 50,000 by inclusion-exclusion on HLL: how far off is the answer?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    hyperloglog-error-merge-and-the-intersection.py77 linesDownload
    View the code
    hyperloglog-error-merge-and-the-intersection.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy", "datasketches"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """HyperLogLog: error, merge, and the intersection trap.
    
    An HLL with 2^14 registers over 10 million distinct integers, 20 times; two
    sets of 5 million overlapping in 50,000, merged by element-wise max; their
    intersection by inclusion-exclusion; then the same with Theta sketches.
    """
    import argparse, json
    import numpy as np
    from datasketches import theta_sketch, theta_intersection, update_theta_sketch
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--p", type=int, default=14, help="log2 of the number of registers.")
    p.add_argument("--distinct", type=int, default=10_000_000, help="Distinct items for the error test.")
    p.add_argument("--runs", type=int, default=20, help="Runs for the error test.")
    p.add_argument("--set-size", type=int, default=5_000_000, help="Items in each of A and B.")
    p.add_argument("--overlap", type=int, default=50_000, help="Items in both A and B.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    m = 1 << args.p
    
    def mix(x):
        z = x.astype(np.uint64) + np.uint64(0x9E3779B97F4A7C15)
        z = (z ^ (z >> np.uint64(30))) * np.uint64(0xBF58476D1CE4E5B9)
        z = (z ^ (z >> np.uint64(27))) * np.uint64(0x94D049BB133111EB)
        return z ^ (z >> np.uint64(31))
    
    def hll(items):
        h = mix(items); idx = (h >> np.uint64(64 - args.p)).astype(np.int64)
        rest = (h << np.uint64(args.p)) | np.uint64(1 << (args.p - 1))
        rank = (64 - np.floor(np.log2(rest.astype(np.float64))).astype(np.int64))      # leading zeros + 1
        reg = np.zeros(m, dtype=np.int64); np.maximum.at(reg, idx, rank); return reg
    
    def estimate(reg):
        a = 0.7213 / (1 + 1.079 / m); e = a * m * m / np.sum(2.0 ** -reg)
        z = np.sum(reg == 0)
        return m * np.log(m / z) if e <= 2.5 * m and z else e
    
    se = 1.04 / np.sqrt(m)
    errs = []
    for r in range(args.runs):
        items = np.arange(args.distinct, dtype=np.uint64) + np.uint64(r * 10**9)
        errs.append(estimate(hll(items)) / args.distinct - 1)
    within = float(np.mean(np.abs(errs) <= se))
    print(f"{args.distinct:,} distinct, {args.runs} runs: mean |error| {np.mean(np.abs(errs)):.3%}, "
          f"{within:.0%} of runs within {se:.2%} (expected about two thirds)")
    
    shared = np.arange(args.overlap, dtype=np.uint64) + np.uint64(5 * 10**10)
    A = np.concatenate([np.arange(args.set_size - args.overlap, dtype=np.uint64) + np.uint64(10**10), shared])
    B = np.concatenate([np.arange(args.set_size - args.overlap, dtype=np.uint64) + np.uint64(2 * 10**10), shared])
    ra, rb = hll(A), hll(B)
    merged = np.maximum(ra, rb); union = hll(np.concatenate([A, B]))
    same = bool(np.array_equal(merged, union))
    inter = estimate(ra) + estimate(rb) - estimate(merged)
    print(f"merge by element-wise max equals the HLL of the union: {same}")
    print(f"|A ∩ B| by inclusion-exclusion: {inter:,.0f} (true {args.overlap:,}; error {inter / args.overlap - 1:+.0%})")
    
    sa, sb = update_theta_sketch(12), update_theta_sketch(12)
    for x in A.tolist(): sa.update(x)
    for x in B.tolist(): sb.update(x)
    ti = theta_intersection(); ti.update(sa); ti.update(sb)
    theta_inter = ti.get_result().get_estimate()
    print(f"|A ∩ B| with Theta sketches (k = 4,096): {theta_inter:,.0f} (error {theta_inter / args.overlap - 1:+.0%})")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"runs": [{"run": i + 1, "error_pct": e * 100} for i, e in enumerate(errs)],
                       "summary": [{"se_pct": se * 100, "within_pct": within * 100, "mean_abs_err_pct": float(np.mean(np.abs(errs))) * 100,
                                    "merge_equal": "yes" if same else "no", "hll_inter": inter, "hll_inter_err_pct": (inter / args.overlap - 1) * 100,
                                    "theta_inter": theta_inter, "theta_inter_err_pct": (theta_inter / args.overlap - 1) * 100, "overlap": args.overlap}]}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run hyperloglog-error-merge-and-the-intersection.py
    uv run hyperloglog-error-merge-and-the-intersection.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--p14 log2 of the number of registers.
--distinct10,000,000 Distinct items for the error test.
--runs20 Runs for the error test.
--set-size5,000,000 Items in each of A and B.
--overlap50,000 Items in both A and B.
--seed0 Random seed.

Our run

HLL error over 20 runs (10 million distinct items)Apple M2 · 8 cores · 8 GB
Data table
HLL error over 20 runs (10 million distinct items)
RunError (%)
1-0.263
21.13
30.195
4-0.742
50.155
6-0.248
70.398
80.648
9-0.652
10-0.304
110.759
120.105
13-1.49
140.802
15-0.115
160.245
17-1.29
18-0.517
190.28
200.565
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · datasketches 5.2.0 · run 2026-10-20
Output · uv run hyperloglog-error-merge-and-the-intersection.py
10,000,000 distinct, 20 runs: mean |error| 0.545%, 85% of runs within 0.81% (expected about two thirds)
merge by element-wise max equals the HLL of the union: True
|A ∩ B| by inclusion-exclusion: 38,696 (true 50,000; error -23%)
|A ∩ B| with Theta sketches (k = 4,096): 49,747 (error -1%)

What we found

Over 20 runs the mean absolute error was 0.545%, and 85% of runs fell within one standard error (0.813%), more than the expected two thirds: with 20 runs that difference is within chance. The merged sketch equalled the sketch of the union: yes. The inclusion-exclusion intersection came to 38.7K for a true overlap of 50K, an error of -22.6%; the Theta sketch gave 49.7K (-0.506%).


Read it

HLL's error is relative to the set it measures. For a union that is excellent; for an intersection by subtraction, the error is relative to the inputs, and an answer a hundred times smaller than the inputs drowns in it. Another seed can be off by much more, or come out negative.

If intersections matter, choose a sketch that supports them: Theta sketches keep a sample of hash values, so set operations on samples estimate intersections with an error relative to the answer.