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?
205fn collector_fan_in(tape: &[Event]) {
206 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
207
208 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
209 for e in tape {
210 per_venue[e.venue as usize].add_u64(e.account);
211 }
212 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
213 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
214
215 let mut firm = HyperLogLog::new(14);
216 for bytes in &shipped {
217 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
218 firm.merge(&decoded).expect("same precision");
219 }
220 println!(
221 " {} sketches on the wire, {} bytes total",
222 shipped.len(),
223 on_wire
224 );
225 println!(
226 " raw ids would have been ~{} bytes",
227 tape.len() * core::mem::size_of::<u64>()
228 );
229 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
230 assert!(
231 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
232 "merged reach within 5%"
233 );
234}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?
205fn collector_fan_in(tape: &[Event]) {
206 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
207
208 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
209 for e in tape {
210 per_venue[e.venue as usize].add_u64(e.account);
211 }
212 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
213 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
214
215 let mut firm = HyperLogLog::new(14);
216 for bytes in &shipped {
217 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
218 firm.merge(&decoded).expect("same precision");
219 }
220 println!(
221 " {} sketches on the wire, {} bytes total",
222 shipped.len(),
223 on_wire
224 );
225 println!(
226 " raw ids would have been ~{} bytes",
227 tape.len() * core::mem::size_of::<u64>()
228 );
229 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
230 assert!(
231 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
232 "merged reach within 5%"
233 );
234}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?
97fn gateway_sessions(tape: &[Event]) {
98 println!("== gateway: distinct sessions this window ==");
99 let mut hll = HyperLogLog::new(14);
100 let mut first_sightings = 0u64;
101 for e in tape {
102 if hll.add_u64(e.session) {
103 first_sightings += 1;
104 }
105 }
106 let est = hll.estimate();
107 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
108 println!(
109 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
110 first_sightings,
111 hll.state_bytes(),
112 hll.standard_error() * 100.0
113 );
114 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
115}
116
117/// Sizing runs the other way round in production: you are handed an error
118/// budget, not a precision. `precision_for_standard_error` turns the budget
119/// into the cheapest register array that meets it.
120fn size_from_an_error_budget() {
121 println!("\n== sizing: error budget -> byte budget ==");
122 for budget in [0.05, 0.02, 0.01, 0.005] {
123 let p = HyperLogLog::precision_for_standard_error(budget);
124 let hll = HyperLogLog::new(p);
125 println!(
126 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
127 budget * 100.0,
128 p,
129 hll.state_bytes(),
130 hll.standard_error() * 100.0
131 );
132 }
133}
134
135/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
136/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
137/// across a real universe. The sparse encoding pays only for registers actually
138/// touched, and promotes the two busy names once they earn it.
139#[cfg(feature = "sparse")]
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}
169
170/// `union-intersect`: how many accounts trade on both venues? Inclusion-
171/// exclusion answers it from two sketches. The error bound is printed next to
172/// the answer because it scales with |A| + |B| rather than with the overlap,
173/// and an overlap smaller than its own bound is not a number to act on.
174#[cfg(feature = "union-intersect")]
175fn cross_venue_overlap(tape: &[Event]) {
176 use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
177 println!("\n== venues: account reach and overlap ==");
178
179 let mut a = HyperLogLog::new(14);
180 let mut b = HyperLogLog::new(14);
181 for e in tape {
182 if e.venue == 0 {
183 a.add_u64(e.account);
184 } else {
185 b.add_u64(e.account);
186 }
187 }
188 let union = estimate_union(&a, &b).expect("same precision");
189 let inter = estimate_intersect(&a, &b).expect("same precision");
190 let bound = intersect_error_bound(&a, &b).expect("same precision");
191 println!(" venue 0: {:>7.0} accounts", a.estimate());
192 println!(" venue 1: {:>7.0} accounts", b.estimate());
193 println!(" reach: {union:>7.0} (true 50000)");
194 println!(" both: {inter:>7.0} (true 10000) +/- {bound:.0}");
195 assert!(
196 (union - 50_000.0).abs() / 50_000.0 < 0.05,
197 "reach within 5%, got {union}"
198 );
199 assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
200}
201
202/// `serialize`: each venue ships its sketch, not its account list. The
203/// collector decodes and merges, and the firm-wide number falls out of 16 KB
204/// per venue instead of a million ids on the wire.
205fn collector_fan_in(tape: &[Event]) {
206 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
207
208 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
209 for e in tape {
210 per_venue[e.venue as usize].add_u64(e.account);
211 }
212 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
213 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
214
215 let mut firm = HyperLogLog::new(14);
216 for bytes in &shipped {
217 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
218 firm.merge(&decoded).expect("same precision");
219 }
220 println!(
221 " {} sketches on the wire, {} bytes total",
222 shipped.len(),
223 on_wire
224 );
225 println!(
226 " raw ids would have been ~{} bytes",
227 tape.len() * core::mem::size_of::<u64>()
228 );
229 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
230 assert!(
231 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
232 "merged reach within 5%"
233 );
234}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?
97fn gateway_sessions(tape: &[Event]) {
98 println!("== gateway: distinct sessions this window ==");
99 let mut hll = HyperLogLog::new(14);
100 let mut first_sightings = 0u64;
101 for e in tape {
102 if hll.add_u64(e.session) {
103 first_sightings += 1;
104 }
105 }
106 let est = hll.estimate();
107 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
108 println!(
109 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
110 first_sightings,
111 hll.state_bytes(),
112 hll.standard_error() * 100.0
113 );
114 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
115}
116
117/// Sizing runs the other way round in production: you are handed an error
118/// budget, not a precision. `precision_for_standard_error` turns the budget
119/// into the cheapest register array that meets it.
120fn size_from_an_error_budget() {
121 println!("\n== sizing: error budget -> byte budget ==");
122 for budget in [0.05, 0.02, 0.01, 0.005] {
123 let p = HyperLogLog::precision_for_standard_error(budget);
124 let hll = HyperLogLog::new(p);
125 println!(
126 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
127 budget * 100.0,
128 p,
129 hll.state_bytes(),
130 hll.standard_error() * 100.0
131 );
132 }
133}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?
120fn size_from_an_error_budget() {
121 println!("\n== sizing: error budget -> byte budget ==");
122 for budget in [0.05, 0.02, 0.01, 0.005] {
123 let p = HyperLogLog::precision_for_standard_error(budget);
124 let hll = HyperLogLog::new(p);
125 println!(
126 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
127 budget * 100.0,
128 p,
129 hll.state_bytes(),
130 hll.standard_error() * 100.0
131 );
132 }
133}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?
97fn gateway_sessions(tape: &[Event]) {
98 println!("== gateway: distinct sessions this window ==");
99 let mut hll = HyperLogLog::new(14);
100 let mut first_sightings = 0u64;
101 for e in tape {
102 if hll.add_u64(e.session) {
103 first_sightings += 1;
104 }
105 }
106 let est = hll.estimate();
107 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
108 println!(
109 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
110 first_sightings,
111 hll.state_bytes(),
112 hll.standard_error() * 100.0
113 );
114 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
115}
116
117/// Sizing runs the other way round in production: you are handed an error
118/// budget, not a precision. `precision_for_standard_error` turns the budget
119/// into the cheapest register array that meets it.
120fn size_from_an_error_budget() {
121 println!("\n== sizing: error budget -> byte budget ==");
122 for budget in [0.05, 0.02, 0.01, 0.005] {
123 let p = HyperLogLog::precision_for_standard_error(budget);
124 let hll = HyperLogLog::new(p);
125 println!(
126 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
127 budget * 100.0,
128 p,
129 hll.state_bytes(),
130 hll.standard_error() * 100.0
131 );
132 }
133}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?
97fn gateway_sessions(tape: &[Event]) {
98 println!("== gateway: distinct sessions this window ==");
99 let mut hll = HyperLogLog::new(14);
100 let mut first_sightings = 0u64;
101 for e in tape {
102 if hll.add_u64(e.session) {
103 first_sightings += 1;
104 }
105 }
106 let est = hll.estimate();
107 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
108 println!(
109 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
110 first_sightings,
111 hll.state_bytes(),
112 hll.standard_error() * 100.0
113 );
114 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
115}
116
117/// Sizing runs the other way round in production: you are handed an error
118/// budget, not a precision. `precision_for_standard_error` turns the budget
119/// into the cheapest register array that meets it.
120fn size_from_an_error_budget() {
121 println!("\n== sizing: error budget -> byte budget ==");
122 for budget in [0.05, 0.02, 0.01, 0.005] {
123 let p = HyperLogLog::precision_for_standard_error(budget);
124 let hll = HyperLogLog::new(p);
125 println!(
126 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
127 budget * 100.0,
128 p,
129 hll.state_bytes(),
130 hll.standard_error() * 100.0
131 );
132 }
133}
134
135/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
136/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
137/// across a real universe. The sparse encoding pays only for registers actually
138/// touched, and promotes the two busy names once they earn it.
139#[cfg(feature = "sparse")]
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}
169
170/// `union-intersect`: how many accounts trade on both venues? Inclusion-
171/// exclusion answers it from two sketches. The error bound is printed next to
172/// the answer because it scales with |A| + |B| rather than with the overlap,
173/// and an overlap smaller than its own bound is not a number to act on.
174#[cfg(feature = "union-intersect")]
175fn cross_venue_overlap(tape: &[Event]) {
176 use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
177 println!("\n== venues: account reach and overlap ==");
178
179 let mut a = HyperLogLog::new(14);
180 let mut b = HyperLogLog::new(14);
181 for e in tape {
182 if e.venue == 0 {
183 a.add_u64(e.account);
184 } else {
185 b.add_u64(e.account);
186 }
187 }
188 let union = estimate_union(&a, &b).expect("same precision");
189 let inter = estimate_intersect(&a, &b).expect("same precision");
190 let bound = intersect_error_bound(&a, &b).expect("same precision");
191 println!(" venue 0: {:>7.0} accounts", a.estimate());
192 println!(" venue 1: {:>7.0} accounts", b.estimate());
193 println!(" reach: {union:>7.0} (true 50000)");
194 println!(" both: {inter:>7.0} (true 10000) +/- {bound:.0}");
195 assert!(
196 (union - 50_000.0).abs() / 50_000.0 < 0.05,
197 "reach within 5%, got {union}"
198 );
199 assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
200}
201
202/// `serialize`: each venue ships its sketch, not its account list. The
203/// collector decodes and merges, and the firm-wide number falls out of 16 KB
204/// per venue instead of a million ids on the wire.
205fn collector_fan_in(tape: &[Event]) {
206 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
207
208 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
209 for e in tape {
210 per_venue[e.venue as usize].add_u64(e.account);
211 }
212 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
213 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
214
215 let mut firm = HyperLogLog::new(14);
216 for bytes in &shipped {
217 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
218 firm.merge(&decoded).expect("same precision");
219 }
220 println!(
221 " {} sketches on the wire, {} bytes total",
222 shipped.len(),
223 on_wire
224 );
225 println!(
226 " raw ids would have been ~{} bytes",
227 tape.len() * core::mem::size_of::<u64>()
228 );
229 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
230 assert!(
231 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
232 "merged reach within 5%"
233 );
234}Sourcepub fn estimate(&self) -> f64
pub fn estimate(&self) -> f64
Estimate distinct count.
Examples found in repository?
97fn gateway_sessions(tape: &[Event]) {
98 println!("== gateway: distinct sessions this window ==");
99 let mut hll = HyperLogLog::new(14);
100 let mut first_sightings = 0u64;
101 for e in tape {
102 if hll.add_u64(e.session) {
103 first_sightings += 1;
104 }
105 }
106 let est = hll.estimate();
107 println!(" {} messages -> {:.0} distinct sessions", tape.len(), est);
108 println!(
109 " {} registers advanced, {} bytes of state, +/- {:.2}% standard error",
110 first_sightings,
111 hll.state_bytes(),
112 hll.standard_error() * 100.0
113 );
114 assert!(est > 30_000.0 && est < 50_000.0, "~40k sessions, got {est}");
115}
116
117/// Sizing runs the other way round in production: you are handed an error
118/// budget, not a precision. `precision_for_standard_error` turns the budget
119/// into the cheapest register array that meets it.
120fn size_from_an_error_budget() {
121 println!("\n== sizing: error budget -> byte budget ==");
122 for budget in [0.05, 0.02, 0.01, 0.005] {
123 let p = HyperLogLog::precision_for_standard_error(budget);
124 let hll = HyperLogLog::new(p);
125 println!(
126 " budget {:>5.1}% -> p={:<2} {:>6} bytes, actual {:.5}%",
127 budget * 100.0,
128 p,
129 hll.state_bytes(),
130 hll.standard_error() * 100.0
131 );
132 }
133}
134
135/// `sparse`: risk wants distinct counterparties per symbol. Most of the book is
136/// thin, so allocating 16 KB per name would cost 128 KB here and gigabytes
137/// across a real universe. The sparse encoding pays only for registers actually
138/// touched, and promotes the two busy names once they earn it.
139#[cfg(feature = "sparse")]
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}
169
170/// `union-intersect`: how many accounts trade on both venues? Inclusion-
171/// exclusion answers it from two sketches. The error bound is printed next to
172/// the answer because it scales with |A| + |B| rather than with the overlap,
173/// and an overlap smaller than its own bound is not a number to act on.
174#[cfg(feature = "union-intersect")]
175fn cross_venue_overlap(tape: &[Event]) {
176 use subms_hyperloglog::{estimate_intersect, estimate_union, intersect_error_bound};
177 println!("\n== venues: account reach and overlap ==");
178
179 let mut a = HyperLogLog::new(14);
180 let mut b = HyperLogLog::new(14);
181 for e in tape {
182 if e.venue == 0 {
183 a.add_u64(e.account);
184 } else {
185 b.add_u64(e.account);
186 }
187 }
188 let union = estimate_union(&a, &b).expect("same precision");
189 let inter = estimate_intersect(&a, &b).expect("same precision");
190 let bound = intersect_error_bound(&a, &b).expect("same precision");
191 println!(" venue 0: {:>7.0} accounts", a.estimate());
192 println!(" venue 1: {:>7.0} accounts", b.estimate());
193 println!(" reach: {union:>7.0} (true 50000)");
194 println!(" both: {inter:>7.0} (true 10000) +/- {bound:.0}");
195 assert!(
196 (union - 50_000.0).abs() / 50_000.0 < 0.05,
197 "reach within 5%, got {union}"
198 );
199 assert!(inter > 0.0, "a 10k overlap must survive the subtraction");
200}
201
202/// `serialize`: each venue ships its sketch, not its account list. The
203/// collector decodes and merges, and the firm-wide number falls out of 16 KB
204/// per venue instead of a million ids on the wire.
205fn collector_fan_in(tape: &[Event]) {
206 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
207
208 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
209 for e in tape {
210 per_venue[e.venue as usize].add_u64(e.account);
211 }
212 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
213 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
214
215 let mut firm = HyperLogLog::new(14);
216 for bytes in &shipped {
217 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
218 firm.merge(&decoded).expect("same precision");
219 }
220 println!(
221 " {} sketches on the wire, {} bytes total",
222 shipped.len(),
223 on_wire
224 );
225 println!(
226 " raw ids would have been ~{} bytes",
227 tape.len() * core::mem::size_of::<u64>()
228 );
229 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
230 assert!(
231 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
232 "merged reach within 5%"
233 );
234}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?
205fn collector_fan_in(tape: &[Event]) {
206 println!("\n== collector: merge shipped sketches into a firm-wide reach ==");
207
208 let mut per_venue = [HyperLogLog::new(14), HyperLogLog::new(14)];
209 for e in tape {
210 per_venue[e.venue as usize].add_u64(e.account);
211 }
212 let shipped: Vec<Vec<u8>> = per_venue.iter().map(|h| h.to_bytes()).collect();
213 let on_wire: usize = shipped.iter().map(|b| b.len()).sum();
214
215 let mut firm = HyperLogLog::new(14);
216 for bytes in &shipped {
217 let decoded = HyperLogLog::from_bytes(bytes).expect("collector reads its own format");
218 firm.merge(&decoded).expect("same precision");
219 }
220 println!(
221 " {} sketches on the wire, {} bytes total",
222 shipped.len(),
223 on_wire
224 );
225 println!(
226 " raw ids would have been ~{} bytes",
227 tape.len() * core::mem::size_of::<u64>()
228 );
229 println!(" firm-wide reach: {:.0} (true 50000)", firm.estimate());
230 assert!(
231 (firm.estimate() - 50_000.0).abs() / 50_000.0 < 0.05,
232 "merged reach within 5%"
233 );
234}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