HyperLogLog: error, merge and the intersection trap
Simulate a modelNeeds Python
Problem
HyperLogLog 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 sketch 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 Theta sketches 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
Expect an error well under 1% (standard error 1.04/√16,384 ≈ 0.81%); about two thirds of runs should be within 0.81%. Expect the inclusion-exclusion intersection to have a relative error of tens of percent or worse, because the two input errors (about 40,000 each) are of the same size as the answer (50,000).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
Data table
| Run | Error (%) |
|---|---|
| 1 | -0.263 |
| 2 | 1.13 |
| 3 | 0.195 |
| 4 | -0.742 |
| 5 | 0.155 |
| 6 | -0.248 |
| 7 | 0.398 |
| 8 | 0.648 |
| 9 | -0.652 |
| 10 | -0.304 |
| 11 | 0.759 |
| 12 | 0.105 |
| 13 | -1.49 |
| 14 | 0.802 |
| 15 | -0.115 |
| 16 | 0.245 |
| 17 | -1.29 |
| 18 | -0.517 |
| 19 | 0.28 |
| 20 | 0.565 |
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.