diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6d6055d1d..0d629c233a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ 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/3504) @mwear + ### :bug: (Bug Fix) * fix: avoid grpc types dependency [#3551](https://github.com/open-telemetry/opentelemetry-js/pull/3551) @flarna 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..49662b44f4e --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ExponentMapping.ts @@ -0,0 +1,105 @@ +/* + * 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 exponential mapping functions for + * scales <=0. For scales > 0 LogarithmMapping should be used. + */ +export class ExponentMapping implements Mapping { + private readonly _shift: number; + + constructor(scale: number) { + this._shift = -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(); + } + + 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 accommodate 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..974d9ff84e8 --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/LogarithmMapping.ts @@ -0,0 +1,108 @@ +/* + * 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 { + private readonly _scale: number; + private readonly _scaleFactor: number; + private readonly _inverseFactor: number; + + 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/getMapping.ts b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/getMapping.ts new file mode 100644 index 00000000000..ce8949b3256 --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/getMapping.ts @@ -0,0 +1,44 @@ +/* + * 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 './ExponentMapping'; +import { LogarithmMapping } from './LogarithmMapping'; +import { MappingError, Mapping } from './types'; + +const MIN_SCALE = -10; +const MAX_SCALE = 20; +const PREBUILT_MAPPINGS = Array.from({ length: 31 }, (_, i) => { + if (i > 10) { + return new LogarithmMapping(i - 10); + } + return new ExponentMapping(i - 10); +}); + +/** + * getMapping returns an appropriate mapping for the given scale. For scales -10 + * to 0 the underlying type will be ExponentMapping. For scales 1 to 20 the + * underlying type will be LogarithmMapping. + * @param scale a number in the range [-10, 20] + * @returns {Mapping} + */ +export function getMapping(scale: number): Mapping { + if (scale > MAX_SCALE || scale < MIN_SCALE) { + throw new MappingError( + `expected scale >= ${MIN_SCALE} && <= ${MAX_SCALE}, got: ${scale}` + ); + } + // mappings are offset by 10. scale -10 is at position 0 and scale 20 is at 30 + return PREBUILT_MAPPINGS[scale + 10]; +} 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..0dc4f01bc36 --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/ieee754.ts @@ -0,0 +1,98 @@ +/* + * 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. + */ + +/** + * The functions and constants in this file allow us to interact + * with the internal representation of an IEEE 64-bit floating point + * number. We need to work with all 64-bits, thus, care needs to be + * taken when working with Javascript's bitwise operators (<<, >>, &, + * |, etc) as they truncate operands to 32-bits. In order to work around + * this we work with the 64-bits as two 32-bit halves, perform bitwise + * operations on them independently, and combine the results (if needed). + */ + +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; + +/** + * 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 note: + // we can't use the native << operator as it will truncate the result to 32-bits + 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..afe6ed9118c --- /dev/null +++ b/packages/sdk-metrics/src/aggregator/exponential-histogram/mapping/types.ts @@ -0,0 +1,27 @@ +/* + * 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 {} + +/** + * The mapping interface is used by the exponential histogram to determine + * where to bucket values. The interface is implemented by ExponentMapping, + * used for scales [-10, 0] and LogarithmMapping, used for scales [1, 20]. + */ +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..8a9ed821573 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/ExponentMapping.test.ts @@ -0,0 +1,295 @@ +/* + * 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'; + +const MIN_SCALE = -10; +const MAX_SCALE = 0; + +describe('ExponentMapping', () => { + it('maps expected values for scale 0', () => { + const mapping = new ExponentMapping(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 = new ExponentMapping(MIN_SCALE); + assert.strictEqual(mapping.scale(), 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 = new ExponentMapping(-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 = new ExponentMapping(-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('handles max index for all scales', () => { + for (let scale = MIN_SCALE; scale <= MAX_SCALE; scale++) { + const mapping = new ExponentMapping(scale); + const index = mapping.mapToIndex(Number.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 = MIN_SCALE; scale <= MAX_SCALE; scale++) { + const mapping = new ExponentMapping(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..d6d04bf8149 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/LogarithmMapping.test.ts @@ -0,0 +1,177 @@ +/* + * 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'; + +const MIN_SCALE = 1; +const MAX_SCALE = 20; + +describe('LogarithmMapping', () => { + it('maps values for scale 1', () => { + const mapping = new LogarithmMapping(1); + assert.strictEqual(mapping.scale(), 1); + + 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 = new LogarithmMapping(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 = MIN_SCALE; scale <= MAX_SCALE; scale++) { + const mapping = new LogarithmMapping(scale); + const index = mapping.mapToIndex(Number.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 < Number.MAX_VALUE, + `expected boundary: ${boundary} to be < max value: ${Number.MAX_VALUE}` + ); + + assertInEpsilon( + base - 1, + (Number.MAX_VALUE - boundary) / boundary, + 10e-6 + ); + } + }); + + it('handles min index for each scale', () => { + for (let scale = MIN_SCALE; scale <= MAX_SCALE; scale++) { + const mapping = new LogarithmMapping(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 = MIN_SCALE; scale <= MAX_SCALE; scale++) { + const mapping = new LogarithmMapping(scale); + const index = mapping.mapToIndex(Number.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 < Number.MAX_VALUE); + assertInEpsilon(base - 1, (Number.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/getMapping.test.ts b/packages/sdk-metrics/test/aggregator/exponential-histogram/getMapping.test.ts new file mode 100644 index 00000000000..4d7326be547 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/getMapping.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { LogarithmMapping } from '../../../src/aggregator/exponential-histogram/mapping/LogarithmMapping'; +import { getMapping } from '../../../src/aggregator/exponential-histogram/mapping/getMapping'; +import * as assert from 'assert'; + +const MIN_SCALE = -10; +const MAX_SCALE = 20; + +describe('getMapping', () => { + it('returns correct mapping for all scales', () => { + for (let scale = MIN_SCALE; scale <= MAX_SCALE; scale++) { + const mapping = getMapping(scale); + if (scale > 0) { + assert.ok(mapping instanceof LogarithmMapping); + } else { + assert.ok(mapping instanceof ExponentMapping); + } + assert.strictEqual(mapping.scale(), scale); + } + }); + + it('throws for invalid scales', () => { + assert.throws(() => { + getMapping(MIN_SCALE - 1); + }); + assert.throws(() => { + getMapping(MAX_SCALE + 1); + }); + }); +}); 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..a2241fbf667 --- /dev/null +++ b/packages/sdk-metrics/test/aggregator/exponential-histogram/helpers.ts @@ -0,0 +1,33 @@ +/* + * 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 expected 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}` + ); +} 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)); + }); + }); +});