diff --git a/benchmark/perf_hooks/histogram-sliding-window-record.js b/benchmark/perf_hooks/histogram-sliding-window-record.js new file mode 100644 index 000000000000..192a1f0cc5f7 --- /dev/null +++ b/benchmark/perf_hooks/histogram-sliding-window-record.js @@ -0,0 +1,24 @@ +'use strict'; + +const assert = require('assert'); +const common = require('../common.js'); +const { createSlidingWindowHistogram } = require('perf_hooks'); + +const bench = common.createBenchmark(main, { + n: [1e6], + mode: ['count', 'time'], + chunks: [6], +}); + +function main({ n, mode, chunks }) { + const options = mode === 'count' ? + { chunks, recordsPerChunk: 1000 } : + { chunks, chunkDuration: 1 }; + const histogram = createSlidingWindowHistogram(options); + + bench.start(); + for (let i = 0; i < n; i++) histogram.record((i % 1000) + 1); + bench.end(n); + + assert.ok(histogram.snapshot().count > 0); +} diff --git a/benchmark/perf_hooks/histogram-sliding-window-snapshot.js b/benchmark/perf_hooks/histogram-sliding-window-snapshot.js new file mode 100644 index 000000000000..9bdc907054a8 --- /dev/null +++ b/benchmark/perf_hooks/histogram-sliding-window-snapshot.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('assert'); +const common = require('../common.js'); +const { createSlidingWindowHistogram } = require('perf_hooks'); + +const bench = common.createBenchmark(main, { + n: [100], + chunks: [2, 8], + recordsPerChunk: [1000], +}); + +let snapshot; + +function main({ n, chunks, recordsPerChunk }) { + const histogram = createSlidingWindowHistogram({ + chunks, + recordsPerChunk, + }); + for (let i = 0; i < chunks * recordsPerChunk; i++) { + histogram.record((i % 1000) + 1); + } + + bench.start(); + for (let i = 0; i < n; i++) snapshot = histogram.snapshot(); + bench.end(n); + + assert.strictEqual(snapshot.count, chunks * recordsPerChunk); +} diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index 76e650b09ada..77a484a62cf6 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -1718,6 +1718,61 @@ added: Returns a {RecordableHistogram}. +## `perf_hooks.createSlidingWindowHistogram(options)` + + + +* `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 @@ -3155,6 +3258,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 4b99d9f97f9d..63f297936773 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; @@ -1741,6 +1742,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_( @@ -2102,6 +2105,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-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-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..3ee1ca4ea437 --- /dev/null +++ b/test/parallel/test-perf-hooks-sliding-window-histogram.js @@ -0,0 +1,195 @@ +'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); + 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', + }); + 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 [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 }, + { 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/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()); 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;