From 0a744b89924d1408842c0313af0495bf21f84404 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 5 Sep 2026 06:18:36 +0000 Subject: [PATCH 1/3] perf_hooks: implement qrde analysis support in Histogram Implements quantile-respectful density estimate calcuation on Histogram. Helps with tail-focused latency analysis without retaining raw samples. Baking this directly into Node.js, based on a 1 million sample, 1k bin workload, this impl is roughly 150-325x faster than performing the equivalent in Rscript. Signed-off-by: James M Snell Assisted-by: Opencode --- benchmark/perf_hooks/histogram-qrde.js | 37 + doc/api/perf_hooks.md | 76 ++ lib/internal/histogram.js | 70 ++ src/histogram-inl.h | 24 +- src/histogram.cc | 726 +++++++++++++++++- src/histogram.h | 29 + test/fixtures/qrde-r-oracle.R | 133 ++++ test/fixtures/qrde-r-oracle.json | 47 ++ .../test-perf-hooks-histogram-qrde-oracle.js | 157 ++++ .../test-perf-hooks-histogram-qrde.js | 222 ++++++ 10 files changed, 1480 insertions(+), 41 deletions(-) create mode 100644 benchmark/perf_hooks/histogram-qrde.js create mode 100644 test/fixtures/qrde-r-oracle.R create mode 100644 test/fixtures/qrde-r-oracle.json create mode 100644 test/parallel/test-perf-hooks-histogram-qrde-oracle.js create mode 100644 test/parallel/test-perf-hooks-histogram-qrde.js diff --git a/benchmark/perf_hooks/histogram-qrde.js b/benchmark/perf_hooks/histogram-qrde.js new file mode 100644 index 000000000000..b23abd485316 --- /dev/null +++ b/benchmark/perf_hooks/histogram-qrde.js @@ -0,0 +1,37 @@ +'use strict'; + +const common = require('../common.js'); +const { createHistogram } = require('perf_hooks'); + +const bench = common.createBenchmark(main, { + n: [5], + bins: [100, 1000], + samples: [1e6], + unique: [100, 1000, 10000], + dequantize: ['none', 'hdr', 'all'], +}, { + test: { + n: 1, + bins: 10, + samples: 100, + unique: 10, + }, +}); + +async function main({ n, bins, samples, unique, dequantize }) { + const histogram = createHistogram(); + const maximum = 1e12; + + for (let i = 0; i < samples; i++) { + const index = i % unique; + const rank = unique === 1 ? 0 : index / (unique - 1); + histogram.record(Math.max(1, Math.round(maximum ** rank))); + } + + await histogram.qrde({ bins, dequantize }); + bench.start(); + for (let i = 0; i < n; i++) { + await histogram.qrde({ bins, dequantize }); + } + bench.end(n); +} diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index 8df9716fa732..429d5482d756 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -2437,6 +2437,82 @@ 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.qrde([options])` + + + +* `options` {Object} + * `bins` {number} The number of equal-probability density bins to return. + Must be between 1 and 1000. Cannot be used with `probabilities`. + **Default:** `100`. + * `probabilities` {number\[]} Custom probability boundaries. The array must + contain between 2 and 1001 strictly increasing values, start with `0`, and + end with `1`. Cannot be used with `bins`. + * `dequantize` {string} Controls whether repeated bucket values are spread + deterministically over their equivalent-value ranges. May be `'none'`, + `'hdr'`, or `'all'`. **Default:** `'hdr'`. + * `cache` {boolean} When `true`, retains the expanded histogram snapshot for + reuse by subsequent calls with `cache: true`. The snapshot is invalidated + when the histogram is modified. **Default:** `false`. +* Returns: {Promise} Fulfills with an {Object} containing: + * `probabilities` {Float64Array} The probability boundaries used by the + estimate. + * `quantiles` {Float64Array} The quantiles at the probability boundaries. + * `densities` {Float64Array} The density within each quantile interval. + * `count` {bigint} The number of values in the histogram snapshot. + * `bucketCount` {number} The number of occupied HDR buckets. + * `corrections` {number} The number of non-monotonic floating-point results + that were clamped to the preceding quantile. + * `dequantize` {string} The selected dequantization mode. + +Returns a quantile-respectful density estimate based on the Harrell-Davis +quantile estimator. By default, `bins` generates equal probability boundaries. +The `probabilities` option can instead focus the estimate on regions such as +p90, p99, p99.9, and p99.99. The density for interval `i` contains probability +mass `probabilities[i + 1] - probabilities[i]`. The histogram is snapshotted +when the method is called. Snapshot expansion and the estimate are calculated +in the libuv thread pool. Highly concentrated beta weights use a second-order +asymptotic approximation to avoid numerical convergence loss at large sample +counts. + +Setting `cache` to `true` avoids repeating snapshot capture and expansion when +several estimates are requested from an unchanged histogram. The retained +snapshot uses memory proportional to the number of occupied HDR buckets and is +released when the histogram is next modified. + +QRDE temporarily uses approximately one additional HDR count array plus 32 +bytes per occupied bucket. With `cache: true`, the expanded 32-byte-per-bucket +snapshot remains allocated. The following estimates use `lowest: 1` and +`highest: Number.MAX_SAFE_INTEGER` and exclude allocator and JavaScript object +overhead: + +| `figures` | Histogram | Maximum expanded snapshot | Peak cache-miss QRDE | +| --------- | --------: | ------------------------: | -------------------: | +| 1 | 6.3 KiB | 25 KiB | 31 KiB | +| 2 | 47 KiB | 188 KiB | 235 KiB | +| 3 | 352 KiB | 1.4 MiB | 1.7 MiB | +| 4 | 5.0 MiB | 20 MiB | 25 MiB | +| 5 | 37 MiB | 148 MiB | 185 MiB | + +The maximum snapshot column assumes every representable bucket is occupied. +Lower `highest` values reduce histogram and temporary copy sizes. Concurrent +calls that miss the cache each require their own temporary copy and expanded +snapshot. + +HDR histograms aggregate observations into equivalent-value buckets. The +`'hdr'` dequantization mode models repeated values in buckets wider than one +unit as a continuous uniform distribution over the bucket resolution. This +reduces density artifacts introduced by HDR quantization while preserving +repeated unit-resolution values as point masses. The `'all'` mode also +dequantizes repeated unit-resolution values. Use `'none'` to calculate the +grouped Harrell-Davis estimator using bucket midpoints directly. + +An empty histogram returns the requested `probabilities` but produces empty +`quantiles` and `densities` arrays. A non-dequantized interval whose quantile +boundaries are equal has an infinite density. + ### `histogram.reset()` + +* `options` {Object} + * `chunks` {number} The number of histogram chunks retained. Must be an + integer between `1` and `1024`. + * `chunkDuration` {number} The duration of each chunk in milliseconds. Must + be an integer between `1` and `18_446_744_073_709`. Exactly one of + `chunkDuration` and `recordsPerChunk` must be specified. + * `recordsPerChunk` {number} The number of calls to `record()` assigned to + each chunk. Must be an integer between `1` and `Number.MAX_SAFE_INTEGER`. + Exactly one of `chunkDuration` and `recordsPerChunk` must be specified. + * `lowest` {number|bigint} The lowest discernible value. Must be an integer + value greater than `0`. **Default:** `1`. + * `highest` {number|bigint} The highest recordable value. Must be an integer + value that is equal to or greater than two times `lowest`. + **Default:** `Number.MAX_SAFE_INTEGER`. + * `figures` {number} The number of accuracy digits. Must be an integer between + `1` and `5`. **Default:** `3`. +* Returns: {SlidingWindowHistogram} + +Creates a {SlidingWindowHistogram} that retains the latest `chunks` histogram +chunks. Rotation is lazy and does not create a timer. Time-based rotation is +evaluated when `record()` or `snapshot()` is called. Count-based rotation is +evaluated when `record()` is called. + +One histogram chunk is allocated during construction. Additional chunks are +allocated lazily. The maximum native memory used by the window scales with +`chunks` and with the `lowest`, `highest`, and `figures` histogram options. + +The window boundary has chunk-level precision. With `N` chunks of duration +`D`, a recorded value is retained for between `(N - 1) * D` and `N * D` +milliseconds. Once a count-based window is populated, it retains between +`(N - 1) * C + 1` and `N * C` recording attempts, where `C` is +`recordsPerChunk`. Recording attempts which exceed `highest` are included when +determining count-based rotation. + +```js +const { createSlidingWindowHistogram } = require('node:perf_hooks'); + +const window = createSlidingWindowHistogram({ + chunks: 6, + chunkDuration: 10_000, +}); + +window.record(20_000_000); + +// Materialize the current window as an independent Histogram. +const snapshot = window.snapshot(); +console.log(snapshot.percentile(99)); +``` + ## `perf_hooks.importHistogram(data)` + +Records values into a lazily rotated ring of histogram chunks. Instances are +created using [`perf_hooks.createSlidingWindowHistogram()`][] and cannot be +constructed directly. A `SlidingWindowHistogram` does not extend {Histogram}; +call `snapshot()` to materialize the current window as a {Histogram}. + +`SlidingWindowHistogram` instances cannot be cloned or transferred through a +{MessagePort}. + +### `slidingWindowHistogram.record(val)` + + + +* `val` {number|bigint} The amount to record. + +Records `val` in the current chunk. For a count-based window, every call that +reaches the native histogram counts toward rotation, including values which +exceed the configured `highest` value. + +### `slidingWindowHistogram.reset()` + + + +Invalidates all chunks in the current window. Allocated chunks are reset +lazily when reused. + +### `slidingWindowHistogram.snapshot()` + + + +* Returns: {Histogram} + +Materializes the current window as a new, independent {Histogram}. Values +recorded or expired after this method returns do not change the returned +histogram. Materialization allocates one histogram and merges every retained +chunk. + ## Histogram analysis examples The `Histogram` class provides statistical analysis methods useful for @@ -3117,6 +3220,7 @@ dns.promises.resolve('localhost'); [`'exit'`]: process.md#event-exit [`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options [`histogram.export()`]: #histogramexport +[`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions [`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2 [`perf_hooks.importHistogram()`]: #perf_hooksimporthistogramdata [`perf_hooks.monitorEventLoopDelay()`]: #perf_hooksmonitoreventloopdelayoptions diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index f2e592d9f814..1cc787404fdc 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -1,6 +1,7 @@ 'use strict'; const { + BigInt, Float64Array, Map, MapPrototypeEntries, @@ -12,6 +13,7 @@ const { const { Histogram: _Histogram, + SlidingWindowHistogram: _SlidingWindowHistogram, } = internalBinding('performance'); const { @@ -47,7 +49,11 @@ const { const kDestroy = Symbol('kDestroy'); const kHandle = Symbol('kHandle'); const kRecordable = Symbol('kRecordable'); +const kSlidingWindowHandle = Symbol('kSlidingWindowHandle'); const kQrdeDequantizationModes = ['none', 'hdr', 'all']; +const kMaxSlidingWindowHistogramChunks = 1024; +const kMaxChunkDuration = 18_446_744_073_709; +const kMaxInt64 = 9_223_372_036_854_775_807n; const { kClone, @@ -801,6 +807,48 @@ class RecordableHistogram extends Histogram { } } +class SlidingWindowHistogram { + constructor(skipThrowSymbol = undefined) { + if (skipThrowSymbol !== kSkipThrow) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + } + + /** + * @param {number|bigint} val + * @returns {void} + */ + record(val) { + if (this[kSlidingWindowHandle] === undefined) + throw new ERR_INVALID_THIS('SlidingWindowHistogram'); + if (typeof val === 'bigint') { + this[kSlidingWindowHandle].record(val); + return; + } + + validateInteger(val, 'val', 1); + this[kSlidingWindowHandle].record(val); + } + + /** + * @returns {Histogram} + */ + snapshot() { + if (this[kSlidingWindowHandle] === undefined) + throw new ERR_INVALID_THIS('SlidingWindowHistogram'); + return new ClonedHistogram(this[kSlidingWindowHandle].snapshot()); + } + + /** + * @returns {void} + */ + reset() { + if (this[kSlidingWindowHandle] === undefined) + throw new ERR_INVALID_THIS('SlidingWindowHistogram'); + this[kSlidingWindowHandle].reset(); + } +} + function ClonedHistogram(handle) { const histogram = new Histogram(kSkipThrow); markTransferMode(histogram, true, false); @@ -827,6 +875,32 @@ function createRecordableHistogram(handle) { return new ClonedRecordableHistogram(handle); } +function validateHistogramOptions(lowest, highest, figures) { + if (typeof lowest !== 'bigint') { + validateInteger(lowest, 'options.lowest', 1, NumberMAX_SAFE_INTEGER); + } else if (lowest < 1n || lowest > kMaxInt64) { + throw new ERR_OUT_OF_RANGE( + 'options.lowest', `>= 1n && <= ${kMaxInt64}n`, lowest); + } + + if (typeof highest !== 'bigint') { + validateInteger(highest, 'options.highest', 1, NumberMAX_SAFE_INTEGER); + } else if (highest < 1n || highest > kMaxInt64) { + throw new ERR_OUT_OF_RANGE( + 'options.highest', `>= 1n && <= ${kMaxInt64}n`, highest); + } + + const minimumHighest = 2n * + (typeof lowest === 'bigint' ? lowest : BigInt(lowest)); + const highestBigInt = typeof highest === 'bigint' ? + highest : BigInt(highest); + if (highestBigInt < minimumHighest) { + throw new ERR_OUT_OF_RANGE( + 'options.highest', `>= 2 * options.lowest (${minimumHighest}n)`, highest); + } + validateInteger(figures, 'options.figures', 1, 5); +} + /** * @param {{ * lowest? : number, @@ -846,15 +920,7 @@ function createHistogram(options = kEmptyObject) { halfLife = 0, threshold = 0, } = options; - if (typeof lowest !== 'bigint') - validateInteger(lowest, 'options.lowest', 1, NumberMAX_SAFE_INTEGER); - if (typeof highest !== 'bigint') { - validateInteger(highest, 'options.highest', - 2 * lowest, NumberMAX_SAFE_INTEGER); - } else if (highest < 2n * lowest) { - throw new ERR_INVALID_ARG_VALUE.RangeError('options.highest', highest); - } - validateInteger(figures, 'options.figures', 1, 5); + validateHistogramOptions(lowest, highest, figures); validateNumber(halfLife, 'options.halfLife'); if (halfLife < 0) throw new ERR_OUT_OF_RANGE('options.halfLife', '>= 0', halfLife); @@ -865,6 +931,57 @@ function createHistogram(options = kEmptyObject) { new _Histogram(lowest, highest, figures, halfLife, threshold)); } +/** + * @param {{ + * chunks: number, + * chunkDuration? : number, + * recordsPerChunk? : number, + * lowest? : number|bigint, + * highest? : number|bigint, + * figures? : number, + * }} options + * @returns {SlidingWindowHistogram} + */ +function createSlidingWindowHistogram(options) { + validateObject(options, 'options'); + const { + chunks, + chunkDuration, + recordsPerChunk, + lowest = 1, + highest = NumberMAX_SAFE_INTEGER, + figures = 3, + } = options; + + validateInteger( + chunks, 'options.chunks', 1, kMaxSlidingWindowHistogramChunks); + validateHistogramOptions(lowest, highest, figures); + + const timeBased = chunkDuration !== undefined; + if (timeBased === (recordsPerChunk !== undefined)) { + throw new ERR_INVALID_ARG_VALUE( + 'options', options, + 'must specify exactly one of "chunkDuration" or "recordsPerChunk"'); + } + + let rotateAt; + if (timeBased) { + validateInteger( + chunkDuration, 'options.chunkDuration', 1, kMaxChunkDuration); + rotateAt = BigInt(chunkDuration) * 1_000_000n; + } else { + validateInteger( + recordsPerChunk, 'options.recordsPerChunk', 1, NumberMAX_SAFE_INTEGER); + rotateAt = BigInt(recordsPerChunk); + } + + const histogram = new SlidingWindowHistogram(kSkipThrow); + markTransferMode(histogram, false, false); + histogram[kSlidingWindowHandle] = new _SlidingWindowHistogram( + lowest, highest, figures, chunks, timeBased, rotateAt); + return histogram; +} + /** * Reconstructs a histogram from a CBOR-encoded Uint8Array previously * produced by `histogram.export()`. @@ -880,6 +997,7 @@ function importHistogram(data) { module.exports = { Histogram, RecordableHistogram, + SlidingWindowHistogram, ClonedHistogram, ClonedRecordableHistogram, isHistogram, @@ -887,5 +1005,6 @@ module.exports = { kHandle, kSkipThrow, createHistogram, + createSlidingWindowHistogram, importHistogram, }; diff --git a/lib/perf_hooks.js b/lib/perf_hooks.js index cc158e5c7625..5de247442b52 100644 --- a/lib/perf_hooks.js +++ b/lib/perf_hooks.js @@ -25,6 +25,7 @@ const { const { createHistogram, + createSlidingWindowHistogram, importHistogram, } = require('internal/histogram'); @@ -44,6 +45,7 @@ module.exports = { eventLoopUtilization, timerify, createHistogram, + createSlidingWindowHistogram, importHistogram, performance, }; diff --git a/src/histogram.cc b/src/histogram.cc index 185db9d5d3c1..ac8c7ced9a6b 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -23,6 +23,7 @@ using v8::BigInt; using v8::CFunction; using v8::Context; using v8::Exception; +using v8::FastApiCallbackOptions; using v8::Float64Array; using v8::FunctionCallbackInfo; using v8::FunctionTemplate; @@ -1730,6 +1731,8 @@ CFunction HistogramBase::fast_record_( CFunction::Make(&HistogramBase::FastRecord)); CFunction HistogramBase::fast_record_delta_( CFunction::Make(&HistogramBase::FastRecordDelta)); +CFunction SlidingWindowHistogram::fast_record_( + CFunction::Make(&SlidingWindowHistogram::FastRecord)); CFunction IntervalHistogram::fast_start_( CFunction::Make(&IntervalHistogram::FastStart)); CFunction IntervalHistogram::fast_stop_( @@ -2091,6 +2094,254 @@ void HistogramBase::HistogramTransferData::MemoryInfo( tracker->TrackField("histogram", histogram_); } +SlidingWindowHistogram::SlidingWindowHistogram( + Environment* env, + Local wrap, + const Histogram::Options& options, + size_t chunk_count, + bool time_based, + uint64_t rotate_at, + std::shared_ptr spare) + : BaseObject(env, wrap), + options_(options), + chunks_(chunk_count), + generations_(chunk_count, kNoGeneration), + spare_(std::move(spare)), + time_based_(time_based), + rotate_at_(rotate_at), + origin_(uv_hrtime()) { + MakeWeak(); + external_memory_ = spare_->GetMemorySize(); + env->external_memory_accounter()->Increase(env->isolate(), external_memory_); +} + +SlidingWindowHistogram::~SlidingWindowHistogram() { + env()->external_memory_accounter()->Decrease(env()->isolate(), + external_memory_); +} + +void SlidingWindowHistogram::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("chunks", chunks_); + tracker->TrackField("generations", generations_); + tracker->TrackField("spare", spare_); +} + +uint64_t SlidingWindowHistogram::CurrentTimeGeneration() const { + const uint64_t now = uv_hrtime(); + CHECK_GE(now, origin_); + return (now - origin_) / rotate_at_; +} + +Histogram* SlidingWindowHistogram::GetChunk(uint64_t generation) { + const size_t index = generation % chunks_.size(); + if (generations_[index] == generation) { + CHECK(chunks_[index]); + return chunks_[index].get(); + } + + if (chunks_[index]) { + chunks_[index]->Reset(); + } else if (spare_) { + chunks_[index] = std::move(spare_); + } else { + chunks_[index] = Histogram::Create(options_); + if (!chunks_[index]) return nullptr; + const size_t size = chunks_[index]->GetMemorySize(); + external_memory_ += size; + env()->external_memory_accounter()->Increase(env()->isolate(), size); + } + + generations_[index] = generation; + return chunks_[index].get(); +} + +bool SlidingWindowHistogram::RecordValue(int64_t value) { + uint64_t generation; + if (time_based_) { + generation = CurrentTimeGeneration(); + } else if (records_in_current_chunk_ == rotate_at_) { + CHECK_LT(current_generation_, kNoGeneration - 1); + generation = current_generation_ + 1; + } else { + generation = current_generation_; + } + + Histogram* chunk = GetChunk(generation); + if (chunk == nullptr) return false; + + chunk->Record(value); + if (!time_based_) { + if (generation != current_generation_) { + current_generation_ = generation; + records_in_current_chunk_ = 0; + } + records_in_current_chunk_++; + has_count_records_ = true; + } + return true; +} + +std::shared_ptr SlidingWindowHistogram::CreateSnapshot() const { + std::shared_ptr snapshot = Histogram::Create(options_); + if (!snapshot) return {}; + + uint64_t current_generation; + if (time_based_) { + current_generation = CurrentTimeGeneration(); + } else { + if (!has_count_records_) return snapshot; + current_generation = current_generation_; + } + + for (size_t i = 0; i < chunks_.size(); i++) { + const uint64_t generation = generations_[i]; + if (generation == kNoGeneration || generation > current_generation || + current_generation - generation >= chunks_.size()) { + continue; + } + CHECK(chunks_[i]); + CHECK_EQ(snapshot->Add(*chunks_[i]), 0); + } + return snapshot; +} + +void SlidingWindowHistogram::ResetWindow() { + std::fill(generations_.begin(), generations_.end(), kNoGeneration); + origin_ = uv_hrtime(); + current_generation_ = 0; + records_in_current_chunk_ = 0; + has_count_records_ = false; +} + +void SlidingWindowHistogram::New(const FunctionCallbackInfo& args) { + CHECK(args.IsConstructCall()); + CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt()); + CHECK_IMPLIES(!args[1]->IsNumber(), args[1]->IsBigInt()); + CHECK(args[2]->IsUint32()); + CHECK(args[3]->IsUint32()); + CHECK(args[4]->IsBoolean()); + CHECK(args[5]->IsBigInt()); + + Environment* env = Environment::GetCurrent(args); + bool lossless = true; + int64_t lowest = 1; + int64_t highest = std::numeric_limits::max(); + + if (args[0]->IsNumber()) { + lowest = args[0].As()->Value(); + } else { + 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 { + highest = args[1].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.highest is out of range"); + } + + const int figures = args[2].As()->Value(); + const uint32_t chunk_count = args[3].As()->Value(); + if (chunk_count == 0) + return THROW_ERR_OUT_OF_RANGE(env, "options.chunks is out of range"); + + lossless = true; + const uint64_t rotate_at = args[5].As()->Uint64Value(&lossless); + if (!lossless || rotate_at == 0) { + return THROW_ERR_OUT_OF_RANGE(env, "rotation interval is out of range"); + } + + Histogram::Options options{lowest, highest, figures}; + std::shared_ptr spare = Histogram::Create(options); + if (!spare) + return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid histogram options"); + + new SlidingWindowHistogram(env, + args.This(), + options, + chunk_count, + args[4]->IsTrue(), + rotate_at, + std::move(spare)); +} + +void SlidingWindowHistogram::Record(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt()); + bool lossless = true; + const 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"); + + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + if (!histogram->RecordValue(value)) THROW_ERR_MEMORY_ALLOCATION_FAILED(env); +} + +void SlidingWindowHistogram::FastRecord(Local receiver, + int64_t value, + // NOLINTNEXTLINE(runtime/references) + FastApiCallbackOptions& options) { + CHECK_GE(value, 1); + TRACK_V8_FAST_API_CALL("histogram.slidingWindow.record"); + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, receiver); + if (!histogram->RecordValue(value)) { + HandleScope scope(options.isolate); + THROW_ERR_MEMORY_ALLOCATION_FAILED(histogram->env()); + } +} + +void SlidingWindowHistogram::Snapshot(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + + std::shared_ptr snapshot = histogram->CreateSnapshot(); + if (!snapshot) return THROW_ERR_MEMORY_ALLOCATION_FAILED(env); + + BaseObjectPtr result = + HistogramBase::Create(env, std::move(snapshot)); + if (result) args.GetReturnValue().Set(result->object()); +} + +void SlidingWindowHistogram::Reset(const FunctionCallbackInfo& args) { + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + histogram->ResetWindow(); +} + +void SlidingWindowHistogram::Initialize(IsolateData* isolate_data, + Local target) { + Isolate* isolate = isolate_data->isolate(); + Local tmpl = NewFunctionTemplate(isolate, New); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "SlidingWindowHistogram")); + auto instance = tmpl->InstanceTemplate(); + instance->SetInternalFieldCount(BaseObject::kInternalFieldCount); + SetFastMethod(isolate, instance, "record", Record, &fast_record_); + SetProtoMethod(isolate, tmpl, "snapshot", Snapshot); + SetProtoMethod(isolate, tmpl, "reset", Reset); + SetConstructorFunction(isolate, + target, + "SlidingWindowHistogram", + tmpl, + SetConstructorFunctionFlag::NONE); +} + +void SlidingWindowHistogram::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(New); + registry->Register(Record); + registry->Register(fast_record_); + registry->Register(Snapshot); + registry->Register(Reset); +} + Local IntervalHistogram::GetConstructorTemplate( Environment* env) { Local tmpl = env->intervalhistogram_constructor_template(); diff --git a/src/histogram.h b/src/histogram.h index 623915e47e45..bc72fa36e104 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -360,6 +360,60 @@ class HistogramBase final : public BaseObject, public HistogramImpl { static v8::CFunction fast_record_delta_; }; +// BaseObject disallows cloning and transfer, so ring state is confined to the +// owning Environment's thread. +class SlidingWindowHistogram final : public BaseObject { + public: + static void Initialize(IsolateData* isolate_data, + v8::Local target); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(SlidingWindowHistogram) + SET_SELF_SIZE(SlidingWindowHistogram) + + private: + static constexpr uint64_t kNoGeneration = + std::numeric_limits::max(); + + static void New(const v8::FunctionCallbackInfo& args); + static void Record(const v8::FunctionCallbackInfo& args); + static void FastRecord(v8::Local receiver, + int64_t value, + v8::FastApiCallbackOptions& options); + static void Snapshot(const v8::FunctionCallbackInfo& args); + static void Reset(const v8::FunctionCallbackInfo& args); + + SlidingWindowHistogram(Environment* env, + v8::Local wrap, + const Histogram::Options& options, + size_t chunk_count, + bool time_based, + uint64_t rotate_at, + std::shared_ptr spare); + ~SlidingWindowHistogram() override; + + Histogram* GetChunk(uint64_t generation); + bool RecordValue(int64_t value); + std::shared_ptr CreateSnapshot() const; + void ResetWindow(); + uint64_t CurrentTimeGeneration() const; + + Histogram::Options options_; + std::vector> chunks_; + std::vector generations_; + std::shared_ptr spare_; + bool time_based_; + uint64_t rotate_at_; + uint64_t origin_; + uint64_t current_generation_ = 0; + uint64_t records_in_current_chunk_ = 0; + size_t external_memory_ = 0; + bool has_count_records_ = false; + + static v8::CFunction fast_record_; +}; + // 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). diff --git a/src/node_perf.cc b/src/node_perf.cc index 177c2a789854..f63e5f288cce 100644 --- a/src/node_perf.cc +++ b/src/node_perf.cc @@ -333,6 +333,7 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data, Isolate* isolate = isolate_data->isolate(); HistogramBase::Initialize(isolate_data, target); + SlidingWindowHistogram::Initialize(isolate_data, target); SetMethod(isolate, target, "setupObservers", SetupPerformanceObservers); SetMethod(isolate, @@ -419,6 +420,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SlowPerformanceNow); registry->Register(fast_performance_now); HistogramBase::RegisterExternalReferences(registry); + SlidingWindowHistogram::RegisterExternalReferences(registry); IntervalHistogram::RegisterExternalReferences(registry); IterationHistogram::RegisterExternalReferences(registry); } diff --git a/test/parallel/test-perf-hooks-sliding-window-histogram-fast-calls.js b/test/parallel/test-perf-hooks-sliding-window-histogram-fast-calls.js new file mode 100644 index 000000000000..1097920f5f73 --- /dev/null +++ b/test/parallel/test-perf-hooks-sliding-window-histogram-fast-calls.js @@ -0,0 +1,31 @@ +// Flags: --expose-internals --no-warnings --allow-natives-syntax +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); +const { + createSlidingWindowHistogram, +} = require('perf_hooks'); + +const histogram = createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, +}); + +function record() { + histogram.record(1); +} + +eval('%PrepareFunctionForOptimization(histogram.record)'); +record(); +eval('%OptimizeFunctionOnNextCall(histogram.record)'); +record(); + +assert.strictEqual(histogram.snapshot().count, 2); + +if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual( + getV8FastApiCallCount('histogram.slidingWindow.record'), 1); +} diff --git a/test/parallel/test-perf-hooks-sliding-window-histogram.js b/test/parallel/test-perf-hooks-sliding-window-histogram.js new file mode 100644 index 000000000000..9e28677e5d62 --- /dev/null +++ b/test/parallel/test-perf-hooks-sliding-window-histogram.js @@ -0,0 +1,177 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { setTimeout: delay } = require('timers/promises'); +const { MessageChannel } = require('worker_threads'); +const { + createSlidingWindowHistogram, +} = require('perf_hooks'); + +{ + const histogram = createSlidingWindowHistogram({ + chunks: 3, + recordsPerChunk: 2, + highest: 100, + }); + + assert.strictEqual(histogram.constructor.name, 'SlidingWindowHistogram'); + assert.strictEqual(histogram.recordDelta, undefined); + assert.strictEqual(histogram.snapshot().count, 0); + + for (let value = 1; value <= 6; value++) histogram.record(value); + + const full = histogram.snapshot(); + assert.strictEqual(full.count, 6); + assert.strictEqual(full.min, 1); + assert.strictEqual(full.max, 6); + assert.strictEqual(full.record, undefined); + + histogram.record(7); + let current = histogram.snapshot(); + assert.strictEqual(current.count, 5); + assert.strictEqual(current.min, 3); + assert.strictEqual(current.max, 7); + + histogram.record(8); + histogram.record(9); + current = histogram.snapshot(); + assert.strictEqual(current.count, 5); + assert.strictEqual(current.min, 5); + assert.strictEqual(current.max, 9); + + // Materialized snapshots do not change with the sliding window. + assert.strictEqual(full.count, 6); + assert.strictEqual(full.min, 1); + assert.strictEqual(full.max, 6); + + histogram.reset(); + assert.strictEqual(histogram.snapshot().count, 0); + histogram.record(10n); + assert.strictEqual(histogram.snapshot().maxBigInt, 10n); + + assert.throws(() => new histogram.constructor(), { + code: 'ERR_ILLEGAL_CONSTRUCTOR', + }); + assert.throws(() => histogram.record.call({}, 1), { + code: 'ERR_INVALID_THIS', + }); + assert.throws(() => histogram.snapshot.call({}), { + code: 'ERR_INVALID_THIS', + }); + assert.throws(() => histogram.reset.call({}), { + code: 'ERR_INVALID_THIS', + }); + assert.throws(() => structuredClone(histogram), { + name: 'DataCloneError', + }); + + const { port1, port2 } = new MessageChannel(); + assert.throws(() => port1.postMessage(histogram), { + name: 'DataCloneError', + }); + assert.throws(() => port1.postMessage(histogram, [histogram]), { + name: 'DataCloneError', + }); + port1.close(); + port2.close(); +} + +{ + const histogram = createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + highest: 10, + }); + + // Out-of-range recording attempts count toward count-based rotation. + histogram.record(11); + histogram.record(1); + let current = histogram.snapshot(); + assert.strictEqual(current.count, 1); + assert.strictEqual(current.exceeds, 1); + + histogram.record(2); + current = histogram.snapshot(); + assert.strictEqual(current.count, 2); + assert.strictEqual(current.exceeds, 0); +} + +{ + for (const options of [ + undefined, + null, + {}, + { chunks: 2 }, + { chunks: 2, chunkDuration: 1, recordsPerChunk: 1 }, + ]) { + assert.throws(() => createSlidingWindowHistogram(options), { + code: options?.chunks === undefined ? + 'ERR_INVALID_ARG_TYPE' : 'ERR_INVALID_ARG_VALUE', + }); + } + + for (const chunks of [0, 1025, 1.5, '2']) { + assert.throws(() => createSlidingWindowHistogram({ + chunks, + recordsPerChunk: 1, + }), { + code: typeof chunks === 'number' ? + 'ERR_OUT_OF_RANGE' : 'ERR_INVALID_ARG_TYPE', + }); + } + + for (const chunkDuration of [0, 1.5, 18_446_744_073_710]) { + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + chunkDuration, + }), { code: 'ERR_OUT_OF_RANGE' }); + } + + for (const recordsPerChunk of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk, + }), { code: 'ERR_OUT_OF_RANGE' }); + } + + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + lowest: 10, + highest: 10, + }), { code: 'ERR_OUT_OF_RANGE' }); + + for (const bounds of [ + { lowest: 1n }, + { lowest: 1n, highest: 100 }, + { lowest: 1, highest: 100n }, + ]) { + const histogram = createSlidingWindowHistogram({ + chunks: 1, + recordsPerChunk: 1, + ...bounds, + }); + histogram.record(1); + assert.strictEqual(histogram.snapshot().count, 1); + } +} + +(async () => { + const histogram = createSlidingWindowHistogram({ + chunks: 1, + chunkDuration: 100, + highest: 100, + }); + + histogram.record(1); + assert.strictEqual(histogram.snapshot().count, 1); + + await delay(common.platformTimeout(200)); + assert.strictEqual(histogram.snapshot().count, 0); + + histogram.record(2); + const current = histogram.snapshot(); + assert.strictEqual(current.count, 1); + assert.strictEqual(current.min, 2); +})().then(common.mustCall()); diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts index fa9a3810fc7a..5f6f4c88022c 100644 --- a/typings/internalBinding/performance.d.ts +++ b/typings/internalBinding/performance.d.ts @@ -76,6 +76,20 @@ declare namespace InternalPerformanceBinding { subtract(other: Histogram): number; } + class SlidingWindowHistogram { + constructor( + lowest: number | bigint, + highest: number | bigint, + figures: number, + chunks: number, + timeBased: boolean, + rotateAt: bigint, + ); + record(value: number | bigint): void; + snapshot(): Histogram; + reset(): void; + } + interface Constants { NODE_PERFORMANCE_GC_MAJOR: number; NODE_PERFORMANCE_GC_MINOR: number; @@ -116,6 +130,8 @@ type PerformanceObserverCallback = export interface PerformanceBinding { Histogram: typeof InternalPerformanceBinding.Histogram; + SlidingWindowHistogram: + typeof InternalPerformanceBinding.SlidingWindowHistogram; constants: InternalPerformanceBinding.Constants; observerCounts: Uint32Array; milestones: Float64Array; From 3d03781048574fb46f9b5e355c9b77ab324f69ff Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 5 Sep 2026 20:27:04 +0000 Subject: [PATCH 3/3] test: expand histogram test coverage Signed-off-by: James M Snell Assisted-by: Opencode --- .../test-perf-hooks-histogram-qrde-worker.js | 21 ++++++++ .../test-perf-hooks-histogram-qrde.js | 27 ++++++++++ ...est-perf-hooks-sliding-window-histogram.js | 18 +++++++ .../test-perf-hooks-histogram-heapdump.js | 54 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 test/parallel/test-perf-hooks-histogram-qrde-worker.js create mode 100644 test/sequential/test-perf-hooks-histogram-heapdump.js diff --git a/test/parallel/test-perf-hooks-histogram-qrde-worker.js b/test/parallel/test-perf-hooks-histogram-qrde-worker.js new file mode 100644 index 000000000000..ba8366516652 --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-qrde-worker.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const { Worker } = require('worker_threads'); + +const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const { createHistogram } = require('perf_hooks'); + + const histogram = createHistogram({ highest: 200000, figures: 5 }); + for (let i = 1; i <= 100000; i++) histogram.record(i); + histogram.qrde({ bins: 1000, dequantize: 'all' }); + parentPort.postMessage('scheduled'); +`, { eval: true }); + +(async () => { + assert.deepStrictEqual(await once(worker, 'message'), ['scheduled']); + assert.strictEqual(await worker.terminate(), 1); +})().then(common.mustCall()); diff --git a/test/parallel/test-perf-hooks-histogram-qrde.js b/test/parallel/test-perf-hooks-histogram-qrde.js index a417942d916a..40eb1e71f1eb 100644 --- a/test/parallel/test-perf-hooks-histogram-qrde.js +++ b/test/parallel/test-perf-hooks-histogram-qrde.js @@ -9,6 +9,16 @@ function assertClose(actual, expected, tolerance = 1e-12) { `${actual} != ${expected}`); } +function recordRepeated(histogram, options, value, count) { + const block = createHistogram(options); + block.record(value); + while (count > 0) { + if (count % 2 === 1) histogram.add(block); + count = Math.floor(count / 2); + if (count > 0) block.add(block); + } +} + (async () => { const empty = createHistogram(); const emptyResult = await empty.qrde(); @@ -23,6 +33,9 @@ function assertClose(actual, expected, tolerance = 1e-12) { assert.strictEqual(emptyResult.corrections, 0); assert.strictEqual(emptyResult.dequantize, 'hdr'); + assert.throws(() => empty.qrde.call({}), { + code: 'ERR_INVALID_THIS', + }); assert.throws(() => empty.qrde(null), { code: 'ERR_INVALID_ARG_TYPE', }); @@ -219,4 +232,18 @@ function assertClose(actual, expected, tolerance = 1e-12) { await largeCount.qrde({ bins: 2, dequantize: 'none' }); assert.strictEqual(largeCountResult.count, (1n << 53n) + 1n); assertClose(largeCountResult.quantiles[1], 2); + + // Exercise correction across the exact-to-asymptotic beta CDF threshold. + const correctionOptions = { highest: 131071, figures: 5 }; + const correction = createHistogram(correctionOptions); + recordRepeated(correction, correctionOptions, 1, 26239); + recordRepeated(correction, correctionOptions, 131071, 973761); + const count = 1_000_000; + const threshold = (1 - Math.sqrt(1 - 100_000 / (count + 1))) / 2; + const corrected = await correction.qrde({ + probabilities: [0, threshold - 1e-10, threshold + 1e-10, 1], + dequantize: 'none', + }); + assert.strictEqual(corrected.corrections, 1); + assert.strictEqual(corrected.quantiles[1], corrected.quantiles[2]); })().then(common.mustCall()); diff --git a/test/parallel/test-perf-hooks-sliding-window-histogram.js b/test/parallel/test-perf-hooks-sliding-window-histogram.js index 9e28677e5d62..3ee1ca4ea437 100644 --- a/test/parallel/test-perf-hooks-sliding-window-histogram.js +++ b/test/parallel/test-perf-hooks-sliding-window-histogram.js @@ -49,6 +49,11 @@ const { assert.strictEqual(histogram.snapshot().count, 0); histogram.record(10n); assert.strictEqual(histogram.snapshot().maxBigInt, 10n); + for (const value of [0n, 2n ** 63n]) { + assert.throws(() => histogram.record(value), { + code: 'ERR_OUT_OF_RANGE', + }); + } assert.throws(() => new histogram.constructor(), { code: 'ERR_ILLEGAL_CONSTRUCTOR', @@ -142,6 +147,19 @@ const { highest: 10, }), { code: 'ERR_OUT_OF_RANGE' }); + for (const [name, value] of [ + ['lowest', 0n], + ['lowest', 2n ** 63n], + ['highest', 0n], + ['highest', 2n ** 63n], + ]) { + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + [name]: value, + }), { code: 'ERR_OUT_OF_RANGE' }); + } + for (const bounds of [ { lowest: 1n }, { lowest: 1n, highest: 100 }, diff --git a/test/sequential/test-perf-hooks-histogram-heapdump.js b/test/sequential/test-perf-hooks-histogram-heapdump.js new file mode 100644 index 000000000000..cf310eb973b1 --- /dev/null +++ b/test/sequential/test-perf-hooks-histogram-heapdump.js @@ -0,0 +1,54 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { + createJSHeapSnapshot, + validateByRetainingPathFromNodes, +} = require('../common/heap'); +const { + createHistogram, + createSlidingWindowHistogram, +} = require('perf_hooks'); + +(async () => { + const uncached = createHistogram(); + const cached = createHistogram(); + cached.record(1); + cached.record(1000); + await cached.qrde({ cache: true }); + + const sliding = createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + }); + + const nodes = createJSHeapSnapshot(); + const snapshots = validateByRetainingPathFromNodes( + nodes, + 'Node / Histogram', + [{ node_name: 'Node / qrde_snapshot', edge_name: 'qrde_snapshot' }], + ); + assert.strictEqual(snapshots.length, 1); + assert.ok(snapshots[0].self_size > 0); + + const windows = validateByRetainingPathFromNodes( + nodes, + 'Node / SlidingWindowHistogram', + [], + ); + for (const [edgeName, nodeName] of [ + ['chunks', 'Node / chunks'], + ['generations', 'Node / generations'], + ['spare', 'Node / Histogram'], + ]) { + validateByRetainingPathFromNodes(windows, 'Node / SlidingWindowHistogram', [ + { node_name: nodeName, edge_name: edgeName }, + ]); + } + + // Keep all three wrappers live through snapshot generation. + assert.strictEqual(uncached.count, 0); + assert.strictEqual(cached.count, 2); + assert.strictEqual(sliding.snapshot().count, 0); +})().then(common.mustCall());