From 6c107028674dd4c918965f3f0e798487fcd76e13 Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 6 Oct 2026 18:27:38 +0000 Subject: [PATCH 1/2] rqe cost export: count top-k heaps, repeat top-k queries, add exact hash-table overhead - CMS+heap and CountSketch+heap memory now adds CMS_HEAP_TOP_K HHItems, as UnivMon counts its heaps, so the TopK rows compare like for like (#146). - TopkGT asks its one question 1024 times per pass, as CardinalityGT does, and the CMS/CS/UnivMon top-k queries answer once per probe, so a pass sits above the timer floor and work = answers keeps per-op CPU per call. - Exact accumulators' footprint adds an estimate of hashbrown's table: capacity x (key + cell + 1 control byte). Closes #146. Part of #147. Co-Authored-By: Claude Opus 5.5 --- aqpbm-cli/src/rows/statistic.rs | 2 +- aqpbm-core/src/accuracy/topk.rs | 12 ++- rqe-optimizer/src/analytical_cost_model.rs | 2 +- sketch-bench/src/wrappers/cms_heap/mod.rs | 12 +-- .../src/wrappers/cms_heap/sketchlib.rs | 82 +++++++++++-------- sketch-bench/src/wrappers/cs_heap.rs | 37 +++++---- sketch-bench/src/wrappers/exact.rs | 22 +++-- .../src/wrappers/univmon/sketchlib.rs | 33 ++++---- 8 files changed, 119 insertions(+), 83 deletions(-) diff --git a/aqpbm-cli/src/rows/statistic.rs b/aqpbm-cli/src/rows/statistic.rs index 504c583a..54df2fbf 100644 --- a/aqpbm-cli/src/rows/statistic.rs +++ b/aqpbm-cli/src/rows/statistic.rs @@ -80,7 +80,7 @@ pub(super) fn cardinality_row( } /// A row answering **top-k**: the heaviest `k` keys, ranked. The probe is -/// `()` — one question, the whole list, asked once — and `k` is +/// `()` — one question, the whole list, asked repeatedly for timing — and `k` is /// `chl::CMS_HEAP_TOP_K`, the same compile-time constant the sketch was built /// with, so the heap's capacity and the truth it's graded against can never /// silently disagree (see #95's design-decision comment on the registry entry). diff --git a/aqpbm-core/src/accuracy/topk.rs b/aqpbm-core/src/accuracy/topk.rs index 9b26fe0b..762a0607 100644 --- a/aqpbm-core/src/accuracy/topk.rs +++ b/aqpbm-core/src/accuracy/topk.rs @@ -9,6 +9,12 @@ use aqpbm_datagen::{DataGenError, GeneratedTable}; use super::{CountedValue, GroundTruth}; +/// How many times to put the one question. A heap dump of `k` items takes +/// well under a microsecond, near `Instant::now()`'s 20-50 ns noise floor, so +/// one call per pass would report mostly timer jitter. As for +/// `CardinalityGT`, the count is this statistic's knowledge, not the runner's. +const QUERY_TIMING_REPEATS: usize = 1024; + pub struct TopkGT { pub k: usize, pub column: usize, @@ -31,7 +37,7 @@ where { /// The true k heaviest keys, in the total order every top-k impl ranks by. type Truth = Vec<(K, u64)>; - /// One question: the whole list. A probe carries nothing. + /// One question, the whole list, asked repeatedly. A probe carries nothing. type Probe = (); type Answer = Vec<(K, u64)>; @@ -60,7 +66,7 @@ where } fn probes(&self, _truth: &Vec<(K, u64)>) -> Vec<()> { - vec![()] + vec![(); QUERY_TIMING_REPEATS] } fn score( @@ -69,6 +75,8 @@ where _probes: &[()], answers: &[Vec<(K, u64)>], ) -> BTreeMap { + // Every answer is to the same question, so the first is the estimate + // and the rest existed to make the timing readable. let empty = Vec::new(); let est = answers.first().unwrap_or(&empty); let est_set: std::collections::HashSet = diff --git a/rqe-optimizer/src/analytical_cost_model.rs b/rqe-optimizer/src/analytical_cost_model.rs index 51f9ad7e..bc8f0dbb 100644 --- a/rqe-optimizer/src/analytical_cost_model.rs +++ b/rqe-optimizer/src/analytical_cost_model.rs @@ -28,7 +28,7 @@ pub const OUTPUT_BYTES_PER_TOPK_ENTRY: f64 = 16.0; /// Top-k entries per group: the heap size every top-k cost row was measured /// at (sketch-bench's `CMS_HEAP_TOP_K`). // ponytail: fixed k. A large k needs `k` on the RAQE and a benchmark that -// varies the heap, whose memory the export leaves out. +// varies the heap. pub const TOPK_ENTRIES: f64 = 32.0; /// One phase's resource use. diff --git a/sketch-bench/src/wrappers/cms_heap/mod.rs b/sketch-bench/src/wrappers/cms_heap/mod.rs index 118da57e..84f0b6b3 100644 --- a/sketch-bench/src/wrappers/cms_heap/mod.rs +++ b/sketch-bench/src/wrappers/cms_heap/mod.rs @@ -65,24 +65,24 @@ mod tests { } } - /// Footprint is `rows * cols` counters at the real width — the heap itself - /// isn't counted, matching how the plain CMS rows don't count their own - /// bookkeeping fields either. + /// Footprint is `rows * cols` counters at the real width plus a full heap + /// of `CMS_HEAP_TOP_K` `HHItem`s, as UnivMon counts its heaps (#146). #[test] - fn footprint_is_rows_times_cols_times_counter_width() { + fn footprint_is_counters_plus_heap() { let p = ParamSet::of(&CmsHeapParams { rows: 5, cols: 2048, }); + let heap = CMS_HEAP_TOP_K * std::mem::size_of::(); let fast = build_cms_heap_lib_vector2d_fast(&p).expect("5x2048 is a valid shape"); assert_eq!( memory_cms_heap_lib_vector2d_fast(&fast), - 5 * 2048 * std::mem::size_of::() + 5 * 2048 * std::mem::size_of::() + heap ); let regular = build_cms_heap_lib_vector2d_regular(&p).expect("5x2048 is a valid shape"); assert_eq!( memory_cms_heap_lib_vector2d_regular(®ular), - 5 * 2048 * std::mem::size_of::() + 5 * 2048 * std::mem::size_of::() + heap ); } } diff --git a/sketch-bench/src/wrappers/cms_heap/sketchlib.rs b/sketch-bench/src/wrappers/cms_heap/sketchlib.rs index c6293183..761178f4 100644 --- a/sketch-bench/src/wrappers/cms_heap/sketchlib.rs +++ b/sketch-bench/src/wrappers/cms_heap/sketchlib.rs @@ -8,6 +8,7 @@ use crate::wrappers::{require_positive, BuildError, Pass, QueryPass, Shared, Ste use std::cell::RefCell; use std::rc::Rc; +use asap_sketchlib::input::HHItem; use asap_sketchlib::{heap_item_to_sketch_input, CMSHeap, FastPath, RegularPath, Vector2D}; /// The heap's fixed capacity, and the `k` every TopK-capability row grades @@ -47,11 +48,11 @@ pub fn build_cms_heap_lib_vector2d_fast( }) } +/// The counter matrix plus a full heap of `CMS_HEAP_TOP_K` `HHItem`s, counted +/// the way UnivMon counts its heaps so the TopK rows compare like for like. pub fn memory_cms_heap_lib_vector2d_fast(sketch: &CmsHeapLibVector2dFast) -> usize { - // The heap holds at most `CMS_HEAP_TOP_K` items — negligible next to the - // counter matrix, and not counted here, matching how the plain CMS rows - // don't count their own bookkeeping fields either. sketch.rows * sketch.cols * std::mem::size_of::() + + CMS_HEAP_TOP_K * std::mem::size_of::() } // ---------- asap_sketchlib: Vector2D + RegularPath ---------- @@ -74,8 +75,10 @@ pub fn build_cms_heap_lib_vector2d_regular( }) } +/// As [`memory_cms_heap_lib_vector2d_fast`]. pub fn memory_cms_heap_lib_vector2d_regular(sketch: &CmsHeapLibVector2dRegular) -> usize { sketch.rows * sketch.cols * std::mem::size_of::() + + CMS_HEAP_TOP_K * std::mem::size_of::() } // ---------- insert / insert_step (FastPath) ---------- @@ -246,10 +249,9 @@ pub fn merge_query_cms_heap_lib_vector2d_regular_estimate( // ---------- query: heap dump (TopK capability) ---------- // -// One `()` probe, one answer: the whole ranked heap, read off in one call. -// `TopkGT`'s `probes()` hands back `vec![()]`, so `probes` here is always -// length 1 — there is nothing per-item to loop over the way the estimate -// queries do. +// One answer per `()` probe: the whole ranked heap, read off in one call. +// Every probe is the same question; `TopkGT` repeats it so a pass's timed +// region sits well above the timer floor, as `CardinalityGT` does for HLL. pub fn query_cms_heap_lib_vector2d_fast_topk( params: &ParamSet, @@ -265,7 +267,7 @@ pub fn query_cms_heap_lib_vector2d_fast_topk( pub fn merge_query_cms_heap_lib_vector2d_fast_topk( params: &ParamSet, items: Rc>, - _probes: Rc>, + probes: Rc>, shards: usize, passes: usize, ) -> Result>>, BuildError> { @@ -276,21 +278,26 @@ pub fn merge_query_cms_heap_lib_vector2d_fast_topk( for other in rest.iter() { sketch.inner.merge(&other.inner); } + let probes = probes.clone(); out.push(Box::new(move || { - let ranked: TopkAnswer = sketch - .inner - .heap() - .heap() - .iter() - .map(|item| { - ( - T::from_data_input(&heap_item_to_sketch_input(&item.key)), - item.count as u64, - ) - }) - .collect(); + let mut answers = Vec::with_capacity(probes.len()); + for _p in probes.iter() { + let ranked: TopkAnswer = sketch + .inner + .heap() + .heap() + .iter() + .map(|item| { + ( + T::from_data_input(&heap_item_to_sketch_input(&item.key)), + item.count as u64, + ) + }) + .collect(); + answers.push(ranked); + } let footprint = memory_cms_heap_lib_vector2d_fast(&sketch); - (vec![ranked], footprint) + (answers, footprint) }) as QueryPass>); } Ok(out) @@ -310,7 +317,7 @@ pub fn query_cms_heap_lib_vector2d_regular_topk( pub fn merge_query_cms_heap_lib_vector2d_regular_topk( params: &ParamSet, items: Rc>, - _probes: Rc>, + probes: Rc>, shards: usize, passes: usize, ) -> Result>>, BuildError> { @@ -321,21 +328,26 @@ pub fn merge_query_cms_heap_lib_vector2d_regular_topk( for other in rest.iter() { sketch.inner.merge(&other.inner); } + let probes = probes.clone(); out.push(Box::new(move || { - let ranked: TopkAnswer = sketch - .inner - .heap() - .heap() - .iter() - .map(|item| { - ( - T::from_data_input(&heap_item_to_sketch_input(&item.key)), - item.count as u64, - ) - }) - .collect(); + let mut answers = Vec::with_capacity(probes.len()); + for _p in probes.iter() { + let ranked: TopkAnswer = sketch + .inner + .heap() + .heap() + .iter() + .map(|item| { + ( + T::from_data_input(&heap_item_to_sketch_input(&item.key)), + item.count as u64, + ) + }) + .collect(); + answers.push(ranked); + } let footprint = memory_cms_heap_lib_vector2d_regular(&sketch); - (vec![ranked], footprint) + (answers, footprint) }) as QueryPass>); } Ok(out) diff --git a/sketch-bench/src/wrappers/cs_heap.rs b/sketch-bench/src/wrappers/cs_heap.rs index 7e977f51..7a8225dd 100644 --- a/sketch-bench/src/wrappers/cs_heap.rs +++ b/sketch-bench/src/wrappers/cs_heap.rs @@ -8,6 +8,7 @@ use crate::params::{CountSketchParams, ParamSet}; use crate::wrappers::cms_heap::sketchlib::{TopkAnswer, CMS_HEAP_TOP_K}; use crate::wrappers::frequency_value::FrequencyValue; use crate::wrappers::{require_positive, BuildError, Pass, QueryPass, Shared, StepPass}; +use asap_sketchlib::input::HHItem; use asap_sketchlib::{heap_item_to_sketch_input, CSHeap, FastPath, Vector2D}; use std::cell::RefCell; use std::rc::Rc; @@ -31,9 +32,10 @@ pub fn build_cs_heap_lib_vector2d_fast( }) } -/// Counter matrix only, as `cms_heap` counts it. +/// Counter matrix plus a full heap, as `cms_heap` counts it. pub fn memory_cs_heap_lib_vector2d_fast(sketch: &CsHeapLibVector2dFast) -> usize { sketch.rows * sketch.cols * std::mem::size_of::() + + CMS_HEAP_TOP_K * std::mem::size_of::() } pub fn insert_cs_heap_lib_vector2d_fast( @@ -86,7 +88,7 @@ pub fn query_cs_heap_lib_vector2d_fast_topk( pub fn merge_query_cs_heap_lib_vector2d_fast_topk( params: &ParamSet, items: Rc>, - _probes: Rc>, + probes: Rc>, shards: usize, passes: usize, ) -> Result>>, BuildError> { @@ -96,21 +98,26 @@ pub fn merge_query_cs_heap_lib_vector2d_fast_topk( for other in rest.iter() { sketch.inner.merge(&other.inner); } + let probes = probes.clone(); out.push(Box::new(move || { - let ranked: TopkAnswer = sketch - .inner - .heap() - .heap() - .iter() - .map(|item| { - ( - T::from_data_input(&heap_item_to_sketch_input(&item.key)), - item.count.max(0) as u64, - ) - }) - .collect(); + let mut answers = Vec::with_capacity(probes.len()); + for _p in probes.iter() { + let ranked: TopkAnswer = sketch + .inner + .heap() + .heap() + .iter() + .map(|item| { + ( + T::from_data_input(&heap_item_to_sketch_input(&item.key)), + item.count.max(0) as u64, + ) + }) + .collect(); + answers.push(ranked); + } let footprint = memory_cs_heap_lib_vector2d_fast(&sketch); - (vec![ranked], footprint) + (answers, footprint) }) as QueryPass>); } Ok(out) diff --git a/sketch-bench/src/wrappers/exact.rs b/sketch-bench/src/wrappers/exact.rs index e8585e3f..a10a7118 100644 --- a/sketch-bench/src/wrappers/exact.rs +++ b/sketch-bench/src/wrappers/exact.rs @@ -234,20 +234,24 @@ impl Multiple { .ok_or_else(|| AggregateQueryError::GroupNotFound(group.to_vec())) } - /// Key and cell bytes per group; hash-table overhead left out. + /// The hash table plus what each group owns outside it. The table is an + /// estimate of hashbrown's layout: one `(key, cell)` slot and one control + /// byte per unit of capacity. It ignores the few trailing control bytes + /// and the gap between `capacity()` and the bucket count. fn footprint(&self) -> usize { - self.by_group + let table = self.by_group.capacity() + * (std::mem::size_of::>() + std::mem::size_of::() + 1); + let owned: usize = self + .by_group .iter() .map(|(key, cell)| { - std::mem::size_of::>() - + key - .iter() - .map(|l| std::mem::size_of::() + l.len()) - .sum::() - + std::mem::size_of::() + key.iter() + .map(|l| std::mem::size_of::() + l.len()) + .sum::() + cell.heap_bytes() }) - .sum() + .sum(); + table + owned } } diff --git a/sketch-bench/src/wrappers/univmon/sketchlib.rs b/sketch-bench/src/wrappers/univmon/sketchlib.rs index 65e3128c..c8ca577b 100644 --- a/sketch-bench/src/wrappers/univmon/sketchlib.rs +++ b/sketch-bench/src/wrappers/univmon/sketchlib.rs @@ -401,7 +401,7 @@ pub fn query_univmon_lib_topk( pub fn merge_query_univmon_lib_topk( params: &ParamSet, items: Rc>, - _probes: Rc>, + probes: Rc>, shards: usize, passes: usize, ) -> Result>>, BuildError> { @@ -411,21 +411,26 @@ pub fn merge_query_univmon_lib_topk( for other in rest.iter() { sketch.inner.merge(&other.inner); } + let probes = probes.clone(); out.push(Box::new(move || { - let mut heap: Vec<_> = sketch.inner.hh_layers[0].heap().iter().collect(); - heap.sort_unstable_by_key(|item| std::cmp::Reverse(item.count)); - let ranked: TopkAnswer = heap - .into_iter() - .take(CMS_HEAP_TOP_K) - .map(|item| { - ( - T::from_data_input(&heap_item_to_sketch_input(&item.key)), - item.count.max(0) as u64, - ) - }) - .collect(); + let mut answers = Vec::with_capacity(probes.len()); + for _p in probes.iter() { + let mut heap: Vec<_> = sketch.inner.hh_layers[0].heap().iter().collect(); + heap.sort_unstable_by_key(|item| std::cmp::Reverse(item.count)); + let ranked: TopkAnswer = heap + .into_iter() + .take(CMS_HEAP_TOP_K) + .map(|item| { + ( + T::from_data_input(&heap_item_to_sketch_input(&item.key)), + item.count.max(0) as u64, + ) + }) + .collect(); + answers.push(ranked); + } let footprint = memory_univmon_lib(&sketch); - (vec![ranked], footprint) + (answers, footprint) }) as QueryPass>); } Ok(out) From e30ed75096ac9221f68aec86452b83d0e729b50c Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 6 Oct 2026 18:54:05 +0000 Subject: [PATCH 2/2] Name getrusage as the query timing floor; note exact memory's load-factor dependence Co-Authored-By: Claude Opus 5.5 --- aqpbm-core/src/accuracy/topk.rs | 4 ++-- sketch-bench/src/wrappers/exact.rs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/aqpbm-core/src/accuracy/topk.rs b/aqpbm-core/src/accuracy/topk.rs index 762a0607..f47fd9cf 100644 --- a/aqpbm-core/src/accuracy/topk.rs +++ b/aqpbm-core/src/accuracy/topk.rs @@ -10,8 +10,8 @@ use aqpbm_datagen::{DataGenError, GeneratedTable}; use super::{CountedValue, GroundTruth}; /// How many times to put the one question. A heap dump of `k` items takes -/// well under a microsecond, near `Instant::now()`'s 20-50 ns noise floor, so -/// one call per pass would report mostly timer jitter. As for +/// about a microsecond, so with one call per pass the CPU time read from +/// `getrusage` around it is mostly the cost of the reading itself. As for /// `CardinalityGT`, the count is this statistic's knowledge, not the runner's. const QUERY_TIMING_REPEATS: usize = 1024; diff --git a/sketch-bench/src/wrappers/exact.rs b/sketch-bench/src/wrappers/exact.rs index a10a7118..4be3d525 100644 --- a/sketch-bench/src/wrappers/exact.rs +++ b/sketch-bench/src/wrappers/exact.rs @@ -237,7 +237,10 @@ impl Multiple { /// The hash table plus what each group owns outside it. The table is an /// estimate of hashbrown's layout: one `(key, cell)` slot and one control /// byte per unit of capacity. It ignores the few trailing control bytes - /// and the gap between `capacity()` and the bucket count. + /// and the gap between `capacity()` and the bucket count. Capacity is what + /// is allocated, so the per-group share depends on the load factor at the + /// measured group count (~1.44 slots per group at the export's ~9.9k + /// groups); other group counts land between 1 and 2. fn footprint(&self) -> usize { let table = self.by_group.capacity() * (std::mem::size_of::>() + std::mem::size_of::() + 1);