Skip to content

Halve the memory the exact dominator tree needs - #2965

Open
pyricau wants to merge 6 commits into
mainfrom
dominator-tree-memory
Open

Halve the memory the exact dominator tree needs#2965
pyricau wants to merge 6 commits into
mainfrom
dominator-tree-memory

Conversation

@pyricau

@pyricau pyricau commented Aug 21, 2026

Copy link
Copy Markdown
Member

shark.HeapDominatorTree is the exact dominator tree of a whole heap dump, built for tools running on
a 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:

Minimum heap Live heap after build
Before 1025 MB 522 MB
Vertex maps keyed by objectIndex rather than by object id 831 MB 308 MB
DFS tree edges left out of the predecessor lists 712 MB 308 MB
Predecessors in contiguous slices rather than linked lists 652 MB 308 MB
SEMI-NCA rather than Lengauer-Tarjan 525 MB 308 MB

End 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 -Xmx at 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 ancestor and the
immediate 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_LINKED being 0 while vertices are numbered from 1 keeps "not linked at all"
distinct from "linked to the virtual root", a collision hprof-analyzer's dominator.rs documents
having 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.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. It is what LLVM and
hprof-analyzer both ship.

🤖 Generated with Claude Code

pyricau and others added 6 commits August 21, 2026 18:07
`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant