1
0
mirror of https://github.com/open-telemetry/opentelemetry-go.git synced 2025-07-15 01:04:25 +02:00

Exponential Histogram mapping functions for public use (#2502)

* Exponential Histogram mapping functions for public use

* pr num

* Apply suggestions from code review

👍

Co-authored-by: Anthony Mirabella <a9@aneurysm9.com>

* mapping interface comments

* missed add

* Update CHANGELOG.md

Co-authored-by: Anthony Mirabella <a9@aneurysm9.com>
Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>
This commit is contained in:
Joshua MacDonald
2022-03-22 12:44:10 -07:00
committed by GitHub
parent 8a7dcd9650
commit 07ad32dc38
8 changed files with 996 additions and 0 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 - 1
// ExponentBias is the exponent bias specified for encoding
// the IEEE 754 double-precision floating point exponent: 1023
ExponentBias = 1<<(ExponentWidth-1) - 1
// ExponentMask are set to 1 for the bits of an IEEE 754
// floating point exponent: 0x7FF0000000000000
ExponentMask = ((1 << ExponentWidth) - 1) << SignificandWidth
// SignMask selects the sign bit of an IEEE 754 floating point
// number.
SignMask = (1 << (SignificandWidth + ExponentWidth))
// MinNormalExponent is the minimum exponent of a normalized
// floating point: -1022
MinNormalExponent int32 = -ExponentBias + 1
// MaxNormalExponent is the maximum exponent of a normalized
// floating point: 1023
MaxNormalExponent int32 = ExponentBias
// MinValue is the smallest normal number.
MinValue = 0x1p-1022
// MaxValue is the largest normal number.
MaxValue = math.MaxFloat64
)
// getBase2 extracts the normalized base-2 fractional exponent. Like
// math.Frexp(), rounds subnormal values up to the minimum normal
// value. Unlike Frexp(), this returns k for the equation f x 2**k
// where f is in the range [1, 2).
func getBase2(value float64) int32 {
if value <= MinValue {
return MinNormalExponent
}
rawBits := math.Float64bits(value)
rawExponent := (int64(rawBits) & ExponentMask) >> SignificandWidth
return int32(rawExponent - ExponentBias)
}

View File

@ -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)<<l.scale))*l.inverseFactor), nil
}
return 0, mapping.ErrOverflow
}
if index <= l.minIndex {
if index == l.minIndex {
return MinValue, nil
}
return 0, mapping.ErrUnderflow
}
return math.Exp(float64(index) * l.inverseFactor), nil
}
// Scale implements mapping.Mapping.
func (l *logarithmMapping) Scale() int32 {
return l.scale
}

View File

@ -0,0 +1,250 @@
// 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 (
"fmt"
"math"
"math/big"
"testing"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping"
"go.opentelemetry.io/otel/sdk/metric/aggregator/exponential/mapping/exponent"
)
type expectMapping struct {
value float64
index int32
}
// Tests an invalid scale.
func TestInvalidScale(t *testing.T) {
_, err := NewMapping(-1)
require.Error(t, err)
}
// Tests a few values are mapped correctly at scale 1, where the
// exponentiation factor is SquareRoot(2).
func TestLogarithmMapping(t *testing.T) {
// Scale 1 means 1 division between every power of two, having
// a factor sqrt(2) times the lower boundary.
m, err := NewMapping(+1)
require.NoError(t, err)
require.Equal(t, int32(+1), m.Scale())
// Note: Do not test exact boundaries, with the exception of
// 1, because we expect errors in that case (e.g.,
// MapToIndex(8) returns 5, an off-by-one. See the following
// test.
for _, pair := range []expectMapping{
{15, 7},
{9, 6},
{7, 5},
{5, 4},
{3, 3},
{2.5, 2},
{1.5, 1},
{1.2, 0},
{1, 0},
{0.75, -1},
{0.55, -2},
{0.45, -3},
} {
idx := m.MapToIndex(pair.value)
require.Equal(t, pair.index, idx, "value: %v", pair.value)
}
}
// Tests the mapping function for correctness-within-epsilon for a few
// scales and index values.
func TestLogarithmBoundary(t *testing.T) {
for _, scale := range []int32{1, 2, 3, 4, 10, 15} {
t.Run(fmt.Sprint(scale), func(t *testing.T) {
m, _ := NewMapping(scale)
for _, index := range []int32{-100, -10, -1, 0, 1, 10, 100} {
t.Run(fmt.Sprint(index), func(t *testing.T) {
lowBoundary, err := m.LowerBoundary(index)
require.NoError(t, err)
mapped := m.MapToIndex(lowBoundary)
// At or near the boundary expected to be off-by-one sometimes.
require.LessOrEqual(t, index-1, mapped)
require.GreaterOrEqual(t, index, mapped)
// The values should be very close.
require.InEpsilon(t, lowBoundary, roundedBoundary(scale, index), 1e-9)
})
}
})
}
}
// roundedBoundary computes the correct boundary rounded to a float64
// using math/big. Note that this function uses a SquareRoot() where the
// one in ../exponent uses a Square().
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{}).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)
}
}

View File

@ -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")
)