diff --git a/benchmark/http/heap-profiler-labels.js b/benchmark/http/heap-profiler-labels.js new file mode 100644 index 000000000000..1426dca174f5 --- /dev/null +++ b/benchmark/http/heap-profiler-labels.js @@ -0,0 +1,90 @@ +'use strict'; + +// Benchmark: HTTP server throughput impact of heap profiler with labels. +// +// Measures requests/sec across three modes: +// - none: no profiler (baseline) +// - sampling: profiler active, no labels +// - sampling-with-labels: profiler active with labels via withHeapProfileLabels +// +// Workload per request: ~100KB V8 heap (JSON parse/stringify) + ~50KB Buffer +// to exercise both HeapProfileLabelsCallback and ProfilingArrayBufferAllocator. +// +// Run with compare.js: +// node benchmark/compare.js --old ./out/Release/node --new ./out/Release/node \ +// --runs 10 --filter heap-profiler-labels --set c=50 -- http + +const common = require('../common.js'); +const { PORT } = require('../_http-benchmarkers.js'); +const v8 = require('v8'); + +const bench = common.createBenchmark(main, { + mode: ['none', 'sampling', 'sampling-with-labels'], + c: [50], + duration: 10, +}); + +// Build a ~100KB JSON payload. +const items = []; +for (let i = 0; i < 200; i++) { + items.push({ + id: i, + name: `user-${i}`, + email: `user${i}@example.com`, + role: 'admin', + metadata: { created: '2024-01-01', tags: ['a', 'b', 'c'] }, + }); +} +const payloadTemplate = JSON.stringify({ data: items, total: 200 }); + +function main({ mode, c, duration }) { + const http = require('http'); + + const interval = 512 * 1024; // 512 KiB, V8's default sampling interval. + + let handle; + if (mode === 'sampling') { + handle = v8.startHeapProfile({ sampleInterval: interval }); + } else if (mode === 'sampling-with-labels') { + handle = v8.startHeapProfile({ labels: true, sampleInterval: interval }); + } + + const server = http.createServer((req, res) => { + const handler = () => { + // 1. ~100KB V8 heap: JSON parse + stringify + const parsed = JSON.parse(payloadTemplate); + parsed.requestId = Math.random(); + const body = JSON.stringify(parsed); + + // 2. ~50KB Buffer + const buf = Buffer.alloc(50 * 1024, 0x42); + + // Keep buf reference alive until response is sent. + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': body.length, + 'X-Buf-Check': buf[0], + }); + res.end(body); + }; + + if (mode === 'sampling-with-labels') { + v8.withHeapProfileLabels({ route: req.url }, handler); + } else { + handler(); + } + }); + + server.listen(PORT, () => { + bench.http({ + path: '/api/bench', + connections: c, + duration, + }, () => { + if (handle) { + handle.stop(); + } + server.close(); + }); + }); +} diff --git a/benchmark/http/heap-profiler-realistic.js b/benchmark/http/heap-profiler-realistic.js new file mode 100644 index 000000000000..baa223abeab9 --- /dev/null +++ b/benchmark/http/heap-profiler-realistic.js @@ -0,0 +1,155 @@ +'use strict'; + +// Benchmark: HTTP app server plus DB server heap profiler overhead. +// +// Architecture: wrk → [App Server :PORT] → [DB Server :PORT+1] +// +// The app server fetches JSON rows from the DB server, parses, +// sums two columns over all rows, and returns the result. This exercises: +// - http.get (async I/O + Buffer allocation for response body) +// - JSON.parse of the DB response (V8 heap allocation) +// - Two iteration passes over the rows (intermediate values) +// - ALS label propagation across async I/O boundary +// +// Run with compare.js for statistical significance: +// node benchmark/compare.js --old ./out/Release/node --new ./out/Release/node \ +// --runs 30 --filter heap-profiler-realistic --set rows=1000 -- http + +const common = require('../common.js'); +const { PORT } = require('../_http-benchmarkers.js'); +const v8 = require('v8'); +const http = require('http'); + +const DB_PORT = PORT + 1; + +const bench = common.createBenchmark(main, { + mode: ['none', 'sampling', 'sampling-with-labels'], + rows: [100, 1000], + c: [50], + duration: 10, +}); + +// --- DB Server: pre-built JSON responses keyed by row count --- + +function buildDBResponse(n) { + const categories = ['electronics', 'clothing', 'food', 'books', 'tools']; + const rows = []; + for (let i = 0; i < n; i++) { + rows.push({ + id: i, + amount: Math.round(Math.random() * 10000) / 100, + quantity: Math.floor(Math.random() * 500), + name: `user-${String(i).padStart(6, '0')}`, + email: `user${i}@example.com`, + category: categories[i % categories.length], + }); + } + const body = JSON.stringify({ rows, total: n }); + return { body, len: Buffer.byteLength(body) }; +} + +// --- App Server helpers --- + +function fetchFromDB(rows) { + return new Promise((resolve, reject) => { + const req = http.get( + `http://127.0.0.1:${DB_PORT}/?rows=${rows}`, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString())); + } catch (e) { + reject(e); + } + }); + }, + ); + req.on('error', reject); + }); +} + +function processRows(data) { + const { rows } = data; + // Two passes over the rows (column aggregation). + let totalAmount = 0; + for (let i = 0; i < rows.length; i++) { + totalAmount += rows[i].amount; + } + let totalQuantity = 0; + for (let i = 0; i < rows.length; i++) { + totalQuantity += rows[i].quantity; + } + return { + totalAmount: Math.round(totalAmount * 100) / 100, + totalQuantity, + count: rows.length, + }; +} + +function main({ mode, rows, c, duration }) { + // Pre-build DB responses, including for a row count passed on the command + // line. Without this the server falls back to the 1000-row response and + // silently measures something other than what was asked for. + const dbResponses = {}; + for (const n of new Set([100, 1000, rows])) { + dbResponses[n] = buildDBResponse(n); + } + + // Start DB server. + const dbServer = http.createServer((req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${DB_PORT}`); + const n = parseInt(url.searchParams.get('rows') || '1000', 10); + const resp = dbResponses[n] || dbResponses[1000]; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': resp.len, + }); + res.end(resp.body); + }); + + dbServer.listen(DB_PORT, () => { + const interval = 512 * 1024; + let handle; + if (mode === 'sampling') { + handle = v8.startHeapProfile({ sampleInterval: interval }); + } else if (mode === 'sampling-with-labels') { + handle = v8.startHeapProfile({ labels: true, sampleInterval: interval }); + } + + // Start app server. + const appServer = http.createServer((req, res) => { + const handler = async () => { + const data = await fetchFromDB(rows); + const result = processRows(data); + const body = JSON.stringify(result); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); + }; + + if (mode === 'sampling-with-labels') { + v8.withHeapProfileLabels({ route: req.url }, handler); + } else { + handler(); + } + }); + + appServer.listen(PORT, () => { + bench.http({ + path: '/api/data', + connections: c, + duration, + }, () => { + if (handle) { + handle.stop(); + } + appServer.close(); + dbServer.close(); + }); + }); + }); +} diff --git a/benchmark/v8/heap-profiler-labels-resolution.js b/benchmark/v8/heap-profiler-labels-resolution.js new file mode 100644 index 000000000000..f16529a49b8f --- /dev/null +++ b/benchmark/v8/heap-profiler-labels-resolution.js @@ -0,0 +1,81 @@ +'use strict'; + +// Benchmark: cost of getAllocationProfile() label resolution. +// +// Builds up live samples under withHeapProfileLabels() across N unique +// label sets, then measures wall-clock time to call getAllocationProfile() +// repeatedly. Varying samples_per_unique_label_set shows whether resolution +// cost scales with sample count or unique label sets. +// +// Run standalone: +// node benchmark/v8/heap-profiler-labels-resolution.js +// +// Run with compare.js for statistical analysis: +// node benchmark/compare.js --old ./node-baseline --new ./node-cached \ +// --filter heap-profiler-labels-resolution +// +// Memory: each retained sample corresponds to ~sampleInterval bytes of +// live heap. With the 512 KiB default and a target of 5,000 samples the +// workload retains ~4 GiB after compensating for the V8 sampler dropping +// ~37% of one-sampleInterval-sized allocations. Hence +// --max-old-space-size=6144. + +const common = require('../common.js'); +const v8 = require('v8'); + +const SAMPLE_INTERVAL = 512 * 1024; // V8 default +const RETAINED_SAMPLES_TARGET = 5000; +// V8's sampler picks each allocation of size A with probability +// 1 - exp(-A / sampleInterval). For A = sampleInterval that's ~63%, so +// allocate ~1.6x as many chunks as the desired sample count. +const SAMPLE_PROBABILITY = 1 - Math.exp(-1); + +const bench = common.createBenchmark(main, { + samples_per_unique_label_set: [1, 10, 100, 1000], + n: [20], +}, { + flags: ['--max-old-space-size=6144'], +}); + +function main({ samples_per_unique_label_set: samplesPerLabel, n }) { + const uniqueLabelSets = Math.max( + 1, Math.ceil(RETAINED_SAMPLES_TARGET / samplesPerLabel), + ); + const chunksPerLabel = Math.max( + 1, Math.ceil(samplesPerLabel / SAMPLE_PROBABILITY), + ); + // Each chunk is one sampleInterval of JS heap (a JSArray of Smi slots). + // JSArray over plain strings here because String.repeat() of a single + // ASCII char appears to bypass the sampler's allocation observers in + // large-object-space, while typed JSArray allocations are reliably + // sampled. + const SLOTS_PER_CHUNK = SAMPLE_INTERVAL / 8; + + const handle = v8.startHeapProfile({ labels: true, sampleInterval: SAMPLE_INTERVAL }); + + // The sampling profiler tracks each sample with a weak global; once + // the underlying object is GC'd the sample is dropped. Holding strong + // references in this retainer keeps samples live throughout the + // measurement loop below. + const retainer = []; + for (let i = 0; i < uniqueLabelSets; i++) { + const labels = { route: `/route-${i}`, method: 'GET' }; + v8.withHeapProfileLabels(labels, () => { + for (let j = 0; j < chunksPerLabel; j++) { + retainer.push(new Array(SLOTS_PER_CHUNK).fill(0)); + } + }); + } + + bench.start(); + for (let i = 0; i < n; i++) { + const profile = handle.getAllocationProfile(); + // Defensive use of the result so the call is not eliminated. + if (!profile) throw new Error('profile missing'); + } + bench.end(n); + + handle.stop(); + // Touch retainer post-bench so it stays live across the measurement. + if (retainer.length === 0) throw new Error('retainer leaked'); +} diff --git a/benchmark/v8/heap-profiler-labels.js b/benchmark/v8/heap-profiler-labels.js new file mode 100644 index 000000000000..653a57d5282b --- /dev/null +++ b/benchmark/v8/heap-profiler-labels.js @@ -0,0 +1,60 @@ +'use strict'; + +// Benchmark: overhead of V8 sampling heap profiler with and without labels. +// +// Measures per-allocation cost across three modes: +// - none: no profiler running (baseline) +// - sampling: profiler active, no labels callback +// - sampling-with-labels: profiler active with labels via withHeapProfileLabels +// +// Run standalone: +// node benchmark/v8/heap-profiler-labels.js +// +// Run with compare.js for statistical analysis: +// node benchmark/compare.js --old ./node-baseline --new ./node-with-labels \ +// --filter heap-profiler-labels + +const common = require('../common.js'); +const v8 = require('v8'); + +const bench = common.createBenchmark(main, { + mode: ['none', 'sampling', 'sampling-with-labels'], + n: [1e6], +}); + +function main({ mode, n }) { + const interval = 512 * 1024; // 512 KiB, V8's default sampling interval. + + let handle; + if (mode === 'sampling') { + handle = v8.startHeapProfile({ sampleInterval: interval }); + } else if (mode === 'sampling-with-labels') { + handle = v8.startHeapProfile({ labels: true, sampleInterval: interval }); + } + + if (mode === 'sampling-with-labels') { + v8.withHeapProfileLabels({ route: '/bench' }, () => { + runWorkload(n); + }); + } else { + runWorkload(n); + } + + if (handle) { + handle.stop(); + } +} + +function runWorkload(n) { + const arr = []; + bench.start(); + for (let i = 0; i < n; i++) { + // Allocate objects with string properties. Each object is ~100-200 + // bytes on the V8 heap. + arr.push({ id: i, name: `item-${i}`, value: Math.random() }); + // Retain the last 1000 objects to keep steady-state GC pressure + // without unbounded growth. + if (arr.length > 1000) arr.shift(); + } + bench.end(n); +} diff --git a/common.gypi b/common.gypi index 0b01ec8c49fe..159968657306 100644 --- a/common.gypi +++ b/common.gypi @@ -89,6 +89,7 @@ 'v8_enable_v8_checks%': 0, 'v8_use_perfetto%': 0, 'tsan%': 0, + 'v8_enable_continuation_preserved_embedder_data%': 1, ##### end V8 defaults ##### @@ -546,6 +547,13 @@ ['tsan == 1', { 'defines': ['V8_IS_TSAN',], }], + # Heap profile sample labels ride ContinuationPreservedEmbedderData, so + # they are gated on the same feature. Defined here in target_defaults + # so that every Node target and every node-gyp addon sees the same + # v8::AllocationProfile::Sample layout as libnode. + ['v8_enable_continuation_preserved_embedder_data == 1', { + 'defines': ['V8_HEAP_PROFILER_SAMPLE_LABELS',], + }], ['OS == "win"', { 'defines': [ 'WIN32', diff --git a/deps/v8/BUILD.gn b/deps/v8/BUILD.gn index e81430fbc393..ad520e2a199a 100644 --- a/deps/v8/BUILD.gn +++ b/deps/v8/BUILD.gn @@ -1439,6 +1439,11 @@ config("features") { } if (v8_enable_continuation_preserved_embedder_data) { defines += [ "V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA" ] + + # Heap profile sample labels ride ContinuationPreservedEmbedderData, so + # they are gated on the same feature. Mirrors tools/v8_gypfiles/ + # features.gypi so GN and GYP builds enable the feature identically. + defines += [ "V8_HEAP_PROFILER_SAMPLE_LABELS" ] } if (v8_enable_allocation_folding) { defines += [ "V8_ALLOCATION_FOLDING" ] @@ -4454,6 +4459,7 @@ v8_header_set("v8_internal_headers") { "src/profiler/heap-snapshot-common.h", "src/profiler/heap-snapshot-generator-inl.h", "src/profiler/heap-snapshot-generator.h", + "src/profiler/label-intern-table.h", "src/profiler/output-stream-writer.h", "src/profiler/profile-generator-inl.h", "src/profiler/profile-generator.h", @@ -6086,6 +6092,7 @@ v8_source_set("v8_base_without_compiler") { "src/profiler/cpu-profiler.cc", "src/profiler/heap-profiler.cc", "src/profiler/heap-snapshot-generator.cc", + "src/profiler/label-intern-table.cc", "src/profiler/profile-generator.cc", "src/profiler/profiler-listener.cc", "src/profiler/profiler-stats.cc", diff --git a/deps/v8/include/v8-profiler.h b/deps/v8/include/v8-profiler.h index 927fa12e3190..efcdc29a04ae 100644 --- a/deps/v8/include/v8-profiler.h +++ b/deps/v8/include/v8-profiler.h @@ -11,6 +11,13 @@ #include #include +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. The V8_HEAP_PROFILER_SAMPLE_LABELS blocks in this header, in +// deps/v8/src/profiler/{sampling-heap-profiler,heap-profiler, +// label-intern-table}.{h,cc} and in deps/v8/src/api/api.cc are a Node.js +// floating patch on vendored V8, not legacy V8 code. See +// doc/contributing/maintaining/maintaining-V8.md for the refloat workflow. + #include "cppgc/common.h" // NOLINT(build/include_directory) #include "v8-local-handle.h" // NOLINT(build/include_directory) #include "v8-message.h" // NOLINT(build/include_directory) @@ -836,6 +843,14 @@ class V8_EXPORT AllocationProfile { * been collected by GC. */ bool is_live; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Opaque id for the label captured at allocation time, or 0 for none. + * Resolve it with HeapProfiler::ResolveLabelValue, which returns empty + * once the profiler has been stopped. + */ + uint32_t label_id; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; /** @@ -987,6 +1002,7 @@ class QueryObjectPredicate { virtual bool Filter(v8::Local object) = 0; }; + /** * Interface for controlling heap profiling. Instance of the * profiler can be retrieved using v8::Isolate::GetHeapProfiler. @@ -1286,6 +1302,50 @@ class V8_EXPORT HeapProfiler { void SetGetDetachednessCallback(GetDetachednessCallback callback, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Sets the key under which each sample's label is looked up in the + * ContinuationPreservedEmbedderData Map when the sample is taken. Setting a + * key also enables label capture; passing an empty handle clears it. May be + * called at any time; changes take effect on subsequent samples. + */ + void SetHeapProfileSampleLabelsKey(Local key); + + /** + * Looks |cped| up as a Map under the key set by + * SetHeapProfileSampleLabelsKey. Allocation-free and GC-safe, so it is + * usable from sampling context. Returns empty if no key is set, |cped| is + * not a Map, or the key is absent. + */ + MaybeLocal LookupAlsValue(Local cped); + + /** + * Interns |value| and returns an id the embedder can store in place of a + * Global, or 0 if no sampling profiler is active or |value| cannot + * be interned. Interning the same value again returns the same id and + * increments its refcount, so every non-zero result must be balanced by a + * ReleaseLabelValue or the value stays pinned. + * + * Main thread only; ReleaseLabelValue is the only entry point here that + * background threads may call. + */ + uint32_t InternLabelValue(Local value); + + /** + * Decrements the refcount for |id|, letting the value be collected once it + * reaches zero. Safe to call from any thread, and safe after + * StopSamplingHeapProfiler, which leaves stale ids resolving to nothing. + */ + void ReleaseLabelValue(uint32_t id); + + /** + * Resolves |id| to the value that was interned under it, or empty if it has + * been released or the session that minted it has stopped. Main thread + * only, and the caller must hold a HandleScope. + */ + MaybeLocal ResolveLabelValue(uint32_t id); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + /** * Returns whether the heap profiler is currently taking a snapshot. */ diff --git a/deps/v8/src/api/api.cc b/deps/v8/src/api/api.cc index fbd628370c0b..d014ed78dcdf 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -12011,6 +12011,29 @@ void HeapProfiler::SetGetDetachednessCallback(GetDetachednessCallback callback, data); } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +void HeapProfiler::SetHeapProfileSampleLabelsKey(Local key) { + reinterpret_cast(this) + ->SetHeapProfileSampleLabelsKey(key); +} + +MaybeLocal HeapProfiler::LookupAlsValue(Local cped) { + return reinterpret_cast(this)->LookupAlsValue(cped); +} + +uint32_t HeapProfiler::InternLabelValue(Local value) { + return reinterpret_cast(this)->InternLabelValue(value); +} + +void HeapProfiler::ReleaseLabelValue(uint32_t id) { + reinterpret_cast(this)->ReleaseLabelValue(id); +} + +MaybeLocal HeapProfiler::ResolveLabelValue(uint32_t id) { + return reinterpret_cast(this)->ResolveLabelValue(id); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + bool HeapProfiler::IsTakingSnapshot() { return reinterpret_cast(this)->IsTakingSnapshot(); } diff --git a/deps/v8/src/profiler/heap-profiler.cc b/deps/v8/src/profiler/heap-profiler.cc index c123645e8a4d..adc3ed2a8a92 100644 --- a/deps/v8/src/profiler/heap-profiler.cc +++ b/deps/v8/src/profiler/heap-profiler.cc @@ -18,6 +18,8 @@ #include "src/heap/heap.h" #include "src/objects/cpp-heap-object-wrapper-inl.h" #include "src/objects/js-array-buffer-inl.h" +#include "src/objects/js-collection-inl.h" +#include "src/objects/ordered-hash-table.h" #include "src/profiler/allocation-tracker.h" #include "src/profiler/heap-snapshot-generator-inl.h" #include "src/profiler/sampling-heap-profiler.h" @@ -29,7 +31,13 @@ HeapProfiler::HeapProfiler(Heap* heap) : ids_(new HeapObjectsMap(heap)), names_(new StringsStorage()), is_tracking_object_moves_(false), - is_taking_snapshot_(false) {} + is_taking_snapshot_(false) +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_intern_table_(reinterpret_cast(heap->isolate())) +#endif +{ +} HeapProfiler::~HeapProfiler() = default; @@ -233,12 +241,28 @@ bool HeapProfiler::StartSamplingHeapProfiler( v8::HeapProfiler::SamplingFlags flags) { if (sampling_heap_profiler_) return false; sampling_heap_profiler_.reset(new SamplingHeapProfiler( - heap(), names_.get(), sample_interval, stack_depth, flags)); + heap(), names_.get(), sample_interval, stack_depth, flags +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_intern_table_ +#endif + )); return true; } void HeapProfiler::StopSamplingHeapProfiler() { sampling_heap_profiler_.reset(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Stop the finished session from pinning JS values. Ids it minted then + // resolve to empty, and releasing them is a no-op. + label_intern_table_.Clear(); + // Clear the ALS key so a later session that never requested labels does + // not inherit the previous session's key and emit labelled samples. + // Node re-arms the key on every labels:true start, so clearing here is + // safe; an out-of-band stop cannot notify Node, so this is the only + // place the clear can happen reliably. + sample_labels_als_key_.Reset(); +#endif MaybeClearStringsStorage(); } @@ -405,4 +429,50 @@ void HeapProfiler::QueryObjects(DirectHandle context, }); } +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. See the comment at the top of include/v8-profiler.h. +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +uint32_t HeapProfiler::InternLabelValue(v8::Local value) { + // Interning with no sampler running would accumulate entries that nothing + // will release. Main thread only, so reading the pointer is safe. + if (!sampling_heap_profiler_) return LabelInternTable::kNoLabelId; + if (value.IsEmpty()) return LabelInternTable::kNoLabelId; + return label_intern_table_.Intern(value); +} + +void HeapProfiler::ReleaseLabelValue(uint32_t id) { + if (id == LabelInternTable::kNoLabelId) return; + // Callable from any thread: the table lives as long as the isolate, so a + // background sweeper never reaches one that is being destroyed, and no + // check of sampling_heap_profiler_ is needed. + label_intern_table_.Release(id); +} + +v8::MaybeLocal HeapProfiler::ResolveLabelValue(uint32_t id) { + if (id == LabelInternTable::kNoLabelId) return v8::MaybeLocal(); + return label_intern_table_.Lookup(id); +} + +v8::MaybeLocal HeapProfiler::LookupAlsValue( + v8::Local cped) { + if (sample_labels_als_key_.IsEmpty() || cped.IsEmpty()) { + return v8::MaybeLocal(); + } + Tagged cped_obj = *Utils::OpenDirectHandle(*cped); + if (!IsJSMap(cped_obj)) return v8::MaybeLocal(); + + Tagged js_map = Cast(cped_obj); + Tagged table = Cast(js_map->table()); + + v8::Isolate* v8_isolate = reinterpret_cast(isolate()); + v8::Local als_key_local = sample_labels_als_key_.Get(v8_isolate); + Tagged key_obj = *Utils::OpenDirectHandle(*als_key_local); + InternalIndex entry = table->FindEntry(isolate(), key_obj); + if (!entry.is_found()) return v8::MaybeLocal(); + + Tagged value = table->ValueAt(entry); + return Utils::ToLocal(direct_handle(value, isolate())); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + } // namespace v8::internal diff --git a/deps/v8/src/profiler/heap-profiler.h b/deps/v8/src/profiler/heap-profiler.h index 82d4db266e7d..597067ab99f9 100644 --- a/deps/v8/src/profiler/heap-profiler.h +++ b/deps/v8/src/profiler/heap-profiler.h @@ -14,6 +14,9 @@ #include "src/debug/debug-interface.h" #include "src/heap/heap.h" #include "src/profiler/heap-snapshot-common.h" +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +#include "src/profiler/label-intern-table.h" +#endif namespace v8 { namespace internal { @@ -79,6 +82,30 @@ class HeapProfiler : public HeapObjectAllocationTracker { bool is_sampling_allocations() { return !!sampling_heap_profiler_; } AllocationProfile* GetAllocationProfile(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + void SetHeapProfileSampleLabelsKey(v8::Local key) { + if (key.IsEmpty()) { + sample_labels_als_key_.Reset(); + } else { + sample_labels_als_key_.Reset( + reinterpret_cast(isolate()), key); + } + } + + const v8::Global& sample_labels_als_key() const { + return sample_labels_als_key_; + } + + v8::MaybeLocal LookupAlsValue(v8::Local cped); + + // Entry points for embedder-side allocation trackers sharing the label + // table. Only ReleaseLabelValue may be called off the main thread; see + // include/v8-profiler.h for the full contract. + uint32_t InternLabelValue(v8::Local value); + void ReleaseLabelValue(uint32_t id); + v8::MaybeLocal ResolveLabelValue(uint32_t id); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + void StartHeapObjectsTracking(bool track_allocations); void StopHeapObjectsTracking(); AllocationTracker* allocation_tracker() const { @@ -168,6 +195,15 @@ class HeapProfiler : public HeapObjectAllocationTracker { bool is_tracking_object_moves_; bool is_taking_snapshot_; base::Mutex profiler_mutex_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Main thread only: written by SetHeapProfileSampleLabelsKey(), read by + // SampleObject(). + v8::Global sample_labels_als_key_; + // Must stay declared before sampling_heap_profiler_. Members are destroyed + // in reverse declaration order, and ~SamplingHeapProfiler releases every + // retained sample's id into this table. + LabelInternTable label_intern_table_; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS std::unique_ptr sampling_heap_profiler_; std::vector> build_embedder_graph_callbacks_; diff --git a/deps/v8/src/profiler/label-intern-table.cc b/deps/v8/src/profiler/label-intern-table.cc new file mode 100644 index 000000000000..717c03fdbefa --- /dev/null +++ b/deps/v8/src/profiler/label-intern-table.cc @@ -0,0 +1,201 @@ +// Copyright 2026 the V8 project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. This whole file is part of the Node.js floating patch set; see the +// comment at the top of include/v8-profiler.h. +#include "src/profiler/label-intern-table.h" + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +#include + +#include "include/v8-isolate.h" +#include "src/api/api-inl.h" +#include "src/execution/isolate.h" +#include "src/objects/js-objects-inl.h" +#include "src/objects/objects-inl.h" +#include "src/objects/smi.h" + +namespace v8 { +namespace internal { + +LabelInternTable::LabelInternTable(v8::Isolate* isolate) : isolate_(isolate) {} + +LabelInternTable::~LabelInternTable() { + // Draining before the walk avoids double-Reset on entries about to be freed. + base::MutexGuard guard(&mutex_); + DrainPendingFreeLocked(); + for (auto& bucket : buckets_) { + for (auto& entry : bucket.second) { + entry.global.Reset(); + } + } + buckets_.clear(); + id_to_hash_.clear(); +} + +void LabelInternTable::DrainPendingFreeLocked() { + // A duplicate id (queued, revived, released again before any drain) is + // harmless: the first occurrence erases id_to_hash_[id] and the rest miss. + for (uint32_t id : pending_free_) { + auto map_it = id_to_hash_.find(id); + if (map_it == id_to_hash_.end()) continue; + uint32_t hash = map_it->second; + auto bucket_it = buckets_.find(hash); + if (bucket_it == buckets_.end()) continue; + auto& chain = bucket_it->second; + for (auto entry_it = chain.begin(); entry_it != chain.end(); ++entry_it) { + if (entry_it->id != id) continue; + if (entry_it->refcount > 0) break; // revived; leave alone + entry_it->global.Reset(); + chain.erase(entry_it); + id_to_hash_.erase(map_it); + if (chain.empty()) buckets_.erase(bucket_it); + break; + } + } + pending_free_.clear(); +} + +uint32_t LabelInternTable::Intern(v8::Local value) { + DCHECK(!value.IsEmpty()); + Isolate* i_isolate = reinterpret_cast(isolate_); + + DisallowGarbageCollection no_gc; + Tagged value_obj = *Utils::OpenDirectHandle(*value); + // Identity hash is only defined for JSReceiver. + if (!IsJSReceiver(value_obj)) return kNoLabelId; + + Tagged receiver = Cast(value_obj); + uint32_t hash = static_cast( + receiver->GetOrCreateIdentityHash(i_isolate).value()); + hash &= hash_mask_; + + Address candidate_ptr = value_obj.ptr(); + base::MutexGuard guard(&mutex_); + auto& chain = buckets_[hash]; + for (Entry& entry : chain) { + Tagged existing = + *Utils::OpenDirectHandle(*entry.global.Get(isolate_)); + if (existing.ptr() == candidate_ptr) { + // Bumping an entry at refcount 0 revives it: the drain below skips + // queued ids that are live again, so the Global is never Reset across + // the Release/Intern race and identity is preserved. + // Saturate rather than wrap: an overflowed refcount would later + // underflow on Release and free a still-live label. + if (entry.refcount != std::numeric_limits::max()) { + ++entry.refcount; + } + // Copy the id out first. The drain can erase earlier entries in this + // chain, which invalidates `entry`. + uint32_t revived_id = entry.id; + DrainPendingFreeLocked(); + return revived_id; + } + } + + // Mint a fresh id. In steady state next_id_ has never been issued, so the + // first candidate is free. After ~2^32 interns the counter wraps; skip + // kNoLabelId and any id still mapped to a live entry. If no free id turns + // up within the probe bound, fail closed (drop the label) rather than + // aliasing a live id, which would corrupt refcounts and attribution. + // + // The probe treats ids queued in pending_free_ as occupied (they are still + // in id_to_hash_ until drained), so on wraparound it may fail closed + // before a pending drain would free an id. Draining first is not done + // here: DrainPendingFreeLocked() can erase from `chain`, invalidating the + // `chain` reference taken above. Wraparound requires roughly 2^32 interns + // in one isolate. + uint32_t id = kNoLabelId; + for (uint32_t probe = 0; probe <= kIdProbeLimit; ++probe) { + uint32_t candidate = ++next_id_; + if (candidate == kNoLabelId) continue; + if (id_to_hash_.count(candidate) == 0) { + id = candidate; + break; + } + } + if (id == kNoLabelId) { + // Do not leave an empty bucket behind from buckets_[hash] above. + if (chain.empty()) buckets_.erase(hash); + DrainPendingFreeLocked(); + return kNoLabelId; + } + chain.push_back(Entry{v8::Global(isolate_, value), 1, id}); + id_to_hash_[id] = hash; + // `chain` must not be used below: the drain can erase from it. + DrainPendingFreeLocked(); + return id; +} + +void LabelInternTable::Release(uint32_t id) { + if (id == kNoLabelId) return; + base::MutexGuard guard(&mutex_); + auto map_it = id_to_hash_.find(id); + if (map_it == id_to_hash_.end()) return; + + auto bucket_it = buckets_.find(map_it->second); + DCHECK(bucket_it != buckets_.end()); + for (Entry& entry : bucket_it->second) { + if (entry.id != id) continue; + DCHECK_GT(entry.refcount, 0u); + --entry.refcount; + // Queue only: Global::Reset() is main-thread only, and this runs on the + // sweeper thread. The entry stays in its bucket until drained, so a + // racing Intern can still revive it. + if (entry.refcount == 0) pending_free_.push_back(id); + return; + } + DCHECK(false); // id_to_hash_ pointed at a bucket with no such entry +} + +void LabelInternTable::Clear() { + // Emptying id_to_hash_ is what makes a later Release() of a stale id a + // no-op rather than a decrement of an unrelated entry. + base::MutexGuard guard(&mutex_); + DrainPendingFreeLocked(); + for (auto& bucket : buckets_) { + for (auto& entry : bucket.second) { + entry.global.Reset(); + } + } + buckets_.clear(); + id_to_hash_.clear(); +} + +v8::MaybeLocal LabelInternTable::Lookup(uint32_t id) { + if (id == kNoLabelId) return v8::MaybeLocal(); + base::MutexGuard guard(&mutex_); + // Drain first, unlike Intern: there is no revival path here, and every + // reference into buckets_ below is taken after the drain. + DrainPendingFreeLocked(); + auto map_it = id_to_hash_.find(id); + if (map_it == id_to_hash_.end()) return v8::MaybeLocal(); + + auto bucket_it = buckets_.find(map_it->second); + DCHECK(bucket_it != buckets_.end()); + for (Entry& entry : bucket_it->second) { + if (entry.id != id) continue; + if (entry.refcount == 0) return v8::MaybeLocal(); + return entry.global.Get(isolate_); + } + return v8::MaybeLocal(); +} + +size_t LabelInternTable::SizeForTesting() const { + base::MutexGuard guard(&mutex_); + size_t live = 0; + for (const auto& bucket : buckets_) { + for (const auto& entry : bucket.second) { + if (entry.refcount > 0) ++live; + } + } + return live; +} + +} // namespace internal +} // namespace v8 + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS diff --git a/deps/v8/src/profiler/label-intern-table.h b/deps/v8/src/profiler/label-intern-table.h new file mode 100644 index 000000000000..7f41fbf8cb8b --- /dev/null +++ b/deps/v8/src/profiler/label-intern-table.h @@ -0,0 +1,125 @@ +// Copyright 2026 the V8 project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 +// update. This whole file is part of the Node.js floating patch set; see the +// comment at the top of include/v8-profiler.h. +#ifndef V8_PROFILER_LABEL_INTERN_TABLE_H_ +#define V8_PROFILER_LABEL_INTERN_TABLE_H_ + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +#include +#include +#include + +#include "include/v8-local-handle.h" +#include "include/v8-persistent-handle.h" +#include "src/base/macros.h" +#include "src/base/platform/mutex.h" + +namespace v8 { + +class Isolate; +class Value; + +namespace internal { + +// Refcounted table mapping JS values to uint32_t ids, so a sample can hold a +// 4-byte id instead of a Global and its GlobalHandles::Node. Keyed on +// JSReceiver::GetOrCreateIdentityHash, which is stable across GC moves; +// collisions walk a per-bucket vector comparing object addresses. +// +// Ids are drawn from a per-table counter and are never reused, so an id minted +// by a stopped session resolves to empty rather than to an unrelated value. +// +// Release() runs on any thread, because V8's ArrayBufferSweeper frees backing +// stores off-thread, but Global::Reset() is main-thread only. Release() +// therefore only does refcount math and queues the id on pending_free_; +// Intern() and Lookup() drain the queue, and so does the destructor if neither +// is ever called again. Intern() drains after inserting, so that an entry +// revived by a racing Intern outlives the drain its own 1->0 transition +// queued; DrainPendingFreeLocked() skipping revived entries is what makes that +// safe. +class V8_EXPORT_PRIVATE LabelInternTable { + public: + // Reserved id meaning "no label". + static constexpr uint32_t kNoLabelId = 0; + + explicit LabelInternTable(v8::Isolate* isolate); + ~LabelInternTable(); + LabelInternTable(const LabelInternTable&) = delete; + LabelInternTable& operator=(const LabelInternTable&) = delete; + + // Returns an id for value, bumping the refcount if it is already interned. + // Non-receiver values return kNoLabelId. Main thread only. + uint32_t Intern(v8::Local value); + + // Decrements the refcount for id, queueing it for drain on the 1->0 + // transition. No-op for kNoLabelId or an id the table does not hold. + // Safe to call from any thread. + void Release(uint32_t id); + + // Empties the table so a stopped session stops pinning JS values. The table + // outlives the session: a later session interns into it again. Main thread + // only. + void Clear(); + + // Returns the interned value for id, or empty if it has been released. + // Main thread only. + v8::MaybeLocal Lookup(uint32_t id); + + // Counts entries with refcount > 0, excluding those pending free. + size_t SizeForTesting() const; + + // Masks every identity hash, so mask 0 forces all entries into one bucket + // and makes collision handling testable. Call before the first Intern(). + void SetHashMaskForTesting(uint32_t mask) { hash_mask_ = mask; } + + // Seeds the id counter so the wraparound path can be reached in a test + // without minting 2^32 ids. The next id issued is next + 1 (skipping + // kNoLabelId). Call before the Intern() under test. + void SetNextIdForTesting(uint32_t next) { next_id_ = next; } + + // The bound Intern() probes before failing closed on wraparound. + static constexpr uint32_t ProbeLimitForTesting() { return kIdProbeLimit; } + + private: + // Upper bound on the linear probe used to find a free id after the counter + // wraps. Live ids are a small fraction of the 2^32 space, so a free id is + // typically found in a few probes; the bound limits the search if no free + // id is present in that window. + static constexpr uint32_t kIdProbeLimit = 4096; + struct Entry { + v8::Global global; + uint32_t refcount; + uint32_t id; + }; + + // Frees each queued id that is still present and still at refcount 0, + // skipping any revived since queueing. Caller must hold mutex_. + void DrainPendingFreeLocked(); + + v8::Isolate* const isolate_; + // Guards every field below. Held across the body of each public method, so + // that a Release() from the ArrayBufferSweeper thread cannot corrupt the + // table. + mutable base::Mutex mutex_; + uint32_t hash_mask_ = 0xffffffff; + // kNoLabelId is reserved, so the first id issued is 1. Wraparound needs 4 + // billion interns in one isolate; Intern() then probes past kNoLabelId and + // any still-live id, and fails closed rather than aliasing a live id. + uint32_t next_id_ = 0; + std::unordered_map> buckets_; + // id -> hash, so Release() and Lookup() find their bucket in O(1). + std::unordered_map id_to_hash_; + std::vector pending_free_; +}; + +} // namespace internal +} // namespace v8 + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + +#endif // V8_PROFILER_LABEL_INTERN_TABLE_H_ diff --git a/deps/v8/src/profiler/sampling-heap-profiler.cc b/deps/v8/src/profiler/sampling-heap-profiler.cc index 228234c02258..ae3b738512e1 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.cc +++ b/deps/v8/src/profiler/sampling-heap-profiler.cc @@ -4,6 +4,7 @@ #include "src/profiler/sampling-heap-profiler.h" +#include #include #include @@ -16,8 +17,44 @@ #include "src/execution/isolate.h" #include "src/heap/heap-layout-inl.h" #include "src/heap/heap.h" +#include "src/profiler/heap-profiler.h" #include "src/profiler/strings-storage.h" +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +// label_id is appended after the pre-existing fields, so their offsets never +// change on any ABI. sizeof(Sample) is unchanged too wherever the base struct +// has tail padding to absorb the field; where it does not (i386 System V, see +// kLabelIdFitsBasePadding below) the struct grows and an addon must define the +// macro to match libnode. The asserts enforce this in code rather than only in +// prose (see doc/api/v8.md, "Native addon ABI"). +namespace { +constexpr size_t kSampleAlign = alignof(v8::AllocationProfile::Sample); +constexpr size_t kSampleBaseEnd = + offsetof(v8::AllocationProfile::Sample, is_live) + sizeof(bool); +constexpr size_t kSampleBaseSize = + (kSampleBaseEnd + kSampleAlign - 1) & ~(kSampleAlign - 1); +// On ABIs where uint64_t is 8-aligned (LP64, MSVC, ARM32 AAPCS, ...) the base +// struct carries enough tail padding to hold label_id without growing, so +// sizeof(Sample) is unchanged and an addon built without the macro strides +// GetSamples() correctly. On i386 System V, uint64_t is 4-aligned and only 3 +// tail bytes exist, so label_id grows the struct by 4; there the macro must be +// defined by such an addon. Only assert the no-growth invariant where the +// padding actually exists, so this does not break the i386 build. +constexpr bool kLabelIdFitsBasePadding = + (kSampleBaseSize - kSampleBaseEnd) >= sizeof(uint32_t); +static_assert( + !kLabelIdFitsBasePadding || + sizeof(v8::AllocationProfile::Sample) == kSampleBaseSize, + "where the base struct has tail padding, label_id must occupy it so " + "sizeof(Sample) is unchanged by V8_HEAP_PROFILER_SAMPLE_LABELS"); +// Always true regardless of ABI: label_id is appended after every pre-existing +// field, so none of their offsets shift. +static_assert(offsetof(v8::AllocationProfile::Sample, label_id) >= + kSampleBaseEnd, + "label_id must follow the pre-existing fields"); +} // namespace +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + namespace v8 { namespace internal { @@ -53,7 +90,12 @@ v8::AllocationProfile::Allocation SamplingHeapProfiler::ScaleSample( SamplingHeapProfiler::SamplingHeapProfiler( Heap* heap, StringsStorage* names, uint64_t rate, int stack_depth, - v8::HeapProfiler::SamplingFlags flags) + v8::HeapProfiler::SamplingFlags flags +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + LabelInternTable& label_intern_table +#endif + ) : isolate_(Isolate::FromHeap(heap)), heap_(heap), allocation_observer_(heap_, static_cast(rate), rate, this, @@ -63,7 +105,12 @@ SamplingHeapProfiler::SamplingHeapProfiler( next_node_id()), stack_depth_(stack_depth), rate_(rate), - flags_(flags) { + flags_(flags) +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_intern_table_(label_intern_table) +#endif +{ CHECK_GT(rate_, 0u); heap_->AddAllocationObserversToAllSpaces(&allocation_observer_, &allocation_observer_); @@ -72,6 +119,18 @@ SamplingHeapProfiler::SamplingHeapProfiler( SamplingHeapProfiler::~SamplingHeapProfiler() { heap_->RemoveAllocationObserversFromAllSpaces(&allocation_observer_, &allocation_observer_); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Retained samples (still live or kept by the include-collected flags) remain + // in samples_ at teardown. OnWeakCallback erases the samples it releases from + // samples_, so the destructor only visits unreleased samples. The guard skips + // samples that never carried a label. + for (auto& [ptr, sample] : samples_) { + if (sample->label_id != LabelInternTable::kNoLabelId) { + label_intern_table_.Release(sample->label_id); + sample->label_id = LabelInternTable::kNoLabelId; + } + } +#endif } void SamplingHeapProfiler::SampleObject(Address soon_object, size_t size) { @@ -95,8 +154,31 @@ void SamplingHeapProfiler::SampleObject(Address soon_object, size_t size) { AllocationNode* node = AddStack(); node->allocations_[size]++; + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // The CPED Map holds every ALS store, so intern only the value under our + // own key: that keeps the sample from pinning the whole map, and lets + // samples sharing an ALS value share one Global. + uint32_t label_id = LabelInternTable::kNoLabelId; + { + HeapProfiler* hp = isolate_->heap()->heap_profiler(); + if (!hp->sample_labels_als_key().IsEmpty()) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + v8::Local context = + v8_isolate->GetContinuationPreservedEmbedderDataV2().As(); + v8::Local als_value; + if (hp->LookupAlsValue(context).ToLocal(&als_value)) { + label_id = label_intern_table_.Intern(als_value); + } + } + } + auto sample = std::make_unique(size, node, loc, this, + next_sample_id(), label_id); +#else auto sample = std::make_unique(size, node, loc, this, next_sample_id()); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + sample->global.SetWeak(sample.get(), OnWeakCallback, WeakCallbackType::kParameter); samples_.emplace(sample.get(), std::move(sample)); @@ -116,8 +198,13 @@ void SamplingHeapProfiler::OnWeakCallback( v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC); if (should_keep_sample) { sample->global.Reset(); + // Keep label_id: the sample stays in samples_, so a later + // GetAllocationProfile can still attribute the collected object. return; } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + sample->profiler->label_intern_table_.Release(sample->label_id); +#endif AllocationNode* node = sample->owner; DCHECK_GT(node->allocations_[sample->size], 0); node->allocations_[sample->size]--; @@ -313,9 +400,15 @@ SamplingHeapProfiler::BuildSamples() const { for (const auto& it : samples_) { const Sample* sample = it.second.get(); const bool is_live = !sample->global.IsEmpty(); - samples.emplace_back(v8::AllocationProfile::Sample{ - sample->owner->id_, sample->size, ScaleSample(sample->size, 1).count, - sample->sample_id, is_live}); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + samples.push_back({sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, sample->sample_id, + is_live, sample->label_id}); +#else + samples.push_back({sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, sample->sample_id, + is_live}); +#endif } return samples; } diff --git a/deps/v8/src/profiler/sampling-heap-profiler.h b/deps/v8/src/profiler/sampling-heap-profiler.h index 6a1010b99931..6371828287cf 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.h +++ b/deps/v8/src/profiler/sampling-heap-profiler.h @@ -12,6 +12,9 @@ #include "include/v8-profiler.h" #include "src/heap/heap.h" +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +#include "src/profiler/label-intern-table.h" +#endif #include "src/profiler/strings-storage.h" namespace v8 { @@ -101,12 +104,23 @@ class SamplingHeapProfiler { struct Sample { Sample(size_t size_, AllocationNode* owner_, Local local_, - SamplingHeapProfiler* profiler_, uint64_t sample_id) + SamplingHeapProfiler* profiler_, uint64_t sample_id +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + uint32_t label_id_ = 0 +#endif + ) : size(size_), owner(owner_), global(reinterpret_cast(profiler_->isolate_), local_), profiler(profiler_), - sample_id(sample_id) {} + sample_id(sample_id) +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + label_id(label_id_) +#endif + { + } Sample(const Sample&) = delete; Sample& operator=(const Sample&) = delete; const size_t size; @@ -114,10 +128,20 @@ class SamplingHeapProfiler { Global global; SamplingHeapProfiler* const profiler; const uint64_t sample_id; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Released in OnWeakCallback, or in ~SamplingHeapProfiler for samples + // retained by the kSamplingIncludeObjectsCollectedBy*GC flags. + uint32_t label_id; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; SamplingHeapProfiler(Heap* heap, StringsStorage* names, uint64_t rate, - int stack_depth, v8::HeapProfiler::SamplingFlags flags); + int stack_depth, v8::HeapProfiler::SamplingFlags flags +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + , + LabelInternTable& label_intern_table +#endif + ); ~SamplingHeapProfiler(); SamplingHeapProfiler(const SamplingHeapProfiler&) = delete; SamplingHeapProfiler& operator=(const SamplingHeapProfiler&) = delete; @@ -194,6 +218,12 @@ class SamplingHeapProfiler { const int stack_depth_; const uint64_t rate_; v8::HeapProfiler::SamplingFlags flags_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // NODE-LOCAL PATCH: heap profile sample labels feature, do not remove on V8 + // update. Owned by HeapProfiler, which outlives this object, so that a + // background ReleaseLabelValue() need not read sampling_heap_profiler_. + LabelInternTable& label_intern_table_; +#endif }; } // namespace internal diff --git a/deps/v8/test/cctest/test-heap-profiler.cc b/deps/v8/test/cctest/test-heap-profiler.cc index 427ad5ce91c0..12dbfcc83c84 100644 --- a/deps/v8/test/cctest/test-heap-profiler.cc +++ b/deps/v8/test/cctest/test-heap-profiler.cc @@ -33,6 +33,7 @@ #include #include +#include "include/v8-container.h" #include "include/v8-function.h" #include "include/v8-json.h" #include "include/v8-profiler.h" @@ -4920,3 +4921,309 @@ TEST(HeapSnapshotWithWasmInstance) { #endif // V8_ENABLE_SANDBOX } #endif // V8_ENABLE_WEBASSEMBLY + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +// --- Tests for Sample::label_id and ResolveLabelValue --- + +// Sets up the ALS key on the heap profiler and stores a flat label array +// [key, val, ...] as the ALS value in a CPED Map. Both are required for +// SampleObject to intern the value at allocation time. +static void SetupAlsContext(v8::Isolate* isolate, v8::Local ctx, + v8::HeapProfiler* hp, + v8::Local als_value) { + v8::Local als_key = + v8::String::NewFromUtf8Literal(isolate, "node-heap-profiler"); + hp->SetHeapProfileSampleLabelsKey(als_key); + v8::Local cped_map = v8::Map::New(isolate); + cped_map->Set(ctx, als_key, als_value).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped_map); +} + +// Returns a flat V8 Array ["route", route_val] as the ALS label value. +static v8::Local MakeLabelArray(v8::Isolate* isolate, + v8::Local ctx, + const char* route_val) { + v8::Local arr = v8::Array::New(isolate, 2); + arr->Set(ctx, 0, v8::String::NewFromUtf8Literal(isolate, "route")).Check(); + arr->Set(ctx, 1, v8::String::NewFromUtf8(isolate, route_val).ToLocalChecked()) + .Check(); + return arr; +} + +TEST(SamplingHeapProfilerLabelsCallback) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // Set up ALS key + CPED Map so SampleObject interns the value. + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/test"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate enough objects to get samples. + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Verify at least one sample has a non-zero label_id that resolves to the + // expected flat array. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + v8::HandleScope hs(isolate); + v8::Local resolved; + CHECK(heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)); + CHECK(resolved->IsArray()); + v8::Local arr = resolved.As(); + CHECK_GE(arr->Length(), 2u); + v8::String::Utf8Value key( + isolate, arr->Get(env.local(), 0).ToLocalChecked()); + v8::String::Utf8Value val( + isolate, arr->Get(env.local(), 1).ToLocalChecked()); + CHECK_EQ(std::string(*key), "route"); + CHECK_EQ(std::string(*val), "/api/test"); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerNoAlsKeySet) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + // No ALS key set — internment gate is closed — label_id must be 0. + heap_profiler->StartSamplingHeapProfiler(256); + + for (int i = 0; i < 8 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + for (const auto& sample : profile->GetSamples()) { + CHECK_EQ(sample.label_id, 0u); + } + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerMultipleLabels) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + v8::Local als_key = + v8::String::NewFromUtf8Literal(isolate, "node-heap-profiler"); + heap_profiler->SetHeapProfileSampleLabelsKey(als_key); + heap_profiler->StartSamplingHeapProfiler(256); + + // Phase 1: allocate under label "/api/first". + v8::Local arr1 = MakeLabelArray(isolate, env.local(), "/api/first"); + { + v8::Local cped = v8::Map::New(isolate); + cped->Set(env.local(), als_key, arr1).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped); + } + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Phase 2: allocate under label "/api/second" (different array object). + v8::Local arr2 = + MakeLabelArray(isolate, env.local(), "/api/second"); + { + v8::Local cped = v8::Map::New(isolate); + cped->Set(env.local(), als_key, arr2).ToLocalChecked(); + isolate->SetContinuationPreservedEmbedderDataV2(cped); + } + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + bool found_first = false; + bool found_second = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + v8::HandleScope hs(isolate); + v8::Local resolved; + if (!heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)) + continue; + if (!resolved->IsArray()) continue; + v8::Local arr = resolved.As(); + if (arr->Length() < 2) continue; + v8::String::Utf8Value val( + isolate, arr->Get(env.local(), 1).ToLocalChecked()); + if (std::string(*val) == "/api/first") found_first = true; + if (std::string(*val) == "/api/second") found_second = true; + } + } + CHECK(found_first); + CHECK(found_second); + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerLabelsWithGCRetain) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/gc-test"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + // Start with GC retain flags — GC'd samples should survive. + heap_profiler->StartSamplingHeapProfiler( + 256, 128, + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC); + + // Allocate short-lived objects (no reference retained). + CompileRun( + "for (var i = 0; i < 4096; i++) {" + " new Array(64);" + "}"); + + // Force GC to collect the short-lived objects. + i::heap::InvokeMajorGC(CcTest::heap()); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Retained samples must still have their label_id resolvable. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + v8::HandleScope hs(isolate); + v8::Local resolved; + CHECK(heap_profiler->ResolveLabelValue(sample.label_id) + .ToLocal(&resolved)); + CHECK(resolved->IsArray()); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerLabelsRemovedByGC) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/gc-remove"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + // Start WITHOUT GC retain flags — GC'd samples should be removed. + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate short-lived objects (no reference retained). + CompileRun( + "for (var i = 0; i < 4096; i++) {" + " new Array(64);" + "}"); + + // Count labelled samples before GC — with suppress_randomness every + // sufficiently-large object is sampled, so there should be many. + std::unique_ptr pre_gc( + heap_profiler->GetAllocationProfile()); + CHECK(pre_gc); + size_t labeled_before = 0; + for (const auto& s : pre_gc->GetSamples()) { + if (s.label_id != 0) labeled_before++; + } + CHECK_GT(labeled_before, 0u); + + // Force GC to collect the short-lived objects. + i::heap::InvokeMajorGC(CcTest::heap()); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Without GC retain flags, samples for collected objects are removed. + // The labelled count must be strictly less than before the GC. + size_t labeled_count = 0; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + labeled_count++; + } + } + CHECK_LT(labeled_count, labeled_before); + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerClearAlsKeyStopsLabels) { + v8::HandleScope scope(CcTest::isolate()); + LocalContext env; + v8::Isolate* isolate = env->GetIsolate(); + v8::HeapProfiler* heap_profiler = isolate->GetHeapProfiler(); + + i::v8_flags.sampling_heap_profiler_suppress_randomness = true; + + v8::Local label_arr = + MakeLabelArray(isolate, env.local(), "/api/before-clear"); + SetupAlsContext(isolate, env.local(), heap_profiler, label_arr); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with ALS key set — label_id will be non-zero. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Clear ALS key — gate closes, new samples get label_id == 0. + heap_profiler->SetHeapProfileSampleLabelsKey(v8::Local()); + + // Allocate more — no internment since gate is closed. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Must have both labeled and unlabeled samples. + bool found_labeled = false; + bool found_unlabeled = false; + for (const auto& sample : profile->GetSamples()) { + if (sample.label_id != 0) { + found_labeled = true; + } else { + found_unlabeled = true; + } + } + CHECK(found_labeled); + CHECK(found_unlabeled); + + heap_profiler->StopSamplingHeapProfiler(); +} + +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS diff --git a/doc/api/v8.md b/doc/api/v8.md index 371d5348f65b..7fe679eb86ba 100644 --- a/doc/api/v8.md +++ b/doc/api/v8.md @@ -1446,6 +1446,90 @@ added: Returns true if the Node.js instance is run to build a snapshot. +## Heap profile labels + + + +> Stability: 1 - Experimental + +Attach string labels to V8 sampling heap profiler allocation samples. +Combined with [`AsyncLocalStorage`][], labels propagate through `await` +boundaries for per-context memory attribution (e.g., per-HTTP-route). + +### `v8.withHeapProfileLabels(labels, fn)` + + + +* `labels` {Object} Key-value string pairs (e.g., `{ route: '/users/:id' }`). +* `fn` {Function} May be `async`. +* Returns: {\*} Return value of `fn`. + +Runs `fn` with the given labels active. If `fn` returns a promise, labels +remain active until the promise settles. + +```mjs +const handle = v8.startHeapProfile({ sampleInterval: 64, labels: true }); + +await v8.withHeapProfileLabels({ route: '/users' }, async () => { + const data = await fetchUsers(); + return processData(data); +}); + +const profile = handle.getAllocationProfile(); +handle.stop(); +``` + +### `v8.setHeapProfileLabels(labels)` + + + +* `labels` {Object} Key-value string pairs. + +Sets labels for the current async scope using `enterWith` semantics. +Useful for frameworks where the handler runs after the extension returns. + +Prefer [`v8.withHeapProfileLabels()`][] when possible for automatic cleanup. + +### Limitations — what is measured + +Heap samples cover V8 heap allocations (JS objects, strings, closures). +`externalBytes` covers `Buffer`/`ArrayBuffer` backing stores. + +Not measured: native addon memory, JIT code space, OS-level allocations. + +**Native addon ABI.** The `v8::AllocationProfile::Sample` struct in +`v8-profiler.h` includes the `label_id` field only when +`V8_HEAP_PROFILER_SAMPLE_LABELS` is defined at compile time. Node.js sets +this macro for its own builds and for addons compiled through node-gyp +(via `common.gypi`). Addons built with other build systems (cmake, meson, +Makefile) must define `-DV8_HEAP_PROFILER_SAMPLE_LABELS` themselves to match +libnode. The `label_id` field is appended after the pre-existing fields, so +their offsets never change. On the common 64-bit ABIs (and on 32-bit targets +whose ABI 8-aligns `uint64_t`, such as Windows and ARM), it lands in existing +tail padding, `sizeof(AllocationProfile::Sample)` is unchanged, and iterating +`GetSamples()` strides correctly whether or not the macro is defined; an addon +only needs the macro to name `label_id`. On i386 System V (32-bit x86 on +Linux and the BSDs), `uint64_t` is 4-aligned and the field grows the struct by +4 bytes, so an addon there must define the macro to stride `GetSamples()` +correctly. A `static_assert` in V8 enforces the no-growth invariant on the +ABIs that provide the padding. When the macro is not defined, the +label APIs (`withHeapProfileLabels()`, `setHeapProfileLabels()`) are no-ops +and `getAllocationProfile()` omits `samples[].labels` and `externalBytes` +entirely. + +**Label availability.** Labels depend on async-context-frame, which propagates +the `AsyncLocalStorage` map through continuation callbacks via +`ContinuationPreservedEmbedderData`. The option is on by default; passing +`--no-async-context-frame` disables it, leaving every sample's `labels` object +empty. A one-time `process.emitWarning` fires at first label use when the +option is off. + ## Class: `v8.GCProfiler` + +* Returns: {Object | undefined} + +Returns the current allocation profile without stopping the profiler, or +`undefined` if the handle has already been stopped or if its session was +superseded by a newer one started on the same binding (for example when +an inspector `HeapProfiler.stopSampling` call ended the handle's V8 +session out of band and a new session was subsequently started). In the +superseded case the handle carries a stale session identity and will never +return a profile; call `stop()` on the new handle instead. The method is +always available regardless of the `labels` option. For sessions started with +`labels: true`, each sample's `labels` object is populated with the +active label context at allocation time and `externalBytes` is included +when labelled backing stores are live. For sessions started without +`labels: true`, each sample carries an empty `labels` object and +`externalBytes` is omitted. + +```json +{ + "samples": [ + { "nodeId": 1, "size": 128, "count": 4, "sampleId": 42, + "labels": { "route": "/users/:id" } } + ], + "externalBytes": [ + { "labels": { "route": "/users/:id" }, "bytes": 1048576 } + ] +} +``` + +* `samples[].labels` — key-value string pairs from the active label context + at allocation time. Empty object if no labels were active. The object is + **frozen** and **shared** across all samples captured under the same + active label context — mutating it throws in strict mode. +* `externalBytes[]` — live `Buffer`/`ArrayBuffer` backing-store bytes per + label context. Omitted when the profiling allocator is inactive, when no + labelled backing stores are live, or when all live stores were allocated + under an empty label set. + +**Label memory model.** The V8 heap profiler retains one copy of each +distinct label set for the profiler's lifetime. In the default mode +(`includeObjectsCollectedByMajorGC: false`) samples are dropped when their +objects are GC'd. With `includeObjectsCollectedByMajorGC: true` or +`includeObjectsCollectedByMinorGC: true`, dead samples are kept and each +unique label set pins one internal array for the profiler's lifetime — +label cardinality should be bounded to avoid unbounded growth. + ### `syncHeapProfileHandle.stop()`