Skip to content

Shrink the per-object entries of the heap dump index - #2964

Merged
pyricau merged 2 commits into
mainfrom
index-entry-shrink
Aug 21, 2026
Merged

Shrink the per-object entries of the heap dump index#2964
pyricau merged 2 commits into
mainfrom
index-entry-shrink

Conversation

@pyricau

@pyricau pyricau commented Aug 21, 2026

Copy link
Copy Markdown
Member

HprofInMemoryIndex is what sets LeakCanary's on-device memory cost, so this looks at the two
biggest fields in a per-object entry: the class id every instance and object array carried, and the
object id that keys every entry. One commit each, each with its own measurement.

The starting point, measured rather than read off the declarations — the brief for this work
estimated ~22 bytes per object, which is a JVM-shaped number:

dump ids class instance object array primitive array per object
the 9 Android dumps in our test resources 4 bytes 23-25 12-15 13-16 10-12 12.0-14.1
a JVM dump (HotSpotDiagnosticMXBean.dumpHeap) 8 bytes 28 18 19 15 19.2

1. Store the index of an object's class, not its class id

Every instance and object array held the id of its class. Every one of those objects belongs to a
class that has a class dump record, and the class index is sorted by class id, so an entry can hold
the index of its class among those records instead — 2 bytes for the few thousand classes of a
typical heap dump — and resolve it back with classIndex.keyAt(index). No side table: the array of
class dump ids the first pass collects and the sorted class index are the same sequence, which a
require in buildIndex checks entry for entry.

dump index bytes before after saved
leak_asynctask_o 1,556,606 1,414,042 9.2%
leak_asynctask_m 1,732,581 1,579,311 8.8%
leak_asynctask_pre_m 587,988 531,914 9.5%
compose_leak 3,549,627 3,221,769 9.2%
gcroot_unknown_object 5,758,161 5,172,045 10.2%
unloaded_classes-stripped 4,700,478 4,111,318 12.5%
gc_root_in_non_primary_heap 1,593,547 1,451,409 8.9%
safe_iterable_map 4,594,099 4,208,959 8.4%
hashmap_api_25 1,860,624 1,692,090 9.1%
a JVM dump 19.2 14.8 bytes per object 23%

On leak_asynctask_o the predicted saving is 2 bytes × (65,814 instances + 5,468 object arrays) =
142,564 bytes and the measured retained drop is 142,561.

2. Key the index by an offset, not by a whole object id

Ids are addresses within one heap, so what they need is set by how far apart they are, not by how
large they are. An entry now stores its id as an offset from the smallest id in the dump, over as
many bytes as the distance to the largest one needs. The base being the smallest id makes encoding
monotonic, so sorting and binary searching still work on the stored bytes and only keyAt() decodes.

dump id span key bytes index bytes before after
leak_asynctask_o 3.35 GB 4 → 4 1,414,042 1,414,042
leak_asynctask_m 2.33 GB 4 → 4 1,579,311 1,579,311
leak_asynctask_pre_m 3.97 MB 4 → 3 531,914 486,529
compose_leak 3.86 GB 4 → 4 3,221,769 3,221,769
gcroot_unknown_object 1.71 GB 4 → 4 5,172,045 5,172,045
unloaded_classes-stripped 4.07 GB 4 → 4 4,111,318 4,111,318
gc_root_in_non_primary_heap 3.39 GB 4 → 4 1,451,409 1,451,409
safe_iterable_map 1.67 GB 4 → 4 4,208,959 4,208,959
hashmap_api_25 3.47 GB 4 → 4 1,692,090 1,692,090
a JVM dump 536 MB 8 → 4 14.77 10.77 bytes per object

This one buys nothing on most Android heap dumps, and it's worth saying plainly since on-device
memory is the point of the exercise. ART writes the low 32 bits of a 64 bit address, and the image
space and the app heap land far enough apart that the span still needs 4 bytes. It buys 4 bytes per
object on every JVM heap dump — 27% of the index — which is where the index gets big enough to be
the binding constraint: gigabytes on the hundreds-of-millions-of-objects dumps shark-cli and Shark
Explorer are pointed at.

Per-page bases rather than one global base would take Android keys down to 2 bytes, since a page of
a sorted index covers a narrow range. Not here: the base of a page is only known after sorting, so
entries would have to be written wide and compacted afterwards, trading peak memory for retained
memory. That's the wrong way round on a device, and HprofRetainedHeapPerfTest wouldn't even see the
regression.

The frozen perf numbers

HprofRetainedHeapPerfTest, all within the existing ±5% margin:

expectation before after commit 1 after commit 2
indexing leak_asynctask_o 4.5 MB 4.33 4.33
indexing leak_asynctask_m 4.4 MB 4.24 4.24
leak_asynctask_o after PARSING_HEAP_DUMP 4.98 MB 4.84 4.84
… EXTRACTING_METADATA 5.20 MB 5.07 5.07
… FINDING_RETAINED_OBJECTS 5.28 MB 5.14 5.14
… the four remaining steps 5.47 MB 5.34 5.34
computing leak shares 0.98 MB 0.86 0.74

The analysis-step numbers move by the same ~142 KB the index of leak_asynctask_o lost in commit 1,
and don't move in commit 2 because that dump stays on 4 byte keys. The leak shares number is the one
that moves twice: those dumps are synthetic, with 8 classes and 60,022 objects whose ids span 60,010,
so commit 1 takes 3 bytes off each instance and object array (1 byte for the class index against the
4 an id took) and commit 2 takes 2 more off every key. Both drops are 120 KB, i.e. exactly the entry
count of the one index still open at that point times the bytes per entry saved.

HprofIOPerfTest doesn't move at all. The first pass now reads the id of each object dump record
where it used to skip over it, but that's a read from an already buffered okio source, so the bytes
read from the file are the same.

Time

Both commits were A/B'd end to end, five runs alternating between the two implementations, comparing
medians of per-run medians (a single JVM run is bimodal here, so one run of each side is worth
nothing — the first attempt showed a 26% "regression" that reran at parity).

leak_asynctask_o leak_asynctask_m
before both 159 ms 168 ms
after commit 1 161 ms 165 ms
after commit 2 161 ms 167 ms

Commit 2 did have a real regression on the way: reading a key of any width through a byte at a time
loop cost 15% to 17% of end to end analysis, consistently across all five runs, because binary
searching an index calls it on every probe. Reading the two widths a real heap dump lands on as a
whole int or long removes it. Indexing is unchanged to +1 ms (19 → 19 ms and 25 → 26 ms).

Notes

  • Everything touched is internal, so there's no ABI change and checkKotlinAbi is unaffected.
  • One behavior change, in commit 1: a heap dump holding an instance or object array whose class has
    no class dump record now fails to open, naming that class id. Nothing could be read from such an
    object anyway — its fields are laid out by a class the heap dump doesn't contain — and none of the
    10 heap dumps measured here hold one, including the two carrying unloaded classes. There's a test
    for it, and a 🔀 changelog entry.
  • ./gradlew build and ./gradlew detekt pass on Java 17.

🤖 Generated with Claude Code

pyricau and others added 2 commits August 21, 2026 16:48
Every instance and object array in a heap dump belongs to a class that
has a class dump record, so an entry doesn't need the 4 or 8 bytes of a
class id: the index of that class among the class dump records is 2
bytes for the few thousand classes a heap dump has. The first pass now
collects those ids so the second can binary search one, and the class
index is sorted by class id, so reading its key back at that index is
all it takes to resolve one.

On the Android heap dumps in our test resources the four object indexes
go from 12.0-14.1 bytes per object to 10.9-12.6, and on a JVM heap dump
from 19.2 to 14.8. The frozen numbers move by exactly what that
predicts: 2 bytes times the 65814 instances and 5468 object arrays of
leak_asynctask_o.hprof is 142564 bytes, and what indexing it retains
drops by 142561.

Indexing binary searches a class id where it used to write it out, and
reading an object resolves the index back to one. Neither is measurable
end to end: over five runs alternating between the two implementations,
the median analysis of leak_asynctask_o.hprof is 159 ms before and 161
ms after, and of leak_asynctask_m.hprof 168 ms before and 165 ms after.

A heap dump with an object whose class has no class dump record now
fails to open rather than failing later: nothing can be read from such
an object, since its fields are laid out by a class the heap dump
doesn't contain. None of the Android or JVM heap dumps in our test
resources hold one, including the two that carry unloaded classes.
Every entry of the four object indexes started with the whole object id
that keys it: 4 bytes on an Android heap dump, 8 on a JVM one. Ids are
addresses within one heap, so what they need is set by how far apart
they are, not by how large they are. An entry now stores its id as an
offset from the smallest id in the heap dump, over as many bytes as the
distance up to the largest one needs, which the first pass computes by
reading the id of every object dump record instead of skipping over it.

The base is the smallest id, so no offset wraps and encoding is
monotonic: stored keys sort in the same order as the ids they encode.
Sorting a builder and binary searching a map therefore work on the
stored bytes, and only keyAt() decodes.

Measured per dump, over the four object indexes:

  leak_asynctask_o             span 3.35 GB  4 bytes  1414042 (0%)
  leak_asynctask_m             span 2.33 GB  4 bytes  1579311 (0%)
  leak_asynctask_pre_m         span 3.97 MB  3 bytes   531914 -> 486529
  compose_leak                 span 3.86 GB  4 bytes  3221769 (0%)
  gcroot_unknown_object        span 1.71 GB  4 bytes  5172045 (0%)
  unloaded_classes-stripped    span 4.07 GB  4 bytes  4111318 (0%)
  gc_root_in_non_primary_heap  span 3.39 GB  4 bytes  1451409 (0%)
  safe_iterable_map            span 1.67 GB  4 bytes  4208959 (0%)
  hashmap_api_25               span 3.47 GB  4 bytes  1692090 (0%)
  a JVM heap dump              span  536 MB  4 bytes  14.77 -> 10.77
                                                      bytes per object

So this buys nothing on most Android heap dumps, and that is not a
surprise: ART writes the low 32 bits of a 64 bit address, and the image
space and the app heap land far enough apart that the span still needs
4 bytes. It buys 4 bytes per object on every JVM heap dump, 27% of the
index, which is where the index gets big enough to be the binding
constraint — gigabytes on the hundreds-of-millions-of-objects dumps that
shark-cli and Shark Explorer are pointed at.

Per-page bases rather than one global base would shrink Android keys to
2 bytes, since a page of a sorted index covers a narrow range, but the
base of a page is only known after sorting, so the entries would have to
be written wide and compacted afterwards. That trades peak memory for
retained memory, which is the wrong way round on a device, and the
retained heap test wouldn't even see the regression.

HprofRetainedHeapPerfTest: only the leak shares expectation moves, from
0.86 MB to 0.74 MB. The dumps it builds hold 60022 objects with ids
spanning 60010, which needs 2 bytes rather than the 4 an id takes, and
the measured drop is 120044 bytes, i.e. exactly 2 bytes times the 60022
entries of the one index that is still open at that point. The
leak_asynctask_o and leak_asynctask_m expectations don't move, because
those dumps stay on 4 byte keys. HprofIOPerfTest doesn't move either:
reading an id out of the already buffered source instead of skipping it
reads the same bytes.

Reading a key of any width through a byte at a time loop cost 15% to 17%
of end to end analysis time, consistently across five alternating runs.
Binary searching an index calls it on every probe, so the two widths a
real heap dump lands on are read as a whole int or long instead. With
that, five more alternating runs put the median analysis of
leak_asynctask_o at 159 ms before and 161 ms after, and of
leak_asynctask_m at 166 ms before and 167 ms after; indexing goes from
19 ms to 19 ms and from 25 ms to 26 ms.

Everything here is internal, so there is no ABI change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau merged commit 0f7dbab into main Aug 21, 2026
14 checks passed
@pyricau
pyricau deleted the index-entry-shrink branch August 21, 2026 15:26
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