diff --git a/benchmark/xNumericLoops.ts b/benchmark/xNumericLoops.ts new file mode 100644 index 000000000..94e113c0b --- /dev/null +++ b/benchmark/xNumericLoops.ts @@ -0,0 +1,343 @@ +/* eslint-disable no-console */ +import Benchmark from 'benchmark'; +import type { NumberArray } from 'cheminfo-types'; +import { XSadd } from 'ml-xsadd'; + +// The loops the library walks a spectrum with, each one as it is in src today next to the +// indexed rewrite. Measured (node 26 / bun 1.3) `for … of` costs 4.5x on V8 and 14.6x on +// JavaScriptCore over a Float64Array, and `x ** 2` costs ~1.9x over `x * x` on V8. +// +// A Float64Array and a number[] are different representations, so a function that sees both +// has a polymorphic load site and measures neither. Pick one per process: +// node benchmark/xNumericLoops.ts # Float64Array, what spectra are +// node benchmark/xNumericLoops.ts array # number[] + +const SIZES = [100_000, 1_000_000]; + +const KIND = process.argv[2] === 'array' ? 'array' : 'float64'; + +/** + * Intensities in the shape the process under test uses. + * @param size - number of values. + * @param seed - seed of the generator, so the two arrays of a pair differ. + * @returns random values, always the same ones. + */ +function makeValues(size: number, seed: number): NumberArray { + const { random } = new XSadd(seed); + if (KIND === 'array') { + const values: number[] = new Array(size); + for (let i = 0; i < size; i++) values[i] = random() * 1000 - 500; + return values; + } + const values = new Float64Array(size); + for (let i = 0; i < size; i++) values[i] = random() * 1000 - 500; + return values; +} + +// xMinMaxValues ------------------------------------------------------------------------------ + +function minMaxForOf(array: NumberArray) { + let min = array[0]; + let max = array[0]; + for (const value of array) { + if (value < min) min = value; + if (value > max) max = value; + } + return { min, max }; +} + +function minMaxIndexed(array: NumberArray) { + let min = array[0]; + let max = array[0]; + for (let i = 1; i < array.length; i++) { + const value = array[i]; + if (value < min) min = value; + else if (value > max) max = value; + } + return { min, max }; +} + +// xNorm -------------------------------------------------------------------------------------- + +function normForOfPower(array: NumberArray) { + let result = 0; + for (const element of array) { + result += element ** 2; + } + return Math.sqrt(result); +} + +function normIndexedPower(array: NumberArray) { + let result = 0; + for (let i = 0; i < array.length; i++) { + result += array[i] ** 2; + } + return Math.sqrt(result); +} + +function normIndexedMultiply(array: NumberArray) { + let result = 0; + for (let i = 0; i < array.length; i++) { + const element = array[i]; + result += element * element; + } + return Math.sqrt(result); +} + +// xVariance ---------------------------------------------------------------------------------- + +function varianceForOf(values: NumberArray, mean: number) { + let sqrError = 0; + for (const value of values) { + const x = value - mean; + sqrError += x * x; + } + return sqrError / (values.length - 1); +} + +function varianceIndexed(values: NumberArray, mean: number) { + let sqrError = 0; + for (let i = 0; i < values.length; i++) { + const x = values[i] - mean; + sqrError += x * x; + } + return sqrError / (values.length - 1); +} + +// xNormed, absoluteSum ------------------------------------------------------------------------- + +function absoluteSumForOf(input: NumberArray) { + let sumValue = 0; + for (const value of input) { + sumValue += Math.abs(value); + } + return sumValue; +} + +function absoluteSumIndexed(input: NumberArray) { + let sumValue = 0; + for (let i = 0; i < input.length; i++) { + sumValue += Math.abs(input[i]); + } + return sumValue; +} + +// xCorrelation --------------------------------------------------------------------------------- + +function correlationPower(a: NumberArray, b: NumberArray) { + const n = a.length; + let sumA = 0; + let sumA2 = 0; + let sumB = 0; + let sumB2 = 0; + let sumAB = 0; + for (let i = 0; i < n; i++) { + const valueA = a[i]; + const valueB = b[i]; + sumA += valueA; + sumA2 += valueA ** 2; + sumB += valueB; + sumB2 += valueB ** 2; + sumAB += valueA * valueB; + } + return ( + (n * sumAB - sumA * sumB) / + (Math.sqrt(n * sumA2 - sumA ** 2) * Math.sqrt(n * sumB2 - sumB ** 2)) + ); +} + +function correlationMultiply(a: NumberArray, b: NumberArray) { + const n = a.length; + let sumA = 0; + let sumA2 = 0; + let sumB = 0; + let sumB2 = 0; + let sumAB = 0; + for (let i = 0; i < n; i++) { + const valueA = a[i]; + const valueB = b[i]; + sumA += valueA; + sumA2 += valueA * valueA; + sumB += valueB; + sumB2 += valueB * valueB; + sumAB += valueA * valueB; + } + return ( + (n * sumAB - sumA * sumB) / + (Math.sqrt(n * sumA2 - sumA * sumA) * Math.sqrt(n * sumB2 - sumB * sumB)) + ); +} + +// xMeanSquaredError ---------------------------------------------------------------------------- + +function meanSquaredErrorPower(array1: NumberArray, array2: NumberArray) { + let sum = 0; + for (let i = 0; i < array1.length; i++) { + sum += (array1[i] - array2[i]) ** 2; + } + return sum / array1.length; +} + +function meanSquaredErrorMultiply(array1: NumberArray, array2: NumberArray) { + let sum = 0; + for (let i = 0; i < array1.length; i++) { + const difference = array1[i] - array2[i]; + sum += difference * difference; + } + return sum / array1.length; +} + +// xHistogram, the binning loop ------------------------------------------------------------------- + +function histogramForOf( + array: NumberArray, + y: Float64Array, + min: number, + slotSize: number, + nbSlots: number, +) { + y.fill(0); + for (const element of array) { + const index = Math.max( + Math.min( + Math.floor((element - min - Number.EPSILON) / slotSize), + nbSlots - 1, + ), + 0, + ); + y[index]++; + } + return y; +} + +function histogramIndexed( + array: NumberArray, + y: Float64Array, + min: number, + slotSize: number, + nbSlots: number, +) { + y.fill(0); + for (let i = 0; i < array.length; i++) { + const index = Math.max( + Math.min( + Math.floor((array[i] - min - Number.EPSILON) / slotSize), + nbSlots - 1, + ), + 0, + ); + y[index]++; + } + return y; +} + +/** + * Runs one A/B suite and prints ns per element for each case. + * @param title - what is being compared. + * @param size - number of elements one call goes through. + * @param cases - the implementations to compare, the current one first. + */ +function compare( + title: string, + size: number, + cases: Array<[string, () => unknown]>, +): void { + const nanoseconds: Record = {}; + const rmes: Record = {}; + const suite = new Benchmark.Suite(); + for (const [name, run] of cases) { + suite.add(name, run, { minSamples: 50 }); + } + suite + .on('cycle', (event: Benchmark.Event) => { + const { stats, name } = event.target; + if (!stats || !name) return; + nanoseconds[name] = (stats.mean * 1e9) / size; + rmes[name] = stats.rme; + }) + .run(); + + const reference = nanoseconds[cases[0][0]]; + const line = cases + .map(([name]) => { + const ns = nanoseconds[name]; + const speedup = + name === cases[0][0] ? '' : ` ${(reference / ns).toFixed(2)}x`; + return `${name} ${ns.toFixed(2)} ns/pt (±${rmes[name].toFixed(1)}%)${speedup}`; + }) + .join(' | '); + console.log(` ${title.padEnd(20)} ${line}`); +} + +console.log(`values: ${KIND === 'array' ? 'number[]' : 'Float64Array'}`); + +for (const size of SIZES) { + const values = makeValues(size, 42); + const other = makeValues(size, 7); + let mean = 0; + for (let i = 0; i < size; i++) mean += values[i]; + mean /= size; + + const nbSlots = 256; + const y = new Float64Array(nbSlots); + const slotSize = 1000 / nbSlots; + + console.log(`\nn = ${size}`); + + compare('min and max', size, [ + ['for of', () => minMaxForOf(values)], + ['indexed', () => minMaxIndexed(values)], + ]); + console.log( + ` min ${minMaxForOf(values).min} / ${minMaxIndexed(values).min}, max ${minMaxForOf(values).max} / ${minMaxIndexed(values).max}`, + ); + + compare('norm', size, [ + ['for of, ** 2', () => normForOfPower(values)], + ['indexed, ** 2', () => normIndexedPower(values)], + ['indexed, x * x', () => normIndexedMultiply(values)], + ]); + console.log( + ` ${normForOfPower(values)} / ${normIndexedPower(values)} / ${normIndexedMultiply(values)}`, + ); + + compare('variance', size, [ + ['for of', () => varianceForOf(values, mean)], + ['indexed', () => varianceIndexed(values, mean)], + ]); + console.log( + ` ${varianceForOf(values, mean)} / ${varianceIndexed(values, mean)}`, + ); + + compare('absolute sum', size, [ + ['for of', () => absoluteSumForOf(values)], + ['indexed', () => absoluteSumIndexed(values)], + ]); + console.log( + ` ${absoluteSumForOf(values)} / ${absoluteSumIndexed(values)}`, + ); + + compare('correlation', size, [ + ['** 2', () => correlationPower(values, other)], + ['x * x', () => correlationMultiply(values, other)], + ]); + console.log( + ` ${correlationPower(values, other)} / ${correlationMultiply(values, other)}`, + ); + + compare('mean squared error', size, [ + ['** 2', () => meanSquaredErrorPower(values, other)], + ['x * x', () => meanSquaredErrorMultiply(values, other)], + ]); + console.log( + ` ${meanSquaredErrorPower(values, other)} / ${meanSquaredErrorMultiply(values, other)}`, + ); + + compare('histogram binning', size, [ + ['for of', () => histogramForOf(values, y, -500, slotSize, nbSlots)], + ['indexed', () => histogramIndexed(values, y, -500, slotSize, nbSlots)], + ]); + console.log( + ` first slots ${histogramForOf(values, y, -500, slotSize, nbSlots).slice(0, 3).join(',')} / ${histogramIndexed(values, y, -500, slotSize, nbSlots).slice(0, 3).join(',')}`, + ); +} diff --git a/eslint.config.js b/eslint.config.js index ec43b0709..9640fea67 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,4 +4,14 @@ import cheminfo from 'eslint-config-cheminfo-typescript'; export default defineConfig( globalIgnores(['.claude', 'coverage', 'lib']), cheminfo, + { + rules: { + // The rule has no options, so it cannot be limited to arrays of objects. Over a + // Float64Array `for … of` costs 4x on V8 and up to 20x on JavaScriptCore, which is + // most of what this library iterates. eslint-config-cheminfo already turns off + // unicorn/no-for-loop for the same reason; this one arrives through the + // typescript-eslint stylistic set. + '@typescript-eslint/prefer-for-of': 'off', + }, + }, ); diff --git a/src/reim/reimAutoPhaseCorrection.ts b/src/reim/reimAutoPhaseCorrection.ts index 41c73ecde..f8978b66a 100644 --- a/src/reim/reimAutoPhaseCorrection.ts +++ b/src/reim/reimAutoPhaseCorrection.ts @@ -339,7 +339,8 @@ function toRadians(degree: number): number { function getNegArea(data: DoubleArray): number { let area = 0; - for (const element of data) { + for (let i = 0; i < data.length; i++) { + const element = data[i]; if (element < 0) area -= element; } return area; diff --git a/src/x/xHistogram.ts b/src/x/xHistogram.ts index a3b3ebee4..58aab6b8d 100644 --- a/src/x/xHistogram.ts +++ b/src/x/xHistogram.ts @@ -102,10 +102,10 @@ export function xHistogram( ) : histogram.x; - for (const element of array) { + for (let i = 0; i < array.length; i++) { const index = Math.max( Math.min( - Math.floor((element - min - Number.EPSILON) / slotSize), + Math.floor((array[i] - min - Number.EPSILON) / slotSize), nbSlots - 1, ), 0, diff --git a/src/x/xMinMaxValues.ts b/src/x/xMinMaxValues.ts index ae61db395..4dfd07ed4 100644 --- a/src/x/xMinMaxValues.ts +++ b/src/x/xMinMaxValues.ts @@ -16,9 +16,10 @@ export function xMinMaxValues(array: NumberArray): { let min = array[0]; let max = array[0]; - for (const value of array) { + for (let i = 1; i < array.length; i++) { + const value = array[i]; if (value < min) min = value; - if (value > max) max = value; + else if (value > max) max = value; } return { min, max }; diff --git a/src/x/xMode.ts b/src/x/xMode.ts index 55c3cfd68..5398650b8 100644 --- a/src/x/xMode.ts +++ b/src/x/xMode.ts @@ -15,7 +15,8 @@ export function xMode(input: NumberArray): number { let count = 0; const counts: Record = {}; - for (const element of input) { + for (let i = 0; i < input.length; i++) { + const element = input[i]; count = counts[element]; if (count) { counts[element]++; diff --git a/src/x/xNorm.ts b/src/x/xNorm.ts index 5ccd73ba4..78556ba94 100644 --- a/src/x/xNorm.ts +++ b/src/x/xNorm.ts @@ -8,8 +8,9 @@ import type { NumberArray } from 'cheminfo-types'; */ export function xNorm(array: NumberArray): number { let result = 0; - for (const element of array) { - result += element ** 2; + for (let i = 0; i < array.length; i++) { + const element = array[i]; + result += element * element; } return Math.sqrt(result); } diff --git a/src/x/xNormed.ts b/src/x/xNormed.ts index 3437860c2..540d55a20 100644 --- a/src/x/xNormed.ts +++ b/src/x/xNormed.ts @@ -80,8 +80,8 @@ export function xNormed( function absoluteSum(input: NumberArray) { let sumValue = 0; - for (const value of input) { - sumValue += Math.abs(value); + for (let i = 0; i < input.length; i++) { + sumValue += Math.abs(input[i]); } return sumValue; } diff --git a/src/x/xRemoveOutliersIQR.ts b/src/x/xRemoveOutliersIQR.ts index b12432ec7..148d896bf 100644 --- a/src/x/xRemoveOutliersIQR.ts +++ b/src/x/xRemoveOutliersIQR.ts @@ -28,7 +28,8 @@ export function xRemoveOutliersIQR(array: NumberArray): NumberArray { } const filteredArray = []; - for (const element of array) { + for (let i = 0; i < array.length; i++) { + const element = array[i]; if (element >= lowerWhisker && element <= higherWhisker) { filteredArray.push(element); } diff --git a/src/x/xVariance.ts b/src/x/xVariance.ts index 3d716535b..7b3f0d717 100644 --- a/src/x/xVariance.ts +++ b/src/x/xVariance.ts @@ -31,8 +31,8 @@ export function xVariance(values: NumberArray, options: XVarianceOptions = {}) { const { unbiased = true, mean = xMean(values) } = options; let sqrError = 0; - for (const value of values) { - const x = value - mean; + for (let i = 0; i < values.length; i++) { + const x = values[i] - mean; sqrError += x * x; } diff --git a/src/xyArray/utils/getSlotsToFirst.ts b/src/xyArray/utils/getSlotsToFirst.ts index cdd2a450c..cfec9b30e 100644 --- a/src/xyArray/utils/getSlotsToFirst.ts +++ b/src/xyArray/utils/getSlotsToFirst.ts @@ -32,7 +32,8 @@ export function getSlotsToFirst( const firstXs = data[0].x; const slots: Slot[] = []; // we first create the slots based on the first spectrum - for (const element of firstXs) { + for (let i = 0; i < firstXs.length; i++) { + const element = firstXs[i]; const currentDelta = deltaIsFunction ? delta(element) : delta; slots.push({ from: element - currentDelta,