diff --git a/exact_minmax.go b/exact_minmax.go new file mode 100644 index 0000000..eadf87f --- /dev/null +++ b/exact_minmax.go @@ -0,0 +1,183 @@ +// Copyright 2026 The Cockroach Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +package goodhistogram + +import ( + "math" + "sync/atomic" + + prometheusgo "github.com/prometheus/client_model/go" +) + +// WithExactMinMax wraps a Histogram, additionally tracking the exact min and +// max recorded values. Extremes span every observation, including out-of-range +// (Underflow/Overflow) and zero/negative values, so Max may exceed hi and Min +// may be below lo. A WithExactMinMax must not be copied after first use. +type WithExactMinMax struct { + histogram Histogram + + // Before any Record, minVal is MaxInt64 and maxVal is MinInt64; these are + // identities for min/max, and minVal <= maxVal iff a value has been + // recorded (which is how readers tell "empty" from "recorded"). + minVal atomic.Int64 + maxVal atomic.Int64 +} + +func NewWithExactMinMax(p Params) *WithExactMinMax { + h := &WithExactMinMax{} + h.histogram.init(p) + h.minVal.Store(math.MaxInt64) + h.maxVal.Store(math.MinInt64) + return h +} + +func (h *WithExactMinMax) Record(v int64) { + h.histogram.Record(v) + + for { + old := h.maxVal.Load() + if v <= old { + break + } + if h.maxVal.CompareAndSwap(old, v) { + break + } + } + for { + old := h.minVal.Load() + if v >= old { + break + } + if h.minVal.CompareAndSwap(old, v) { + break + } + } +} + +func (h *WithExactMinMax) Reset() { + h.histogram.Reset() + h.minVal.Store(math.MaxInt64) + h.maxVal.Store(math.MinInt64) +} + +// Schema returns the Prometheus native histogram schema (0–8). +func (h *WithExactMinMax) Schema() int32 { + return h.histogram.Schema() +} + +// Summary is an exact summary of the recorded values. Min and Max are +// meaningful only when Count > 0. +type Summary struct { + Count uint64 + Sum int64 + Min int64 + Max int64 +} + +func (h *WithExactMinMax) Summary() Summary { + var count uint64 + for i := range h.histogram.counts { + count += h.histogram.counts[i].Load() + } + count += h.histogram.ZeroCount.Load() + h.histogram.Underflow.Load() + h.histogram.Overflow.Load() + + s := Summary{Count: count, Sum: h.histogram.sum.Load()} + if mn, mx := h.minVal.Load(), h.maxVal.Load(); mn <= mx { + s.Min, s.Max = mn, mx + } + return s +} + +// ExactSnapshot is a Snapshot with exact extremes. Min and Max are meaningful +// only when Summary().Count > 0. Subtraction is not supported because exact +// extremes cannot be recovered by subtracting snapshots. +type ExactSnapshot struct { + snapshot Snapshot + Min int64 + Max int64 +} + +func (h *WithExactMinMax) Snapshot() ExactSnapshot { + es := ExactSnapshot{snapshot: h.histogram.Snapshot()} + if mn, mx := h.minVal.Load(), h.maxVal.Load(); mn <= mx { + es.Min, es.Max = mn, mx + } + return es +} + +// ValueAtQuantile returns the exact min at q<=0 and max at q>=1 (which may fall +// outside [lo, hi]); interior quantiles use the base estimate. +func (s *ExactSnapshot) ValueAtQuantile(q float64) float64 { + if s.snapshot.TotalCount == 0 { + return 0 + } + if q <= 0 { + return float64(s.Min) + } + if q >= 1 { + return float64(s.Max) + } + return s.snapshot.ValueAtQuantile(q) +} + +func (s *ExactSnapshot) ValuesAtQuantiles(qs []float64) []float64 { + res := s.snapshot.ValuesAtQuantiles(qs) + if s.snapshot.TotalCount == 0 { + return res + } + for i, q := range qs { + switch { + case q <= 0: + res[i] = float64(s.Min) + case q >= 1: + res[i] = float64(s.Max) + } + } + return res +} + +func (s *ExactSnapshot) Merge(other *ExactSnapshot) ExactSnapshot { + m := ExactSnapshot{snapshot: s.snapshot.Merge(&other.snapshot)} + switch { + case s.snapshot.TotalCount == 0: + m.Min, m.Max = other.Min, other.Max + case other.snapshot.TotalCount == 0: + m.Min, m.Max = s.Min, s.Max + default: + m.Min = min(s.Min, other.Min) + m.Max = max(s.Max, other.Max) + } + return m +} + +// Summary returns the exact count, sum, and extremes in the snapshot. +func (s *ExactSnapshot) Summary() Summary { + return Summary{Count: s.snapshot.TotalCount, Sum: s.snapshot.TotalSum, Min: s.Min, Max: s.Max} +} + +// Schema returns the Prometheus native histogram schema (0–8). +func (s *ExactSnapshot) Schema() int32 { + return s.snapshot.Schema() +} + +// Mean returns the arithmetic mean, or zero for an empty snapshot. +func (s *ExactSnapshot) Mean() float64 { + return s.snapshot.Mean() +} + +// Total returns the observation count and sum. +func (s *ExactSnapshot) Total() (int64, float64) { + return s.snapshot.Total() +} + +// ToPrometheusHistogram exports the bucket counts, sum, and count. Exact +// extremes are not represented in the Prometheus histogram format. +func (s *ExactSnapshot) ToPrometheusHistogram() *prometheusgo.Histogram { + return s.snapshot.ToPrometheusHistogram() +} diff --git a/exact_minmax_test.go b/exact_minmax_test.go new file mode 100644 index 0000000..764a6c2 --- /dev/null +++ b/exact_minmax_test.go @@ -0,0 +1,219 @@ +// Copyright 2026 The Cockroach Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +package goodhistogram + +import ( + "math/rand" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExactMinMaxSummary(t *testing.T) { + h := NewWithExactMinMax(Params{Lo: 100, Hi: 10000, ErrorBound: 0.05}) + + // Includes an underflow (50), an overflow (20000), a zero, a negative, + // and in-range values. Min/Max and Sum must account for all of them. + values := []int64{500, 50, 20000, 0, -5, 1000, 300} + var wantSum int64 + for _, v := range values { + h.Record(v) + wantSum += v + } + + s := h.Summary() + require.Equal(t, uint64(len(values)), s.Count) + require.Equal(t, wantSum, s.Sum) + require.Equal(t, int64(-5), s.Min, "min must include out-of-range/negative values") + require.Equal(t, int64(20000), s.Max, "max must include overflow values") +} + +func TestExactMinMaxEmpty(t *testing.T) { + h := NewWithExactMinMax(Params{Lo: 1, Hi: 1000, ErrorBound: 0.05}) + + s := h.Summary() + require.Equal(t, uint64(0), s.Count) + require.Equal(t, int64(0), s.Sum) + require.Equal(t, int64(0), s.Min) + require.Equal(t, int64(0), s.Max) + + snap := h.Snapshot() + require.Equal(t, uint64(0), snap.Summary().Count) + require.Equal(t, 0.0, snap.ValueAtQuantile(0)) + require.Equal(t, 0.0, snap.ValueAtQuantile(0.5)) + require.Equal(t, 0.0, snap.ValueAtQuantile(1.0)) +} + +func TestExactMinMaxSingleZero(t *testing.T) { + // Recording a single 0 must be distinguishable from empty: min==max==0 but + // with Count==1. The minVal<=maxVal invariant makes this work. + h := NewWithExactMinMax(Params{Lo: 1, Hi: 1000, ErrorBound: 0.05}) + h.Record(0) + + s := h.Summary() + require.Equal(t, uint64(1), s.Count) + require.Equal(t, int64(0), s.Min) + require.Equal(t, int64(0), s.Max) +} + +func TestExactMinMaxQuantileEndpoints(t *testing.T) { + h := NewWithExactMinMax(Params{Lo: 100, Hi: 10000, ErrorBound: 0.05}) + // 40 is below lo, 50000 is above hi — both are the true extremes and must + // be returned exactly at q=0 / q=1 even though they fall outside [lo, hi]. + h.Record(40) + h.Record(50000) + for i := int64(200); i <= 1000; i += 10 { + h.Record(i) + } + + snap := h.Snapshot() + + require.Equal(t, float64(40), snap.ValueAtQuantile(0), + "q=0 must return exact min, even below lo") + require.Equal(t, float64(50000), snap.ValueAtQuantile(1.0), + "q=1 must return exact max, even above hi") + + // Interior quantiles must delegate unchanged to the base estimate. + for _, q := range []float64{0.25, 0.5, 0.75, 0.99} { + require.Equalf(t, snap.snapshot.ValueAtQuantile(q), snap.ValueAtQuantile(q), + "interior q=%.2f must match base estimate", q) + } +} + +func TestExactMinMaxBatchEndpoints(t *testing.T) { + h := NewWithExactMinMax(Params{Lo: 100, Hi: 10000, ErrorBound: 0.05}) + h.Record(40) + h.Record(50000) + for i := int64(200); i <= 1000; i += 10 { + h.Record(i) + } + snap := h.Snapshot() + + qs := []float64{0, 0.5, 0.99, 1.0} + batch := snap.ValuesAtQuantiles(qs) + for i, q := range qs { + require.InDeltaf(t, snap.ValueAtQuantile(q), batch[i], 1e-9, "q=%.2f", q) + } + require.Equal(t, float64(40), batch[0]) + require.Equal(t, float64(50000), batch[3]) +} + +func TestExactMinMaxMerge(t *testing.T) { + mk := func(vals ...int64) ExactSnapshot { + h := NewWithExactMinMax(Params{Lo: 1, Hi: 1e6, ErrorBound: 0.05}) + for _, v := range vals { + h.Record(v) + } + return h.Snapshot() + } + + a := mk(10, 500, 3000) + b := mk(50, 90000) + + m := a.Merge(&b) + require.Equal(t, uint64(5), m.Summary().Count) + require.Equal(t, int64(10), m.Min, "min-of-mins") + require.Equal(t, int64(90000), m.Max, "max-of-maxes") + + // Merging with an empty operand preserves the non-empty extremes. + empty := mk() + m2 := a.Merge(&empty) + require.Equal(t, int64(10), m2.Min) + require.Equal(t, int64(3000), m2.Max) + require.Equal(t, a.Summary().Count, m2.Summary().Count) + + // Empty on the left as well. + m3 := empty.Merge(&a) + require.Equal(t, int64(10), m3.Min) + require.Equal(t, int64(3000), m3.Max) +} + +func TestExactMinMaxReset(t *testing.T) { + h := NewWithExactMinMax(Params{Lo: 1, Hi: 1000, ErrorBound: 0.05}) + for i := int64(1); i <= 500; i++ { + h.Record(i) + } + h.Reset() + + s := h.Summary() + require.Equal(t, uint64(0), s.Count) + require.Equal(t, int64(0), s.Min) + require.Equal(t, int64(0), s.Max) + + // Recording after reset tracks fresh extremes. + h.Record(42) + h.Record(7) + s = h.Summary() + require.Equal(t, int64(7), s.Min) + require.Equal(t, int64(42), s.Max) +} + +func TestExactMinMaxConcurrent(t *testing.T) { + h := NewWithExactMinMax(Params{Lo: 1, Hi: 1e9, ErrorBound: 0.05}) + const goroutines = 8 + const perG = 20000 + + // Each goroutine records values in [2, 1e6]; goroutine 0 additionally + // records a known global min (1) and max (1e9) exactly once. + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func(seed int64) { + defer wg.Done() + rng := rand.New(rand.NewSource(seed)) + if seed == 0 { + h.Record(1) + h.Record(1e9) + } + for i := 0; i < perG; i++ { + h.Record(rng.Int63n(1e6-1) + 2) + } + }(int64(g)) + } + wg.Wait() + + s := h.Summary() + require.Equal(t, uint64(goroutines*perG+2), s.Count) + require.Equal(t, int64(1), s.Min) + require.Equal(t, int64(1e9), s.Max) +} + +func TestExactMinMaxSnapshotMethods(t *testing.T) { + // The explicit snapshot API preserves summary and export behavior. + h := NewWithExactMinMax(Params{Lo: 1, Hi: 1000, ErrorBound: 0.05}) + h.Record(100) + h.Record(200) + h.Record(300) + + snap := h.Snapshot() + require.Equal(t, h.Summary(), snap.Summary()) + require.Equal(t, h.Schema(), snap.Schema()) + count, sum := snap.Total() + require.Equal(t, int64(3), count) + require.Equal(t, float64(600), sum) + require.InDelta(t, 200.0, snap.Mean(), 1e-9) + ph := snap.ToPrometheusHistogram() + require.Equal(t, uint64(3), ph.GetSampleCount()) + require.Equal(t, float64(600), ph.GetSampleSum()) + require.Equal(t, h.Schema(), ph.GetSchema()) +} + +func TestExactMinMaxAllocations(t *testing.T) { + p := Params{Lo: 1, Hi: 1000, ErrorBound: 0.05} + // AllocsPerRun warms the shared config cache before measuring. Retain + // each result so both constructors must allocate their returned object. + var base *Histogram + baseAllocs := testing.AllocsPerRun(100, func() { base = New(p) }) + var exact *WithExactMinMax + exactAllocs := testing.AllocsPerRun(100, func() { exact = NewWithExactMinMax(p) }) + require.NotNil(t, base) + require.NotNil(t, exact) + require.Equal(t, baseAllocs, exactAllocs, "tracking extremes must not add an allocation") +} diff --git a/histogram.go b/histogram.go index aa9829e..aa50fa2 100644 --- a/histogram.go +++ b/histogram.go @@ -352,12 +352,15 @@ func (h *Histogram) Reset() { // New creates a new Histogram for the given range and error bound. Configs // are cached and shared across histograms with identical parameters. func New(p Params) *Histogram { - p = p.withDefaults() - cfg := getOrCreateConfig(p) - return &Histogram{ - cfg: cfg, - counts: make([]atomic.Uint64, cfg.numBuckets), - } + h := &Histogram{} + h.init(p) + return h +} + +// init initializes a new histogram in place so wrappers can store it by value. +func (h *Histogram) init(p Params) { + h.cfg = getOrCreateConfig(p.withDefaults()) + h.counts = make([]atomic.Uint64, h.cfg.numBuckets) } // Record adds a value to the histogram. This is the hot path: O(1), lock-free,