Memory Tool Architecture

The memory tool is built of three main elements:

  1. The live heap graph exists in memory, and is managed by the C++ allocator and garbage collector. In order to get access to the structure of this graph, a specialized interface is created to represent its state. The JS::ubi::Node is the basis for this representation. This interface can be created from the live heap graph, or a serialized, offline snapshot from a previous moment in time. Our various heap analyses (census, dominator trees, shortest paths, etc) run on top of JS::ubi::Node graphs. The ubi in the name stands for “ubiquitous” and provides a namespace for memory analyses in C++ code.

  2. The HeapAnalysesWorker runs in a worker thread, performing analyses on snapshots and translating the results into something the frontend can render simply and quickly. The HeapAnalysesClient is used to communicate between the worker and the main thread.

  3. Finally, the last element is the frontend that renders data received from the HeapAnalysesClient to the DOM and translates user input into requests for new data with the HeapAnalysesClient.

Unlike other tools (such as the JavaScript debugger), the memory tool makes very little use of the Remote DevTools Server and the actors that reside in it. Use of the MemoryActor is limited to toggling allocation stack recording on and off, and transferring heap snapshots from the debuggee (which is on the server) to the HeapAnalysesWorker (which is on the client). A nice benefit that naturally emerges, is that supporting “legacy” servers (eg, using Firefox Developer Edition as a client to remote debug a release Firefox for Android server) is a no-op. As we add new analyses, we can run them on snapshots taken on old servers no problem. The only requirement is that changes to the snapshot format itself remain backwards compatible.

JS::ubi::Node

JS::ubi::Node is a lightweight serializable interface that can represent the current state of the heap graph. For a deeper dive into the particulars of how it works, it is very well documented in the js/public/UbiNode.h

A “heap snapshot” is a representation of the heap graph at some particular past instance in time.

A “heap analysis” is an algorithm that runs on a JS::ubi::Node heap graph. Generally, analyses can run on either the live heap graph or a deserialized snapshot. Example analyses include “census”, which aggregates and counts nodes into various user-specified buckets; “dominator trees”, which compute the dominates relation and retained size for all nodes in the heap graph; and “shortest paths” which finds the shortest paths from the GC roots to some subset of nodes.

Saving Heap Snapshots

Saving a heap snapshot has a few requirements:

  1. The binary format must remain backwards compatible and future extensible.

  2. The live heap graph must not mutate while we are in the process of serializing it.

  3. The act of saving a heap snapshot should impose as little memory overhead as possible. If we are taking a snapshot to debug frequent out-of-memory errors, we don’t want to trigger an OOM ourselves!

To solve (1), we use the protobuf message format. The message definitions themselves are in devtools/shared/heapsnapshot/CoreDump.proto. We always use optional fields so we can change our mind about what fields are required sometime in the future. Deserialization checks the semantic integrity of deserialized protobuf messages.

For (2), we rely on SpiderMonkey’s GC rooting hazard static analysis and the AutoCheckCannotGC dynamic analysis to ensure that neither JS nor GC runs and modifies objects or moves them from one address in memory to another. There is no equivalent suppression and static analysis technique for the cycle collector, so care must be taken not to invoke methods that could start cycle collection or mutate the heap graph from the cycle collector’s perspective. At the time of writing, we don’t yet support saving the cycle collector’s portion of the heap graph in snapshots, but that work is deemed Very Important and Very High Priority.

Finally, (3) imposes upon us that we do not build the serialized heap snapshot binary blob in memory, but instead stream it out to disk while generating it.

Once all of that is accounted for, saving snapshots becomes pretty straight forward. We traverse the live heap graph with JS::ubi::Node and JS::ubi::BreadthFirst, create a protobuf message for each node and each node’s edges, and write these messages to disk before continuing the traversal to the next node.

This functionality is exposed to chrome JavaScript as the ChromeUtils.saveHeapSnapshot function. See dom/webidl/ChromeUtils.webidl for API documentation.

Reading Heap Snapshots

Reading heap snapshots has less restrictions than saving heap snapshots. The protobuf messages that make up the core dump are deserialized one by one, stored as a set of DeserializedNodes and a set of DeserializedEdges, and the result is a HeapSnapshot instance.

The DeserializedNode and DeserializedEdge classes implement the JS::ubi::Node interface. Analyses running on offline heap snapshots rather than the live heap graph operate on these classes (unknowingly, of course).

For more details, see the mozilla::devtools::HeapSnapshot and mozilla::devtools::Deserialized{Node,Edge} classes.

Heap Analyses

Heap analyses operate on JS::ubi::Node graphs without knowledge of whether that graph is backed by the live heap graph or an offline heap snapshot. They must make sure never to allocate GC things or modify the live heap graph.

In general, analyses are implemented in their own js/public/Ubi{AnalysisName}.h header (eg js/public/UbiCensus.h), and are exposed to chrome JavaScript code via a method on the HeapSnapshot webidl interface.

For each analysis we expose to chrome JavaScript on the HeapSnapshot webidl interface, there is a small amount of glue code in Gecko. The mozilla::devtools::HeapSnapshot C++ class implements the webidl interface. The analyses methods (eg ComputeDominatorTree) take the deserialized nodes and edges from the heap snapshot, create JS::ubi::Nodes from them, call the analyses from js/public/Ubi*.h, and wrap the results in something that can be represented in JavaScript.

For API documentation on running specific analyses, see the HeapSnapshot webidl interface.

Testing JS::ubi::Node, Snapshots, and Analyses

The majority of the tests reside within devtools/shared/heapsnapshot/tests/**. For reading and saving heap snapshots, most tests are gtests. The gtests can be run with the mach gtest DevTools.* command. The rest are integration sanity tests to make sure we can read and save snapshots in various environments, such as xpcshell or workers. These can be run with the usual mach test $PATH commands.

There are also JS::ubi::Node related unit tests in js/src/jit-test/tests/heap-analysis/*, js/src/jit-test/tests/debug/Memory-*, and js/src/jsapi-tests/testUbiNode.cpp. See https://firefox-source-docs.mozilla.org/js/test.html#running-jit-tests-locally for running the JIT tests.

HeapAnalysesWorker

The HeapAnalysesWorker orchestrates running specific analyses on snapshots and transforming the results into something that can simply and quickly be rendered by the frontend. The analyses can take some time to run (sometimes on the order of seconds), so doing them in a worker thread allows the interface to stay responsive. The HeapAnalysisClient provides the main thread’s interface to the worker.

The HeapAnalysesWorker doesn’t actually do much itself; mostly just shuffling data and transforming it from one representation to another or calling C++ utility functions exposed by webidl that do those things. Most of these are implemented as traversals of the resulting census or dominator trees.

See the following files for details on the various data transformations and shuffling that the HeapAnalysesWorker delegates to.

  • devtools/shared/heapsnapshot/CensusUtils.js

  • devtools/shared/heapsnapshot/CensusTreeNode.js

  • devtools/shared/heapsnapshot/DominatorTreeNode.js

Testing the HeapAnalysesWorker and HeapAnalysesClient

Tests for the HeapAnalysesWorker and HeapAnalysesClient reside in devtools/shared/heapsnapshot/tests/** and can be run with the usual mach test $PATH command.

Frontend

The frontend of the memory tool is built with React and Redux.

React has thorough documentation.

Redux has thorough documentation.

We have React components in devtools/client/memory/components/*.

We have Redux reducers in devtools/client/memory/reducers/*.

We have Redux actions and action-creating tasks in devtools/client/memory/actions/*.

React components should be pure functions from their props to the rendered (virtual) DOM. Redux reducers should also be observably pure.

Impurity within the frontend is confined to the tasks that are creating and dispatching actions. All communication with the outside world (such as the HeapAnalysesWorker, the Remote DevTools Server, or the file system) is restricted to within these tasks.

Snapshots State

On the JavaScript side, the snapshots represent a reference to the underlying heap dump and the various analyses. The following diagram represents a finite state machine describing the snapshot states. Any of these states may go to the ERROR state, from which they can never leave.

SAVING → SAVED → READING → READ
                  ↗
         IMPORTING

Each of the report types (census, diffing, tree maps, dominators) have their own states as well, and are documented at devtools/client/memory/constants.js. These report states are updated as the various filtering and selecting options are updated in the UI.

Testing the Frontend

Unit tests for React components are in devtools/client/memory/test/chrome/*.

Unit tests for actions, reducers, and state changes are in devtools/client/memory/test/xpcshell/*.

Holistic integration tests for the frontend and the whole memory tool are in devtools/client/memory/test/browser/*.

All tests can be run with the usual mach test $PATH command.