|
| 1 | +# The referrer index |
| 2 | + |
| 3 | +`ReferrerIndex` answers "what points at this object", for every object of a heap dump, from memory. It is |
| 4 | +built once per open dump and held until the window closes, so **what it holds is what matters** — the peak |
| 5 | +while it is being built is a fraction of the dominator tree standing beside it. |
| 6 | + |
| 7 | +It used to be one linked list per object: an int per object for the head, and two ints per reference. It is |
| 8 | +now one delta encoded byte slice per object. Everything below was measured on the ten real Android heap |
| 9 | +dumps in this repo, with a copy of the linked list version alongside the new one in one JVM, checking that |
| 10 | +the two hand back the same referrers before timing either. |
| 11 | + |
| 12 | +## What it holds |
| 13 | + |
| 14 | +| dump | objects | references | linked | encoded | linked B/ref | encoded B/ref | smaller by | |
| 15 | +| --- | --- | --- | --- | --- | --- | --- | --- | |
| 16 | +| `large-dump.hprof` | 387 971 | 514 515 | 5 668 004 | 1 932 355 | 11.02 | 3.76 | 2.93x | |
| 17 | +| `safe_iterable_map.hprof` | 333 566 | 564 595 | 5 851 024 | 1 839 201 | 10.36 | 3.26 | 3.18x | |
| 18 | +| `gcroot_unknown_object.hprof` | 424 418 | 531 264 | 5 947 784 | 2 085 570 | 11.20 | 3.93 | 2.85x | |
| 19 | +| `compose_leak.hprof` | 258 993 | 410 447 | 4 319 548 | 1 447 172 | 10.52 | 3.53 | 2.98x | |
| 20 | +| `unloaded_classes-stripped.hprof` | 332 905 | 861 174 | 8 221 012 | 2 014 588 | 9.55 | 2.34 | 4.08x | |
| 21 | +| `hashmap_api_25.hprof` | 149 742 | 118 402 | 1 546 184 | 569 903 | 13.06 | 4.81 | 2.71x | |
| 22 | +| `leak_asynctask_m.hprof` | 139 818 | 132 083 | 1 615 936 | 608 214 | 12.23 | 4.60 | 2.66x | |
| 23 | +| `gc_root_in_non_primary_heap.hprof` | 133 177 | 88 854 | 1 243 540 | 467 028 | 14.00 | 5.26 | 2.66x | |
| 24 | +| `leak_asynctask_o.hprof` | 129 757 | 94 145 | 1 272 188 | 473 415 | 13.51 | 5.03 | 2.69x | |
| 25 | +| `leak_asynctask_pre_m.hprof` | 45 385 | 57 337 | 640 236 | 220 773 | 11.17 | 3.85 | 2.90x | |
| 26 | + |
| 27 | +Array payload only, both sides, which is all either holds. |
| 28 | + |
| 29 | +**Bytes per reference is the wrong unit for these dumps, and it is worth knowing why.** The encoding |
| 30 | +`parttimenerd/hprof-analyzer` uses reaches 1.29 bytes an edge at 1.65 G edges; here the same encoding lands |
| 31 | +between 2.3 and 5.3. Nothing is wrong: **these dumps have between 0.67 and 2.59 references per object**, so |
| 32 | +the per-object cost dominates. Every object needs a byte saying how long its slice is even when it is empty, |
| 33 | +and 14% to 65% of the objects in these dumps have nothing pointing at them at all. Divide the same numbers by |
| 34 | +objects rather than references and the spread collapses to 3.5 to 6.1 bytes an object, against the 12 to 15 |
| 35 | +the linked list held. So **the saving scales with objects, not with references**, and a dump with a denser |
| 36 | +graph gets closer to their figure — `unloaded_classes-stripped.hprof`, the densest here at 2.59 references |
| 37 | +an object, is the best of the ten at 2.34. |
| 38 | + |
| 39 | +## The smallest heap a session runs in |
| 40 | + |
| 41 | +A minimum-heap ladder over `large-dump.hprof` — open the dump the way a window does, find the leaks, then ask |
| 42 | +for the chain from a GC root to 4 000 objects, and take the smallest `-Xmx` the run still completes in: |
| 43 | + |
| 44 | +| | linked | encoded | |
| 45 | +| --- | --- | --- | |
| 46 | +| Building the index alone | 41 MB | 41 MB | |
| 47 | +| A session holding the tree and the index | 106 MB | 98 MB | |
| 48 | + |
| 49 | +Two things follow. **The build peak does not move**, which it shouldn't: the new form is compacted out of the |
| 50 | +same linked lists, so both runs peak on the same two `MutableIntList`s and the encoded bytes growing beside |
| 51 | +them are lost in the slack those lists already carry. **The session floor drops by 8 MB**, more than the |
| 52 | +3.7 MB of arrays, because a collector needs headroom in proportion to what is live. |
| 53 | + |
| 54 | +## Why the offsets are per four objects and not per sixteen |
| 55 | + |
| 56 | +A slice is self delimiting, so the index into `referrers` can be sampled rather than complete: one byte |
| 57 | +offset per block of objects, and a lookup steps over the slices of its block to reach its own. The block size |
| 58 | +trades bytes against those steps, and it is the one number here that had to be measured rather than reasoned |
| 59 | +about. Bytes are exact — the offsets are 4 × ⌈objects / block⌉ — and the time is 200 breadth first walks up |
| 60 | +the referrers on `large-dump.hprof`, reaching 1.93 M objects between them: |
| 61 | + |
| 62 | +| objects per block | encoded bytes | walks, linked | walks, encoded | |
| 63 | +| --- | --- | --- | --- | |
| 64 | +| 16 | 1 641 379 | 59–68 ms | 80–88 ms | |
| 65 | +| 8 | 1 738 371 | 51–56 ms | 51–57 ms | |
| 66 | +| **4** | **1 932 355** | **55–61 ms** | **46–47 ms** | |
| 67 | +| 2 | 2 320 327 | 52–56 ms | 36–39 ms | |
| 68 | +| 1 (an offset per object) | 3 096 267 | 60–64 ms | 32–39 ms | |
| 69 | + |
| 70 | +**Sixteen, which is what the Rust analyzer uses, makes a walk a third slower than the linked list it |
| 71 | +replaced.** Eight breaks even. Four is 20% *faster* than the linked list while holding a third of what it |
| 72 | +held, and that is where this stops — not because the curve stops there, but because it is the first rung |
| 73 | +where nothing has been given up. Two and one are faster still, and buying that would mean giving back 0.4 MB |
| 74 | +and 1.2 MB of the 3.7 MB this change is *for*, to save fractions of a millisecond per chain on a question |
| 75 | +whose budget is a hundred of them. |
| 76 | + |
| 77 | +Their sixteen is the right answer for their problem and not for this one: their offsets are gigabytes at |
| 78 | +514 M objects, and their walk is a one-off inside a batch analysis, where this one is what a pointer moving |
| 79 | +over a treemap asks for. |
| 80 | + |
| 81 | +The reason four is faster than a linked list at all is that a slice is a run of adjacent bytes where the |
| 82 | +linked list was a pointer chase through two int arrays the size of the whole dump — sequential reads against |
| 83 | +a cache miss per reference. |
| 84 | + |
| 85 | +Build time is unchanged, 610–646 ms against 611–634 ms on `large-dump.hprof` over four alternating rounds: |
| 86 | +the compaction is a sort of a handful of ints per object, and the pass over the dump that dominates it is the |
| 87 | +same pass. |
| 88 | + |
| 89 | +**A sweep of every object's referrers is still slower** — 5.6 ms against 3.9 ms sequentially, 11.7 against |
| 90 | +8.0 in a shuffled order — because a sweep asks about the third to two thirds of objects nothing points at, |
| 91 | +where the linked list reads one int and this steps over a block. Walks are what the app does; sweeps are not. |
| 92 | + |
| 93 | +## The order the referrers come back in did not change, and could not |
| 94 | + |
| 95 | +The linked list handed back **the highest object index first**, because it was built by prepending while |
| 96 | +`graph.objects` ran in index order. Sorted ascending is the natural thing to store, and it is what the Rust |
| 97 | +analyzer stores, so the first version of this stored it that way — a variable length int can only be read |
| 98 | +forwards, so what is stored is what a lookup hands back. |
| 99 | + |
| 100 | +**That reversal is not a cosmetic tie-break.** A breadth first walk up the referrers takes whichever of two |
| 101 | +equally distant referrers it sees first, and the search for every way an object is held is greedy — it blocks |
| 102 | +the middle of each path it finds so the next path has to go another way. On the heap dump |
| 103 | +`HeapExplorerTest.cachedPayloadHeapDump` builds, where a tile holds an image both through its view and |
| 104 | +through the request that loaded it, and a cache holds the same image through that request's wrapper: |
| 105 | + |
| 106 | +- highest index first: two chains, `Tile → view → image` and `Cache → wrapper → image`. |
| 107 | +- lowest index first: the first walk claims the wrapper, so the second cannot use it — two chains, both from |
| 108 | + the tile, and **the cache never appears as a holder at all**. |
| 109 | + |
| 110 | +So the slices are stored counting **down** from the last object of the heap dump instead, which costs 0.7% |
| 111 | +more bytes than counting up (the one absolute value in a slice becomes the distance to the end of the dump |
| 112 | +rather than the distance from its start) and hands back exactly the old order. Verified per object rather |
| 113 | +than argued: over all ten dumps every object's referrers came back in the same order and with the same |
| 114 | +`isLowPriority` bits as the linked list gave, 68 515 of 68 515 multi-referrer objects on `large-dump.hprof` |
| 115 | +and every one of the others. The session ladder above is the same check end to end — both implementations |
| 116 | +report 2 leak groups, 4 000 chains and 8 664 steps on `large-dump.hprof`. |
| 117 | + |
| 118 | +Which is why there is no leak-fingerprint sweep to go with this change. The sweep over the ten dumps that |
| 119 | +`decisions.md` records at 8 of 10 dumps and 12 of 15 leaks is a function of which referrers the index hands |
| 120 | +back in which order, and that is byte for byte what it was. |
| 121 | + |
| 122 | +## Two places this deliberately differs from `parttimenerd/hprof-analyzer` |
| 123 | + |
| 124 | +Read `src/pass2/model.rs` (`encode_phase4` and the `INB_BLOCK` comment), `src/vbyte.rs` and |
| 125 | +`src/chunkvec.rs` for theirs. Besides the block size above: |
| 126 | + |
| 127 | +**A slice is prefixed with its byte length, where theirs is prefixed with the count of referrers.** A count |
| 128 | +makes stepping over a slice a walk of every byte of it, because only the continuation bits say where each |
| 129 | +value ends. Nearly every dump here has an object twenty thousand others point at — 23 871 on |
| 130 | +`unloaded_classes-stripped.hprof`, 20 533 on `safe_iterable_map.hprof` — and a lookup of anything sharing |
| 131 | +that object's block would have had 50 KB to read past. With a length it is a read and an add. |
| 132 | + |
| 133 | +**Nothing is freed as the compaction advances**, where their `chunkvec.rs` hands each 256 MB chunk of the |
| 134 | +source back to the allocator as the read cursor passes it. It cannot be done from a linked list: the |
| 135 | +references pointing at one object are spread over the whole of it, so the compaction reads the source in |
| 136 | +scattered order, not left to right. Making it consumable in order means a first pass over the heap dump to |
| 137 | +count what points at each object before anything can be stored — about a quarter added to the time it takes |
| 138 | +to open a dump, to lower a peak the ladder above shows is not the binding one. If the peak ever becomes the |
| 139 | +constraint, that is the change to make. |
| 140 | + |
| 141 | +**And referrers are not translated into another numbering.** Theirs are turned into dominator pre-order |
| 142 | +numbers, which is both what their algorithm needs and why their deltas are so small: pre-order puts a node's |
| 143 | +predecessors near each other. Here they stay `HeapObject.objectIndex`, which is the order the dump was |
| 144 | +written in, so the deltas are only as small as the dump's own locality makes them. Renumbering would mean a |
| 145 | +translation table the size of the dump and a second numbering for every caller to hold, which is a larger |
| 146 | +change than this one and worth measuring separately if the bytes ever matter more than they do now. |
0 commit comments