Skip to content

Commit 21702bb

Browse files
committed
src: add heap profile labels and ProfilingAllocator
C++ bindings for V8 heap profile labels with ALS store lookup: - HeapProfileLabelsCallback reads the ALS store directly from the captured CPED (AsyncContextFrame Map) at profile-read time - Uses Map::AsArray() + linear scan for ALS key lookup, safe inside DisallowJavascriptExecution (ArrayBuffer allocator context) - ProfilingArrayBufferAllocator tracks per-label external memory (Buffer/ArrayBuffer) using the same CPED-based label resolution - SetHeapProfileLabelsStore receives the ALS key from JS at init time - GetAllocationProfile returns samples with labels and externalBytes - Cleanup hooks for environment teardown - Node.js cctests for label registration, callback, and cleanup Signed-off-by: Rudolf Meijering <[email protected]>
1 parent e8e5f80 commit 21702bb

6 files changed

Lines changed: 877 additions & 2 deletions

File tree

node.gyp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
'v8_trace_maps%': 0,
55
'v8_enable_pointer_compression%': 0,
66
'v8_enable_31bit_smis_on_64bit_arch%': 0,
7+
'v8_enable_continuation_preserved_embedder_data%': 1,
78
'force_dynamic_crt%': 0,
89
'node_builtin_modules_path%': '',
910
'node_core_target_name%': 'node',
@@ -937,6 +938,12 @@
937938
'msvs_disabled_warnings!': [4244],
938939

939940
'conditions': [
941+
[ 'v8_enable_continuation_preserved_embedder_data==1', {
942+
'defines': [
943+
# Enable heap profiler sample labels when CPED is available.
944+
'V8_HEAP_PROFILER_SAMPLE_LABELS',
945+
],
946+
}],
940947
[ 'openssl_default_cipher_list!=""', {
941948
'defines': [
942949
'NODE_OPENSSL_DEFAULT_CIPHER_LIST="<(openssl_default_cipher_list)"'
@@ -1322,6 +1329,11 @@
13221329
'sources': [ '<@(node_cctest_sources)' ],
13231330

13241331
'conditions': [
1332+
[ 'v8_enable_continuation_preserved_embedder_data==1', {
1333+
'defines': [
1334+
'V8_HEAP_PROFILER_SAMPLE_LABELS',
1335+
],
1336+
}],
13251337
[ 'node_shared_gtest=="false"', {
13261338
'dependencies': [
13271339
'deps/googletest/googletest.gyp:gtest',

src/api/environment.cc

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include "node_realm-inl.h"
1616
#include "node_shadow_realm.h"
1717
#include "node_snapshot_builder.h"
18+
#include "node_v8.h"
1819
#include "node_v8_platform-inl.h"
1920
#include "node_wasm_web_api.h"
2021
#include "uv.h"
@@ -191,11 +192,159 @@ void DebuggingArrayBufferAllocator::RegisterPointerInternal(void* data,
191192
allocations_[data] = size;
192193
}
193194

195+
void* ProfilingArrayBufferAllocator::Allocate(size_t size) {
196+
void* ret = NodeArrayBufferAllocator::Allocate(size);
197+
if (ret != nullptr && enabled_.load(std::memory_order_acquire)) {
198+
LabelPairs labels = FindCurrentLabels();
199+
if (!labels.empty()) {
200+
std::string key = SerializeLabels(labels);
201+
Mutex::ScopedLock lock(mutex_);
202+
allocations_[ret] = {key, size};
203+
auto& entry = per_label_bytes_[key];
204+
if (entry.labels.empty()) entry.labels = std::move(labels);
205+
entry.bytes += static_cast<int64_t>(size);
206+
}
207+
}
208+
return ret;
209+
}
210+
211+
void* ProfilingArrayBufferAllocator::AllocateUninitialized(size_t size) {
212+
void* ret = NodeArrayBufferAllocator::AllocateUninitialized(size);
213+
if (ret != nullptr && enabled_.load(std::memory_order_acquire)) {
214+
LabelPairs labels = FindCurrentLabels();
215+
if (!labels.empty()) {
216+
std::string key = SerializeLabels(labels);
217+
Mutex::ScopedLock lock(mutex_);
218+
allocations_[ret] = {key, size};
219+
auto& entry = per_label_bytes_[key];
220+
if (entry.labels.empty()) entry.labels = std::move(labels);
221+
entry.bytes += static_cast<int64_t>(size);
222+
}
223+
}
224+
return ret;
225+
}
226+
227+
void ProfilingArrayBufferAllocator::Free(void* data, size_t size) {
228+
if (enabled_.load(std::memory_order_acquire)) {
229+
Mutex::ScopedLock lock(mutex_);
230+
auto it = allocations_.find(data);
231+
if (it != allocations_.end()) {
232+
auto label_it = per_label_bytes_.find(it->second.first);
233+
if (label_it != per_label_bytes_.end()) {
234+
label_it->second.bytes -= static_cast<int64_t>(it->second.second);
235+
}
236+
allocations_.erase(it);
237+
}
238+
}
239+
NodeArrayBufferAllocator::Free(data, size);
240+
}
241+
242+
void ProfilingArrayBufferAllocator::Enable(
243+
v8::Isolate* isolate, v8::Global<v8::Value>* als_key) {
244+
Mutex::ScopedLock lock(mutex_);
245+
isolate_ = isolate;
246+
als_key_ = als_key;
247+
main_thread_id_ = std::this_thread::get_id();
248+
enabled_.store(true, std::memory_order_release);
249+
}
250+
251+
void ProfilingArrayBufferAllocator::Disable() {
252+
enabled_.store(false, std::memory_order_release);
253+
Mutex::ScopedLock lock(mutex_);
254+
allocations_.clear();
255+
per_label_bytes_.clear();
256+
isolate_ = nullptr;
257+
als_key_ = nullptr;
258+
}
259+
260+
std::vector<ProfilingArrayBufferAllocator::LabeledBytes>
261+
ProfilingArrayBufferAllocator::GetPerLabelBytes() const {
262+
Mutex::ScopedLock lock(mutex_);
263+
std::vector<LabeledBytes> result;
264+
for (const auto& [key, entry] : per_label_bytes_) {
265+
if (entry.bytes > 0) {
266+
result.push_back(entry);
267+
}
268+
}
269+
return result;
270+
}
271+
272+
std::string ProfilingArrayBufferAllocator::SerializeLabels(
273+
const LabelPairs& labels) {
274+
std::string key;
275+
for (const auto& [k, v] : labels) {
276+
if (!key.empty()) key += '\0';
277+
key += k;
278+
key += '\0';
279+
key += v;
280+
}
281+
return key;
282+
}
283+
284+
ProfilingArrayBufferAllocator::LabelPairs
285+
ProfilingArrayBufferAllocator::FindCurrentLabels() {
286+
// Skip non-main-thread allocations (SharedArrayBuffer from workers).
287+
if (std::this_thread::get_id() != main_thread_id_) return {};
288+
if (isolate_ == nullptr || als_key_ == nullptr || als_key_->IsEmpty()) {
289+
return {};
290+
}
291+
292+
// Read CPED via public V8 API. This is safe because:
293+
// 1. ArrayBuffer allocator runs in normal JS context, not during GC
294+
// 2. HandleScope is always active during JS execution
295+
v8::Local<v8::Value> cped =
296+
isolate_->GetContinuationPreservedEmbedderData();
297+
if (cped.IsEmpty() || !cped->IsMap()) return {};
298+
299+
v8::HandleScope handle_scope(isolate_);
300+
v8::Local<v8::Context> context = isolate_->GetCurrentContext();
301+
v8::Local<v8::Map> frame = cped.As<v8::Map>();
302+
v8::Local<v8::Value> als_key = als_key_->Get(isolate_);
303+
304+
// Cannot use Map::Get() here — it calls a JS builtin which is not safe
305+
// in DisallowJavascriptExecution (ArrayBuffer allocator is called from
306+
// BackingStore::Allocate inside the ArrayBuffer constructor).
307+
// Use AsArray() which reads the internal backing store directly without
308+
// calling JS builtins, then iterate entries by identity comparison.
309+
v8::Local<v8::Array> entries = frame->AsArray();
310+
uint32_t entries_len = entries->Length();
311+
for (uint32_t i = 0; i + 1 < entries_len; i += 2) {
312+
v8::Local<v8::Value> entry_key;
313+
if (!entries->Get(context, i).ToLocal(&entry_key)) continue;
314+
if (!entry_key->StrictEquals(als_key)) continue;
315+
316+
// Found the labels ALS entry — value is the flat array.
317+
v8::Local<v8::Value> val;
318+
if (!entries->Get(context, i + 1).ToLocal(&val) || !val->IsArray()) {
319+
return {};
320+
}
321+
322+
// Convert flat [key1, val1, key2, val2, ...] array to string pairs.
323+
v8::Local<v8::Array> flat = val.As<v8::Array>();
324+
uint32_t len = flat->Length();
325+
LabelPairs result;
326+
for (uint32_t j = 0; j + 1 < len; j += 2) {
327+
v8::Local<v8::Value> k, v;
328+
if (!flat->Get(context, j).ToLocal(&k)) return {};
329+
if (!flat->Get(context, j + 1).ToLocal(&v)) return {};
330+
v8::String::Utf8Value key_str(isolate_, k);
331+
v8::String::Utf8Value val_str(isolate_, v);
332+
result.emplace_back(*key_str, *val_str);
333+
}
334+
return result;
335+
}
336+
return {};
337+
}
338+
194339
std::unique_ptr<ArrayBufferAllocator> ArrayBufferAllocator::Create(bool debug) {
195340
if (debug || per_process::cli_options->debug_arraybuffer_allocations)
196341
return std::make_unique<DebuggingArrayBufferAllocator>();
197-
else
198-
return std::make_unique<NodeArrayBufferAllocator>();
342+
// Always use ProfilingArrayBufferAllocator so that per-label external memory
343+
// tracking is available when the sampling heap profiler is started via
344+
// v8.startSamplingHeapProfiler(). When profiling is disabled (the default)
345+
// the only overhead is a single atomic load (enabled_.load()) on each
346+
// Allocate/Free — no hash-map lookups or CPED reads occur.
347+
return std::make_unique<ProfilingArrayBufferAllocator>();
199348
}
200349

201350
ArrayBufferAllocator* CreateArrayBufferAllocator() {

src/node_internals.h

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ v8::Maybe<void> InitializePrimordials(v8::Local<v8::Context> context,
123123
v8::MaybeLocal<v8::Object> InitializePrivateSymbols(
124124
v8::Local<v8::Context> context, IsolateData* isolate_data);
125125

126+
class ProfilingArrayBufferAllocator; // Forward declaration.
127+
126128
class NodeArrayBufferAllocator : public ArrayBufferAllocator {
127129
public:
128130
void* Allocate(size_t size) override; // Defined in src/node.cc
@@ -136,6 +138,9 @@ class NodeArrayBufferAllocator : public ArrayBufferAllocator {
136138
}
137139

138140
NodeArrayBufferAllocator* GetImpl() final { return this; }
141+
virtual ProfilingArrayBufferAllocator* GetProfilingAllocator() {
142+
return nullptr;
143+
}
139144
inline uint64_t total_mem_usage() const {
140145
return total_mem_usage_.load(std::memory_order_relaxed);
141146
}
@@ -164,6 +169,50 @@ class DebuggingArrayBufferAllocator final : public NodeArrayBufferAllocator {
164169
std::unordered_map<void*, size_t> allocations_;
165170
};
166171

172+
// Subclass of NodeArrayBufferAllocator that tracks per-label external memory
173+
// (Buffer/ArrayBuffer backing stores) when heap profiling with labels is active.
174+
// When disabled (default), overhead is a single relaxed atomic load per alloc.
175+
class ProfilingArrayBufferAllocator : public NodeArrayBufferAllocator {
176+
public:
177+
using LabelPairs = std::vector<std::pair<std::string, std::string>>;
178+
179+
struct LabeledBytes {
180+
LabelPairs labels;
181+
int64_t bytes = 0;
182+
};
183+
184+
void* Allocate(size_t size) override;
185+
void* AllocateUninitialized(size_t size) override;
186+
void Free(void* data, size_t size) override;
187+
ProfilingArrayBufferAllocator* GetProfilingAllocator() override {
188+
return this;
189+
}
190+
191+
// Called from StartSamplingHeapProfiler/StopSamplingHeapProfiler.
192+
void Enable(v8::Isolate* isolate, v8::Global<v8::Value>* als_key);
193+
void Disable();
194+
195+
// Returns per-label live external bytes (for getAllocationProfile).
196+
std::vector<LabeledBytes> GetPerLabelBytes() const;
197+
198+
private:
199+
LabelPairs FindCurrentLabels();
200+
static std::string SerializeLabels(const LabelPairs& labels);
201+
202+
std::atomic<bool> enabled_{false};
203+
v8::Isolate* isolate_ = nullptr;
204+
// Borrowed pointer to BindingData::heap_profile_labels_als_key.
205+
v8::Global<v8::Value>* als_key_ = nullptr;
206+
207+
std::thread::id main_thread_id_ = std::this_thread::get_id();
208+
209+
mutable Mutex mutex_;
210+
// Maps allocation pointer to {serialized_label_key, size}.
211+
std::unordered_map<void*, std::pair<std::string, size_t>> allocations_;
212+
// Per-serialized-label-key entry with full labels and live bytes.
213+
std::unordered_map<std::string, LabeledBytes> per_label_bytes_;
214+
};
215+
167216
namespace Buffer {
168217
v8::MaybeLocal<v8::Object> Copy(Environment* env, const char* data, size_t len);
169218
v8::MaybeLocal<v8::Object> New(Environment* env, size_t size);

0 commit comments

Comments
 (0)