diff --git a/output/cloud/expv2/hdr.go b/output/cloud/expv2/hdr.go index 54ff939e4a1..ec3ce23830e 100644 --- a/output/cloud/expv2/hdr.go +++ b/output/cloud/expv2/hdr.go @@ -3,6 +3,7 @@ package expv2 import ( "math" "math/bits" + "sort" "go.k6.io/k6/output/cloud/expv2/pbcloud" ) @@ -37,14 +38,13 @@ const ( // The current version is: f(N = 25, m = 7) = 3200. type histogram struct { // Buckets stores the counters for each bin of the histogram. - // It does not include the first and the last absolute bucket, - // because they contain exception cases - // and they requires to be tracked in a dedicated way. - // - // It is expected to start and end with a non-zero bucket, - // in this way we can avoid extra allocation for not significant buckets. - // All the zero buckets in between are preserved. - Buckets []uint32 + // It does not include counters for the untrackable values, + // because they contain exception cases and require to be tracked in a dedicated way. + Buckets map[uint32]uint32 + + // Indexes keeps an ordered slice of unique-seen buckets' indexes. + // It allows to iterate the buckets in order. It uses an ascendent order. + Indexes []uint32 // ExtraLowBucket counts occurrences of observed values smaller // than the minimum trackable value. @@ -54,16 +54,6 @@ type histogram struct { // than the maximum trackable value. ExtraHighBucket uint32 - // FirstNotZeroBucket represents the index of the first bucket - // with a significant counter in the Buckets slice (a not zero value). - // In this way, all the buckets before can be omitted. - FirstNotZeroBucket uint32 - - // LastNotZeroBucket represents the index of the last bucket - // with a significant counter in the Buckets slice (a not zero value). - // In this way, all the buckets after can be omitted. - LastNotZeroBucket uint32 - // Max is the absolute maximum observed value. Max float64 @@ -104,92 +94,72 @@ func (h *histogram) addToBucket(v float64) { return } - index := resolveBucketIndex(v) + ix := resolveBucketIndex(v) + if _, contains := h.Buckets[ix]; !contains { + h.trackBucket(ix) + } - // they grow the current Buckets slice if there isn't enough capacity. - // - // An example with growRight: - // With Buckets [4, 1] and index equals to 5 - // then we expect a slice like [4,1,0,0,0,0] - // then the counter at 5th position will be incremented - // generating the final slice [4,1,0,0,0,1] - switch { - case len(h.Buckets) == 0: - h.init(index) - case index < h.FirstNotZeroBucket: - h.prependBuckets(index) - case index > h.LastNotZeroBucket: - h.appendBuckets(index) - default: - h.Buckets[index-h.FirstNotZeroBucket]++ + if h.Buckets == nil { + h.Buckets = make(map[uint32]uint32) } + h.Buckets[ix]++ } -func (h *histogram) init(index uint32) { - h.FirstNotZeroBucket = index - h.LastNotZeroBucket = index - h.Buckets = make([]uint32, 1, 32) - h.Buckets[0] = 1 -} +// trackBucket stores the unique seen buckets. +func (h *histogram) trackBucket(index uint32) { + i := sort.Search(len(h.Indexes), func(i int) bool { + return h.Indexes[i] > index + }) -// prependBuckets expands the buckets slice with zeros up to the required index, -// then it increments the required bucket. -func (h *histogram) prependBuckets(index uint32) { - if h.FirstNotZeroBucket <= index { - panic("buckets is already contains the requested index") + // insert at the end + if len(h.Indexes) == i { + h.Indexes = append(h.Indexes, index) + return } - newLen := (h.FirstNotZeroBucket - index) + uint32(len(h.Buckets)) - - // TODO: we may consider to swap by sub-groups - // e.g [4, 1] => [4, 1, 0, 0] => [0, 0, 4, 1] - // It requires a benchmark if it is better than just copy it. - - newBuckets := make([]uint32, newLen) - copy(newBuckets[h.FirstNotZeroBucket-index:], h.Buckets) - h.Buckets = newBuckets - - // Update the stats - h.Buckets[0] = 1 - h.FirstNotZeroBucket = index + // insert in the middle + h.Indexes = append(h.Indexes, 0) // expand the slice + copy(h.Indexes[i+1:], h.Indexes[i:]) // make the space + h.Indexes[i] = index // set the index } -// appendBuckets expands the buckets slice with zeros buckets till the required index, -// then it increments the required bucket. -// If the slice has enough capacity then it reuses it without allocate. -func (h *histogram) appendBuckets(index uint32) { - if h.LastNotZeroBucket >= index { - panic("buckets is already bigger than requested index") +// histogramAsProto converts the histogram into the equivalent Protobuf version. +func histogramAsProto(h *histogram, time int64) *pbcloud.TrendHdrValue { + var ( + counters []uint32 + spans []*pbcloud.BucketSpan + ) + + // allocate only if at least one item is available + if len(h.Indexes) > 0 { + // init the counters + counters = make([]uint32, 1, len(h.Indexes)) + counters[0] = h.Buckets[h.Indexes[0]] + // open the first span + spans = append(spans, &pbcloud.BucketSpan{Offset: h.Indexes[0], Length: 1}) } - newLen := index - h.FirstNotZeroBucket + 1 + for i := 1; i < len(h.Buckets); i++ { + counters = append(counters, h.Buckets[h.Indexes[i]]) - if uint32(cap(h.Buckets)) > newLen { - // See https://go.dev/ref/spec#Slice_expressions - // "For slices, the upper index bound is - // the slice capacity cap(a) rather than the length" - h.Buckets = h.Buckets[:newLen] - } else { - newBuckets := make([]uint32, newLen) - copy(newBuckets, h.Buckets) - h.Buckets = newBuckets - } + // if the current and the previous indexes are not consecutive + // consider as closed the current on-going span and start a new one. + if diff := h.Indexes[i] - h.Indexes[i-1]; diff > 1 { + spans = append(spans, &pbcloud.BucketSpan{Offset: diff, Length: 1}) + continue + } - // Update the stats - h.Buckets[len(h.Buckets)-1] = 1 - h.LastNotZeroBucket = index -} + spans[len(spans)-1].Length++ + } -// histogramAsProto converts the histogram into the equivalent Protobuf version. -func histogramAsProto(h *histogram, time int64) *pbcloud.TrendHdrValue { hval := &pbcloud.TrendHdrValue{ - Time: timestampAsProto(time), - LowerCounterIndex: h.FirstNotZeroBucket, - MinValue: h.Min, - MaxValue: h.Max, - Sum: h.Sum, - Count: h.Count, - Counters: h.Buckets, + Time: timestampAsProto(time), + MinValue: h.Min, + MaxValue: h.Max, + Sum: h.Sum, + Count: h.Count, + Counters: counters, + Spans: spans, } if h.ExtraLowBucket > 0 { hval.ExtraLowValuesCounter = &h.ExtraLowBucket @@ -255,6 +225,7 @@ func resolveBucketIndex(val float64) uint32 { return (nkdiff << k) + (upscaled >> nkdiff) } +// Add implements the metricValue interface. func (h *histogram) Add(v float64) { h.addToBucket(v) } diff --git a/output/cloud/expv2/hdr_test.go b/output/cloud/expv2/hdr_test.go index 2187eaebfda..7dd2c622561 100644 --- a/output/cloud/expv2/hdr_test.go +++ b/output/cloud/expv2/hdr_test.go @@ -11,7 +11,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func TestValueBacket(t *testing.T) { +func TestResolveBucketIndex(t *testing.T) { t.Parallel() tests := []struct { @@ -32,6 +32,7 @@ func TestValueBacket(t *testing.T) { {in: 256, exp: 256}, {in: 282.29, exp: 269}, {in: 1029, exp: 512}, + {in: 39751, exp: 1179}, {in: (1 << 30) - 1, exp: 3071}, {in: (1 << 30), exp: 3072}, {in: math.MaxInt32, exp: 3199}, @@ -41,181 +42,171 @@ func TestValueBacket(t *testing.T) { } } -func TestNewHistogramWithSimpleValue(t *testing.T) { +func TestHistogramAddWithSimpleValue(t *testing.T) { t.Parallel() // Zero as value res := histogram{} - res.addToBucket(0) + res.Add(0) exp := histogram{ - Buckets: []uint32{1}, - FirstNotZeroBucket: 0, - LastNotZeroBucket: 0, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Max: 0, - Min: 0, - Sum: 0, - Count: 1, + Buckets: map[uint32]uint32{0: 1}, + Indexes: []uint32{0}, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Max: 0, + Min: 0, + Sum: 0, + Count: 1, } require.Equal(t, exp, res) // Add a lower bucket index within slice capacity res = histogram{} - res.addToBucket(8) - res.addToBucket(5) + res.Add(8) + res.Add(5) exp = histogram{ - Buckets: []uint32{1, 0, 0, 1}, - FirstNotZeroBucket: 5, - LastNotZeroBucket: 8, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Max: 8, - Min: 5, - Sum: 13, - Count: 2, + Buckets: map[uint32]uint32{5: 1, 8: 1}, + Indexes: []uint32{5, 8}, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Max: 8, + Min: 5, + Sum: 13, + Count: 2, } require.Equal(t, exp, res) // Add a higher bucket index within slice capacity res = histogram{} - res.addToBucket(100) - res.addToBucket(101) + res.Add(100) + res.Add(101) exp = histogram{ - Buckets: []uint32{1, 1}, - FirstNotZeroBucket: 100, - LastNotZeroBucket: 101, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Max: 101, - Min: 100, - Sum: 201, - Count: 2, + Buckets: map[uint32]uint32{100: 1, 101: 1}, + Indexes: []uint32{100, 101}, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Max: 101, + Min: 100, + Sum: 201, + Count: 2, } require.Equal(t, exp, res) // Same case but reversed test check res = histogram{} - res.addToBucket(101) - res.addToBucket(100) + res.Add(101) + res.Add(100) exp = histogram{ - Buckets: []uint32{1, 1}, - FirstNotZeroBucket: 100, - LastNotZeroBucket: 101, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Max: 101, - Min: 100, - Sum: 201, - Count: 2, + Buckets: map[uint32]uint32{100: 1, 101: 1}, + Indexes: []uint32{100, 101}, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Max: 101, + Min: 100, + Sum: 201, + Count: 2, } assert.Equal(t, exp, res) // One more complex case with lower index and more than two indexes res = histogram{} - res.addToBucket(8) - res.addToBucket(9) - res.addToBucket(10) - res.addToBucket(5) + res.Add(8) + res.Add(9) + res.Add(10) + res.Add(5) exp = histogram{ - Buckets: []uint32{1, 0, 0, 1, 1, 1}, - FirstNotZeroBucket: 5, - LastNotZeroBucket: 10, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Max: 10, - Min: 5, - Sum: 32, - Count: 4, + Buckets: map[uint32]uint32{8: 1, 9: 1, 10: 1, 5: 1}, + Indexes: []uint32{5, 8, 9, 10}, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Max: 10, + Min: 5, + Sum: 32, + Count: 4, } assert.Equal(t, exp, res) } -func TestNewHistogramWithUntrackables(t *testing.T) { +func TestHistogramAddWithUntrackables(t *testing.T) { t.Parallel() res := histogram{} for _, v := range []float64{5, -3.14, 2 * 1e9, 1} { - res.addToBucket(v) + res.Add(v) } exp := histogram{ - Buckets: []uint32{1, 0, 0, 0, 1}, - FirstNotZeroBucket: 1, - LastNotZeroBucket: 5, - ExtraLowBucket: 1, - ExtraHighBucket: 1, - Max: 2 * 1e9, - Min: -3.14, - Sum: 2*1e9 + 5 + 1 - 3.14, - Count: 4, + Buckets: map[uint32]uint32{1: 1, 5: 1}, + Indexes: []uint32{1, 5}, + ExtraLowBucket: 1, + ExtraHighBucket: 1, + Max: 2 * 1e9, + Min: -3.14, + Sum: 2*1e9 + 5 + 1 - 3.14, + Count: 4, } assert.Equal(t, exp, res) } -func TestNewHistogramWithMultipleValues(t *testing.T) { +func TestHistogramAddWithMultipleOccurances(t *testing.T) { t.Parallel() res := histogram{} for _, v := range []float64{51.8, 103.6, 103.6, 103.6, 103.6} { - res.addToBucket(v) + res.Add(v) } exp := histogram{ - FirstNotZeroBucket: 52, - LastNotZeroBucket: 104, - Max: 103.6, - Min: 51.8, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Buckets: append(append([]uint32{1}, make([]uint32, 51)...), 4), - // Buckets = {1, 0 for 51 times, 4} - Sum: 466.20000000000005, - Count: 5, + Buckets: map[uint32]uint32{52: 1, 104: 4}, + Indexes: []uint32{52, 104}, + Max: 103.6, + Min: 51.8, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Sum: 466.20000000000005, + Count: 5, } assert.Equal(t, exp, res) } -func TestNewHistogramWithNegativeNum(t *testing.T) { +func TestHistogramAddWithNegativeNum(t *testing.T) { t.Parallel() res := histogram{} - res.addToBucket(-2.42314) + res.Add(-2.42314) exp := histogram{ - FirstNotZeroBucket: 0, - Max: -2.42314, - Min: -2.42314, - Buckets: nil, - ExtraLowBucket: 1, - ExtraHighBucket: 0, - Sum: -2.42314, - Count: 1, + Max: -2.42314, + Min: -2.42314, + Buckets: nil, + ExtraLowBucket: 1, + ExtraHighBucket: 0, + Sum: -2.42314, + Count: 1, } assert.Equal(t, exp, res) } -func TestNewHistogramWithMultipleNegativeNums(t *testing.T) { +func TestHistogramAddWithMultipleNegativeNums(t *testing.T) { t.Parallel() res := histogram{} for _, v := range []float64{-0.001, -0.001, -0.001} { - res.addToBucket(v) + res.Add(v) } exp := histogram{ - Buckets: nil, - FirstNotZeroBucket: 0, - ExtraLowBucket: 3, - ExtraHighBucket: 0, - Max: -0.001, - Min: -0.001, - Sum: -0.003, - Count: 3, + Buckets: nil, + ExtraLowBucket: 3, + ExtraHighBucket: 0, + Max: -0.001, + Min: -0.001, + Sum: -0.003, + Count: 3, } assert.Equal(t, exp, res) } @@ -225,41 +216,16 @@ func TestNewHistoramWithNoVals(t *testing.T) { res := histogram{} exp := histogram{ - Buckets: nil, - FirstNotZeroBucket: 0, - ExtraLowBucket: 0, - ExtraHighBucket: 0, - Max: 0, - Min: 0, - Sum: 0, + Buckets: nil, + ExtraLowBucket: 0, + ExtraHighBucket: 0, + Max: 0, + Min: 0, + Sum: 0, } assert.Equal(t, exp, res) } -func TestHistogramAppendBuckets(t *testing.T) { - t.Parallel() - h := histogram{} - - // the cap is smaller than requested index - // so it creates a new slice - h.appendBuckets(3) - assert.Len(t, h.Buckets, 4) - - // it must preserve already existing items - h.Buckets[2] = 101 - - // it appends to the same slice - h.appendBuckets(5) - assert.Len(t, h.Buckets, 6) - assert.Equal(t, uint32(101), h.Buckets[2]) - assert.Equal(t, uint32(1), h.Buckets[5]) - - // it is not possible to request an index smaller than - // the last already available index - h.LastNotZeroBucket = 5 - assert.Panics(t, func() { h.appendBuckets(4) }) -} - func TestHistogramAsProto(t *testing.T) { t.Parallel() @@ -280,11 +246,11 @@ func TestHistogramAsProto(t *testing.T) { name: "not trackable values", vals: []float64{-0.23, 1<<30 + 1}, exp: &pbcloud.TrendHdrValue{ - Count: 2, ExtraLowValuesCounter: uint32ptr(1), ExtraHighValuesCounter: uint32ptr(1), Counters: nil, - LowerCounterIndex: 0, + Spans: nil, + Count: 2, MinValue: -0.23, MaxValue: 1<<30 + 1, Sum: (1 << 30) + 1 - 0.23, @@ -298,10 +264,54 @@ func TestHistogramAsProto(t *testing.T) { ExtraLowValuesCounter: nil, ExtraHighValuesCounter: nil, Counters: []uint32{2, 1}, - LowerCounterIndex: 2, - MinValue: 1.1, - MaxValue: 3, - Sum: 6.1, + Spans: []*pbcloud.BucketSpan{ + { + Offset: 2, + Length: 2, + }, + }, + MinValue: 1.1, + MaxValue: 3, + Sum: 6.1, + }, + }, + { + name: "longer sequence", + vals: []float64{ + 2275, 52.25, 268.85, 383.47, 18.49, + 163.85, 4105, 835.27, 52, 18.28, 238.44, 39751, 18.86, + 967.05, 967.01, 967, 4123.5, 270.69, 677.27, + }, + // Sorted: + // 18.28,18.49,18.86,52,52.25,163.85, + // 238.44,268.85,270.69,383.47,677.27,835.27,967,967.01,967.05 + // 2275, 4105, 4123.5, 39751 + // Distribution + // - {x<256}: 19:3, 52:1, 53:1, 164:1, 239:1 + // - {x >= 256}: 262:1, 263:1, 320:1, 425:1, 465:1, 497:1 498:2 + // - {x > 1k}: 654:1, 768:2, 1179:1 + exp: &pbcloud.TrendHdrValue{ + Count: 19, + Counters: []uint32{3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1}, + Spans: []*pbcloud.BucketSpan{ + {Offset: 19, Length: 1}, + {Offset: 33, Length: 2}, + {Offset: 111, Length: 1}, + {Offset: 75, Length: 1}, + {Offset: 23, Length: 2}, // 262 + {Offset: 57, Length: 1}, + {Offset: 105, Length: 1}, + {Offset: 40, Length: 1}, + {Offset: 32, Length: 2}, + {Offset: 156, Length: 1}, // 654 + {Offset: 114, Length: 1}, + {Offset: 411, Length: 1}, + }, + ExtraLowValuesCounter: nil, + ExtraHighValuesCounter: nil, + MinValue: 18.28, + MaxValue: 39751, + Sum: 56153.280000000006, }, }, } @@ -309,7 +319,7 @@ func TestHistogramAsProto(t *testing.T) { for _, tc := range cases { h := histogram{} for _, v := range tc.vals { - h.addToBucket(v) + h.Add(v) } tc.exp.Time = ×tamppb.Timestamp{Seconds: 1} assert.Equal(t, tc.exp, histogramAsProto(&h, time.Unix(1, 0).UnixNano()), tc.name)