Skip to content

feat(theta,tuple): serialize union results directly - #336

Merged
tisonkun merged 9 commits into
apache:mainfrom
tisonkun:codex/theta-tuple-union-serialization
Nov 10, 2026
Merged

tisonkun merged 9 commits into
apache:mainfrom
tisonkun:codex/theta-tuple-union-serialization

Conversation

@tisonkun

@tisonkun tisonkun commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Adds ThetaUnion::serialize(ordered) and TupleUnion::serialize(ordered) so callers can serialize union results directly. Tuple serialization borrows retained summaries, avoiding the clones and associated allocations required by to_sketch(ordered).serialize().

Owned and borrowed result paths share theta filtering, nominal-capacity trimming, and comparison sorting for ordered results. Each family's compact encoder serves both compact sketches and unions. Theta output is byte-identical to to_sketch(ordered).serialize(); Tuple output is byte-identical when ordered and semantically equivalent when unordered, where entry order may differ.

Tuple serialization collects references to retained entries, trims and optionally sorts those references, and writes the summaries through the shared encoder. It does not clone summaries.

Combines #322 and #333 by @Aschenbath, retaining contributor attribution in the commit.

Closes #320.
Closes #321.

Validation

  • cargo x check across the feature matrix passed.
  • cargo x test: 767 tests, including doctests, passed with the pinned C++, Go, and Java fixtures.
  • cargo x lint passed.
  • cargo x bench -- serde --test passed, including all 64 Theta/Tuple implementation cases.
  • Serialization tests cover fresh/reset unions, exact and estimation modes, trimming, sampled inputs, non-empty results retaining no entries, custom seeds, and large results. Heap-backed summary tests check zero summary clones and subsequent merges of restored Tuple results at multiple capacities.
  • Tuple benchmarks cover scalar and heap-backed summaries, ordered and unordered results, exact sizes through 1,000 inputs, and estimation workloads with lg_k values of 8, 12, and 16.

Performance

Measured at 1f8c314d1f614c66a00bf607b715d5569a27245f on Apple M4 Max, macOS 26.6.2, Rust 1.101.0-nightly (8006091eb). Each scenario below uses 80,000 inputs and the default lg_k=12, retaining 4,096 entries. The labels summary holds four strings per key.

Run each scenario with cargo x bench -- <scenario> --exact --raw, using tuple::serde::u64::ordered/80000, tuple::serde::labels::ordered/80000, tuple::serde::u64::unordered/80000, and tuple::serde::labels::unordered/80000. Each scenario reuses its union for the fixed number of serializations; timing includes fixture construction and output destruction. Results are median milliseconds and min–max over five interleaved repetitions per scenario. Different rows use different round counts and are not a throughput ranking.

Summary Order Rounds Via compact sketch Direct
u64 Ordered 5,000 296 (294–305) 311 (306–322)
Labels Ordered 500 373 (364–380) 76 (75–79)
u64 Unordered 5,000 121 (120–133) 142 (135–162)
Labels Unordered 500 338 (337–346) 52 (51–53)

Heap-backed summaries benefit from avoiding clones: direct serialization takes about 20% of the compact-sketch path's time when ordered and 15% when unordered in these workloads. Direct serialization is not universally faster: scalar summaries are inexpensive to clone, and the measured ordered and unordered scalar cases take about 5% and 17% longer, respectively. These results are workload- and toolchain-dependent and do not establish a library-wide speedup.

Add compact serialization to ThetaUnion and TupleUnion with shared result
filtering, trimming, and ordering. Reuse each family's compact encoder
and borrow Tuple entries to avoid cloning summaries during serialization.

Cover canonical results, custom seeds, summary clone avoidance, and
subsequent merges. Compare direct serialization with compact-sketch
serialization for scalar and heap-backed summaries.

Co-authored-by: Nash Aschenbath <nashaschenbath@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

pub(super) fn serialize_uncompressed<E>(
state: &CompactSketchState<E>,
hash: impl Fn(&E) -> u64,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about

pub(super) fn serialize_uncompressed<E: SketchEntry>(
    state: &CompactSketchState<E>,
) -> Vec<u8> 

to omit hash

}

let entries = state.retained_entries();
let pre_longs = if state.is_estimation_mode() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better extract preamble_longs as before

pub const EMPTY_SKETCH_BYTES: [u8; 8] = [1, 3, 9, 1, 0, 0x1e, 0, 0];

/// Serializes compact Tuple state whose retained entries are owned or borrowed.
pub(super) fn serialize_compact_state<S, E>(compact_state: &CompactSketchState<E>) -> Vec<u8>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't get the meaning of the name serialize_compact_state and serialize_uncompressed. Looks like the diff is how they serialize entry. Maybe need to refine.

@tisonkun
tisonkun marked this pull request as ready for review November 10, 2026 06:59
Implement the internal SketchEntry trait for retained u64 hashes so the
compact encoder can read both compact-sketch hashes and union entries
through the same interface without a hash projection callback.
Name both family encoders serialize_compact_uncompressed to describe the
compact representation and the uncompressed wire format consistently.
Keep the family-specific headers and entry encoding in their respective
serialization modules.
Derive the compact preamble length from the canonical sketch state in one
place for both family encoders: one 8-byte word for empty images and exact
singletons, two for other exact results, and three for estimation results.

The helper is specific to uncompressed formats. Compressed Theta has a
different header layout, so its size calculation belongs with that codec
rather than behind a compression flag on the shared helper.
Add a larger exact result and estimation workloads with lg_k 8 and 16
alongside the default-capacity cases. Compare ordered and unordered
serialization for scalar and heap-backed summaries with fixed rounds
so changes to result preparation can be checked across retained sizes.
Global comparison sorting repeatedly dereferences the union hash table
when ordering borrowed entries. Partition large ordered results by their
top active hash bits, scaled to the result theta, and sort within each
bucket to reduce those indirect comparisons without cloning summaries.

Keep small and unordered results on the existing compaction path and
keep shared compaction inlineable. Large ordered results use one extra
reference buffer, or 32 KiB for 4096 retained entries on 64-bit targets.

Cover ordering with spread and clustered hashes, small-result boundaries,
and larger serialized results whose heap-backed summaries must not be
cloned. Size the lg_k 8 benchmark rounds for the measured workload.
Use shared compaction to trim and sort borrowed entries in one path. Remove the bucket partition, size cutoff, temporary reference buffer, and associated inline hints to keep result construction straightforward.

Retain summary borrowing, expanded serialization benchmarks, and integration coverage for large results, zero summary clones, and subsequent merges.
@tisonkun
tisonkun merged commit 84294f0 into apache:main Nov 10, 2026
10 checks passed
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.

feat(tuple): serialize union results without cloning summaries feat(theta): serialize union results directly

2 participants