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.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..fbde2649945cfb 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,39 @@ class V8_EXPORT AllocationProfile { * Represent a single sample recorded for an allocation. */ struct Sample { - /** - * id of the node in the profile tree. - */ + 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) {} +#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)) {} +#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 captured at allocation time via the + * HeapProfileSampleLabelsCallback. Each pair is (key, value). + * Empty if no callback is registered or the callback returned no labels. + */ + std::vector> labels; +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS }; /** @@ -1001,6 +1019,24 @@ 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 sampling heap profiler allocation events to + * retrieve embedder-defined labels for the current execution context. + * + * |data| is the opaque pointer passed to SetHeapProfileSampleLabelsCallback. + * |context| is the ContinuationPreservedEmbedderData (CPED) value, which + * the embedder can use to look up the current async context (e.g., route). + * + * 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 +1297,18 @@ class V8_EXPORT HeapProfiler { void SetGetDetachednessCallback(GetDetachednessCallback callback, void* data); +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + /** + * Registers a callback that the sampling heap profiler invokes on each + * allocation to retrieve embedder-defined string labels. The labels are + * stored on AllocationProfile::Sample::labels. + * + * Pass nullptr to clear the callback. + */ + void SetHeapProfileSampleLabelsCallback( + HeapProfileSampleLabelsCallback callback, void* data = nullptr); +#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..72d770bb513438 100644 --- a/deps/v8/src/api/api.cc +++ b/deps/v8/src/api/api.cc @@ -11829,6 +11829,14 @@ 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); +} +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + bool HeapProfiler::IsTakingSnapshot() { return reinterpret_cast(this)->IsTakingSnapshot(); } diff --git a/deps/v8/src/profiler/heap-profiler.h b/deps/v8/src/profiler/heap-profiler.h index 82d4db266e7d96..18751b24f4bc2f 100644 --- a/deps/v8/src/profiler/heap-profiler.h +++ b/deps/v8/src/profiler/heap-profiler.h @@ -79,6 +79,21 @@ 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; + } + + v8::HeapProfiler::HeapProfileSampleLabelsCallback + sample_labels_callback() const { + return sample_labels_callback_; + } + void* sample_labels_data() const { return sample_labels_data_; } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + void StartHeapObjectsTracking(bool track_allocations); void StopHeapObjectsTracking(); AllocationTracker* allocation_tracker() const { @@ -176,6 +191,11 @@ class HeapProfiler : public HeapObjectAllocationTracker { std::pair get_detachedness_callback_; std::unique_ptr native_move_listener_; +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + v8::HeapProfiler::HeapProfileSampleLabelsCallback sample_labels_callback_ = + nullptr; + void* sample_labels_data_ = nullptr; +#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..639dd7e082de63 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.cc +++ b/deps/v8/src/profiler/sampling-heap-profiler.cc @@ -15,6 +15,7 @@ #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" namespace v8 { @@ -96,6 +97,26 @@ 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 + // If an embedder labels callback is registered, capture the CPED + // (ContinuationPreservedEmbedderData) for later label resolution in + // BuildSamples(). Storing as Global keeps the AsyncContextFrame alive + // and prevents it from being GC'd while the sample exists. + // Global::Reset is safe inside DisallowGC (uses malloc, not V8 heap). + { + HeapProfiler* hp = isolate_->heap()->heap_profiler(); + if (hp->sample_labels_callback()) { + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); + v8::Local context = + v8_isolate->GetContinuationPreservedEmbedderData(); + if (!context.IsEmpty() && !context->IsUndefined()) { + sample->cped.Reset(v8_isolate, context); + } + } + } +#endif // V8_HEAP_PROFILER_SAMPLE_LABELS + sample->global.SetWeak(sample.get(), OnWeakCallback, WeakCallbackType::kParameter); samples_.emplace(sample.get(), std::move(sample)); @@ -307,14 +328,34 @@ v8::AllocationProfile* SamplingHeapProfiler::GetAllocationProfile() { } const std::vector -SamplingHeapProfiler::BuildSamples() const { +SamplingHeapProfiler::BuildSamples() { std::vector samples; samples.reserve(samples_.size()); + +#ifdef V8_HEAP_PROFILER_SAMPLE_LABELS + HeapProfiler* hp = heap_->heap_profiler(); + auto callback = hp->sample_labels_callback(); + void* callback_data = hp->sample_labels_data(); + v8::Isolate* v8_isolate = reinterpret_cast(isolate_); +#endif + 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; + if (callback && !sample->cped.IsEmpty()) { + HandleScope scope(isolate_); + v8::Local cped_local = sample->cped.Get(v8_isolate); + callback(callback_data, cped_local, &labels); + } + 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..bd794c33872e40 100644 --- a/deps/v8/src/profiler/sampling-heap-profiler.h +++ b/deps/v8/src/profiler/sampling-heap-profiler.h @@ -114,6 +114,12 @@ class SamplingHeapProfiler { Global global; SamplingHeapProfiler* const profiler; const uint64_t sample_id; + std::vector> labels; + // ContinuationPreservedEmbedderData captured at allocation time. + // Stored as Global to prevent GC of the AsyncContextFrame while + // the sample exists. Labels are resolved from this at read time + // (in BuildSamples) via the registered labels callback. + Global cped; }; SamplingHeapProfiler(Heap* heap, StringsStorage* names, uint64_t rate, @@ -160,7 +166,7 @@ class SamplingHeapProfiler { void SampleObject(Address soon_object, size_t size); - const std::vector BuildSamples() const; + const std::vector BuildSamples(); 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..f634d66dccdfd3 100644 --- a/deps/v8/test/cctest/test-heap-profiler.cc +++ b/deps/v8/test/cctest/test-heap-profiler.cc @@ -4854,3 +4854,349 @@ 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 switches labels based on a flag. +struct MultiLabelState { + bool use_second; + std::vector> first; + std::vector> second; +}; + +static bool MultiLabelsCallback( + void* data, v8::Local context, + std::vector>* out_labels) { + auto* state = static_cast(data); + *out_labels = state->use_second ? state->second : state->first; + 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; + + // Set CPED so callback is invoked. + isolate->SetContinuationPreservedEmbedderData( + v8::String::NewFromUtf8Literal(isolate, "test-context")); + + MultiLabelState state; + state.use_second = false; + state.first = {{"route", "/api/first"}}; + state.second = {{"route", "/api/second"}}; + + heap_profiler->SetHeapProfileSampleLabelsCallback(MultiLabelsCallback, + &state); + + heap_profiler->StartSamplingHeapProfiler(256); + + // Allocate with first label set. + for (int i = 0; i < 4 * 1024; ++i) v8::Object::New(isolate); + + // Switch to second label set. + state.use_second = true; + + // Allocate with second label set. + 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`