Skip to content

fix(kmv): read and merge by shared reference, report memory, skip the duplicate scan past the k-th hash - #228

Open
zzylol wants to merge 1 commit into
mainfrom
fix/kmv-shared-reads-and-memory
Open

zzylol wants to merge 1 commit into
mainfrom
fix/kmv-shared-reads-and-memory

Conversation

@zzylol

@zzylol zzylol commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Why

KMV makes shared readers pay for copies and its own inserts pay O(k):

  • estimate takes &mut self and merge takes the other sketch as &mut KMV, though neither mutates. A caller holding a state behind Arc or & (a pane shared by several windows) must clone all k hashes to read an estimate or to merge it in. ASAPPlanner's executor does both on every read and every merge.
  • insert_by_hash scans all k retained hashes for a duplicate before offering a hash to the heap, so once the sketch is full every insert costs O(k), even though almost every hash is above the k-th smallest and the heap would turn it away.
  • There is no memory report, so callers guess: ASAPPlanner charged k * 8 bytes, assuming the heap reserves all k slots up front. It reserves at most 1024 and grows.

What

  • estimate(&self) and merge(&mut self, other: &KMV<H>). Existing &mut call sites still compile.
  • approx_memory_bytes(&self): the struct plus the hash slots the heap has allocated.
  • A full sketch turns away a hash at or above its largest retained one before the duplicate scan. The retained set and every estimate are unchanged.
  • CommonHeap::allocated_slots(), the heap's allocated element slots, which the memory report reads.

How

The early return in insert_by_hash is exactly the case CommonHeap::push already ignored for a full max-heap (new >= root). The memory report reads the heap's Vec capacity through the new accessor.

Key code interfaces added / modified

src/sketches/kmv.rs

impl<H: SketchHasher> KMV<H> {
    /// Distinct count: the retained count below k, else (k - 1) / U(k).
    /// Read whenever a query asks for cardinality.
    pub fn estimate(&self) -> f64;   // was estimate(&mut self)

    /// Merges `other`'s retained hashes in; panics on a different k.
    /// Used to combine panes or shards.
    pub fn merge(&mut self, other: &KMV<H>);   // was merge(&mut self, other: &mut KMV<H>)

    /// Approximate in-memory footprint in bytes: the struct plus the hash slots
    /// the heap has allocated (up to 1024 at construction, growing toward k).
    /// Used for memory accounting and reservations.
    pub fn approx_memory_bytes(&self) -> usize;   // new
}

src/common/structures/heap.rs

impl<T, O: CommonHeapOrder<T>> CommonHeap<T, O> {
    /// Element slots the heap has allocated: at least `len()`, grown on demand
    /// toward `capacity()`. Used for memory accounting.
    pub fn allocated_slots(&self) -> usize;   // new
}

Usage, end to end (checked by a scratch test at the head commit):

use asap_sketchlib::{DataInput, KMV};
use std::sync::Arc;

// 1. A planner certified `count(count by (instance) (m))` with KMV k = 4096. Two ingestion
//    panes each built one state: `first` saw instances 0..60000, `second` 40000..100000.
let mut first: KMV = KMV::new(4096);
let mut second: KMV = KMV::new(4096);
for i in 0..60_000u64 { first.insert(&DataInput::U64(i)); }      // steady-state inserts: no O(k) scan
for i in 40_000..100_000u64 { second.insert(&DataInput::U64(i)); }
// `panes` is how the executor stores them: shared, read by every window that covers them.
let panes = [Arc::new(first), Arc::new(second)];

// 2. A query window covers both panes. `window` starts as a copy of the first pane and merges
//    the second by reference; the shared pane is not cloned.
let mut window = (*panes[0]).clone();
window.merge(&panes[1]);

// 3. What the query gets back: about 99,735 distinct instances (exact: 100,000).
//    A pane's own estimate is read through `&`: about 60,016.
let distinct = window.estimate();
let pane = panes[0].estimate();
// The executor reserves memory by the state's footprint: 32,808 bytes once full, 8,232 empty.
let bytes = window.approx_memory_bytes();

Before this PR

Release build, k = 4096, 2,000,000 distinct inserts: 2.43 us per insert. Reading or merging a shared state required a clone of up to k hashes.

After this PR

The same insert loop: 37 ns per insert. Shared states are read and merged by reference.

Verification

  • New tests, failing to compile before the change: estimate_and_merge_borrow_inputs_shared (an immutable state's estimate; merging an immutable sketch equals the union build) and memory_report_covers_the_retained_hashes (k = 3000: the report covers every retained hash, grows from empty to full, and does not move on a duplicate or a hash of u64::MAX).
  • Call sites in src/sketches/kmv/wire.rs, tests/pbt/kmv.rs, tests/e2e/experimental.rs and tests/e2e/wire.rs drop their now-needless mut.
  • cargo fmt --all -- --check; clippy with -D warnings for --all-features, default features, experimental and octo-runtime; cargo test --all-features --locked (every target) passes.
  • The insert timings come from a temporary release-mode test, run against main's kmv.rs and this branch's, removed before commit.

Limitations

merge still panics on a different k, as before.

… duplicate scan for hashes past the k-th

estimate took &mut self and merge took the other sketch as &mut KMV
without mutating either, so a caller holding a shared state had to
clone it (k hashes) to read an estimate or to merge it in. Both now
borrow shared; &mut call sites still compile.

approx_memory_bytes reports the struct plus the hash slots the heap has
allocated, so callers stop guessing the footprint. (The heap reserves
up to 1024 slots and grows toward k, so neither the retained count nor
k alone is the allocation.)

insert_by_hash scanned all k retained hashes for a duplicate before
offering a hash to the heap, so every steady-state insert cost O(k).
A full sketch now turns away a hash at or above its largest retained
one first; the heap rejected those anyway, so the retained set and
every estimate are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant