A Rust implementation of the h2histogram design, providing standard, atomic, and sparse histogram variants. Like HDRHistogram, values are stored in quantized buckets, but the bucket construction and indexing algorithm are modified for fast increments and lookups.
cargo add histogramuse histogram::{Histogram, Quantile};
// Create a histogram with grouping power 7 and max value power 64.
let mut histogram = Histogram::new(7, 64).unwrap();
// Record some values.
for i in 1..=100 {
histogram.increment(i).unwrap();
}
// Query quantiles using the 0.0..=1.0 scale.
let report = histogram.quantiles(&[0.5, 0.99]).unwrap().unwrap();
// quantiles() shares scan work and returns Result<Option<QuantilesResult>, Error>
// outer unwrap: quantile value is valid
// inner unwrap: histogram is non-empty
let median = report.get(&Quantile::new(0.5).unwrap()).unwrap();
let p99 = report.get(&Quantile::new(0.99).unwrap()).unwrap();
println!("median: {}", median.end());
println!("p99: {}", p99.end());- Histogram -- Standard histogram with plain 64-bit counters. Best for single-threaded use.
- AtomicHistogram -- Uses atomic 64-bit counters, allowing concurrent
recording from multiple threads. Take a snapshot via
load()ordrain()to query percentiles. - SparseHistogram -- Columnar representation that only stores non-zero buckets. Ideal for serialization and storage when most buckets are empty.
- CumulativeROHistogram -- Read-only histogram with cumulative counts for fast O(log n) quantile queries via binary search.
All four types ship with a *32 sibling (Histogram32, AtomicHistogram32,
SparseHistogram32, CumulativeROHistogram32) that uses 32-bit counters.
| Phase | Starting point | Cost to consider |
|---|---|---|
| Update | Histogram owned by one writer, or AtomicHistogram when writers must share |
Update rate, recorder count, counter storage, and contention |
| Report | Batch quantiles on the dense recorder or a completed snapshot | Count scanning, publication/handoff, reset, and aggregation |
| Analytics | Convert a completed window to CumulativeROHistogram for repeated queries; use sparse storage when appropriate |
Conversion cost, subsequent read count, retained capacity, and transforms |
Thread ownership is an application decision. Per-writer dense recorders require handoff and aggregation; shared atomic recorders incur atomic operations and may contend. Writers and readers can live in different services. Choose based on the whole workflow, not the fastest isolated query.
Dense recording maintains bucket counts without cached totals or bounds. Reporting derives that metadata when requested, keeping the recording path small. Use one batch for a report's quantiles instead of separate scans. A cumulative snapshot pays construction once for binary-search reads; it need not pay off for one report.
Use Histogram::checked_sum(&[&first, &second, &third]) to combine compatible
completed windows or per-writer histograms into a fresh owned result.
Histogram32::checked_sum provides the same operation for u32 counters. Inputs
are borrowed and remain unchanged, including on failure. Every configuration is
validated before allocation or arithmetic; empty input returns
Error::IncompatibleParameters, and one input is independently cloned.
The operation clones the first histogram once, then fuses addition and overflow
detection into one pass per remaining input. Any per-bucket overflow returns
Error::Overflow and discards the private result. The sum across separate buckets
may exceed the counter width. If a consumer needs cumulative storage, ensure that
its total count fits before converting the merged result. Use checked_add_assign
when you already have a destination whose allocation should be reused.
On x86/x86-64, checked_sum detects CPU and operating-system support and selects
an AVX2 kernel automatically. Detection is outside the bucket loops. Other machines
use the portable kernel, including the normal compiler target on Apple Silicon.
No RUSTFLAGS or higher minimum CPU requirement are needed. Both paths share the
same checked arithmetic and input-preservation contract.
cargo bench --bench dense_sumThe benchmark separates merge-only from merge-plus-five-quantile reporting, includes owned output construction/destruction, and excludes source and reference-list preparation. It covers tiny and larger geometries, clustered/full occupancy and 2/8/64 inputs. Compare methods within the same build; vectorization and performance depend on the compiler target.
Both counter families have the same value range and bucket precision. The limit that matters depends on the representation:
| Representation | u32 limit | u64 limit |
|---|---|---|
| Dense, atomic, sparse individual counts | 2^32 − 1 per bucket | 2^64 − 1 per bucket |
| Cumulative prefix counts | 2^32 − 1 observations in total | 2^64 − 1 observations in total |
Recording uses wrapping arithmetic. Choose a width that fits the activity between resets, or the lifetime count when retaining cumulative metrics. u64 provides more headroom; a bounded reporting interval may safely use u32 even for direct queries. Dense queries widen sums to u128, so their total can exceed a single counter's limit.
u32 halves the counter array size. It does not halve sparse/cumulative indices,
object overhead, spare capacity, or necessarily serialized size. Widening u32 to
u64 is infallible; checked narrowing uses TryFrom and returns Error::Overflow
when the destination count limit is exceeded. Ensure the total fits when converting
individual counts into cumulative prefixes, even at the same width.
For completed windows, checked conversion from a u64 dense histogram to
CumulativeROHistogram32 is useful when the total fits u32. Construction and
retained storage remain separate costs from querying. Merge/downsample operations
can create subsequent analytical summaries without modifying the inputs.
Dense recorders offer reset() to clear counts without reallocating and
checked_add_assign() to aggregate compatible workers/windows in place. Checked
aggregation validates every addition before changing the destination; overflow or
incompatible geometry leaves it unchanged. This requires two bucket passes.
Atomic recorders offer load_into() and drain_into() to overwrite an existing,
compatible dense histogram without allocating. Both overwrite zero buckets too;
drain_into() also clears each source bucket as it captures it. Configuration
mismatch leaves both source and destination unchanged.
use histogram::{AtomicHistogram, Bucket, Histogram};
let recorder = AtomicHistogram::new(7, 32).unwrap();
let mut window = Histogram::with_config(&recorder.config());
let mut combined = Histogram::with_config(&recorder.config());
let mut output: [Option<Bucket>; 2] = std::array::from_fn(|_| None);
recorder.increment(100).unwrap();
recorder.drain_into(&mut window).unwrap();
window.quantile_buckets_into(&[0.5, 0.99], &mut output).unwrap();
combined.checked_add_assign(&window).unwrap();
// Reuse window as the destination for the next drain after consumers finish.Atomic loads and drains visit buckets individually; they do not establish one instantaneous histogram-wide reporting boundary or publish unrelated application data. Coordinate writers if an exact boundary is required. A separate snapshot followed by clearing while writers continue can lose observations. An owned dense recorder can instead be handed off or rotated, then reset when consumers finish.
Lifetime cumulative metrics need not reset. Compatible bucket snapshots can be subtracted to obtain interval counts if the recorder has not reset or overflowed; subtracting percentile values does not produce an interval percentile.
Dense histograms and owned/borrowed cumulative snapshots provide
quantile_bucket() and quantile_buckets_into() for both counter widths.
Dense batches share a forward rank scan, with O(B + Q²) work for B buckets and
Q requests; the allocation-free request ordering is intended for small reports.
Cumulative snapshots use binary searches, with O(Q log K) work for K stored buckets.
Construction/conversion is a separate cost.
use histogram::{Bucket, CumulativeROHistogram, Histogram};
let mut recorder = Histogram::new(7, 32).unwrap();
for value in [100, 200, 300] {
recorder.increment(value).unwrap();
}
let snapshot = CumulativeROHistogram::from(&recorder);
let p99 = snapshot.quantile_bucket(0.99).unwrap().unwrap();
println!("p99: {}..={}", p99.start(), p99.end());
let requests = [0.99, 0.5, 0.99];
let mut output: [Option<Bucket>; 3] = std::array::from_fn(|_| None);
let written = snapshot.as_ref().quantile_buckets_into(&requests, &mut output).unwrap();
assert_eq!(written, 3);
assert_eq!(output[0], output[2]);Batch requests may be unsorted and may repeat. Results preserve request order,
and buckets include their individual counts. Empty histograms return/write None.
Invalid quantiles or insufficient output capacity return an error without changing
the output; extra output slots are left untouched. Empty requests write nothing.
The existing quantile()/quantiles() APIs still provide a sorted result map,
total count and min/max metadata when those are needed.
Sparse and cumulative snapshots use vectors that can retain spare capacity after
construction. For snapshots you intend to keep, call shrink_to_fit() explicitly:
use histogram::{CumulativeROHistogram32, Histogram};
let mut recorder = Histogram::new(10, 30).unwrap();
recorder.increment(1000).unwrap();
let mut retained = CumulativeROHistogram32::try_from(&recorder).unwrap();
retained.shrink_to_fit();All four owned sparse/cumulative types support this method. It preserves counts, quantiles, cached means, and serialization. It can reallocate and move the backing vectors, so budget the one-time cost at the retention boundary. Short-lived snapshots can skip it; conversions keep their existing allocation behavior.
Slice lengths describe logical payload, not allocated capacity. Use the vectors
returned by into_parts() to inspect their capacities. Like Vec::shrink_to_fit,
this method does not guarantee exact capacity or that the allocator returns freed
memory to the operating system. It changes neither precision nor counter width.
Owned cumulative histograms and their borrowed views support checked_add and
downsample, returning new owned cumulative snapshots. These operations work on
individual counts derived from adjacent prefixes. Inputs stay unchanged.
use histogram::{CumulativeROHistogram32, Histogram};
let mut first = Histogram::new(10, 30).unwrap();
let mut second = Histogram::new(7, 30).unwrap();
first.increment(1000).unwrap();
second.increment(2000).unwrap();
let first = CumulativeROHistogram32::try_from(&first).unwrap();
let second = CumulativeROHistogram32::try_from(&second).unwrap();
// Choose a common geometry explicitly before merging.
let first = first.downsample(7).unwrap();
let mut summary = first.as_ref().checked_add(&second.as_ref()).unwrap();
assert_eq!(summary.total_count(), 2);
summary.shrink_to_fit(); // Optional retention decision after transformation.
let p99 = summary.quantile_bucket(0.99).unwrap();Addition requires matching configurations and counter widths. It rejects a
combined total above u32::MAX or u64::MAX, including disjoint buckets whose
individual counts fit. Use CumulativeROHistogram::from(&narrow) to widen before
merging; narrow the result with CumulativeROHistogram32::try_from(&wide) only
when its total fits. Different maximum value powers are rejected. Downsampling
requires a strictly smaller grouping power and preserves range, width and total.
For n and m stored input buckets and k occupied output buckets, addition takes O(n + m) time and O(k) output space; downsampling takes O(n) time and O(k) output space. Both validate input indices/prefixes, omit zero individual counts and allocate two growing vectors without a dense intermediate. Inputs coexist with the output and any allocator-internal reallocation overlap. Output vectors may retain spare capacity; compaction is separate.
The output mean is recomputed from output bucket midpoints. Downsampling can change this estimate; neither operation recovers exact raw-observation means or preserves externally supplied cached moments. Serialized representation is unchanged.
For many windows, repeated checked_add calls rescan the growing accumulator
and allocate each result. A balanced reduction or an explicitly owned dense
accumulator populated from snapshot.iter() may suit different occupancy and
window counts. Iterator buckets contain individual counts; snapshot.count()
contains prefixes and must not be treated as independent bucket counts. When
using a dense accumulator, check the combined total before wrapping recorder
operations or conversion. Benchmark these application choices separately from
downstream quantile reads with cargo bench --bench cumulative_transforms.
That benchmark uses fully overlapping windows at 8 or 2,048 occupied buckets;
partially overlapping windows can grow the output and change the tradeoff.
serde-- EnablesSerializeandDeserializefor histogram types.schemars-- Enables JSON Schema generation (impliesserde).
With serde, decoding validates the configuration and storage invariants before
returning a histogram. Valid serialized field names, order, and binary layout are
preserved. Cumulative snapshots recompute their cached mean from validated counts
instead of trusting serialized metadata. These checks run during decoding and do
not change the recording path.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.