Skip to content

feat(heap): estimated_memory_bytes for HHHeap and WeightedFrequency - #233

Closed
zzylol wants to merge 1 commit into
perf/weighted-frequency-update-without-candidate-clonesfrom
feat/heap-memory-bytes
Closed

zzylol wants to merge 1 commit into
perf/weighted-frequency-update-without-candidate-clonesfrom
feat/heap-memory-bytes

Conversation

@zzylol

@zzylol zzylol commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Why

A planner prices a top-k heap before it builds one, and today it has to guess the heap's bytes. ASAPPlanner charges 24 bytes per heap entry for Count-Min/Count Sketch with a heap and for UnivMon, while the library holds far more: a WeightedFrequency candidate is a 56-byte struct plus its identity tuple and its encoded key, and an HHHeap resident is a 48-byte HHItem, an 8-byte digest and a 40-byte index entry. Neither type says what it holds, so every caller re-derives (and drifts from) the layout.

While measuring this, a retained WeightedFrequency candidate turned out to hold its identity tuple in a four-slot Vec (from collect::<Result<Vec<_>, _>>(), whose size hint starts at 0): 72 spare bytes per candidate for a one-value identity.

What

  • HHHeap::estimated_memory_bytes(k, key): bytes a heap of capacity k holds once full of keys shaped like key.
  • WeightedFrequency::estimated_memory_bytes(width, depth, capacity, identity): bytes a state of that shape holds once it retains capacity candidates shaped like identity.
  • A retained candidate's identity tuple is allocated at its exact length. Retained candidates, scores and encodings are unchanged.

Stacked on #227 (the WeightedFrequency stack: #160 → #225 → #226 → #227), since WeightedFrequency is not on main yet.

How

Both functions add up the parts from the types' own size_of and the containers' growth:

  • HHHeap: the struct, the HHItem array and the u64 digest array (both reserve min(k, 1024) and double from there), the digest index (std HashMap buckets for that reservation, grown as a fresh fill grows them, with its control bytes), and each resident's owned key bytes (a string's or byte slice's length, 0 for a number).
  • WeightedFrequency: the struct, width × depth 8-byte cells, the candidate heap's slots (it is rebuilt each update by pushing up to capacity + 1 candidates, so max(4, next power of two ≥ capacity + 1) slots), and per candidate its identity tuple (size_of::<FrequencyIdentity>() per value plus Utf8 bytes) and its key (bincode::serialized_size of the identity).

Left out, and said so in the docs: index tombstones from evictions can make the HHHeap index rehash into twice its buckets, and a WeightedFrequency update briefly holds the previous heap's slots too.

Key code interfaces added / modified

src/common/heap.rs

impl HHHeap {
    /// Bytes a heap built by `HHHeap::new(k)` holds once `k` distinct keys shaped like
    /// `key` are resident: struct, `HHItem` array, digest array, digest index and owned
    /// key bytes. A memory model calls it to price a top-k heap before building one.
    pub fn estimated_memory_bytes(k: usize, key: &DataInput) -> usize;   // new
}

src/sketches/weighted_frequency.rs

impl WeightedFrequency {
    /// Bytes a `width × depth` state holds once it retains `capacity` candidates whose
    /// identity tuples are shaped like `identity` (only variants and string lengths
    /// matter): struct, cells, candidate slots, identity tuples and encoded keys.
    /// A memory model calls it to price a top-k frequency state before building one.
    pub fn estimated_memory_bytes(
        width: usize,
        depth: usize,
        capacity: usize,
        identity: &[FrequencyIdentity],
    ) -> usize;   // new
}

Usage, end to end: a planner pricing a top-k state per group before it builds any (numbers from running these calls at the head commit).

use asap_sketchlib::{DataInput, FrequencyIdentity, HHHeap, WeightedFrequency};

// 1. The planner chose Count-Min with a heap for `topk(10, sum by (path) (rate(m[1m])))`:
//    width 64, depth 3, heap capacity 1,000, keyed by one Int64 identity per item.
//    `cms_heap_bytes` is the bytes one group's state holds; the planner multiplies it by
//    the groups it expects and charges that as peak memory.
let cms_heap_bytes = WeightedFrequency::estimated_memory_bytes(
    64, 3, 1000, &[FrequencyIdentity::Int64(0)],
);
// 80 (struct) + 1,536 (cells) + 1,024 slots × 56 + 1,000 × (24 identity + 20 key)
assert_eq!(cms_heap_bytes, 102_960);

// 2. A 16-byte string identity at width 2,048, depth 5, capacity 256:
//    each candidate also owns the string (16) and a 36-byte key.
assert_eq!(
    WeightedFrequency::estimated_memory_bytes(
        2048, 5, 256, &[FrequencyIdentity::Utf8("0123456789abcdef".into())],
    ),
    130_128,
);

// 3. One UnivMon layer's heavy-hitter heap of 256 entries: `layer_heap_bytes` is what the
//    planner adds per layer next to the layer's Count Sketch counters.
let layer_heap_bytes = HHHeap::estimated_memory_bytes(256, &DataInput::U64(0));
assert_eq!(layer_heap_bytes, 35_440); // 96 + 256 × 48 + 256 × 8 + 512-bucket index
// With 9-byte type-tagged keys, each resident also owns its 9 bytes.
assert_eq!(HHHeap::estimated_memory_bytes(256, &DataInput::Bytes(&[0; 9])), 37_744);

Before this PR

A caller had no way to ask what a heap holds, and guessed: ASAPPlanner priced 24 bytes per entry. A WeightedFrequency state of capacity 1,000 with Int64 identities held 174,960 bytes, 72 of them per candidate in unused identity slots.

After this PR

The same state holds 102,960 bytes, and estimated_memory_bytes returns exactly that before the state exists. ASAPPlanner's plan selection can read these functions instead of hardcoding a per-entry size once its pin includes this PR.

Verification

  • tests/heap_memory_bytes.rs (new; failed to compile before the functions existed): checks both functions against size_of and against the bytes actually allocated, counted by a global allocator: HHHeap filled with k ∈ {1, 3, 7, 8, 100, 1024, 2000, 5000} u64 keys and 9-byte keys, and WeightedFrequency after 4 × capacity updates for capacity ∈ {1, 7, 64, 100, 1000} with Int64 and Utf8+Bool identities. Passes.
  • cargo test --all-features --locked --lib -- weighted_frequency heap: 120 passed. Doc tests for weighted_frequency: pass.
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets --all-features --locked -- -D warnings: clean.
  • Limitation: the index size follows std HashMap's table layout on the build target (16-byte control groups with SSE2 on x86, 8 elsewhere); a std change to that layout would show up as a failure of this test.

A memory model can price a top-k heap before building one: the struct,
the heap and digest arrays, the digest index and the owned key bytes for
HHHeap; the cells, candidate slots, identity tuples and encoded keys for
WeightedFrequency. Checked against size_of and a counting allocator.

A retained candidate's identity tuple is now allocated at its exact
length; it held four slots for a one-value tuple.
@zzylol

zzylol commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Closing: main now provides f64 Count-Min and Count Sketch with heaps (#232), ASAPv1 serialization as the only format (#241) and TupleKey group-by keys (#237), so WeightedFrequency's own kernel and ASAP-WFREQ-1 format are redundant. The remaining gaps (shape and weight checks, an atomic checked f64 update, a full clear, opt-in rescoring of retained candidates, and estimated_memory_bytes) move to new PRs on main's heap sketches. The old head is kept on backup/feat/heap-memory-bytes-20261020-161432.

@zzylol zzylol closed this Oct 20, 2026
GordonYuanyc added a commit that referenced this pull request Oct 20, 2026
…223, #228, #229, #233, #238) (#242)

* feat(univmon): check_shape refuses a pyramid shape without building it

`UnivMon::check_shape(heap_size, rows, cols, layers)` accepts exactly the
shapes `UnivMon::new` builds: positive dimensions, at most UNIVMON_MAX_LAYERS
layers, at most MATRIX_MAX_ROWS rows, rows within the 128-bit hash budget and
a counter count that fits usize. `new` panics exactly when it fails, so it no
longer builds a pyramid the ASAPv1 wire refuses.

Ports #229.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(univmon): count every cardinality candidate, direct L2, documented error bounds

`estimate_cardinality` counts every heap key, including zero-weight keys and
keys whose Count Sketch estimate is not positive. `estimate_l2` reads layer 0's
Count Sketch F2 estimate in standard mode and sums the strata's F2 estimates in
terminal mode; `estimate_g_sum` keeps the paper's recurrence, and its flag is
now `count_every_key` (`true` counts every heap key). New
`top_layer_complete()`. The L2 bound and the distinct-count bound, stated
jointly with `top_layer_complete()`, are derived in docs/api/api_univmon.md,
with Monte Carlo tests.

Ports #221.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(univmon_two_tier): count every cardinality candidate and direct L2

`UnivMonTwoTier` gets the estimators `UnivMon` has: `estimate_cardinality`
counts every heap key whatever its estimate, zero-weight keys included;
`estimate_l2` reads layer 0's Count Sketch F2 estimate in standard mode and
sums the strata's in terminal mode; new `top_layer_complete()`. The API doc
states the bounds in the two-tier layout's terms, with Monte Carlo tests.

Ports #222.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(hydra): group-total query from per-cell mass

`Hydra::query_group_total(key)` returns a subpopulation's estimated total
mass: the minimum over rows of the subkey's cell `HydraCounter::total_mass()`,
a Count-Min cell's inner row sum or a KLL cell's retained weight. Count Sketch,
HyperLogLog and UnivMon counters return an error. The total is read from
existing cell state, so the wire format is unchanged. `query_key` and the new
query share one subkey hash helper.

Ports #223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(kll): allocate the item buffer as items arrive

Kll's item buffer starts empty, then holds 16 items and doubles up to
max_capacity; a full buffer compacts only at max_capacity, so retained
items, coin tosses, quantiles, merges and ASAPv1 bytes match the eagerly
allocated buffer for the same input and seed. The merge scratch buffer
starts empty; merge, from_portable_state and the ASAPv1 decoder size the
buffer to what they hold.

lazy_allocation_matches_eager compares every level and the coin with a
test-only eager copy (k = 8 to 1024, up to 50,000 items, with a merge).

Ports #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(kll): Kll::memory_bytes and KllDynamic::memory_bytes

Both report the bytes the sketch holds now: Kll its struct, item buffer,
level array, merge scratch space and memoized CDF; KllDynamic its struct,
item buffer and level bounds.

Ports #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kmv): estimate and merge by shared reference, memory_bytes, early reject past the k-th hash

Kmv::estimate takes &self and Kmv::merge takes &Kmv. Kmv::memory_bytes
is the struct plus the hash slots the heap has allocated
(CommonHeap::allocated_slots, crate-private). A full sketch turns away a
hash at or above its largest retained one before scanning the retained
hashes for a duplicate; the retained set and every estimate are unchanged.

Ports #228.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(heap)!: TopKHeap::estimated_memory_bytes; UnivMonQ::memory_bytes

TopKHeap::estimated_memory_bytes(heap_size, key) is the bytes a heap of
that capacity holds in steady state once keys shaped like key churn
through it: the struct, entry and digest arrays, the digest index (std
HashMap layout, grown once more when tombstones force a resize of an index
over half full) and the owned key bytes. A heap holds at most this.

UnivMonQ::estimated_memory_bytes(&self) becomes memory_bytes(&self) and
counts what the instance holds: its counters, candidate tables (bucket
count from the largest capacity() seen), candidate heaps and ordered-sample
heap.

tests/heap_memory_bytes.rs checks both against a counting global
allocator: the heap estimate as an upper bound after a fill and exactly
after churn, UnivMon-Q exactly.

Ports #233 (the heap half).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cms,cs)!: check_shape, clear and memory accounting for the matrix sketches

CountMinSketch, CountSketch and their WithHeap and WithSpaceSaving forms
gain check_shape(rows, cols), which accepts the ASAPv1 geometry,
1 <= rows <= MATRIX_MAX_ROWS and 1 <= cols <= u32::MAX, on every hashing
path. with_dimensions, new and from_storage panic on any other shape. Past
20 rows the regular path and the fast path's per-row (Rows) layout hash
row r + 20 with row r's seed; the packed layouts reuse no seed. Tests that
built such a shape to watch the encoder refuse it check check_shape
instead, Hydra's included.

clear() zeroes the counters (and empties the heap for the WithHeap types)
and returns the Nitro sampler to its start (Nitro::reset, through the new
MatrixStorage::reset_sampler), keeping shape, allocation and admission
rate.

TopKHeap::memory_bytes reports the bytes a heap holds, taking the digest
index's bucket count from the largest capacity() it has reported, which
evictions' tombstones cannot lower. The WithHeap types add memory_bytes
and estimated_memory_bytes(rows, cols, heap_size, key).
tests/heap_memory_bytes.rs checks all of them against a counting
allocator, after a fill and after 50 times heap_size evicting keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(hash)!: a seed for every seed index; matrix sketches take any row count

Seed index i hashes with seed_for_index(i) = SEED_LIST[i % 20] *
(i / 20 + 1) + i / 20 in wrapping u64 arithmetic; indexes below 20 keep
their seed. The matrix hash passes seed indexes through unreduced, so rows
past 20 on the regular path and the fast path's per-row layout hash
independently, and UnivMon-Q's occurrence priorities hash at their own seed.

check_shape on Count-Min, Count Sketch and their WithHeap and
WithSpaceSaving forms refuses only a zero dimension and a counter count past
usize; Elastic, Hydra and UnivMon build any row count. to_asapv1 and
from_asapv1 keep the MATRIX_MAX_ROWS bound.

The UnivMon-Q residual-CDF accuracy test hashes each of its 32 sketches at
its own seed index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cms,cs)!: memory_bytes, CountMinF64 at the root, check_shape without type parameters

CountMinSketch and CountSketch gain memory_bytes(&self): the struct plus
its rows * cols counters, exact for Vector2D storage, checked against a
counting allocator in tests/heap_memory_bytes.rs. CountMinF64 is
re-exported from sketches and the crate root.

check_shape on CountMinSketch, CountSketch, their WithHeap and
WithSpaceSaving forms is defined on the type with its default parameters,
so CountMinSketch::check_shape(3, 64) resolves without naming storage,
path or hasher. A call naming non-default type parameters no longer
compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(asapv1)!: seed_derivation is "seed_list_index_scaled"

The ASAPv1 seed_derivation string names seed_for_index. Bytes carrying
"seed_list_index_wrap" no longer decode. The golden submodule moves to the
regenerated fixtures (sketchlib-golden-bytes#3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: zz_y <zeyingz@umd.edu>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant