CN

Android Perfetto Series 14: heapprofd and Memory Profiling

Word count: 5.7kReading time: 35 min
2026/05/04
loading

The hardest part of a memory problem is that a rising number does not immediately tell you what is growing. dumpsys meminfo provides RSS/PSS, and a Java heap dump reveals object retention relationships. But for JNI/C++, CPU-side Skia/Bitmap allocations, and native wrappers in media players, you often also need to know which call stack issued the malloc/new request.

This article covers heapprofd. Its value is bringing native allocations, frees, and call stacks into a Perfetto trace, where you can inspect them alongside application events, thread scheduling, and Binder on the same timeline.

Use heapprofd after confirming the trend. I would split the growth between native heap, graphics buffers, and other mappings with meminfo or smaps before opening a flamegraph. Otherwise, it is easy to chase a malloc stack when the growth came from GraphicBuffer. This article does not address Java object references or graphics backing memory.

We will cover when to use heapprofd, how to capture a profile, how to read it in the UI and SQL, how to interpret sampling intervals and symbolization, and finally what to check before publishing a report.

Version note (reviewed October 2026): Configuration, script, SQL, and current UI behavior are checked against Perfetto v58.3. Native heapprofd starts at Android 10; examples using named heaps here target Android 12+. The UI section distinguishes historical diamonds from current interval results, which are not the process’s total retained memory. Record producer, capture-script, and Trace Processor/UI versions separately.

Perfetto Series Catalog

  1. Android Perfetto Series Catalog
  2. Android Perfetto Series 1: Introduction to Perfetto
  3. Android Perfetto Series 2: Capturing Perfetto Traces
  4. Android Perfetto Series 3: Familiarizing with the Perfetto View
  5. Android Perfetto Series 4: Opening Large Traces via Command Line
  6. Android Perfetto Series 5: Choreographer-based Rendering Flow
  7. Android Perfetto Series 6: Why 120Hz? Advantages and Challenges
  8. Android Perfetto Series 7: MainThread and RenderThread Deep Dive
  9. Android Perfetto Series 8: Understanding Vsync and Performance Analysis
  10. Android Perfetto Series 9: Interpreting CPU Information
  11. Android Perfetto Series 10: Binder Scheduling and Lock Contention
  12. Android Perfetto Series 11: PerfettoSQL, Trace Processor and Regression Detection
  13. Android Perfetto Series 12: Trace Dataflow and Data Loss
  14. Android Perfetto Series 13: Perfetto SDK, Track Event and App Field Traces
  15. Android Perfetto Series 14: heapprofd and Memory Profiling
  16. Android Perfetto Series 15: Boot Traces and Long-running Field Tracing
  17. Android Perfetto Series 16: GPU, Power Counters and Hardware Bottlenecks
  18. Android Perfetto Series 17: Scenario Automation and Platform Tracing
  19. Android Perfetto Series 18: Input Response Latency
  20. Video (Bilibili) - Android Perfetto Basics and Case Studies
  21. Video (Bilibili) - Android Perfetto: Trace Graph Types - AOSP, WebView, Flutter + OEM System Optimization

Keep Memory Accounting Categories Separate

Before capturing a heapprofd profile, separate memory data into several categories. Many incorrect conclusions come from mixing them together.

Data Common sources Questions it answers
Process RSS / swap trends linux.process_stats Are the target process’s RSS, anon/file RSS, swap, or oom_score_adj changing?
Process PSS / Private Dirty dumpsys meminfo, /proc/<pid>/smaps_rollup, process_stats_config.scan_smaps_rollup Are PSS, Private Dirty, or SwapPss rising? scan_smaps_rollup is subject to permissions and target-process restrictions.
System memory pressure linux.sys_stats Do MemAvailable, Cached, ZRAM, vmstat, and PSI indicate reclaim activity or memory pressure?
Native allocation call stacks android.heapprofd Which malloc/new call stacks contributed allocations, and which allocations remain unreleased?
Java object retention relationships android.java_hprof, MAT, Android Studio Profiler Which Java objects are still held, and by whom?

By default, heapprofd accounts for the bytes the target process requests from allocators such as libc malloc/free and new/delete. This is not RSS, and it does not cover all native backing memory.

Attaching to a running process does not reconstruct allocations made before profiling started. “Unreleased” covers sampled allocations observed during this profiling run, not the process’s entire lifetime merely because its name matched.

GraphicBuffer, dma-buf, GPU textures, driver-side allocations, direct mmap/memfd/ashmem allocations, and allocators that bypass libc malloc are outside the default malloc accounting. For malloc-backed arenas, you may see only the large arena allocation, not the objects inside it. To include a custom allocator, use heapprofd’s custom allocator API (AHeapProfile_registerHeap and related APIs; this requires linking the client library and changing the allocation path), then name the custom heap in heapprofd_config.heaps. This requires dedicated integration work; it is not a default capability. For graphics memory, start with meminfo Graphics/GL, dmabuf, GPU/graphics counters, and SurfaceFlinger / BufferQueue relationships.

heapprofd only adds malloc/new evidence along app/HWUI/Skia/JNI paths.

Use this table to choose the next step:

Symptom Preferred tools Role of heapprofd
Java heap grows, with clear object retention HPROF / MAT / Android Studio Not the first entry point
Native Heap / malloc wrappers grow heapprofd Locate malloc/new call stacks
Graphics / GL / dmabuf / Surface buffers grow meminfo, dmabuf, GPU/graphics, SurfaceFlinger Only check whether app/native wrappers are also allocating
RSS/PSS grows but heapprofd values remain low smaps_rollup, maps, mmap/syscall, dmabuf/graphics Separate the accounting categories first; do not immediately conclude that the heapprofd capture is wrong
RSS grows but PSS does not smaps / file mappings / allocator caches Do not conclude that there is a leak yet

Allocator thread caches, fragmentation, ZRAM, and page granularity can all cause RSS and heapprofd to disagree. When RSS is high but heapprofd values are low, consider these boundaries before assuming a capture failure.

The reverse also holds: high cumulative allocations on a call stack in heapprofd do not mean the process’s RSS will rise by the same amount. This may be allocation churn: memory is allocated frequently and freed quickly, producing a high Total malloc size but a low Unreleased malloc size. First distinguish memory that remains held from repeated creation of temporary objects.

For suspected mmap or other out-of-scope allocations, start with smaps Private_Dirty/Rss/Pss and maps. Perfetto raw_syscalls capture depends on kernel events and permissions; documented Android support is userdebug, not “all Android 14+ devices.” A sys_mmap slice normally gives call timing without address/length arguments, so it cannot reconstruct mapped-byte accounting. perf/simpleperf syscall stacks require their own event and sampling permissions.

From native allocation sampling to call stacks

Conceptual diagram: heapprofd samples native allocations and records call stacks; a Heap Profile is not an RSS ledger.

The “Sampling & Call Stack” box abbreviates two stages: the target copies sampled raw stacks/registers into shared memory, then heapprofd asynchronously unwinds and accounts for them before writing trace data. Full symbolization does not happen synchronously on every malloc.

When to Use heapprofd

When a Java heap dump cannot explain the growth, first identify which memory category is growing. Proceed to heapprofd only when the native heap or native wrappers are clearly suspect.

heapprofd is suitable for these problems:

  • Native Heap / malloc wrappers keep growing, but a Java heap dump shows no obvious object retention.
  • Bitmap, Skia, media-player buffers, or JNI/C++ modules may be holding native backing memory allocated through libc malloc/new.
  • Abnormal malloc/new behavior in C++ modules, native services, or daemons.
  • Checking whether unreleased allocations increase after a particular application operation.
  • Correlating memory growth with thread execution, Binder, and application Track Events.

These problems should not start with heapprofd:

  • You only need Java object reference relationships. Use a Java heap dump.
  • You want an exact ledger of every small allocation. heapprofd is a sampling profiler by default.
  • The target app on a user build is neither debuggable nor profileable. Such processes generally cannot be profiled by the shell.
  • GraphicBuffer, dma-buf, GPU driver memory, or Surface buffer counts are rising. Start with evidence from meminfo Graphics/GL, dmabuf, GPU/graphics, and SurfaceFlinger; use heapprofd only to rule out or locate malloc/new wrapper allocations.

Target eligibility also needs separate consideration. User builds primarily support debuggable/profileable Java apps; userdebug/eng builds can profile most apps and system services, but some critical services may be prohibited by SELinux policies such as never_profile_heap. System processes are not unlocked through <profileable>.

Capture a Native Heap Profile Quickly

Start with tools/heap_profile. Run the example from a Perfetto v58.3 source checkout with host Python 3, adb, and an authorized device. It is not an Android system command. The script may download host conversion tools from its manifest; record the script and actual tool versions separately from the device OS.

This command captures by process name for 15 seconds, with a sampling interval of 4096 bytes and a continuous dump every 5 seconds:

1
2
3
4
5
6
7
8
out_dir=$(mktemp -d /tmp/heapprofd-com-example-app.XXXXXX)
tools/heap_profile android \
-n com.example.app \
-d 15000 \
-i 4096 \
-c 5000 \
--no-block-client \
-o "$out_dir"

The script requires an existing, empty -o directory; mktemp -d creates one for this run. The output directory contains raw-trace and converted profile files. Drag raw-trace into Perfetto UI to inspect the target’s Heap Profile track if profiling succeeded.

Use -n com.example.app to match running targets and future processes with that name; PID selection targets a particular running instance. For multiple processes, use -n com.example.app,com.example.app:remote,com.example.app:player. The v58.3 script expects a comma-separated list; repeated -n flags overwrite earlier values. In handwritten configs, repeat process_cmdline instead. Each target still needs profiling permission, and startup capture may miss the earliest zygote-specialization allocations. v57+ cmdline wildcards require no_startup=true and match only running processes; com.example.app* is not a startup-process list.

To capture an app through adb / shell on a user build, allow shell profiling in its manifest:

1
2
3
<application ...>
<profileable android:shell="true" />
</application>

This only grants local shell profiling access. It does not mean that arbitrary user processes can be profiled remotely in production.

From Android 15/API 35, apps can request a heap profile through ProfilingManager, with an executor/listener and handling for delayed starts, rate limits, and failures. Trigger registration starts at API 36. Android 17/API 37’s OOM trigger returns a Java heap dump, not a malloc profile; custom UncaughtExceptionHandlers must call the default handler. ANOMALY output depends on the anomaly. Trigger results use a global listener and remain subject to system policy. Keep production requests separate from laboratory adb/shell capture.

Capture It Alongside a System Trace

The script is useful for quick investigation, while a handwritten TraceConfig lets you combine heapprofd with system data sources. Start with the minimum heapprofd settings: android.heapprofd, process_cmdline, the sampling interval, continuous dumps, shared memory, and the blocking policy.

System context is optional. This process_stats example polls RSS/swap, not PSS automatically. On Android, scan_smaps_rollup normally requires root traced_probes/tracebox; profileable alone does not grant that access. The sys_stats block enables meminfo/vmstat but not psi_period_ms. ftrace and track_event align the application window; replace producer_name_filter with the observed producer name. Compare scenario timing against an unprofiled run to assess perturbation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
duration_ms: 15000

buffers { size_kb: 65536 fill_policy: DISCARD } # 0: heapprofd
buffers { size_kb: 16384 fill_policy: RING_BUFFER } # 1: system context

data_sources {
config {
name: "linux.process_stats"
target_buffer: 1
process_stats_config {
scan_all_processes_on_start: true
proc_stats_poll_ms: 1000
}
}
}

data_sources {
config {
name: "linux.sys_stats"
target_buffer: 1
sys_stats_config {
meminfo_period_ms: 1000
meminfo_counters: MEMINFO_MEM_AVAILABLE
meminfo_counters: MEMINFO_CACHED
meminfo_counters: MEMINFO_SWAP_FREE
meminfo_counters: MEMINFO_ZRAM
vmstat_period_ms: 1000
vmstat_counters: VMSTAT_PGFAULT
vmstat_counters: VMSTAT_PGMAJFAULT
vmstat_counters: VMSTAT_OOM_KILL
}
}
}

data_sources {
config {
name: "linux.ftrace"
target_buffer: 1
ftrace_config {
ftrace_events: "sched/sched_switch"
ftrace_events: "sched/sched_waking"
atrace_categories: "am"
atrace_categories: "view"
atrace_apps: "com.example.app"
}
}
}

data_sources {
config {
name: "track_event"
target_buffer: 1
track_event_config {
disabled_categories: "*"
enabled_categories: "memory"
enabled_categories: "app"
}
}
producer_name_filter: "com.example.app"
}

data_sources {
config {
name: "android.heapprofd"
target_buffer: 0
heapprofd_config {
sampling_interval_bytes: 4096
process_cmdline: "com.example.app"
continuous_dump_config {
dump_phase_ms: 5000
dump_interval_ms: 5000
}
shmem_size_bytes: 8388608
block_client: false
max_heapprofd_memory_kb: 262144
max_heapprofd_cpu_secs: 30
}
}
}

This is still a native heap template, not a graphics memory template. For GraphicBuffer, dma-buf, GPU textures, and Surface buffers, additionally compare dmabuf data, GPU/graphics counters, dumpsys meminfo Graphics/GL, and SurfaceFlinger layer / buffer information. heapprofd can only establish whether malloc/new contributes corresponding allocations.

These central buffers total 80 MiB, below the default 128 MiB limit when extra guardrails are enabled; the original 160 MiB config could be rejected. Measure dump bursts to size them. A full DISCARD buffer drops new data, potentially damaging the final dump. RING may overwrite historical profile packets, interned stacks, or statistics, so it is not an unconditional replacement. First reduce targets, duration, or sampling density, then assess memory and file-writing budgets.

For ad hoc investigation, piping the PBTX directly into perfetto through stdin is the most reliable approach. Place the Android output under /data/misc/perfetto-traces/:

1
2
3
adb push config.pbtx /data/local/tmp/config.pbtx
adb shell 'cat /data/local/tmp/config.pbtx | perfetto -c - --txt -o /data/misc/perfetto-traces/heap.pftrace'
adb pull /data/misc/perfetto-traces/heap.pftrace .

Handwritten HeapprofdConfig defaults to block_client=false, whereas the script defaults to blocking. The quick command explicitly uses --no-block-client: shared-memory exhaustion can end the profile early, not merely drop a few samples and continue. The 8 MiB shmem_size_bytes buffer sits between the target and heapprofd; it is separate from the central buffers above.

A laboratory leak test can evaluate block_client=true with block_client_timeout_us, trading target malloc-path progress for a chance to keep up, not a lossless guarantee. v58.3 requires a timeout above 100 microseconds for retries and still stops at timeout. Android 10/11 also have known small-buffer/blocking lockup issues. Start interaction tests nonblocking and check overruns, non-finalized state, actual dumps, and window coverage. max_heapprofd_memory_kb/max_heapprofd_cpu_secs limit the profiler’s memory/CPU time, not the target app’s heap or wall-clock duration.

When adding heapprofd to a system trace, retain a small set of application markers as well. In-app android.os.Trace / AndroidX Trace events go through atrace; Perfetto SDK Track Events require the track_event data source to be enabled separately.

A media-player scenario, for example, should have at least four marker categories: operation_start, steady_state, operation_end, and release_done/gc_idle_done. Without a release marker, the conclusion can only be “retained allocations increased after the operation,” not “a leak.” The official documentation lists android.log support for userdebug. A rooted user build may also capture it if logd/SELinux permissions allow, but adb root alone does not establish support on every device. On ordinary production user builds, do not assume this configuration captures logcat evidence by default.

Reading Heap Profiles in the UI

Older UIs commonly displayed heap-profile dumps as diamonds; the two diagrams retain that schematic style and are not current UI screenshots. Perfetto v58.3 displays profiling intervals as slices for the target process/heap. Click a slice for its flamegraph or select consecutive slices to summarize a window. Check process, heap, and selected time range first.

Selecting one v58.3 slice gives interval metrics, not an automatic sum from profiling start to its end. The UI filters callsites by net count over the selection, hiding some negative contributions from freeing allocations made before that window. Thus Unreleased is not automatically total retained memory at that instant. For before/after retained state, use the cumulative upid/heap SQL below after checking historical completeness. Read the profile in this order:

  1. Distinguish interval Unreleased from retention accumulated since profiling began; do not subtract unrelated interval values.
  2. Check Total malloc size to see whether allocation churn is high but frees are also prompt.
  3. Use flamegraph widths and self/cumulative metrics to find major call paths; view labels depend on the UI version.
  4. Locate the first app, SDK, or application-domain frame above the allocator / ART glue.
  5. Return to the timeline and correlate the triggering operation, application Track Events, and thread execution.

Observing retained-memory changes across continuous dumps

Hypothetical diagram using historical diamonds: 2→5→4 MiB are retained estimates accumulated from the same start to three dumps, not the default UI values for three separate v58.3 intervals. One point cannot establish a leak.

Do not diagnose a leak from one profile point or interval. At minimum, compare points before the operation, after the operation, and after cleanup. If memory rises after the operation and still does not fall after cleanup, inspect the unreleased call stacks.

With sufficient permissions, adb shell killall -USR1 heapprofd requests an extra snapshot for all processes currently profiled by that daemon, not just this app. For example, request one before entering a page, another once the page settles, and a third after leaving it and completing release/close/destruction. The dump may occur after the request; inspect heap_profile.ts_end. Choose a completed baseline before the operation and an after-release dump after the cleanup marker, rather than a nearest point on the wrong side.

Include “waiting for idle, finalizers, and GC to complete” in this state point only when the native backing is owned by a Java wrapper, NativeAllocationRegistry, or a Cleaner/finalizer. Allocator caches and fragmentation can also mean that heapprofd values fall while RSS does not fall immediately. “Cache” is not a euphemism for “never freed”: there must be a capacity limit, evidence of hits/reuse, and a reclamation policy on exit or under pressure. Otherwise, describe it only as suspected long-term retention.

Querying Unreleased Allocations with SQL

heapprofd writes to these call-stack-related tables:

Table Meaning
heap_profile_allocation Allocation/free samples and sizes
stack_profile_callsite Call-stack nodes
stack_profile_frame Function frames
stack_profile_mapping so, apk, jar, and binary mappings
stack_profile_symbol Offline symbolization results

For a quick view of a single-target profile, start with the official standard library’s summary tree. It aggregates entire call stacks along stack_profile_callsite.parent_id, producing results closer to the flamegraph’s cumulative view:

1
2
3
4
5
6
7
8
9
10
11
12
INCLUDE PERFETTO MODULE android.memory.heap_profile.summary_tree;

SELECT
id, parent_id, name,
mapping_name AS map_name,
self_size AS self_bytes,
cumulative_size AS cumulative_bytes,
cumulative_size / 1024.0 / 1024.0 AS unreleased_mib
FROM android_heap_profile_summary_tree
WHERE cumulative_size > 0
ORDER BY cumulative_size DESC
LIMIT 50;

The summary tree aggregates records across all processes, heaps, and times in the trace. This example is only suitable for inspecting the final cumulative state when capturing a single process’s native heap; it is not a snapshot of an arbitrary endpoint or a new interval. It has no process column, so do not directly use it for multiprocess regression statistics. For multiprocess apps, :remote processes, or restarts of identically named processes, return to the raw tables and filter by upid, process identity, and dump endpoint to avoid attributing another process’s allocations to the target.

For an explicit process instance and dump endpoint, inspect process/heap_profile first, then replace NULL below with the verified upid. Positive allocations and negative frees summed through ts_end estimate retention for the observed profiling period, unlike the current UI’s single-interval Unreleased metric. heap_profile_allocation is delta-encoded per dump; ts is the dump timestamp, not each malloc’s execution time. Select libc.malloc only: com.android.art does not record frees and cannot be counted as unreleased native memory.

1
2
3
4
5
6
SELECT p.upid, p.pid, p.name, p.cmdline, p.start_ts,
h.heap_name, h.ts AS window_start_ns, h.ts_end AS dump_end_ns
FROM process p
LEFT JOIN heap_profile h USING (upid)
WHERE p.name = 'com.example.app'
ORDER BY p.upid, h.heap_name, h.ts_end;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
WITH target_process AS (
SELECT upid, pid, name, cmdline
FROM process
WHERE upid = NULL
),
target_dump AS (
SELECT MAX(ts_end) AS dump_ts
FROM heap_profile
WHERE upid = (SELECT upid FROM target_process)
AND heap_name = 'libc.malloc'
)
SELECT
d.dump_ts,
p.upid,
p.pid,
p.name AS process_name,
p.cmdline AS process_cmdline,
a.callsite_id,
f.name AS frame_name,
m.name AS mapping_name,
SUM(a.size) AS leaf_unreleased_bytes,
SUM(a.size) / 1024.0 / 1024.0 AS leaf_unreleased_mib,
SUM(a.count) AS unreleased_count
FROM heap_profile_allocation a
JOIN target_process p USING (upid)
JOIN target_dump d ON a.ts <= d.dump_ts
LEFT JOIN stack_profile_callsite c ON a.callsite_id = c.id
LEFT JOIN stack_profile_frame f ON c.frame_id = f.id
LEFT JOIN stack_profile_mapping m ON f.mapping = m.id
WHERE a.heap_name = 'libc.malloc'
GROUP BY d.dump_ts, p.upid, p.pid, p.name, p.cmdline, a.callsite_id, f.name, m.name
HAVING leaf_unreleased_mib > 0
ORDER BY leaf_unreleased_bytes DESC
LIMIT 50;

This query retains callsite_id and attaches only its leaf-frame name; it does not expand a stack into multiple rows. Missing names must not discard the associated bytes. Summary-tree cumulative_size includes descendants, so parent/child rows overlap; summing the top 50 rows is not a total. Final attribution still needs full call paths and matching-build symbols.

A dump with no allocation changes may have no new allocation rows, so select endpoints from heap_profile.ts_end instead of treating MAX(allocation.ts) as proof of the final dump. A heap_profile row has no boolean certifying complete lossless capture; also inspect packet completion, stats, and coverage. If an older Trace Processor lacks this table, upgrade or verify separately. Missing tables/data are not zero, and SQL cannot invent unparsed history.

Leak-candidate assessment compares before-operation, after-operation, and after-release points. Use one upid/heap and three verified, strictly increasing dump endpoints. NULL entries must be filled before the query can produce a report. It returns comparisons only when all three selected dumps exist:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
WITH target_process AS (
SELECT upid, pid, name
FROM process
WHERE upid = NULL
),
selected_dumps(label, dump_ts) AS (
VALUES
('baseline', NULL),
('after_action', NULL),
('after_release', NULL)
),
valid_dumps AS (
SELECT d.* FROM selected_dumps d
WHERE EXISTS (
SELECT 1 FROM heap_profile h
WHERE h.upid = (SELECT upid FROM target_process)
AND h.heap_name = 'libc.malloc' AND h.ts_end = d.dump_ts
)
),
alloc_by_dump AS (
SELECT
d.label,
a.callsite_id,
SUM(a.size) / 1024.0 / 1024.0 AS unreleased_mib
FROM heap_profile_allocation a
JOIN valid_dumps d ON a.ts <= d.dump_ts
WHERE a.upid = (SELECT upid FROM target_process)
AND a.heap_name = 'libc.malloc'
GROUP BY d.label, a.callsite_id
),
pivot AS (
SELECT
callsite_id,
SUM(CASE WHEN label = 'baseline' THEN unreleased_mib ELSE 0 END) AS baseline_unreleased_mib,
SUM(CASE WHEN label = 'after_action' THEN unreleased_mib ELSE 0 END) AS after_action_unreleased_mib,
SUM(CASE WHEN label = 'after_release' THEN unreleased_mib ELSE 0 END) AS after_release_unreleased_mib
FROM alloc_by_dump
GROUP BY callsite_id
)
SELECT
callsite_id,
baseline_unreleased_mib,
after_action_unreleased_mib,
after_release_unreleased_mib,
after_action_unreleased_mib - baseline_unreleased_mib AS action_delta_mib,
after_release_unreleased_mib - baseline_unreleased_mib AS retained_delta_mib,
after_action_unreleased_mib - after_release_unreleased_mib AS net_decrease_after_action_mib,
CASE
WHEN after_release_unreleased_mib - baseline_unreleased_mib > 1 THEN 'leak_candidate'
WHEN after_action_unreleased_mib > baseline_unreleased_mib
AND after_release_unreleased_mib <= baseline_unreleased_mib THEN 'transient_growth'
ELSE 'unknown'
END AS conclusion_type
FROM pivot
WHERE (SELECT COUNT(*) FROM valid_dumps) = 3
AND (SELECT dump_ts FROM selected_dumps WHERE label = 'baseline')
< (SELECT dump_ts FROM selected_dumps WHERE label = 'after_action')
AND (SELECT dump_ts FROM selected_dumps WHERE label = 'after_action')
< (SELECT dump_ts FROM selected_dumps WHERE label = 'after_release')
ORDER BY retained_delta_mib DESC
LIMIT 50;

All three dumps existing does not establish completeness. Validate the profiling session, start time, loss, and stacks first. No rows can mean unfilled parameters, missing dumps, or no usable callsites; it must not become “everything freed.” A pivot zero for a stack not yet seen is valid only within a qualified single capture. The 1 MiB threshold is illustrative, not a statistical-significance test.

Persistent retention after cleanup across repeated runs warrants investigating a leak candidate. Growth followed by a fall is transient growth between observed points, not proof of the maximum between dumps. Before/after differences are net changes: new allocations can offset frees, so a difference is not total freed bytes. Cache claims still need capacity, reuse, and reclamation evidence.

A report can use this fixed set of fields:

This is an illustrative record, not direct SQL output. Symbol status, sampling counts, loss, and evidence grades require separate computation or verification. Values divided by 1024² use MiB / _mib.

1
2
trace_name,process_name,upid,pid,scenario,dump_phase,dump_ts_ms,sampling_interval_bytes,dump_interval_ms,top_stack_frame,mapping_name,unreleased_mib,retained_delta_mib,total_malloc_mib,sampled_allocation_count,symbol_status,profileable_state,data_loss_status,evidence_grade,conclusion_type,next_action
heap-run01,com.example.app,42,1234,player_open,after_release,150000,4096,5000,PlayerBuffer::Alloc,libplayer.so,8.4,6.9,42.1,128,strong,profileable,clean,strong,leak_candidate,inspect_owner_module

Understanding the Sampling Interval

sampling_interval_bytes: 4096 samples by allocated bytes, not at a fixed time frequency. In v58.3, Sampler::SampleSize returns the actual requested size for allocations at least as large as the current interval; smaller allocations use sampling weights. That implementation branch does not make the entire profile exact: start time, loss, unwinding, and adaptive changes to the effective interval still matter.

How to choose an interval:

  • 4096 bytes is a common starting point, suitable for most local analysis.
  • If you suspect large numbers of small native allocations, reduce the interval, at the cost of greater overhead.
  • If a high allocation rate causes buffer overruns, first increase shared memory or increase the sampling interval.
  • Set a nonzero interval for every target heap. An interval of 1 observes all nonzero allocations but is expensive and can still suffer data loss. Zero does not disable sampling and can crash the target on older Android 10/11 releases.

Do not describe sampled results as “exactly 12.34 MB was allocated.” A better formulation is: “At a 4096-byte sampling interval, unreleased allocations were concentrated in call stacks A/B/C, with A contributing the largest share.” A sampling profiler helps establish direction and ranking; it does not replace allocator accounting.

Top-stack rankings become unstable with dense small-object allocations, few samples, short windows, or allocations spread across many call stacks. Before/after comparisons must use the same sampling interval, comparable workloads, and the same target window. Do not compare small differences such as 1% or 2% unless they reproduce across multiple runs. You can assign evidence_grade as follows: sufficient samples, consistent trends across continuous dumps, and complete symbols qualify as strong; data loss, an incomplete profile, unknown symbols, few samples, or only one reproduction make the evidence weak. For weak evidence, say “candidate call stack,” not “root-cause call stack.”

With adaptive sampling, an initial 4096 does not mean the entire run used that interval; record adjustments and effective conditions. Strong is this article’s reporting convention, not a Perfetto statistical confidence level. Android 12 and some Android 13 ART versions can miss Java unwinding; the documented issue is fixed in Android 13 QPR1. Symbols cannot restore frames that were never captured.

Java Heap Dumps and Java Allocation Sampling

This section supplements the memory accounting categories introduced earlier. heapprofd can inspect native malloc/new and, on Android 12 or later, switch to Java allocation sampling, but it still does not replace a Java heap dump.

Capability Output Suitable questions
heapprofd native profiling malloc/new call stacks Who allocated native memory?
Java heap dump Object retention graph Who holds Java objects?
Java allocation sampling Call stacks that create Java objects Which code generates heavy Java allocation churn?

Enable Java allocation sampling through heapprofd with heaps: "com.android.art", or use the script argument --heaps com.android.art. It requires Android 12 or later. It records call stacks at object creation, not when objects are garbage-collected, and is not equivalent to the retention relationships in a Java heap dump.

android.java_hprof produces a Perfetto ART reference graph, not a standard .hprof to open directly in MAT. The MAT/Android Studio HPROF workflow needs its own capture; output formats and available fields differ.

Symbolization and Confidence

For addresses, unknown frames, or obfuscated Java/Kotlin names, first prepare matching unstripped ELF/Breakpad symbols, R8/ProGuard mappings, and llvm-symbolizer on PATH. Then use a Trace Processor supporting bundle. Bundling packages the original trace and symbols it can resolve; it does not create missing symbols. Check coverage and Build IDs afterward. See the v58.3 symbolization guide.

1
2
3
4
5
trace_processor bundle \
--symbol-paths /absolute/path/to/matching-symbols \
--proguard-map com.example.app=/absolute/path/to/mapping.txt \
--verbose \
heap.pftrace heap-symbolized.tar

Replace the paths/package and omit --proguard-map when no Java deobfuscation is needed. Open the archive directly with a supporting UI/Trace Processor. If the installed CLI lacks bundle, follow its version’s workflow rather than treating an unavailable command as successful symbolization.

Record the APK/build ID, native-symbol version, and ProGuard/R8 mapping version in the report. A Build ID mismatch reduces the evidence to address-level observations. When a third-party SDK or stripped so leaves only addresses, the next step is to obtain the matching build’s unstripped symbols / mapping, not to immediately change application code.

Do not blindly filter out libart.so, allocator glue, or system libraries while reading stacks. ART frames can provide essential context for Java-to-native calls, NativeAllocationRegistry, or object creation paths.

First find the first app, SDK, or application-domain frame above allocator/ART glue. Filter a class of frames only after confirming that they are merely repetitive glue. Treat DEDUPED, function folding caused by ICF, Build ID mismatches, and single-frame stacks as symbolization issues first.

Also assess unwinding confidence: the share of unknown frames, the share of single-frame stacks, Build ID matching, Java-frame availability, and known issues for the target ABI / Android version. Missing symbols affect attribution confidence. Distinguish “symbolized and attributable” from “localized only to an so / allocator glue” in the report.

At minimum, document:

  • Whether the target process is profileable/debuggable.
  • sampling_interval_bytes and the continuous-dump interval.
  • Whether stats records heapprofd buffer overruns, client errors, incomplete profiles, heapprofd_unwind_time_us, heapprofd_client_spinlock_blocked, heapprofd_sampling_interval_adjusted, or data loss.

With data loss or overruns, do not present an exact ledger or assume the remaining stack ranking still represents the whole run. Loss can change that ranking. Restrict analysis to a qualified window, or list candidates and recapture when evidence is insufficient.

For the general trace health check (buffer/ftrace loss), use the stats query from article 12; run a separate heapprofd health check. heapprofd_buffer_overran, heapprofd_client_error, and heapprofd_hit_guardrail already exist in v57.2/v58.2. Most are indexed stats whose records are generated on demand. Seeing only two single-value entries in a trace without a heap profile does not imply that the other entries are exclusive to newer versions.

Before interpreting the exception query below, establish that a profile or dump for the target process/heap exists, covers the window, and has completed. With no profile, report profile_unavailable, not “all clean.” Even when a profile exists, an empty exception query only means these exceptions were not recorded. heapprofd_unwind_time_us, heapprofd_client_spinlock_blocked, and heapprofd_sampling_interval_adjusted provide overhead/sampling context; they should not all be treated as failures in the same way as nonzero error/data_loss entries.

heapprofd_last_profile_timestamp is a supporting timestamp for the last profile packet. It cannot replace heap_profile.ts_end, dump windows, and final completion status as proof that the target window is complete. The following query retains this context for review:

1
2
3
4
5
6
7
8
9
SELECT name, idx, severity, source, value
FROM stats
WHERE value != 0
AND (
name GLOB 'heapprofd_*'
OR name GLOB 'stackprofile_*'
OR name GLOB 'symbolization_*'
)
ORDER BY name, idx;

Checks Before Publishing a Report

Capture conditions:

  • Native heapprofd starts at Android 10. These examples target Android 12+, as does Java allocation sampling; verify the device producer and permissions.
  • Capturing a target app through adb / shell on a user build generally requires debuggable or <profileable android:shell="true"/>.
  • Capturing a running process by PID may miss early startup allocations; waiting for startup by process name covers more of startup.
  • Runtime profiling does not take effect immediately. If the target process is very idle, profiling may not activate until another wave of allocations.
  • A target process can be profiled by only one relevant session at a time. If there is a conflict, check for an old perfetto session first.

Data confidence:

  • Review heapprofd, stackprofile, and symbolization stats. Downgrade confidence for overruns, non-finalized profiles, and unknown symbols.
  • Disagreement between heapprofd numbers and RSS is common. Fragmentation, thread caches, allocator caches, and ZRAM can all contribute.

Conclusion boundaries:

  • A high Total malloc size does not mean a leak. Compare it with Unreleased malloc size, dumps with the same cumulative scope, and application-operation timestamps first.
  • High graphics backing memory does not necessarily produce high heapprofd values. Account for Graphics/GL, dmabuf, GPU, and Surface buffers separately.
  • Classify the conclusion as leak / peak / cache / churn / out-of-scope graphics memory / evidence insufficient, each with a different next action.

heapprofd addresses native allocation call stacks. It replaces neither Java heap dumps nor RSS/PSS trends; it supplies the missing answer to “Who allocated native heap memory managed by libc malloc/new?” GraphicBuffer, dma-buf, GPU textures, and Surface buffers belong to separate graphics memory accounting. heapprofd can only help determine whether app/native wrappers are also creating malloc/new pressure.

In practice, follow this sequence:

  1. Confirm the memory trend with process stats, meminfo, smaps, or production monitoring.
  2. If the growth is in Native Heap / malloc wrappers / allocator paths visible to heapprofd, use heapprofd. If only RSS/PSS is rising, first separate the categories using smaps_rollup, meminfo classifications, mmap/syscall data, or dmabuf/graphics evidence.
  3. Inspect Unreleased malloc size and the flamegraph in the UI.
  4. Find the largest contributors with SQL, then return to the flamegraph for complete call stacks.
  5. Complete symbolization and document sampling conditions and confidence.
  6. Classify the result as a leak, peak, cache, churn, graphics memory outside heapprofd’s scope, or insufficient evidence.

References

  1. Memory: Callstack-based Allocation Profiling
  2. Memory: Java heap dumps
  3. Memory counters and events
  4. heapprofd design
  5. TraceConfig reference
  6. Track Events
  7. Android Log data source
  8. ProfilingManager
  9. Android Profiling module

Original source baseline: b23c67be (September 19, 2026), linked below. This review also checks v58.3 HeapprofdConfig, the capture script, table definitions, UI interval metrics, summary tree, and syscall capture limits.

About Me and the Blog

Follow Android Performance.

CATALOG
  1. 1. Perfetto Series Catalog
  2. 2. Keep Memory Accounting Categories Separate
  3. 3. When to Use heapprofd
  4. 4. Capture a Native Heap Profile Quickly
  5. 5. Capture It Alongside a System Trace
  6. 6. Reading Heap Profiles in the UI
  7. 7. Querying Unreleased Allocations with SQL
  8. 8. Understanding the Sampling Interval
  9. 9. Java Heap Dumps and Java Allocation Sampling
  10. 10. Symbolization and Confidence
  11. 11. Checks Before Publishing a Report
  12. 12. Connecting Memory Trends to Call Stacks
  13. 13. References
  14. 14. About Me and the Blog