From 53faf4a22f15728888a33e74473cabf0e66e3779 Mon Sep 17 00:00:00 2001 From: ishabi Date: Tue, 2 Jun 2026 16:08:04 +0200 Subject: [PATCH] feat: Allocation profiler --- binding.gyp | 2 + bindings/allocation-profile.cc | 99 +++++++++++++++++++++ bindings/allocation-profile.hh | 47 ++++++++++ bindings/profilers/heap.cc | 78 ++++++++++++++--- bindings/test/binding.cc | 53 +++++++++++- bindings/translate-heap-profile.cc | 34 ++++++++ bindings/translate-heap-profile.hh | 5 ++ ts/src/heap-profiler-bindings.ts | 11 ++- ts/src/heap-profiler.ts | 73 ++++++++++++---- ts/src/profile-serializer.ts | 107 +++++++++++++++++++++-- ts/src/v8-types.ts | 10 +++ ts/test/test-heap-profiler.ts | 133 +++++++++++++++++++++++++++-- ts/test/test-profile-serializer.ts | 91 +++++++++++++++++++- 13 files changed, 693 insertions(+), 50 deletions(-) create mode 100644 bindings/allocation-profile.cc create mode 100644 bindings/allocation-profile.hh diff --git a/binding.gyp b/binding.gyp index 2594f898..3b650daf 100644 --- a/binding.gyp +++ b/binding.gyp @@ -20,6 +20,7 @@ "bindings/translate-time-profile.cc", "bindings/binding.cc", "bindings/map-get.cc", + "bindings/allocation-profile.cc", "bindings/allocation-profile-node.cc" ], "include_dirs": [ @@ -44,6 +45,7 @@ "bindings/translate-heap-profile.cc", "bindings/translate-time-profile.cc", "bindings/test/binding.cc", + "bindings/allocation-profile.cc", "bindings/allocation-profile-node.cc" ], "include_dirs": [ diff --git a/bindings/allocation-profile.cc b/bindings/allocation-profile.cc new file mode 100644 index 00000000..ed2c9be0 --- /dev/null +++ b/bindings/allocation-profile.cc @@ -0,0 +1,99 @@ +/* + * Copyright 2026 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.hh" + +#include + +#include + +using namespace v8; + +namespace dd { +namespace { +Local CreateAllocationObject(Isolate* isolate, + size_t size, + const AllocationProfileNodeStats& stats) { + Local alloc_obj = Object::New(isolate); + Nan::Set(alloc_obj, + String::NewFromUtf8Literal(isolate, "sizeBytes"), + Number::New(isolate, static_cast(stats.live_size))); + Nan::Set(alloc_obj, + String::NewFromUtf8Literal(isolate, "count"), + Number::New(isolate, static_cast(stats.live_count))); + Nan::Set(alloc_obj, + String::NewFromUtf8Literal(isolate, "allocSpaceBytes"), + Number::New(isolate, static_cast(stats.total_size))); + Nan::Set(alloc_obj, + String::NewFromUtf8Literal(isolate, "allocObjects"), + Number::New(isolate, static_cast(stats.total_count))); + return alloc_obj; +} +} // namespace + +AllocationProfileNodeStatsMap BuildAllocationStatsByNodeId( + const std::vector& samples) { + AllocationProfileNodeStatsMap stats_by_node_id; + for (const auto& sample : samples) { + auto& stats = stats_by_node_id[sample.node_id][sample.size]; + stats.total_count += sample.count; + +#if NODE_MAJOR_VERSION >= 26 + if (sample.is_live) { + stats.live_count += sample.count; + } +#endif + } + + for (auto& node_stats : stats_by_node_id) { + for (auto& size_stats : node_stats.second) { + const auto size = size_stats.first; + auto& stats = size_stats.second; + stats.live_size = stats.live_count * static_cast(size); + stats.total_size = stats.total_count * static_cast(size); + } + } + + return stats_by_node_id; +} + +Local TranslateAllocationStats( + const AllocationProfileSizeStatsMap* allocation_stats) { + auto* isolate = Isolate::GetCurrent(); + auto context = isolate->GetCurrentContext(); + + if (!allocation_stats || allocation_stats->empty()) { + return Array::New(isolate, 0); + } + + std::vector sizes; + sizes.reserve(allocation_stats->size()); + for (const auto& allocation : *allocation_stats) { + sizes.push_back(allocation.first); + } + std::sort(sizes.begin(), sizes.end()); + + Local arr = Array::New(isolate, sizes.size()); + for (size_t i = 0; i < sizes.size(); i++) { + const auto size = sizes[i]; + const auto& stats = allocation_stats->at(size); + arr->Set(context, i, CreateAllocationObject(isolate, size, stats)).Check(); + } + + return arr; +} + +} // namespace dd diff --git a/bindings/allocation-profile.hh b/bindings/allocation-profile.hh new file mode 100644 index 00000000..ebab8c5c --- /dev/null +++ b/bindings/allocation-profile.hh @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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 +#include +#include +#include + +namespace dd { + +struct AllocationProfileNodeStats { + uint64_t live_count = 0; + uint64_t total_count = 0; + uint64_t live_size = 0; + uint64_t total_size = 0; +}; + +using AllocationProfileSizeStatsMap = + std::unordered_map; +using AllocationProfileNodeStatsMap = + std::unordered_map; + +AllocationProfileNodeStatsMap BuildAllocationStatsByNodeId( + const std::vector& samples); + +v8::Local TranslateAllocationStats( + const AllocationProfileSizeStatsMap* allocation_stats); + +} // namespace dd diff --git a/bindings/profilers/heap.cc b/bindings/profilers/heap.cc index a5e3c435..92e26161 100644 --- a/bindings/profilers/heap.cc +++ b/bindings/profilers/heap.cc @@ -16,6 +16,8 @@ #include "heap.hh" +#include "allocation-profile-node.hh" +#include "allocation-profile.hh" #include "defer.hh" #include "per-isolate-data.hh" #include "translate-heap-profile.hh" @@ -28,7 +30,6 @@ #include #include -#include "allocation-profile-node.hh" namespace dd { @@ -121,6 +122,7 @@ struct HeapProfilerState { uv_async_t* async = nullptr; std::shared_ptr profile; std::vector export_command; + bool allocations = false; bool dumpProfileOnStderr = false; Nan::Callback callback; uint32_t callbackMode = 0; @@ -453,22 +455,49 @@ NAN_METHOD(HeapProfiler::StartSamplingHeapProfiler) { } } - if (info.Length() == 2) { + bool allocations = false; + + if (info.Length() == 2 || info.Length() == 3) { if (!info[0]->IsUint32()) { return Nan::ThrowTypeError("First argument type must be uint32."); } if (!info[1]->IsNumber()) { - return Nan::ThrowTypeError("First argument type must be Integer."); + return Nan::ThrowTypeError("Second argument type must be Integer."); + } + if (info.Length() == 3 && !info[2]->IsBoolean()) { + return Nan::ThrowTypeError("Third argument type must be boolean."); } uint64_t sample_interval = info[0].As()->Value(); int stack_depth = info[1].As()->Value(); + allocations = info.Length() == 3 && info[2].As()->Value(); +#if NODE_MAJOR_VERSION < 26 + if (allocations) { + return Nan::ThrowError( + "Allocation profiling requires Node.js 26 or newer."); + } +#endif + auto flags = v8::HeapProfiler::kSamplingNoFlags; +#if NODE_MAJOR_VERSION >= 26 + if (allocations) { + flags = static_cast( + v8::HeapProfiler::kSamplingForceGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMajorGC | + v8::HeapProfiler::kSamplingIncludeObjectsCollectedByMinorGC); + } +#endif - isolate->GetHeapProfiler()->StartSamplingHeapProfiler(sample_interval, - stack_depth); + isolate->GetHeapProfiler()->StartSamplingHeapProfiler( + sample_interval, stack_depth, flags); } else { isolate->GetHeapProfiler()->StartSamplingHeapProfiler(); } + + auto& state = PerIsolateData::For(isolate)->GetHeapProfilerState(); + if (!state) { + state = std::make_shared(isolate); + } + state->allocations = allocations; } // Signature: @@ -492,17 +521,26 @@ NAN_METHOD(HeapProfiler::StopSamplingHeapProfiler) { // getAllocationProfile(): AllocationProfileNode NAN_METHOD(HeapProfiler::GetAllocationProfile) { auto isolate = info.GetIsolate(); + auto& state = PerIsolateData::For(isolate)->GetHeapProfilerState(); + std::unique_ptr profile( isolate->GetHeapProfiler()->GetAllocationProfile()); if (!profile) { return Nan::ThrowError("Heap profiler is not enabled."); } + if (!state) { + state = std::make_shared(isolate); + } + const bool allocations = state->allocations; v8::AllocationProfile::Node* root = profile->GetRootNode(); - auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); - if (state) { - state->OnNewProfile(); + AllocationProfileNodeStatsMap allocation_stats; + if (allocations) { + allocation_stats = BuildAllocationStatsByNodeId(profile->GetSamples()); } - info.GetReturnValue().Set(TranslateAllocationProfile(root)); + + state->OnNewProfile(); + info.GetReturnValue().Set(TranslateAllocationProfile( + root, allocations ? &allocation_stats : nullptr)); } // mapAllocationProfile(callback): callback result @@ -512,6 +550,11 @@ NAN_METHOD(HeapProfiler::MapAllocationProfile) { } auto isolate = info.GetIsolate(); auto callback = info[0].As(); + auto& state = PerIsolateData::For(isolate)->GetHeapProfilerState(); + if (state && state->allocations) { + return Nan::ThrowError( + "mapAllocationProfile does not support allocation mode."); + } std::unique_ptr profile( isolate->GetHeapProfiler()->GetAllocationProfile()); @@ -519,12 +562,12 @@ NAN_METHOD(HeapProfiler::MapAllocationProfile) { if (!profile) { return Nan::ThrowError("Heap profiler is not enabled."); } - - auto state = PerIsolateData::For(isolate)->GetHeapProfilerState(); - if (state) { - state->OnNewProfile(); + if (!state) { + state = std::make_shared(isolate); } + state->OnNewProfile(); + auto root = AllocationProfileNodeView::New(profile->GetRootNode()); v8::Local argv[] = {root}; auto result = @@ -564,7 +607,14 @@ NAN_METHOD(HeapProfiler::MonitorOutOfMemory) { auto isolate = v8::Isolate::GetCurrent(); auto& state = PerIsolateData::For(isolate)->GetHeapProfilerState(); - state = std::make_shared(isolate); + if (!state) { + state = std::make_shared(isolate); + } + + state->current_heap_extension_count = 0; + state->profile.reset(); + state->export_command.clear(); + state->callback.Reset(); state->heap_extension_size = info[0].As()->Value(); state->max_heap_extension_count = info[1].As()->Value(); diff --git a/bindings/test/binding.cc b/bindings/test/binding.cc index 22ca5f13..ada4141c 100644 --- a/bindings/test/binding.cc +++ b/bindings/test/binding.cc @@ -17,12 +17,61 @@ #include #include #include +#include +#include "allocation-profile.hh" #include "nan.h" #include "node.h" +#include "node_version.h" #include "tap.h" #include "v8.h" +namespace { +v8::AllocationProfile::Sample MakeAllocationSample(uint32_t node_id, + size_t size, + unsigned int count, + uint64_t sample_id, + bool is_live) { +#if NODE_MAJOR_VERSION >= 26 + return {node_id, size, count, sample_id, is_live}; +#else + (void)is_live; + return {node_id, size, count, sample_id}; +#endif +} + +void TestBuildAllocationStatsByNodeId(Tap& t) { + std::vector samples = { + MakeAllocationSample(1, 100, 3, 1, true), + MakeAllocationSample(1, 100, 2, 2, false), + MakeAllocationSample(1, 50, 5, 3, true), + MakeAllocationSample(2, 100, 7, 4, true), + }; + + auto stats_by_node_id = dd::BuildAllocationStatsByNodeId(samples); + t.equal(stats_by_node_id.size(), static_cast(2)); + + const auto& node1_size100 = stats_by_node_id.at(1).at(100); + t.equal(node1_size100.total_count, static_cast(5)); + t.equal(node1_size100.total_size, static_cast(500)); +#if NODE_MAJOR_VERSION >= 26 + t.equal(node1_size100.live_count, static_cast(3)); + t.equal(node1_size100.live_size, static_cast(300)); +#else + t.equal(node1_size100.live_count, static_cast(0)); + t.equal(node1_size100.live_size, static_cast(0)); +#endif + + const auto& node1_size50 = stats_by_node_id.at(1).at(50); + t.equal(node1_size50.total_count, static_cast(5)); + t.equal(node1_size50.total_size, static_cast(250)); + + const auto& node2_size100 = stats_by_node_id.at(2).at(100); + t.equal(node2_size100.total_count, static_cast(7)); + t.equal(node2_size100.total_size, static_cast(700)); +} +} // namespace + #if defined(__GNUC__) && !defined(__clang__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wcast-function-type" @@ -36,7 +85,9 @@ NODE_MODULE_INIT(/* exports, module, context */) { const char* env_var = std::getenv("TEST"); std::string name(env_var == nullptr ? "" : env_var); - std::unordered_map> tests = {}; + std::unordered_map> tests = { + {"BuildAllocationStatsByNodeId", TestBuildAllocationStatsByNodeId}, + }; if (name.empty()) { t.plan(tests.size()); diff --git a/bindings/translate-heap-profile.cc b/bindings/translate-heap-profile.cc index 41f5e129..5e5f59ed 100644 --- a/bindings/translate-heap-profile.cc +++ b/bindings/translate-heap-profile.cc @@ -65,6 +65,33 @@ class HeapProfileTranslator : ProfileTranslator { allocations); } + v8::Local TranslateAllocationProfile( + v8::AllocationProfile::Node* node, + const AllocationProfileNodeStatsMap* allocation_stats) { + if (!allocation_stats) { + return TranslateAllocationProfile(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], allocation_stats)); + } + + auto node_stats = allocation_stats->find(node->node_id); + v8::Local allocations = TranslateAllocationStats( + node_stats == allocation_stats->end() ? nullptr : &node_stats->second); + + return CreateNode(node->name, + node->script_name, + NewInteger(node->script_id), + NewInteger(node->line_number), + NewInteger(node->column_number), + children, + allocations); + } + v8::Local TranslateAllocationProfile(Node* node) { v8::Local children = NewArray(node->children.size()); for (size_t i = 0; i < node->children.size(); i++) { @@ -147,6 +174,13 @@ v8::Local TranslateAllocationProfile( return HeapProfileTranslator().TranslateAllocationProfile(node); } +v8::Local TranslateAllocationProfile( + v8::AllocationProfile::Node* node, + const AllocationProfileNodeStatsMap* allocation_stats) { + return HeapProfileTranslator().TranslateAllocationProfile(node, + allocation_stats); +} + v8::Local TranslateAllocationProfile(Node* node) { return HeapProfileTranslator().TranslateAllocationProfile(node); } diff --git a/bindings/translate-heap-profile.hh b/bindings/translate-heap-profile.hh index e913dd66..e62f14f4 100644 --- a/bindings/translate-heap-profile.hh +++ b/bindings/translate-heap-profile.hh @@ -16,6 +16,8 @@ #pragma once +#include "allocation-profile.hh" + #include #include #include @@ -41,5 +43,8 @@ std::shared_ptr TranslateAllocationProfileToCpp( v8::Local TranslateAllocationProfile(Node* node); v8::Local TranslateAllocationProfile( v8::AllocationProfile::Node* node); +v8::Local TranslateAllocationProfile( + v8::AllocationProfile::Node* node, + const AllocationProfileNodeStatsMap* allocation_stats); } // namespace dd diff --git a/ts/src/heap-profiler-bindings.ts b/ts/src/heap-profiler-bindings.ts index 50d0e97b..9ca439c6 100644 --- a/ts/src/heap-profiler-bindings.ts +++ b/ts/src/heap-profiler-bindings.ts @@ -16,7 +16,10 @@ import * as path from 'path'; -import {AllocationProfileNode} from './v8-types'; +import { + AllocationProfileNode, + AllocationProfileNodeWithStats, +} from './v8-types'; const findBinding = require('node-gyp-build'); const profiler = findBinding(path.join(__dirname, '..', '..')); @@ -26,10 +29,12 @@ const profiler = findBinding(path.join(__dirname, '..', '..')); export function startSamplingHeapProfiler( heapIntervalBytes: number, heapStackDepth: number, + allocations = false, ) { profiler.heapProfiler.startSamplingHeapProfiler( heapIntervalBytes, heapStackDepth, + allocations, ); } @@ -37,7 +42,9 @@ export function stopSamplingHeapProfiler() { profiler.heapProfiler.stopSamplingHeapProfiler(); } -export function getAllocationProfile(): AllocationProfileNode { +export function getAllocationProfile(): + | AllocationProfileNode + | AllocationProfileNodeWithStats { return profiler.heapProfiler.getAllocationProfile(); } diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index bb0f9c57..cdb2c3ce 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -27,13 +27,14 @@ import {serializeHeapProfile} from './profile-serializer'; import {SourceMapper} from './sourcemapper/sourcemapper'; import { AllocationProfileNode, + AllocationProfileNodeWithStats, GenerateAllocationLabelsFunction, } from './v8-types'; import {isMainThread} from 'worker_threads'; let enabled = false; let heapIntervalBytes = 0; -let heapStackDepth = 0; +let startedWithAllocations = false; /* * Collects a heap profile when heapProfiler is enabled. Otherwise throws @@ -41,7 +42,9 @@ let heapStackDepth = 0; * * Data is returned in V8 allocation profile format. */ -export function v8Profile(): AllocationProfileNode { +export function v8Profile(): + | AllocationProfileNode + | AllocationProfileNodeWithStats { if (!enabled) { throw new Error('Heap profiler is not enabled.'); } @@ -56,8 +59,10 @@ export function v8Profile(): AllocationProfileNode { * WARNING: Nodes in the tree are only valid during the callback. Do not store * references to them. The memory is freed when the callback returns. * - * @param callback - function to convert the heap profiler to a converted profile - * @returns converted profile + * @param callback - function to convert the heap profiler to a converted + |file + * @returns c + verted profile */ export function v8ProfileV2( callback: (root: AllocationProfileNode) => T, @@ -65,6 +70,9 @@ export function v8ProfileV2( if (!enabled) { throw new Error('Heap profiler is not enabled.'); } + if (startedWithAllocations) { + throw new Error('profileV2 does not support allocation mode.'); + } return mapAllocationProfile(callback); } @@ -89,26 +97,49 @@ export function profile( } export function convertProfile( - rootNode: AllocationProfileNode, + rootNode: AllocationProfileNode | AllocationProfileNodeWithStats, ignoreSamplePath?: string, sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, ): Profile { + const allocations = startedWithAllocations; const startTimeNanos = Date.now() * 1000 * 1000; // Add node for external memory usage. // Current type definitions do not have external. // 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; + let root: AllocationProfileNode | AllocationProfileNodeWithStats; if (external > 0) { - const externalNode: AllocationProfileNode = { - name: '(external)', - scriptName: '', - children: [], - allocations: [{sizeBytes: external, count: 1}], - }; - root = {...rootNode, children: [...rootNode.children, externalNode]}; + if (allocations) { + const externalNode: AllocationProfileNodeWithStats = { + name: '(external)', + scriptName: '', + children: [], + allocations: [ + { + sizeBytes: external, + count: 1, + allocObjects: 1, + allocSpaceBytes: external, + }, + ], + }; + const allocationRoot = rootNode as AllocationProfileNodeWithStats; + root = { + ...allocationRoot, + children: [...allocationRoot.children, externalNode], + }; + } else { + const externalNode: AllocationProfileNode = { + name: '(external)', + scriptName: '', + children: [], + allocations: [{sizeBytes: external, count: 1}], + }; + const heapRoot = rootNode as AllocationProfileNode; + root = {...heapRoot, children: [...heapRoot.children, externalNode]}; + } } else { root = rootNode; } @@ -119,6 +150,7 @@ export function convertProfile( ignoreSamplePath, sourceMapper, generateLabels, + allocations, ); } @@ -147,16 +179,24 @@ export function profileV2( * * @param intervalBytes - average number of bytes between samples. * @param stackDepth - maximum stack depth for samples collected. + * @param allocations - include total allocation samples in the profile. + * When true, each call to `profile()` forces a full GC (via + * v8::HeapProfiler::kSamplingForceGC) to classify sampled objects as + * live/dead, which adds a measurable pause. Requires Node.js >= 26. */ -export function start(intervalBytes: number, stackDepth: number) { +export function start( + intervalBytes: number, + stackDepth: number, + allocations = false, +) { if (enabled) { throw new Error( `Heap profiler is already started with intervalBytes ${heapIntervalBytes} and stackDepth ${stackDepth}`, ); } heapIntervalBytes = intervalBytes; - heapStackDepth = stackDepth; - startSamplingHeapProfiler(heapIntervalBytes, heapStackDepth); + startedWithAllocations = allocations; + startSamplingHeapProfiler(intervalBytes, stackDepth, allocations); enabled = true; } @@ -164,6 +204,7 @@ export function start(intervalBytes: number, stackDepth: number) { export function stop() { if (enabled) { enabled = false; + startedWithAllocations = false; stopSamplingHeapProfiler(); } } diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index c104ddf7..10bdb048 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -32,7 +32,10 @@ import { SourceMapper, } from './sourcemapper/sourcemapper'; import { + Allocation, + AllocationWithStats, AllocationProfileNode, + AllocationProfileNodeWithStats, GenerateAllocationLabelsFunction, GenerateTimeLabelsFunction, ProfileNode, @@ -75,6 +78,16 @@ function isGeneratedLocation( ); } +function requireAllocationStat( + value: number | undefined, + field: string, +): number { + if (value === undefined) { + throw new Error(`Allocation profile is missing ${field}.`); + } + return value; +} + /** * Takes v8 profile and populates sample, location, and function fields of * profile.proto. @@ -268,6 +281,34 @@ function createAllocationValueType(table: StringTable): ValueType { }); } +function createInUseObjectCountValueType(table: StringTable): ValueType { + return new ValueType({ + type: table.dedup('inuse_objects'), + unit: table.dedup('count'), + }); +} + +function createAllocatedObjectCountValueType(table: StringTable): ValueType { + return new ValueType({ + type: table.dedup('alloc_objects'), + unit: table.dedup('count'), + }); +} + +function createInUseSpaceValueType(table: StringTable): ValueType { + return new ValueType({ + type: table.dedup('inuse_space'), + unit: table.dedup('bytes'), + }); +} + +function createAllocatedSpaceValueType(table: StringTable): ValueType { + return new ValueType({ + type: table.dedup('alloc_space'), + unit: table.dedup('bytes'), + }); +} + export function computeTotalHitCount(root: TimeProfileNode): number { return ( root.hitCount + @@ -530,24 +571,63 @@ function buildLabels(labelSet: object, stringTable: StringTable): Label[] { * @param intervalBytes - bytes allocated between samples. */ export function serializeHeapProfile( - prof: AllocationProfileNode, + prof: AllocationProfileNode | AllocationProfileNodeWithStats, startTimeNanos: number, intervalBytes: number, ignoreSamplesPath?: string, sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, + allocations = false, ): Profile { const appendHeapEntryToSamples: AppendEntryToSamples< - AllocationProfileNode - > = (entry: Entry, samples: Sample[]) => { + AllocationProfileNode | AllocationProfileNodeWithStats + > = ( + entry: Entry, + samples: Sample[], + ) => { if (entry.node.allocations.length > 0) { const labels = generateLabels - ? buildLabels(generateLabels({node: entry.node}), stringTable) + ? buildLabels( + generateLabels({node: entry.node as AllocationProfileNode}), + stringTable, + ) : []; for (const alloc of entry.node.allocations) { + // Live heap only + if (!allocations) { + const heapAllocation = alloc as Allocation; + const sample = new Sample({ + locationId: entry.stack, + value: [ + heapAllocation.count, + heapAllocation.sizeBytes * heapAllocation.count, + ], + label: labels, + // TODO: add tag for allocation size + }); + samples.push(sample); + continue; + } + + // Allocation mode reuses count/sizeBytes for in-use values and adds + // total allocation counters. + const allocationStats = alloc as AllocationWithStats; + const allocObjects = requireAllocationStat( + allocationStats.allocObjects, + 'allocObjects', + ); + const allocSpaceBytes = requireAllocationStat( + allocationStats.allocSpaceBytes, + 'allocSpaceBytes', + ); const sample = new Sample({ locationId: entry.stack, - value: [alloc.count, alloc.sizeBytes * alloc.count], + value: [ + allocationStats.count, + allocObjects, + allocationStats.sizeBytes, + allocSpaceBytes, + ], label: labels, // TODO: add tag for allocation size }); @@ -557,13 +637,22 @@ export function serializeHeapProfile( }; const stringTable = new StringTable(); - const sampleValueType = createObjectCountValueType(stringTable); - const allocationValueType = createAllocationValueType(stringTable); + const sampleTypes = allocations + ? [ + createInUseObjectCountValueType(stringTable), + createAllocatedObjectCountValueType(stringTable), + createInUseSpaceValueType(stringTable), + createAllocatedSpaceValueType(stringTable), + ] + : [ + createObjectCountValueType(stringTable), + createAllocationValueType(stringTable), + ]; const profile = { - sampleType: [sampleValueType, allocationValueType], + sampleType: sampleTypes, timeNanos: startTimeNanos, - periodType: allocationValueType, + periodType: sampleTypes[sampleTypes.length - 1], period: intervalBytes, }; diff --git a/ts/src/v8-types.ts b/ts/src/v8-types.ts index 87fbc333..0a342dbb 100644 --- a/ts/src/v8-types.ts +++ b/ts/src/v8-types.ts @@ -60,6 +60,16 @@ export interface Allocation { sizeBytes: number; count: number; } + +export interface AllocationWithStats extends Allocation { + allocObjects?: number; + allocSpaceBytes?: number; +} + +export interface AllocationProfileNodeWithStats extends ProfileNode { + allocations: AllocationWithStats[]; + children: AllocationProfileNodeWithStats[]; +} export interface LabelSet { [key: string]: string | number; } diff --git a/ts/test/test-heap-profiler.ts b/ts/test/test-heap-profiler.ts index 21fcddc2..c9352a24 100644 --- a/ts/test/test-heap-profiler.ts +++ b/ts/test/test-heap-profiler.ts @@ -18,7 +18,11 @@ import * as sinon from 'sinon'; import * as heapProfiler from '../src/heap-profiler'; import * as v8HeapProfiler from '../src/heap-profiler-bindings'; -import {AllocationProfileNode, LabelSet} from '../src/v8-types'; +import { + AllocationProfileNode, + AllocationProfileNodeWithStats, + LabelSet, +} from '../src/v8-types'; import {fork} from 'child_process'; import path from 'path'; import fs from 'fs'; @@ -35,10 +39,55 @@ import { const copy = require('deep-copy'); const assert = require('assert'); +function withAllocationStats( + node: AllocationProfileNode, +): AllocationProfileNodeWithStats { + return { + ...node, + allocations: node.allocations.map(alloc => ({ + count: alloc.count, + sizeBytes: alloc.sizeBytes * alloc.count, + allocObjects: alloc.count, + allocSpaceBytes: alloc.sizeBytes * alloc.count, + })), + children: node.children.map(withAllocationStats), + }; +} + +const v8AllocationProfile: AllocationProfileNodeWithStats = { + name: '(root)', + scriptName: '(root)', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + allocations: [], + children: [ + { + name: 'allocatingFunction', + scriptName: 'script1', + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [ + { + count: 4, + sizeBytes: 400, + allocObjects: 10, + allocSpaceBytes: 1000, + }, + ], + children: [], + }, + ], +}; + describe('HeapProfiler', () => { - let startStub: sinon.SinonStub<[number, number], void>; + let startStub: sinon.SinonStub<[number, number, boolean?], void>; let stopStub: sinon.SinonStub<[], void>; - let profileStub: sinon.SinonStub<[], AllocationProfileNode>; + let profileStub: sinon.SinonStub< + [], + AllocationProfileNode | AllocationProfileNodeWithStats + >; let dateStub: sinon.SinonStub<[], number>; let memoryUsageStub: sinon.SinonStub<[], NodeJS.MemoryUsage>; beforeEach(() => { @@ -51,9 +100,9 @@ describe('HeapProfiler', () => { heapProfiler.stop(); startStub.restore(); stopStub.restore(); - profileStub.restore(); + profileStub?.restore(); dateStub.restore(); - memoryUsageStub.restore(); + memoryUsageStub?.restore(); }); describe('profile', () => { it('should return a profile equal to the expected profile when external memory is allocated', async () => { @@ -131,6 +180,67 @@ describe('HeapProfiler', () => { assert.deepEqual(heapProfileIncludePathWithLabels, profile); }); + it('should use allocation profile mode when allocations is passed', async () => { + profileStub = sinon + .stub(v8HeapProfiler, 'getAllocationProfile') + .returns(copy(v8AllocationProfile)); + memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ + external: 0, + rss: 2048, + heapTotal: 4096, + heapUsed: 2048, + arrayBuffers: 512, + }); + const intervalBytes = 1024 * 512; + const stackDepth = 32; + heapProfiler.start(intervalBytes, stackDepth, true); + const profile = heapProfiler.profile(); + const sampleTypeNames = profile.sampleType.map( + sampleType => profile.stringTable.strings[Number(sampleType.type)], + ); + assert.deepEqual(sampleTypeNames, [ + 'inuse_objects', + 'alloc_objects', + 'inuse_space', + 'alloc_space', + ]); + assert.deepEqual(profile.sample[0].value, [4, 10, 400, 1000]); + assert.equal(profileStub.calledOnce, true); + assert.equal(profileStub.firstCall.args.length, 0); + }); + + it('should preserve allocation stats for external memory in allocation mode', async () => { + profileStub = sinon + .stub(v8HeapProfiler, 'getAllocationProfile') + .returns(withAllocationStats(copy(v8HeapProfile))); + memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ + external: 1024, + rss: 2048, + heapTotal: 4096, + heapUsed: 2048, + arrayBuffers: 512, + }); + const intervalBytes = 1024 * 512; + const stackDepth = 32; + heapProfiler.start(intervalBytes, stackDepth, true); + const profile = heapProfiler.profile(); + assert.ok(profile.sample.some(sample => sample.value[2] === 1024)); + }); + + it('should throw when profileV2 is requested from allocation mode', async () => { + const intervalBytes = 1024 * 512; + const stackDepth = 32; + heapProfiler.start(intervalBytes, stackDepth, true); + assert.throws( + () => { + heapProfiler.profileV2(); + }, + (err: Error) => { + return err.message === 'profileV2 does not support allocation mode.'; + }, + ); + }); + it('should throw error when not started', () => { assert.throws( () => { @@ -164,16 +274,25 @@ describe('HeapProfiler', () => { const stackDepth1 = 32; heapProfiler.start(intervalBytes1, stackDepth1); assert.ok( - startStub.calledWith(intervalBytes1, stackDepth1), + startStub.calledWith(intervalBytes1, stackDepth1, false), 'expected startSamplingHeapProfiler to be called', ); }); + it('should pass allocations to startSamplingHeapProfiler', () => { + const intervalBytes1 = 1024 * 512; + const stackDepth1 = 32; + heapProfiler.start(intervalBytes1, stackDepth1, true); + assert.ok( + startStub.calledWith(intervalBytes1, stackDepth1, true), + 'expected startSamplingHeapProfiler to be called with allocations', + ); + }); it('should throw error when enabled and started with different parameters', () => { const intervalBytes1 = 1024 * 512; const stackDepth1 = 32; heapProfiler.start(intervalBytes1, stackDepth1); assert.ok( - startStub.calledWith(intervalBytes1, stackDepth1), + startStub.calledWith(intervalBytes1, stackDepth1, false), 'expected startSamplingHeapProfiler to be called', ); startStub.resetHistory(); diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index 7450e118..c18a6397 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -23,7 +23,11 @@ import { } from '../src/profile-serializer'; import {SourceMapper} from '../src/sourcemapper/sourcemapper'; import {Label, Profile} from 'pprof-format'; -import {TimeProfile, TimeProfileNode} from '../src/v8-types'; +import { + AllocationProfileNodeWithStats, + TimeProfile, + TimeProfileNode, +} from '../src/v8-types'; import { anonymousFunctionHeapProfile, getAndVerifyPresence, @@ -201,6 +205,91 @@ describe('profile-serializer', () => { ); assert.deepEqual(heapProfileOut, anonymousFunctionHeapProfile); }); + it('should emit in-use and allocation values when allocations are enabled', () => { + const prof: AllocationProfileNodeWithStats = { + name: '(root)', + scriptName: '(root)', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + allocations: [], + children: [ + { + name: 'allocatingFunction', + scriptName: 'script1', + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [ + { + count: 4, + sizeBytes: 400, + allocObjects: 10, + allocSpaceBytes: 1000, + }, + ], + children: [], + }, + ], + }; + const heapProfileOut = serializeHeapProfile( + prof, + 0, + 512 * 1024, + undefined, + undefined, + undefined, + true, + ); + const sampleTypeNames = heapProfileOut.sampleType.map( + sampleType => + heapProfileOut.stringTable.strings[Number(sampleType.type)], + ); + assert.deepEqual(sampleTypeNames, [ + 'inuse_objects', + 'alloc_objects', + 'inuse_space', + 'alloc_space', + ]); + assert.deepEqual(heapProfileOut.sample[0].value, [4, 10, 400, 1000]); + }); + + it('should throw when allocation values are missing in allocation mode', () => { + assert.throws( + () => { + serializeHeapProfile( + { + name: '(root)', + scriptName: '(root)', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + allocations: [], + children: [ + { + name: 'allocatingFunction', + scriptName: 'script1', + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [{count: 4, sizeBytes: 400}], + children: [], + }, + ], + }, + 0, + 512 * 1024, + undefined, + undefined, + undefined, + true, + ); + }, + (err: Error) => { + return err.message === 'Allocation profile is missing allocObjects.'; + }, + ); + }); }); describe('source map specified', () => {