Skip to main content

SparseHyperLogLog

Struct SparseHyperLogLog 

Source
pub struct SparseHyperLogLog { /* private fields */ }
Expand description

HyperLogLog variant that holds a compact (idx, rho) pair list at low cardinality and promotes to a dense register array once the list grows past a threshold.

Single-writer, same as the base type: add, merge, clear and promote take &mut self.

Implementations§

Source§

impl SparseHyperLogLog

Source

pub fn to_bytes(&self) -> Vec<u8>

Serialise in whichever representation the sketch currently holds. A thin sketch stays thin on the wire; a promoted one writes the same dense buffer HyperLogLog::to_bytes would.

Source

pub fn from_bytes(bytes: &[u8]) -> Result<Self, HllError>

Parse either encoding. A dense buffer comes back as an already-promoted sketch, which is the honest reading: the writer had crossed the threshold and the reader inherits that.

Source§

impl SparseHyperLogLog

Source

pub fn new(precision: u32) -> Self

New empty sparse-mode HLL at precision p (clamped to [4, 18]) and default threshold m / 4.

m/4 is a round heuristic and it overshoots: at five bytes an entry the pair list reaches the dense array’s byte cost at m/5, so between m/5 and m/4 a sparse sketch is both bigger and slower to probe. Pass with_threshold(p, m / 5) when bytes are what you are buying.

Examples found in repository?
examples/perf_features.rs (line 190)
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}
Source

pub fn with_threshold(precision: u32, threshold: usize) -> Self

Explicit promotion threshold (in distinct register entries). Use this when you know the workload’s cardinality envelope and want to delay or hasten the dense crossover.

Examples found in repository?
examples/sample_app.rs (line 149)
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
More examples
Hide additional examples
examples/perf_features.rs (line 164)
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}
Source

pub fn precision(&self) -> u32

Source

pub fn register_count(&self) -> u32

Source

pub fn is_sparse(&self) -> bool

Examples found in repository?
examples/sample_app.rs (line 162)
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
Source

pub fn threshold(&self) -> usize

Entry count at which this sketch promotes to dense.

Source

pub fn standard_error(&self) -> f64

Analytic relative standard error once dense, 1.04 / sqrt(m). Sparse mode is tighter than this because linear counting over a mostly-empty register space is the accurate estimator down there; the number is the envelope the sketch converges to, not a bound on its current state.

Source

pub fn state_bytes(&self) -> usize

Payload cost of the representation, register array or pair list. This is the number the feature exists to move: at p=14 an untouched sparse sketch is a fraction of the dense 16384 bytes.

Five bytes per entry, matching the wire encoding rather than the allocator - a Vec<(u32, u8)> pads each pair to eight and Java’s two parallel arrays do not, so a layout-exact number would disagree across the ports for no useful reason.

Examples found in repository?
examples/sample_app.rs (line 161)
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
Source

pub fn is_empty(&self) -> bool

True while nothing has been recorded.

Source

pub fn clear(&mut self)

Reset to an empty sparse sketch, dropping the dense array if we had promoted. The threshold survives; a reused sketch keeps its sizing.

Source

pub fn entry_count(&self) -> usize

Distinct register entries currently held. Once promoted to dense the answer is the count of non-zero registers.

Source

pub fn add(&mut self, key: &str) -> bool

Record a key. Returns true when the sketch changed. If we’re sparse and the new entry pushes us past the threshold, promote to dense before returning.

Examples found in repository?
examples/perf_features.rs (line 166)
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}
Source

pub fn add_u64(&mut self, key: u64) -> bool

Record a 64-bit id without rendering it to a string.

Examples found in repository?
examples/sample_app.rs (line 152)
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
Source

pub fn add_bytes(&mut self, key: &[u8]) -> bool

Record raw bytes.

Source

pub fn estimate(&self) -> f64

Estimate distinct count. In sparse mode the registers we don’t hold are zero, so linear counting is exact under the HLL assumption that absent registers contribute log term -m * ln(1) = 0. We use the base HLL formula uniformly for consistency.

Examples found in repository?
examples/sample_app.rs (line 160)
144fn per_symbol_counterparties(tape: &[Event]) {
145    use subms_hyperloglog::SparseHyperLogLog;
146    println!("\n== risk: distinct counterparties per symbol ==");
147
148    let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
149        .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
150        .collect();
151    for e in tape {
152        books[e.symbol].add_u64(e.counterparty);
153    }
154
155    let mut sparse_bytes = 0usize;
156    for (i, b) in books.iter().enumerate() {
157        println!(
158            "  {:<5} {:>7.0} counterparties  {:>6} bytes  {}",
159            SYMBOLS[i],
160            b.estimate(),
161            b.state_bytes(),
162            if b.is_sparse() { "sparse" } else { "dense" }
163        );
164        sparse_bytes += b.state_bytes();
165    }
166    let dense_bytes = SYMBOLS.len() * 16_384;
167    println!("  total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
168    assert!(
169        sparse_bytes < dense_bytes,
170        "sparse must win on the long tail"
171    );
172}
More examples
Hide additional examples
examples/perf_features.rs (line 208)
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}
Source

pub fn merge(&mut self, other: &Self) -> Result<(), HllError>

Merge another sparse sketch of the same precision. Two sparse lists combine entry-wise and may cross the threshold on the way, in which case the result promotes. Once either side is dense the merge runs on dense registers, which is where a fan-in of many shards ends up.

Source

pub fn promote(&mut self)

Force promotion to dense even if below the threshold. Useful for benchmarking or for handing the inner dense HLL to a peer that does not understand sparse mode.

Source

pub fn as_dense(&self) -> Option<&HyperLogLog>

View into the dense HLL after promotion. None while sparse.

Source

pub fn to_dense(&self) -> HyperLogLog

Materialise a dense copy without mutating this sketch. The bridge to estimate_union / estimate_intersect, which only take base sketches.

Trait Implementations§

Source§

impl Clone for SparseHyperLogLog

Source§

fn clone(&self) -> SparseHyperLogLog

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SparseHyperLogLog

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SparseHyperLogLog

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Serialize for SparseHyperLogLog

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DeserializeOwned for T
where T: for<'de> Deserialize<'de>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.