diff --git a/CHANGELOG.md b/CHANGELOG.md index c6465e99e34..e4533184470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Code instrumented with the `go.opentelemetry.io/otel/metric` will need to be mod ### Added +- Log the Exporters configuration in the TracerProviders message. (#2578) +- Metrics Exponential Histogram support: Mapping functions have been made available + in `sdk/metric/aggregator/exponential/mapping` for other OpenTelemetry projects to take + dependencies on. (#2502) - Add go 1.18 to our compatibility tests. (#2679) - Allow configuring the Sampler with the `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG` environment variables. (#2305, #2517) - Add the `metric/global` for obtaining and setting the global `MeterProvider` (#2660) diff --git a/sdk/metric/aggregator/exponential/README.md b/sdk/metric/aggregator/exponential/README.md new file mode 100644 index 00000000000..b58d6aecbb2 --- /dev/null +++ b/sdk/metric/aggregator/exponential/README.md @@ -0,0 +1,27 @@ +# Base-2 Exponential Histogram + +## Design + +This document is a placeholder for future Aggregator, once seen in [PR +2393](https://github.com/open-telemetry/opentelemetry-go/pull/2393). + +Only the mapping functions have been made available at this time. The +equations tested here are specified in the [data model for Exponential +Histogram data points](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/datamodel.md#exponentialhistogram). + +### Mapping function + +There are two mapping functions used, depending on the sign of the +scale. Negative and zero scales use the `mapping/exponent` mapping +function, which computes the bucket index directly from the bits of +the `float64` exponent. This mapping function is used with scale `-10 +<= scale <= 0`. Scales smaller than -10 map the entire normal +`float64` number range into a single bucket, thus are not considered +useful. + +The `mapping/logarithm` mapping function uses `math.Log(value)` times +the scaling factor `math.Ldexp(math.Log2E, scale)`. This mapping +function is used with `0 < scale <= 20`. The maximum scale is +selected because at scale 21, simply, it becomes difficult to test +correctness--at this point `math.MaxFloat64` maps to index +`math.MaxInt32` and the `math/big` logic used in testing breaks down. diff --git a/sdk/metric/aggregator/exponential/mapping/exponent/exponent.go b/sdk/metric/aggregator/exponential/mapping/exponent/exponent.go new file mode 100644 index 00000000000..93dff7ef007 --- /dev/null +++ b/sdk/metric/aggregator/exponential/mapping/exponent/exponent.go @@ -0,0 +1,117 @@ +// Copyright The OpenTelemetry 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package exponent // import "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping/exponent" + +import ( + "fmt" + "math" + + "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping" +) + +const ( + // MinScale defines the point at which the exponential mapping + // function becomes useless for float64. With scale -10, ignoring + // subnormal values, bucket indices range from -1 to 1. + MinScale int32 = -10 + + // MaxScale is the largest scale supported in this code. Use + // ../logarithm for larger scales. + MaxScale int32 = 0 +) + +type exponentMapping struct { + shift uint8 // equals negative scale +} + +// exponentMapping is used for negative scales, effectively a +// mapping of the base-2 logarithm of the exponent. +var prebuiltMappings = [-MinScale + 1]exponentMapping{ + {10}, + {9}, + {8}, + {7}, + {6}, + {5}, + {4}, + {3}, + {2}, + {1}, + {0}, +} + +// NewMapping constructs an exponential mapping function, used for scales <= 0. +func NewMapping(scale int32) (mapping.Mapping, error) { + if scale > MaxScale { + return nil, fmt.Errorf("exponent mapping requires scale <= 0") + } + if scale < MinScale { + return nil, fmt.Errorf("scale too low") + } + return &prebuiltMappings[scale-MinScale], nil +} + +// MapToIndex implements mapping.Mapping. +func (e *exponentMapping) MapToIndex(value float64) int32 { + // Note: we can assume not a 0, Inf, or NaN; positive sign bit. + + // Note: bit-shifting does the right thing for negative + // exponents, e.g., -1 >> 1 == -1. + return getBase2(value) >> e.shift +} + +func (e *exponentMapping) minIndex() int32 { + return int32(MinNormalExponent) >> e.shift +} + +func (e *exponentMapping) maxIndex() int32 { + return int32(MaxNormalExponent) >> e.shift +} + +// LowerBoundary implements mapping.Mapping. +func (e *exponentMapping) LowerBoundary(index int32) (float64, error) { + if min := e.minIndex(); index < min { + return 0, mapping.ErrUnderflow + } + + if max := e.maxIndex(); index > max { + return 0, mapping.ErrOverflow + } + + unbiased := int64(index << e.shift) + + // Note: although the mapping function rounds subnormal values + // up to the smallest normal value, there are still buckets + // that may be filled that start at subnormal values. The + // following code handles this correctly. It's equivalent to and + // faster than math.Ldexp(1, int(unbiased)). + if unbiased < int64(MinNormalExponent) { + subnormal := uint64(1 << SignificandWidth) + for unbiased < int64(MinNormalExponent) { + unbiased++ + subnormal >>= 1 + } + return math.Float64frombits(subnormal), nil + } + exponent := unbiased + ExponentBias + + bits := uint64(exponent << SignificandWidth) + return math.Float64frombits(bits), nil +} + +// Scale implements mapping.Mapping. +func (e *exponentMapping) Scale() int32 { + return -int32(e.shift) +} diff --git a/sdk/metric/aggregator/exponential/mapping/exponent/exponent_test.go b/sdk/metric/aggregator/exponential/mapping/exponent/exponent_test.go new file mode 100644 index 00000000000..0258aaf8dc6 --- /dev/null +++ b/sdk/metric/aggregator/exponential/mapping/exponent/exponent_test.go @@ -0,0 +1,310 @@ +// Copyright The OpenTelemetry 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package exponent + +import ( + "fmt" + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping" +) + +type expectMapping struct { + value float64 + index int32 +} + +// Tests that getBase2 returns the base-2 exponent as documented, unlike +// math.Frexp. +func TestGetBase2(t *testing.T) { + require.Equal(t, int32(-1022), MinNormalExponent) + require.Equal(t, int32(+1023), MaxNormalExponent) + + require.Equal(t, MaxNormalExponent, getBase2(0x1p+1023)) + require.Equal(t, int32(1022), getBase2(0x1p+1022)) + + require.Equal(t, int32(0), getBase2(1)) + + require.Equal(t, int32(-1021), getBase2(0x1p-1021)) + require.Equal(t, int32(-1022), getBase2(0x1p-1022)) + + // Subnormals below this point + require.Equal(t, int32(-1022), getBase2(0x1p-1023)) + require.Equal(t, int32(-1022), getBase2(0x1p-1024)) + require.Equal(t, int32(-1022), getBase2(0x1p-1025)) + require.Equal(t, int32(-1022), getBase2(0x1p-1074)) +} + +// Tests a few cases with scale=0. +func TestExponentMappingZero(t *testing.T) { + m, err := NewMapping(0) + require.NoError(t, err) + + require.Equal(t, int32(0), m.Scale()) + + for _, pair := range []expectMapping{ + {math.MaxFloat64, MaxNormalExponent}, + {0x1p+1023, MaxNormalExponent}, + {0x1p-1022, MinNormalExponent}, + {math.SmallestNonzeroFloat64, MinNormalExponent}, + {4, 2}, + {3, 1}, + {2, 1}, + {1.5, 0}, + {1, 0}, + {0.75, -1}, + {0.5, -1}, + {0.25, -2}, + } { + idx := m.MapToIndex(pair.value) + + require.Equal(t, pair.index, idx) + } +} + +// Tests a few cases with scale=MinScale. +func TestExponentMappingMinScale(t *testing.T) { + m, err := NewMapping(MinScale) + require.NoError(t, err) + + require.Equal(t, MinScale, m.Scale()) + + for _, pair := range []expectMapping{ + {1, 0}, + {math.MaxFloat64 / 2, 0}, + {math.MaxFloat64, 0}, + {math.SmallestNonzeroFloat64, -1}, + {0.5, -1}, + } { + t.Run(fmt.Sprint(pair.value), func(t *testing.T) { + idx := m.MapToIndex(pair.value) + + require.Equal(t, pair.index, idx) + }) + } +} + +// Tests invalid scales. +func TestInvalidScale(t *testing.T) { + m, err := NewMapping(1) + require.Error(t, err) + require.Nil(t, m) + + m, err = NewMapping(MinScale - 1) + require.Error(t, err) + require.Nil(t, m) +} + +// Tests a few cases with scale=-1. +func TestExponentMappingNegOne(t *testing.T) { + m, _ := NewMapping(-1) + + for _, pair := range []expectMapping{ + {16, 2}, + {15, 1}, + {9, 1}, + {8, 1}, + {5, 1}, + {4, 1}, + {3, 0}, + {2, 0}, + {1.5, 0}, + {1, 0}, + {0.75, -1}, + {0.5, -1}, + {0.25, -1}, + {0.20, -2}, + {0.13, -2}, + {0.125, -2}, + {0.10, -2}, + {0.0625, -2}, + {0.06, -3}, + } { + idx := m.MapToIndex(pair.value) + require.Equal(t, pair.index, idx, "value: %v", pair.value) + } +} + +// Tests a few cases with scale=-4. +func TestExponentMappingNegFour(t *testing.T) { + m, err := NewMapping(-4) + require.NoError(t, err) + require.Equal(t, int32(-4), m.Scale()) + + for _, pair := range []expectMapping{ + {float64(0x1), 0}, + {float64(0x10), 0}, + {float64(0x100), 0}, + {float64(0x1000), 0}, + {float64(0x10000), 1}, // Base == 2**16 + {float64(0x100000), 1}, + {float64(0x1000000), 1}, + {float64(0x10000000), 1}, + {float64(0x100000000), 2}, // == 2**32 + {float64(0x1000000000), 2}, + {float64(0x10000000000), 2}, + {float64(0x100000000000), 2}, + {float64(0x1000000000000), 3}, // 2**48 + {float64(0x10000000000000), 3}, + {float64(0x100000000000000), 3}, + {float64(0x1000000000000000), 3}, + {float64(0x10000000000000000), 4}, // 2**64 + {float64(0x100000000000000000), 4}, + {float64(0x1000000000000000000), 4}, + {float64(0x10000000000000000000), 4}, + {float64(0x100000000000000000000), 5}, + + {1 / float64(0x1), 0}, + {1 / float64(0x10), -1}, + {1 / float64(0x100), -1}, + {1 / float64(0x1000), -1}, + {1 / float64(0x10000), -1}, // 2**-16 + {1 / float64(0x100000), -2}, + {1 / float64(0x1000000), -2}, + {1 / float64(0x10000000), -2}, + {1 / float64(0x100000000), -2}, // 2**-32 + {1 / float64(0x1000000000), -3}, + {1 / float64(0x10000000000), -3}, + {1 / float64(0x100000000000), -3}, + {1 / float64(0x1000000000000), -3}, // 2**-48 + {1 / float64(0x10000000000000), -4}, + {1 / float64(0x100000000000000), -4}, + {1 / float64(0x1000000000000000), -4}, + {1 / float64(0x10000000000000000), -4}, // 2**-64 + {1 / float64(0x100000000000000000), -5}, + + // Max values + {0x1.FFFFFFFFFFFFFp1023, 63}, + {0x1p1023, 63}, + {0x1p1019, 63}, + {0x1p1008, 63}, + {0x1p1007, 62}, + {0x1p1000, 62}, + {0x1p0992, 62}, + {0x1p0991, 61}, + + // Min and subnormal values + {0x1p-1074, -64}, + {0x1p-1073, -64}, + {0x1p-1072, -64}, + {0x1p-1057, -64}, + {0x1p-1056, -64}, + {0x1p-1041, -64}, + {0x1p-1040, -64}, + {0x1p-1025, -64}, + {0x1p-1024, -64}, + {0x1p-1023, -64}, + {0x1p-1022, -64}, + {0x1p-1009, -64}, + {0x1p-1008, -63}, + {0x1p-0993, -63}, + {0x1p-0992, -62}, + {0x1p-0977, -62}, + {0x1p-0976, -61}, + } { + t.Run(fmt.Sprintf("%x", pair.value), func(t *testing.T) { + index := m.MapToIndex(pair.value) + + require.Equal(t, pair.index, index, "value: %#x", pair.value) + }) + } +} + +// roundedBoundary computes the correct boundary rounded to a float64 +// using math/big. Note that this function uses a Square() where the +// one in ../logarithm uses a SquareRoot(). +func roundedBoundary(scale, index int32) float64 { + one := big.NewFloat(1) + f := (&big.Float{}).SetMantExp(one, int(index)) + for i := scale; i < 0; i++ { + f = (&big.Float{}).Mul(f, f) + } + + result, _ := f.Float64() + return result +} + +// TestExponentIndexMax ensures that for every valid scale, MaxFloat +// maps into the correct maximum index. Also tests that the reverse +// lookup does not produce infinity and the following index produces +// an overflow error. +func TestExponentIndexMax(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + index := m.MapToIndex(MaxValue) + + // Correct max index is one less than the first index + // that overflows math.MaxFloat64, i.e., one less than + // the index of +Inf. + maxIndex := (int32(MaxNormalExponent+1) >> -scale) - 1 + require.Equal(t, index, int32(maxIndex)) + + // The index maps to a finite boundary. + bound, err := m.LowerBoundary(index) + require.NoError(t, err) + + require.Equal(t, bound, roundedBoundary(scale, maxIndex)) + + // One larger index will overflow. + _, err = m.LowerBoundary(index + 1) + require.Equal(t, err, mapping.ErrOverflow) + } +} + +// TestExponentIndexMin ensures that for every valid scale, the +// smallest normal number and all smaller numbers map to the correct +// index, which is that of the smallest normal number. +// +// Tests that the lower boundary of the smallest bucket is correct, +// even when that number is subnormal. +func TestExponentIndexMin(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + minIndex := m.MapToIndex(MinValue) + + boundary, err := m.LowerBoundary(minIndex) + require.NoError(t, err) + + correctMinIndex := int64(MinNormalExponent) >> -scale + require.Greater(t, correctMinIndex, int64(math.MinInt32)) + require.Equal(t, int32(correctMinIndex), minIndex) + + correctBoundary := roundedBoundary(scale, int32(correctMinIndex)) + + require.Equal(t, correctBoundary, boundary) + require.Greater(t, roundedBoundary(scale, int32(correctMinIndex+1)), boundary) + + // Subnormal values map to the min index: + require.Equal(t, m.MapToIndex(MinValue/2), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(MinValue/3), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(MinValue/100), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1050), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1073), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1.1p-1073), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1074), int32(correctMinIndex)) + + // One smaller index will underflow. + _, err = m.LowerBoundary(minIndex - 1) + require.Equal(t, err, mapping.ErrUnderflow) + } +} diff --git a/sdk/metric/aggregator/exponential/mapping/exponent/float64.go b/sdk/metric/aggregator/exponential/mapping/exponent/float64.go new file mode 100644 index 00000000000..6deb81192de --- /dev/null +++ b/sdk/metric/aggregator/exponential/mapping/exponent/float64.go @@ -0,0 +1,69 @@ +// Copyright The OpenTelemetry 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package exponent // import "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping/exponent" + +import "math" + +const ( + // SignificandWidth is the size of an IEEE 754 double-precision + // floating-point significand. + SignificandWidth = 52 + // ExponentWidth is the size of an IEEE 754 double-precision + // floating-point exponent. + ExponentWidth = 11 + + // SignificandMask is the mask for the significand of an IEEE 754 + // double-precision floating-point value: 0xFFFFFFFFFFFFF. + SignificandMask = 1<> SignificandWidth + return int32(rawExponent - ExponentBias) +} diff --git a/sdk/metric/aggregator/exponential/mapping/logarithm/logarithm.go b/sdk/metric/aggregator/exponential/mapping/logarithm/logarithm.go new file mode 100644 index 00000000000..223c93d16a9 --- /dev/null +++ b/sdk/metric/aggregator/exponential/mapping/logarithm/logarithm.go @@ -0,0 +1,171 @@ +// Copyright The OpenTelemetry 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logarithm // import "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping/logarithm" + +import ( + "fmt" + "math" + "sync" + + "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping" + "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping/exponent" +) + +const ( + // MinScale ensures that the ../exponent mapper is used for + // zero and negative scale values. Do not use the logarithm + // mapper for scales <= 0. + MinScale int32 = 1 + + // MaxScale is selected as the largest scale that is possible + // in current code, considering there are 10 bits of base-2 + // exponent combined with scale-bits of range. At this scale, + // the growth factor is 0.0000661%. + // + // Scales larger than 20 complicate the logic in cmd/prebuild, + // because math/big overflows when exponent is math.MaxInt32 + // (== the index of math.MaxFloat64 at scale=21), + // + // At scale=20, index values are in the interval [-0x3fe00000, + // 0x3fffffff], having 31 bits of information. This is + // sensible given that the OTLP exponential histogram data + // point uses a signed 32 bit integer for indices. + MaxScale int32 = 20 + + // MaxValue is the largest normal number. + MaxValue = math.MaxFloat64 + + // MinValue is the smallest normal number. + MinValue = 0x1p-1022 +) + +// logarithmMapping contains the constants used to implement the +// exponential mapping function for a particular scale > 0. Note that +// these structs are compiled in using code generated by the +// ./cmd/prebuild package, this way no allocations are required as the +// aggregators switch between mapping functions and the two mapping +// functions are kept separate. +// +// Note that some of these fields could be calculated easily at +// runtime, but they are compiled in to avoid those operations at +// runtime (e.g., calls to math.Ldexp(math.Log2E, scale) for every +// measurement). +type logarithmMapping struct { + // scale is between MinScale and MaxScale + scale int32 + + // minIndex is the index of MinValue + minIndex int32 + // maxIndex is the index of MaxValue + maxIndex int32 + + // scaleFactor is used and computed as follows: + // index = log(value) / log(base) + // = log(value) / log(2^(2^-scale)) + // = log(value) / (2^-scale * log(2)) + // = log(value) * (1/log(2) * 2^scale) + // = log(value) * scaleFactor + // where: + // scaleFactor = (1/log(2) * 2^scale) + // = math.Log2E * math.Exp2(scale) + // = math.Ldexp(math.Log2E, scale) + // Because multiplication is faster than division, we define scaleFactor as a multiplier. + // This implementation was copied from a Java prototype. See: + // https://github.com/newrelic-experimental/newrelic-sketch-java/blob/1ce245713603d61ba3a4510f6df930a5479cd3f6/src/main/java/com/newrelic/nrsketch/indexer/LogIndexer.java + // for the equations used here. + scaleFactor float64 + + // log(boundary) = index * log(base) + // log(boundary) = index * log(2^(2^-scale)) + // log(boundary) = index * 2^-scale * log(2) + // boundary = exp(index * inverseFactor) + // where: + // inverseFactor = 2^-scale * log(2) + // = math.Ldexp(math.Ln2, -scale) + inverseFactor float64 +} + +var ( + _ mapping.Mapping = &logarithmMapping{} + + prebuiltMappingsLock sync.Mutex + prebuiltMappings = map[int32]*logarithmMapping{} +) + +// NewMapping constructs a logarithm mapping function, used for scales > 0. +func NewMapping(scale int32) (mapping.Mapping, error) { + // An assumption used in this code is that scale is > 0. If + // scale is <= 0 it's better to use the exponent mapping. + if scale < MinScale || scale > MaxScale { + // scale 20 can represent the entire float64 range + // with a 30 bit index, and we don't handle larger + // scales to simplify range tests in this package. + return nil, fmt.Errorf("scale out of bounds") + } + prebuiltMappingsLock.Lock() + defer prebuiltMappingsLock.Unlock() + + if p := prebuiltMappings[scale]; p != nil { + return p, nil + } + l := &logarithmMapping{ + scale: scale, + maxIndex: int32((int64(exponent.MaxNormalExponent+1) << scale) - 1), + minIndex: int32(int64(exponent.MinNormalExponent) << scale), + scaleFactor: math.Ldexp(math.Log2E, int(scale)), + inverseFactor: math.Ldexp(math.Ln2, int(-scale)), + } + prebuiltMappings[scale] = l + return l, nil +} + +// MapToIndex implements mapping.Mapping. +func (l *logarithmMapping) MapToIndex(value float64) int32 { + // Note: we can assume not a 0, Inf, or NaN; positive sign bit. + if value <= MinValue { + return l.minIndex + } + // Use Floor() to round toward 0. + index := int32(math.Floor(math.Log(value) * l.scaleFactor)) + + if index > l.maxIndex { + return l.maxIndex + } + return index +} + +// LowerBoundary implements mapping.Mapping. +func (l *logarithmMapping) LowerBoundary(index int32) (float64, error) { + if index >= l.maxIndex { + if index == l.maxIndex { + // Note that the equation on the last line of this + // function returns +Inf. Use the alternate equation. + return 2 * math.Exp(float64(index-(int32(1)< 0; i-- { + f = (&big.Float{}).Sqrt(f) + } + + result, _ := f.Float64() + return result +} + +// TestLogarithmIndexMax ensures that for every valid scale, MaxFloat +// maps into the correct maximum index. Also tests that the reverse +// lookup does not produce infinity and the following index produces +// an overflow error. +func TestLogarithmIndexMax(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + index := m.MapToIndex(MaxValue) + + // Correct max index is one less than the first index + // that overflows math.MaxFloat64, i.e., one less than + // the index of +Inf. + maxIndex64 := (int64(exponent.MaxNormalExponent+1) << scale) - 1 + require.Less(t, maxIndex64, int64(math.MaxInt32)) + require.Equal(t, index, int32(maxIndex64)) + + // The index maps to a finite boundary near MaxFloat. + bound, err := m.LowerBoundary(index) + require.NoError(t, err) + + base, _ := m.LowerBoundary(1) + + require.Less(t, bound, MaxValue) + + // The expected ratio equals the base factor. + require.InEpsilon(t, (MaxValue-bound)/bound, base-1, 1e-6) + + // One larger index will overflow. + _, err = m.LowerBoundary(index + 1) + require.Equal(t, err, mapping.ErrOverflow) + } +} + +// TestLogarithmIndexMin ensures that for every valid scale, Non-zero numbers +func TestLogarithmIndexMin(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + minIndex := m.MapToIndex(MinValue) + + mapped, err := m.LowerBoundary(minIndex) + require.NoError(t, err) + + correctMinIndex := int64(exponent.MinNormalExponent) << scale + require.Greater(t, correctMinIndex, int64(math.MinInt32)) + + correctMapped := roundedBoundary(scale, int32(correctMinIndex)) + require.Equal(t, correctMapped, MinValue) + require.InEpsilon(t, mapped, MinValue, 1e-6) + + require.Equal(t, minIndex, int32(correctMinIndex)) + + // Subnormal values map to the min index: + require.Equal(t, m.MapToIndex(MinValue/2), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(MinValue/3), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(MinValue/100), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1050), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1073), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1.1p-1073), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1074), int32(correctMinIndex)) + + // One smaller index will underflow. + _, err = m.LowerBoundary(minIndex - 1) + require.Equal(t, err, mapping.ErrUnderflow) + } +} + +// TestExponentIndexMax ensures that for every valid scale, MaxFloat +// maps into the correct maximum index. Also tests that the reverse +// lookup does not produce infinity and the following index produces +// an overflow error. +func TestExponentIndexMax(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + index := m.MapToIndex(MaxValue) + + // Correct max index is one less than the first index + // that overflows math.MaxFloat64, i.e., one less than + // the index of +Inf. + maxIndex64 := (int64(exponent.MaxNormalExponent+1) << scale) - 1 + require.Less(t, maxIndex64, int64(math.MaxInt32)) + require.Equal(t, index, int32(maxIndex64)) + + // The index maps to a finite boundary near MaxFloat. + bound, err := m.LowerBoundary(index) + require.NoError(t, err) + + base, _ := m.LowerBoundary(1) + + require.Less(t, bound, MaxValue) + + // The expected ratio equals the base factor. + require.InEpsilon(t, (MaxValue-bound)/bound, base-1, 1e-6) + + // One larger index will overflow. + _, err = m.LowerBoundary(index + 1) + require.Equal(t, err, mapping.ErrOverflow) + } +} + +// TestExponentIndexMin ensures that for every valid scale, the +// smallest normal number and all smaller numbers map to the correct +// index, which is that of the smallest normal number. +func TestExponentIndexMin(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + minIndex := m.MapToIndex(MinValue) + + mapped, err := m.LowerBoundary(minIndex) + require.NoError(t, err) + + correctMinIndex := int64(exponent.MinNormalExponent) << scale + require.Greater(t, correctMinIndex, int64(math.MinInt32)) + + correctMapped := roundedBoundary(scale, int32(correctMinIndex)) + require.Equal(t, correctMapped, MinValue) + require.InEpsilon(t, mapped, MinValue, 1e-6) + + require.Equal(t, minIndex, int32(correctMinIndex)) + + // Subnormal values map to the min index: + require.Equal(t, m.MapToIndex(MinValue/2), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(MinValue/3), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(MinValue/100), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1050), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1073), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1.1p-1073), int32(correctMinIndex)) + require.Equal(t, m.MapToIndex(0x1p-1074), int32(correctMinIndex)) + + // One smaller index will underflow. + _, err = m.LowerBoundary(minIndex - 1) + require.Equal(t, err, mapping.ErrUnderflow) + } +} diff --git a/sdk/metric/aggregator/exponential/mapping/mapping.go b/sdk/metric/aggregator/exponential/mapping/mapping.go new file mode 100644 index 00000000000..19bf9df72d1 --- /dev/null +++ b/sdk/metric/aggregator/exponential/mapping/mapping.go @@ -0,0 +1,48 @@ +// Copyright The OpenTelemetry 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mapping // import "go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping" + +import "fmt" + +// Mapping is the interface of an exponential histogram mapper. +type Mapping interface { + // MapToIndex maps positive floating point values to indexes + // corresponding to Scale(). Implementations are not expected + // to handle zeros, +Inf, NaN, or negative values. + MapToIndex(value float64) int32 + + // LowerBoundary returns the lower boundary of a given bucket + // index. The index is expected to map onto a range that is + // at least partially inside the range of normalized floating + // point values. If the corresponding bucket's upper boundary + // is less than or equal to 0x1p-1022, ErrUnderflow will be + // returned. If the corresponding bucket's lower boundary is + // greater than math.MaxFloat64, ErrOverflow will be returned. + LowerBoundary(index int32) (float64, error) + + // Scale returns the parameter that controls the resolution of + // this mapping. For details see: + // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/datamodel.md#exponential-scale + Scale() int32 +} + +var ( + // ErrUnderflow is returned when computing the lower boundary + // of an index that maps into a denormalized floating point value. + ErrUnderflow = fmt.Errorf("underflow") + // ErrOverflow is returned when computing the lower boundary + // of an index that maps into +Inf. + ErrOverflow = fmt.Errorf("overflow") +)