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
impl SparseHyperLogLog
Sourcepub fn to_bytes(&self) -> Vec<u8> ⓘ
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.
Sourcepub fn from_bytes(bytes: &[u8]) -> Result<Self, HllError>
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
impl SparseHyperLogLog
Sourcepub fn new(precision: u32) -> Self
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?
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}Sourcepub fn with_threshold(precision: u32, threshold: usize) -> Self
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?
140fn per_symbol_counterparties(tape: &[Event]) {
141 use subms_hyperloglog::SparseHyperLogLog;
142 println!("\n== risk: distinct counterparties per symbol ==");
143
144 let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
145 .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
146 .collect();
147 for e in tape {
148 books[e.symbol].add_u64(e.counterparty);
149 }
150
151 let mut sparse_bytes = 0usize;
152 for (i, b) in books.iter().enumerate() {
153 println!(
154 " {:<5} {:>7.0} counterparties {:>6} bytes {}",
155 SYMBOLS[i],
156 b.estimate(),
157 b.state_bytes(),
158 if b.is_sparse() { "sparse" } else { "dense" }
159 );
160 sparse_bytes += b.state_bytes();
161 }
162 let dense_bytes = SYMBOLS.len() * 16_384;
163 println!(" total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
164 assert!(
165 sparse_bytes < dense_bytes,
166 "sparse must win on the long tail"
167 );
168}More examples
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}pub fn precision(&self) -> u32
pub fn register_count(&self) -> u32
Sourcepub fn is_sparse(&self) -> bool
pub fn is_sparse(&self) -> bool
Examples found in repository?
140fn per_symbol_counterparties(tape: &[Event]) {
141 use subms_hyperloglog::SparseHyperLogLog;
142 println!("\n== risk: distinct counterparties per symbol ==");
143
144 let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
145 .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
146 .collect();
147 for e in tape {
148 books[e.symbol].add_u64(e.counterparty);
149 }
150
151 let mut sparse_bytes = 0usize;
152 for (i, b) in books.iter().enumerate() {
153 println!(
154 " {:<5} {:>7.0} counterparties {:>6} bytes {}",
155 SYMBOLS[i],
156 b.estimate(),
157 b.state_bytes(),
158 if b.is_sparse() { "sparse" } else { "dense" }
159 );
160 sparse_bytes += b.state_bytes();
161 }
162 let dense_bytes = SYMBOLS.len() * 16_384;
163 println!(" total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
164 assert!(
165 sparse_bytes < dense_bytes,
166 "sparse must win on the long tail"
167 );
168}Sourcepub fn standard_error(&self) -> f64
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.
Sourcepub fn state_bytes(&self) -> usize
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?
140fn per_symbol_counterparties(tape: &[Event]) {
141 use subms_hyperloglog::SparseHyperLogLog;
142 println!("\n== risk: distinct counterparties per symbol ==");
143
144 let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
145 .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
146 .collect();
147 for e in tape {
148 books[e.symbol].add_u64(e.counterparty);
149 }
150
151 let mut sparse_bytes = 0usize;
152 for (i, b) in books.iter().enumerate() {
153 println!(
154 " {:<5} {:>7.0} counterparties {:>6} bytes {}",
155 SYMBOLS[i],
156 b.estimate(),
157 b.state_bytes(),
158 if b.is_sparse() { "sparse" } else { "dense" }
159 );
160 sparse_bytes += b.state_bytes();
161 }
162 let dense_bytes = SYMBOLS.len() * 16_384;
163 println!(" total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
164 assert!(
165 sparse_bytes < dense_bytes,
166 "sparse must win on the long tail"
167 );
168}Sourcepub fn clear(&mut self)
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.
Sourcepub fn entry_count(&self) -> usize
pub fn entry_count(&self) -> usize
Distinct register entries currently held. Once promoted to dense the answer is the count of non-zero registers.
Sourcepub fn add(&mut self, key: &str) -> bool
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?
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}Sourcepub fn add_u64(&mut self, key: u64) -> bool
pub fn add_u64(&mut self, key: u64) -> bool
Record a 64-bit id without rendering it to a string.
Examples found in repository?
140fn per_symbol_counterparties(tape: &[Event]) {
141 use subms_hyperloglog::SparseHyperLogLog;
142 println!("\n== risk: distinct counterparties per symbol ==");
143
144 let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
145 .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
146 .collect();
147 for e in tape {
148 books[e.symbol].add_u64(e.counterparty);
149 }
150
151 let mut sparse_bytes = 0usize;
152 for (i, b) in books.iter().enumerate() {
153 println!(
154 " {:<5} {:>7.0} counterparties {:>6} bytes {}",
155 SYMBOLS[i],
156 b.estimate(),
157 b.state_bytes(),
158 if b.is_sparse() { "sparse" } else { "dense" }
159 );
160 sparse_bytes += b.state_bytes();
161 }
162 let dense_bytes = SYMBOLS.len() * 16_384;
163 println!(" total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
164 assert!(
165 sparse_bytes < dense_bytes,
166 "sparse must win on the long tail"
167 );
168}Sourcepub fn estimate(&self) -> f64
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?
140fn per_symbol_counterparties(tape: &[Event]) {
141 use subms_hyperloglog::SparseHyperLogLog;
142 println!("\n== risk: distinct counterparties per symbol ==");
143
144 let mut books: Vec<SparseHyperLogLog> = (0..SYMBOLS.len())
145 .map(|_| SparseHyperLogLog::with_threshold(14, 2_000))
146 .collect();
147 for e in tape {
148 books[e.symbol].add_u64(e.counterparty);
149 }
150
151 let mut sparse_bytes = 0usize;
152 for (i, b) in books.iter().enumerate() {
153 println!(
154 " {:<5} {:>7.0} counterparties {:>6} bytes {}",
155 SYMBOLS[i],
156 b.estimate(),
157 b.state_bytes(),
158 if b.is_sparse() { "sparse" } else { "dense" }
159 );
160 sparse_bytes += b.state_bytes();
161 }
162 let dense_bytes = SYMBOLS.len() * 16_384;
163 println!(" total {sparse_bytes} bytes against {dense_bytes} if every name held a dense array");
164 assert!(
165 sparse_bytes < dense_bytes,
166 "sparse must win on the long tail"
167 );
168}More examples
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}Sourcepub fn merge(&mut self, other: &Self) -> Result<(), HllError>
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.
Sourcepub fn promote(&mut self)
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.
Sourcepub fn as_dense(&self) -> Option<&HyperLogLog>
pub fn as_dense(&self) -> Option<&HyperLogLog>
View into the dense HLL after promotion. None while sparse.
Sourcepub fn to_dense(&self) -> HyperLogLog
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
impl Clone for SparseHyperLogLog
Source§fn clone(&self) -> SparseHyperLogLog
fn clone(&self) -> SparseHyperLogLog
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more