Skip to content

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

Open
IlyasShabi wants to merge 16 commits intomainfrom
ishabi/js-objects-allocations-improvements
Open

Improve heap profile memory usage by lazily loading js objects#260
IlyasShabi wants to merge 16 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-17 15:07:31

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

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

@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
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.

Looks pretty good overall, I have suggestions for some additional memory use reductions, specifically switching from shared pointers to unique ones (yikes!)

};
} // namespace

std::shared_ptr<Node> TranslateAllocationProfileToCpp(
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?

@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);

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! 🎉

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