diff --git a/benchmark/http/heap-profiler-labels.js b/benchmark/http/heap-profiler-labels.js new file mode 100644 index 00000000000000..2da729ff8d13b6 --- /dev/null +++ b/benchmark/http/heap-profiler-labels.js @@ -0,0 +1,88 @@ +'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 realistic JSON payload template (API response shape). +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; // 512KB — V8 default, production-realistic. + + if (mode !== 'none') { + v8.startSamplingHeapProfiler(interval); + } + + const server = http.createServer((req, res) => { + const handler = () => { + // Realistic mixed workload: + // 1. ~100KB V8 heap: JSON parse + stringify (simulates API response building) + const parsed = JSON.parse(payloadTemplate); + parsed.requestId = Math.random(); + const body = JSON.stringify(parsed); + + // 2. ~50KB Buffer (simulates response buffering / crypto / compression) + 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 (mode !== 'none') { + v8.stopSamplingHeapProfiler(); + } + server.close(); + }); + }); +} diff --git a/benchmark/http/heap-profiler-realistic.js b/benchmark/http/heap-profiler-realistic.js new file mode 100644 index 00000000000000..9092ca46c336cb --- /dev/null +++ b/benchmark/http/heap-profiler-realistic.js @@ -0,0 +1,150 @@ +'use strict'; + +// Benchmark: realistic app-server + 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 realistic DB response (V8 heap allocation) +// - Two iteration passes over 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 — simulates light business logic (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. + const dbResponses = {}; + for (const n of [100, 1000]) { + 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; + if (mode !== 'none') { + v8.startSamplingHeapProfiler(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 (mode !== 'none') { + v8.stopSamplingHeapProfiler(); + } + 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 00000000000000..1a713c82f1458a --- /dev/null +++ b/benchmark/v8/heap-profiler-labels-resolution.js @@ -0,0 +1,82 @@ +'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 exposes how resolution +// cost scales with sample count vs. unique label sets — this is the +// signal a per-call resolution cache (see PR #62649) should improve. +// +// 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; + + v8.startSamplingHeapProfiler(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 = v8.getAllocationProfile(); + // Defensive use of the result so the call is not eliminated. + if (!profile) throw new Error('profile missing'); + } + bench.end(n); + + v8.stopSamplingHeapProfiler(); + // 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 00000000000000..e715bfe0c007b5 --- /dev/null +++ b/benchmark/v8/heap-profiler-labels.js @@ -0,0 +1,59 @@ +'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; // 512KB — V8 default, production-realistic. + + if (mode === 'sampling') { + v8.startSamplingHeapProfiler(interval); + } else if (mode === 'sampling-with-labels') { + v8.startSamplingHeapProfiler(interval); + } + + if (mode === 'sampling-with-labels') { + v8.withHeapProfileLabels({ route: '/bench' }, () => { + runWorkload(n); + }); + } else { + runWorkload(n); + } + + if (mode !== 'none') { + v8.stopSamplingHeapProfiler(); + } +} + +function runWorkload(n) { + const arr = []; + bench.start(); + for (let i = 0; i < n; i++) { + // Allocate objects with string properties — representative of JSON API + // workloads. Each object is ~100-200 bytes on the V8 heap. + arr.push({ id: i, name: `item-${i}`, value: Math.random() }); + // Prevent unbounded growth — keep last 1000 to maintain GC pressure + // without running out of memory. + if (arr.length > 1000) arr.shift(); + } + bench.end(n); +} diff --git a/deps/v8/include/v8-profiler.h b/deps/v8/include/v8-profiler.h index 61f427ea47c691..7b3d78434abb13 100644 --- a/deps/v8/include/v8-profiler.h +++ b/deps/v8/include/v8-profiler.h @@ -11,6 +11,11 @@ #include #include +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +#include +#include +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + #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) @@ -791,26 +796,42 @@ class V8_EXPORT AllocationProfile { * Represent a single sample recorded for an allocation. */ struct Sample { - /** - * id of the node in the profile tree. - */ +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + Sample(uint32_t node_id, size_t size, unsigned int count, + uint64_t sample_id, + std::vector> labels = {}) + : node_id(node_id), + size(size), + count(count), + sample_id(sample_id), + labels(std::move(labels)) {} +#else + Sample(uint32_t node_id, size_t size, unsigned int count, + uint64_t sample_id) + : node_id(node_id), size(size), count(count), sample_id(sample_id) {} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + + /** id of the node in the profile tree. */ uint32_t node_id; - - /** - * Size of the sampled allocation object. - */ + /** Size of the sampled allocation object. */ size_t size; - - /** - * The number of objects of such size that were sampled. - */ + /** The number of objects of such size that were sampled. */ unsigned int count; - /** * Unique time-ordered id of the allocation sample. Can be used to track * what samples were added or removed between two snapshots. */ uint64_t sample_id; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Embedder-provided labels resolved by the HeapProfileSampleLabelsCallback + * during GetAllocationProfile(). The ALS value is captured at allocation + * time; labels are parsed from it when the profile is read. Each pair is + * (key, value). Empty if no callback is registered or the callback + * returned no labels for this sample. + */ + std::vector> labels; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; /** @@ -1001,6 +1022,31 @@ class V8_EXPORT HeapProfiler { v8::Isolate* isolate, const v8::Local& v8_value, uint16_t class_id, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Callback invoked during GetAllocationProfile() to resolve labels from + * the ALS value captured at allocation time. + * + * |data| is the opaque pointer passed to SetHeapProfileSampleLabelsCallback. + * |context| is the ALS value (e.g. a flat [key, val, ...] array) that was + * extracted from the ContinuationPreservedEmbedderData (CPED) Map at + * allocation time using the ALS key set via SetHeapProfileSampleLabelsKey. + * The extraction uses OrderedHashMap::FindEntry (zero-allocation, GC-safe). + * The callback parses this value directly to produce labels — no Map lookup + * is needed at callback time. + * + * Only invoked for samples that had an ALS value at allocation time (i.e. + * the ALS key was found in the CPED Map). + * + * Write labels to |out_labels| and return true, or return false if no + * labels apply. The caller provides a stack-local vector; returning false + * avoids any heap allocation on the hot path. + */ + using HeapProfileSampleLabelsCallback = bool (*)( + void* data, v8::Local context, + std::vector>* out_labels); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + /** Returns the number of snapshots taken. */ int GetSnapshotCount(); @@ -1261,6 +1307,41 @@ class V8_EXPORT HeapProfiler { void SetGetDetachednessCallback(GetDetachednessCallback callback, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Registers a callback invoked during GetAllocationProfile() to resolve + * embedder-defined labels from the ALS value extracted at allocation time. + * The labels are stored on AllocationProfile::Sample::labels. + * + * Pass nullptr to clear the callback. + */ + void SetHeapProfileSampleLabelsCallback( + HeapProfileSampleLabelsCallback callback, void* data = nullptr); + + /** + * Sets the AsyncLocalStorage (ALS) key used to extract the label value + * from the ContinuationPreservedEmbedderData (CPED) Map at allocation + * time. When a heap sample is taken, SampleObject uses this key with + * OrderedHashMap::FindEntry to look up the corresponding ALS value and + * store it on the sample. The stored value is later passed to the + * HeapProfileSampleLabelsCallback as the |context| parameter. + * + * Must be called before starting the sampling profiler. + * Pass an empty handle to clear. + */ + void SetHeapProfileSampleLabelsKey(Local key); + + /** + * Extracts the ALS value from a CPED (ContinuationPreservedEmbedderData) + * Map using the stored ALS key (set via SetHeapProfileSampleLabelsKey). + * + * Uses OrderedHashMap::FindEntry internally — zero-allocation, GC-safe. + * Returns an empty MaybeLocal if the CPED is not a Map, no ALS key is set, + * or the key is not found in the Map. + */ + MaybeLocal LookupAlsValue(Local cped); +#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 18d762c6443073..515eda8b139a18 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -11829,6 +11829,23 @@ void HeapProfiler::SetGetDetachednessCallback(GetDetachednessCallback callback, data); } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +void HeapProfiler::SetHeapProfileSampleLabelsCallback( + HeapProfileSampleLabelsCallback callback, void* data) { + reinterpret_cast(this) + ->SetHeapProfileSampleLabelsCallback(callback, data); +} + +void HeapProfiler::SetHeapProfileSampleLabelsKey(Local key) { + reinterpret_cast(this) + ->SetHeapProfileSampleLabelsKey(key); +} + +MaybeLocal HeapProfiler::LookupAlsValue(Local cped) { + return reinterpret_cast(this)->LookupAlsValue(cped); +} +#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 c123645e8a4d41..5351da5f608d9e 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" @@ -405,4 +407,27 @@ void HeapProfiler::QueryObjects(DirectHandle context, }); } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +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 82d4db266e7d96..554a6e57f1b871 100644 --- a/deps/v8/src/profiler/heap-profiler.h +++ b/deps/v8/src/profiler/heap-profiler.h @@ -79,6 +79,38 @@ class HeapProfiler : public HeapObjectAllocationTracker { bool is_sampling_allocations() { return !!sampling_heap_profiler_; } AllocationProfile* GetAllocationProfile(); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + void SetHeapProfileSampleLabelsCallback( + v8::HeapProfiler::HeapProfileSampleLabelsCallback callback, + void* data) { + sample_labels_callback_ = callback; + sample_labels_data_ = data; + } + + void SetHeapProfileSampleLabelsKey(v8::Local key) { + if (key.IsEmpty()) { + sample_labels_als_key_.Reset(); + } else { + sample_labels_als_key_.Reset( + reinterpret_cast(isolate()), key); + } + } + + v8::HeapProfiler::HeapProfileSampleLabelsCallback + sample_labels_callback() const { + return sample_labels_callback_; + } + void* sample_labels_data() const { return sample_labels_data_; } + + const v8::Global& sample_labels_als_key() const { + return sample_labels_als_key_; + } + + // Extracts the ALS value from a CPED Map using the stored ALS key. + // Uses OrderedHashMap::FindEntry — zero-allocation, GC-safe. + v8::MaybeLocal LookupAlsValue(v8::Local cped); +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + void StartHeapObjectsTracking(bool track_allocations); void StopHeapObjectsTracking(); AllocationTracker* allocation_tracker() const { @@ -176,6 +208,18 @@ class HeapProfiler : public HeapObjectAllocationTracker { std::pair get_detachedness_callback_; std::unique_ptr native_move_listener_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // All three fields are written only from the main thread via + // SetHeapProfileSampleLabelsCallback() / SetHeapProfileSampleLabelsKey() + // and read during GetAllocationProfile() or SampleObject() (also main + // thread). No cross-thread access, so no synchronization is required. + v8::HeapProfiler::HeapProfileSampleLabelsCallback sample_labels_callback_ = + nullptr; + void* sample_labels_data_ = nullptr; + // The ALS key used at allocation time to extract the label value from + // the CPED Map via OrderedHashMap::FindEntry. + v8::Global sample_labels_als_key_; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; } // namespace internal diff --git a/deps/v8/src/profiler/sampling-heap-profiler.cc b/deps/v8/src/profiler/sampling-heap-profiler.cc index 8133dec033b9fb..79476b5925e85b 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.cc +++ b/deps/v8/src/profiler/sampling-heap-profiler.cc @@ -15,6 +15,9 @@ #include "src/execution/isolate.h" #include "src/heap/heap-layout-inl.h" #include "src/heap/heap.h" +#include "src/objects/js-collection-inl.h" +#include "src/objects/ordered-hash-table.h" +#include "src/profiler/heap-profiler.h" #include "src/profiler/strings-storage.h" namespace v8 { @@ -96,6 +99,47 @@ void SamplingHeapProfiler::SampleObject(Address soon_object, size_t size) { node->allocations_[size]++; auto sample = std::make_unique(size, node, loc, this, next_sample_id()); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Extract the ALS value from the CPED Map at allocation time. + // The CPED (ContinuationPreservedEmbedderData) is a JSMap containing + // ALL ALS stores. We look up only our ALS key via + // OrderedHashMap::FindEntry (zero-allocation, GC-safe) and store just + // the resulting value (the flat label array). This avoids retaining + // the entire CPED for the lifetime of each sample. + // Global::Reset is safe inside DisallowGC (uses malloc, not V8 heap). + { + HeapProfiler* hp = isolate_->heap()->heap_profiler(); + if (hp->sample_labels_callback()) { + const v8::Global& als_key_global = + hp->sample_labels_als_key(); + if (!als_key_global.IsEmpty()) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + v8::Local context = + v8_isolate->GetContinuationPreservedEmbedderData(); + if (!context.IsEmpty() && !context->IsUndefined()) { + Tagged cped_obj = *Utils::OpenDirectHandle(*context); + if (IsJSMap(cped_obj)) { + Tagged js_map = Cast(cped_obj); + Tagged table = + Cast(js_map->table()); + v8::Local als_key_local = + als_key_global.Get(v8_isolate); + Tagged key_obj = *Utils::OpenDirectHandle(*als_key_local); + InternalIndex entry = table->FindEntry(isolate_, key_obj); + if (entry.is_found()) { + Tagged value = table->ValueAt(entry); + v8::Local value_local = + Utils::ToLocal(direct_handle(value, isolate_)); + sample->label_value.Reset(v8_isolate, value_local); + } + } + } + } + } + } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + sample->global.SetWeak(sample.get(), OnWeakCallback, WeakCallbackType::kParameter); samples_.emplace(sample.get(), std::move(sample)); @@ -299,22 +343,123 @@ v8::AllocationProfile* SamplingHeapProfiler::GetAllocationProfile() { scripts[script->id()] = handle(script, isolate_); } } + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // Pre-resolve labels while GC is still allowed. + // The labels callback may allocate on the V8 heap. These MUST NOT run + // during BuildSamples() iteration — GC would fire weak callbacks that + // erase from samples_, causing iterator invalidation / UAF. + // + // Two-phase approach: + // Phase 1: snapshot sample IDs and ALS values (Global::Get + Global + // ctor use malloc, not V8 heap — no GC risk during iteration). + // Phase 2: invoke the callback for each snapshot entry. GC may fire + // here but we iterate our snapshot vector, not samples_. + ResolvedLabelsMap resolved_labels; + { + HeapProfiler* hp = heap_->heap_profiler(); + auto callback = hp->sample_labels_callback(); + void* callback_data = hp->sample_labels_data(); + if (callback) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + + // Phase 1: snapshot ALS values keyed by sample_id. + // Global ctor (GlobalizeReference) and Global::Get use global handle + // storage (malloc), not the V8 heap — safe under DisallowGarbageCollection. + // Locals from Get() are immediately consumed by Global ctor, so a single + // HandleScope outside the loop suffices (no per-iteration scope churn). + struct LabelValueSnapshot { + uint64_t sample_id; + v8::Global label_value; + }; + std::vector snapshot; + snapshot.reserve(samples_.size()); + { + HandleScope handle_scope(isolate_); + DisallowGarbageCollection no_gc; + for (const auto& [ptr, sample] : samples_) { + if (!sample->label_value.IsEmpty()) { + snapshot.push_back( + {sample->sample_id, + v8::Global( + v8_isolate, sample->label_value.Get(v8_isolate))}); + } + } + } + + // Phase 2: resolve labels via callback (GC allowed). + // The callback receives the ALS value (flat label array) directly — + // the CPED Map lookup was already done at allocation time. + // + // Per-call cache: samples sharing the same ALS value (e.g. all + // allocations under one withHeapProfileLabels scope) resolve to the + // same labels, so we dedup callback invocations within this call. + // Key: raw object Address (cheap identity check). If GC moves the + // object between iterations we get a cache miss and re-call the + // callback — correctness preserved, just less optimal. Negative + // results (callback returned false or empty labels) are also cached + // so we don't re-call for ALS values that resolve to nothing. + std::unordered_map>> + label_cache; + for (auto& entry : snapshot) { + HandleScope scope(isolate_); + v8::Local value_local = entry.label_value.Get(v8_isolate); + Address key = (*Utils::OpenDirectHandle(*value_local)).ptr(); + auto [cache_it, inserted] = label_cache.try_emplace(key); + if (inserted) { + std::vector> labels; + if (callback(callback_data, value_local, &labels) && + !labels.empty()) { + cache_it->second = std::move(labels); + } + } + if (!cache_it->second.empty()) { + resolved_labels[entry.sample_id] = cache_it->second; + } + } + } + } +#endif + auto profile = new v8::internal::AllocationProfile(); TranslateAllocationNode(profile, &profile_root_, scripts); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + profile->samples_ = BuildSamples(std::move(resolved_labels)); +#else profile->samples_ = BuildSamples(); +#endif return profile; } +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS +const std::vector +SamplingHeapProfiler::BuildSamples(ResolvedLabelsMap resolved_labels) const { +#else const std::vector SamplingHeapProfiler::BuildSamples() const { +#endif std::vector samples; samples.reserve(samples_.size()); + for (const auto& it : samples_) { const Sample* sample = it.second.get(); - samples.emplace_back(v8::AllocationProfile::Sample{ - sample->owner->id_, sample->size, ScaleSample(sample->size, 1).count, - sample->sample_id}); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + std::vector> labels; + auto label_it = resolved_labels.find(sample->sample_id); + if (label_it != resolved_labels.end()) { + labels = std::move(label_it->second); + } + samples.emplace_back(sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, + sample->sample_id, std::move(labels)); +#else + samples.emplace_back(sample->owner->id_, sample->size, + ScaleSample(sample->size, 1).count, + sample->sample_id); +#endif } return samples; } diff --git a/deps/v8/src/profiler/sampling-heap-profiler.h b/deps/v8/src/profiler/sampling-heap-profiler.h index 6a1010b9993193..d54f2b9bd2a42c 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.h +++ b/deps/v8/src/profiler/sampling-heap-profiler.h @@ -114,6 +114,14 @@ class SamplingHeapProfiler { Global global; SamplingHeapProfiler* const profiler; const uint64_t sample_id; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + // ALS value extracted from the CPED Map at allocation time via + // OrderedHashMap::FindEntry. Storing only the ALS value (the flat + // label array) instead of the full CPED avoids retaining all ALS + // stores for the lifetime of the sample. Labels are resolved from + // this at read time (in GetAllocationProfile) via the callback. + Global label_value; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; SamplingHeapProfiler(Heap* heap, StringsStorage* names, uint64_t rate, @@ -160,7 +168,14 @@ class SamplingHeapProfiler { void SampleObject(Address soon_object, size_t size); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + using ResolvedLabelsMap = std::unordered_map< + uint64_t, std::vector>>; + const std::vector BuildSamples( + ResolvedLabelsMap resolved_labels) const; +#else const std::vector BuildSamples() const; +#endif AllocationNode* FindOrAddChildNode(AllocationNode* parent, const char* name, int script_id, int start_position); diff --git a/deps/v8/test/cctest/test-heap-profiler.cc b/deps/v8/test/cctest/test-heap-profiler.cc index 4dbb3fc7604344..7187993aaf5070 100644 --- a/deps/v8/test/cctest/test-heap-profiler.cc +++ b/deps/v8/test/cctest/test-heap-profiler.cc @@ -4854,3 +4854,355 @@ TEST(HeapSnapshotWithWasmInstance) { #endif // V8_ENABLE_SANDBOX } #endif // V8_ENABLE_WEBASSEMBLY + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + +// --- Tests for HeapProfileSampleLabelsCallback --- + +// Helper: a label callback that writes fixed labels via output parameter. +static bool FixedLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* labels = + static_cast>*>(data); + *out_labels = *labels; + return true; +} + +// Helper: a label callback that returns false (no labels). +static bool EmptyLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + return false; +} + +// Helper: a label callback that returns different labels based on CPED value. +struct MultiLabelState { + std::string first_cped; + std::vector> first; + std::string second_cped; + std::vector> second; +}; + +static bool MultiLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* state = static_cast(data); + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + v8::String::Utf8Value cped_str(isolate, context); + std::string cped(*cped_str, cped_str.length()); + if (cped == state->first_cped) { + *out_labels = state->first; + } else if (cped == state->second_cped) { + *out_labels = state->second; + } + return true; +} + +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; + + std::vector> labels = { + {"route", "/api/test"}}; + + // Set CPED so the callback gets invoked (non-empty context required). + { + v8::HandleScope inner(isolate); + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + } + + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + 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 the expected labels. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + CHECK_EQ(sample.labels.size(), 1); + CHECK_EQ(sample.labels[0].first, "route"); + CHECK_EQ(sample.labels[0].second, "/api/test"); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); + + // Clear callback. + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerNoLabelsCallback) { + 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 callback registered — samples should have empty labels. + 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(sample.labels.empty()); + } + + heap_profiler->StopSamplingHeapProfiler(); +} + +TEST(SamplingHeapProfilerEmptyLabelsCallback) { + 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 CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + // Callback returns empty vector — samples should have empty labels. + heap_profiler->SetHeapProfileSampleLabelsCallback(EmptyLabelsCallback, + nullptr); + + 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(sample.labels.empty()); + } + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +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; + + MultiLabelState state; + state.first_cped = "context-first"; + state.first = {{"route", "/api/first"}}; + state.second_cped = "context-second"; + state.second = {{"route", "/api/second"}}; + + heap_profiler->SetHeapProfileSampleLabelsCallback(MultiLabelsCallback, + &state); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Set first CPED and allocate. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "context-first")); + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Set second CPED and allocate. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "context-second")); + 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.labels.empty()) { + CHECK_EQ(sample.labels.size(), 1); + CHECK_EQ(sample.labels[0].first, "route"); + if (sample.labels[0].second == "/api/first") found_first = true; + if (sample.labels[0].second == "/api/second") found_second = true; + } + } + CHECK(found_first); + CHECK(found_second); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +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; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + std::vector> labels = { + {"route", "/api/gc-test"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + // 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); + + // With GC retain flags, samples for collected objects should still exist + // with their labels intact. + bool found_labeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + CHECK_EQ(sample.labels[0].first, "route"); + CHECK_EQ(sample.labels[0].second, "/api/gc-test"); + found_labeled = true; + } + } + CHECK(found_labeled); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +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; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + std::vector> labels = { + {"route", "/api/gc-remove"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + // 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);" + "}"); + + // 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, most/all short-lived samples should be gone. + // Count remaining labeled samples — should be significantly fewer than + // what was allocated (many were collected by GC). + size_t labeled_count = 0; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + labeled_count++; + } + } + // We can't assert zero because some objects may survive GC, but the count + // should be much smaller than the retained case. Just verify the profile + // is valid and doesn't crash. + CHECK(profile->GetRootNode()); + + heap_profiler->StopSamplingHeapProfiler(); + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); +} + +TEST(SamplingHeapProfilerUnregisterCallback) { + 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 CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + std::vector> labels = { + {"route", "/api/before-unregister"}}; + heap_profiler->SetHeapProfileSampleLabelsCallback(FixedLabelsCallback, + &labels); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with callback active. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Unregister callback (pass nullptr). + heap_profiler->SetHeapProfileSampleLabelsCallback(nullptr, nullptr); + + // Allocate more — these should have no labels. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + std::unique_ptr profile( + heap_profiler->GetAllocationProfile()); + CHECK(profile); + + // Should have some labeled samples (from before unregister) and some + // unlabeled (from after). Verify at least one labeled exists. + bool found_labeled = false; + bool found_unlabeled = false; + for (const auto& sample : profile->GetSamples()) { + if (!sample.labels.empty()) { + 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 7ee7a748674cae..d4bb576a85e295 100644 --- a/doc/api/v8.md +++ b/doc/api/v8.md @@ -1453,6 +1453,113 @@ 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.startSamplingHeapProfiler([sampleInterval[, stackDepth[, options]]])` + + + +* `sampleInterval` {number} Average interval in bytes. **Default:** `524288`. +* `stackDepth` {number} Maximum call stack depth. **Default:** `16`. +* `options` {Object} + * `includeCollectedObjects` {boolean} Retain samples for GC'd objects. + **Default:** `false`. + +Starts the V8 sampling heap profiler. + +### `v8.stopSamplingHeapProfiler()` + + + +Stops the sampling heap profiler and clears all registered label entries. + +### `v8.getAllocationProfile()` + + + +* Returns: {Object | undefined} + +Returns the current allocation profile, or `undefined` if the profiler is +not running. + +```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. +* `externalBytes[]` — live `Buffer`/`ArrayBuffer` backing-store bytes per + label context. Complements heap samples which only see the JS wrapper. + +### `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. + +```js +v8.startSamplingHeapProfiler(64); + +await v8.withHeapProfileLabels({ route: '/users' }, async () => { + const data = await fetchUsers(); + return processData(data); +}); + +const profile = v8.getAllocationProfile(); +v8.stopSamplingHeapProfiler(); +``` + +### `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. + ## Class: `v8.GCProfiler`