学习记录:Perfetto工具_4 Memory Profiling了解

0 阅读10分钟

原文

Recording memory profiles with Perfetto - Perfetto Tracing Docs

📘 Tutorials(教程)

原文中文解释
Full-Stack Perfetto全栈 Perfetto —— 涵盖从系统底层到应用层的完整跟踪教程。
├─ System Tracing系统跟踪 —— 如何记录整个系统的活动(内核、CPU、进程等)。
└─ In-App Tracing应用内跟踪 —— 如何在 Android 应用中插入自定义跟踪点。
Memory Profiling内存性能剖析 —— 使用 Perfetto 分析内存使用情况(如 malloc 调用、Java 堆等)。
CPU ProfilingCPU 性能剖析 —— 使用 Perfetto 分析 CPU 使用率、函数耗时等。

下半部分是该文档翻译,主要描述 Memory Profiling 内存性能剖析等等

翻译

使用 Perfetto 记录内存配置文件

在本指南中,您将学习如何:

  • 使用 Perfetto 记录 原生(nativeJava 堆(heap) 配置文件
  • 在 Perfetto UI 中可视化和分析堆配置文件
  • 理解不同的内存分析模式及其使用场景

进程的内存使用在进程性能和整体系统稳定性方面起着关键作用。了解进程在何处以及如何使用内存,可以为了解进程运行速度低于预期的原因提供重要洞察,或者帮助提升程序效率。

谈到应用程序和内存,进程主要通过两种方式使用内存:

  1. 原生 C/C++/Rust 进程: 通常通过 libc 的 malloc/free(或其包装器如 C++ 的 new/delete)分配内存。需要注意的是,当使用由 JNI 对应层支持的 Java API 时,原生内存分配仍然是可能的(而且相当频繁)。一个典型例子是 java.util.regex.Pattern,它通常在 Java 堆上拥有托管内存,同时由于底层使用了原生正则表达式库,也拥有原生内存

  2. Java/KT 应用程序进程: 应用程序的很大一部分内存占用位于托管堆中(在 Android 中,由 ART 的垃圾回收器管理)。这是每个 new X() 对象所在的位置。

Perfetto 为调试上述情况提供了两种互补的技术:

技术适用场景说明
堆分析(heap profiling)原生代码基于在 malloc/free 发生时对调用栈进行采样,并显示聚合的火焰图(flamegraphs),按调用点分解内存使用情况
堆转储(heap dumps)Java/托管代码基于创建堆保留图(heap retention graphs),显示对象之间的保留依赖关系(但不包含调用点信息)

原生(C/C++/Rust)堆内存分析

C/C++/Rust 这类原生语言,通常通过使用 libc 家族的 malloc/free 函数在最底层分配和释放内存。原生堆内存分析的原理是拦截对这些函数的调用,并注入代码来跟踪已分配但未释放内存的调用栈。这样就可以追踪到每次分配的代码来源。在堆操作密集的进程中,malloc/free 可能是热点所在:为了降低内存分析器的开销,我们支持通过采样来权衡精度与性能损耗。

注意:Perfetto 的原生堆内存分析仅适用于 Android 和 Linux;这是因为我们用于拦截 malloc 和 free 的技术手段仅在这两种操作系统上有效。

需要特别注意的是,堆内存分析不具备追溯能力。它只能报告跟踪开始之后发生的分配行为,无法提供跟踪开始之前发生的任何分配信息。如果你需要分析进程启动之初的内存使用情况,则必须在进程启动之前就开始跟踪。

如果你的问题是 “这个进程现在为什么这么大?”,你无法用堆内存分析来回答过去发生了什么。不过根据经验,如果你是在追踪内存泄漏,泄漏很可能会持续发生,因此你仍然可以看到未来的增量变化。

1.可视化你的第一个堆分析数据(Native (C/C++/Rust) Heap Profiling)

在 Perfetto 界面中打开你收集的 /tmp/heap_profile-latest 痕迹追踪trace文件,然后点击标记为  “堆分析”  的界面轨道上的 V 形标记。 image.png 默认情况下,聚合火焰图按调用栈聚合显示未释放的内存(即尚未 free() 的内存)。顶部的帧代表调用栈中最早的入口点(通常是 main() 或 pthread_start())。越往下走,就越接近最终调用 malloc() 的帧。

您还可以将聚合更改为以下模式:

image.png

  • 未释放的 Malloc 大小(Unreleased Malloc Size):默认模式,按 SUM(未释放内存字节数) 聚合调用栈。
  • 未释放的 Malloc 计数(Unreleased Malloc Count):按未释放分配的次数聚合,忽略每次分配的大小。当泄露的对象本身很小,但数量随时间大量堆积时,此模式非常有用。
  • 总 Malloc 大小(Total Malloc Size):按通过 malloc() 分配的字节数聚合调用栈(无论是否已被释放)。这有助于分析堆内存搅动情况,以及那些虽最终释放内存、但对分配器造成很大压力的代码路径。
  • 总 Malloc 计数(Total Malloc Count):上述模式按次数的版本,按 malloc()调用次数聚合,忽略每次分配的大小

2.查询你的第一个堆分析数据

除了在时间线上可视化跟踪数据外,Perfetto 还支持使用 SQL 查询跟踪数据。最简单的方法是使用 Perfetto 界面中直接提供的查询引擎。

(a). 在 Perfetto 界面中,点击左侧菜单中的  “Query (SQL)”  选项卡。 image.png

(b).这将打开一个分为上下两部分的窗口。你可以在上半部分编写 PerfettoSQL 查询语句,并在下半部分查看查询结果。 image.png

(c).然后你可以通过 Ctrl/Cmd + Enter 执行查询:

例如,运行:

INCLUDE PERFETTO MODULE android.memory.heap_graph.heap_graph_class_aggregation;   -- 引入 Perfetto 模块:Android 堆图类聚合

SELECT
  -- 类名(如已反混淆则显示反混淆后的名称)
  type_name,
  -- 类实例数量
  obj_count,
  -- 类实例占用的 Java 堆大小(字节)
  size_bytes,
  -- 类实例占用的 Native 堆大小(字节)
  native_size_bytes,
  -- 可达类实例数量
  reachable_obj_count,
  -- 可达类实例占用的 Java 堆大小(字节)
  reachable_size_bytes,
  -- 可达类实例占用的 Native 堆大小(字节)
  reachable_native_size_bytes
FROM android_heap_graph_class_aggregation;   -- 从视图“android_heap_graph_class_aggregation”中查询

您可以看到可访问的聚合对象大小及对象数量的汇总信息。

Java/托管堆转储(Java/Managed Heap Dumps)

Java 以及 基于 Java 构建的托管语言(如 Kotlin) 使用运行时环境(runtime environment)来处理内存管理和垃圾回收。在这些语言中,(几乎)每个对象都是一次堆分配。内存通过对象引用进行管理:对象持有其他对象的引用,一旦对象变得不可达,垃圾回收器便会自动回收其内存。这里没有像手动内存管理那样的 free() 调用。

因此,大多数面向托管语言的分析工具都是通过捕获并分析完整的堆转储来工作的,其中包含所有存活对象及其保持关系——即一个完整的对象图

这种方法的优势在于具备追溯分析能力:它提供整个堆的一致性快照,无需事先插桩。然而,这种方法也有权衡:虽然你能看到是哪些对象保持了其他对象的存活,但你通常看不到这些对象被分配时的确切调用点。当同一类型的对象从代码中的多个位置被分配时,这会增加分析内存使用情况的难度。

注意: Perfetto 的 Java 堆转储功能仅适用于 Android。这是因为需要与 JVM(Android 运行时 —— ART)深度集成,才能在不影响进程性能的前提下高效捕获堆转储。

堆转储(也有人叫“堆快照”、“堆转存”,但堆转储是工程领域最通用的说法。)

📸 什么叫“堆转储”? 堆转储 = 把堆内存“拍一张照片”保存下来。 就像你玩游戏遇到Bug,按个快捷键截图发给开发人员。

  • 截图 = 保存当前屏幕的像素状态
  • 堆转储 = 保存当前堆内存里的所有对象 + 它们之间的引用关系

1.可视化你的第一个堆转储

在 Perfetto 界面中打开 /tmp/xxxx 文件,然后点击标记为 “堆分析” 的界面轨道上的 V 形标记。

界面将以火焰图的形态展示堆图的扁平化版本。火焰图将具有相同可达路径的同类型对象聚合求和

共有两种扁平化策略可选:

  • 最短路径:这是在火焰图标题栏中选择 “对象大小” 时的默认选项。它基于启发式算法排列对象,使对象之间的距离最小化。
  • 支配树:选择 “支配大小” 时,使用支配树算法扁平化堆图。

你可以在《调试内存使用》案例研究中了解更多关于这两种策略的细节。

image.png

2.查询你的第一个堆分析数据

除了在时间线上可视化跟踪数据外,Perfetto 还支持使用 SQL 查询跟踪数据。最简单的方法是使用 Perfetto 界面中直接提供的查询引擎。

(a). 在 Perfetto 界面中,点击左侧菜单中的 “Query (SQL)” 选项卡。 image.png

(b).这将打开一个分为上下两部分的窗口。你可以在上半部分编写 PerfettoSQL 查询语句,并在下半部分查看查询结果。 image.png

(c).然后你可以通过 Ctrl/Cmd + Enter 执行查询:

例如,运行:

-- 引入 Perfetto 模块:android.memory.heap_profile.summary_tree
INCLUDE PERFETTO MODULE android.memory.heap_profile.summary_tree;

SELECT
  -- 调用栈的ID。在此上下文中,调用栈是指一组唯一的栈帧,回溯到根节点。
  id,
  -- 此调用栈的父调用栈ID。
  parent_id,
  -- 此调用栈对应的栈帧函数名称。
  name,
  -- 包含该栈帧的映射名称。可以是原生二进制文件、库、JAR 或 APK。
  mapping_name,
  -- 包含该函数的文件名。
  source_file,
  -- 函数所在文件的行号。
  line_number,
  -- 以此函数为叶子节点分配且**未释放**的内存字节数。
  self_size,
  -- 此函数出现在调用栈任意位置时分配且**未释放**的内存字节数。
  cumulative_size,
  -- 以此函数为叶子节点分配的总内存字节数(可能包含后续已释放的部分)。
  self_alloc_size,
  -- 此函数出现在调用栈任意位置时分配的总内存字节数(可能包含后续已释放的部分)。
  cumulative_alloc_size
FROM android_heap_profile_summary_tree;

在该跟踪记录中,您可以看到每个独立调用栈所占用的内存情况。

其他类型的内存

除了标准的原生堆和 Java 堆之外,还可以通过其他方式分配内存,这些方式默认情况下不会被分析。以下是一些常见示例:

  • 直接 mmap() 调用:应用程序可以使用 mmap() 直接从内核请求内存。这通常用于大块内存分配或将文件映射到内存中。Perfetto 目前尚无自动分析此类内存分配的方法。

  • 自定义分配器:某些应用程序出于性能考虑使用自己的内存分配器。这些分配器通常通过 mmap() 从系统获取内存,然后在内部自行管理。虽然 Perfetto 无法自动分析此类分配,但你可以使用 heapprofd 自定义分配器 API 对自定义分配器进行插桩,从而启用堆内存分析。

  • DMA 缓冲区(dmabuf):这是一种特殊缓冲区,用于不同硬件组件(如 CPU、GPU 和摄像头)之间共享内存,常见于图形密集型应用。你可以在 Trace 配置中启用 dmabuf_heap/dma_heap_stat ftrace 事件,从而跟踪 dmabuf 的分配情况。

下一步

现在你已经完成首个内存分析数据的录制与分析,可以进一步探索更高级的主题: