Skip to content

Improve heap profile memory usage by lazily loading js objects#260

Merged
IlyasShabi merged 18 commits intomainfrom
ishabi/js-objects-allocations-improvements
Feb 19, 2026
Merged

Improve heap profile memory usage by lazily loading js objects#260
IlyasShabi merged 18 commits intomainfrom
ishabi/js-objects-allocations-improvements

Conversation

@IlyasShabi
Copy link

@IlyasShabi IlyasShabi commented Feb 9, 2026

What does this PR do?:
Introduces a new lazy heap profiling API profileV2 that reduces memory usage during heap profile serialization. Instead of materializing the entire V8 allocation profile as JS object upfront, the new API traverses the profile root tree on-demand

Motivation:

When collecting heap profiles, the current profile() API calls GetAllocationProfile() to get the profile data, then recursively translate the entire profile tree into JS objects, this creates a full JS objects tree in heap memory which can be significant for large apps.
The new profileV2() API introduces a lazy traversal pattern, getting JS object is done on-demand using getters to get children, name, scriptName and others. On the TS side, we only request for current node JS object which allow us to gradually build the tree and reduce memory usage at this operation.
This approach is using a callback to keep V8 local string alive during the entire process

@IlyasShabi IlyasShabi self-assigned this Feb 9, 2026
@IlyasShabi IlyasShabi added the semver-patch Bug or security fixes, mainly label Feb 9, 2026
@github-actions
Copy link

github-actions bot commented Feb 9, 2026

Overall package size

Self size: 1.86 MB
Deduped: 2.23 MB
No deduping: 2.23 MB

Dependency sizes | name | version | self size | total size | |------|---------|-----------|------------| | source-map | 0.7.6 | 185.63 kB | 185.63 kB | | pprof-format | 2.2.1 | 163.06 kB | 163.06 kB | | p-limit | 3.1.0 | 7.75 kB | 13.78 kB | | node-gyp-build | 3.9.0 | 8.81 kB | 8.81 kB |

🤖 This report was automatically generated by heaviest-objects-in-the-universe

@pr-commenter
Copy link

pr-commenter bot commented Feb 9, 2026

Benchmarks

Benchmark execution time: 2026-02-19 11:20:15

Comparing candidate commit a683217 in PR branch ishabi/js-objects-allocations-improvements with baseline commit e7bcdef in branch main.

Found 1 performance improvements and 0 performance regressions! Performance is the same for 88 metrics, 31 unstable metrics.

scenario:profiler-heavy-load-no-wall-profiler-24

  • 🟩 cpu_user_time [-27.846ms; -7.000ms] or [-8.777%; -2.207%]

@IlyasShabi IlyasShabi marked this pull request as ready for review February 9, 2026 09:05
@IlyasShabi IlyasShabi changed the title Improve heap profile memoery usage by lazily loading js objects Improve heap profile memory usage by lazily loading js objects Feb 9, 2026
@nsavoire
Copy link

nsavoire commented Feb 9, 2026

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d2982ce329

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@szegedi
Copy link

szegedi commented Feb 9, 2026

Some thoughts:

  • It's indeed unfortunate that AllocationProfile::Node stores Local<String> values in a data field. I'm a bit wary about duplication of string values (especially script names) if you convert them to std::string. I do think there's a better way, though: create a Node structure that's very similar to the V8 one, but use Global<String> for these fields. This will prevent duplication.
  • You can then convert those globals to locals when you do populateFields(). This will preserve all the deduplication of those strings; this way you never go from a V8 String to a std::string and back, you just go from Local<String> to Global<String> and back, which are very small pointer operations and just keep referencing the already constructed JS string values on the heap.
  • populateFields will also have the unfortunate side-effect of creating a bunch of new strings for keys too. I guess you could create a bunch of NAN_GETTER() methods that on invocation extract the data from the wrapped Node object.
  • Since the serializer is enqueuing all of the children of the current node, we don't really gain anything by having a separate GetChildrenCount/GetChild API. You could thus declare a NAN_GETTER(Children) that creates the array of children, and I think this'd allow you to keep using the existing serialize method in the profile-serializer.ts and then you wouldn't need serializeHeapProfileV2.
  • the concept of the AllocationProfileHolder might be unnecessary. I'd think it should be enough for AllocationNodeWrapper to have a std::shared_ptr<Node> – since they also keep their children through shared pointers, this should all nicely clean itself up once the wrapper to the root gets garbage collected by V8. You don't need to hold the original profile in memory once our C++ tree of nodes was constructed.

@IlyasShabi IlyasShabi marked this pull request as draft February 10, 2026 08:35
};
} // namespace

std::shared_ptr<Node> TranslateAllocationProfileToCpp(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TranslateAllocationProfileToCpp and TranslateAllocationProfileToExternal are ~similar. While templating could reduce duplication, the different string assignment mechanisms would add complexity. Keeping both functions for clarity

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. Maybe add this as a comment in the source code?


export interface AllocationProfileNode extends ProfileNode {
allocations: Allocation[];
children: AllocationProfileNode[];
Copy link
Author

@IlyasShabi IlyasShabi Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing children type bug

@IlyasShabi IlyasShabi force-pushed the ishabi/js-objects-allocations-improvements branch from 7e1921f to b0caf9a Compare February 10, 2026 09:51
@IlyasShabi IlyasShabi marked this pull request as ready for review February 10, 2026 11:53
@nsavoire
Copy link

Currently we have this processing flow for a heap profile:

  • GetAllocationProfile()
    • v8::AllocationProfile (C++)
  • TranslateAllocationProfile()
    • JS profile (node based)
  • serializeHeapProfile
    • pprof-format JS profile (sample based)
  • pprof-format encode
    • protobuf
  • gzip
    • compressed buffer

IIUC the proposed change should decrease the JS memory used during the second step but at the expense of creating a copy of the entire C++ node tree.

It would be nice to have benchmark / test cases that exhibits the behaviour that need fixing and measure the improvement.
Without this, we're essentially optimizing blindly. And I am wary that we might be adding complexity that doesn't solve the actual problem.

@IlyasShabi
Copy link
Author

IlyasShabi commented Feb 11, 2026

I added a benchmark for the second step that:

  • Runs V1 and V2 in separate processes for accurate isolated measurements
  • Generates allocations and traverses the entire tree to load all nodes
  • Measure heapUsed before/after

Results I got from Local and CI across platforms:

V1: ~48 KB | V2: ~40 KB | ~16% of memory usage reduction

After applying callback approach and avoid copying the root to cpp with larger profilers:

GetAllocationProfile: ~3.7 MB | MapAllocationProfile: ~1.75 MB | ~52% of memory usage reduction

@IlyasShabi IlyasShabi marked this pull request as draft February 11, 2026 13:23
@IlyasShabi IlyasShabi marked this pull request as ready for review February 11, 2026 13:42
@szegedi
Copy link

szegedi commented Feb 16, 2026

For your consideration, I think you could also introduce a small encapsulation for the common part in all the getters:

diff --git a/bindings/allocation-profile-node.cc b/bindings/allocation-profile-node.cc
index 876d119..3112db2 100644
--- a/bindings/allocation-profile-node.cc
+++ b/bindings/allocation-profile-node.cc
@@ -19,6 +19,15 @@
 
 namespace dd {
 
+template <typename F>
+void ExternalAllocationNode::mapExternalNode(
+    const Nan::PropertyCallbackInfo<v8::Value>& info,
+    F&& mapper) {
+  auto* wrapper =
+      Nan::ObjectWrap::Unwrap<ExternalAllocationNode>(info.Holder());
+  info.GetReturnValue().Set(mapper(wrapper->node_.get()));
+}
+
 NAN_MODULE_INIT(ExternalAllocationNode::Init) {
   v8::Local<v8::FunctionTemplate> tpl = Nan::New<v8::FunctionTemplate>();
   tpl->SetClassName(Nan::New("AllocationProfileNode").ToLocalChecked());
@@ -58,37 +67,35 @@ v8::Local<v8::Object> ExternalAllocationNode::New(
 }
 
 NAN_GETTER(ExternalAllocationNode::GetName) {
-  auto* wrapper =
-      Nan::ObjectWrap::Unwrap<ExternalAllocationNode>(info.Holder());
-  auto* isolate = v8::Isolate::GetCurrent();
-  info.GetReturnValue().Set(
-      v8::Local<v8::String>::New(isolate, wrapper->node_->name));
+  mapExternalNode(info, [](ExternalNode* node) {
+    auto* isolate = v8::Isolate::GetCurrent();
+    return v8::Local<v8::String>::New(isolate, node->name);
+  });
 }
 
 NAN_GETTER(ExternalAllocationNode::GetScriptName) {
-  auto* wrapper =
-      Nan::ObjectWrap::Unwrap<ExternalAllocationNode>(info.Holder());
-  auto* isolate = v8::Isolate::GetCurrent();
-  info.GetReturnValue().Set(
-      v8::Local<v8::String>::New(isolate, wrapper->node_->script_name));
+  mapExternalNode(info, [](ExternalNode* node) {
+    auto* isolate = v8::Isolate::GetCurrent();
+    return v8::Local<v8::String>::New(isolate, node->script_name);
+  });
 }
 
 NAN_GETTER(ExternalAllocationNode::GetScriptId) {
-  auto* wrapper =
-      Nan::ObjectWrap::Unwrap<ExternalAllocationNode>(info.Holder());
-  info.GetReturnValue().Set(Nan::New(wrapper->node_->script_id));
+  mapExternalNode(info, [](ExternalNode* node) {
+    return Nan::New(node->script_id);
+  });
 }
 
 NAN_GETTER(ExternalAllocationNode::GetLineNumber) {
-  auto* wrapper =
-      Nan::ObjectWrap::Unwrap<ExternalAllocationNode>(info.Holder());
-  info.GetReturnValue().Set(Nan::New(wrapper->node_->line_number));
+  mapExternalNode(info, [](ExternalNode* node) {
+    return Nan::New(node->line_number);
+  });
 }
 
 NAN_GETTER(ExternalAllocationNode::GetColumnNumber) {
-  auto* wrapper =
-      Nan::ObjectWrap::Unwrap<ExternalAllocationNode>(info.Holder());
-  info.GetReturnValue().Set(Nan::New(wrapper->node_->column_number));
+  mapExternalNode(info, [](ExternalNode* node) {
+    return Nan::New(node->column_number);
+  });
 }
 
 NAN_GETTER(ExternalAllocationNode::GetAllocations) {
diff --git a/bindings/allocation-profile-node.hh b/bindings/allocation-profile-node.hh
index 0db6bfb..5c289cf 100644
--- a/bindings/allocation-profile-node.hh
+++ b/bindings/allocation-profile-node.hh
@@ -32,6 +32,11 @@ class ExternalAllocationNode : public Nan::ObjectWrap {
  private:
   ExternalAllocationNode(std::shared_ptr<ExternalNode> node) : node_(node) {}
 
+  template <typename F>
+  static void mapExternalNode(
+      const Nan::PropertyCallbackInfo<v8::Value>& info,
+      F&& mapper);
+
   static NAN_GETTER(GetName);
   static NAN_GETTER(GetScriptName);
   static NAN_GETTER(GetScriptId);

szegedi
szegedi previously approved these changes Feb 17, 2026
Copy link

@szegedi szegedi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! 🎉

@nsavoire
Copy link

Nice work 🔥
Thanks for adding a test measuring the memory used !

nsavoire
nsavoire previously approved these changes Feb 18, 2026
@IlyasShabi IlyasShabi dismissed stale reviews from nsavoire and szegedi via 1a66a02 February 19, 2026 08:00
@nsavoire
Copy link

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a66a02db7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +138 to +139
return v8ProfileV2(root => {
return convertProfile(root, ignoreSamplePath, sourceMapper, generateLabels);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid feeding immutable V2 nodes into convertProfile

profileV2() now forwards the lazy native nodes directly to convertProfile(), but the serializer path still mutates node.scriptName to strip file:// prefixes (see serialize() in ts/src/profile-serializer.ts). The new V2 nodes expose scriptName via getter-only accessors, so in strict mode this assignment throws for ESM/mjs frames, causing heap.profileV2() to fail instead of producing a profile whenever a sampled frame has a file:// script URL.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IlyasShabi
Might be worth checking that.
Weirdly I could not make it throw an error, it seems that setting node.scriptName creates a property on the JS object that bypasses the getter when it is accessed later on.

Copy link
Author

@IlyasShabi IlyasShabi Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nsavoire I think this is because Nan::SetAccessor is read only since we don't set a setter here

I managed to reproduce it with a mock using Object with a getter descriptor rather than a real MJS file. well the last commit is a fix to avoids the mutation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is read only, you need to pass an extra v8::ReadOnly property attribute to SetNativeDataProperty to make it read-only.
With this attribute, I get an error if I try to mutate the property.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it's better to not touch the node at all and pass scriptName as a separate param to getLocation

@nsavoire
Copy link

nsavoire commented Feb 19, 2026

@codex review

@IlyasShabi IlyasShabi merged commit e88303f into main Feb 19, 2026
67 checks passed
@IlyasShabi IlyasShabi deleted the ishabi/js-objects-allocations-improvements branch February 19, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-patch Bug or security fixes, mainly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments