diff --git a/CHANGELOG.md b/CHANGELOG.md index c390c68fbb6..100ef78eaf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ For experimental package changes, see the [experimental CHANGELOG](experimental/ ### :rocket: (Enhancement) +* feat(sdk-metrics): add exponential histogram mapping functions [#3504](https://github.com/open-telemetry/opentelemetry-js/pull/3502) @mwear * feat(api): add `getActiveBaggage` API [#3385](https://github.com/open-telemetry/opentelemetry-js/pull/3385) * feat(instrumentation-grpc): set net.peer.name and net.peer.port on client spans [#3430](https://github.com/open-telemetry/opentelemetry-js/pull/3430) diff --git a/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ExponentMapping.ts b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ExponentMapping.ts new file mode 100644 index 00000000000..953cd81c1f5 --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ExponentMapping.ts @@ -0,0 +1,139 @@ +/* + * 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 + * + * https://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. + */ +import * as ieee754 from './ieee754'; +import * as util from '../util'; +import { Mapping, MappingError } from './types'; + +/** + * ExponentMapping implements a exponential mapping functions for + * for scales <=0. For scales > 0 LogarithmMapping should be used. + */ +export class ExponentMapping implements Mapping { + static readonly MIN_SCALE = -10; + static readonly MAX_SCALE = 0; + private static readonly _PREBUILT_MAPPINGS = [ + new ExponentMapping(10), + new ExponentMapping(9), + new ExponentMapping(8), + new ExponentMapping(7), + new ExponentMapping(6), + new ExponentMapping(5), + new ExponentMapping(4), + new ExponentMapping(3), + new ExponentMapping(2), + new ExponentMapping(1), + new ExponentMapping(0), + ]; + + /** + * Returns the pre-built mapping for the given scale + * @param scale An integer >= -10 and <= 0 + * @returns {ExponentMapping} + */ + public static get(scale: number) { + if (scale > ExponentMapping.MAX_SCALE) { + throw new MappingError( + `exponent mapping requires scale <= ${ExponentMapping.MAX_SCALE}` + ); + } + if (scale < ExponentMapping.MIN_SCALE) { + throw new MappingError( + `exponent mapping requires a scale > ${ExponentMapping.MIN_SCALE}` + ); + } + + return ExponentMapping._PREBUILT_MAPPINGS[ + scale - ExponentMapping.MIN_SCALE + ]; + } + + private constructor(private readonly _shift: number) {} + + /** + * Maps positive floating point values to indexes corresponding to scale + * @param value + * @returns {number} index for provided value at the current scale + */ + mapToIndex(value: number): number { + if (value < ieee754.MIN_VALUE) { + return this._minNormalLowerBoundaryIndex(); + } + + const exp = ieee754.getNormalBase2(value); + + // In case the value is an exact power of two, compute a + // correction of -1. Note, we are using a custom _rightShift + // to accomodate a 52-bit argument, which the native bitwise + // operators do not support + const correction = this._rightShift( + ieee754.getSignificand(value) - 1, + ieee754.SIGNIFICAND_WIDTH + ); + + return (exp + correction) >> this._shift; + } + + /** + * Returns the lower bucket boundary for the given index for scale + * + * @param index + * @returns {number} + */ + lowerBoundary(index: number): number { + const minIndex = this._minNormalLowerBoundaryIndex(); + if (index < minIndex) { + throw new MappingError( + `underflow: ${index} is < minimum lower boundary: ${minIndex}` + ); + } + const maxIndex = this._maxNormalLowerBoundaryIndex(); + if (index > maxIndex) { + throw new MappingError( + `overflow: ${index} is > maximum lower boundary: ${maxIndex}` + ); + } + + return util.ldexp(1, index << this._shift); + } + + /** + * The scale used by this mapping + * @returns {number} + */ + scale(): number { + if (this._shift === 0) { + return 0; + } + return -this._shift; + } + + private _minNormalLowerBoundaryIndex(): number { + let index = ieee754.MIN_NORMAL_EXPONENT >> this._shift; + if (this._shift < 2) { + index--; + } + + return index; + } + + private _maxNormalLowerBoundaryIndex(): number { + return ieee754.MAX_NORMAL_EXPONENT >> this._shift; + } + + private _rightShift(value: number, shift: number): number { + return Math.floor(value * Math.pow(2, -shift)); + } +} diff --git a/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/LogarithmMapping.ts b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/LogarithmMapping.ts new file mode 100644 index 00000000000..cf99f44508d --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/LogarithmMapping.ts @@ -0,0 +1,138 @@ +/* + * 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 + * + * https://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. + */ +import * as ieee754 from './ieee754'; +import * as util from '../util'; +import { Mapping, MappingError } from './types'; + +/** + * LogarithmMapping implements exponential mapping functions for scale > 0. + * For scales <= 0 the exponent mapping should be used. + */ +export class LogarithmMapping implements Mapping { + static readonly MIN_SCALE = 1; + static readonly MAX_SCALE = 20; + private static _PREBUILT_MAPPINGS = new Map(); + + /** + * get creates or returns a memoized logarithm mapping function for + * the given scale. used for scales > 0. + * @param scale - a number > 0 and <= 20 + * @returns {LogarithmMapping} + */ + static get(scale: number): LogarithmMapping { + if ( + scale > LogarithmMapping.MAX_SCALE || + scale < LogarithmMapping.MIN_SCALE + ) { + throw new MappingError( + `logarithm mapping requires scale in the range [${LogarithmMapping.MIN_SCALE}, ${LogarithmMapping.MAX_SCALE}]` + ); + } + + let mapping = this._PREBUILT_MAPPINGS.get(scale); + if (mapping) { + return mapping; + } + + mapping = new LogarithmMapping(scale); + this._PREBUILT_MAPPINGS.set(scale, mapping); + return mapping; + } + + private readonly _scale: number; + private readonly _scaleFactor: number; + private readonly _inverseFactor: number; + + private constructor(scale: number) { + this._scale = scale; + this._scaleFactor = util.ldexp(Math.LOG2E, scale); + this._inverseFactor = util.ldexp(Math.LN2, -scale); + } + + /** + * Maps positive floating point values to indexes corresponding to scale + * @param value + * @returns {number} index for provided value at the current scale + */ + mapToIndex(value: number): number { + if (value <= ieee754.MIN_VALUE) { + return this._minNormalLowerBoundaryIndex() - 1; + } + + // exact power of two special case + if (ieee754.getSignificand(value) === 0) { + const exp = ieee754.getNormalBase2(value); + return (exp << this._scale) - 1; + } + + // non-power of two cases. use Math.floor to round the scaled logarithm + const index = Math.floor(Math.log(value) * this._scaleFactor); + const maxIndex = this._maxNormalLowerBoundaryIndex(); + if (index >= maxIndex) { + return maxIndex; + } + + return index; + } + + /** + * Returns the lower bucket boundary for the given index for scale + * + * @param index + * @returns {number} + */ + lowerBoundary(index: number): number { + const maxIndex = this._maxNormalLowerBoundaryIndex(); + if (index >= maxIndex) { + if (index === maxIndex) { + return 2 * Math.exp((index - (1 << this._scale)) / this._scaleFactor); + } + throw new MappingError( + `overflow: ${index} is > maximum lower boundary: ${maxIndex}` + ); + } + + const minIndex = this._minNormalLowerBoundaryIndex(); + if (index <= minIndex) { + if (index === minIndex) { + return ieee754.MIN_VALUE; + } else if (index === minIndex - 1) { + return Math.exp((index + (1 << this._scale)) / this._scaleFactor) / 2; + } + throw new MappingError( + `overflow: ${index} is < minimum lower boundary: ${minIndex}` + ); + } + + return Math.exp(index * this._inverseFactor); + } + + /** + * The scale used by this mapping + * @returns {number} + */ + scale(): number { + return this._scale; + } + + private _minNormalLowerBoundaryIndex(): number { + return ieee754.MIN_NORMAL_EXPONENT << this._scale; + } + + private _maxNormalLowerBoundaryIndex(): number { + return ((ieee754.MAX_NORMAL_EXPONENT + 1) << this._scale) - 1; + } +} diff --git a/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ieee754.ts b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ieee754.ts new file mode 100644 index 00000000000..0dac0070ada --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ieee754.ts @@ -0,0 +1,91 @@ +/* + * 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 + * + * https://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. + */ +export const SIGNIFICAND_WIDTH = 52; + +/** + * EXPONENT_MASK is set to 1 for the hi 32-bits of an IEEE 754 + * floating point exponent: 0x7ff00000. + */ +const EXPONENT_MASK = 0x7ff00000; + +/** + * SIGNIFICAND_MASK is the mask for the significand portion of the hi 32-bits + * of an IEEE 754 double-precision floating-point value: 0xfffff + */ +const SIGNIFICAND_MASK = 0xfffff; + +/* + * EXPONENT_BIAS is the exponent bias specified for encoding + * the IEEE 754 double-precision floating point exponent: 1023 + */ +const EXPONENT_BIAS = 1023; + +/** + * MIN_NORMAL_EXPONENT is the minimum exponent of a normalized + * floating point: -1022. + */ +export const MIN_NORMAL_EXPONENT = -EXPONENT_BIAS + 1; + +/* + * MAX_NORMAL_EXPONENT is the maximum exponent of a normalized + * floating point: 1023. + */ +export const MAX_NORMAL_EXPONENT = EXPONENT_BIAS; + +/** + * MAX_VALUE is the largest normal number + */ +export const MAX_VALUE = Number.MAX_VALUE; + +/** + * MIN_VALUE is the smallest normal number + */ +export const MIN_VALUE = Math.pow(2, -1022); + +/** + * getNormalBase2 extracts the normalized base-2 fractional exponent. + * This returns k for the equation f x 2**k where f is + * in the range [1, 2). Note that this function is not called for + * subnormal numbers. + * @param {number} value - the value to determine normalized base-2 fractional + * exponent for + * @returns {number} the normalized base-2 exponent + */ +export function getNormalBase2(value: number): number { + const dv = new DataView(new ArrayBuffer(8)); + dv.setFloat64(0, value); + // access the raw 64-bit float as 32-bit uints + const hiBits = dv.getUint32(0); + const expBits = (hiBits & EXPONENT_MASK) >> 20; + return expBits - EXPONENT_BIAS; +} + +/** + * GetSignificand returns the 52 bit (unsigned) significand as a signed value. + * @param {number} value - the floating point number to extract the significand from + * @returns {number} The 52-bit significand + */ +export function getSignificand(value: number): number { + const dv = new DataView(new ArrayBuffer(8)); + dv.setFloat64(0, value); + // access the raw 64-bit float as two 32-bit uints + const hiBits = dv.getUint32(0); + const loBits = dv.getUint32(4); + // extract the significand bits from the hi bits and left shift 32 places + const significandHiBits = (hiBits & SIGNIFICAND_MASK) * Math.pow(2, 32); + // combine the hi and lo bits and return + return significandHiBits + loBits; +} diff --git a/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/types.ts b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/types.ts new file mode 100644 index 00000000000..d54d778131f --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/types.ts @@ -0,0 +1,22 @@ +/* + * 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 + * + * https://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. + */ +export class MappingError extends Error {} + +export interface Mapping { + mapToIndex(value: number): number; + lowerBoundary(index: number): number; + scale(): number; +} diff --git a/packages/sdk-metrics/src/aggregator/exponential-histogram/util.ts b/packages/sdk-metrics/src/aggregator/exponential-histogram/util.ts new file mode 100644 index 00000000000..356bbab2601 --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/util.ts @@ -0,0 +1,40 @@ +/* + * 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 + * + * https://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. + */ + +/** + * Note: other languages provide this as a built in function. This is + * a naive, but functionally correct implementation. This is used sparingly, + * when creating a new mapping in a running application. + * + * ldexp returns frac × 2**exp. With the following special cases: + * ldexp(±0, exp) = ±0 + * ldexp(±Inf, exp) = ±Inf + * ldexp(NaN, exp) = NaN + * @param frac + * @param exp + * @returns {number} + */ +export function ldexp(frac: number, exp: number): number { + if ( + frac === 0 || + frac === Number.POSITIVE_INFINITY || + frac === Number.NEGATIVE_INFINITY || + Number.isNaN(frac) + ) { + return frac; + } + return frac * Math.pow(2, exp); +} diff --git a/packages/sdk-metrics/test/aggregator/exponential-histogram/ExponentMapping.test.ts b/packages/sdk-metrics/test/aggregator/exponential-histogram/ExponentMapping.test.ts new file mode 100644 index 00000000000..47b160a28d2 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/ExponentMapping.test.ts @@ -0,0 +1,309 @@ +/* + * 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 + * + * https://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. + */ +import { ExponentMapping } from '../../../src/aggregator/exponential-histogram/mapping/ExponentMapping'; +import * as ieee754 from '../../../src/aggregator/exponential-histogram/mapping/ieee754'; +import * as assert from 'assert'; + +describe('ExponentMapping', () => { + it('maps expected values for scale 0', () => { + const mapping = ExponentMapping.get(0); + assert.strictEqual(mapping.scale(), 0); + + const expectedMappings = [ + // near +inf + [Number.MAX_VALUE, ieee754.MAX_NORMAL_EXPONENT], + [Number.MAX_VALUE, 1023], + [Math.pow(2, 1023), 1022], + [1.0625 * Math.pow(2, 1023), 1023], + [Math.pow(2, 1022), 1021], + [1.0625 * Math.pow(2, 1023), 1023], + + // near 0 + [Math.pow(2, -1022), -1023], + [1.0625 * Math.pow(2, -1022), -1022], + [Math.pow(2, -1021), -1022], + [1.0625 * Math.pow(2, -1021), -1021], + + [Math.pow(2, -1022), ieee754.MIN_NORMAL_EXPONENT - 1], + [Math.pow(2, -1021), ieee754.MIN_NORMAL_EXPONENT], + [Number.MIN_VALUE, ieee754.MIN_NORMAL_EXPONENT - 1], + + // near 1 + [4, 1], + [3, 1], + [2, 0], + [1.5, 0], + [1, -1], + [0.75, -1], + [0.51, -1], + [0.5, -2], + [0.26, -2], + [0.25, -3], + [0.126, -3], + [0.125, -4], + ]; + + expectedMappings.forEach(([value, expected]) => { + const result = mapping.mapToIndex(value); + assert.strictEqual( + result, + expected, + `expected: ${value} to map to: ${expected}, got: ${result}` + ); + }); + }); + + it('maps expected values for min scale', () => { + const mapping = ExponentMapping.get(ExponentMapping.MIN_SCALE); + assert.strictEqual(mapping.scale(), ExponentMapping.MIN_SCALE); + + const expectedMappings = [ + [1.000001, 0], + [1, -1], + [Number.MAX_VALUE / 2, 0], + [Number.MAX_VALUE, 0], + [Number.MIN_VALUE, -1], + [0.5, -1], + ]; + + expectedMappings.forEach(([value, expected]) => { + const result = mapping.mapToIndex(value); + assert.strictEqual( + result, + expected, + `expected: ${value} to map to: ${expected}, got: ${result}` + ); + }); + }); + + it('maps expected values for scale -1', () => { + const mapping = ExponentMapping.get(-1); + assert.strictEqual(mapping.scale(), -1); + + const expectedMappings = [ + [17, 2], + [16, 1], + [15, 1], + [9, 1], + [8, 1], + [5, 1], + [4, 0], + [3, 0], + [2, 0], + [1.5, 0], + [1, -1], + [0.75, -1], + [0.5, -1], + [0.25, -2], + [0.2, -2], + [0.13, -2], + [0.125, -2], + [0.1, -2], + [0.0625, -3], + [0.06, -3], + ]; + + expectedMappings.forEach(([value, expected]) => { + const result = mapping.mapToIndex(value); + assert.strictEqual( + result, + expected, + `expected: ${value} to map to: ${expected}, got: ${result}` + ); + }); + }); + + it('maps expected values for scale -4', () => { + const mapping = ExponentMapping.get(-4); + assert.strictEqual(mapping.scale(), -4); + + const expectedMappings = [ + [0x1, -1], + [0x10, 0], + [0x100, 0], + [0x1000, 0], + [0x10000, 0], // Base == 2**16 + [0x100000, 1], + [0x1000000, 1], + [0x10000000, 1], + [0x100000000, 1], // == 2**32 + [0x1000000000, 2], + [0x10000000000, 2], + [0x100000000000, 2], + [0x1000000000000, 2], // 2**48 + [0x10000000000000, 3], + [0x1000000000000000, 3], + [0x10000000000000000, 3], // 2**64 + [0x100000000000000000, 4], + [0x1000000000000000000, 4], + [0x10000000000000000000, 4], + [0x100000000000000000000, 4], // 2**80 + [0x1000000000000000000000, 5], + + [1 / 0x1, -1], + [1 / 0x10, -1], + [1 / 0x100, -1], + [1 / 0x1000, -1], + [1 / 0x10000, -2], // 2**-16 + [1 / 0x100000, -2], + [1 / 0x1000000, -2], + [1 / 0x10000000, -2], + [1 / 0x100000000, -3], // 2**-32 + [1 / 0x1000000000, -3], + [1 / 0x10000000000, -3], + [1 / 0x100000000000, -3], + [1 / 0x1000000000000, -4], // 2**-48 + [1 / 0x10000000000000, -4], + [1 / 0x100000000000000, -4], + [1 / 0x1000000000000000, -4], + [1 / 0x10000000000000000, -5], // 2**-64 + [1 / 0x100000000000000000, -5], + + // Max values + // below is equivalent to [0x1.FFFFFFFFFFFFFp1023, 63], + [ + Array.from({ length: 13 }, (_, x) => 0xf * Math.pow(16, -x - 1)).reduce( + (x, y) => x + y, + 1 + ) * Math.pow(2, 1023), + 63, + ], + [Math.pow(2, 1023), 63], + [Math.pow(2, 1019), 63], + [Math.pow(2, 1009), 63], + [Math.pow(2, 1008), 62], + [Math.pow(2, 1007), 62], + [Math.pow(2, 1000), 62], + [Math.pow(2, 993), 62], + [Math.pow(2, 992), 61], + [Math.pow(2, 991), 61], + + // Min and subnormal values + [Math.pow(2, -1074), -64], + [Math.pow(2, -1073), -64], + [Math.pow(2, -1072), -64], + [Math.pow(2, -1057), -64], + [Math.pow(2, -1056), -64], + [Math.pow(2, -1041), -64], + [Math.pow(2, -1040), -64], + [Math.pow(2, -1025), -64], + [Math.pow(2, -1024), -64], + [Math.pow(2, -1023), -64], + [Math.pow(2, -1022), -64], + [Math.pow(2, -1009), -64], + [Math.pow(2, -1008), -64], + [Math.pow(2, -1007), -63], + [Math.pow(2, -993), -63], + [Math.pow(2, -992), -63], + [Math.pow(2, -991), -62], + [Math.pow(2, -977), -62], + [Math.pow(2, -976), -62], + [Math.pow(2, -975), -61], + ]; + + expectedMappings.forEach(([value, expected]) => { + const result = mapping.mapToIndex(value); + assert.strictEqual( + result, + expected, + `expected: ${value} to map to: ${expected}, got: ${result}` + ); + }); + }); + + it('throws errors for invalid scales', () => { + assert.throws(() => { + ExponentMapping.get(1); + }); + assert.throws(() => { + ExponentMapping.get(ExponentMapping.MIN_SCALE - 1); + }); + }); + + it('handles max index for all scales', () => { + for ( + let scale = ExponentMapping.MIN_SCALE; + scale <= ExponentMapping.MAX_SCALE; + scale++ + ) { + const mapping = ExponentMapping.get(scale); + const index = mapping.mapToIndex(ieee754.MAX_VALUE); + const maxIndex = ((ieee754.MAX_NORMAL_EXPONENT + 1) >> -scale) - 1; + assert.strictEqual( + index, + maxIndex, + `expected index: ${index} and ${maxIndex} to be equal for scale: ${scale}` + ); + + const boundary = mapping.lowerBoundary(index); + assert.strictEqual(boundary, roundedBoundary(scale, maxIndex)); + + assert.throws(() => { + // one larger will overflow + mapping.lowerBoundary(index + 1); + }); + } + }); + + it('handles min index for all scales', () => { + for ( + let scale = ExponentMapping.MIN_SCALE; + scale <= ExponentMapping.MAX_SCALE; + scale++ + ) { + const mapping = ExponentMapping.get(scale); + const minIndex = mapping.mapToIndex(ieee754.MIN_VALUE); + let expectedMinIndex = ieee754.MIN_NORMAL_EXPONENT >> -scale; + if (ieee754.MIN_NORMAL_EXPONENT % (1 << -scale) === 0) { + expectedMinIndex--; + } + assert.strictEqual( + minIndex, + expectedMinIndex, + `expected expectedMinIndex: ${expectedMinIndex} and ${minIndex} to be equal for scale: ${scale}` + ); + + const boundary = mapping.lowerBoundary(minIndex); + const expectedBoundary = roundedBoundary(scale, expectedMinIndex); + assert.strictEqual(boundary, expectedBoundary); + + //one smaller will underflow + assert.throws(() => { + mapping.lowerBoundary(minIndex - 1); + }); + + // subnormals map to the min index + [ + ieee754.MIN_VALUE / 2, + ieee754.MIN_VALUE / 3, + Math.pow(2, -1050), + Math.pow(2, -1073), + 1.0625 * Math.pow(2, -1073), + Math.pow(2, -1074), + ].forEach(value => { + assert.strictEqual(mapping.mapToIndex(value), expectedMinIndex); + }); + } + }); +}); + +function roundedBoundary(scale: number, index: number): number { + let result = Math.pow(2, index); + for (let i = scale; i < 0; i++) { + result = result * result; + } + return result; +} diff --git a/packages/sdk-metrics/test/aggregator/exponential-histogram/LogarithmMapping.test.ts b/packages/sdk-metrics/test/aggregator/exponential-histogram/LogarithmMapping.test.ts new file mode 100644 index 00000000000..c5bd1c9f1d8 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/LogarithmMapping.test.ts @@ -0,0 +1,196 @@ +/* + * 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 + * + * https://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. + */ +import { LogarithmMapping } from '../../../src/aggregator/exponential-histogram/mapping/LogarithmMapping'; +import * as ieee754 from '../../../src/aggregator/exponential-histogram/mapping/ieee754'; +import * as assert from 'assert'; +import { assertInEpsilon } from './helpers'; + +describe('LogarithmMapping', () => { + it('throws for invalid scale', () => { + assert.throws(() => { + LogarithmMapping.get(-1); + }); + }); + + it('maps values for scale 1', () => { + const mapping = LogarithmMapping.get(1); + assert.strictEqual(1, mapping.scale()); + + const expectedMappings = [ + [15, 7], + [9, 6], + [7, 5], + [5, 4], + [3, 3], + [2.5, 2], + [1.5, 1], + [1.2, 0], + [1, -1], + [0.75, -1], + [0.55, -2], + [0.45, -3], + ]; + + expectedMappings.forEach(([value, expected]) => { + const result = mapping.mapToIndex(value); + assert.strictEqual( + result, + expected, + `expected: ${value} to map to: ${expected}, got: ${result}` + ); + }); + }); + + it('computes boundary', () => { + [1, 2, 3, 4, 10, 15].forEach(scale => { + const mapping = LogarithmMapping.get(scale); + [-100, -10, -1, 0, 1, 10, 100].forEach(index => { + const boundary = mapping.lowerBoundary(index); + const mappedIndex = mapping.mapToIndex(boundary); + + assert.ok(index - 1 <= mappedIndex); + assert.ok(index >= mappedIndex); + assertInEpsilon(roundedBoundary(scale, index), boundary, 1e-9); + }); + }); + }); + + it('handles max index for each scale', () => { + for ( + let scale = LogarithmMapping.MIN_SCALE; + scale <= LogarithmMapping.MAX_SCALE; + scale++ + ) { + const mapping = LogarithmMapping.get(scale); + const index = mapping.mapToIndex(ieee754.MAX_VALUE); + + // the max index is one less than the first index that + // overflows Number.MAX_VALUE + const maxIndex = ((ieee754.MAX_NORMAL_EXPONENT + 1) << scale) - 1; + + assert.strictEqual(index, maxIndex); + + const boundary = mapping.lowerBoundary(index); + const base = mapping.lowerBoundary(1); + + assert.ok( + boundary < ieee754.MAX_VALUE, + `expected boundary: ${boundary} to be < max value: ${ieee754.MAX_VALUE}` + ); + + assertInEpsilon( + base - 1, + (ieee754.MAX_VALUE - boundary) / boundary, + 10e-6 + ); + } + }); + + it('handles min index for each scale', () => { + for ( + let scale = LogarithmMapping.MIN_SCALE; + scale <= LogarithmMapping.MAX_SCALE; + scale++ + ) { + const mapping = LogarithmMapping.get(scale); + const minIndex = mapping.mapToIndex(ieee754.MIN_VALUE); + + const expectedMinIndex = (ieee754.MIN_NORMAL_EXPONENT << scale) - 1; + assert.strictEqual(minIndex, expectedMinIndex); + + const expectedBoundary = roundedBoundary(scale, expectedMinIndex); + assert.ok(expectedBoundary < ieee754.MIN_VALUE); + + const expectedUpperBoundary = roundedBoundary( + scale, + expectedMinIndex + 1 + ); + assert.strictEqual(ieee754.MIN_VALUE, expectedUpperBoundary); + + const mappedLowerBoundary = mapping.lowerBoundary(minIndex + 1); + assertInEpsilon(ieee754.MIN_VALUE, mappedLowerBoundary, 1e-6); + + // subnormals map to the min index + [ + ieee754.MIN_VALUE / 2, + ieee754.MIN_VALUE / 3, + ieee754.MIN_VALUE / 100, + Math.pow(2, -1050), + Math.pow(2, -1073), + 1.0625 * Math.pow(2, -1073), + Math.pow(2, -1074), + ].forEach(value => { + const result = mapping.mapToIndex(value); + assert.strictEqual(result, expectedMinIndex); + }); + + const mappedMinLower = mapping.lowerBoundary(minIndex); + + assertInEpsilon(expectedBoundary, mappedMinLower, 1e-6); + + // one smaller will underflow + assert.throws(() => { + mapping.lowerBoundary(minIndex - 1); + }); + } + }); + + it('maps max float to max index for each scale', () => { + for ( + let scale = LogarithmMapping.MIN_SCALE; + scale <= LogarithmMapping.MAX_SCALE; + scale++ + ) { + const mapping = LogarithmMapping.get(scale); + const index = mapping.mapToIndex(ieee754.MAX_VALUE); + const maxIndex = ((ieee754.MAX_NORMAL_EXPONENT + 1) << scale) - 1; + assert.strictEqual(maxIndex, index); + + const boundary = mapping.lowerBoundary(index); + const base = mapping.lowerBoundary(1); + + assert.ok(boundary < ieee754.MAX_VALUE); + assertInEpsilon( + base - 1, + (ieee754.MAX_VALUE - boundary) / boundary, + 1e-6 + ); + + //one larger will overflow + assert.throws(() => { + mapping.lowerBoundary(index + 1); + }); + } + }); +}); + +function roundedBoundary(scale: number, index: number): number { + while (scale > 0) { + if (index < -1022) { + index /= 2; + scale--; + } else { + break; + } + } + + let result = Math.pow(2, index); + for (let i = scale; i > 0; i--) { + result = Math.sqrt(result); + } + + return result; +} diff --git a/packages/sdk-metrics/test/aggregator/exponential-histogram/helpers.ts b/packages/sdk-metrics/test/aggregator/exponential-histogram/helpers.ts new file mode 100644 index 00000000000..5f89b347710 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/helpers.ts @@ -0,0 +1,41 @@ +/* + * 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 + * + * https://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. + */ +import * as assert from 'assert'; + +export function assertInEpsilon( + actual: number, + expected: number, + epsilon: number +) { + assert.ok(!Number.isNaN(actual), 'unexpected NaN for actual argument'); + assert.ok(!Number.isNaN(expected), 'unexpected NaN for exepected argument'); + assert.ok(actual !== 0, 'unexpected 0 for actual argument'); + + const relErr = Math.abs(actual - expected) / Math.abs(actual); + + assert.ok( + relErr < epsilon, + `expected relative error: ${relErr} to be < ${epsilon}` + ); +} + +export function assertInDelta(actual: number, expected: number, delta: number) { + const actualDelta = Math.abs(expected - actual); + assert.ok( + actualDelta < delta, + `expected delta: ${delta} to be < ${actualDelta}` + ); +} diff --git a/packages/sdk-metrics/test/aggregator/exponential-histogram/ieee754.test.ts b/packages/sdk-metrics/test/aggregator/exponential-histogram/ieee754.test.ts new file mode 100644 index 00000000000..3db0da32681 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/ieee754.test.ts @@ -0,0 +1,58 @@ +/* + * 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 + * + * https://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. + */ +import * as ieee754 from '../../../src/aggregator/exponential-histogram/mapping/ieee754'; +import * as assert from 'assert'; + +describe('ieee754 helpers', () => { + describe('MIN_NORMAL_EXPONENT', () => { + it('has expected value', () => { + assert.strictEqual(ieee754.MIN_NORMAL_EXPONENT, -1022); + }); + }); + + describe('MAX_NORMAL_EXPONENT', () => { + it('has expected value', () => { + assert.strictEqual(ieee754.MAX_NORMAL_EXPONENT, 1023); + }); + }); + + describe('getNormalBase2', () => { + it('extracts exponent', () => { + assert.strictEqual( + ieee754.getNormalBase2(Math.pow(2, 1023)), + ieee754.MAX_NORMAL_EXPONENT + ); + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, 1022)), 1022); + assert.strictEqual(ieee754.getNormalBase2(18.9), 4); + assert.strictEqual(ieee754.getNormalBase2(1), 0); + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, -1021)), -1021); + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, -1022)), -1022); + + // Subnormals below + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, -1023)), -1023); + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, -1024)), -1023); + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, -1025)), -1023); + assert.strictEqual(ieee754.getNormalBase2(Math.pow(2, -1074)), -1023); + }); + }); + + describe('getSignificand', () => { + it('returns expected values', () => { + // The number 1.5 has a single most-significant bit set, i.e., 1<<51. + assert.strictEqual(ieee754.getSignificand(1.5), Math.pow(2, 51)); + }); + }); +});