From 7d2db13a2f015e8dc1f472ba66b607b4e4a2d334 Mon Sep 17 00:00:00 2001 From: ishabi Date: Sun, 8 Feb 2026 20:46:56 +0100 Subject: [PATCH 1/7] Improve heap profile memoery usage by lazily loading js objects --- binding.gyp | 4 +- bindings/allocation-profile-node.cc | 113 ++++++++ bindings/allocation-profile-node.hh | 57 ++++ bindings/binding.cc | 2 + bindings/per-isolate-data.cc | 4 + bindings/per-isolate-data.hh | 2 + bindings/profilers/heap.cc | 104 ++------ bindings/profilers/heap.hh | 4 + bindings/translate-heap-profile.cc | 51 ++++ bindings/translate-heap-profile.hh | 18 ++ ts/src/heap-profiler-bindings.ts | 6 +- ts/src/heap-profiler.ts | 44 ++- ts/src/index.ts | 1 + ts/src/profile-serializer.ts | 338 +++++++++++++++++------- ts/src/v8-types.ts | 12 +- ts/test/test-allocation-profile-node.ts | 76 ++++++ 16 files changed, 664 insertions(+), 172 deletions(-) create mode 100644 bindings/allocation-profile-node.cc create mode 100644 bindings/allocation-profile-node.hh create mode 100644 ts/test/test-allocation-profile-node.ts diff --git a/binding.gyp b/binding.gyp index 71b0f215..b8af5ca4 100644 --- a/binding.gyp +++ b/binding.gyp @@ -18,7 +18,8 @@ "bindings/thread-cpu-clock.cc", "bindings/translate-heap-profile.cc", "bindings/translate-time-profile.cc", - "bindings/binding.cc" + "bindings/binding.cc", + "bindings/allocation-profile-node.cc" ], "include_dirs": [ "bindings", @@ -42,6 +43,7 @@ "bindings/translate-heap-profile.cc", "bindings/translate-time-profile.cc", "bindings/test/binding.cc", + "bindings/allocation-profile-node.cc" ], "include_dirs": [ "bindings", diff --git a/bindings/allocation-profile-node.cc b/bindings/allocation-profile-node.cc new file mode 100644 index 00000000..a070995f --- /dev/null +++ b/bindings/allocation-profile-node.cc @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Datadog, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "allocation-profile-node.hh" +#include "per-isolate-data.hh" + +namespace dd { + +NAN_MODULE_INIT(AllocationNodeWrapper::Init) { + v8::Local tpl = Nan::New(); + tpl->SetClassName(Nan::New("AllocationProfileNode").ToLocalChecked()); + tpl->InstanceTemplate()->SetInternalFieldCount(1); + + Nan::SetPrototypeMethod(tpl, "getChildrenCount", GetChildrenCount); + Nan::SetPrototypeMethod(tpl, "getChild", GetChild); + Nan::SetPrototypeMethod(tpl, "dispose", Dispose); + + PerIsolateData::For(v8::Isolate::GetCurrent()) + ->AllocationNodeConstructor() + .Reset(Nan::GetFunction(tpl).ToLocalChecked()); +} + +v8::Local AllocationNodeWrapper::New( + std::shared_ptr holder, + Node* node) { + auto* isolate = v8::Isolate::GetCurrent(); + + v8::Local constructor = + Nan::New(PerIsolateData::For(isolate)->AllocationNodeConstructor()); + + v8::Local obj = Nan::NewInstance(constructor).ToLocalChecked(); + + auto* wrapper = new AllocationNodeWrapper(holder, node); + wrapper->Wrap(obj); + wrapper->PopulateFields(obj); + + return obj; +} + +void AllocationNodeWrapper::PopulateFields(v8::Local obj) { + auto* isolate = v8::Isolate::GetCurrent(); + auto context = isolate->GetCurrentContext(); + + Nan::Set(obj, Nan::New("name").ToLocalChecked(), + Nan::New(node_->name).ToLocalChecked()); + Nan::Set(obj, Nan::New("scriptName").ToLocalChecked(), + Nan::New(node_->script_name).ToLocalChecked()); + Nan::Set(obj, Nan::New("scriptId").ToLocalChecked(), + Nan::New(node_->script_id)); + Nan::Set(obj, Nan::New("lineNumber").ToLocalChecked(), + Nan::New(node_->line_number)); + Nan::Set(obj, Nan::New("columnNumber").ToLocalChecked(), + Nan::New(node_->column_number)); + + v8::Local allocations = + v8::Array::New(isolate, node_->allocations.size()); + for (size_t i = 0; i < node_->allocations.size(); i++) { + const auto& alloc = node_->allocations[i]; + v8::Local alloc_obj = v8::Object::New(isolate); + Nan::Set(alloc_obj, Nan::New("sizeBytes").ToLocalChecked(), + Nan::New(static_cast(alloc.size))); + Nan::Set(alloc_obj, Nan::New("count").ToLocalChecked(), + Nan::New(static_cast(alloc.count))); + allocations->Set(context, i, alloc_obj).Check(); + } + Nan::Set(obj, Nan::New("allocations").ToLocalChecked(), allocations); +} + +NAN_METHOD(AllocationNodeWrapper::GetChildrenCount) { + auto* wrapper = Nan::ObjectWrap::Unwrap(info.Holder()); + info.GetReturnValue().Set( + Nan::New(static_cast(wrapper->node_->children.size()))); +} + +NAN_METHOD(AllocationNodeWrapper::GetChild) { + auto* wrapper = Nan::ObjectWrap::Unwrap(info.Holder()); + + if (info.Length() < 1 || !info[0]->IsUint32()) { + return Nan::ThrowTypeError("Index must be a uint32."); + } + + uint32_t index = Nan::To(info[0]).FromJust(); + const auto& children = wrapper->node_->children; + + if (index >= children.size()) { + return Nan::ThrowRangeError("Child index out of bounds"); + } + + auto child = AllocationNodeWrapper::New(wrapper->holder_, children[index].get()); + info.GetReturnValue().Set(child); +} + +NAN_METHOD(AllocationNodeWrapper::Dispose) { + auto* wrapper = Nan::ObjectWrap::Unwrap(info.Holder()); + if (wrapper->holder_) { + wrapper->holder_->Dispose(); + } +} + +} // namespace dd diff --git a/bindings/allocation-profile-node.hh b/bindings/allocation-profile-node.hh new file mode 100644 index 00000000..f5000745 --- /dev/null +++ b/bindings/allocation-profile-node.hh @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Datadog, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "translate-heap-profile.hh" + +namespace dd { + +struct AllocationProfileHolder { + std::shared_ptr profile; + + void Dispose() { + profile.reset(); + } +}; + +class AllocationNodeWrapper : public Nan::ObjectWrap { + public: + static NAN_MODULE_INIT(Init); + + static v8::Local New( + std::shared_ptr holder, + Node* node); + + private: + AllocationNodeWrapper(std::shared_ptr holder, + Node* node) + : holder_(holder), node_(node) {} + + void PopulateFields(v8::Local obj); + + static NAN_METHOD(GetChildrenCount); + static NAN_METHOD(GetChild); + static NAN_METHOD(Dispose); + + std::shared_ptr holder_; + Node* node_; +}; + +} // namespace dd diff --git a/bindings/binding.cc b/bindings/binding.cc index 57640194..4a006d14 100644 --- a/bindings/binding.cc +++ b/bindings/binding.cc @@ -18,6 +18,7 @@ #include #include +#include "allocation-profile-node.hh" #include "profilers/heap.hh" #include "profilers/wall.hh" @@ -47,6 +48,7 @@ NODE_MODULE_INIT(/* exports, module, context */) { #pragma GCC diagnostic pop #endif + dd::AllocationNodeWrapper::Init(exports); dd::HeapProfiler::Init(exports); dd::WallProfiler::Init(exports); Nan::SetMethod(exports, "getNativeThreadId", GetNativeThreadId); diff --git a/bindings/per-isolate-data.cc b/bindings/per-isolate-data.cc index 6dfbd6d3..424f9c47 100644 --- a/bindings/per-isolate-data.cc +++ b/bindings/per-isolate-data.cc @@ -52,6 +52,10 @@ Nan::Global& PerIsolateData::WallProfilerConstructor() { return wall_profiler_constructor; } +Nan::Global& PerIsolateData::AllocationNodeConstructor() { + return allocation_node_constructor; +} + std::shared_ptr& PerIsolateData::GetHeapProfilerState() { return heap_profiler_state; } diff --git a/bindings/per-isolate-data.hh b/bindings/per-isolate-data.hh index f555c5e8..dba9d52a 100644 --- a/bindings/per-isolate-data.hh +++ b/bindings/per-isolate-data.hh @@ -28,6 +28,7 @@ struct HeapProfilerState; class PerIsolateData { private: Nan::Global wall_profiler_constructor; + Nan::Global allocation_node_constructor; std::shared_ptr heap_profiler_state; PerIsolateData() {} @@ -36,6 +37,7 @@ class PerIsolateData { static PerIsolateData* For(v8::Isolate* isolate); Nan::Global& WallProfilerConstructor(); + Nan::Global& AllocationNodeConstructor(); std::shared_ptr& GetHeapProfilerState(); }; diff --git a/bindings/profilers/heap.cc b/bindings/profilers/heap.cc index a577b7fb..66cec320 100644 --- a/bindings/profilers/heap.cc +++ b/bindings/profilers/heap.cc @@ -28,6 +28,7 @@ #include #include +#include "allocation-profile-node.hh" namespace dd { @@ -55,17 +56,6 @@ static size_t NearHeapLimit(void* data, static void InterruptCallback(v8::Isolate* isolate, void* data); static void AsyncCallback(uv_async_t* handle); -struct Node { - using Allocation = v8::AllocationProfile::Allocation; - std::string name; - std::string script_name; - int line_number; - int column_number; - int script_id; - std::vector> children; - std::vector allocations; -}; - enum CallbackMode { kNoCallback = 0, kAsyncCallback = 1, @@ -139,73 +129,6 @@ struct HeapProfilerState { bool insideCallback = false; }; -std::shared_ptr TranslateAllocationProfileToCpp( - v8::AllocationProfile::Node* node) { - auto new_node = std::make_shared(); - new_node->line_number = node->line_number; - new_node->column_number = node->column_number; - new_node->script_id = node->script_id; - Nan::Utf8String name(node->name); - new_node->name.assign(*name, name.length()); - Nan::Utf8String script_name(node->script_name); - new_node->script_name.assign(*script_name, script_name.length()); - - new_node->children.reserve(node->children.size()); - for (auto& child : node->children) { - new_node->children.push_back(TranslateAllocationProfileToCpp(child)); - } - - new_node->allocations.reserve(node->allocations.size()); - for (auto& allocation : node->allocations) { - new_node->allocations.push_back(allocation); - } - return new_node; -} - -v8::Local TranslateAllocationProfile(Node* node) { - v8::Local js_node = Nan::New(); - - Nan::Set(js_node, - Nan::New("name").ToLocalChecked(), - Nan::New(node->name).ToLocalChecked()); - Nan::Set(js_node, - Nan::New("scriptName").ToLocalChecked(), - Nan::New(node->script_name).ToLocalChecked()); - Nan::Set(js_node, - Nan::New("scriptId").ToLocalChecked(), - Nan::New(node->script_id)); - Nan::Set(js_node, - Nan::New("lineNumber").ToLocalChecked(), - Nan::New(node->line_number)); - Nan::Set(js_node, - Nan::New("columnNumber").ToLocalChecked(), - Nan::New(node->column_number)); - - v8::Local children = Nan::New(node->children.size()); - for (size_t i = 0; i < node->children.size(); i++) { - Nan::Set(children, i, TranslateAllocationProfile(node->children[i].get())); - } - Nan::Set( - js_node, Nan::New("children").ToLocalChecked(), children); - v8::Local allocations = - Nan::New(node->allocations.size()); - for (size_t i = 0; i < node->allocations.size(); i++) { - v8::AllocationProfile::Allocation alloc = node->allocations[i]; - v8::Local js_alloc = Nan::New(); - Nan::Set(js_alloc, - Nan::New("sizeBytes").ToLocalChecked(), - Nan::New(alloc.size)); - Nan::Set(js_alloc, - Nan::New("count").ToLocalChecked(), - Nan::New(alloc.count)); - Nan::Set(allocations, i, js_alloc); - } - Nan::Set(js_node, - Nan::New("allocations").ToLocalChecked(), - allocations); - return js_node; -} - static void dumpAllocationProfile(FILE* file, Node* node, std::string& cur_stack) { @@ -582,6 +505,30 @@ NAN_METHOD(HeapProfiler::GetAllocationProfile) { info.GetReturnValue().Set(TranslateAllocationProfile(root)); } +// getAllocationProfileV2(): AllocationNodeWrapper +NAN_METHOD(HeapProfiler::GetAllocationProfileV2) { + auto isolate = info.GetIsolate(); + auto holder = std::make_shared(); + + std::unique_ptr profile( + isolate->GetHeapProfiler()->GetAllocationProfile()); + + if (!profile) { + return Nan::ThrowError("Heap profiler is not enabled."); + } + + // Convert to C++ tree and store in the holder + holder->profile = TranslateAllocationProfileToCpp(profile->GetRootNode()); + + auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); + if (state) { + state->OnNewProfile(); + } + + auto root = AllocationNodeWrapper::New(holder, holder->profile.get()); + info.GetReturnValue().Set(root); +} + NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { if (info.Length() != 7) { return Nan::ThrowTypeError("MonitorOOMCondition must have 7 arguments."); @@ -645,6 +592,7 @@ NAN_MODULE_INIT(HeapProfiler::Init) { Nan::SetMethod( heapProfiler, "stopSamplingHeapProfiler", StopSamplingHeapProfiler); Nan::SetMethod(heapProfiler, "getAllocationProfile", GetAllocationProfile); + Nan::SetMethod(heapProfiler, "getAllocationProfileV2", GetAllocationProfileV2); Nan::SetMethod(heapProfiler, "monitorOutOfMemory", MonitorOutOfMemory); Nan::Set(target, Nan::New("heapProfiler").ToLocalChecked(), diff --git a/bindings/profilers/heap.hh b/bindings/profilers/heap.hh index 5badc46c..a1e4ba49 100644 --- a/bindings/profilers/heap.hh +++ b/bindings/profilers/heap.hh @@ -34,6 +34,10 @@ class HeapProfiler { // getAllocationProfile(): AllocationProfileNode static NAN_METHOD(GetAllocationProfile); + // Signature: + // getAllocationProfileV2(): AllocationNodeWrapper + static NAN_METHOD(GetAllocationProfileV2); + static NAN_METHOD(MonitorOutOfMemory); static NAN_MODULE_INIT(Init); diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index a4331e09..41f5e129 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -15,6 +15,7 @@ */ #include "translate-heap-profile.hh" +#include #include "profile-translator.hh" namespace dd { @@ -64,6 +65,29 @@ class HeapProfileTranslator : ProfileTranslator { allocations); } + v8::Local TranslateAllocationProfile(Node* node) { + v8::Local children = NewArray(node->children.size()); + for (size_t i = 0; i < node->children.size(); i++) { + Set(children, i, TranslateAllocationProfile(node->children[i].get())); + } + + v8::Local allocations = NewArray(node->allocations.size()); + for (size_t i = 0; i < node->allocations.size(); i++) { + auto alloc = node->allocations[i]; + Set(allocations, + i, + CreateAllocation(NewNumber(alloc.count), NewNumber(alloc.size))); + } + + return CreateNode(NewString(node->name.c_str()), + NewString(node->script_name.c_str()), + NewInteger(node->script_id), + NewInteger(node->line_number), + NewInteger(node->column_number), + children, + allocations); + } + private: v8::Local CreateNode(v8::Local name, v8::Local scriptName, @@ -95,9 +119,36 @@ class HeapProfileTranslator : ProfileTranslator { }; } // namespace +std::shared_ptr TranslateAllocationProfileToCpp( + v8::AllocationProfile::Node* node) { + auto new_node = std::make_shared(); + new_node->line_number = node->line_number; + new_node->column_number = node->column_number; + new_node->script_id = node->script_id; + Nan::Utf8String name(node->name); + new_node->name.assign(*name, name.length()); + Nan::Utf8String script_name(node->script_name); + new_node->script_name.assign(*script_name, script_name.length()); + + new_node->children.reserve(node->children.size()); + for (auto& child : node->children) { + new_node->children.push_back(TranslateAllocationProfileToCpp(child)); + } + + new_node->allocations.reserve(node->allocations.size()); + for (auto& allocation : node->allocations) { + new_node->allocations.push_back(allocation); + } + return new_node; +} + v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node) { return HeapProfileTranslator().TranslateAllocationProfile(node); } +v8::Local TranslateAllocationProfile(Node* node) { + return HeapProfileTranslator().TranslateAllocationProfile(node); +} + } // namespace dd diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index dc5c7aa6..0743ec02 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -16,10 +16,28 @@ #pragma once +#include +#include +#include #include namespace dd { +struct Node { + using Allocation = v8::AllocationProfile::Allocation; + std::string name; + std::string script_name; + int line_number; + int column_number; + int script_id; + std::vector> children; + std::vector allocations; +}; + +std::shared_ptr TranslateAllocationProfileToCpp( + v8::AllocationProfile::Node* node); + +v8::Local TranslateAllocationProfile(Node* node); v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node); diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index 77522577..f22430b1 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -16,7 +16,7 @@ import * as path from 'path'; -import {AllocationProfileNode} from './v8-types'; +import {AllocationProfileNode, AllocationProfileNodeWrapper} from './v8-types'; const findBinding = require('node-gyp-build'); const profiler = findBinding(path.join(__dirname, '..', '..')); @@ -41,6 +41,10 @@ export function getAllocationProfile(): AllocationProfileNode { return profiler.heapProfiler.getAllocationProfile(); } +export function getAllocationProfileV2(): AllocationProfileNodeWrapper { + return profiler.heapProfiler.getAllocationProfileV2(); +} + export type NearHeapLimitCallback = (profile: AllocationProfileNode) => void; export function monitorOutOfMemory( diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index b6c64d0f..1c2595ff 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -18,14 +18,19 @@ import {Profile} from 'pprof-format'; import { getAllocationProfile, + getAllocationProfileV2, startSamplingHeapProfiler, stopSamplingHeapProfiler, monitorOutOfMemory as monitorOutOfMemoryImported, } from './heap-profiler-bindings'; -import {serializeHeapProfile} from './profile-serializer'; +import { + serializeHeapProfile, + serializeHeapProfileV2, +} from './profile-serializer'; import {SourceMapper} from './sourcemapper/sourcemapper'; import { AllocationProfileNode, + AllocationProfileNodeWrapper, GenerateAllocationLabelsFunction, } from './v8-types'; import {isMainThread} from 'worker_threads'; @@ -47,6 +52,13 @@ export function v8Profile(): AllocationProfileNode { return getAllocationProfile(); } +export function v8ProfileV2(): AllocationProfileNodeWrapper { + if (!enabled) { + throw new Error('Heap profiler is not enabled.'); + } + return getAllocationProfileV2(); +} + /** * Collects a profile and returns it serialized in pprof format. * Throws if heap profiler is not enabled. @@ -98,6 +110,36 @@ export function convertProfile( ); } +/** + * Collects a profile and returns it serialized in pprof format using lazy V2 API. + * Throws if heap profiler is not enabled. + * The underlying C++ profile is automatically disposed after serialization. + * + * @param ignoreSamplePath + * @param sourceMapper + * @param generateLabels + */ +export function profileV2( + ignoreSamplePath?: string, + sourceMapper?: SourceMapper, + generateLabels?: GenerateAllocationLabelsFunction +): Profile { + const root = v8ProfileV2(); + const startTimeNanos = Date.now() * 1000 * 1000; + try { + return serializeHeapProfileV2( + root, + startTimeNanos, + heapIntervalBytes, + ignoreSamplePath, + sourceMapper, + generateLabels + ); + } finally { + root.dispose(); + } +} + /** * Starts heap profiling. If heap profiling has already been started with * the same parameters, this is a noop. If heap profiler has already been diff --git a/ts/src/index.ts b/ts/src/index.ts index 42454629..be2a4170 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -48,6 +48,7 @@ export const heap = { start: heapProfiler.start, stop: heapProfiler.stop, profile: heapProfiler.profile, + profileV2: heapProfiler.profileV2, convertProfile: heapProfiler.convertProfile, v8Profile: heapProfiler.v8Profile, monitorOutOfMemory: heapProfiler.monitorOutOfMemory, diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 802bc968..77d86751 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -33,6 +33,7 @@ import { } from './sourcemapper/sourcemapper'; import { AllocationProfileNode, + AllocationProfileNodeWrapper, GenerateAllocationLabelsFunction, GenerateTimeLabelsFunction, ProfileNode, @@ -75,6 +76,114 @@ function isGeneratedLocation( ); } +function getFunction( + loc: SourceLocation, + scriptId: number | undefined, + functions: Function[], + functionIdMap: Map, + stringTable: StringTable +): Function { + let name = loc.name; + const keyStr = name + ? `${scriptId}:${name}` + : `${scriptId}:${loc.line}:${loc.column}`; + let id = functionIdMap.get(keyStr); + if (id !== undefined) { + // id is index+1, since 0 is not valid id. + return functions[id - 1]; + } + id = functions.length + 1; + functionIdMap.set(keyStr, id); + if (!name) { + if (loc.line) { + if (loc.column) { + name = `(anonymous:L#${loc.line}:C#${loc.column})`; + } else { + name = `(anonymous:L#${loc.line})`; + } + } else { + name = '(anonymous)'; + } + } + const nameId = stringTable.dedup(name); + const f = new Function({ + id, + name: nameId, + systemName: nameId, + filename: stringTable.dedup(loc.file || ''), + }); + functions.push(f); + return f; +} + +function getLine( + loc: SourceLocation, + scriptId: number | undefined, + functions: Function[], + functionIdMap: Map, + stringTable: StringTable +): Line { + return new Line({ + functionId: getFunction( + loc, + scriptId, + functions, + functionIdMap, + stringTable + ).id, + line: loc.line, + }); +} + +type LocationNodeInput = { + name?: string; + scriptName: string; + scriptId?: number; + lineNumber?: number; + columnNumber?: number; +}; + +function getLocation( + node: LocationNodeInput, + locations: Location[], + locationIdMap: Map, + functions: Function[], + functionIdMap: Map, + stringTable: StringTable, + sourceMapper?: SourceMapper +): Location { + let profLoc: SourceLocation = { + file: node.scriptName || '', + line: node.lineNumber, + column: node.columnNumber, + name: node.name, + }; + + if (profLoc.line) { + if (sourceMapper && isGeneratedLocation(profLoc)) { + profLoc = sourceMapper.mappingInfo(profLoc); + } + } + const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; + let id = locationIdMap.get(keyStr); + if (id !== undefined) { + // id is index+1, since 0 is not valid id. + return locations[id - 1]; + } + id = locations.length + 1; + locationIdMap.set(keyStr, id); + const line = getLine( + profLoc, + node.scriptId, + functions, + functionIdMap, + stringTable + ); + const location = new Location({id, line: [line]}); + locations.push(location); + return location; +} + /** * Takes v8 profile and populates sample, location, and function fields of * profile.proto. @@ -118,7 +227,15 @@ function serialize( continue; } const stack = entry.stack; - const location = getLocation(node, sourceMapper); + const location = getLocation( + node, + locations, + locationIdMap, + functions, + functionIdMap, + stringTable, + sourceMapper + ); stack.unshift(location.id as number); appendToSamples(entry, samples); for (const child of node.children as T[]) { @@ -130,77 +247,6 @@ function serialize( profile.location = locations; profile.function = functions; profile.stringTable = stringTable; - - function getLocation( - node: ProfileNode, - sourceMapper?: SourceMapper - ): Location { - let profLoc: SourceLocation = { - file: node.scriptName || '', - line: node.lineNumber, - column: node.columnNumber, - name: node.name, - }; - - if (profLoc.line) { - if (sourceMapper && isGeneratedLocation(profLoc)) { - profLoc = sourceMapper.mappingInfo(profLoc); - } - } - const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; - let id = locationIdMap.get(keyStr); - if (id !== undefined) { - // id is index+1, since 0 is not valid id. - return locations[id - 1]; - } - id = locations.length + 1; - locationIdMap.set(keyStr, id); - const line = getLine(profLoc, node.scriptId); - const location = new Location({id, line: [line]}); - locations.push(location); - return location; - } - - function getLine(loc: SourceLocation, scriptId?: number): Line { - return new Line({ - functionId: getFunction(loc, scriptId).id, - line: loc.line, - }); - } - - function getFunction(loc: SourceLocation, scriptId?: number): Function { - let name = loc.name; - const keyStr = name - ? `${scriptId}:${name}` - : `${scriptId}:${loc.line}:${loc.column}`; - let id = functionIdMap.get(keyStr); - if (id !== undefined) { - // id is index+1, since 0 is not valid id. - return functions[id - 1]; - } - id = functions.length + 1; - functionIdMap.set(keyStr, id); - if (!name) { - if (loc.line) { - if (loc.column) { - name = `(anonymous:L#${loc.line}:C#${loc.column})`; - } else { - name = `(anonymous:L#${loc.line})`; - } - } else { - name = '(anonymous)'; - } - } - const nameId = stringTable.dedup(name); - const f = new Function({ - id, - name: nameId, - systemName: nameId, - filename: stringTable.dedup(loc.file || ''), - }); - functions.push(f); - return f; - } } /** @@ -508,6 +554,29 @@ function buildLabels(labelSet: object, stringTable: StringTable): Label[] { return labels; } +function appendHeapSamples( + node: AllocationProfileNode | AllocationProfileNodeWrapper, + locationIds: number[], + samples: Sample[], + stringTable: StringTable, + generateLabels?: GenerateAllocationLabelsFunction +) { + if (node.allocations.length === 0) { + return; + } + const labels = generateLabels + ? buildLabels(generateLabels({node}), stringTable) + : []; + for (const alloc of node.allocations) { + const sample = new Sample({ + locationId: locationIds, + value: [alloc.count, alloc.sizeBytes * alloc.count], + label: labels, + }); + samples.push(sample); + } +} + /** * Converts v8 heap profile into into a profile proto. * (https://github.com/google/pprof/blob/master/proto/profile.proto) @@ -526,29 +595,22 @@ export function serializeHeapProfile( sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction ): Profile { + const stringTable = new StringTable(); + const sampleValueType = createObjectCountValueType(stringTable); + const allocationValueType = createAllocationValueType(stringTable); + const appendHeapEntryToSamples: AppendEntryToSamples< AllocationProfileNode > = (entry: Entry, samples: Sample[]) => { - if (entry.node.allocations.length > 0) { - const labels = generateLabels - ? buildLabels(generateLabels({node: entry.node}), stringTable) - : []; - for (const alloc of entry.node.allocations) { - const sample = new Sample({ - locationId: entry.stack, - value: [alloc.count, alloc.sizeBytes * alloc.count], - label: labels, - // TODO: add tag for allocation size - }); - samples.push(sample); - } - } + appendHeapSamples( + entry.node, + entry.stack, + samples, + stringTable, + generateLabels + ); }; - const stringTable = new StringTable(); - const sampleValueType = createObjectCountValueType(stringTable); - const allocationValueType = createAllocationValueType(stringTable); - const profile = { sampleType: [sampleValueType, allocationValueType], timeNanos: startTimeNanos, @@ -567,3 +629,99 @@ export function serializeHeapProfile( return new Profile(profile); } + +/** + * Lazy version of serializeHeapProfile that uses getChildrenCount/getChild + * to avoid materializing the full children array at once. + */ +export function serializeHeapProfileV2( + root: AllocationProfileNodeWrapper, + startTimeNanos: number, + intervalBytes: number, + ignoreSamplesPath?: string, + sourceMapper?: SourceMapper, + generateLabels?: GenerateAllocationLabelsFunction +): Profile { + const stringTable = new StringTable(); + const sampleValueType = createObjectCountValueType(stringTable); + const allocationValueType = createAllocationValueType(stringTable); + + const samples: Sample[] = []; + const locations: Location[] = []; + const functions: Function[] = []; + const functionIdMap = new Map(); + const locationIdMap = new Map(); + + interface Entry { + node: AllocationProfileNodeWrapper; + stack: Stack; + } + + const entries: Entry[] = []; + + // Start from root's children (skip root itself, like serialize does) + const rootChildCount = root.getChildrenCount(); + for (let i = 0; i < rootChildCount; i++) { + entries.push({node: root.getChild(i), stack: []}); + } + + // Add node for external memory usage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const {external}: {external: number} = process.memoryUsage() as any; + if (external > 0) { + const externalNode: AllocationProfileNodeWrapper = { + name: '(external)', + scriptName: '', + allocations: [{sizeBytes: external, count: 1}], + getChildrenCount: () => 0, + getChild: () => null as unknown as AllocationProfileNodeWrapper, + dispose: () => {}, + }; + entries.push({node: externalNode, stack: []}); + } + + while (entries.length > 0) { + const entry = entries.pop()!; + const node = entry.node; + + // Handle file:// prefix + let scriptName = node.scriptName; + if (scriptName.startsWith('file://')) { + scriptName = scriptName.slice(7); + } + + if (ignoreSamplesPath && scriptName.indexOf(ignoreSamplesPath) > -1) { + continue; + } + + const stack = entry.stack; + const location = getLocation( + node, + locations, + locationIdMap, + functions, + functionIdMap, + stringTable, + sourceMapper + ); + stack.unshift(location.id as number); + + appendHeapSamples(node, stack, samples, stringTable, generateLabels); + + const childCount = node.getChildrenCount(); + for (let i = 0; i < childCount; i++) { + entries.push({node: node.getChild(i), stack: stack.slice()}); + } + } + + return new Profile({ + sampleType: [sampleValueType, allocationValueType], + timeNanos: startTimeNanos, + periodType: allocationValueType, + period: intervalBytes, + sample: samples, + location: locations, + function: functions, + stringTable: stringTable, + }); +} diff --git a/ts/src/v8-types.ts b/ts/src/v8-types.ts index 39178b19..e5d138c6 100644 --- a/ts/src/v8-types.ts +++ b/ts/src/v8-types.ts @@ -53,6 +53,12 @@ export interface AllocationProfileNode extends ProfileNode { allocations: Allocation[]; } +export interface AllocationProfileNodeWrapper + extends Omit { + getChildrenCount(): number; + getChild(index: number): AllocationProfileNodeWrapper; + dispose(): void; +} export interface Allocation { sizeBytes: number; count: number; @@ -62,7 +68,11 @@ export interface LabelSet { } export interface GenerateAllocationLabelsFunction { - ({node}: {node: AllocationProfileNode}): LabelSet; + ({ + node, + }: { + node: AllocationProfileNode | AllocationProfileNodeWrapper; + }): LabelSet; } export interface GenerateTimeLabelsArgs { diff --git a/ts/test/test-allocation-profile-node.ts b/ts/test/test-allocation-profile-node.ts new file mode 100644 index 00000000..ae763a53 --- /dev/null +++ b/ts/test/test-allocation-profile-node.ts @@ -0,0 +1,76 @@ +import {strict as assert} from 'assert'; +import * as heapProfiler from '../src/heap-profiler'; + +function generateAllocations(): object[] { + const allocations: object[] = []; + for (let i = 0; i < 1000; i++) { + allocations.push({data: new Array(100).fill(i)}); + } + return allocations; +} + +describe('AllocationProfileNodeWrapper', () => { + let keepAlive: object[] = []; + + before(() => { + heapProfiler.start(512, 64); + keepAlive = generateAllocations(); + }); + + after(() => { + heapProfiler.stop(); + keepAlive.length = 0; + }); + + it('exposes lazy accessors and valid fields', () => { + const root = heapProfiler.v8ProfileV2(); + try { + // Check methods exist + assert.equal(typeof root.getChildrenCount, 'function'); + assert.equal(typeof root.getChild, 'function'); + assert.equal(typeof root.dispose, 'function'); + + // Check properties + assert.equal(typeof root.name, 'string'); + assert.equal(typeof root.scriptName, 'string'); + assert.equal(typeof root.scriptId, 'number'); + assert.equal(typeof root.lineNumber, 'number'); + assert.equal(typeof root.columnNumber, 'number'); + assert.ok(Array.isArray(root.allocations)); + + // Traverse children lazily + const childCount = root.getChildrenCount(); + assert.equal(typeof childCount, 'number'); + assert.ok(childCount >= 0); + + if (childCount > 0) { + const child = root.getChild(0); + assert.equal(typeof child.name, 'string'); + assert.equal(typeof child.scriptName, 'string'); + assert.equal(typeof child.scriptId, 'number'); + assert.ok(Array.isArray(child.allocations)); + + for (const alloc of child.allocations) { + assert.equal(typeof alloc.count, 'number'); + assert.equal(typeof alloc.sizeBytes, 'number'); + } + + const grandchildCount = child.getChildrenCount(); + assert.equal(typeof grandchildCount, 'number'); + } + } finally { + root.dispose(); + } + }); + + it('profileV2 produces valid pprof output', () => { + const profile = heapProfiler.profileV2(); + + // Verify profile structure + assert.ok(profile.sampleType); + assert.ok(profile.sample); + assert.ok(profile.location); + assert.ok(profile.function); + assert.ok(profile.stringTable); + }); +}); From d2982ce329dc0f467c48a0a1bb65714c1f122993 Mon Sep 17 00:00:00 2001 From: ishabi Date: Mon, 9 Feb 2026 09:51:07 +0100 Subject: [PATCH 2/7] format cc files --- bindings/allocation-profile-node.cc | 28 +++++++++++++++++----------- bindings/allocation-profile-node.hh | 7 ++----- bindings/profilers/heap.cc | 3 ++- bindings/translate-heap-profile.hh | 2 +- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/bindings/allocation-profile-node.cc b/bindings/allocation-profile-node.cc index a070995f..58722db0 100644 --- a/bindings/allocation-profile-node.cc +++ b/bindings/allocation-profile-node.cc @@ -34,8 +34,7 @@ NAN_MODULE_INIT(AllocationNodeWrapper::Init) { } v8::Local AllocationNodeWrapper::New( - std::shared_ptr holder, - Node* node) { + std::shared_ptr holder, Node* node) { auto* isolate = v8::Isolate::GetCurrent(); v8::Local constructor = @@ -54,15 +53,19 @@ void AllocationNodeWrapper::PopulateFields(v8::Local obj) { auto* isolate = v8::Isolate::GetCurrent(); auto context = isolate->GetCurrentContext(); - Nan::Set(obj, Nan::New("name").ToLocalChecked(), + Nan::Set(obj, + Nan::New("name").ToLocalChecked(), Nan::New(node_->name).ToLocalChecked()); - Nan::Set(obj, Nan::New("scriptName").ToLocalChecked(), + Nan::Set(obj, + Nan::New("scriptName").ToLocalChecked(), Nan::New(node_->script_name).ToLocalChecked()); - Nan::Set(obj, Nan::New("scriptId").ToLocalChecked(), - Nan::New(node_->script_id)); - Nan::Set(obj, Nan::New("lineNumber").ToLocalChecked(), + Nan::Set( + obj, Nan::New("scriptId").ToLocalChecked(), Nan::New(node_->script_id)); + Nan::Set(obj, + Nan::New("lineNumber").ToLocalChecked(), Nan::New(node_->line_number)); - Nan::Set(obj, Nan::New("columnNumber").ToLocalChecked(), + Nan::Set(obj, + Nan::New("columnNumber").ToLocalChecked(), Nan::New(node_->column_number)); v8::Local allocations = @@ -70,9 +73,11 @@ void AllocationNodeWrapper::PopulateFields(v8::Local obj) { for (size_t i = 0; i < node_->allocations.size(); i++) { const auto& alloc = node_->allocations[i]; v8::Local alloc_obj = v8::Object::New(isolate); - Nan::Set(alloc_obj, Nan::New("sizeBytes").ToLocalChecked(), + Nan::Set(alloc_obj, + Nan::New("sizeBytes").ToLocalChecked(), Nan::New(static_cast(alloc.size))); - Nan::Set(alloc_obj, Nan::New("count").ToLocalChecked(), + Nan::Set(alloc_obj, + Nan::New("count").ToLocalChecked(), Nan::New(static_cast(alloc.count))); allocations->Set(context, i, alloc_obj).Check(); } @@ -99,7 +104,8 @@ NAN_METHOD(AllocationNodeWrapper::GetChild) { return Nan::ThrowRangeError("Child index out of bounds"); } - auto child = AllocationNodeWrapper::New(wrapper->holder_, children[index].get()); + auto child = + AllocationNodeWrapper::New(wrapper->holder_, children[index].get()); info.GetReturnValue().Set(child); } diff --git a/bindings/allocation-profile-node.hh b/bindings/allocation-profile-node.hh index f5000745..f31c4eca 100644 --- a/bindings/allocation-profile-node.hh +++ b/bindings/allocation-profile-node.hh @@ -26,9 +26,7 @@ namespace dd { struct AllocationProfileHolder { std::shared_ptr profile; - void Dispose() { - profile.reset(); - } + void Dispose() { profile.reset(); } }; class AllocationNodeWrapper : public Nan::ObjectWrap { @@ -36,8 +34,7 @@ class AllocationNodeWrapper : public Nan::ObjectWrap { static NAN_MODULE_INIT(Init); static v8::Local New( - std::shared_ptr holder, - Node* node); + std::shared_ptr holder, Node* node); private: AllocationNodeWrapper(std::shared_ptr holder, diff --git a/bindings/profilers/heap.cc b/bindings/profilers/heap.cc index 66cec320..3ea76ac4 100644 --- a/bindings/profilers/heap.cc +++ b/bindings/profilers/heap.cc @@ -592,7 +592,8 @@ NAN_MODULE_INIT(HeapProfiler::Init) { Nan::SetMethod( heapProfiler, "stopSamplingHeapProfiler", StopSamplingHeapProfiler); Nan::SetMethod(heapProfiler, "getAllocationProfile", GetAllocationProfile); - Nan::SetMethod(heapProfiler, "getAllocationProfileV2", GetAllocationProfileV2); + Nan::SetMethod( + heapProfiler, "getAllocationProfileV2", GetAllocationProfileV2); Nan::SetMethod(heapProfiler, "monitorOutOfMemory", MonitorOutOfMemory); Nan::Set(target, Nan::New("heapProfiler").ToLocalChecked(), diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index 0743ec02..19106952 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -16,10 +16,10 @@ #pragma once +#include #include #include #include -#include namespace dd { From 4c77cf6ba9bd7a803b693758269d86922e568699 Mon Sep 17 00:00:00 2001 From: ishabi Date: Mon, 9 Feb 2026 14:47:02 +0100 Subject: [PATCH 3/7] fix removing file:// prefix from mjs scriptname --- ts/src/profile-serializer.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 77d86751..615e1e02 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -684,13 +684,12 @@ export function serializeHeapProfileV2( const entry = entries.pop()!; const node = entry.node; - // Handle file:// prefix - let scriptName = node.scriptName; - if (scriptName.startsWith('file://')) { - scriptName = scriptName.slice(7); + // mjs files have a `file://` prefix in the scriptName -> remove it + if (node.scriptName.startsWith('file://')) { + node.scriptName = node.scriptName.slice(7); } - if (ignoreSamplesPath && scriptName.indexOf(ignoreSamplesPath) > -1) { + if (ignoreSamplesPath && node.scriptName.indexOf(ignoreSamplesPath) > -1) { continue; } From 856f3ab5e71b9cb1c3b266b6a0e84c73d30ca75d Mon Sep 17 00:00:00 2001 From: ishabi Date: Tue, 10 Feb 2026 09:38:10 +0100 Subject: [PATCH 4/7] remove holder and use v8 external and local conversion --- bindings/allocation-profile-node.cc | 125 +++++---- bindings/allocation-profile-node.hh | 30 +-- bindings/binding.cc | 2 +- bindings/profilers/heap.cc | 9 +- bindings/profilers/heap.hh | 2 +- bindings/translate-heap-profile.cc | 22 ++ bindings/translate-heap-profile.hh | 17 ++ ts/src/heap-profiler-bindings.ts | 4 +- ts/src/heap-profiler.ts | 36 +-- ts/src/profile-serializer.ts | 337 +++++++----------------- ts/src/v8-types.ts | 12 +- ts/test/test-allocation-profile-node.ts | 76 ------ 12 files changed, 230 insertions(+), 442 deletions(-) delete mode 100644 ts/test/test-allocation-profile-node.ts diff --git a/bindings/allocation-profile-node.cc b/bindings/allocation-profile-node.cc index 58722db0..876d119a 100644 --- a/bindings/allocation-profile-node.cc +++ b/bindings/allocation-profile-node.cc @@ -19,22 +19,31 @@ namespace dd { -NAN_MODULE_INIT(AllocationNodeWrapper::Init) { +NAN_MODULE_INIT(ExternalAllocationNode::Init) { v8::Local tpl = Nan::New(); tpl->SetClassName(Nan::New("AllocationProfileNode").ToLocalChecked()); tpl->InstanceTemplate()->SetInternalFieldCount(1); - Nan::SetPrototypeMethod(tpl, "getChildrenCount", GetChildrenCount); - Nan::SetPrototypeMethod(tpl, "getChild", GetChild); - Nan::SetPrototypeMethod(tpl, "dispose", Dispose); + auto inst = tpl->InstanceTemplate(); + Nan::SetAccessor(inst, Nan::New("name").ToLocalChecked(), GetName); + Nan::SetAccessor( + inst, Nan::New("scriptName").ToLocalChecked(), GetScriptName); + Nan::SetAccessor(inst, Nan::New("scriptId").ToLocalChecked(), GetScriptId); + Nan::SetAccessor( + inst, Nan::New("lineNumber").ToLocalChecked(), GetLineNumber); + Nan::SetAccessor( + inst, Nan::New("columnNumber").ToLocalChecked(), GetColumnNumber); + Nan::SetAccessor( + inst, Nan::New("allocations").ToLocalChecked(), GetAllocations); + Nan::SetAccessor(inst, Nan::New("children").ToLocalChecked(), GetChildren); PerIsolateData::For(v8::Isolate::GetCurrent()) ->AllocationNodeConstructor() .Reset(Nan::GetFunction(tpl).ToLocalChecked()); } -v8::Local AllocationNodeWrapper::New( - std::shared_ptr holder, Node* node) { +v8::Local ExternalAllocationNode::New( + std::shared_ptr node) { auto* isolate = v8::Isolate::GetCurrent(); v8::Local constructor = @@ -42,36 +51,56 @@ v8::Local AllocationNodeWrapper::New( v8::Local obj = Nan::NewInstance(constructor).ToLocalChecked(); - auto* wrapper = new AllocationNodeWrapper(holder, node); + auto* wrapper = new ExternalAllocationNode(node); wrapper->Wrap(obj); - wrapper->PopulateFields(obj); return obj; } -void AllocationNodeWrapper::PopulateFields(v8::Local obj) { +NAN_GETTER(ExternalAllocationNode::GetName) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); + auto* isolate = v8::Isolate::GetCurrent(); + info.GetReturnValue().Set( + v8::Local::New(isolate, wrapper->node_->name)); +} + +NAN_GETTER(ExternalAllocationNode::GetScriptName) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); + auto* isolate = v8::Isolate::GetCurrent(); + info.GetReturnValue().Set( + v8::Local::New(isolate, wrapper->node_->script_name)); +} + +NAN_GETTER(ExternalAllocationNode::GetScriptId) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); + info.GetReturnValue().Set(Nan::New(wrapper->node_->script_id)); +} + +NAN_GETTER(ExternalAllocationNode::GetLineNumber) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); + info.GetReturnValue().Set(Nan::New(wrapper->node_->line_number)); +} + +NAN_GETTER(ExternalAllocationNode::GetColumnNumber) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); + info.GetReturnValue().Set(Nan::New(wrapper->node_->column_number)); +} + +NAN_GETTER(ExternalAllocationNode::GetAllocations) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); auto* isolate = v8::Isolate::GetCurrent(); auto context = isolate->GetCurrentContext(); - Nan::Set(obj, - Nan::New("name").ToLocalChecked(), - Nan::New(node_->name).ToLocalChecked()); - Nan::Set(obj, - Nan::New("scriptName").ToLocalChecked(), - Nan::New(node_->script_name).ToLocalChecked()); - Nan::Set( - obj, Nan::New("scriptId").ToLocalChecked(), Nan::New(node_->script_id)); - Nan::Set(obj, - Nan::New("lineNumber").ToLocalChecked(), - Nan::New(node_->line_number)); - Nan::Set(obj, - Nan::New("columnNumber").ToLocalChecked(), - Nan::New(node_->column_number)); - - v8::Local allocations = - v8::Array::New(isolate, node_->allocations.size()); - for (size_t i = 0; i < node_->allocations.size(); i++) { - const auto& alloc = node_->allocations[i]; + const auto& allocations = wrapper->node_->allocations; + v8::Local arr = v8::Array::New(isolate, allocations.size()); + for (size_t i = 0; i < allocations.size(); i++) { + const auto& alloc = allocations[i]; v8::Local alloc_obj = v8::Object::New(isolate); Nan::Set(alloc_obj, Nan::New("sizeBytes").ToLocalChecked(), @@ -79,41 +108,23 @@ void AllocationNodeWrapper::PopulateFields(v8::Local obj) { Nan::Set(alloc_obj, Nan::New("count").ToLocalChecked(), Nan::New(static_cast(alloc.count))); - allocations->Set(context, i, alloc_obj).Check(); + arr->Set(context, i, alloc_obj).Check(); } - Nan::Set(obj, Nan::New("allocations").ToLocalChecked(), allocations); -} - -NAN_METHOD(AllocationNodeWrapper::GetChildrenCount) { - auto* wrapper = Nan::ObjectWrap::Unwrap(info.Holder()); - info.GetReturnValue().Set( - Nan::New(static_cast(wrapper->node_->children.size()))); + info.GetReturnValue().Set(arr); } -NAN_METHOD(AllocationNodeWrapper::GetChild) { - auto* wrapper = Nan::ObjectWrap::Unwrap(info.Holder()); - - if (info.Length() < 1 || !info[0]->IsUint32()) { - return Nan::ThrowTypeError("Index must be a uint32."); - } +NAN_GETTER(ExternalAllocationNode::GetChildren) { + auto* wrapper = + Nan::ObjectWrap::Unwrap(info.Holder()); + auto* isolate = v8::Isolate::GetCurrent(); + auto context = isolate->GetCurrentContext(); - uint32_t index = Nan::To(info[0]).FromJust(); const auto& children = wrapper->node_->children; - - if (index >= children.size()) { - return Nan::ThrowRangeError("Child index out of bounds"); - } - - auto child = - AllocationNodeWrapper::New(wrapper->holder_, children[index].get()); - info.GetReturnValue().Set(child); -} - -NAN_METHOD(AllocationNodeWrapper::Dispose) { - auto* wrapper = Nan::ObjectWrap::Unwrap(info.Holder()); - if (wrapper->holder_) { - wrapper->holder_->Dispose(); + v8::Local arr = v8::Array::New(isolate, children.size()); + for (size_t i = 0; i < children.size(); i++) { + arr->Set(context, i, ExternalAllocationNode::New(children[i])).Check(); } + info.GetReturnValue().Set(arr); } } // namespace dd diff --git a/bindings/allocation-profile-node.hh b/bindings/allocation-profile-node.hh index f31c4eca..0db6bfb5 100644 --- a/bindings/allocation-profile-node.hh +++ b/bindings/allocation-profile-node.hh @@ -23,32 +23,24 @@ namespace dd { -struct AllocationProfileHolder { - std::shared_ptr profile; - - void Dispose() { profile.reset(); } -}; - -class AllocationNodeWrapper : public Nan::ObjectWrap { +class ExternalAllocationNode : public Nan::ObjectWrap { public: static NAN_MODULE_INIT(Init); - static v8::Local New( - std::shared_ptr holder, Node* node); + static v8::Local New(std::shared_ptr node); private: - AllocationNodeWrapper(std::shared_ptr holder, - Node* node) - : holder_(holder), node_(node) {} - - void PopulateFields(v8::Local obj); + ExternalAllocationNode(std::shared_ptr node) : node_(node) {} - static NAN_METHOD(GetChildrenCount); - static NAN_METHOD(GetChild); - static NAN_METHOD(Dispose); + static NAN_GETTER(GetName); + static NAN_GETTER(GetScriptName); + static NAN_GETTER(GetScriptId); + static NAN_GETTER(GetLineNumber); + static NAN_GETTER(GetColumnNumber); + static NAN_GETTER(GetAllocations); + static NAN_GETTER(GetChildren); - std::shared_ptr holder_; - Node* node_; + std::shared_ptr node_; }; } // namespace dd diff --git a/bindings/binding.cc b/bindings/binding.cc index 4a006d14..b1935d28 100644 --- a/bindings/binding.cc +++ b/bindings/binding.cc @@ -48,7 +48,7 @@ NODE_MODULE_INIT(/* exports, module, context */) { #pragma GCC diagnostic pop #endif - dd::AllocationNodeWrapper::Init(exports); + dd::ExternalAllocationNode::Init(exports); dd::HeapProfiler::Init(exports); dd::WallProfiler::Init(exports); Nan::SetMethod(exports, "getNativeThreadId", GetNativeThreadId); diff --git a/bindings/profilers/heap.cc b/bindings/profilers/heap.cc index 3ea76ac4..4a4b54c9 100644 --- a/bindings/profilers/heap.cc +++ b/bindings/profilers/heap.cc @@ -505,10 +505,9 @@ NAN_METHOD(HeapProfiler::GetAllocationProfile) { info.GetReturnValue().Set(TranslateAllocationProfile(root)); } -// getAllocationProfileV2(): AllocationNodeWrapper +// getAllocationProfileV2(): ExternalAllocationNode NAN_METHOD(HeapProfiler::GetAllocationProfileV2) { auto isolate = info.GetIsolate(); - auto holder = std::make_shared(); std::unique_ptr profile( isolate->GetHeapProfiler()->GetAllocationProfile()); @@ -517,15 +516,15 @@ NAN_METHOD(HeapProfiler::GetAllocationProfileV2) { return Nan::ThrowError("Heap profiler is not enabled."); } - // Convert to C++ tree and store in the holder - holder->profile = TranslateAllocationProfileToCpp(profile->GetRootNode()); + auto root_node = + TranslateAllocationProfileToExternal(isolate, profile->GetRootNode()); auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); if (state) { state->OnNewProfile(); } - auto root = AllocationNodeWrapper::New(holder, holder->profile.get()); + auto root = ExternalAllocationNode::New(root_node); info.GetReturnValue().Set(root); } diff --git a/bindings/profilers/heap.hh b/bindings/profilers/heap.hh index a1e4ba49..34b8cb72 100644 --- a/bindings/profilers/heap.hh +++ b/bindings/profilers/heap.hh @@ -35,7 +35,7 @@ class HeapProfiler { static NAN_METHOD(GetAllocationProfile); // Signature: - // getAllocationProfileV2(): AllocationNodeWrapper + // getAllocationProfileV2(): ExternalAllocationNode static NAN_METHOD(GetAllocationProfileV2); static NAN_METHOD(MonitorOutOfMemory); diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index 41f5e129..5ef6902f 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -142,6 +142,28 @@ std::shared_ptr TranslateAllocationProfileToCpp( return new_node; } +std::shared_ptr TranslateAllocationProfileToExternal( + v8::Isolate* isolate, v8::AllocationProfile::Node* node) { + auto new_node = std::make_shared(); + new_node->line_number = node->line_number; + new_node->column_number = node->column_number; + new_node->script_id = node->script_id; + new_node->name.Reset(isolate, node->name); + new_node->script_name.Reset(isolate, node->script_name); + + new_node->children.reserve(node->children.size()); + for (auto& child : node->children) { + new_node->children.push_back( + TranslateAllocationProfileToExternal(isolate, child)); + } + + new_node->allocations.reserve(node->allocations.size()); + for (auto& allocation : node->allocations) { + new_node->allocations.push_back(allocation); + } + return new_node; +} + v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node) { return HeapProfileTranslator().TranslateAllocationProfile(node); diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index 19106952..a8ccb279 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -17,6 +17,7 @@ #pragma once #include +#include #include #include #include @@ -34,9 +35,25 @@ struct Node { std::vector allocations; }; +// ExternalNode with v8::Global fields — used by +// ExternalAllocationNode. Promotion from Local to Global +struct ExternalNode { + using Allocation = v8::AllocationProfile::Allocation; + v8::Global name; + v8::Global script_name; + int line_number; + int column_number; + int script_id; + std::vector> children; + std::vector allocations; +}; + std::shared_ptr TranslateAllocationProfileToCpp( v8::AllocationProfile::Node* node); +std::shared_ptr TranslateAllocationProfileToExternal( + v8::Isolate* isolate, v8::AllocationProfile::Node* node); + v8::Local TranslateAllocationProfile(Node* node); v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node); diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index f22430b1..8506f9f7 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -16,7 +16,7 @@ import * as path from 'path'; -import {AllocationProfileNode, AllocationProfileNodeWrapper} from './v8-types'; +import {AllocationProfileNode} from './v8-types'; const findBinding = require('node-gyp-build'); const profiler = findBinding(path.join(__dirname, '..', '..')); @@ -41,7 +41,7 @@ export function getAllocationProfile(): AllocationProfileNode { return profiler.heapProfiler.getAllocationProfile(); } -export function getAllocationProfileV2(): AllocationProfileNodeWrapper { +export function getAllocationProfileV2(): AllocationProfileNode { return profiler.heapProfiler.getAllocationProfileV2(); } diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index 1c2595ff..d2ba5d2a 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -23,14 +23,10 @@ import { stopSamplingHeapProfiler, monitorOutOfMemory as monitorOutOfMemoryImported, } from './heap-profiler-bindings'; -import { - serializeHeapProfile, - serializeHeapProfileV2, -} from './profile-serializer'; +import {serializeHeapProfile} from './profile-serializer'; import {SourceMapper} from './sourcemapper/sourcemapper'; import { AllocationProfileNode, - AllocationProfileNodeWrapper, GenerateAllocationLabelsFunction, } from './v8-types'; import {isMainThread} from 'worker_threads'; @@ -52,7 +48,7 @@ export function v8Profile(): AllocationProfileNode { return getAllocationProfile(); } -export function v8ProfileV2(): AllocationProfileNodeWrapper { +export function v8ProfileV2(): AllocationProfileNode { if (!enabled) { throw new Error('Heap profiler is not enabled.'); } @@ -91,6 +87,7 @@ export function convertProfile( // TODO: remove any once type definition is updated to include external. // eslint-disable-next-line @typescript-eslint/no-explicit-any const {external}: {external: number} = process.memoryUsage() as any; + let root: AllocationProfileNode; if (external > 0) { const externalNode: AllocationProfileNode = { name: '(external)', @@ -98,10 +95,12 @@ export function convertProfile( children: [], allocations: [{sizeBytes: external, count: 1}], }; - rootNode.children.push(externalNode); + root = {...rootNode, children: [...rootNode.children, externalNode]}; + } else { + root = {...rootNode, children: [...rootNode.children]}; } return serializeHeapProfile( - rootNode, + root, startTimeNanos, heapIntervalBytes, ignoreSamplePath, @@ -113,7 +112,6 @@ export function convertProfile( /** * Collects a profile and returns it serialized in pprof format using lazy V2 API. * Throws if heap profiler is not enabled. - * The underlying C++ profile is automatically disposed after serialization. * * @param ignoreSamplePath * @param sourceMapper @@ -124,20 +122,12 @@ export function profileV2( sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction ): Profile { - const root = v8ProfileV2(); - const startTimeNanos = Date.now() * 1000 * 1000; - try { - return serializeHeapProfileV2( - root, - startTimeNanos, - heapIntervalBytes, - ignoreSamplePath, - sourceMapper, - generateLabels - ); - } finally { - root.dispose(); - } + return convertProfile( + v8ProfileV2(), + ignoreSamplePath, + sourceMapper, + generateLabels + ); } /** diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 615e1e02..802bc968 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -33,7 +33,6 @@ import { } from './sourcemapper/sourcemapper'; import { AllocationProfileNode, - AllocationProfileNodeWrapper, GenerateAllocationLabelsFunction, GenerateTimeLabelsFunction, ProfileNode, @@ -76,114 +75,6 @@ function isGeneratedLocation( ); } -function getFunction( - loc: SourceLocation, - scriptId: number | undefined, - functions: Function[], - functionIdMap: Map, - stringTable: StringTable -): Function { - let name = loc.name; - const keyStr = name - ? `${scriptId}:${name}` - : `${scriptId}:${loc.line}:${loc.column}`; - let id = functionIdMap.get(keyStr); - if (id !== undefined) { - // id is index+1, since 0 is not valid id. - return functions[id - 1]; - } - id = functions.length + 1; - functionIdMap.set(keyStr, id); - if (!name) { - if (loc.line) { - if (loc.column) { - name = `(anonymous:L#${loc.line}:C#${loc.column})`; - } else { - name = `(anonymous:L#${loc.line})`; - } - } else { - name = '(anonymous)'; - } - } - const nameId = stringTable.dedup(name); - const f = new Function({ - id, - name: nameId, - systemName: nameId, - filename: stringTable.dedup(loc.file || ''), - }); - functions.push(f); - return f; -} - -function getLine( - loc: SourceLocation, - scriptId: number | undefined, - functions: Function[], - functionIdMap: Map, - stringTable: StringTable -): Line { - return new Line({ - functionId: getFunction( - loc, - scriptId, - functions, - functionIdMap, - stringTable - ).id, - line: loc.line, - }); -} - -type LocationNodeInput = { - name?: string; - scriptName: string; - scriptId?: number; - lineNumber?: number; - columnNumber?: number; -}; - -function getLocation( - node: LocationNodeInput, - locations: Location[], - locationIdMap: Map, - functions: Function[], - functionIdMap: Map, - stringTable: StringTable, - sourceMapper?: SourceMapper -): Location { - let profLoc: SourceLocation = { - file: node.scriptName || '', - line: node.lineNumber, - column: node.columnNumber, - name: node.name, - }; - - if (profLoc.line) { - if (sourceMapper && isGeneratedLocation(profLoc)) { - profLoc = sourceMapper.mappingInfo(profLoc); - } - } - const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; - let id = locationIdMap.get(keyStr); - if (id !== undefined) { - // id is index+1, since 0 is not valid id. - return locations[id - 1]; - } - id = locations.length + 1; - locationIdMap.set(keyStr, id); - const line = getLine( - profLoc, - node.scriptId, - functions, - functionIdMap, - stringTable - ); - const location = new Location({id, line: [line]}); - locations.push(location); - return location; -} - /** * Takes v8 profile and populates sample, location, and function fields of * profile.proto. @@ -227,15 +118,7 @@ function serialize( continue; } const stack = entry.stack; - const location = getLocation( - node, - locations, - locationIdMap, - functions, - functionIdMap, - stringTable, - sourceMapper - ); + const location = getLocation(node, sourceMapper); stack.unshift(location.id as number); appendToSamples(entry, samples); for (const child of node.children as T[]) { @@ -247,6 +130,77 @@ function serialize( profile.location = locations; profile.function = functions; profile.stringTable = stringTable; + + function getLocation( + node: ProfileNode, + sourceMapper?: SourceMapper + ): Location { + let profLoc: SourceLocation = { + file: node.scriptName || '', + line: node.lineNumber, + column: node.columnNumber, + name: node.name, + }; + + if (profLoc.line) { + if (sourceMapper && isGeneratedLocation(profLoc)) { + profLoc = sourceMapper.mappingInfo(profLoc); + } + } + const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; + let id = locationIdMap.get(keyStr); + if (id !== undefined) { + // id is index+1, since 0 is not valid id. + return locations[id - 1]; + } + id = locations.length + 1; + locationIdMap.set(keyStr, id); + const line = getLine(profLoc, node.scriptId); + const location = new Location({id, line: [line]}); + locations.push(location); + return location; + } + + function getLine(loc: SourceLocation, scriptId?: number): Line { + return new Line({ + functionId: getFunction(loc, scriptId).id, + line: loc.line, + }); + } + + function getFunction(loc: SourceLocation, scriptId?: number): Function { + let name = loc.name; + const keyStr = name + ? `${scriptId}:${name}` + : `${scriptId}:${loc.line}:${loc.column}`; + let id = functionIdMap.get(keyStr); + if (id !== undefined) { + // id is index+1, since 0 is not valid id. + return functions[id - 1]; + } + id = functions.length + 1; + functionIdMap.set(keyStr, id); + if (!name) { + if (loc.line) { + if (loc.column) { + name = `(anonymous:L#${loc.line}:C#${loc.column})`; + } else { + name = `(anonymous:L#${loc.line})`; + } + } else { + name = '(anonymous)'; + } + } + const nameId = stringTable.dedup(name); + const f = new Function({ + id, + name: nameId, + systemName: nameId, + filename: stringTable.dedup(loc.file || ''), + }); + functions.push(f); + return f; + } } /** @@ -554,29 +508,6 @@ function buildLabels(labelSet: object, stringTable: StringTable): Label[] { return labels; } -function appendHeapSamples( - node: AllocationProfileNode | AllocationProfileNodeWrapper, - locationIds: number[], - samples: Sample[], - stringTable: StringTable, - generateLabels?: GenerateAllocationLabelsFunction -) { - if (node.allocations.length === 0) { - return; - } - const labels = generateLabels - ? buildLabels(generateLabels({node}), stringTable) - : []; - for (const alloc of node.allocations) { - const sample = new Sample({ - locationId: locationIds, - value: [alloc.count, alloc.sizeBytes * alloc.count], - label: labels, - }); - samples.push(sample); - } -} - /** * Converts v8 heap profile into into a profile proto. * (https://github.com/google/pprof/blob/master/proto/profile.proto) @@ -595,22 +526,29 @@ export function serializeHeapProfile( sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction ): Profile { - const stringTable = new StringTable(); - const sampleValueType = createObjectCountValueType(stringTable); - const allocationValueType = createAllocationValueType(stringTable); - const appendHeapEntryToSamples: AppendEntryToSamples< AllocationProfileNode > = (entry: Entry, samples: Sample[]) => { - appendHeapSamples( - entry.node, - entry.stack, - samples, - stringTable, - generateLabels - ); + if (entry.node.allocations.length > 0) { + const labels = generateLabels + ? buildLabels(generateLabels({node: entry.node}), stringTable) + : []; + for (const alloc of entry.node.allocations) { + const sample = new Sample({ + locationId: entry.stack, + value: [alloc.count, alloc.sizeBytes * alloc.count], + label: labels, + // TODO: add tag for allocation size + }); + samples.push(sample); + } + } }; + const stringTable = new StringTable(); + const sampleValueType = createObjectCountValueType(stringTable); + const allocationValueType = createAllocationValueType(stringTable); + const profile = { sampleType: [sampleValueType, allocationValueType], timeNanos: startTimeNanos, @@ -629,98 +567,3 @@ export function serializeHeapProfile( return new Profile(profile); } - -/** - * Lazy version of serializeHeapProfile that uses getChildrenCount/getChild - * to avoid materializing the full children array at once. - */ -export function serializeHeapProfileV2( - root: AllocationProfileNodeWrapper, - startTimeNanos: number, - intervalBytes: number, - ignoreSamplesPath?: string, - sourceMapper?: SourceMapper, - generateLabels?: GenerateAllocationLabelsFunction -): Profile { - const stringTable = new StringTable(); - const sampleValueType = createObjectCountValueType(stringTable); - const allocationValueType = createAllocationValueType(stringTable); - - const samples: Sample[] = []; - const locations: Location[] = []; - const functions: Function[] = []; - const functionIdMap = new Map(); - const locationIdMap = new Map(); - - interface Entry { - node: AllocationProfileNodeWrapper; - stack: Stack; - } - - const entries: Entry[] = []; - - // Start from root's children (skip root itself, like serialize does) - const rootChildCount = root.getChildrenCount(); - for (let i = 0; i < rootChildCount; i++) { - entries.push({node: root.getChild(i), stack: []}); - } - - // Add node for external memory usage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const {external}: {external: number} = process.memoryUsage() as any; - if (external > 0) { - const externalNode: AllocationProfileNodeWrapper = { - name: '(external)', - scriptName: '', - allocations: [{sizeBytes: external, count: 1}], - getChildrenCount: () => 0, - getChild: () => null as unknown as AllocationProfileNodeWrapper, - dispose: () => {}, - }; - entries.push({node: externalNode, stack: []}); - } - - while (entries.length > 0) { - const entry = entries.pop()!; - const node = entry.node; - - // mjs files have a `file://` prefix in the scriptName -> remove it - if (node.scriptName.startsWith('file://')) { - node.scriptName = node.scriptName.slice(7); - } - - if (ignoreSamplesPath && node.scriptName.indexOf(ignoreSamplesPath) > -1) { - continue; - } - - const stack = entry.stack; - const location = getLocation( - node, - locations, - locationIdMap, - functions, - functionIdMap, - stringTable, - sourceMapper - ); - stack.unshift(location.id as number); - - appendHeapSamples(node, stack, samples, stringTable, generateLabels); - - const childCount = node.getChildrenCount(); - for (let i = 0; i < childCount; i++) { - entries.push({node: node.getChild(i), stack: stack.slice()}); - } - } - - return new Profile({ - sampleType: [sampleValueType, allocationValueType], - timeNanos: startTimeNanos, - periodType: allocationValueType, - period: intervalBytes, - sample: samples, - location: locations, - function: functions, - stringTable: stringTable, - }); -} diff --git a/ts/src/v8-types.ts b/ts/src/v8-types.ts index e5d138c6..39178b19 100644 --- a/ts/src/v8-types.ts +++ b/ts/src/v8-types.ts @@ -53,12 +53,6 @@ export interface AllocationProfileNode extends ProfileNode { allocations: Allocation[]; } -export interface AllocationProfileNodeWrapper - extends Omit { - getChildrenCount(): number; - getChild(index: number): AllocationProfileNodeWrapper; - dispose(): void; -} export interface Allocation { sizeBytes: number; count: number; @@ -68,11 +62,7 @@ export interface LabelSet { } export interface GenerateAllocationLabelsFunction { - ({ - node, - }: { - node: AllocationProfileNode | AllocationProfileNodeWrapper; - }): LabelSet; + ({node}: {node: AllocationProfileNode}): LabelSet; } export interface GenerateTimeLabelsArgs { diff --git a/ts/test/test-allocation-profile-node.ts b/ts/test/test-allocation-profile-node.ts deleted file mode 100644 index ae763a53..00000000 --- a/ts/test/test-allocation-profile-node.ts +++ /dev/null @@ -1,76 +0,0 @@ -import {strict as assert} from 'assert'; -import * as heapProfiler from '../src/heap-profiler'; - -function generateAllocations(): object[] { - const allocations: object[] = []; - for (let i = 0; i < 1000; i++) { - allocations.push({data: new Array(100).fill(i)}); - } - return allocations; -} - -describe('AllocationProfileNodeWrapper', () => { - let keepAlive: object[] = []; - - before(() => { - heapProfiler.start(512, 64); - keepAlive = generateAllocations(); - }); - - after(() => { - heapProfiler.stop(); - keepAlive.length = 0; - }); - - it('exposes lazy accessors and valid fields', () => { - const root = heapProfiler.v8ProfileV2(); - try { - // Check methods exist - assert.equal(typeof root.getChildrenCount, 'function'); - assert.equal(typeof root.getChild, 'function'); - assert.equal(typeof root.dispose, 'function'); - - // Check properties - assert.equal(typeof root.name, 'string'); - assert.equal(typeof root.scriptName, 'string'); - assert.equal(typeof root.scriptId, 'number'); - assert.equal(typeof root.lineNumber, 'number'); - assert.equal(typeof root.columnNumber, 'number'); - assert.ok(Array.isArray(root.allocations)); - - // Traverse children lazily - const childCount = root.getChildrenCount(); - assert.equal(typeof childCount, 'number'); - assert.ok(childCount >= 0); - - if (childCount > 0) { - const child = root.getChild(0); - assert.equal(typeof child.name, 'string'); - assert.equal(typeof child.scriptName, 'string'); - assert.equal(typeof child.scriptId, 'number'); - assert.ok(Array.isArray(child.allocations)); - - for (const alloc of child.allocations) { - assert.equal(typeof alloc.count, 'number'); - assert.equal(typeof alloc.sizeBytes, 'number'); - } - - const grandchildCount = child.getChildrenCount(); - assert.equal(typeof grandchildCount, 'number'); - } - } finally { - root.dispose(); - } - }); - - it('profileV2 produces valid pprof output', () => { - const profile = heapProfiler.profileV2(); - - // Verify profile structure - assert.ok(profile.sampleType); - assert.ok(profile.sample); - assert.ok(profile.location); - assert.ok(profile.function); - assert.ok(profile.stringTable); - }); -}); From b0caf9a0856188893ee18ba75f75c32b56e23ebb Mon Sep 17 00:00:00 2001 From: ishabi Date: Tue, 10 Feb 2026 10:47:54 +0100 Subject: [PATCH 5/7] profile v2 tests --- ts/src/v8-types.ts | 1 + ts/test/test-heap-profiler-v2.ts | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 ts/test/test-heap-profiler-v2.ts diff --git a/ts/src/v8-types.ts b/ts/src/v8-types.ts index 39178b19..1becf7d2 100644 --- a/ts/src/v8-types.ts +++ b/ts/src/v8-types.ts @@ -51,6 +51,7 @@ export interface TimeProfileNode extends ProfileNode { export interface AllocationProfileNode extends ProfileNode { allocations: Allocation[]; + children: AllocationProfileNode[]; } export interface Allocation { diff --git a/ts/test/test-heap-profiler-v2.ts b/ts/test/test-heap-profiler-v2.ts new file mode 100644 index 00000000..d701f29d --- /dev/null +++ b/ts/test/test-heap-profiler-v2.ts @@ -0,0 +1,84 @@ +import {strict as assert} from 'assert'; +import * as heapProfiler from '../src/heap-profiler'; +import * as v8HeapProfiler from '../src/heap-profiler-bindings'; + +function generateAllocations(): object[] { + const allocations: object[] = []; + for (let i = 0; i < 1000; i++) { + allocations.push({data: new Array(100).fill(i)}); + } + return allocations; +} + +describe('HeapProfiler V2 API', () => { + let keepAlive: object[] = []; + + before(() => { + heapProfiler.start(512, 64); + keepAlive = generateAllocations(); + }); + + after(() => { + heapProfiler.stop(); + keepAlive.length = 0; + }); + + describe('v8ProfileV2', () => { + it('should return AllocationProfileNode', () => { + const root = heapProfiler.v8ProfileV2(); + + assert.equal(typeof root.name, 'string'); + assert.equal(typeof root.scriptName, 'string'); + assert.equal(typeof root.scriptId, 'number'); + assert.equal(typeof root.lineNumber, 'number'); + assert.equal(typeof root.columnNumber, 'number'); + assert.ok(Array.isArray(root.allocations)); + + assert.ok(Array.isArray(root.children)); + assert.equal(typeof root.children.length, 'number'); + + if (root.children.length > 0) { + const child = root.children[0]; + assert.equal(typeof child.name, 'string'); + assert.ok(Array.isArray(child.children)); + assert.ok(Array.isArray(child.allocations)); + } + }); + + it('should throw error when profiler not started', () => { + heapProfiler.stop(); + assert.throws( + () => { + heapProfiler.v8ProfileV2(); + }, + (err: Error) => { + return err.message === 'Heap profiler is not enabled.'; + } + ); + heapProfiler.start(512, 64); + }); + }); + + describe('getAllocationProfileV2', () => { + it('should return AllocationProfileNode directly', () => { + const root = v8HeapProfiler.getAllocationProfileV2(); + + assert.equal(typeof root.name, 'string'); + assert.equal(typeof root.scriptName, 'string'); + assert.ok(Array.isArray(root.children)); + assert.ok(Array.isArray(root.allocations)); + }); + }); + + describe('profileV2', () => { + it('should produce valid pprof Profile', () => { + const profile = heapProfiler.profileV2(); + + assert.ok(profile.sampleType); + assert.ok(profile.sample); + assert.ok(profile.location); + assert.ok(profile.function); + assert.ok(profile.stringTable); + }); + }); +}); From 4b23c6f8984fc3c5e88c730ee5bce0e43103104a Mon Sep 17 00:00:00 2001 From: ishabi Date: Wed, 11 Feb 2026 10:11:12 +0100 Subject: [PATCH 6/7] Compare memory usage between current and v2 version --- ts/src/heap-profiler.ts | 2 +- ts/test/heap-memory-worker.ts | 66 ++++++++++++++++++++++++++++++++ ts/test/test-heap-profiler-v2.ts | 39 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 ts/test/heap-memory-worker.ts diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index d2ba5d2a..673bbc9b 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -97,7 +97,7 @@ export function convertProfile( }; root = {...rootNode, children: [...rootNode.children, externalNode]}; } else { - root = {...rootNode, children: [...rootNode.children]}; + root = rootNode; } return serializeHeapProfile( root, diff --git a/ts/test/heap-memory-worker.ts b/ts/test/heap-memory-worker.ts new file mode 100644 index 00000000..368848a0 --- /dev/null +++ b/ts/test/heap-memory-worker.ts @@ -0,0 +1,66 @@ +import * as heapProfiler from '../src/heap-profiler'; +import * as v8HeapProfiler from '../src/heap-profiler-bindings'; +import {AllocationProfileNode} from '../src/v8-types'; + +const ALLOCATION_COUNT = 100_000; +const ALLOCATION_SIZE = 200; + +const gc = (global as NodeJS.Global & {gc?: () => void}).gc; +if (!gc) { + throw new Error('Run with --expose-gc flag'); +} + +let keepAlive: object[] = []; + +function generateAllocations(): object[] { + const result: object[] = []; + for (let i = 0; i < ALLOCATION_COUNT; i++) { + result.push({ + id: i, + data: new Array(ALLOCATION_SIZE).fill('Hello, world!'), + nested: {map: new Map([[i, new Array(ALLOCATION_SIZE).fill(i)]])}, + }); + } + return result; +} + +function traverseTree(root: AllocationProfileNode): void { + const stack: AllocationProfileNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.children) { + for (const child of node.children) { + stack.push(child); + } + } + } +} + +function measureMemoryUsage(getProfile: () => AllocationProfileNode): number { + gc!(); + gc!(); + const baseline = process.memoryUsage().heapUsed; + + const profile = getProfile(); + const memoryUsage = process.memoryUsage().heapUsed - baseline; + traverseTree(profile); + + return memoryUsage; +} + +process.on('message', (version: 'v1' | 'v2') => { + heapProfiler.start(128, 128); + keepAlive = generateAllocations(); + + const getProfile = + version === 'v1' + ? v8HeapProfiler.getAllocationProfile + : v8HeapProfiler.getAllocationProfileV2; + + const memoryUsage = measureMemoryUsage(getProfile); + + heapProfiler.stop(); + keepAlive.length = 0; + + process.send!(memoryUsage); +}); diff --git a/ts/test/test-heap-profiler-v2.ts b/ts/test/test-heap-profiler-v2.ts index d701f29d..f11e4037 100644 --- a/ts/test/test-heap-profiler-v2.ts +++ b/ts/test/test-heap-profiler-v2.ts @@ -1,4 +1,5 @@ import {strict as assert} from 'assert'; +import {fork} from 'child_process'; import * as heapProfiler from '../src/heap-profiler'; import * as v8HeapProfiler from '../src/heap-profiler-bindings'; @@ -81,4 +82,42 @@ describe('HeapProfiler V2 API', () => { assert.ok(profile.stringTable); }); }); + + describe('Memory comparison', () => { + interface MemoryResult { + memoryUsage: number; + nodeCount: number; + } + + function measureMemoryInWorker( + version: 'v1' | 'v2' + ): Promise { + return new Promise((resolve, reject) => { + const child = fork('./out/test/heap-memory-worker.js', [], { + execArgv: ['--expose-gc'], + }); + + child.on('message', (result: MemoryResult) => { + resolve(result); + child.kill(); + }); + + child.on('error', reject); + child.send(version); + }); + } + + it('getAllocationProfileV2 should use less initial memory than getAllocationProfile', async () => { + const v1MemoryUsage = await measureMemoryInWorker('v1'); + const v2MemoryUsage = await measureMemoryInWorker('v2'); + + console.log( + `V1 memory usage: ${v1MemoryUsage}, V2 memory usage: ${v2MemoryUsage}` + ); + assert.ok( + v2MemoryUsage < v1MemoryUsage, + `V2 should use less memory: V1=${v1MemoryUsage}, V2=${v2MemoryUsage}` + ); + }).timeout(30000); + }); }); From fbcdce89c7d1115242f127a37009143a350739fc Mon Sep 17 00:00:00 2001 From: ishabi Date: Wed, 11 Feb 2026 14:21:43 +0100 Subject: [PATCH 7/7] compare total heap used --- ts/test/heap-memory-worker.ts | 17 ++++++++++++----- ts/test/test-heap-profiler-v2.ts | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/ts/test/heap-memory-worker.ts b/ts/test/heap-memory-worker.ts index 368848a0..9d05b53f 100644 --- a/ts/test/heap-memory-worker.ts +++ b/ts/test/heap-memory-worker.ts @@ -36,16 +36,23 @@ function traverseTree(root: AllocationProfileNode): void { } } -function measureMemoryUsage(getProfile: () => AllocationProfileNode): number { +function measureMemoryUsage(getProfile: () => AllocationProfileNode): { + initial: number; + afterTraversal: number; +} { gc!(); gc!(); const baseline = process.memoryUsage().heapUsed; const profile = getProfile(); - const memoryUsage = process.memoryUsage().heapUsed - baseline; + const initial = process.memoryUsage().heapUsed - baseline; + traverseTree(profile); - return memoryUsage; + return { + initial, + afterTraversal: process.memoryUsage().heapUsed - baseline, + }; } process.on('message', (version: 'v1' | 'v2') => { @@ -57,10 +64,10 @@ process.on('message', (version: 'v1' | 'v2') => { ? v8HeapProfiler.getAllocationProfile : v8HeapProfiler.getAllocationProfileV2; - const memoryUsage = measureMemoryUsage(getProfile); + const {initial, afterTraversal} = measureMemoryUsage(getProfile); heapProfiler.stop(); keepAlive.length = 0; - process.send!(memoryUsage); + process.send!({initial, afterTraversal}); }); diff --git a/ts/test/test-heap-profiler-v2.ts b/ts/test/test-heap-profiler-v2.ts index f11e4037..3108e7b2 100644 --- a/ts/test/test-heap-profiler-v2.ts +++ b/ts/test/test-heap-profiler-v2.ts @@ -85,8 +85,8 @@ describe('HeapProfiler V2 API', () => { describe('Memory comparison', () => { interface MemoryResult { - memoryUsage: number; - nodeCount: number; + initial: number; + afterTraversal: number; } function measureMemoryInWorker( @@ -112,11 +112,18 @@ describe('HeapProfiler V2 API', () => { const v2MemoryUsage = await measureMemoryInWorker('v2'); console.log( - `V1 memory usage: ${v1MemoryUsage}, V2 memory usage: ${v2MemoryUsage}` + ` V1 initial: ${v1MemoryUsage.initial}, afterTraversal: ${v1MemoryUsage.afterTraversal} + | V2 initial: ${v2MemoryUsage.initial}, afterTraversal: ${v2MemoryUsage.afterTraversal}` ); + + assert.ok( + v2MemoryUsage.initial < v1MemoryUsage.initial, + `V2 initial should be less: V1=${v1MemoryUsage.initial}, V2=${v2MemoryUsage.initial}` + ); + assert.ok( - v2MemoryUsage < v1MemoryUsage, - `V2 should use less memory: V1=${v1MemoryUsage}, V2=${v2MemoryUsage}` + v2MemoryUsage.afterTraversal < v1MemoryUsage.afterTraversal, + `V2 afterTraversal should be less: V1=${v1MemoryUsage.afterTraversal}, V2=${v2MemoryUsage.afterTraversal}` ); }).timeout(30000); });