perf_hooks: report histogram memory to V8

The native object behind histograms created by `createHistogram()`,
`importHistogram()`, and `snapshot()` did not report the memory of its
HDR histogram to V8. That memory is about 352 KiB with the default
options, and V8 did not take it into account when scheduling garbage
collection. Short-lived histograms could therefore hold on to a large
amount of native memory until an unrelated garbage collection: in a
loop taking 2,000 snapshots, RSS grew by 477 MiB.

Report the size of the native histogram while the object is alive, as
`SlidingWindowHistogram` already does for its chunks. Objects that
share a native histogram after cloning each report its full size.
With this change, RSS grows by 8 MiB in the same loop.

Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: OpenCode
PR-URL: https://github.com/nodejs/node/pull/66099
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
James M Snell committed 2026-09-25 03:31:52 +00:00
1 parent b9e257f5cb
commit 7d2b7e178d
3 files changed
+61

No files matched your search

+16
View File
@@ -2032,6 +2032,7 @@ HistogramBase::HistogramBase(Environment* env,
HistogramImpl::InternalFields::kImplField,
static_cast<HistogramImpl*>(this),
EmbedderDataTag::kDefault);
ReportExternalMemory();
}
HistogramBase::HistogramBase(Environment* env,
@@ -2043,6 +2044,21 @@ HistogramBase::HistogramBase(Environment* env,
HistogramImpl::InternalFields::kImplField,
static_cast<HistogramImpl*>(this),
EmbedderDataTag::kDefault);
ReportExternalMemory();
}
HistogramBase::~HistogramBase() {
env()->external_memory_accounter()->Decrease(env()->isolate(),
external_memory_);
}
// Reports the size of the native histogram to V8 so that the garbage
// collector accounts for it. Every object that refers to a native histogram
// reports its full size, including objects that share one after cloning.
void HistogramBase::ReportExternalMemory() {
external_memory_ = histogram()->GetMemorySize();
env()->external_memory_accounter()->Increase(env()->isolate(),
external_memory_);
}
void HistogramBase::MemoryInfo(MemoryTracker* tracker) const {
+7
View File
@@ -334,6 +334,8 @@ class HistogramBase final : public BaseObject, public HistogramImpl {
v8::Local<v8::Object> wrap,
std::shared_ptr<Histogram> histogram);
~HistogramBase() override;
BaseObject::TransferMode GetTransferMode() const override {
return TransferMode::kCloneable;
}
@@ -361,6 +363,11 @@ class HistogramBase final : public BaseObject, public HistogramImpl {
};
private:
void ReportExternalMemory();
// The native memory reported to V8 while this object is alive.
size_t external_memory_ = 0;
static v8::CFunction fast_record_;
static v8::CFunction fast_record_delta_;
};
@@ -0,0 +1,38 @@
'use strict';
// Tests that histogram objects report the memory of their native histogram to
// V8, so that creating many short-lived histograms triggers garbage
// collection.
const common = require('../common');
const assert = require('assert');
const { setImmediate: setImmediatePromise } = require('timers/promises');
const {
PerformanceObserver,
constants: { NODE_PERFORMANCE_GC_FLAGS_ALL_EXTERNAL_MEMORY },
createHistogram,
} = require('perf_hooks');
let externalMemoryGCs = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.detail.flags & NODE_PERFORMANCE_GC_FLAGS_ALL_EXTERNAL_MEMORY)
externalMemoryGCs++;
}
});
observer.observe({ entryTypes: ['gc'] });
// With the default options, a histogram holds 45,056 64-bit counts, about
// 352 KiB. V8 starts a garbage collection once external memory has grown by
// 64 MiB, so 400 unreferenced snapshots (about 137 MiB) must trigger one.
const histogram = createHistogram();
for (let i = 0; i < 400; i++) histogram.snapshot();
(async () => {
// Performance entries for garbage collections are delivered asynchronously.
for (let i = 0; i < 10 && externalMemoryGCs === 0; i++)
await setImmediatePromise();
observer.disconnect();
assert.ok(externalMemoryGCs > 0,
'Expected a garbage collection caused by external memory');
})().then(common.mustCall());