Skip to content

Commit cbb0180

Browse files
committed
perf_hooks: add histogram.diff()
Getting the values recorded during an interval requires calling `reset()`, which removes them for every other user of the histogram, or copying and subtracting histograms, which silently produces a wrong result when the source was reset in between. Add `histogram.diff(other)`, which returns a new read-only `Histogram` containing the values recorded after `other`, an earlier snapshot of the histogram, was taken. Neither histogram is changed. Unlike `subtract()`, it verifies that both histograms have the same layout, and it throws instead of clamping when `other` contains values that the histogram does not. Add `histogram.resetCount`, the number of calls to `reset()` and `subtract()`, which `snapshot()` copies. `diff()` throws when the counts differ, so a reset between two snapshots is detected even when every bucket has since grown past its previous count. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: OpenCode
1 parent 7c6e006 commit cbb0180

7 files changed

Lines changed: 392 additions & 8 deletions

File tree

doc/api/perf_hooks.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2152,6 +2152,52 @@ added:
21522152
Returns the number of recorded values that fall within the equivalent
21532153
value range of the given value.
21542154

2155+
### `histogram.diff(other)`
2156+
2157+
<!-- YAML
2158+
added: REPLACEME
2159+
-->
2160+
2161+
* `other` {Histogram} An earlier snapshot of this histogram.
2162+
* Returns: {Histogram}
2163+
2164+
Returns a new {Histogram} containing the values recorded in this histogram after
2165+
`other` was taken. Neither histogram is changed. To get the values recorded
2166+
during each interval without calling `reset()`, compute each difference from a
2167+
snapshot and keep that snapshot as the baseline for the next interval:
2168+
2169+
```js
2170+
const { monitorEventLoopDelay } = require('node:perf_hooks');
2171+
2172+
const histogram = monitorEventLoopDelay();
2173+
histogram.enable();
2174+
let previous = histogram.snapshot();
2175+
2176+
setInterval(() => {
2177+
const current = histogram.snapshot();
2178+
// After a reset, use everything recorded since the reset.
2179+
const delta = current.resetCount === previous.resetCount ?
2180+
current.diff(previous) : current;
2181+
console.log(delta.percentile(99));
2182+
previous = current;
2183+
}, 10_000);
2184+
```
2185+
2186+
The `count`, `exceeds`, and bucket counts of the returned histogram are the
2187+
differences between the two histograms. Its `min` and `max` are computed from
2188+
the buckets of the difference, it has no EWMA state, and its `resetCount` is
2189+
`0`.
2190+
2191+
This method throws:
2192+
2193+
* `ERR_INVALID_ARG_VALUE` if `other` has a different `lowest`, `highest`, or
2194+
`figures` configuration.
2195+
* `ERR_INVALID_STATE` if values have been removed from this histogram since
2196+
`other` was taken, which is the case when the `resetCount` of the two
2197+
histograms differs.
2198+
* `ERR_INVALID_ARG_VALUE` if `other` contains values that are not in this
2199+
histogram, for example because the histograms were passed in the wrong order.
2200+
21552201
### `histogram.exceeds`
21562202

21572203
<!-- YAML
@@ -2604,7 +2650,21 @@ boundaries are equal has an infinite density.
26042650
added: v11.10.0
26052651
-->
26062652

2607-
Resets the collected histogram data.
2653+
Resets the collected histogram data and increments `histogram.resetCount`.
2654+
2655+
### `histogram.resetCount`
2656+
2657+
<!-- YAML
2658+
added: REPLACEME
2659+
-->
2660+
2661+
* Type: {number}
2662+
2663+
The number of times values have been removed from this histogram by `reset()`
2664+
or, for a {RecordableHistogram}, `subtract()`. A snapshot has the `resetCount`
2665+
of its source at the time it was taken, so comparing the `resetCount` of two
2666+
snapshots shows whether the source was reset between them. See
2667+
[`histogram.diff()`][].
26082668

26092669
### `histogram.skewness`
26102670

@@ -2810,7 +2870,7 @@ added:
28102870

28112871
Subtracts the values of `other` from this histogram. Both histograms should
28122872
have compatible configurations. Bucket counts that would become negative
2813-
are clamped to zero.
2873+
are clamped to zero. Increments `histogram.resetCount`.
28142874

28152875
## Class: `SlidingWindowHistogram`
28162876

@@ -3289,6 +3349,7 @@ dns.promises.resolve('localhost');
32893349
[Worker threads]: worker_threads.md#worker-threads
32903350
[`'exit'`]: process.md#event-exit
32913351
[`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options
3352+
[`histogram.diff()`]: #histogramdiffother
32923353
[`histogram.export()`]: #histogramexport
32933354
[`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions
32943355
[`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2

lib/internal/histogram.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,18 @@ class Histogram {
679679
this[kHandle]?.reset();
680680
}
681681

682+
/**
683+
* The number of times values have been removed from the histogram by
684+
* `reset()` or `subtract()`.
685+
* @readonly
686+
* @type {number}
687+
*/
688+
get resetCount() {
689+
if (!isHistogram(this))
690+
throw new ERR_INVALID_THIS('Histogram');
691+
return this[kHandle]?.resetCount();
692+
}
693+
682694
/**
683695
* Returns a new, independent histogram containing a copy of this
684696
* histogram's current state. Values cannot be recorded into the returned
@@ -691,6 +703,20 @@ class Histogram {
691703
return new ClonedHistogram(this[kHandle].snapshot());
692704
}
693705

706+
/**
707+
* Returns a new histogram containing the values recorded in this histogram
708+
* after `other`, an earlier snapshot of it, was taken.
709+
* @param {Histogram} other
710+
* @returns {Histogram}
711+
*/
712+
diff(other) {
713+
if (!isHistogram(this))
714+
throw new ERR_INVALID_THIS('Histogram');
715+
if (!isHistogram(other))
716+
throw new ERR_INVALID_ARG_TYPE('other', 'Histogram', other);
717+
return new ClonedHistogram(this[kHandle].diff(other[kHandle]));
718+
}
719+
694720
[kClone]() {
695721
const handle = this[kHandle];
696722
return {

src/histogram-inl.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ void Histogram::Reset() {
4242
RwLock::ScopedWriteLock lock(mutex_);
4343
hdr_reset(histogram_.get());
4444
InvalidateRecordedSnapshot();
45+
reset_count_++;
4546
exceeds_ = 0;
4647
prev_ = 0;
4748
ewma_mean_ = 0;
@@ -90,6 +91,11 @@ size_t Histogram::Exceeds() const {
9091
return exceeds_;
9192
}
9293

94+
uint64_t Histogram::ResetCount() const {
95+
RwLock::ScopedReadLock lock(mutex_);
96+
return reset_count_;
97+
}
98+
9399
int64_t Histogram::Min() const {
94100
RwLock::ScopedReadLock lock(mutex_);
95101
return hdr_min(histogram_.get());

src/histogram.cc

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,22 @@ void CopyRecordedData(hdr_histogram* target, const hdr_histogram* source) {
9393
}
9494
} // namespace
9595

96-
std::shared_ptr<Histogram> Histogram::Clone() const {
97-
// The layout is fixed when the histogram is created, so the copy can be
98-
// allocated without holding the lock.
99-
hdr_histogram* copy;
96+
std::shared_ptr<Histogram> Histogram::CreateWithSameLayout() const {
97+
// The layout is fixed when the histogram is created, so it can be read
98+
// without holding the lock.
99+
hdr_histogram* histogram;
100100
if (hdr_init(histogram_->lowest_discernible_value,
101101
histogram_->highest_trackable_value,
102102
histogram_->significant_figures,
103-
&copy) != 0) {
103+
&histogram) != 0) {
104104
return {};
105105
}
106-
auto clone = std::make_shared<Histogram>(HistogramPointer(copy), Options{});
106+
return std::make_shared<Histogram>(HistogramPointer(histogram), Options{});
107+
}
108+
109+
std::shared_ptr<Histogram> Histogram::Clone() const {
110+
std::shared_ptr<Histogram> clone = CreateWithSameLayout();
111+
if (!clone) return {};
107112

108113
// Every member that holds recorded or statistical state must be copied
109114
// here. The recorded snapshot cache is not copied; the clone builds its own
@@ -112,6 +117,7 @@ std::shared_ptr<Histogram> Histogram::Clone() const {
112117
CopyRecordedData(clone->histogram_.get(), histogram_.get());
113118
clone->prev_ = prev_;
114119
clone->exceeds_ = exceeds_;
120+
clone->reset_count_ = reset_count_;
115121
clone->ewma_alpha_ = ewma_alpha_;
116122
clone->ewma_mean_ = ewma_mean_;
117123
clone->ewma_variance_ = ewma_variance_;
@@ -121,6 +127,59 @@ std::shared_ptr<Histogram> Histogram::Clone() const {
121127
return clone;
122128
}
123129

130+
std::shared_ptr<Histogram> Histogram::Diff(const Histogram& other,
131+
DiffError* error) const {
132+
// Counts are subtracted index by index, so both histograms must map values
133+
// to the same indexes. None of these fields change after creation.
134+
if (!IsCompatible(other) || histogram_->normalizing_index_offset !=
135+
other.histogram_->normalizing_index_offset) {
136+
*error = DiffError::kIncompatible;
137+
return {};
138+
}
139+
140+
std::shared_ptr<Histogram> diff = CreateWithSameLayout();
141+
if (!diff) {
142+
*error = DiffError::kOutOfMemory;
143+
return {};
144+
}
145+
146+
// Only the recorded values and the exceeds count carry over. EWMA and timing
147+
// state cannot be subtracted.
148+
uint64_t reset_count;
149+
{
150+
RwLock::ScopedReadLock lock(mutex_);
151+
CopyRecordedData(diff->histogram_.get(), histogram_.get());
152+
diff->exceeds_ = exceeds_;
153+
reset_count = reset_count_;
154+
}
155+
156+
// `diff` is not shared yet, so only the lock of `other` is needed from here
157+
// on. Never holding both locks at once avoids lock ordering issues.
158+
RwLock::ScopedReadLock lock(other.mutex_);
159+
if (reset_count != other.reset_count_) {
160+
*error = DiffError::kReset;
161+
return {};
162+
}
163+
if (diff->exceeds_ < other.exceeds_) {
164+
*error = DiffError::kNotEarlier;
165+
return {};
166+
}
167+
168+
hdr_histogram* target = diff->histogram_.get();
169+
const hdr_histogram* source = other.histogram_.get();
170+
for (int32_t i = 0; i < target->counts_len; i++) {
171+
if (target->counts[i] < source->counts[i]) {
172+
*error = DiffError::kNotEarlier;
173+
return {};
174+
}
175+
target->counts[i] -= source->counts[i];
176+
}
177+
diff->exceeds_ -= other.exceeds_;
178+
hdr_reset_internal_counters(target);
179+
*error = DiffError::kNone;
180+
return diff;
181+
}
182+
124183
void Histogram::MemoryInfo(MemoryTracker* tracker) const {
125184
tracker->TrackFieldWithSize("histogram", GetMemorySize());
126185
tracker->TrackFieldWithSize("qrde_snapshot",
@@ -313,6 +372,7 @@ double Histogram::Subtract(const Histogram& other) {
313372
}
314373
hdr_reset_internal_counters(histogram_.get());
315374
InvalidateRecordedSnapshot();
375+
reset_count_++;
316376
exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0;
317377
return static_cast<double>(dropped);
318378
};
@@ -1844,6 +1904,8 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local<FunctionTemplate> tmpl) {
18441904
&fast_get_ewma_error_rate_);
18451905
SetProtoMethodNoSideEffect(isolate, tmpl, "export", DoExport);
18461906
SetProtoMethodNoSideEffect(isolate, tmpl, "snapshot", DoSnapshot);
1907+
SetProtoMethodNoSideEffect(isolate, tmpl, "diff", DoDiff);
1908+
SetProtoMethodNoSideEffect(isolate, tmpl, "resetCount", GetResetCount);
18471909
SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_);
18481910
}
18491911

@@ -1894,6 +1956,8 @@ void HistogramImpl::RegisterExternalReferences(
18941956
registry->Register(GetEwmaErrorRate);
18951957
registry->Register(DoExport);
18961958
registry->Register(DoSnapshot);
1959+
registry->Register(DoDiff);
1960+
registry->Register(GetResetCount);
18971961
registry->Register(fast_get_ewma_mean_);
18981962
registry->Register(fast_get_ewma_stddev_);
18991963
registry->Register(fast_get_ewma_error_rate_);
@@ -3054,6 +3118,39 @@ void HistogramImpl::DoSnapshot(const FunctionCallbackInfo<Value>& args) {
30543118
if (result) args.GetReturnValue().Set(result->object());
30553119
}
30563120

3121+
void HistogramImpl::DoDiff(const FunctionCallbackInfo<Value>& args) {
3122+
Environment* env = Environment::GetCurrent(args);
3123+
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
3124+
HistogramImpl* other = HistogramImpl::FromJSObject(args[0]);
3125+
Histogram::DiffError error;
3126+
std::shared_ptr<Histogram> diff =
3127+
(*histogram)->Diff(*(other->histogram()), &error);
3128+
switch (error) {
3129+
case Histogram::DiffError::kNone:
3130+
break;
3131+
case Histogram::DiffError::kOutOfMemory:
3132+
return THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
3133+
case Histogram::DiffError::kIncompatible:
3134+
return THROW_ERR_INVALID_ARG_VALUE(
3135+
env, "other must have the same configuration as the histogram");
3136+
case Histogram::DiffError::kReset:
3137+
return THROW_ERR_INVALID_STATE(
3138+
env, "Values were removed from the histogram after other was taken");
3139+
case Histogram::DiffError::kNotEarlier:
3140+
return THROW_ERR_INVALID_ARG_VALUE(
3141+
env, "other contains values that are not in the histogram");
3142+
}
3143+
3144+
BaseObjectPtr<HistogramBase> result =
3145+
HistogramBase::Create(env, std::move(diff));
3146+
if (result) args.GetReturnValue().Set(result->object());
3147+
}
3148+
3149+
void HistogramImpl::GetResetCount(const FunctionCallbackInfo<Value>& args) {
3150+
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
3151+
args.GetReturnValue().Set(static_cast<double>((*histogram)->ResetCount()));
3152+
}
3153+
30573154
void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo<Value>& args) {
30583155
Environment* env = Environment::GetCurrent(args);
30593156
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());

src/histogram.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ class Histogram : public MemoryRetainer {
6565
// if the copy cannot be allocated.
6666
std::shared_ptr<Histogram> Clone() const;
6767

68+
enum class DiffError {
69+
kNone,
70+
kOutOfMemory,
71+
// `other` has a different layout.
72+
kIncompatible,
73+
// Values were removed from this histogram after `other` was taken.
74+
kReset,
75+
// `other` contains values that this histogram does not.
76+
kNotEarlier,
77+
};
78+
79+
// Returns a new histogram containing the values recorded in this histogram
80+
// after `other`, an earlier copy of it, was taken. Returns nullptr and sets
81+
// `error` if the difference cannot be computed.
82+
std::shared_ptr<Histogram> Diff(const Histogram& other,
83+
DiffError* error) const;
84+
6885
Histogram(HistogramPointer histogram, const Options& options);
6986
virtual ~Histogram() = default;
7087

@@ -80,6 +97,7 @@ class Histogram : public MemoryRetainer {
8097
inline int64_t Percentile(double percentile) const;
8198
inline size_t Exceeds() const;
8299
inline size_t Count() const;
100+
inline uint64_t ResetCount() const;
83101

84102
inline uint64_t RecordDelta();
85103

@@ -165,10 +183,13 @@ class Histogram : public MemoryRetainer {
165183
inline void UpdateEwma(double value);
166184
inline void InvalidateRecordedSnapshot();
167185
size_t GetCachedRecordedSnapshotMemorySize() const;
186+
std::shared_ptr<Histogram> CreateWithSameLayout() const;
168187

169188
HistogramPointer histogram_;
170189
uint64_t prev_ = 0;
171190
size_t exceeds_ = 0;
191+
// Incremented whenever recorded values are removed by Reset() or Subtract().
192+
uint64_t reset_count_ = 0;
172193

173194
// EWMA state (active when ewma_alpha_ > 0)
174195
double ewma_alpha_ = 0;
@@ -242,6 +263,8 @@ class HistogramImpl {
242263
static void DoExport(const v8::FunctionCallbackInfo<v8::Value>& args);
243264
static void DoImport(const v8::FunctionCallbackInfo<v8::Value>& args);
244265
static void DoSnapshot(const v8::FunctionCallbackInfo<v8::Value>& args);
266+
static void DoDiff(const v8::FunctionCallbackInfo<v8::Value>& args);
267+
static void GetResetCount(const v8::FunctionCallbackInfo<v8::Value>& args);
245268

246269
static void FastReset(v8::Local<v8::Value> receiver);
247270
static double FastGetCount(v8::Local<v8::Value> receiver);

0 commit comments

Comments
 (0)