From dd7ecea509fe6e4f097e837df40a4beadbf92624 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 4 Aug 2026 08:39:49 -0700 Subject: [PATCH 1/4] lib,src: improve histogram implementation Several improvements: 1. In histogram-inl, Add previous locked only this->mutex while reading the other's fields unsafely. 2. In histogram.cc, PrepareCB now uses ContainerOf 3. In histogram.cc, BigInt value range is checked 4. In histogram.js, simplified impl and reduced duplication 5. In event_loop_delay.js, use a more consistent constructor Signed-off-by: James M Snell Assisted-by: Opencode/Opus --- lib/internal/histogram.js | 28 ++++++++++--------------- lib/internal/perf/event_loop_delay.js | 23 ++++++++++---------- src/histogram-inl.h | 30 +++++++++++++++++++++------ src/histogram.cc | 14 ++++++++----- 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index f2cf3835b9a6..e308d76b66ed 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -2,12 +2,10 @@ const { Map, - MapPrototypeClear, MapPrototypeEntries, NumberIsNaN, NumberMAX_SAFE_INTEGER, ObjectFromEntries, - ReflectConstruct, Symbol, } = primordials; @@ -40,7 +38,6 @@ const { const kDestroy = Symbol('kDestroy'); const kHandle = Symbol('kHandle'); -const kMap = Symbol('kMap'); const kRecordable = Symbol('kRecordable'); const { @@ -217,9 +214,9 @@ class Histogram { get percentiles() { if (!isHistogram(this)) throw new ERR_INVALID_THIS('Histogram'); - MapPrototypeClear(this[kMap]); - this[kHandle]?.percentiles(this[kMap]); - return this[kMap]; + const map = new Map(); + this[kHandle]?.percentiles(map); + return map; } /** @@ -229,9 +226,9 @@ class Histogram { get percentilesBigInt() { if (!isHistogram(this)) throw new ERR_INVALID_THIS('Histogram'); - MapPrototypeClear(this[kMap]); - this[kHandle]?.percentilesBigInt(this[kMap]); - return this[kMap]; + const map = new Map(); + this[kHandle]?.percentilesBigInt(map); + return map; } /** @@ -328,12 +325,10 @@ class RecordableHistogram extends Histogram { } function ClonedHistogram(handle) { - return ReflectConstruct( - function() { - markTransferMode(this, true, false); - this[kHandle] = handle; - this[kMap] = new Map(); - }, [], Histogram); + const histogram = new Histogram(kSkipThrow); + markTransferMode(histogram, true, false); + histogram[kHandle] = handle; + return histogram; } ClonedHistogram.prototype[kDeserialize] = () => { }; @@ -343,7 +338,6 @@ function ClonedRecordableHistogram(handle) { markTransferMode(histogram, true, false); histogram[kRecordable] = true; - histogram[kMap] = new Map(); histogram[kHandle] = handle; histogram.constructor = RecordableHistogram; @@ -391,6 +385,6 @@ module.exports = { isHistogram, kDestroy, kHandle, - kMap, + kSkipThrow, createHistogram, }; diff --git a/lib/internal/perf/event_loop_delay.js b/lib/internal/perf/event_loop_delay.js index ebf0017b70df..4d14182c83fa 100644 --- a/lib/internal/perf/event_loop_delay.js +++ b/lib/internal/perf/event_loop_delay.js @@ -1,7 +1,5 @@ 'use strict'; const { - ReflectConstruct, - SafeMap, Symbol, SymbolDispose, } = primordials; @@ -26,7 +24,7 @@ const { const { Histogram, kHandle, - kMap, + kSkipThrow, } = require('internal/histogram'); const { @@ -40,8 +38,11 @@ const { const kEnabled = Symbol('kEnabled'); class ELDHistogram extends Histogram { - constructor() { - throw new ERR_ILLEGAL_CONSTRUCTOR(); + constructor(skipThrowSymbol = undefined) { + if (skipThrowSymbol !== kSkipThrow) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + super(skipThrowSymbol); } /** @@ -87,13 +88,11 @@ function monitorEventLoopDelay(options = kEmptyObject) { validateBoolean(samplePerIteration, 'options.samplePerIteration'); validateInteger(resolution, 'options.resolution', 1); - return ReflectConstruct( - function() { - markTransferMode(this, true, false); - this[kEnabled] = false; - this[kHandle] = createELDHistogram(resolution, samplePerIteration); - this[kMap] = new SafeMap(); - }, [], ELDHistogram); + const histogram = new ELDHistogram(kSkipThrow); + markTransferMode(histogram, true, false); + histogram[kEnabled] = false; + histogram[kHandle] = createELDHistogram(resolution, samplePerIteration); + return histogram; } module.exports = monitorEventLoopDelay; diff --git a/src/histogram-inl.h b/src/histogram-inl.h index 3b8712c87879..f6af4a37cd9d 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -18,12 +18,30 @@ void Histogram::Reset() { } double Histogram::Add(const Histogram& other) { - Mutex::ScopedLock lock(mutex_); - count_ += other.count_; - exceeds_ += other.exceeds_; - if (other.prev_ > prev_) - prev_ = other.prev_; - return static_cast(hdr_add(histogram_.get(), other.histogram_.get())); + auto do_add = [&]() { + count_ += other.count_; + exceeds_ += other.exceeds_; + if (other.prev_ > prev_) prev_ = other.prev_; + return static_cast( + hdr_add(histogram_.get(), other.histogram_.get())); + }; + + // When adding a histogram to itself, a single lock suffices. + if (this == &other) { + Mutex::ScopedLock lock(mutex_); + return do_add(); + } + + // Lock both mutexes in pointer order to prevent deadlock. + if (this < &other) { + Mutex::ScopedLock lock1(mutex_); + Mutex::ScopedLock lock2(other.mutex_); + return do_add(); + } + + Mutex::ScopedLock lock1(other.mutex_); + Mutex::ScopedLock lock2(mutex_); + return do_add(); } size_t Histogram::Count() const { diff --git a/src/histogram.cc b/src/histogram.cc index 5dd82c305bf7..19e17ed88005 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -261,18 +261,22 @@ void HistogramBase::New(const FunctionCallbackInfo& args) { int64_t lowest = 1; int64_t highest = std::numeric_limits::max(); - bool lossless_ignored; + bool lossless = true; if (args[0]->IsNumber()) { lowest = args[0].As()->Value(); } else if (args[0]->IsBigInt()) { - lowest = args[0].As()->Int64Value(&lossless_ignored); + lowest = args[0].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.lowest is out of range"); } if (args[1]->IsNumber()) { highest = args[1].As()->Value(); } else if (args[1]->IsBigInt()) { - highest = args[1].As()->Int64Value(&lossless_ignored); + highest = args[1].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.highest is out of range"); } int32_t figures = args[2].As()->Value(); @@ -497,7 +501,6 @@ IterationHistogram::IterationHistogram(Environment* env, uv_prepare_init(env->event_loop(), &prepare_handle_); uv_unref(reinterpret_cast(&check_handle_)); uv_unref(reinterpret_cast(&prepare_handle_)); - prepare_handle_.data = this; } BaseObjectPtr IterationHistogram::Create( @@ -515,7 +518,8 @@ BaseObjectPtr IterationHistogram::Create( } void IterationHistogram::PrepareCB(uv_prepare_t* handle) { - IterationHistogram* self = static_cast(handle->data); + IterationHistogram* self = + ContainerOf(&IterationHistogram::prepare_handle_, handle); if (!self->enabled_) return; self->prepare_time_ = uv_hrtime(); self->timeout_ = uv_backend_timeout(handle->loop); From 63c43c1a64c16770eeaa36317fe141f201a42248 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 4 Aug 2026 08:47:55 -0700 Subject: [PATCH 2/4] src: fix histogram mutex locking Signed-off-by: James M Snell Assisted-by: Opencode/Opus --- src/histogram-inl.h | 37 +++++++++++++++++++++---------------- src/histogram.h | 4 ++-- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/histogram-inl.h b/src/histogram-inl.h index f6af4a37cd9d..d08a0ee1a71f 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -10,7 +10,7 @@ namespace node { void Histogram::Reset() { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); hdr_reset(histogram_.get()); exceeds_ = 0; count_ = 0; @@ -28,49 +28,54 @@ double Histogram::Add(const Histogram& other) { // When adding a histogram to itself, a single lock suffices. if (this == &other) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); return do_add(); } // Lock both mutexes in pointer order to prevent deadlock. if (this < &other) { - Mutex::ScopedLock lock1(mutex_); - Mutex::ScopedLock lock2(other.mutex_); + RwLock::ScopedWriteLock lock1(mutex_); + RwLock::ScopedWriteLock lock2(other.mutex_); return do_add(); } - Mutex::ScopedLock lock1(other.mutex_); - Mutex::ScopedLock lock2(mutex_); + RwLock::ScopedWriteLock lock1(other.mutex_); + RwLock::ScopedWriteLock lock2(mutex_); return do_add(); } size_t Histogram::Count() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return count_; } +size_t Histogram::Exceeds() const { + RwLock::ScopedReadLock lock(mutex_); + return exceeds_; +} + int64_t Histogram::Min() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_min(histogram_.get()); } int64_t Histogram::Max() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_max(histogram_.get()); } double Histogram::Mean() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_mean(histogram_.get()); } double Histogram::Stddev() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_stddev(histogram_.get()); } int64_t Histogram::Percentile(double percentile) const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); CHECK_GT(percentile, 0); CHECK_LE(percentile, 100); return hdr_value_at_percentile(histogram_.get(), percentile); @@ -78,7 +83,7 @@ int64_t Histogram::Percentile(double percentile) const { template void Histogram::Percentiles(Iterator&& fn) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); hdr_iter iter; hdr_iter_percentile_init(&iter, histogram_.get(), 1); while (hdr_iter_next(&iter)) { @@ -88,7 +93,7 @@ void Histogram::Percentiles(Iterator&& fn) { } bool Histogram::Record(int64_t value) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); bool recorded = hdr_record_value(histogram_.get(), value); if (!recorded) exceeds_++; @@ -98,7 +103,7 @@ bool Histogram::Record(int64_t value) { } uint64_t Histogram::RecordDelta() { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); uint64_t time = uv_hrtime(); int64_t delta = 0; if (prev_ > 0) { @@ -114,7 +119,7 @@ uint64_t Histogram::RecordDelta() { } size_t Histogram::GetMemorySize() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_get_memory_size(histogram_.get()); } diff --git a/src/histogram.h b/src/histogram.h index b9f968e8347c..deba00af16a1 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -45,7 +45,7 @@ class Histogram : public MemoryRetainer { inline double Mean() const; inline double Stddev() const; inline int64_t Percentile(double percentile) const; - inline size_t Exceeds() const { return exceeds_; } + inline size_t Exceeds() const; inline size_t Count() const; inline uint64_t RecordDelta(); @@ -69,7 +69,7 @@ class Histogram : public MemoryRetainer { uint64_t prev_ = 0; size_t exceeds_ = 0; size_t count_ = 0; - Mutex mutex_; + RwLock mutex_; }; class HistogramImpl { From 156857148290ad32a4c2f5f142f3fe4540f52beb Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 4 Aug 2026 09:01:05 -0700 Subject: [PATCH 3/4] src: improvement histogram implementation Signed-off-by: James M Snell Assisted-by: Opencode/Opus --- src/histogram-inl.h | 22 +++++++++------------- src/histogram.cc | 30 ++++++++++++++++++++++-------- src/histogram.h | 3 +-- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/histogram-inl.h b/src/histogram-inl.h index d08a0ee1a71f..1b312c7bd68d 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -13,40 +13,40 @@ void Histogram::Reset() { RwLock::ScopedWriteLock lock(mutex_); hdr_reset(histogram_.get()); exceeds_ = 0; - count_ = 0; prev_ = 0; } double Histogram::Add(const Histogram& other) { auto do_add = [&]() { - count_ += other.count_; exceeds_ += other.exceeds_; if (other.prev_ > prev_) prev_ = other.prev_; + // hdr_add merges all bucket counts and total_count internally. return static_cast( hdr_add(histogram_.get(), other.histogram_.get())); }; - // When adding a histogram to itself, a single lock suffices. + // When adding a histogram to itself, a single write lock suffices. if (this == &other) { RwLock::ScopedWriteLock lock(mutex_); return do_add(); } - // Lock both mutexes in pointer order to prevent deadlock. + // Write-lock this (modified), read-lock other (only read). + // Lock in pointer order to prevent deadlock. if (this < &other) { RwLock::ScopedWriteLock lock1(mutex_); - RwLock::ScopedWriteLock lock2(other.mutex_); + RwLock::ScopedReadLock lock2(other.mutex_); return do_add(); } - RwLock::ScopedWriteLock lock1(other.mutex_); + RwLock::ScopedReadLock lock1(other.mutex_); RwLock::ScopedWriteLock lock2(mutex_); return do_add(); } size_t Histogram::Count() const { RwLock::ScopedReadLock lock(mutex_); - return count_; + return static_cast(histogram_->total_count); } size_t Histogram::Exceeds() const { @@ -82,7 +82,7 @@ int64_t Histogram::Percentile(double percentile) const { } template -void Histogram::Percentiles(Iterator&& fn) { +void Histogram::Percentiles(Iterator&& fn) const { RwLock::ScopedReadLock lock(mutex_); hdr_iter iter; hdr_iter_percentile_init(&iter, histogram_.get(), 1); @@ -97,8 +97,6 @@ bool Histogram::Record(int64_t value) { bool recorded = hdr_record_value(histogram_.get(), value); if (!recorded) exceeds_++; - else - count_++; return recorded; } @@ -109,9 +107,7 @@ uint64_t Histogram::RecordDelta() { if (prev_ > 0) { CHECK_GE(time, prev_); delta = time - prev_; - if (hdr_record_value(histogram_.get(), delta)) - count_++; - else + if (!hdr_record_value(histogram_.get(), delta)) exceeds_++; } prev_ = time; diff --git a/src/histogram.cc b/src/histogram.cc index 19e17ed88005..70b8f4d67a0a 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -7,6 +7,8 @@ #include "node_external_reference.h" #include "util.h" +#include + namespace node { using v8::BigInt; @@ -674,12 +676,19 @@ void HistogramImpl::GetPercentiles(const FunctionCallbackInfo& args) { HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); CHECK(args[0]->IsMap()); Local map = args[0].As(); - (*histogram)->Percentiles([map, env](double key, int64_t value) { + + // Collect percentile data under the histogram lock, then populate the + // V8 Map after releasing it to avoid V8 allocations under the lock. + std::vector> entries; + (*histogram)->Percentiles([&entries](double key, int64_t value) { + entries.emplace_back(key, value); + }); + for (const auto& entry : entries) { USE(map->Set( env->context(), - Number::New(env->isolate(), key), - Number::New(env->isolate(), static_cast(value)))); - }); + Number::New(env->isolate(), entry.first), + Number::New(env->isolate(), static_cast(entry.second)))); + } } void HistogramImpl::GetPercentilesBigInt( @@ -688,12 +697,17 @@ void HistogramImpl::GetPercentilesBigInt( HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); CHECK(args[0]->IsMap()); Local map = args[0].As(); - (*histogram)->Percentiles([map, env](double key, int64_t value) { + + std::vector> entries; + (*histogram)->Percentiles([&entries](double key, int64_t value) { + entries.emplace_back(key, value); + }); + for (const auto& entry : entries) { USE(map->Set( env->context(), - Number::New(env->isolate(), key), - BigInt::New(env->isolate(), value))); - }); + Number::New(env->isolate(), entry.first), + BigInt::New(env->isolate(), entry.second))); + } } void HistogramImpl::DoReset(const FunctionCallbackInfo& args) { diff --git a/src/histogram.h b/src/histogram.h index deba00af16a1..ce0739fa7544 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -55,7 +55,7 @@ class Histogram : public MemoryRetainer { // Iterator is a function type that takes two doubles as argument, one for // percentile and one for the value at that percentile. template - inline void Percentiles(Iterator&& fn); + inline void Percentiles(Iterator&& fn) const; inline size_t GetMemorySize() const; @@ -68,7 +68,6 @@ class Histogram : public MemoryRetainer { HistogramPointer histogram_; uint64_t prev_ = 0; size_t exceeds_ = 0; - size_t count_ = 0; RwLock mutex_; }; From 4c5ff48e0cae6123517cfadb106099c584de7e8c Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 4 Aug 2026 09:31:54 -0700 Subject: [PATCH 4/4] perf_hooks: expand histogram analytical apis Adds new analytical APIs to Histogram * histogram.ccdf(value) * histogram.cdf(value) * histogram.countAt(value) * histogram.ksTest(other) * histogram.kurtosis * histogram.linearBuckets(stepSize) * histogram.logBuckets(first, base) * histogram.percentilesAt(percentiles) * histogram.shewness On RecordableHistogram * histogram.recordCorrected(val, expectedInterval) * histogram.subtract(other) Signed-off-by: James M Snell --- doc/api/perf_hooks.md | 240 +++++++++ lib/internal/histogram.js | 184 +++++++ src/histogram-inl.h | 41 +- src/histogram.cc | 435 +++++++++++++-- src/histogram.h | 126 +++-- .../test-perf-hooks-histogram-analysis.js | 501 ++++++++++++++++++ 6 files changed, 1436 insertions(+), 91 deletions(-) create mode 100644 test/parallel/test-perf-hooks-histogram-analysis.js diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index 0eca76ed9842..eb2076eb7921 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -1868,6 +1868,45 @@ added: The number of samples recorded by the histogram. +### `histogram.ccdf(value)` + + + +* `value` {number} The value to query. +* Returns: {number} A probability between 0.0 and 1.0. + +Returns the complementary cumulative distribution function (CCDF) value +for the given value, representing the probability that a recorded value +will exceed `value`. Equivalent to `1 - histogram.cdf(value)`. + +### `histogram.cdf(value)` + + + +* `value` {number} The value to query. +* Returns: {number} A probability between 0.0 and 1.0. + +Returns the cumulative distribution function (CDF) value for the given +value, representing the probability that a recorded value will be less +than or equal to `value`. This is the inverse operation of +`histogram.percentile()`. + +### `histogram.countAt(value)` + + + +* `value` {number} The value to query. +* Returns: {number} + +Returns the number of recorded values that fall within the equivalent +value range of the given value. + ### `histogram.exceeds` + +* `other` {Histogram} The histogram to compare against. +* Returns: {number} The KS D-statistic, between 0.0 and 1.0. + +Computes the Kolmogorov-Smirnov test statistic comparing this histogram's +distribution to `other`. A value of 0 indicates identical distributions; +values close to 1 indicate completely disjoint distributions. Useful for +detecting performance regressions by comparing before/after histograms. + +### `histogram.kurtosis` + + + +* Type: {number} + +The excess kurtosis of the recorded values. Measures the heaviness of the +distribution's tails relative to a normal distribution. Positive values +indicate heavier tails (more extreme outliers); negative values indicate +lighter tails. + +### `histogram.linearBuckets(stepSize)` + + + +* `stepSize` {number} The width of each linear bucket. +* Returns: {Map} A map of bucket boundary values to counts. + +Returns the histogram data rebucketed into linearly-spaced intervals +of `stepSize`. Useful for visualization and export. + +### `histogram.logBuckets(firstBucket, base)` + + + +* `firstBucket` {number} The value of the first bucket boundary. +* `base` {number} The logarithmic base for bucket width growth. Must be > 1. +* Returns: {Map} A map of bucket boundary values to counts. + +Returns the histogram data rebucketed into logarithmically-spaced +intervals, where each bucket's width is multiplied by `base`. +Useful for visualization and export. + ### `histogram.max` + +* `percentiles` {number\[]} An array of percentile values in the range (0, 100]. +* Returns: {Map} A map of percentile values to their corresponding histogram + values. + +Returns the values at the specified percentiles, computed in a single +efficient pass over the histogram data. More efficient than calling +`histogram.percentile()` multiple times. + ### `histogram.reset()` + +* Type: {number} + +The skewness of the recorded values. Measures the asymmetry of the +distribution. A positive value indicates a right-skewed distribution +(longer right tail, common for latency data); a negative value +indicates a left-skewed distribution. + ### `histogram.stddev` + +* `val` {number|bigint} The value to record. +* `expectedInterval` {number|bigint} The expected recording interval. + +Records a value with coordinated omission correction. When a system stall +prevents timely recording, this method backfills intermediate values at +`expectedInterval` steps between the previously recorded value and `val`. +This compensates for measurement gaps that would otherwise underrepresent +latency. + +### `histogram.subtract(other)` + + + +* `other` {RecordableHistogram} + +Subtracts the values of `other` from this histogram. Both histograms should +have compatible configurations. Bucket counts that would become negative +are clamped to zero. + +## Histogram analysis examples + +The `Histogram` class provides statistical analysis methods useful for +performance monitoring, SLO enforcement, and regression detection. + +### Distribution shape analysis + +```js +const { createHistogram } = require('node:perf_hooks'); + +const h = createHistogram(); + +// Simulate a right-skewed latency distribution +for (let i = 0; i < 1000; i++) { + h.record(Math.ceil(Math.random() * 100)); +} +// Add some outliers +for (let i = 0; i < 10; i++) { + h.record(500 + Math.ceil(Math.random() * 500)); +} + +console.log('Skewness:', h.skewness.toFixed(4)); // Positive = right-skewed +console.log('Kurtosis:', h.kurtosis.toFixed(4)); // Positive = heavy tails +``` + +### SLO monitoring with CDF + +```js +const { createHistogram } = require('node:perf_hooks'); + +const latency = createHistogram(); + +// Record request latencies (in nanoseconds)... + +// "What fraction of requests complete within 100ms?" +const withinSLO = latency.cdf(100_000_000); +console.log(`${(withinSLO * 100).toFixed(1)}% of requests within SLO`); + +// "What fraction of requests exceed 500ms?" +const violating = latency.ccdf(500_000_000); +console.log(`${(violating * 100).toFixed(1)}% of requests violating SLO`); +``` + +### Regression detection with KS test + +```js +const { createHistogram } = require('node:perf_hooks'); + +const baseline = createHistogram(); +const current = createHistogram(); + +// Record baseline and current latencies... + +// D-statistic: 0 = identical, 1 = completely different +const d = baseline.ksTest(current); +if (d > 0.1) { + console.log(`Possible regression detected (D=${d.toFixed(4)})`); +} +``` + +### Batch percentile queries + +```js +const { createHistogram } = require('node:perf_hooks'); + +const h = createHistogram(); +// Record values... + +// Efficiently query common monitoring percentiles in one pass +const p = h.percentilesAt([50, 75, 90, 95, 99, 99.9]); +console.log('p50:', p.get(50)); +console.log('p99:', p.get(99)); +``` + +### Snapshot diffing with subtract + +```js +const { createHistogram } = require('node:perf_hooks'); + +const total = createHistogram(); +const snapshot = createHistogram(); + +// Record values into total... +// Periodically snapshot for "last interval" analysis: +snapshot.add(total); + +// Later, take a new snapshot and diff: +const newSnapshot = createHistogram(); +newSnapshot.add(total); +newSnapshot.subtract(snapshot); +// newSnapshot now contains only the values recorded since the last snapshot +console.log('Recent p99:', newSnapshot.percentile(99)); +``` + ## Examples ### Measuring the duration of async operations diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index e308d76b66ed..c16c894dd147 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -1,6 +1,8 @@ 'use strict'; const { + ArrayIsArray, + Float64Array, Map, MapPrototypeEntries, NumberIsNaN, @@ -74,6 +76,8 @@ class Histogram { mean: this.mean, exceeds: this.exceeds, stddev: this.stddev, + skewness: this.skewness, + kurtosis: this.kurtosis, count: this.count, percentiles: this.percentiles, }, opts)}`; @@ -99,6 +103,46 @@ class Histogram { return this[kHandle]?.countBigInt(); } + /** + * Returns the probability that a recorded value will exceed `value` + * (the complement of the cumulative distribution function). + * @param {number} value + * @returns {number} A value between 0.0 and 1.0. + */ + ccdf(value) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateNumber(value, 'value'); + return 1 - this[kHandle]?.cdf(value); + } + + /** + * Returns the cumulative distribution function (CDF) value for the + * given value, representing the probability that a recorded value + * will be less than or equal to `value`. + * @param {number} value + * @returns {number} A value between 0.0 and 1.0. + */ + cdf(value) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateNumber(value, 'value'); + return this[kHandle]?.cdf(value); + } + + /** + * Returns the number of recorded values that fall within the + * equivalent value range of the given value. + * @param {number} value + * @returns {number} + */ + countAt(value) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateNumber(value, 'value'); + return this[kHandle]?.countAt(value); + } + /** * @readonly * @type {number} @@ -169,6 +213,81 @@ class Histogram { return this[kHandle]?.exceedsBigInt(); } + /** + * Returns the Kolmogorov-Smirnov test statistic comparing this + * histogram's distribution to another's. Returns a value between + * 0.0 (identical distributions) and 1.0 (completely disjoint). + * @param {Histogram} other + * @returns {number} + */ + ksTest(other) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + if (!isHistogram(other)) + throw new ERR_INVALID_ARG_TYPE('other', 'Histogram', other); + return this[kHandle]?.ksTest(other[kHandle]); + } + + /** + * Returns the excess kurtosis of the recorded values, a measure of + * the heaviness of the distribution's tails. A positive value indicates + * heavier tails (more outliers) than a normal distribution. + * @readonly + * @type {number} + */ + get kurtosis() { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + return this[kHandle]?.kurtosis(); + } + + /** + * Returns a {Map} containing the histogram data bucketed into + * linearly-spaced intervals of `stepSize`. + * @param {number} stepSize The width of each linear bucket. + * @returns {Map} + */ + linearBuckets(stepSize) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateInteger(stepSize, 'stepSize', 1); + const map = new Map(); + this[kHandle]?.linearBuckets(stepSize, map); + return map; + } + + /** + * Returns a {Map} containing the histogram data bucketed into + * logarithmically-spaced intervals. + * @param {number} firstBucket The value of the first bucket boundary. + * @param {number} base The logarithmic base for bucket width growth. + * @returns {Map} + */ + logBuckets(firstBucket, base) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateInteger(firstBucket, 'firstBucket', 1); + validateNumber(base, 'base'); + if (base <= 1) + throw new ERR_OUT_OF_RANGE('base', '> 1', base); + const map = new Map(); + this[kHandle]?.logBuckets(firstBucket, base, map); + return map; + } + + /** + * Returns the skewness of the recorded values, a measure of the + * asymmetry of the distribution. A positive value indicates a + * right-skewed distribution (longer right tail). + * @readonly + * @type {number} + */ + get skewness() { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + return this[kHandle]?.skewness(); + } + /** * @readonly * @type {number} @@ -231,6 +350,31 @@ class Histogram { return map; } + /** + * Returns a {Map} of values at the specified percentiles, computed + * in a single efficient pass over the histogram. + * @param {number[]} percentiles Array of percentile values (0, 100]. + * @returns {Map} + */ + percentilesAt(percentiles) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + if (!ArrayIsArray(percentiles)) + throw new ERR_INVALID_ARG_TYPE('percentiles', 'Array', percentiles); + for (let i = 0; i < percentiles.length; i++) { + validateNumber(percentiles[i], `percentiles[${i}]`); + if (NumberIsNaN(percentiles[i]) || + percentiles[i] <= 0 || percentiles[i] > 100) + throw new ERR_OUT_OF_RANGE( + `percentiles[${i}]`, '> 0 && <= 100', percentiles[i]); + } + const sorted = [...percentiles].sort((a, b) => a - b); + const input = new Float64Array(sorted); + const map = new Map(); + this[kHandle]?.percentilesAt(map, input); + return map; + } + /** * @returns {void} */ @@ -260,6 +404,8 @@ class Histogram { mean: this.mean, exceeds: this.exceeds, stddev: this.stddev, + skewness: this.skewness, + kurtosis: this.kurtosis, percentiles: ObjectFromEntries(MapPrototypeEntries(this.percentiles)), }; } @@ -300,6 +446,44 @@ class RecordableHistogram extends Histogram { this[kHandle]?.recordDelta(); } + /** + * Records a value with coordinated omission correction, backfilling + * intermediate values at `expectedInterval` steps between the last + * recorded value and `val`. This compensates for measurement gaps + * caused by the system being stalled. + * @param {number|bigint} val The amount to record. + * @param {number|bigint} expectedInterval The expected recording interval. + * @returns {void} + */ + recordCorrected(val, expectedInterval) { + if (this[kRecordable] === undefined) + throw new ERR_INVALID_THIS('RecordableHistogram'); + if (typeof val === 'bigint') { + if (typeof expectedInterval !== 'bigint') + throw new ERR_INVALID_ARG_TYPE( + 'expectedInterval', 'bigint', expectedInterval); + this[kHandle]?.recordCorrected(val, expectedInterval); + return; + } + validateInteger(val, 'val', 1); + validateInteger(expectedInterval, 'expectedInterval', 1); + this[kHandle]?.recordCorrected(val, expectedInterval); + } + + /** + * Subtracts the values of `other` from this histogram. Both + * histograms must have compatible configurations. Counts that would + * become negative are clamped to zero. + * @param {RecordableHistogram} other + */ + subtract(other) { + if (this[kRecordable] === undefined) + throw new ERR_INVALID_THIS('RecordableHistogram'); + if (other[kRecordable] === undefined) + throw new ERR_INVALID_ARG_TYPE('other', 'RecordableHistogram', other); + this[kHandle]?.subtract(other[kHandle]); + } + /** * @param {RecordableHistogram} other */ diff --git a/src/histogram-inl.h b/src/histogram-inl.h index 1b312c7bd68d..7c3545f53aad 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -92,11 +92,23 @@ void Histogram::Percentiles(Iterator&& fn) const { } } +int64_t Histogram::CountAt(int64_t value) const { + RwLock::ScopedReadLock lock(mutex_); + return hdr_count_at_value(histogram_.get(), value); +} + +bool Histogram::RecordCorrected(int64_t value, int64_t expected_interval) { + RwLock::ScopedWriteLock lock(mutex_); + bool recorded = + hdr_record_corrected_value(histogram_.get(), value, expected_interval); + if (!recorded) exceeds_++; + return recorded; +} + bool Histogram::Record(int64_t value) { RwLock::ScopedWriteLock lock(mutex_); bool recorded = hdr_record_value(histogram_.get(), value); - if (!recorded) - exceeds_++; + if (!recorded) exceeds_++; return recorded; } @@ -107,8 +119,7 @@ uint64_t Histogram::RecordDelta() { if (prev_ > 0) { CHECK_GE(time, prev_); delta = time - prev_; - if (!hdr_record_value(histogram_.get(), delta)) - exceeds_++; + if (!hdr_record_value(histogram_.get(), delta)) exceeds_++; } prev_ = time; return delta; @@ -119,6 +130,28 @@ size_t Histogram::GetMemorySize() const { return hdr_get_memory_size(histogram_.get()); } +template +void Histogram::LinearBuckets(int64_t step_size, Iterator&& fn) const { + RwLock::ScopedReadLock lock(mutex_); + hdr_iter iter; + hdr_iter_linear_init(&iter, histogram_.get(), step_size); + while (hdr_iter_next(&iter)) { + fn(iter.value, iter.specifics.linear.count_added_in_this_iteration_step); + } +} + +template +void Histogram::LogBuckets(int64_t first_bucket, + double log_base, + Iterator&& fn) const { + RwLock::ScopedReadLock lock(mutex_); + hdr_iter iter; + hdr_iter_log_init(&iter, histogram_.get(), first_bucket, log_base); + while (hdr_iter_next(&iter)) { + fn(iter.value, iter.specifics.log.count_added_in_this_iteration_step); + } +} + } // namespace node #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/histogram.cc b/src/histogram.cc index 70b8f4d67a0a..3aa451685e86 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -54,6 +54,169 @@ void Histogram::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackFieldWithSize("histogram", GetMemorySize()); } +bool Histogram::IsCompatible(const Histogram& other) const { + return histogram_->counts_len == other.histogram_->counts_len && + histogram_->lowest_discernible_value == + other.histogram_->lowest_discernible_value && + histogram_->highest_trackable_value == + other.histogram_->highest_trackable_value && + histogram_->significant_figures == + other.histogram_->significant_figures; +} + +double Histogram::Cdf(int64_t value) const { + RwLock::ScopedReadLock lock(mutex_); + int64_t total = histogram_->total_count; + if (total == 0) return 0.0; + + hdr_iter iter; + hdr_iter_init(&iter, histogram_.get()); + while (hdr_iter_next(&iter)) { + if (iter.highest_equivalent_value >= value) { + return static_cast(iter.cumulative_count) / + static_cast(total); + } + // All recorded data accounted for; remaining buckets are empty. + if (iter.cumulative_count >= total) break; + } + return 1.0; +} + +double Histogram::Skewness() const { + RwLock::ScopedReadLock lock(mutex_); + int64_t total = histogram_->total_count; + if (total < 3) return 0.0; + + // Compute mean in one pass, then variance and skewness in a second + // pass. This avoids calling hdr_stddev (which internally recomputes + // hdr_mean), reducing the total from 4 iterations to 2. + double mean = hdr_mean(histogram_.get()); + + double m2 = 0.0; + double m3 = 0.0; + hdr_iter iter; + hdr_iter_recorded_init(&iter, histogram_.get()); + while (hdr_iter_next(&iter)) { + double dev = static_cast(hdr_median_equivalent_value( + histogram_.get(), iter.value)) - + mean; + double d2 = dev * dev; + m2 += static_cast(iter.count) * d2; + m3 += static_cast(iter.count) * d2 * dev; + } + + double n = static_cast(total); + double variance = m2 / n; + if (variance == 0.0) return 0.0; + double s3 = variance * std::sqrt(variance); // stddev^3 + return (m3 / n) / s3; +} + +double Histogram::Kurtosis() const { + RwLock::ScopedReadLock lock(mutex_); + int64_t total = histogram_->total_count; + if (total < 4) return 0.0; + + // Same single-pass approach as Skewness: compute mean first, then + // variance and excess kurtosis together in one iteration. + double mean = hdr_mean(histogram_.get()); + + double m2 = 0.0; + double m4 = 0.0; + hdr_iter iter; + hdr_iter_recorded_init(&iter, histogram_.get()); + while (hdr_iter_next(&iter)) { + double dev = static_cast(hdr_median_equivalent_value( + histogram_.get(), iter.value)) - + mean; + double d2 = dev * dev; + m2 += static_cast(iter.count) * d2; + m4 += static_cast(iter.count) * d2 * d2; + } + + double n = static_cast(total); + double variance = m2 / n; + if (variance == 0.0) return 0.0; + double s4 = variance * variance; // stddev^4 + return (m4 / n) / s4 - 3.0; +} + +double Histogram::Subtract(const Histogram& other) { + auto do_subtract = [&]() -> double { + int64_t dropped = 0; + int32_t len = + std::min(histogram_->counts_len, other.histogram_->counts_len); + for (int32_t i = 0; i < len; i++) { + int64_t count = histogram_->counts[i] - other.histogram_->counts[i]; + if (count < 0) { + dropped += -count; + count = 0; + } + histogram_->counts[i] = count; + } + hdr_reset_internal_counters(histogram_.get()); + exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0; + return static_cast(dropped); + }; + + if (this == &other) { + RwLock::ScopedWriteLock lock(mutex_); + return do_subtract(); + } + + if (this < &other) { + RwLock::ScopedWriteLock lock1(mutex_); + RwLock::ScopedReadLock lock2(other.mutex_); + return do_subtract(); + } + + RwLock::ScopedReadLock lock1(other.mutex_); + RwLock::ScopedWriteLock lock2(mutex_); + return do_subtract(); +} + +double Histogram::KsTest(const Histogram& other) const { + auto do_ks = [&]() -> double { + int64_t n1 = histogram_->total_count; + int64_t n2 = other.histogram_->total_count; + if (n1 == 0 || n2 == 0) return 0.0; + + double max_d = 0.0; + int64_t cum1 = 0, cum2 = 0; + int32_t len = + std::max(histogram_->counts_len, other.histogram_->counts_len); + + for (int32_t i = 0; i < len; i++) { + if (i < histogram_->counts_len) cum1 += histogram_->counts[i]; + if (i < other.histogram_->counts_len) cum2 += other.histogram_->counts[i]; + double cdf1 = static_cast(cum1) / static_cast(n1); + double cdf2 = static_cast(cum2) / static_cast(n2); + double d = cdf1 > cdf2 ? cdf1 - cdf2 : cdf2 - cdf1; + if (d > max_d) max_d = d; + } + return max_d; + }; + + if (this == &other) return 0.0; + + if (this < &other) { + RwLock::ScopedReadLock lock1(mutex_); + RwLock::ScopedReadLock lock2(other.mutex_); + return do_ks(); + } + + RwLock::ScopedReadLock lock1(other.mutex_); + RwLock::ScopedReadLock lock2(mutex_); + return do_ks(); +} + +void Histogram::PercentilesAt(const double* percentiles, + int64_t* values, + size_t length) const { + RwLock::ScopedReadLock lock(mutex_); + hdr_value_at_percentiles(histogram_.get(), percentiles, values, length); +} + HistogramImpl::HistogramImpl(const Histogram::Options& options) : histogram_(new Histogram(options)) {} @@ -76,6 +239,14 @@ CFunction HistogramImpl::fast_get_stddev_( CFunction::Make(&HistogramImpl::FastGetStddev)); CFunction HistogramImpl::fast_get_percentile_( CFunction::Make(&HistogramImpl::FastGetPercentile)); +CFunction HistogramImpl::fast_get_skewness_( + CFunction::Make(&HistogramImpl::FastGetSkewness)); +CFunction HistogramImpl::fast_get_kurtosis_( + CFunction::Make(&HistogramImpl::FastGetKurtosis)); +CFunction HistogramImpl::fast_get_cdf_( + CFunction::Make(&HistogramImpl::FastGetCdf)); +CFunction HistogramImpl::fast_get_count_at_( + CFunction::Make(&HistogramImpl::FastGetCountAt)); CFunction HistogramBase::fast_record_( CFunction::Make(&HistogramBase::FastRecord)); CFunction HistogramBase::fast_record_delta_( @@ -114,6 +285,17 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local tmpl) { isolate, instance, "stddev", GetStddev, &fast_get_stddev_); SetFastMethodNoSideEffect( isolate, instance, "percentile", GetPercentile, &fast_get_percentile_); + SetFastMethodNoSideEffect( + isolate, instance, "skewness", GetSkewness, &fast_get_skewness_); + SetFastMethodNoSideEffect( + isolate, instance, "kurtosis", GetKurtosis, &fast_get_kurtosis_); + SetFastMethodNoSideEffect(isolate, instance, "cdf", GetCdf, &fast_get_cdf_); + SetFastMethodNoSideEffect( + isolate, instance, "countAt", GetCountAt, &fast_get_count_at_); + SetProtoMethodNoSideEffect(isolate, tmpl, "ksTest", GetKsTest); + SetProtoMethodNoSideEffect(isolate, tmpl, "percentilesAt", GetPercentilesAt); + SetProtoMethodNoSideEffect(isolate, tmpl, "linearBuckets", GetLinearBuckets); + SetProtoMethodNoSideEffect(isolate, tmpl, "logBuckets", GetLogBuckets); SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_); } @@ -144,6 +326,18 @@ void HistogramImpl::RegisterExternalReferences( registry->Register(fast_get_exceeds_); registry->Register(fast_get_stddev_); registry->Register(fast_get_percentile_); + registry->Register(GetSkewness); + registry->Register(GetKurtosis); + registry->Register(GetCdf); + registry->Register(GetCountAt); + registry->Register(GetKsTest); + registry->Register(GetPercentilesAt); + registry->Register(GetLinearBuckets); + registry->Register(GetLogBuckets); + registry->Register(fast_get_skewness_); + registry->Register(fast_get_kurtosis_); + registry->Register(fast_get_cdf_); + registry->Register(fast_get_count_at_); is_registered = true; } @@ -225,6 +419,39 @@ void HistogramBase::Add(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(count); } +void HistogramBase::Subtract(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramBase* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + + CHECK(GetConstructorTemplate(env->isolate_data())->HasInstance(args[0])); + HistogramBase* other; + ASSIGN_OR_RETURN_UNWRAP(&other, args[0]); + + double dropped = (*histogram)->Subtract(*(other->histogram())); + args.GetReturnValue().Set(dropped); +} + +void HistogramBase::RecordCorrected(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt()); + CHECK_IMPLIES(!args[1]->IsNumber(), args[1]->IsBigInt()); + bool lossless = true; + int64_t value = args[0]->IsBigInt() + ? args[0].As()->Int64Value(&lossless) + : static_cast(args[0].As()->Value()); + if (!lossless || value < 1) + return THROW_ERR_OUT_OF_RANGE(env, "value is out of range"); + int64_t expected_interval = + args[1]->IsBigInt() ? args[1].As()->Int64Value(&lossless) + : static_cast(args[1].As()->Value()); + if (!lossless || expected_interval < 1) + return THROW_ERR_OUT_OF_RANGE(env, "expected_interval is out of range"); + HistogramBase* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + (*histogram)->RecordCorrected(value, expected_interval); +} + BaseObjectPtr HistogramBase::Create( Environment* env, const Histogram::Options& options) { @@ -301,6 +528,8 @@ Local HistogramBase::GetConstructorTemplate( SetFastMethod( isolate, instance, "recordDelta", RecordDelta, &fast_record_delta_); SetProtoMethod(isolate, tmpl, "add", Add); + SetProtoMethod(isolate, tmpl, "subtract", Subtract); + SetProtoMethod(isolate, tmpl, "recordCorrected", RecordCorrected); HistogramImpl::AddMethods(isolate, tmpl); isolate_data->set_histogram_ctor_template(tmpl); } @@ -311,8 +540,10 @@ void HistogramBase::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(New); registry->Register(Add); + registry->Register(Subtract); registry->Register(Record); registry->Register(RecordDelta); + registry->Register(RecordCorrected); registry->Register(fast_record_); registry->Register(fast_record_delta_); HistogramImpl::RegisterExternalReferences(registry); @@ -351,11 +582,7 @@ Local IntervalHistogram::GetConstructorTemplate( tmpl = NewFunctionTemplate(isolate, nullptr); tmpl->Inherit(HandleWrap::GetConstructorTemplate(env)); tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "Histogram")); - auto instance = tmpl->InstanceTemplate(); - instance->SetInternalFieldCount(IntervalHistogram::kInternalFieldCount); - HistogramImpl::AddMethods(isolate, tmpl); - SetFastMethod(isolate, instance, "start", Start, &fast_start_); - SetFastMethod(isolate, instance, "stop", Stop, &fast_stop_); + InitTemplate(isolate, tmpl, IntervalHistogram::kInternalFieldCount); env->set_intervalhistogram_constructor_template(tmpl); } return tmpl; @@ -370,21 +597,16 @@ void IntervalHistogram::RegisterExternalReferences( HistogramImpl::RegisterExternalReferences(registry); } -IntervalHistogram::IntervalHistogram( - Environment* env, - Local wrap, - AsyncWrap::ProviderType type, - int32_t interval, - std::function on_interval, - const Histogram::Options& options) - : HandleWrap( - env, - wrap, - reinterpret_cast(&timer_), - type), +IntervalHistogram::IntervalHistogram(Environment* env, + Local wrap, + AsyncWrap::ProviderType type, + int32_t interval, + OnInterval on_interval, + const Histogram::Options& options) + : HandleWrap(env, wrap, reinterpret_cast(&timer_), type), HistogramImpl(options), interval_(interval), - on_interval_(std::move(on_interval)) { + on_interval_(on_interval) { MakeWeak(); wrap->SetAlignedPointerInInternalField( HistogramImpl::InternalFields::kImplField, @@ -396,8 +618,9 @@ IntervalHistogram::IntervalHistogram( BaseObjectPtr IntervalHistogram::Create( Environment* env, int32_t interval, - std::function on_interval, - const Histogram::Options& options) { + OnInterval on_interval, + const Histogram::Options& options, + AsyncWrap::ProviderType type) { Local obj; if (!GetConstructorTemplate(env) ->InstanceTemplate() @@ -406,12 +629,7 @@ BaseObjectPtr IntervalHistogram::Create( } return MakeBaseObject( - env, - obj, - AsyncWrap::PROVIDER_ELDHISTOGRAM, - interval, - std::move(on_interval), - options); + env, obj, type, interval, on_interval, options); } void IntervalHistogram::TimerCB(uv_timer_t* handle) { @@ -442,19 +660,11 @@ void IntervalHistogram::OnStop() { uv_timer_stop(&timer_); } -void IntervalHistogram::Start(const FunctionCallbackInfo& args) { - StartHandleHistogram(args.This(), args[0]->IsTrue()); -} - void IntervalHistogram::FastStart(Local receiver, bool reset) { TRACK_V8_FAST_API_CALL("histogram.start"); StartHandleHistogram(receiver, reset); } -void IntervalHistogram::Stop(const FunctionCallbackInfo& args) { - StopHandleHistogram(args.This()); -} - void IntervalHistogram::FastStop(Local receiver) { TRACK_V8_FAST_API_CALL("histogram.stop"); StopHandleHistogram(receiver); @@ -468,11 +678,7 @@ Local IterationHistogram::GetConstructorTemplate( tmpl = NewFunctionTemplate(isolate, nullptr); tmpl->Inherit(HandleWrap::GetConstructorTemplate(env)); tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "Histogram")); - auto instance = tmpl->InstanceTemplate(); - instance->SetInternalFieldCount(IterationHistogram::kInternalFieldCount); - HistogramImpl::AddMethods(isolate, tmpl); - SetFastMethod(isolate, instance, "start", Start, &fast_start_); - SetFastMethod(isolate, instance, "stop", Stop, &fast_stop_); + InitTemplate(isolate, tmpl, IterationHistogram::kInternalFieldCount); env->set_iterationhistogram_constructor_template(tmpl); } return tmpl; @@ -506,7 +712,9 @@ IterationHistogram::IterationHistogram(Environment* env, } BaseObjectPtr IterationHistogram::Create( - Environment* env, const Histogram::Options& options) { + Environment* env, + const Histogram::Options& options, + AsyncWrap::ProviderType type) { Local obj; if (!GetConstructorTemplate(env) ->InstanceTemplate() @@ -515,8 +723,7 @@ BaseObjectPtr IterationHistogram::Create( return nullptr; } - return MakeBaseObject( - env, obj, AsyncWrap::PROVIDER_ELDHISTOGRAM, options); + return MakeBaseObject(env, obj, type, options); } void IterationHistogram::PrepareCB(uv_prepare_t* handle) { @@ -578,19 +785,11 @@ void IterationHistogram::Close(Local close_callback) { uv_close(reinterpret_cast(&prepare_handle_), nullptr); } -void IterationHistogram::Start(const FunctionCallbackInfo& args) { - StartHandleHistogram(args.This(), args[0]->IsTrue()); -} - void IterationHistogram::FastStart(Local receiver, bool reset) { TRACK_V8_FAST_API_CALL("histogram.eventLoopDelay.start"); StartHandleHistogram(receiver, reset); } -void IterationHistogram::Stop(const FunctionCallbackInfo& args) { - StopHandleHistogram(args.This()); -} - void IterationHistogram::FastStop(Local receiver) { TRACK_V8_FAST_API_CALL("histogram.eventLoopDelay.stop"); StopHandleHistogram(receiver); @@ -685,9 +884,9 @@ void HistogramImpl::GetPercentiles(const FunctionCallbackInfo& args) { }); for (const auto& entry : entries) { USE(map->Set( - env->context(), - Number::New(env->isolate(), entry.first), - Number::New(env->isolate(), static_cast(entry.second)))); + env->context(), + Number::New(env->isolate(), entry.first), + Number::New(env->isolate(), static_cast(entry.second)))); } } @@ -703,10 +902,9 @@ void HistogramImpl::GetPercentilesBigInt( entries.emplace_back(key, value); }); for (const auto& entry : entries) { - USE(map->Set( - env->context(), - Number::New(env->isolate(), entry.first), - BigInt::New(env->isolate(), entry.second))); + USE(map->Set(env->context(), + Number::New(env->isolate(), entry.first), + BigInt::New(env->isolate(), entry.second))); } } @@ -764,6 +962,129 @@ double HistogramImpl::FastGetPercentile(Local receiver, return static_cast((*histogram)->Percentile(percentile)); } +void HistogramImpl::GetSkewness(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + args.GetReturnValue().Set((*histogram)->Skewness()); +} + +double HistogramImpl::FastGetSkewness(Local receiver) { + TRACK_V8_FAST_API_CALL("histogram.skewness"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return (*histogram)->Skewness(); +} + +void HistogramImpl::GetKurtosis(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + args.GetReturnValue().Set((*histogram)->Kurtosis()); +} + +double HistogramImpl::FastGetKurtosis(Local receiver) { + TRACK_V8_FAST_API_CALL("histogram.kurtosis"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return (*histogram)->Kurtosis(); +} + +void HistogramImpl::GetCdf(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + int64_t value = static_cast(args[0].As()->Value()); + args.GetReturnValue().Set((*histogram)->Cdf(value)); +} + +double HistogramImpl::FastGetCdf(Local receiver, const int64_t value) { + TRACK_V8_FAST_API_CALL("histogram.cdf"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return (*histogram)->Cdf(value); +} + +void HistogramImpl::GetCountAt(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + int64_t value = static_cast(args[0].As()->Value()); + double count = static_cast((*histogram)->CountAt(value)); + args.GetReturnValue().Set(count); +} + +double HistogramImpl::FastGetCountAt(Local receiver, + const int64_t value) { + TRACK_V8_FAST_API_CALL("histogram.countAt"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return static_cast((*histogram)->CountAt(value)); +} + +void HistogramImpl::GetKsTest(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + HistogramImpl* other = HistogramImpl::FromJSObject(args[0]); + args.GetReturnValue().Set((*histogram)->KsTest(*(other->histogram()))); +} + +void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsMap()); + Local map = args[0].As(); + CHECK(args[1]->IsFloat64Array()); + Local input = args[1].As(); + size_t length = input->Length(); + auto backing = input->Buffer()->GetBackingStore(); + double* percentiles = reinterpret_cast( + static_cast(backing->Data()) + input->ByteOffset()); + + std::vector values(length); + (*histogram)->PercentilesAt(percentiles, values.data(), length); + + for (size_t i = 0; i < length; i++) { + USE(map->Set(env->context(), + Number::New(env->isolate(), percentiles[i]), + Number::New(env->isolate(), static_cast(values[i])))); + } +} + +void HistogramImpl::GetLinearBuckets(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + CHECK(args[1]->IsMap()); + int64_t step_size = static_cast(args[0].As()->Value()); + Local map = args[1].As(); + + std::vector> entries; + (*histogram) + ->LinearBuckets(step_size, [&entries](int64_t value, int64_t count) { + entries.emplace_back(value, count); + }); + for (const auto& entry : entries) { + USE(map->Set( + env->context(), + Number::New(env->isolate(), static_cast(entry.first)), + Number::New(env->isolate(), static_cast(entry.second)))); + } +} + +void HistogramImpl::GetLogBuckets(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + CHECK(args[1]->IsNumber()); + CHECK(args[2]->IsMap()); + int64_t first_bucket = static_cast(args[0].As()->Value()); + double log_base = args[1].As()->Value(); + Local map = args[2].As(); + + std::vector> entries; + (*histogram) + ->LogBuckets( + first_bucket, log_base, [&entries](int64_t value, int64_t count) { + entries.emplace_back(value, count); + }); + for (const auto& entry : entries) { + USE(map->Set( + env->context(), + Number::New(env->isolate(), static_cast(entry.first)), + Number::New(env->isolate(), static_cast(entry.second)))); + } +} + HistogramImpl* HistogramImpl::FromJSObject(Local value) { auto obj = value.As(); DCHECK_GE(obj->InternalFieldCount(), HistogramImpl::kInternalFieldCount); diff --git a/src/histogram.h b/src/histogram.h index ce0739fa7544..5fbffa2a4879 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -11,10 +11,7 @@ #include "uv.h" #include "v8.h" -#include #include -#include -#include namespace node { @@ -59,6 +56,27 @@ class Histogram : public MemoryRetainer { inline size_t GetMemorySize() const; + // Analysis methods + inline int64_t CountAt(int64_t value) const; + double Cdf(int64_t value) const; + double Skewness() const; + double Kurtosis() const; + double KsTest(const Histogram& other) const; + double Subtract(const Histogram& other); + void PercentilesAt(const double* percentiles, + int64_t* values, + size_t length) const; + + inline bool RecordCorrected(int64_t value, int64_t expected_interval); + + template + void LinearBuckets(int64_t step_size, Iterator&& fn) const; + + template + void LogBuckets(int64_t first_bucket, double log_base, Iterator&& fn) const; + + bool IsCompatible(const Histogram& other) const; + void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(Histogram) SET_SELF_SIZE(Histogram) @@ -105,6 +123,15 @@ class HistogramImpl { static void GetPercentilesBigInt( const v8::FunctionCallbackInfo& args); + static void GetSkewness(const v8::FunctionCallbackInfo& args); + static void GetKurtosis(const v8::FunctionCallbackInfo& args); + static void GetCdf(const v8::FunctionCallbackInfo& args); + static void GetCountAt(const v8::FunctionCallbackInfo& args); + static void GetKsTest(const v8::FunctionCallbackInfo& args); + static void GetPercentilesAt(const v8::FunctionCallbackInfo& args); + static void GetLinearBuckets(const v8::FunctionCallbackInfo& args); + static void GetLogBuckets(const v8::FunctionCallbackInfo& args); + static void FastReset(v8::Local receiver); static double FastGetCount(v8::Local receiver); static double FastGetMin(v8::Local receiver); @@ -114,6 +141,11 @@ class HistogramImpl { static double FastGetStddev(v8::Local receiver); static double FastGetPercentile(v8::Local receiver, const double percentile); + static double FastGetSkewness(v8::Local receiver); + static double FastGetKurtosis(v8::Local receiver); + static double FastGetCdf(v8::Local receiver, const int64_t value); + static double FastGetCountAt(v8::Local receiver, + const int64_t value); static void AddMethods(v8::Isolate* isolate, v8::Local tmpl); @@ -133,6 +165,10 @@ class HistogramImpl { static v8::CFunction fast_get_exceeds_; static v8::CFunction fast_get_stddev_; static v8::CFunction fast_get_percentile_; + static v8::CFunction fast_get_skewness_; + static v8::CFunction fast_get_kurtosis_; + static v8::CFunction fast_get_cdf_; + static v8::CFunction fast_get_count_at_; }; class HistogramBase final : public BaseObject, public HistogramImpl { @@ -164,7 +200,9 @@ class HistogramBase final : public BaseObject, public HistogramImpl { static void Record(const v8::FunctionCallbackInfo& args); static void RecordDelta(const v8::FunctionCallbackInfo& args); + static void RecordCorrected(const v8::FunctionCallbackInfo& args); static void Add(const v8::FunctionCallbackInfo& args); + static void Subtract(const v8::FunctionCallbackInfo& args); static void FastRecord(v8::Local receiver, const int64_t value); static void FastRecordDelta(v8::Local receiver); @@ -210,17 +248,48 @@ class HistogramBase final : public BaseObject, public HistogramImpl { static v8::CFunction fast_record_delta_; }; -class IntervalHistogram final : public HandleWrap, public HistogramImpl { +// CRTP mixin for HandleWrap-based histograms with start/stop support. +// Provides: StartFlags enum, Start/Stop slow-path handlers, enabled_ flag, +// and InitTemplate (shared GetConstructorTemplate body). +// Derived must provide: fast_start_, fast_stop_ (static CFunction), +// FastStart, FastStop, OnStart, OnStop. +template +class HandleHistogramMixin { + public: + enum class StartFlags { NONE, RESET }; + + static void Start(const v8::FunctionCallbackInfo& args) { + StartHandleHistogram(args.This(), args[0]->IsTrue()); + } + + static void Stop(const v8::FunctionCallbackInfo& args) { + StopHandleHistogram(args.This()); + } + + protected: + static void InitTemplate(v8::Isolate* isolate, + v8::Local tmpl, + uint32_t internal_field_count) { + auto instance = tmpl->InstanceTemplate(); + instance->SetInternalFieldCount(internal_field_count); + HistogramImpl::AddMethods(isolate, tmpl); + SetFastMethod(isolate, instance, "start", Start, &Derived::fast_start_); + SetFastMethod(isolate, instance, "stop", Stop, &Derived::fast_stop_); + } + + bool enabled_ = false; +}; + +class IntervalHistogram final : public HandleWrap, + public HistogramImpl, + public HandleHistogramMixin { public: enum InternalFields { kInternalFieldCount = std::max( HandleWrap::kInternalFieldCount, HistogramImpl::kInternalFieldCount), }; - enum class StartFlags { - NONE, - RESET - }; + using OnInterval = void (*)(Histogram&); static void RegisterExternalReferences(ExternalReferenceRegistry* registry); @@ -230,19 +299,16 @@ class IntervalHistogram final : public HandleWrap, public HistogramImpl { static BaseObjectPtr Create( Environment* env, int32_t interval, - std::function on_interval, - const Histogram::Options& options); - - IntervalHistogram( - Environment* env, - v8::Local wrap, - AsyncWrap::ProviderType type, - int32_t interval, - std::function on_interval, - const Histogram::Options& options = Histogram::Options {}); + OnInterval on_interval, + const Histogram::Options& options, + AsyncWrap::ProviderType type = AsyncWrap::PROVIDER_ELDHISTOGRAM); - static void Start(const v8::FunctionCallbackInfo& args); - static void Stop(const v8::FunctionCallbackInfo& args); + IntervalHistogram(Environment* env, + v8::Local wrap, + AsyncWrap::ProviderType type, + int32_t interval, + OnInterval on_interval, + const Histogram::Options& options = Histogram::Options{}); static void FastStart(v8::Local receiver, bool reset); static void FastStop(v8::Local receiver); @@ -261,45 +327,45 @@ class IntervalHistogram final : public HandleWrap, public HistogramImpl { void OnStart(StartFlags flags = StartFlags::RESET); void OnStop(); + friend class HandleHistogramMixin; template friend void StartHandleHistogram(v8::Local, bool); template friend void StopHandleHistogram(v8::Local); - bool enabled_ = false; int32_t interval_ = 0; - std::function on_interval_; + OnInterval on_interval_ = nullptr; uv_timer_t timer_; static v8::CFunction fast_start_; static v8::CFunction fast_stop_; }; -class IterationHistogram final : public HandleWrap, public HistogramImpl { +class IterationHistogram final + : public HandleWrap, + public HistogramImpl, + public HandleHistogramMixin { public: enum InternalFields { kInternalFieldCount = std::max( HandleWrap::kInternalFieldCount, HistogramImpl::kInternalFieldCount), }; - enum class StartFlags { NONE, RESET }; - static void RegisterExternalReferences(ExternalReferenceRegistry* registry); static v8::Local GetConstructorTemplate( Environment* env); static BaseObjectPtr Create( - Environment* env, const Histogram::Options& options); + Environment* env, + const Histogram::Options& options, + AsyncWrap::ProviderType type = AsyncWrap::PROVIDER_ELDHISTOGRAM); IterationHistogram(Environment* env, v8::Local wrap, AsyncWrap::ProviderType type, const Histogram::Options& options = Histogram::Options{}); - static void Start(const v8::FunctionCallbackInfo& args); - static void Stop(const v8::FunctionCallbackInfo& args); - static void FastStart(v8::Local receiver, bool reset); static void FastStop(v8::Local receiver); @@ -321,12 +387,12 @@ class IterationHistogram final : public HandleWrap, public HistogramImpl { void OnStart(StartFlags flags = StartFlags::RESET); void OnStop(); + friend class HandleHistogramMixin; template friend void StartHandleHistogram(v8::Local, bool); template friend void StopHandleHistogram(v8::Local); - bool enabled_ = false; uv_prepare_t prepare_handle_; uv_check_t check_handle_; uint64_t prepare_time_ = 0; diff --git a/test/parallel/test-perf-hooks-histogram-analysis.js b/test/parallel/test-perf-hooks-histogram-analysis.js new file mode 100644 index 000000000000..acc2b5a7eb2d --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-analysis.js @@ -0,0 +1,501 @@ +// Flags: --expose-internals --no-warnings --allow-natives-syntax +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { createHistogram } = require('perf_hooks'); +const { internalBinding } = require('internal/test/binding'); +const { inspect } = require('util'); + +// --------------------------------------------------------------------------- +// cdf(value) — cumulative distribution function +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Empty histogram returns 0 + assert.strictEqual(h.cdf(1), 0); + + for (let i = 1; i <= 5; i++) h.record(i); + + // Below min → 0 + assert.strictEqual(h.cdf(0), 0); + + // At or above some values → monotonically increasing + assert.ok(h.cdf(1) > 0); + assert.ok(h.cdf(3) >= h.cdf(1)); + assert.ok(h.cdf(5) >= h.cdf(3)); + + // Well above max → 1.0 + assert.strictEqual(h.cdf(1000000), 1.0); + + // Validation + assert.throws(() => h.cdf('hello'), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.cdf(), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.cdf(undefined), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// ccdf(value) — complementary CDF = 1 - cdf +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Empty: cdf=0 so ccdf=1 + assert.strictEqual(h.ccdf(1), 1); + + for (let i = 1; i <= 5; i++) h.record(i); + + // CCDF + CDF === 1 for all values + for (const v of [0, 1, 3, 5, 1000000]) { + const sum = h.ccdf(v) + h.cdf(v); + assert.ok(Math.abs(sum - 1) < 1e-10, `ccdf(${v})+cdf(${v})=${sum}`); + } + + // Well above max → 0 + assert.strictEqual(h.ccdf(1000000), 0); + + // Validation + assert.throws(() => h.ccdf('hello'), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// countAt(value) — count in equivalent bucket +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Empty → 0 + assert.strictEqual(h.countAt(1), 0); + + h.record(1); + h.record(1); + h.record(1); + h.record(100); + + assert.strictEqual(h.countAt(1), 3); + assert.strictEqual(h.countAt(100), 1); + assert.strictEqual(h.countAt(999999), 0); + + // Validation + assert.throws(() => h.countAt('hello'), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.countAt(), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// skewness getter +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Too few values returns 0 + assert.strictEqual(h.skewness, 0); + h.record(1); + assert.strictEqual(h.skewness, 0); + h.record(2); + assert.strictEqual(h.skewness, 0); + + // With 3+ values, returns a number + h.record(3); + assert.strictEqual(typeof h.skewness, 'number'); + assert.ok(!Number.isNaN(h.skewness)); + + // Right-skewed distribution → positive skewness + const right = createHistogram(); + for (let i = 0; i < 100; i++) right.record(1); + for (let i = 0; i < 10; i++) right.record(10000); + assert.ok(right.skewness > 0); + + // Appears in inspect output + assert.ok(inspect(right, { depth: null }).includes('skewness')); + + // Appears in toJSON + const json = right.toJSON(); + assert.ok('skewness' in json); + assert.strictEqual(typeof json.skewness, 'number'); + + // Uniform distribution: zero stddev → returns 0 + const uniform = createHistogram(); + for (let i = 0; i < 10; i++) uniform.record(1); + assert.strictEqual(uniform.skewness, 0); +} + +// --------------------------------------------------------------------------- +// kurtosis getter +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Too few values returns 0 + assert.strictEqual(h.kurtosis, 0); + h.record(1); + h.record(2); + h.record(3); + assert.strictEqual(h.kurtosis, 0); + + // With 4+ values, returns a number + h.record(4); + assert.strictEqual(typeof h.kurtosis, 'number'); + assert.ok(!Number.isNaN(h.kurtosis)); + + // Appears in inspect and toJSON + const h2 = createHistogram(); + for (let i = 1; i <= 100; i++) h2.record(i); + assert.ok(inspect(h2, { depth: null }).includes('kurtosis')); + const json = h2.toJSON(); + assert.ok('kurtosis' in json); + assert.strictEqual(typeof json.kurtosis, 'number'); + + // Uniform distribution: zero stddev → returns 0 + const uniform = createHistogram(); + for (let i = 0; i < 10; i++) uniform.record(1); + assert.strictEqual(uniform.kurtosis, 0); +} + +// --------------------------------------------------------------------------- +// ksTest(other) — Kolmogorov-Smirnov D-statistic +// --------------------------------------------------------------------------- +{ + const h1 = createHistogram(); + const h2 = createHistogram(); + + // Both empty → 0 + assert.strictEqual(h1.ksTest(h2), 0); + + // Identical distributions → 0 + for (let i = 1; i <= 100; i++) { h1.record(i); h2.record(i); } + assert.strictEqual(h1.ksTest(h2), 0); + + // Same histogram against itself → 0 + assert.strictEqual(h1.ksTest(h1), 0); + + // Different distributions → D > 0 + const h3 = createHistogram(); + for (let i = 1000; i <= 2000; i++) h3.record(i); + const d = h1.ksTest(h3); + assert.ok(d > 0); + assert.ok(d <= 1); + + // Symmetry: D(a,b) === D(b,a) + assert.strictEqual(h1.ksTest(h3), h3.ksTest(h1)); + + // Completely disjoint → D close to 1 + const hLow = createHistogram(); + const hHigh = createHistogram(); + for (let i = 0; i < 100; i++) hLow.record(1); + for (let i = 0; i < 100; i++) hHigh.record(100000); + assert.ok(hLow.ksTest(hHigh) > 0.9); + + // One empty → 0 + const empty = createHistogram(); + assert.strictEqual(h1.ksTest(empty), 0); + + // Validation: non-histogram throws + assert.throws(() => h1.ksTest('not a histogram'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.ksTest(42), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.ksTest({}), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// percentilesAt(percentiles) — batch percentile query +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + for (let i = 1; i <= 100; i++) h.record(i); + + // Returns a Map + const result = h.percentilesAt([50, 90, 99]); + assert.ok(result instanceof Map); + assert.strictEqual(result.size, 3); + + // Keys are the requested percentiles + assert.ok(result.has(50)); + assert.ok(result.has(90)); + assert.ok(result.has(99)); + + // Values match individual percentile() calls + assert.strictEqual(result.get(50), h.percentile(50)); + assert.strictEqual(result.get(90), h.percentile(90)); + assert.strictEqual(result.get(99), h.percentile(99)); + + // Single element + const single = h.percentilesAt([50]); + assert.strictEqual(single.size, 1); + + // Unsorted input still works (internally sorted) + const unsorted = h.percentilesAt([99, 50, 90]); + assert.strictEqual(unsorted.get(50), h.percentile(50)); + + // Validation + assert.throws(() => h.percentilesAt('not array'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.percentilesAt([0]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt([101]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt([NaN]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt([-1]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt(['hello']), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// linearBuckets(stepSize) — linearly-spaced bucket iteration +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + for (let i = 1; i <= 100; i++) h.record(i); + + const buckets = h.linearBuckets(10); + assert.ok(buckets instanceof Map); + assert.ok(buckets.size > 0); + + // All keys and values are numbers + for (const [key, value] of buckets) { + assert.strictEqual(typeof key, 'number'); + assert.strictEqual(typeof value, 'number'); + assert.ok(value >= 0); + } + + // Total count across buckets equals histogram count + let total = 0; + for (const [, count] of buckets) total += count; + assert.strictEqual(total, h.count); + + // Different step sizes produce different bucket counts + const finer = h.linearBuckets(5); + assert.ok(finer.size >= buckets.size); + + // Validation + assert.throws(() => h.linearBuckets(0), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.linearBuckets(-1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.linearBuckets('hello'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.linearBuckets(1.5), { code: 'ERR_OUT_OF_RANGE' }); +} + +// --------------------------------------------------------------------------- +// logBuckets(firstBucket, base) — logarithmically-spaced bucket iteration +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + for (let i = 1; i <= 1000; i++) h.record(i); + + const buckets = h.logBuckets(1, 2); + assert.ok(buckets instanceof Map); + assert.ok(buckets.size > 0); + + for (const [key, value] of buckets) { + assert.strictEqual(typeof key, 'number'); + assert.strictEqual(typeof value, 'number'); + assert.ok(value >= 0); + } + + // Total count across buckets equals histogram count + let total = 0; + for (const [, count] of buckets) total += count; + assert.strictEqual(total, h.count); + + // Validation + assert.throws(() => h.logBuckets(0, 2), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(-1, 2), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(1, 1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(1, 0.5), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(1, -2), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets('hello', 2), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.logBuckets(1, 'hello'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.logBuckets(1.5, 2), { code: 'ERR_OUT_OF_RANGE' }); +} + +// --------------------------------------------------------------------------- +// subtract(other) — subtract histogram counts +// --------------------------------------------------------------------------- +{ + const h1 = createHistogram(); + const h2 = createHistogram(); + + for (let i = 1; i <= 10; i++) h1.record(i); + for (let i = 1; i <= 5; i++) h2.record(i); + + const countBefore = h1.count; + h1.subtract(h2); + + // Count should decrease + assert.ok(h1.count < countBefore); + + // Subtracting from self zeros out + const h3 = createHistogram(); + for (let i = 1; i <= 10; i++) h3.record(i); + h3.subtract(h3); + assert.strictEqual(h3.count, 0); + + // Clamping: subtracting more than present doesn't go negative + const hSmall = createHistogram(); + const hBig = createHistogram(); + hSmall.record(1); + for (let i = 0; i < 100; i++) hBig.record(1); + hSmall.subtract(hBig); + assert.strictEqual(hSmall.count, 0); + + // Validation + assert.throws(() => h1.subtract('not a histogram'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.subtract(42), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.subtract({}), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// recordCorrected(val, expectedInterval) — coordinated omission correction +// --------------------------------------------------------------------------- +{ + // Basic recording with number args + const h = createHistogram(); + h.recordCorrected(100, 10); + assert.ok(h.count > 0); + + // Should record more values than a plain record (backfilling) + const hPlain = createHistogram(); + hPlain.record(100); + assert.ok(h.count > hPlain.count); + + // BigInt variant + const hBig = createHistogram(); + hBig.recordCorrected(100n, 10n); + assert.ok(hBig.count > 0); + + // Mixed types should throw (bigint val, number interval) + assert.throws(() => h.recordCorrected(100n, 10), + { code: 'ERR_INVALID_ARG_TYPE' }); + + // Validation: non-integer + assert.throws(() => h.recordCorrected('hello', 10), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.recordCorrected(100, 'hello'), + { code: 'ERR_INVALID_ARG_TYPE' }); + + // Out of range + assert.throws(() => h.recordCorrected(0, 10), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.recordCorrected(100, 0), + { code: 'ERR_OUT_OF_RANGE' }); +} + +// --------------------------------------------------------------------------- +// ERR_INVALID_THIS for all new methods on wrong receiver +// --------------------------------------------------------------------------- +{ + const { Histogram } = require('internal/histogram'); + const h = createHistogram(); + const wrongThis = {}; + + // Methods + const methods = [ + ['cdf', [1]], + ['ccdf', [1]], + ['countAt', [1]], + ['ksTest', [h]], + ['linearBuckets', [10]], + ['logBuckets', [1, 2]], + ['percentilesAt', [[50]]], + ]; + + for (const [method, args] of methods) { + assert.throws( + () => Histogram.prototype[method].call(wrongThis, ...args), + { code: 'ERR_INVALID_THIS' }, + `${method} should throw ERR_INVALID_THIS` + ); + } + + // Getters + for (const getter of ['skewness', 'kurtosis']) { + const desc = Object.getOwnPropertyDescriptor( + Histogram.prototype, getter); + assert.throws( + () => desc.get.call(wrongThis), + { code: 'ERR_INVALID_THIS' }, + `${getter} getter should throw ERR_INVALID_THIS` + ); + } +} + +// --------------------------------------------------------------------------- +// Empty histogram edge cases +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + assert.strictEqual(h.cdf(1), 0); + assert.strictEqual(h.ccdf(1), 1); + assert.strictEqual(h.countAt(1), 0); + assert.strictEqual(h.skewness, 0); + assert.strictEqual(h.kurtosis, 0); + + const empty2 = createHistogram(); + assert.strictEqual(h.ksTest(empty2), 0); + + const pctAt = h.percentilesAt([50, 99]); + assert.ok(pctAt instanceof Map); + assert.strictEqual(pctAt.size, 2); + + const linear = h.linearBuckets(10); + assert.ok(linear instanceof Map); + + const log = h.logBuckets(1, 2); + assert.ok(log instanceof Map); +} + +// --------------------------------------------------------------------------- +// Single-value histogram edge cases +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + h.record(42); + + assert.strictEqual(h.skewness, 0); // Needs >= 3 + assert.strictEqual(h.kurtosis, 0); // Needs >= 4 + assert.strictEqual(h.cdf(42), 1); + assert.strictEqual(h.cdf(1), 0); + assert.strictEqual(h.ccdf(42), 0); + assert.strictEqual(h.countAt(42), 1); +} + +// --------------------------------------------------------------------------- +// Fast API call tests for new methods +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + h.record(1); + h.record(100); + + // Prepare cdf and countAt methods for optimization + eval('%PrepareFunctionForOptimization(h.cdf)'); + eval('%PrepareFunctionForOptimization(h.countAt)'); + + // Warmup call + h.cdf(50); + h.countAt(1); + + // Optimize + eval('%OptimizeFunctionOnNextCall(h.cdf)'); + eval('%OptimizeFunctionOnNextCall(h.countAt)'); + + // Fast-path call + h.cdf(50); + h.countAt(1); + + if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual(getV8FastApiCallCount('histogram.cdf'), 1); + assert.strictEqual(getV8FastApiCallCount('histogram.countAt'), 1); + } +}