pub struct HyperLogLog { /* private fields */ }Implementations§
Source§impl HyperLogLog
impl HyperLogLog
Sourcepub fn to_bytes(&self) -> Vec<u8> ⓘ
pub fn to_bytes(&self) -> Vec<u8> ⓘ
Serialise to the canonical dense form: an 8-byte header then the raw
register array. Length is always 8 + 2^p, so a reader can size the
allocation from the header alone.
Examples found in repository?
209fn collector_fan_in(tape: &[Event]) {
210 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213 for e in tape {
214 per_venue[e.venue as usize].add_u64(e.account);
215 }
216 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219 let mut firm = HyperLogLog::new(14);
220 for bytes in &shipped {
221 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222 firm.merge(&decoded).expect("same precision");
223 }
224 println!(
225 " {} sketches on the wire, {} bytes total",
226 shipped.len(),
227 on_wire
228 );
229 println!(
230 " raw ids would have been ~{} bytes",
231 tape.len() * core::mem::size_of::<u64>()
232 );
233 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
234 assert!(
235 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236 "merged reach within 5%"
237 );
238}Sourcepub fn from_bytes(bytes: &[u8]) -> Result<Self, HllError>
pub fn from_bytes(bytes: &[u8]) -> Result<Self, HllError>
Parse a dense buffer. A sparse buffer is rejected with
UnsupportedEncoding rather than silently densified - use
SparseHyperLogLog::from_bytes, which reads both.
Examples found in repository?
209fn collector_fan_in(tape: &[Event]) {
210 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213 for e in tape {
214 per_venue[e.venue as usize].add_u64(e.account);
215 }
216 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219 let mut firm = HyperLogLog::new(14);
220 for bytes in &shipped {
221 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222 firm.merge(&decoded).expect("same precision");
223 }
224 println!(
225 " {} sketches on the wire, {} bytes total",
226 shipped.len(),
227 on_wire
228 );
229 println!(
230 " raw ids would have been ~{} bytes",
231 tape.len() * core::mem::size_of::<u64>()
232 );
233 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
234 assert!(
235 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236 "merged reach within 5%"
237 );
238}Source§impl HyperLogLog
impl HyperLogLog
Sourcepub fn new(precision: u32) -> Self
pub fn new(precision: u32) -> Self
New empty HLL at the given precision. precision is clamped to
[4, 18]; 14 gives ~16k registers / ~16 KB / ~1% std error. Use
HyperLogLog::try_new when a caller-supplied precision should be
rejected rather than silently pulled into range.
Examples found in repository?
101fn gateway_sessions(tape: &[Event]) {
102 println!("== gateway: distinct sessions this window ==");
103 let mut hll = HyperLogLog::new(14);
104 let mut first_sightings = 0u64;
105 for e in tape {
106 if hll.add_u64(e.session) {
107 first_sightings += 1;
108 }
109 }
110 let est = hll.estimate();
111 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
112 println!(
113 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114 first_sightings,
115 hll.state_bytes(),
116 hll.standard_error() * 100.0
117 );
118 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125 println!("\n== sizing: error budget -> byte budget ==");
126 for budget in [0.05, 0.02, 0.01, 0.005] {
127 let p = HyperLogLog::precision_for_standard_error(budget);
128 let hll = HyperLogLog::new(p);
129 println!(
130 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
131 budget * 100.0,
132 p,
133 hll.state_bytes(),
134 hll.standard_error() * 100.0
135 );
136 }
137}
138
139/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
140/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
141/// across a real universe. The sparse encoding pays only for registers actually
142/// touched, and promotes the two busy names once they earn it.
143#[cfg(feature = "sparse")]
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}
173
174/// `union-intersect`: how many accounts trade on both venues? Inclusion-
175/// exclusion answers it from two sketches. The error bound is printed next to
176/// the answer because it scales with |A| + |B| rather than with the overlap,
177/// and an overlap smaller than its own bound is not a number to act on.
178#[cfg(feature = "union-intersect")]
179fn cross_venue_overlap(tape: &[Event]) {
180 use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
181 println!("\n== venues: account reach and overlap ==");
182
183 let mut a = HyperLogLog::new(14);
184 let mut b = HyperLogLog::new(14);
185 for e in tape {
186 if e.venue == 0 {
187 a.add_u64(e.account);
188 } else {
189 b.add_u64(e.account);
190 }
191 }
192 let union = estimate_union(&a, &b).expect("same precision");
193 let inter = estimate_intersect(&a, &b).expect("same precision");
194 let bound = intersect_error_bound(&a, &b).expect("same precision");
195 println!(" venue 0: {:>7.0} accounts", a.estimate());
196 println!(" venue 1: {:>7.0} accounts", b.estimate());
197 println!(" reach: {union:>7.0} (true 50000)");
198 println!(" both: {inter:>7.0} (true 10000) +/- {bound:.0}");
199 assert!(
200 (union - 50_000.0).abs() / 50_000.0 < 0.05,
201 "reach within 5%, got {union}"
202 );
203 assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
204}
205
206/// `serialize`: each venue ships its sketch, not its account list. The
207/// collector decodes and merges, and the firm-wide number falls out of 16 KB
208/// per venue instead of a million ids on the wire.
209fn collector_fan_in(tape: &[Event]) {
210 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213 for e in tape {
214 per_venue[e.venue as usize].add_u64(e.account);
215 }
216 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219 let mut firm = HyperLogLog::new(14);
220 for bytes in &shipped {
221 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222 firm.merge(&decoded).expect("same precision");
223 }
224 println!(
225 " {} sketches on the wire, {} bytes total",
226 shipped.len(),
227 on_wire
228 );
229 println!(
230 " raw ids would have been ~{} bytes",
231 tape.len() * core::mem::size_of::<u64>()
232 );
233 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
234 assert!(
235 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236 "merged reach within 5%"
237 );
238}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 try_new(precision: u32) -> Result<Self, HllError>
pub fn try_new(precision: u32) -> Result<Self, HllError>
New empty HLL, rejecting a precision outside [4, 18] instead of
clamping it. Reach for this when the precision comes from config or a
wire message and a typo should fail loudly.
pub fn precision(&self) -> u32
pub fn register_count(&self) -> u32
Sourcepub fn standard_error(&self) -> f64
pub fn standard_error(&self) -> f64
Analytic relative standard error, 1.04 / sqrt(m). This is the error
the structure carries by construction, not a measurement of the current
contents: at p=14 it is 0.813%, so a 1,000,000 estimate is one standard
deviation away from anything in [992k, 1008k].
Examples found in repository?
101fn gateway_sessions(tape: &[Event]) {
102 println!("== gateway: distinct sessions this window ==");
103 let mut hll = HyperLogLog::new(14);
104 let mut first_sightings = 0u64;
105 for e in tape {
106 if hll.add_u64(e.session) {
107 first_sightings += 1;
108 }
109 }
110 let est = hll.estimate();
111 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
112 println!(
113 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114 first_sightings,
115 hll.state_bytes(),
116 hll.standard_error() * 100.0
117 );
118 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125 println!("\n== sizing: error budget -> byte budget ==");
126 for budget in [0.05, 0.02, 0.01, 0.005] {
127 let p = HyperLogLog::precision_for_standard_error(budget);
128 let hll = HyperLogLog::new(p);
129 println!(
130 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
131 budget * 100.0,
132 p,
133 hll.state_bytes(),
134 hll.standard_error() * 100.0
135 );
136 }
137}Sourcepub fn precision_for_standard_error(target: f64) -> u32
pub fn precision_for_standard_error(target: f64) -> u32
Smallest precision whose standard error is at or below target
(expressed as a fraction, so 0.01 for 1%). Clamped to [4, 18], so a
target finer than 0.26% returns 18 and the caller gets the best this
recipe allocates for rather than an error.
Examples found in repository?
124fn size_from_an_error_budget() {
125 println!("\n== sizing: error budget -> byte budget ==");
126 for budget in [0.05, 0.02, 0.01, 0.005] {
127 let p = HyperLogLog::precision_for_standard_error(budget);
128 let hll = HyperLogLog::new(p);
129 println!(
130 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
131 budget * 100.0,
132 p,
133 hll.state_bytes(),
134 hll.standard_error() * 100.0
135 );
136 }
137}Sourcepub fn state_bytes(&self) -> usize
pub fn state_bytes(&self) -> usize
Bytes of register state this sketch holds. Fixed at construction and independent of how many items it has seen.
Examples found in repository?
101fn gateway_sessions(tape: &[Event]) {
102 println!("== gateway: distinct sessions this window ==");
103 let mut hll = HyperLogLog::new(14);
104 let mut first_sightings = 0u64;
105 for e in tape {
106 if hll.add_u64(e.session) {
107 first_sightings += 1;
108 }
109 }
110 let est = hll.estimate();
111 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
112 println!(
113 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114 first_sightings,
115 hll.state_bytes(),
116 hll.standard_error() * 100.0
117 );
118 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125 println!("\n== sizing: error budget -> byte budget ==");
126 for budget in [0.05, 0.02, 0.01, 0.005] {
127 let p = HyperLogLog::precision_for_standard_error(budget);
128 let hll = HyperLogLog::new(p);
129 println!(
130 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
131 budget * 100.0,
132 p,
133 hll.state_bytes(),
134 hll.standard_error() * 100.0
135 );
136 }
137}Sourcepub fn clear(&mut self)
pub fn clear(&mut self)
Zero every register, keeping the allocation. Reuse across windows without re-allocating the array.
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 - a register moved
up, so this key was the first of its kind to land that deep. Matching
PFADD’s return, and cheap enough to ignore when you do not want it.
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_bytes(&mut self, key: &[u8]) -> bool
pub fn add_bytes(&mut self, key: &[u8]) -> bool
Record raw bytes. The string path funnels through here, so add("AAPL")
and add_bytes(b"AAPL") land in the same register.
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 first. Hashes the big-endian bytes, so the Rust and Java ports agree register for register on the same id.
Examples found in repository?
101fn gateway_sessions(tape: &[Event]) {
102 println!("== gateway: distinct sessions this window ==");
103 let mut hll = HyperLogLog::new(14);
104 let mut first_sightings = 0u64;
105 for e in tape {
106 if hll.add_u64(e.session) {
107 first_sightings += 1;
108 }
109 }
110 let est = hll.estimate();
111 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
112 println!(
113 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114 first_sightings,
115 hll.state_bytes(),
116 hll.standard_error() * 100.0
117 );
118 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125 println!("\n== sizing: error budget -> byte budget ==");
126 for budget in [0.05, 0.02, 0.01, 0.005] {
127 let p = HyperLogLog::precision_for_standard_error(budget);
128 let hll = HyperLogLog::new(p);
129 println!(
130 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
131 budget * 100.0,
132 p,
133 hll.state_bytes(),
134 hll.standard_error() * 100.0
135 );
136 }
137}
138
139/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
140/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
141/// across a real universe. The sparse encoding pays only for registers actually
142/// touched, and promotes the two busy names once they earn it.
143#[cfg(feature = "sparse")]
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}
173
174/// `union-intersect`: how many accounts trade on both venues? Inclusion-
175/// exclusion answers it from two sketches. The error bound is printed next to
176/// the answer because it scales with |A| + |B| rather than with the overlap,
177/// and an overlap smaller than its own bound is not a number to act on.
178#[cfg(feature = "union-intersect")]
179fn cross_venue_overlap(tape: &[Event]) {
180 use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
181 println!("\n== venues: account reach and overlap ==");
182
183 let mut a = HyperLogLog::new(14);
184 let mut b = HyperLogLog::new(14);
185 for e in tape {
186 if e.venue == 0 {
187 a.add_u64(e.account);
188 } else {
189 b.add_u64(e.account);
190 }
191 }
192 let union = estimate_union(&a, &b).expect("same precision");
193 let inter = estimate_intersect(&a, &b).expect("same precision");
194 let bound = intersect_error_bound(&a, &b).expect("same precision");
195 println!(" venue 0: {:>7.0} accounts", a.estimate());
196 println!(" venue 1: {:>7.0} accounts", b.estimate());
197 println!(" reach: {union:>7.0} (true 50000)");
198 println!(" both: {inter:>7.0} (true 10000) +/- {bound:.0}");
199 assert!(
200 (union - 50_000.0).abs() / 50_000.0 < 0.05,
201 "reach within 5%, got {union}"
202 );
203 assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
204}
205
206/// `serialize`: each venue ships its sketch, not its account list. The
207/// collector decodes and merges, and the firm-wide number falls out of 16 KB
208/// per venue instead of a million ids on the wire.
209fn collector_fan_in(tape: &[Event]) {
210 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213 for e in tape {
214 per_venue[e.venue as usize].add_u64(e.account);
215 }
216 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219 let mut firm = HyperLogLog::new(14);
220 for bytes in &shipped {
221 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222 firm.merge(&decoded).expect("same precision");
223 }
224 println!(
225 " {} sketches on the wire, {} bytes total",
226 shipped.len(),
227 on_wire
228 );
229 println!(
230 " raw ids would have been ~{} bytes",
231 tape.len() * core::mem::size_of::<u64>()
232 );
233 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
234 assert!(
235 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236 "merged reach within 5%"
237 );
238}Sourcepub fn estimate(&self) -> f64
pub fn estimate(&self) -> f64
Estimate distinct count.
Examples found in repository?
101fn gateway_sessions(tape: &[Event]) {
102 println!("== gateway: distinct sessions this window ==");
103 let mut hll = HyperLogLog::new(14);
104 let mut first_sightings = 0u64;
105 for e in tape {
106 if hll.add_u64(e.session) {
107 first_sightings += 1;
108 }
109 }
110 let est = hll.estimate();
111 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
112 println!(
113 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
114 first_sightings,
115 hll.state_bytes(),
116 hll.standard_error() * 100.0
117 );
118 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
119}
120
121/// Sizing runs the other way round in production: you are handed an error
122/// budget, not a precision. `precision_for_standard_error` turns the budget
123/// into the cheapest register array that meets it.
124fn size_from_an_error_budget() {
125 println!("\n== sizing: error budget -> byte budget ==");
126 for budget in [0.05, 0.02, 0.01, 0.005] {
127 let p = HyperLogLog::precision_for_standard_error(budget);
128 let hll = HyperLogLog::new(p);
129 println!(
130 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
131 budget * 100.0,
132 p,
133 hll.state_bytes(),
134 hll.standard_error() * 100.0
135 );
136 }
137}
138
139/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
140/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
141/// across a real universe. The sparse encoding pays only for registers actually
142/// touched, and promotes the two busy names once they earn it.
143#[cfg(feature = "sparse")]
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}
173
174/// `union-intersect`: how many accounts trade on both venues? Inclusion-
175/// exclusion answers it from two sketches. The error bound is printed next to
176/// the answer because it scales with |A| + |B| rather than with the overlap,
177/// and an overlap smaller than its own bound is not a number to act on.
178#[cfg(feature = "union-intersect")]
179fn cross_venue_overlap(tape: &[Event]) {
180 use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
181 println!("\n== venues: account reach and overlap ==");
182
183 let mut a = HyperLogLog::new(14);
184 let mut b = HyperLogLog::new(14);
185 for e in tape {
186 if e.venue == 0 {
187 a.add_u64(e.account);
188 } else {
189 b.add_u64(e.account);
190 }
191 }
192 let union = estimate_union(&a, &b).expect("same precision");
193 let inter = estimate_intersect(&a, &b).expect("same precision");
194 let bound = intersect_error_bound(&a, &b).expect("same precision");
195 println!(" venue 0: {:>7.0} accounts", a.estimate());
196 println!(" venue 1: {:>7.0} accounts", b.estimate());
197 println!(" reach: {union:>7.0} (true 50000)");
198 println!(" both: {inter:>7.0} (true 10000) +/- {bound:.0}");
199 assert!(
200 (union - 50_000.0).abs() / 50_000.0 < 0.05,
201 "reach within 5%, got {union}"
202 );
203 assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
204}
205
206/// `serialize`: each venue ships its sketch, not its account list. The
207/// collector decodes and merges, and the firm-wide number falls out of 16 KB
208/// per venue instead of a million ids on the wire.
209fn collector_fan_in(tape: &[Event]) {
210 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213 for e in tape {
214 per_venue[e.venue as usize].add_u64(e.account);
215 }
216 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219 let mut firm = HyperLogLog::new(14);
220 for bytes in &shipped {
221 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222 firm.merge(&decoded).expect("same precision");
223 }
224 println!(
225 " {} sketches on the wire, {} bytes total",
226 shipped.len(),
227 on_wire
228 );
229 println!(
230 " raw ids would have been ~{} bytes",
231 tape.len() * core::mem::size_of::<u64>()
232 );
233 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
234 assert!(
235 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236 "merged reach within 5%"
237 );
238}Sourcepub fn merge(&mut self, other: &Self) -> Result<(), HllError>
pub fn merge(&mut self, other: &Self) -> Result<(), HllError>
Merge another HLL of the same precision. Element-wise max over registers.
Examples found in repository?
209fn collector_fan_in(tape: &[Event]) {
210 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
211
212 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
213 for e in tape {
214 per_venue[e.venue as usize].add_u64(e.account);
215 }
216 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
217 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
218
219 let mut firm = HyperLogLog::new(14);
220 for bytes in &shipped {
221 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
222 firm.merge(&decoded).expect("same precision");
223 }
224 println!(
225 " {} sketches on the wire, {} bytes total",
226 shipped.len(),
227 on_wire
228 );
229 println!(
230 " raw ids would have been ~{} bytes",
231 tape.len() * core::mem::size_of::<u64>()
232 );
233 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
234 assert!(
235 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
236 "merged reach within 5%"
237 );
238}Trait Implementations§
Source§impl Clone for HyperLogLog
impl Clone for HyperLogLog
Source§fn clone(&self) -> HyperLogLog
fn clone(&self) -> HyperLogLog
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more