From ad89d961676a14c2c3cebb17f4a16da19d0e7c08 Mon Sep 17 00:00:00 2001 From: Joshua MacDonald Date: Thu, 29 Sep 2022 09:06:28 -0700 Subject: [PATCH] Restore the exponential mapping functions in sdk/metric/metricdata/exponential/mapping --- sdk/metric/metricdata/exponential/README.md | 32 ++ .../exponential/mapping/exponent/exponent.go | 127 +++++++ .../mapping/exponent/exponent_test.go | 341 ++++++++++++++++++ .../exponential/mapping/internal/float64.go | 72 ++++ .../mapping/internal/float64_test.go | 47 +++ .../mapping/logarithm/logarithm.go | 190 ++++++++++ .../mapping/logarithm/logarithm_test.go | 231 ++++++++++++ .../metricdata/exponential/mapping/mapping.go | 48 +++ 8 files changed, 1088 insertions(+) create mode 100644 sdk/metric/metricdata/exponential/README.md create mode 100644 sdk/metric/metricdata/exponential/mapping/exponent/exponent.go create mode 100644 sdk/metric/metricdata/exponential/mapping/exponent/exponent_test.go create mode 100644 sdk/metric/metricdata/exponential/mapping/internal/float64.go create mode 100644 sdk/metric/metricdata/exponential/mapping/internal/float64_test.go create mode 100644 sdk/metric/metricdata/exponential/mapping/logarithm/logarithm.go create mode 100644 sdk/metric/metricdata/exponential/mapping/logarithm/logarithm_test.go create mode 100644 sdk/metric/metricdata/exponential/mapping/mapping.go diff --git a/sdk/metric/metricdata/exponential/README.md b/sdk/metric/metricdata/exponential/README.md new file mode 100644 index 00000000000..f4e2bf5c8ac --- /dev/null +++ b/sdk/metric/metricdata/exponential/README.md @@ -0,0 +1,32 @@ +# Base-2 Exponential Histogram + +## Design + +This document is a placeholder for the future when this directory +contains both the mapping and data structure of the OpenTelemetry +Exponential Histogram. The complete prototype for this data structure +and the SDK integration has been seen in draft PRs +[2393](https://github.com/open-telemetry/opentelemetry-go/pull/2393), +[3022](https://github.com/open-telemetry/opentelemetry-go/pull/3022), +and [3174](https://github.com/open-telemetry/opentelemetry-go/pull/3174). + +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/data-model.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/metricdata/exponential/mapping/exponent/exponent.go b/sdk/metric/metricdata/exponential/mapping/exponent/exponent.go new file mode 100644 index 00000000000..3b5750b4e2d --- /dev/null +++ b/sdk/metric/metricdata/exponential/mapping/exponent/exponent.go @@ -0,0 +1,127 @@ +// 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/metricdata/exponential/mapping/exponent" + +import ( + "fmt" + "math" + + "go.opentelemetry.io/otel/sdk/metric/metricdata/exponential/mapping" + "go.opentelemetry.io/otel/sdk/metric/metricdata/exponential/mapping/internal" +) + +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 +} + +// minNormalLowerBoundaryIndex is the largest index such that +// base**index is <= MinValue. A histogram bucket with this index +// covers the range (base**index, base**(index+1)], including +// MinValue. +func (e *exponentMapping) minNormalLowerBoundaryIndex() int32 { + idx := int32(internal.MinNormalExponent) >> e.shift + if e.shift < 2 { + // For scales -1 and 0 the minimum value 2**-1022 + // is a power-of-two multiple, meaning it belongs + // to the index one less. + idx-- + } + return idx +} + +// maxNormalLowerBoundaryIndex is the index such that base**index +// equals the largest representable boundary. A histogram bucket with this +// index covers the range (0x1p+1024/base, 0x1p+1024], which includes +// MaxValue; note that this bucket is incomplete, since the upper +// boundary cannot be represented. One greater than this index +// corresponds with the bucket containing values > 0x1p1024. +func (e *exponentMapping) maxNormalLowerBoundaryIndex() int32 { + return int32(internal.MaxNormalExponent) >> e.shift +} + +// MapToIndex implements mapping.Mapping. +func (e *exponentMapping) MapToIndex(value float64) int32 { + // Note: we can assume not a 0, Inf, or NaN; positive sign bit. + if value < internal.MinValue { + return e.minNormalLowerBoundaryIndex() + } + + // Extract the raw exponent. + rawExp := internal.GetNormalBase2(value) + + // In case the value is an exact power of two, compute a + // correction of -1: + correction := int32((internal.GetSignificand(value) - 1) >> internal.SignificandWidth) + + // Note: bit-shifting does the right thing for negative + // exponents, e.g., -1 >> 1 == -1. + return (rawExp + correction) >> e.shift +} + +// LowerBoundary implements mapping.Mapping. +func (e *exponentMapping) LowerBoundary(index int32) (float64, error) { + if min := e.minNormalLowerBoundaryIndex(); index < min { + return 0, mapping.ErrUnderflow + } + + if max := e.maxNormalLowerBoundaryIndex(); index > max { + return 0, mapping.ErrOverflow + } + + return math.Ldexp(1, int(index<> -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) + + // Test the smallest normal value. + minIndex := m.MapToIndex(MinValue) + + boundary, err := m.LowerBoundary(minIndex) + require.NoError(t, err) + + // The correct index for MinValue depends on whether + // 2**(-scale) evenly divides -1022. This is true for + // scales -1 and 0. + correctMinIndex := int64(MinNormalExponent) >> -scale + if MinNormalExponent%(int32(1)<<-scale) == 0 { + correctMinIndex-- + } + + 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, int32(correctMinIndex), m.MapToIndex(MinValue/2)) + require.Equal(t, int32(correctMinIndex), m.MapToIndex(MinValue/3)) + require.Equal(t, int32(correctMinIndex), m.MapToIndex(MinValue/100)) + require.Equal(t, int32(correctMinIndex), m.MapToIndex(0x1p-1050)) + require.Equal(t, int32(correctMinIndex), m.MapToIndex(0x1p-1073)) + require.Equal(t, int32(correctMinIndex), m.MapToIndex(0x1.1p-1073)) + require.Equal(t, int32(correctMinIndex), m.MapToIndex(0x1p-1074)) + + // One smaller index will underflow. + _, err = m.LowerBoundary(minIndex - 1) + require.Equal(t, err, mapping.ErrUnderflow) + + // Next value above MinValue (not a power of two). + minPlus1Index := m.MapToIndex(math.Nextafter(MinValue, math.Inf(+1))) + + // The following boundary equation always works for + // non-powers of two (same as correctMinIndex before its + // power-of-two correction, above). + correctMinPlus1Index := int64(MinNormalExponent) >> -scale + require.Equal(t, int32(correctMinPlus1Index), minPlus1Index) + } +} diff --git a/sdk/metric/metricdata/exponential/mapping/internal/float64.go b/sdk/metric/metricdata/exponential/mapping/internal/float64.go new file mode 100644 index 00000000000..9686f7c62cc --- /dev/null +++ b/sdk/metric/metricdata/exponential/mapping/internal/float64.go @@ -0,0 +1,72 @@ +// 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 internal // import "go.opentelemetry.io/otel/sdk/metricdata/exponential/mapping/internal" + +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) +} + +// GetSignificand returns the 52 bit (unsigned) significand as a +// signed value. +func GetSignificand(value float64) int64 { + return int64(math.Float64bits(value)) & SignificandMask +} diff --git a/sdk/metric/metricdata/exponential/mapping/internal/float64_test.go b/sdk/metric/metricdata/exponential/mapping/internal/float64_test.go new file mode 100644 index 00000000000..7c86391744c --- /dev/null +++ b/sdk/metric/metricdata/exponential/mapping/internal/float64_test.go @@ -0,0 +1,47 @@ +// 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 internal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Tests that GetNormalBase2 returns the base-2 exponent as documented, unlike +// math.Frexp. +func TestGetNormalBase2(t *testing.T) { + require.Equal(t, int32(-1022), MinNormalExponent) + require.Equal(t, int32(+1023), MaxNormalExponent) + + require.Equal(t, MaxNormalExponent, GetNormalBase2(0x1p+1023)) + require.Equal(t, int32(1022), GetNormalBase2(0x1p+1022)) + + require.Equal(t, int32(0), GetNormalBase2(1)) + + require.Equal(t, int32(-1021), GetNormalBase2(0x1p-1021)) + require.Equal(t, int32(-1022), GetNormalBase2(0x1p-1022)) + + // Subnormals below this point + require.Equal(t, int32(-1023), GetNormalBase2(0x1p-1023)) + require.Equal(t, int32(-1023), GetNormalBase2(0x1p-1024)) + require.Equal(t, int32(-1023), GetNormalBase2(0x1p-1025)) + require.Equal(t, int32(-1023), GetNormalBase2(0x1p-1074)) +} + +func TestGetSignificand(t *testing.T) { + // The number 1.5 has a single most-significant bit set, i.e., 1<<51. + require.Equal(t, int64(1)<<(SignificandWidth-1), GetSignificand(1.5)) +} diff --git a/sdk/metric/metricdata/exponential/mapping/logarithm/logarithm.go b/sdk/metric/metricdata/exponential/mapping/logarithm/logarithm.go new file mode 100644 index 00000000000..4bc39d67be5 --- /dev/null +++ b/sdk/metric/metricdata/exponential/mapping/logarithm/logarithm.go @@ -0,0 +1,190 @@ +// 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/metricdata/exponential/mapping/logarithm" + +import ( + "fmt" + "math" + "sync" + + "go.opentelemetry.io/otel/sdk/metric/metricdata/exponential/mapping" + "go.opentelemetry.io/otel/sdk/metric/metricdata/exponential/mapping/internal" +) + +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 + + // MinValue is the smallest normal number. + MinValue = internal.MinValue + + // MaxValue is the largest normal number. + MaxValue = internal.MaxValue +) + +// logarithmMapping contains the constants used to implement the +// exponential mapping function for a particular scale > 0. +type logarithmMapping struct { + // scale is between MinScale and MaxScale. The exponential + // base is defined as 2**(2**(-scale)). + scale 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, + scaleFactor: math.Ldexp(math.Log2E, int(scale)), + inverseFactor: math.Ldexp(math.Ln2, int(-scale)), + } + prebuiltMappings[scale] = l + return l, nil +} + +// minNormalLowerBoundaryIndex is the index such that base**index equals +// MinValue. A histogram bucket with this index covers the range +// (MinValue, MinValue*base]. One less than this index corresponds +// with the bucket containing values <= MinValue. +func (l *logarithmMapping) minNormalLowerBoundaryIndex() int32 { + return int32(internal.MinNormalExponent << l.scale) +} + +// maxNormalLowerBoundaryIndex is the index such that base**index equals the +// greatest representable lower boundary. A histogram bucket with this +// index covers the range (0x1p+1024/base, 0x1p+1024], which includes +// MaxValue; note that this bucket is incomplete, since the upper +// boundary cannot be represented. One greater than this index +// corresponds with the bucket containing values > 0x1p1024. +func (l *logarithmMapping) maxNormalLowerBoundaryIndex() int32 { + return (int32(internal.MaxNormalExponent+1) << l.scale) - 1 +} + +// 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.minNormalLowerBoundaryIndex() - 1 + } + + // Exact power-of-two correctness: an optional special case. + if internal.GetSignificand(value) == 0 { + exp := internal.GetNormalBase2(value) + return (exp << l.scale) - 1 + } + + // Non-power of two cases. Use Floor(x) to round the scaled + // logarithm. We could use Ceil(x)-1 to achieve the same + // result, though Ceil() is typically defined as -Floor(-x) + // and typically not performed in hardware, so this is likely + // less code. + index := int32(math.Floor(math.Log(value) * l.scaleFactor)) + + if max := l.maxNormalLowerBoundaryIndex(); index >= max { + return max + } + return index +} + +// LowerBoundary implements mapping.Mapping. +func (l *logarithmMapping) LowerBoundary(index int32) (float64, error) { + if max := l.maxNormalLowerBoundaryIndex(); index >= max { + if index == max { + // 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(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) + + // Two larger will overflow. + _, err = m.LowerBoundary(index + 2) + require.Equal(t, err, mapping.ErrOverflow) + } +} + +// TestLogarithmIndexMin ensures that for every valid scale, the +// smallest normal number and all smaller numbers map to the correct +// index. +func TestLogarithmIndexMin(t *testing.T) { + for scale := MinScale; scale <= MaxScale; scale++ { + m, err := NewMapping(scale) + require.NoError(t, err) + + minIndex := m.MapToIndex(MinValue) + + correctMinIndex := (int64(MinNormalExponent) << scale) - 1 + require.Greater(t, correctMinIndex, int64(math.MinInt32)) + require.Equal(t, minIndex, int32(correctMinIndex)) + + correctMapped := roundedBoundary(scale, int32(correctMinIndex)) + require.Less(t, correctMapped, MinValue) + + correctMappedUpper := roundedBoundary(scale, int32(correctMinIndex+1)) + require.Equal(t, correctMappedUpper, MinValue) + + mapped, err := m.LowerBoundary(minIndex + 1) + require.NoError(t, err) + require.InEpsilon(t, mapped, MinValue, 1e-6) + + // 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)) + + // All subnormal values map and MinValue to the min index: + mappedLower, err := m.LowerBoundary(minIndex) + require.NoError(t, err) + require.InEpsilon(t, correctMapped, mappedLower, 1e-6) + + // 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(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) + } +} diff --git a/sdk/metric/metricdata/exponential/mapping/mapping.go b/sdk/metric/metricdata/exponential/mapping/mapping.go new file mode 100644 index 00000000000..ceffbeef8c6 --- /dev/null +++ b/sdk/metric/metricdata/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/metricdata/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") +)