diff --git a/devtools/shared/heapsnapshot/DominatorTreeNode.js b/devtools/shared/heapsnapshot/DominatorTreeNode.js index 4655bae0c1a2..13a847fd048f 100644 --- a/devtools/shared/heapsnapshot/DominatorTreeNode.js +++ b/devtools/shared/heapsnapshot/DominatorTreeNode.js @@ -3,11 +3,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; -const { Visitor, walk } = require("resource://devtools/shared/heapsnapshot/CensusUtils.js"); const { immutableUpdate } = require("resource://devtools/shared/ThreadSafeDevToolsUtils.js"); +const { Visitor, walk } = require("resource://devtools/shared/heapsnapshot/CensusUtils.js"); +const { deduplicatePaths } = require("resource://devtools/shared/heapsnapshot/shortest-paths"); const DEFAULT_MAX_DEPTH = 4; const DEFAULT_MAX_SIBLINGS = 15; +const DEFAULT_MAX_NUM_PATHS = 5; /** * A single node in a dominator tree. @@ -34,6 +36,10 @@ function DominatorTreeNode(nodeId, label, shallowSize, retainedSize) { // An array of immediately dominated child `DominatorTreeNode`s, or undefined. this.children = undefined; + // An object of the form returned by `deduplicatePaths`, encoding the set of + // the N shortest retaining paths for this node as a graph. + this.shortestPaths = undefined; + // True iff the `children` property does not contain every immediately // dominated node. // @@ -289,3 +295,42 @@ DominatorTreeNode.getNodeByIdAlongPath = function (id, tree, path) { return find(tree, 0); }; + +/** + * Find the shortest retaining paths for the given set of DominatorTreeNodes, + * and populate each node's `shortestPaths` property with them in place. + * + * @param {HeapSnapshot} snapshot + * @param {Object} breakdown + * @param {NodeId} start + * @param {Array} treeNodes + * @param {Number} maxNumPaths + */ +DominatorTreeNode.attachShortestPaths = function (snapshot, + breakdown, + start, + treeNodes, + maxNumPaths = DEFAULT_MAX_NUM_PATHS) { + const idToTreeNode = new Map(); + const targets = []; + for (let node of treeNodes) { + const id = node.nodeId; + idToTreeNode.set(id, node); + targets.push(id); + } + + const shortestPaths = snapshot.computeShortestPaths(start, + targets, + maxNumPaths); + + for (let [target, paths] of shortestPaths) { + const deduped = deduplicatePaths(target, paths); + deduped.nodes = deduped.nodes.map(id => { + const { label } = + DominatorTreeNode.getLabelAndShallowSize(id, snapshot, breakdown); + return { id, label }; + }); + + idToTreeNode.get(target).shortestPaths = deduped; + } +}; diff --git a/devtools/shared/heapsnapshot/HeapAnalysesClient.js b/devtools/shared/heapsnapshot/HeapAnalysesClient.js index f8ada1820430..a9d76cb53d87 100644 --- a/devtools/shared/heapsnapshot/HeapAnalysesClient.js +++ b/devtools/shared/heapsnapshot/HeapAnalysesClient.js @@ -226,6 +226,8 @@ HeapAnalysesClient.prototype.getDominatorTree = function (opts) { * by greatest to least retained size. * - {Number} maxCount * The maximum number of children to return. + * - {Number} maxRetainingPaths + * The maximum number of retaining paths to find for each node. * * @returns {Promise} * A promise of an object with the following properties: diff --git a/devtools/shared/heapsnapshot/HeapAnalysesWorker.js b/devtools/shared/heapsnapshot/HeapAnalysesWorker.js index a01d9fc964e3..45642962ca4d 100644 --- a/devtools/shared/heapsnapshot/HeapAnalysesWorker.js +++ b/devtools/shared/heapsnapshot/HeapAnalysesWorker.js @@ -157,7 +157,8 @@ workerHelper.createTask(self, "getDominatorTree", request => { dominatorTreeId, breakdown, maxDepth, - maxSiblings + maxSiblings, + maxRetainingPaths, } = request; if (!(0 <= dominatorTreeId && dominatorTreeId < dominatorTrees.length)) { @@ -168,11 +169,29 @@ workerHelper.createTask(self, "getDominatorTree", request => { const dominatorTree = dominatorTrees[dominatorTreeId]; const snapshot = dominatorTreeSnapshots[dominatorTreeId]; - return DominatorTreeNode.partialTraversal(dominatorTree, - snapshot, - breakdown, - maxDepth, - maxSiblings); + const tree = DominatorTreeNode.partialTraversal(dominatorTree, + snapshot, + breakdown, + maxDepth, + maxSiblings); + + const nodes = []; + (function getNodes(node) { + nodes.push(node); + if (node.children) { + for (let i = 0, length = node.children.length; i < length; i++) { + getNodes(node.children[i]); + } + } + }(tree)); + + DominatorTreeNode.attachShortestPaths(snapshot, + breakdown, + dominatorTree.root, + nodes, + maxRetainingPaths); + + return tree; }); /** @@ -184,7 +203,8 @@ workerHelper.createTask(self, "getImmediatelyDominated", request => { nodeId, breakdown, startIndex, - maxCount + maxCount, + maxRetainingPaths, } = request; if (!(0 <= dominatorTreeId && dominatorTreeId < dominatorTrees.length)) { @@ -228,5 +248,11 @@ workerHelper.createTask(self, "getImmediatelyDominated", request => { const moreChildrenAvailable = childIds.length > end; + DominatorTreeNode.attachShortestPaths(snapshot, + breakdown, + dominatorTree.root, + nodes, + maxRetainingPaths); + return { nodes, moreChildrenAvailable, path }; }); diff --git a/devtools/shared/heapsnapshot/moz.build b/devtools/shared/heapsnapshot/moz.build index 750de0199ccd..a2fc7936078d 100644 --- a/devtools/shared/heapsnapshot/moz.build +++ b/devtools/shared/heapsnapshot/moz.build @@ -55,4 +55,5 @@ DevToolsModules( 'HeapAnalysesClient.js', 'HeapAnalysesWorker.js', 'HeapSnapshotFileUtils.js', + 'shortest-paths.js', ) diff --git a/devtools/shared/heapsnapshot/shortest-paths.js b/devtools/shared/heapsnapshot/shortest-paths.js new file mode 100644 index 000000000000..60a7c57eea91 --- /dev/null +++ b/devtools/shared/heapsnapshot/shortest-paths.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Compress a set of paths leading to `target` into a single graph, returned as + * a set of nodes and a set of edges. + * + * @param {NodeId} target + * The target node passed to `HeapSnapshot.computeShortestPaths`. + * + * @param {Array} paths + * An array of paths to `target`, as returned by + * `HeapSnapshot.computeShortestPaths`. + * + * @returns {Object} + * An object with two properties: + * - edges: An array of unique objects of the form: + * { + * from: , + * to: , + * name: + * } + * - nodes: An array of unique node IDs. Every `from` and `to` id is + * guaranteed to be in this array exactly once. + */ +exports.deduplicatePaths = function (target, paths) { + // Use this structure to de-duplicate edges among many retaining paths from + // start to target. + // + // Map>> + const deduped = new Map(); + + function insert(from, to, name) { + let toMap = deduped.get(from); + if (!toMap) { + toMap = new Map(); + deduped.set(from, toMap); + } + + let nameSet = toMap.get(to); + if (!nameSet) { + nameSet = new Set(); + toMap.set(to, nameSet); + } + + nameSet.add(name); + } + + for (let path of paths) { + const pathLength = path.length; + for (let i = 0; i < pathLength - 1; i++) { + insert(path[i].predecessor, path[i + 1].predecessor, path[i].edge); + } + + insert(path[pathLength - 1].predecessor, target, path[pathLength - 1].edge); + } + + const nodes = [target]; + const edges = []; + + for (let [from, toMap] of deduped) { + // If the second/third/etc shortest path contains the `target` anywhere + // other than the very last node, we could accidentally put the `target` in + // `nodes` more than once. + if (from !== target) { + nodes.push(from); + } + + for (let [to, edgeNameSet] of toMap) { + for (let name of edgeNameSet) { + edges.push({ from, to, name }); + } + } + } + + return { nodes, edges }; +}; diff --git a/devtools/shared/heapsnapshot/tests/unit/head_heapsnapshot.js b/devtools/shared/heapsnapshot/tests/unit/head_heapsnapshot.js index 4172c1d2619c..59228aab0ef4 100644 --- a/devtools/shared/heapsnapshot/tests/unit/head_heapsnapshot.js +++ b/devtools/shared/heapsnapshot/tests/unit/head_heapsnapshot.js @@ -23,6 +23,7 @@ const Services = require("Services"); const { censusReportToCensusTreeNode } = require("devtools/shared/heapsnapshot/census-tree-node"); const CensusUtils = require("devtools/shared/heapsnapshot/CensusUtils"); const DominatorTreeNode = require("devtools/shared/heapsnapshot/DominatorTreeNode"); +const { deduplicatePaths } = require("devtools/shared/heapsnapshot/shortest-paths"); const { LabelAndShallowSizeVisitor } = DominatorTreeNode; @@ -375,3 +376,51 @@ function assertDominatorTreeNodeInsertion(tree, path, newChildren, moreChildrenA assertStructurallyEquivalent(actual, expected); } + +function assertDeduplicatedPaths({ target, paths, expectedNodes, expectedEdges }) { + dumpn("Deduplicating paths:"); + dumpn("target = " + target); + dumpn("paths = " + JSON.stringify(paths, null, 2)); + dumpn("expectedNodes = " + expectedNodes); + dumpn("expectedEdges = " + JSON.stringify(expectedEdges, null, 2)); + + const { nodes, edges } = deduplicatePaths(target, paths); + + dumpn("Actual nodes = " + nodes); + dumpn("Actual edges = " + JSON.stringify(edges, null, 2)); + + equal(nodes.length, expectedNodes.length, + "actual number of nodes is equal to the expected number of nodes"); + + equal(edges.length, expectedEdges.length, + "actual number of edges is equal to the expected number of edges"); + + const expectedNodeSet = new Set(expectedNodes); + const nodeSet = new Set(nodes); + ok(nodeSet.size === nodes.length, + "each returned node should be unique"); + + for (let node of nodes) { + ok(expectedNodeSet.has(node), `the ${node} node was expected`); + } + + for (let expectedEdge of expectedEdges) { + let count = 0; + for (let edge of edges) { + if (edge.from === expectedEdge.from && + edge.to === expectedEdge.to && + edge.name === expectedEdge.name) { + count++; + } + } + equal(count, 1, + "should have exactly one matching edge for the expected edge = " + JSON.stringify(edge)); + } +} + +/** + * Create a mock path entry for the given predecessor and edge. + */ +function pathEntry(predecessor, edge) { + return { predecessor, edge }; +} diff --git a/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_attachShortestPaths_01.js b/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_attachShortestPaths_01.js new file mode 100644 index 000000000000..24e8e2eb57e5 --- /dev/null +++ b/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_attachShortestPaths_01.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that the DominatorTreeNode.attachShortestPaths function can correctly +// attach the deduplicated shortest retaining paths for each node it is given. + +const startNodeId = 9999; +const maxNumPaths = 2; + +// Mock data mapping node id to shortest paths to that node id. +const shortestPaths = new Map([ + [1000, [ + [pathEntry(1100, "a"), pathEntry(1200, "b")], + [pathEntry(1100, "c"), pathEntry(1300, "d")], + ]], + [2000, [ + [pathEntry(2100, "e"), pathEntry(2200, "f"), pathEntry(2300, "g")] + ]], + [3000, [ + [pathEntry(3100, "h")], + [pathEntry(3100, "i")], + [pathEntry(3100, "j")], + [pathEntry(3200, "k")], + [pathEntry(3300, "l")], + [pathEntry(3400, "m")], + ]], +]); + +const actual = [ + makeTestDominatorTreeNode({ nodeId: 1000 }), + makeTestDominatorTreeNode({ nodeId: 2000 }), + makeTestDominatorTreeNode({ nodeId: 3000 }), +]; + +const expected = [ + makeTestDominatorTreeNode({ + nodeId: 1000, + shortestPaths: { + nodes: [ + { id: 1000, label: ["SomeType-1000"] }, + { id: 1100, label: ["SomeType-1100"] }, + { id: 1200, label: ["SomeType-1200"] }, + { id: 1300, label: ["SomeType-1300"] }, + ], + edges: [ + { from: 1100, to: 1200, name: "a" }, + { from: 1100, to: 1300, name: "c" }, + { from: 1200, to: 1000, name: "b" }, + { from: 1300, to: 1000, name: "d" }, + ] + } + }), + + makeTestDominatorTreeNode({ + nodeId: 2000, + shortestPaths: { + nodes: [ + { id: 2000, label: ["SomeType-2000"] }, + { id: 2100, label: ["SomeType-2100"] }, + { id: 2200, label: ["SomeType-2200"] }, + { id: 2300, label: ["SomeType-2300"] }, + ], + edges: [ + { from: 2100, to: 2200, name: "e" }, + { from: 2200, to: 2300, name: "f" }, + { from: 2300, to: 2000, name: "g" }, + ] + } + }), + + makeTestDominatorTreeNode({ nodeId: 3000, + shortestPaths: { + nodes: [ + { id: 3000, label: ["SomeType-3000"] }, + { id: 3100, label: ["SomeType-3100"] }, + { id: 3200, label: ["SomeType-3200"] }, + { id: 3300, label: ["SomeType-3300"] }, + { id: 3400, label: ["SomeType-3400"] }, + ], + edges: [ + { from: 3100, to: 3000, name: "h" }, + { from: 3100, to: 3000, name: "i" }, + { from: 3100, to: 3000, name: "j" }, + { from: 3200, to: 3000, name: "k" }, + { from: 3300, to: 3000, name: "l" }, + { from: 3400, to: 3000, name: "m" }, + ] + } + }), +]; + +const breakdown = { + by: "internalType", + then: { by: "count", count: true, bytes: true } +}; + +const mockSnapshot = { + computeShortestPaths: (start, nodeIds, max) => { + equal(start, startNodeId); + equal(max, maxNumPaths); + + return new Map(nodeIds.map(nodeId => { + const paths = shortestPaths.get(nodeId); + ok(paths, "Expected computeShortestPaths call for node id = " + nodeId); + return [nodeId, paths]; + })); + }, + + describeNode: (bd, nodeId) => { + equal(bd, breakdown); + return { + ["SomeType-" + nodeId]: { + count: 1, + bytes: 10, + } + }; + }, +}; + +function run_test() { + DominatorTreeNode.attachShortestPaths(mockSnapshot, + breakdown, + startNodeId, + actual, + maxNumPaths); + + dumpn("Expected = " + JSON.stringify(expected, null, 2)); + dumpn("Actual = " + JSON.stringify(actual, null, 2)); + + assertStructurallyEquivalent(expected, actual); +} diff --git a/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_partialTraversal_01.js b/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_partialTraversal_01.js index fb24af455510..78ec47b646f5 100644 --- a/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_partialTraversal_01.js +++ b/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_partialTraversal_01.js @@ -60,6 +60,7 @@ const expected = { ], shallowSize: 10, retainedSize: 10, + shortestPaths: undefined, children: [ { nodeId: 200, @@ -70,6 +71,7 @@ const expected = { shallowSize: 10, retainedSize: 10, parentId: 100, + shortestPaths: undefined, children: [ { nodeId: 500, @@ -81,6 +83,7 @@ const expected = { retainedSize: 10, parentId: 200, moreChildrenAvailable: false, + shortestPaths: undefined, children: undefined }, { @@ -93,6 +96,7 @@ const expected = { retainedSize: 10, parentId: 200, moreChildrenAvailable: false, + shortestPaths: undefined, children: undefined } ], @@ -107,6 +111,7 @@ const expected = { shallowSize: 10, retainedSize: 10, parentId: 100, + shortestPaths: undefined, children: [ { nodeId: 800, @@ -118,6 +123,7 @@ const expected = { retainedSize: 10, parentId: 300, moreChildrenAvailable: false, + shortestPaths: undefined, children: undefined }, { @@ -130,6 +136,7 @@ const expected = { retainedSize: 10, parentId: 300, moreChildrenAvailable: false, + shortestPaths: undefined, children: undefined } ], diff --git a/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getDominatorTree_01.js b/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getDominatorTree_01.js index b41ef9a278b4..cedea5375418 100644 --- a/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getDominatorTree_01.js +++ b/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getDominatorTree_01.js @@ -51,6 +51,13 @@ add_task(function* () { equal(typeof node.moreChildrenAvailable, "boolean", "each node should indicate if there are more children available or not"); + equal(typeof node.shortestPaths, "object", + "Should have shortest paths"); + equal(typeof node.shortestPaths.nodes, "object", + "Should have shortest paths' nodes"); + equal(typeof node.shortestPaths.edges, "object", + "Should have shortest paths' edges"); + if (node.children) { node.children.forEach(checkTree); } diff --git a/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getImmediatelyDominated_01.js b/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getImmediatelyDominated_01.js index c371a9440174..caf1c2056922 100644 --- a/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getImmediatelyDominated_01.js +++ b/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getImmediatelyDominated_01.js @@ -44,6 +44,15 @@ add_task(function* () { equal(response.path.length, 1); equal(response.path[0], partialTree.nodeId); + for (let node of response.nodes) { + equal(typeof node.shortestPaths, "object", + "Should have shortest paths"); + equal(typeof node.shortestPaths.nodes, "object", + "Should have shortest paths' nodes"); + equal(typeof node.shortestPaths.edges, "object", + "Should have shortest paths' edges"); + } + // Next, test getting a subset of children available. const secondResponse = yield client.getImmediatelyDominated({ dominatorTreeId, @@ -59,5 +68,14 @@ add_task(function* () { equal(secondResponse.path.length, 1); equal(secondResponse.path[0], partialTree.nodeId); + for (let node of secondResponse.nodes) { + equal(typeof node.shortestPaths, "object", + "Should have shortest paths"); + equal(typeof node.shortestPaths.nodes, "object", + "Should have shortest paths' nodes"); + equal(typeof node.shortestPaths.edges, "object", + "Should have shortest paths' edges"); + } + client.destroy(); }); diff --git a/devtools/shared/heapsnapshot/tests/unit/test_deduplicatePaths_01.js b/devtools/shared/heapsnapshot/tests/unit/test_deduplicatePaths_01.js new file mode 100644 index 000000000000..49590b2396cd --- /dev/null +++ b/devtools/shared/heapsnapshot/tests/unit/test_deduplicatePaths_01.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test the behavior of the deduplicatePaths utility function. + +function edge(from, to, name) { + return { from, to, name }; +} + +function run_test() { + const a = 1; + const b = 2; + const c = 3; + const d = 4; + const e = 5; + const f = 6; + const g = 7; + + dumpn("Single long path"); + assertDeduplicatedPaths({ + target: g, + paths: [ + [ + pathEntry(a, "e1"), + pathEntry(b, "e2"), + pathEntry(c, "e3"), + pathEntry(d, "e4"), + pathEntry(e, "e5"), + pathEntry(f, "e6"), + ] + ], + expectedNodes: [a, b, c, d, e, f, g], + expectedEdges: [ + edge(a, b, "e1"), + edge(b, c, "e2"), + edge(c, d, "e3"), + edge(d, e, "e4"), + edge(e, f, "e5"), + edge(f, g, "e6"), + ] + }); + + dumpn("Multiple edges from and to the same nodes"); + assertDeduplicatedPaths({ + target: a, + paths: [ + [pathEntry(b, "x")], + [pathEntry(b, "y")], + [pathEntry(b, "z")], + ], + expectedNodes: [a, b], + expectedEdges: [ + edge(b, a, "x"), + edge(b, a, "y"), + edge(b, a, "z"), + ] + }); + + dumpn("Multiple paths sharing some nodes and edges"); + assertDeduplicatedPaths({ + target: g, + paths: [ + [ + pathEntry(a, "a->b"), + pathEntry(b, "b->c"), + pathEntry(c, "foo"), + ], + [ + pathEntry(a, "a->b"), + pathEntry(b, "b->d"), + pathEntry(d, "bar"), + ], + [ + pathEntry(a, "a->b"), + pathEntry(b, "b->e"), + pathEntry(e, "baz"), + ], + ], + expectedNodes: [a, b, c, d, e, g], + expectedEdges: [ + edge(a, b, "a->b"), + edge(b, c, "b->c"), + edge(b, d, "b->d"), + edge(b, e, "b->e"), + edge(c, g, "foo"), + edge(d, g, "bar"), + edge(e, g, "baz"), + ] + }); + + dumpn("Second shortest path contains target itself"); + assertDeduplicatedPaths({ + target: g, + paths: [ + [ + pathEntry(a, "a->b"), + pathEntry(b, "b->g"), + ], + [ + pathEntry(a, "a->b"), + pathEntry(b, "b->g"), + pathEntry(g, "g->f"), + pathEntry(f, "f->g"), + ], + ], + expectedNodes: [a, b, f, g], + expectedEdges: [ + edge(a, b, "a->b"), + edge(b, g, "b->g"), + edge(g, f, "g->f"), + edge(f, g, "f->g"), + ] + }); +} diff --git a/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini b/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini index 1969ce5ec386..2bcc9b802f09 100644 --- a/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini +++ b/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini @@ -29,12 +29,14 @@ support-files = [test_census-tree-node-06.js] [test_census-tree-node-07.js] [test_census-tree-node-08.js] +[test_deduplicatePaths_01.js] [test_DominatorTree_01.js] [test_DominatorTree_02.js] [test_DominatorTree_03.js] [test_DominatorTree_04.js] [test_DominatorTree_05.js] [test_DominatorTree_06.js] +[test_DominatorTreeNode_attachShortestPaths_01.js] [test_DominatorTreeNode_getNodeByIdAlongPath_01.js] [test_DominatorTreeNode_insert_01.js] [test_DominatorTreeNode_insert_02.js]