Halve the memory the exact dominator tree needs - #2965
Open
pyricau wants to merge 6 commits into
Open
Conversation
`dfsNumberByObjectId` was a `MutableLongIntMap`, which is ~23 bytes per object once its capacity is rounded up for the load factor, and `objectIdByDfsNumber` held an 8 byte id per vertex. Both are now `IntArray`s keyed by `objectIndex`, the dense index over `[0, objectCount[` that `HprofInMemoryIndex` already builds, so they are 4 bytes each and sized once rather than grown and rehashed. Measured on a generated 487 MB heap dump of 9.24 M reachable objects and 17.35 M references between them, minimum heap the build completes in: 1025 MB to 831 MB. Live heap after the build, with only the tree and the graph held: 522 MB to 308 MB. The immediate dominator of every reachable object is identical, A/B'd inside one JVM process against the previous implementation. The price is a binary search per edge in place of a hash lookup, which comes out free: the id has to be resolved to an index anyway, so the DFS then reads the object with `findObjectByIndex` rather than `findObjectById`, dropping the binary search it used to do per vertex. 19.2 s against 19.0 s on that dump, 341 ms against 364 ms on `large-dump.hprof` — a wash either way. It's the same trade `HeapObjectIdSet` makes. An object id that isn't in the heap dump has no index and so can't be stored. That was already fatal here — the DFS reads every non leaf vertex it creates, and `findObjectById` throws on an id the dump doesn't have — so it now throws where the index is resolved, naming the dump as corrupt instead of failing a level down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The edge that discovers a vertex is that vertex's DFS tree edge, and `dfsTreeParent` already holds it, so storing it a second time as a predecessor buys nothing. On a generated 487 MB heap dump of 9.24 M reachable objects that is 9.24 M of the 17.35 M references between them: more than half of them. Lengauer-Tarjan needs no list to recover what a tree edge contributes to a vertex's semidominator. It is exactly the parent's DFS number: the loop runs in descending DFS order, so when it reaches w the parent is still unlinked in the link-eval forest and still labelled with itself, which makes eval(parent) the parent, and the parent's own semidominator hasn't been computed yet, so semi of it is still its DFS number. Minimum heap the build completes in, on that dump: 831 MB to 712 MB. The steady state of the two edge arrays only shrinks by 74 MB, and the rest is the 1.5x overshoot `MutableIntList` leaves behind at its last growth, which halves with them. Time is unchanged, and the immediate dominator of every reachable object is identical, A/B'd inside one JVM process. A heap size only counts as passing when three consecutive runs of it complete: near its boundary the ladder is a coin toss, and a single run per candidate reads about 60 MB better than the truth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing asks for a vertex's predecessors twice or out of order — the semidominator loop reads each vertex's exactly once, in descending DFS order — so they have no use for random access and every use for being contiguous. They are now one slice per vertex: 4 bytes an edge plus 4 bytes a vertex, where the linked lists were 8 bytes an edge plus a head per vertex. Neither the vertex count nor the edge count is known while the DFS runs, so the edges are collected in the order it finds them and counting sorted into slices at the end, and the discovery order lists dropped there — which is the point at which it matters, since the dominator arrays are then allocated on top of whatever is left. Measured on a generated 487 MB heap dump of 9.24 M reachable objects, minimum heap the build completes in: 712 MB to 652 MB. The time it buys depends on the shape of the dump, and this isn't the shape that shows it: nothing measurable here, 17.6 s either way, against 15% on a dump whose predecessor lists were spread through a 3.7 M frame deep DFS, 20.7 s against 24.4 s. The immediate dominator of every reachable object is identical, A/B'd inside one JVM process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both compute semidominators the same way. Lengauer-Tarjan then puts each vertex in the bucket of its semidominator and fixes the immediate dominators up in a second pass over those buckets; SEMI-NCA instead walks up the dominator tree it is building, from a vertex's DFS tree parent to the nearest dominator at or above it whose DFS number is at most that vertex's semidominator's. So it needs `semi`, `label`, and one array that is the link-eval `ancestor` while the semidominators are computed and the immediate dominators afterwards: three arrays per vertex where Lengauer-Tarjan needs six. `compressStack` is grown from 1024 rather than sized at one entry per vertex, which was 37 MB held to store a path that never got past 14 deep. See "Finding Dominators Revisited", Georgiadis, Tarjan & Werneck, 2006. It's what LLVM and hprof-analyzer both ship. Measured on a generated 487 MB heap dump of 9.24 M reachable objects and 17.35 M references between them, minimum heap the build completes in: 652 MB to 525 MB, where the first version of this class needed 1025 MB. No time difference, 15.9 to 18.2 s against 16.7 to 19.6 s, overlapping. The immediate dominator of every reachable object is identical, A/B'd inside one JVM process, on that dump and on all eight heap dumps in the repo. Three things make holding both in one array safe: - The dominator pass reads only entries it has written. It walks up from a DFS tree parent, and that parent and every dominator above it have a lower DFS number than the vertex, so the ascending loop wrote them already. - Nothing reads the virtual root's entry, so nothing has to be seeded: a semidominator is a DFS number, so it is at least the virtual root's, and the walk stops there. - `NOT_LINKED` is 0 and vertices are numbered from 1, so "not linked at all" and "linked to the virtual root" are different values. hprof-analyzer's dominator.rs documents having hit that collision. The trade is a worst case. The dominator walk is bounded by the height of the dominator tree, so it is linear in practice but O(n^2) on a deep chain whose bottom vertices are also referenced from near the root, where Lengauer-Tarjan is O(m log n). Measured rather than assumed: on `large-dump.hprof` 1.64 M steps for 271 K vertices, 6 per vertex, longest single walk 4770; on a generated 9.26 M vertex dump 0.8 per vertex, longest 14. Both loops ask the cancel signal, so a pathological heap dump is a slow read that can be abandoned rather than a hang. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One measurement of the whole series, on one workload, rather than four measurements the reader has to reconcile: the four changes above take the smallest heap the build of a 9.24 M object heap dump completes in from 1025 MB to 525 MB, and the build from 19.4 to 15.4 s. The workload had to be replaced to get there, which is the part worth writing down. The first generated dump pointed each node's cross references forward to a nearby index, and that chains: its DFS path reached 3.69 M frames, 40% of its vertices, where a real Android dump of 271 K reachable objects reaches 4911. At ~90 bytes of headers and bookkeeping a frame, that stack alone was ~350 MB and the peak of the whole analysis, so a ladder on it moved for anything that made the DFS cheaper and not at all for the algorithm that runs after it — it valued SEMI-NCA at 7 MB against the 127 MB it is worth on a dump shaped like a real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
shark.HeapDominatorTreeis the exact dominator tree of a whole heap dump, built for tools running ona workstation rather than for the analysis on device — Shark Explorer is built on it. It held about 60
bytes per reachable object plus 8 per reference between them. Four independent cuts, one per commit,
each with its own measurement so a regression is attributable:
objectIndexrather than by object idEnd to end, 1025 MB to 525 MB, and the build 19.4 s to 15.4 s. No public API changed, so there is
no ABI dump to update.
How it was measured
Peak heap used is not the metric — a JVM with room to spare collects late, so the number reads as
whatever the collector felt like. The metric is the smallest
-Xmxat which the build still completes,bisected, and a heap size only counts as passing when three consecutive runs of it complete: near the
boundary it is a coin toss, and a single run per candidate reads about 60 MB better than the truth.
Alongside it, the live heap after a full collection with only the finished tree and the graph held,
which is what the tree costs for the rest of the session.
The workload is a generated 487 MB heap dump, 9.24 M reachable objects and 17.35 M references between
them, dumped out of a JVM holding a wide shallow hierarchy plus a pool of shared leaves several nodes
each point at. No heap dump in the repo is large enough to discriminate: a 20 byte per object cut on
the largest one is 5 MB, which is inside the noise.
A generated workload has to be checked for DFS depth, which is the trap here and cost a full round
of measurements. The first one pointed each node's cross references forward to a nearby index, and that
chains: its DFS path reached 3.69 M frames, 40% of its vertices, where a real Android dump of 271 K
reachable objects reaches 4911. At ~90 bytes of headers and bookkeeping a frame, that stack alone was
~350 MB and the peak of the whole analysis, so the ladder moved for anything that made the DFS cheaper
and not at all for the algorithm that runs after it — it valued SEMI-NCA at 7 MB against the 127 MB it
is worth on a dump shaped like a real one.
Correctness
Every commit is A/B'd against the implementation before it inside one JVM process, alternating
them, comparing the immediate dominator of every reachable object: an identical relation over an
identical object set is the whole gate, since retained sizes and a node's children are functions of it
and of shallow sizes. Identical on the generated dump and on all eight heap dumps in the repo, for all
four cuts.
The one that needs an argument beyond that is SEMI-NCA, which holds the link-eval
ancestorand theimmediate dominators in one array. Three things make that safe, and the commit message spells each out:
the dominator pass only reads entries it has already written, since it walks up from a DFS tree parent
and every vertex above it has a lower DFS number; nothing reads the virtual root's entry, so nothing
has to be seeded; and
NOT_LINKEDbeing 0 while vertices are numbered from 1 keeps "not linked at all"distinct from "linked to the virtual root", a collision
hprof-analyzer'sdominator.rsdocumentshaving hit.
The trade SEMI-NCA makes is a worst case: its dominator walk is bounded by the height of the dominator
tree, so it is linear in practice but O(n²) on a deep chain whose bottom vertices are also referenced
from near the root, where Lengauer-Tarjan is O(m log n). Measured rather than assumed — on
large-dump.hprof1.64 M steps for 271 K vertices, 6 per vertex, longest single walk 4770; on agenerated 9.26 M vertex dump 0.8 per vertex, longest 14. Both loops ask the cancel signal, so a
pathological heap dump is a slow read that can be abandoned rather than a hang. It is what LLVM and
hprof-analyzerboth ship.🤖 Generated with Claude Code