Cardinality wall and Gorilla encoding
Measure your machineNeeds Python with NumPy, datasketches
Problem
A time-series database stores one series per label combination, so adding a high-cardinality label multiplies the series count by its cardinality. Within a series, Gorilla-style encoding exploits regularity: timestamps at a fixed interval have delta-of-deltas of zero (one bit each), and values that change slowly XOR to words with long runs of zero bits. Noise removes both regularities. Rollups add a third trap: means can be rolled up from stored sums and counts, percentiles cannot be averaged, and merging mergeable sketches (t-digests) recovers them.
- Count series for labels
region(5),city(50),courier_tier(3), then add auser_idlabel with 100,000 values, at 3 KB per series. 2. Generate a million timestamps at a 1 s interval with small jitter and a million slowly drifting values; count zero delta-of-deltas and XOR leading and trailing zero bits, estimate the encoded size against 16 raw bytes per point andzlib; repeat with noisy values. 3. Roll an hour of latencies up to one-minute buckets: the hourly mean from minute sums and counts, the hourly p99 from minute p99 values, and from merged t-digests.
The question. How many series does the user_id label add, how much does Gorilla encoding save on regular and on noisy data, and which rollups preserve the hourly p99?
Predict
The label product explodes from hundreds to millions of series. Regular timestamps give mostly zeros and a ratio of 10x or more; adding noise to the data drops the ratio toward 2x. The p99 derived from minute p99 values is visibly wrong, while the t-digest merge is close to the true value.
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
cardinality-wall-and-gorilla-encoding.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 """The cardinality wall, and Gorilla encoding. (1) Series counts for labels region (5), city (50) and courier_tier (3), then with a user_id label of 100,000 values, at 3 KB per series. (2) A million timestamps at a 1 s interval with small jitter and a million slowly drifting values: delta-of-delta zeros, XOR leading/trailing zero bits, the estimated encoded size against 16 raw bytes per point and zlib; then the same with noisy values. (3) Minute rollups: the hourly mean from minute (sum, count), the hourly p99 from minute p99s, and from merged t-digests. """ import argparse, json, struct, zlib import numpy as np from datasketches import tdigest_double p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--points", type=int, default=1_000_000, help="Points.") p.add_argument("--users", type=int, default=100_000, help="Values of user_id.") 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() rng = np.random.default_rng(args.seed) base_series = 5 * 50 * 3; with_user = base_series * args.users card = [{"labels": "region, city, courier_tier", "series": base_series, "memory_gb": base_series * 3 / 2**20}, {"labels": "+ user_id", "series": with_user, "memory_gb": with_user * 3 / 2**20}] def encoded_bits(ts, vals): dod = np.diff(np.diff(ts)); zero = int((dod == 0).sum()) ts_bits = zero * 1 + (len(dod) - zero) * 14 # 1 bit per zero, ~9-14 bits otherwise (upper end) b = vals.astype(np.float64).view(np.uint64); x = b[1:] ^ b[:-1]; same = int((x == 0).sum()) nz = x[x != 0]; lead = np.array([64 - int(v).bit_length() for v in nz.tolist()]); trail = np.array([(int(v) & -int(v)).bit_length() - 1 for v in nz.tolist()]) meaningful = 64 - lead - trail val_bits = same * 1 + int((meaningful + 13).sum()) # control bits + 6-bit lead + 6-bit length + meaningful bits return ts_bits, val_bits, zero / len(dod), same / len(x), float(lead.mean()) if len(lead) else 64, float(trail.mean()) if len(trail) else 64 rows = [] ts = (1_700_000_000_000 + np.arange(args.points) * 1000 + np.where(rng.random(args.points) < 0.05, rng.integers(-3, 4, args.points), 0)).astype(np.int64) for label, vals in (("slowly drifting values", np.round(20 + np.cumsum(rng.normal(0, 0.01, args.points)), 2)), ("noisy values", 20 + rng.normal(0, 1, args.points))): tb, vb, zero, same, lead, trail = encoded_bits(ts, vals) raw = args.points * 16; enc = (tb + vb) / 8 z = len(zlib.compress(ts.tobytes() + vals.astype(np.float64).tobytes(), 6)) rows.append({"data": label, "dod_zero_pct": zero * 100, "xor_equal_pct": same * 100, "mean_leading_zeros": lead, "mean_trailing_zeros": trail, "raw_mb": raw / 1e6, "gorilla_mb": enc / 1e6, "gorilla_ratio": raw / enc, "zlib_ratio": raw / z}) lat = rng.lognormal(3, 0.8, 3600 * 200) # an hour of latencies, 200 per second minutes = lat.reshape(60, -1) true_mean, true_p99 = float(lat.mean()), float(np.percentile(lat, 99)) mean_from_sums = float(minutes.sum(1).sum() / minutes.size); p99_from_p99s = float(np.mean(np.percentile(minutes, 99, axis=1))) merged = tdigest_double(200) for m in minutes: td = tdigest_double(200) for v in m.tolist(): td.update(v) merged.merge(td) rollup = [{"statistic": "hourly mean from minute (sum, count)", "value": mean_from_sums, "true": true_mean}, {"statistic": "hourly p99 as mean of minute p99s", "value": p99_from_p99s, "true": true_p99}, {"statistic": "hourly p99 from merged t-digests", "value": float(merged.get_quantile(0.99)), "true": true_p99}] for r in rollup: r["err_pct"] = (r["value"] / r["true"] - 1) * 100 for r in card: print(f"{r['labels']:<30} {r['series']:>12,} series ~{r['memory_gb']:8.2f} GB at 3 KB each") for r in rows: print(f"{r['data']:<24} dod zeros {r['dod_zero_pct']:5.1f}% XOR equal {r['xor_equal_pct']:5.1f}% Gorilla ~{r['gorilla_ratio']:4.1f}x zlib {r['zlib_ratio']:4.1f}x") for r in rollup: print(f"{r['statistic']:<40} {r['value']:8.2f} (true {r['true']:.2f}, {r['err_pct']:+.2f}%)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"cardinality": card, "encoding": rows, "rollup": rollup}}, 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 cardinality-wall-and-gorilla-encoding.py uv run cardinality-wall-and-gorilla-encoding.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --points1,000,000 | Points. |
| --users100,000 | Values of user_id. |
| --seed0 | Random seed. |
Our run
Series count
| Labels | Series | Memory (GB) |
|---|---|---|
| region, city, courier_tier | 750 | 0.002 |
| + user_id | 75M | 215 |
A million points
| Values | Zero delta-of-delta | XOR = 0 | Gorilla (est.) | zlib |
|---|---|---|---|---|
| slowly drifting values | 87.8% | 36.9% | 3.34× | 4.89× |
| noisy values | 87.8% | 0% | 2× | 1.62× |
An hour rolled up from minutes
| Statistic | Value | True | Error |
|---|---|---|---|
| hourly mean from minute (sum, count) | 27.7 | 27.7 | 0% |
| hourly p99 as mean of minute p99s | 129 | 129 | -0.1% |
| hourly p99 from merged t-digests | 131 | 129 | 1.36% |
region, city, courier_tier 750 series ~ 0.00 GB at 3 KB each
+ user_id 75,000,000 series ~ 214.58 GB at 3 KB each
slowly drifting values dod zeros 87.8% XOR equal 36.9% Gorilla ~ 3.3x zlib 4.9x
noisy values dod zeros 87.8% XOR equal 0.0% Gorilla ~ 2.0x zlib 1.6x
hourly mean from minute (sum, count) 27.66 (true 27.66, +0.00%)
hourly p99 as mean of minute p99s 129.22 (true 129.35, -0.10%)
hourly p99 from merged t-digests 131.11 (true 129.35, +1.36%)What we found
The three labels made 750 series; adding user_id made 75M, about 215 GB at 3 KB each. Delta-of-delta was zero for 87.8% of timestamps. The estimated Gorilla ratio was 3.34× for drifting values and 2× for noisy ones, below the expected 10×: the values drift at two decimals, so only 36.9% repeat exactly. The rollups did not show the expected failure: the mean of minute p99s was -0.1% off and merged t-digests 1.36%, because every minute here has the same distribution; averaging percentiles fails when minutes differ, as in the observability lab on merged histograms.
Read it
Cardinality is the cost driver of a metrics system, and it multiplies. Compression depends on regularity, so scrape intervals that drift and values with needless precision both cost bytes. And rollups must store what can be combined: sums and counts for means, sketches for percentiles.