current commit
261105.9 · lib: replace Kahan summation with `MathSumPrecise`
'use strict';

const {
  ArrayIsArray,
  ArrayPrototypeJoin,
  ArrayPrototypePush,
  ArrayPrototypeReverse,
  ArrayPrototypeSlice,
  ArrayPrototypeSort,
  JSONStringify,
  MathFloor,
  MathMax,
  MathMin,
  MathRound,
  MathSqrt,
  MathSumPrecise,
  Number,
  NumberIsFinite,
  NumberMAX_SAFE_INTEGER,
  ObjectFreeze,
  ObjectKeys,
  PromiseWithResolvers,
  SafeArrayIterator,
  SafeSet,
  StringPrototypeToLowerCase,
} = primordials;
const { AsyncResource } = require('async_hooks');
const {
  codes: {
    ERR_INVALID_ARG_TYPE,
    ERR_INVALID_ARG_VALUE,
    ERR_INVALID_STATE,
    ERR_OUT_OF_RANGE,
  },
} = require('internal/errors');
const { createHistogram } = require('internal/histogram');
const { TIMEOUT_MAX } = require('internal/timers');
const { kEmptyObject } = require('internal/util');
const {
  validateAbortSignal,
  validateFunction,
  validateInteger,
  validateNumber,
  validateObject,
  validateString,
  validateUint32,
} = require('internal/validators');
const { structuredClone } = require('internal/worker/js_transferable');

const { bigint: hrtime } = process.hrtime;
const kDefaultSamples = 30;
const kDefaultWarmup = 10;
const kEmptyDiagnosticChannels = ObjectFreeze([]);
const kEmptyNamePath = ObjectFreeze([]);
const kEmptyParams = ObjectFreeze({ __proto__: null });
const kEmptyTags = ObjectFreeze([]);

function validateSkip(skip) {
  if (skip !== undefined && typeof skip !== 'boolean' &&
      typeof skip !== 'string') {
    throw new ERR_INVALID_ARG_TYPE('options.skip', ['boolean', 'string'], skip);
  }
}

function canonicalizeTags(tags, parentTags = kEmptyTags) {
  if (tags === undefined) return parentTags;
  if (!ArrayIsArray(tags)) {
    throw new ERR_INVALID_ARG_TYPE('options.tags', 'Array', tags);
  }

  const result = ArrayPrototypeSlice(parentTags);
  const seen = new SafeSet(parentTags);
  for (let i = 0; i < tags.length; i++) {
    validateString(tags[i], `options.tags[${i}]`);
    if (tags[i].length === 0) {
      throw new ERR_INVALID_ARG_VALUE(
        `options.tags[${i}]`, tags[i], 'must not be empty');
    }
    const tag = StringPrototypeToLowerCase(tags[i]);
    if (!seen.has(tag)) {
      seen.add(tag);
      ArrayPrototypePush(result, tag);
    }
  }
  return ObjectFreeze(result);
}

function canonicalizeDiagnosticChannels(
  diagnosticChannels,
  parentDiagnosticChannels = kEmptyDiagnosticChannels,
) {
  if (diagnosticChannels === undefined) return parentDiagnosticChannels;
  if (!ArrayIsArray(diagnosticChannels)) {
    throw new ERR_INVALID_ARG_TYPE(
      'options.diagnosticChannels', 'Array', diagnosticChannels);
  }

  const result = ArrayPrototypeSlice(parentDiagnosticChannels);
  const seen = new SafeSet(parentDiagnosticChannels);
  for (let i = 0; i < diagnosticChannels.length; i++) {
    const name = diagnosticChannels[i];
    if (typeof name === 'symbol') continue;
    if (typeof name !== 'string') {
      throw new ERR_INVALID_ARG_TYPE(
        `options.diagnosticChannels[${i}]`, ['string', 'symbol'], name);
    }
    if (!seen.has(name)) {
      seen.add(name);
      ArrayPrototypePush(result, name);
    }
  }
  return ObjectFreeze(result);
}

function canonicalizeParams(params) {
  if (params === undefined) return kEmptyParams;
  validateObject(params, 'options.params');

  const result = { __proto__: null };
  const keys = ObjectKeys(params);
  ArrayPrototypeSort(keys);
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    const value = params[key];
    if (typeof value !== 'string' && typeof value !== 'boolean' &&
        (typeof value !== 'number' || !NumberIsFinite(value))) {
      if (typeof value === 'number') {
        throw new ERR_OUT_OF_RANGE(
          `options.params.${key}`, 'a finite number', value);
      }
      throw new ERR_INVALID_ARG_TYPE(
        `options.params.${key}`, ['string', 'number', 'boolean'], value);
    }
    result[key] = value;
  }
  return ObjectFreeze(result);
}

function validateNodeOptions(options, parentTags, parentDiagnosticChannels) {
  validateObject(options, 'options');
  const { diagnosticChannels, only = false, skip, tags } = options;
  if (typeof only !== 'boolean') {
    throw new ERR_INVALID_ARG_TYPE('options.only', 'boolean', only);
  }
  validateSkip(skip);
  return {
    __proto__: null,
    diagnosticChannels: canonicalizeDiagnosticChannels(
      diagnosticChannels, parentDiagnosticChannels),
    only,
    skip,
    tags: canonicalizeTags(tags, parentTags),
  };
}

function createLocation(loc, fallbackFile) {
  return {
    __proto__: null,
    file: loc?.[2] ?? fallbackFile,
    line: loc?.[0],
    column: loc?.[1],
  };
}

function getNamePath(parent, name) {
  const path = [];
  for (let current = parent; current?.parent !== null; current = current.parent) {
    ArrayPrototypePush(path, current.name);
  }
  ArrayPrototypeReverse(path);
  ArrayPrototypePush(path, name);
  return path;
}

function cloneSample(sample) {
  const result = {
    __proto__: null,
    operations: sample.operations,
    duration_ns: sample.duration_ns,
    rate: sample.rate,
  };
  if (sample.detail !== undefined) {
    result.detail = structuredClone(sample.detail);
  }
  return result;
}

class Suite extends AsyncResource {
  constructor(harness, parent, name, options, fn, loc, isRoot = false) {
    super('BenchSuite');
    const validated = validateNodeOptions(
      options,
      parent?.tags ?? kEmptyTags,
      parent?.diagnosticChannels ?? kEmptyDiagnosticChannels,
    );

    this.harness = harness;
    this.parent = parent;
    this.name = name;
    this.fn = fn;
    this.loc = createLocation(loc, harness.entryFile);
    this.isRoot = isRoot;
    this.fileScope = isRoot ? null :
      (parent.isRoot ? harness.getFileScope() : parent.fileScope);
    this.namePath = isRoot ? kEmptyNamePath :
      ObjectFreeze(getNamePath(parent, name));
    this.suiteId = isRoot ? null : JSONStringify([
      this.loc.file,
      this.namePath,
    ]);
    this.parentId = isRoot || parent.isRoot ? null : parent.suiteId;
    this.diagnosticChannels = validated.diagnosticChannels;
    this.only = validated.only;
    this.skip = validated.skip;
    this.tags = validated.tags;
    this.children = [];
    this.hooks = {
      __proto__: null,
      after: [],
      afterEach: [],
      before: [],
      beforeEach: [],
    };
    this.buildError = null;
    this.buildPromise = null;
    this.finished = false;
    this.completion = PromiseWithResolvers();
  }
}

class Bench extends AsyncResource {
  constructor(harness, parent, name, options, fn, loc) {
    super('Benchmark');
    const validated = validateNodeOptions(
      options, parent.tags, parent.diagnosticChannels);
    const {
      params,
      samples = kDefaultSamples,
      signal,
      timeout = Infinity,
      warmup = kDefaultWarmup,
    } = options;

    validateUint32(samples, 'options.samples', true);
    validateUint32(warmup, 'options.warmup');
    validateAbortSignal(signal, 'options.signal');
    if (timeout !== Infinity) {
      validateNumber(timeout, 'options.timeout', 0, TIMEOUT_MAX);
    }

    this.harness = harness;
    this.parent = parent;
    this.name = name;
    this.fn = fn;
    this.loc = createLocation(loc, harness.entryFile);
    this.diagnosticChannels = validated.diagnosticChannels;
    this.only = validated.only;
    this.skip = validated.skip;
    this.tags = validated.tags;
    this.params = canonicalizeParams(params);
    this.samples = samples;
    this.warmup = warmup;
    this.timeout = timeout;
    this.outerSignal = signal;
    this.fileScope = parent.isRoot ? harness.getFileScope() : parent.fileScope;
    this.namePath = ObjectFreeze(getNamePath(parent, name));
    this.fullName = ArrayPrototypeJoin(this.namePath, ' ');
    this.benchId = JSONStringify([
      this.loc.file,
      this.namePath,
      this.params,
    ]);
    this.parentId = parent.suiteId;
    this.finished = false;
    this.result = null;
    this.completion = PromiseWithResolvers();
  }
}

class BenchContext {
  #closed = false;
  #done = false;
  #endCalled = false;
  #index;
  #invalid = false;
  #onDiagnostic;
  #phase;
  #recordCalled = false;
  #sample = null;
  #startCalled = false;
  #startTime;

  constructor(bench, signal, phase, index, onDiagnostic) {
    this.name = bench.name;
    this.params = bench.params;
    this.signal = signal;
    this.#phase = phase;
    this.#index = index;
    this.#onDiagnostic = onDiagnostic;
  }

  get index() {
    return this.#index;
  }

  get phase() {
    return this.#phase;
  }

  diagnostic(message, options = kEmptyObject) {
    if (this.#closed) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE('benchmark sample is no longer active');
    }
    try {
      const clonedMessage = structuredClone(message);
      validateObject(options, 'options');
      const { detail, level = 'info' } = options;
      validateString(level, 'options.level');
      if (level !== 'info' && level !== 'warning') {
        throw new ERR_INVALID_ARG_VALUE(
          'options.level', level, "must be 'info' or 'warning'");
      }
      const diagnostic = { __proto__: null, level, message: clonedMessage };
      if (detail !== undefined) diagnostic.detail = structuredClone(detail);
      this.#onDiagnostic(diagnostic);
    } catch (error) {
      this.#invalid = true;
      throw error;
    }
  }

  start() {
    if (this.#closed) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE('benchmark sample is no longer active');
    }
    if (this.#recordCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'start() cannot be combined with record()');
    }
    if (this.#startCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'start() must be called exactly once per benchmark sample');
    }
    this.#startCalled = true;
    this.#startTime = hrtime();
  }

  end(operations, options = kEmptyObject) {
    const endTime = hrtime();
    if (this.#closed) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE('benchmark sample is no longer active');
    }
    if (this.#recordCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'end() cannot be combined with record()');
    }
    if (this.#endCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'end() must be called exactly once per benchmark sample');
    }
    this.#endCalled = true;
    if (!this.#startCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE('end() cannot be called before start()');
    }

    try {
      validateObject(options, 'options');
      const { detail } = options;
      validateInteger(operations, 'operations', 1, NumberMAX_SAFE_INTEGER);
      const duration = endTime - this.#startTime;
      if (duration === 0n) {
        throw new ERR_INVALID_STATE(
          'insufficient clock precision for benchmark sample');
      }
      this.#sample = {
        __proto__: null,
        operations,
        duration_ns: duration,
        rate: operations / (Number(duration) / 1e9),
      };
      if (detail !== undefined) {
        this.#sample.detail = structuredClone(detail);
      }
    } catch (error) {
      this.#invalid = true;
      throw error;
    }
    return cloneSample(this.#sample);
  }

  record(sample) {
    if (this.#closed) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE('benchmark sample is no longer active');
    }
    if (this.#recordCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'record() must be called exactly once per benchmark sample');
    }
    if (this.#startCalled || this.#endCalled) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'record() cannot be combined with start() or end()');
    }
    this.#recordCalled = true;

    try {
      validateObject(sample, 'sample');
      const { detail, duration_ns, operations } = sample;
      validateInteger(
        operations, 'sample.operations', 1, NumberMAX_SAFE_INTEGER);
      if (typeof duration_ns !== 'bigint') {
        throw new ERR_INVALID_ARG_TYPE(
          'sample.duration_ns', 'bigint', duration_ns);
      }
      if (duration_ns <= 0n) {
        throw new ERR_OUT_OF_RANGE(
          'sample.duration_ns', 'a positive bigint', duration_ns);
      }
      if (duration_ns > 9_007_199_254_740_991n) {
        throw new ERR_OUT_OF_RANGE(
          'sample.duration_ns',
          'less than or equal to Number.MAX_SAFE_INTEGER',
          duration_ns);
      }
      this.#sample = {
        __proto__: null,
        operations,
        duration_ns,
        rate: operations / (Number(duration_ns) / 1e9),
      };
      if (detail !== undefined) {
        this.#sample.detail = structuredClone(detail);
      }
    } catch (error) {
      this.#invalid = true;
      throw error;
    }
    return cloneSample(this.#sample);
  }

  done() {
    if (this.#closed) {
      this.#invalid = true;
      throw new ERR_INVALID_STATE('benchmark sample is no longer active');
    }
    if (this.#phase !== 'measurement') {
      this.#invalid = true;
      throw new ERR_INVALID_STATE(
        'done() can only be called during a measured sample');
    }
    this.#done = true;
  }

  finish() {
    this.#closed = true;
    if (this.#invalid) {
      throw new ERR_INVALID_STATE(
        'benchmark sample violated the context method contract');
    }
    if (!this.#recordCalled && !this.#startCalled) {
      throw new ERR_INVALID_STATE(
        'benchmark callback did not call start() or record()');
    }
    if (!this.#recordCalled && (!this.#endCalled || this.#sample === null)) {
      throw new ERR_INVALID_STATE(
        'benchmark callback did not call end()');
    }
    return {
      __proto__: null,
      done: this.#done,
      sample: this.#sample,
    };
  }

  close() {
    this.#closed = true;
  }
}

function summarizeSamples(samples) {
  const rates = [];
  let min = Infinity;
  let max = -Infinity;
  for (let i = 0; i < samples.length; i++) {
    const rate = samples[i].rate;
    ArrayPrototypePush(rates, rate);
    min = MathMin(min, rate);
    max = MathMax(max, rate);
  }

  const mean = MathSumPrecise(new SafeArrayIterator(rates)) / rates.length;
  let variance = 0;
  for (let i = 0; i < rates.length; i++) {
    const difference = rates[i] - mean;
    variance += difference * difference;
  }
  variance /= rates.length;
  const stddev = MathSqrt(variance);

  const sorted = ArrayPrototypeSlice(rates);
  ArrayPrototypeSort(sorted, (a, b) => a - b);
  const middle = MathFloor(sorted.length / 2);
  const median = sorted.length % 2 === 0 ?
    (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];

  const scale = MathMin(1_000_000, NumberMAX_SAFE_INTEGER / max);
  const histogram = createHistogram({ __proto__: null, figures: 5 });
  for (let i = 0; i < rates.length; i++) {
    const value = MathMin(NumberMAX_SAFE_INTEGER, MathRound(rates[i] * scale));
    histogram.record(value);
  }

  const meanCI = histogram.meanCI();
  const histogramMean = meanCI.mean / scale;
  const medianCI = histogram.percentileCI(50);

  return {
    __proto__: null,
    mean,
    median,
    min,
    max,
    stddev,
    coefficientOfVariation: stddev / mean,
    confidenceInterval: {
      __proto__: null,
      lower: mean + meanCI.lower / scale - histogramMean,
      upper: mean + meanCI.upper / scale - histogramMean,
    },
    medianConfidenceInterval: {
      __proto__: null,
      lower: medianCI.lower / scale,
      upper: medianCI.upper / scale,
    },
    skewness: histogram.skewness,
  };
}

function normalizeArgs(type, name, options, fn) {
  if (typeof name === 'function') {
    fn = name;
    name = fn.name || '<anonymous>';
    options = kEmptyObject;
  } else if (name !== null && typeof name === 'object') {
    fn = options;
    options = name;
    name = fn?.name || '<anonymous>';
  } else if (typeof options === 'function') {
    fn = options;
    options = kEmptyObject;
  }

  validateFunction(fn, `${type} function`);
  validateString(name, `${type} name`);
  if (name.length === 0) {
    throw new ERR_INVALID_ARG_VALUE(`${type} name`, name, 'must not be empty');
  }
  validateObject(options, 'options');
  return { __proto__: null, fn, name, options };
}

module.exports = {
  Bench,
  BenchContext,
  Suite,
  normalizeArgs,
  summarizeSamples,
};