性能工具 之 火焰图

//

火焰图简述

火焰图(Flame Graph) 由Brendan Gregg在2011年创造,是一种可视化程序性能分析工具,它可以帮助开发人员追踪程序的函数调用以及调用所占用的时间,并且展示出这些信息。

一般性解释

火焰图的基本思想是将程序的函数调用栈转化为一个矩形的 “火焰” 形图像,每个矩形的宽度表示该函数所占用的比例,高度表示函数的调用深度(也就是递归调用的层数)。通过比较不同时间点的火焰图,可以快速诊断程序的性能瓶颈所在,从而针对性地进行优化。通常情况下,如果遇到栈顶上存在很宽的矩形,那么这个函数就是性能瓶颈,需要重点分析优化。

火焰图(广义)分为两种画法,包括火焰图(狭义)、冰柱图。火焰图(狭义)的根位于底部,子节点显示在其父节点上方,而冰柱图的根位于顶部,子节点显示在其父节点下方。两种画法仅仅是展现方式和叫法不同,通常也统称为火焰图(广义)。

火焰图类型

根据创始人Gregg给出的类型,常见的火焰图类型有5种,CPU、Off-CPU、Memory、Hot/Cold、Differential。

类型横轴纵轴解决的问题采样方式
CPUCPU 占用时间调用栈找出CPU占用高的问题函数,分析代码热路径固定频率采样CPU调用栈
Off-CPU阻塞时间调用栈i/o、网络等阻塞场景导致的性能下降;锁竞争、死锁导致的性能下降问题固定频率采样阻塞事件调用栈
Memory内存申请/释放函数调用次数,或分配的总字节数调用栈内存泄漏问题、内存占用高的对象/申请内存多的函数、虚拟内存或物理内存泄漏问题跟踪malloc/free、跟踪brk、跟踪mmap、跟踪页错误
Hot/ColdCPU和Off-CPU结合调用栈需要结合CPU占用以及阻塞分析的场景、Off-CPU无法直观判断问题的场景CPU和Off-CPU结合
Differential前后火焰图之间的差异调用栈性能回归问题、调优效果分析与前后火焰图一致

关于On-CPU与Off-CPU

CPU火焰图展现的是在CPU上发生的事情,为下图中的红色部分。Off-CPU火焰图展现的是在CPU之外发生的事情,也就是在 I/O、锁、定时器、分页/交换等阻塞时等待的时间,在下图中用蓝色展示。

I/O期间有File I/O、Block Device I/O,通过采集进程让出CPU时调用栈,可以知道哪些函数正在频繁地等待其它事件,以至于需要让出CPU,通过采集进程被唤醒时的调用栈,可以知道哪些函数让进程等待的时间比较长。

关于冷热火焰图(Hot/Cold)与差异火焰图(Differential

二者都有“对比”的意味,但是维度不一样。

冷热火焰图主要比较的是一次性能分析的On-CPU与Off-CPU。如果使用原生火焰图套件,只能缩放到相同的 x 轴,通常相对较大的 Off-CPU 时间会挤压 On-CPU 时间。Vladimir Kirillov 将阻塞数据与 CPU 配置文件集成,将阻塞调用包含在eflame中,实现了合并祖先,使得阻塞函数在暖色堆栈顶部显示为蓝色。

差异火焰图主要比较的是两次性能分析的差异。通过第一次性能分析的火焰度了解了程序运行期间的情况以后,接下来是有针对性地修改调优。 调整之后,进行第二次性能分析生成火焰图,通过对比调优前和调优后的火焰图,评估调整是否有效。

有时候可能发现系统升级之后,某些指标突然升高,这时候也可以对比升级前和升级后的火焰图,找到那些耗时增加的函数。

火焰图在Continuous Profiling中的应用

Continuous Profiling是一种持续性能分析技术,它从任何环境(包括生产环境)连续收集代码行级别性能数据。之后提供数据的可视化,使开发人员可以分析、排除故障和优化他们的代码。

与传统的静态分析技术不同,Continuous Profiling可以在实际运行环境下获取性能数据,并且不会对应用程序的性能产生显著的影响。这使得它可以更加准确地分析应用程序的性能问题,并且可以在实际部署环境中进行性能优化和调试。开发人员可以为生产环境实施持续集成和部署。然后,生产反馈到Continuous Profiler,这是一个反馈回路,为开发人员提供剖析数据的回馈。

动图封面

更多类型可能

从实现角度而言,火焰图是一种“栈-值”数据结构的图,只要满足该数据结构的数据,都可以转化为火焰图的展示方式。创始人Gregg给出的CPU、Off-CPU、Memory类型,被赋予了更多的想象空间,以Pyroscope为例,由Pyroscope Server 和 Pyroscope Agent 两部分组成,Agent记录并聚合应用程序执行动作,发送到Server,Server 处理、聚合和存储来自 Agent 的数据,以便在按照时间范围进行快速查询。因此可以针对于不同语言设计不同的Agent,进行更细致的性能监控。

以针对于不同语言设计不同的Agent,进行更细致的性能监控。

火焰图相关开源仓库

火焰图相关开源仓库

Pyroscope源码解析之火焰图

本文所述Pyroscope相关源码的版本为v0.35.1。源码分析主要聚焦火焰图部分(/packages/pyroscope-flamegraph),以及模型定义部分(/packages/pyroscope-models),采集侧暂不涉及。

代码结构

pyroscope-flamegraph

--- src
 |--- convert                            ->  一些工具、转换方法,包含diff两个Profile,flamebearer转换为树的方法等
 |--- fitMode                            ->  火焰图搭配的Table每一行的行内排序模式,分为Head First和Tail First两种
 |--- FlameGraph                         ->  火焰图主要文件夹
 | |--- FlameGraphComponent              ->  火焰图组件主要文件夹
 | | |--- ...
 | | |--- color.ts                       ->  火焰图配色哈希策略以及Diff线性渐变配色逻辑
 | | |--- colorPalette.ts                ->  火焰图配色调色盘
 | | |--- constants.ts                   ->  火焰图canvas显示全局配置,比如每一个bar的宽度、之间的间距等
 | | |--- ContextMenu.tsx                ->  右键火焰图弹出的Menu组件
 | | |--- ContextMenuHighlight.tsx       ->  为火焰图中被右键点中(呼出ContextMenu)的bar提供高亮效果
 | | |--- DiffLegend.tsx                 ->  火焰图Diff的调色盘配置(默认和色盲模式)中间的色条组件
 | | |--- DiffLegendPaletteDropdown.tsx  ->  火焰图Diff的调色盘配置的下拉框组件
 | | |--- Flamegraph_render.ts           ->  火焰图核心绘图渲染代码,基于flamebarear,包含canvas的绘图逻辑、聚焦逻辑(zoom)、折叠逻辑(collapse)、高亮联动逻辑
 | | |--- Flamegraph.ts                  ->  火焰图核心类,驱动Flamegraph_render,并且包含所有的组件操作逻辑实现,适配flamebarear数据结构在调用Stack中的二分搜索(xyToBarIndex),实现了与canvas配合紧密的数据可控组件
 | | |--- Header.tsx                     ->  火焰图标题组件,主要是根据unit换title,如果是Diff则展示DiffLegendPaletteDropdown
 | | |--- Highlight.tsx                  ->  火焰图高亮润色,为canvas设置EventListener监听鼠标事件并添加/移除火焰图bar高亮
 | | |--- index.tsx                      ->  火焰图入口组件,将其他相关组件接入(ContextMenu、Tooltip、Header等),封装调用Flamegraph的xyToData逻辑,传给其他子组件
 | | |--- LogoLink.tsx                   ->  Pyroscope的svg logo组件
 | | |--- murmur3.ts                     ->  MurmurHash3 哈希算法,可以将任意长度的数据映射为固定长度的哈希值,用于火焰图中相似调用栈层的相近颜色显示
 | | |--- testData.ts                    ->  火焰图样例数据格式,包含SimpleTree、ComplexTree、DiffTree
 | | |--- utils.ts                       ->  计算Diff时的两部分占比比率的辅助方法
 | | |--- viewTypes.ts                   ->  火焰图的显示模式,包含'flamegraph' | 'both' | 'table' | 'sandwich'
 | |--- decode.ts                        ->  在火焰图挂载/改变/covert的时候执行其中的decode方法,将原始数据结构的level进行二次运算
 | |--- FlameGraphRenderer.tsx           ->  火焰图全功能的入口,包含Toolbar、3种形态(表格、火焰图、三明治)
 |--- format                             ->  单位格式化工具文件夹
 | |--- format.ts                        ->  不同unit的Formatter也不一样,大致分为Duration、Objects、Bytes、Nanoseconds这几种Formatter,在Tooltip和表格中会用到
 |--- Tooltip                            ->  hover时出现的框
 | |--- ...
 | |--- FlamegraphTooltip.tsx            ->  用于火焰图的Tooltip组件,通过xyToData获取bar数据,并通过Formatter展示
 | |--- TableTooltip.tsx                 ->  用于表格的Tooltip组件,通过表格的数据回调方法获取数据,并通过Formatter展示
 | |--- Tooltip.tsx                      ->  Tooltip组件具体实现,通过baselineDataExpression判断hover类型是火焰图还是表格
 |--- FlamegraphRenderer.tsx             ->  FlameGraph/FlameGraphRenderer.tsx的包装
 |--- index.tsx                          ->  暴露Flamegraph等组件
 |--- ProfilerTable.tsx                  ->  火焰图表格的实现,包括singleRow和DoubleRow(Diff视图)的两种展现
 |--- search.ts                          ->  搜索工具类,判断bar名称是否和搜索内容一致
 |--- SharedQueryInput.tsx               ->  搜索框功能实现
 |--- Toolbar.tsx                        ->  火焰图控制bar的实现,可以切换视图和排序等

pyroscope-models

--- src
 |--- decode.ts        ->  TODO:理想情况下,这应该被移动到 FlamegraphRenderer 组件中,但是因为现在它需要太多的改变
 |--- flamebearer.ts   ->  老版本的火焰图数据结构
 |--- groups.ts        ->  火焰图主要文件夹
 |--- index.ts         ->  暴露索引文件
 |--- profile.ts       ->  新版本的火焰图数据结构(实际上本质是一样的,新版本用zod驱动)
 |--- spyName.ts       ->  不同语言的spy的相关常量与数据结构定义
 |--- trace.ts         ->  Trace相关的Schema定义
 |--- units.ts         ->  unit相关常量与数据结构定义

火焰图数据结构

Single Format

const SimpleTree = {
  version: 1,
  flamebearer: {
    names: [
      'total',
      'runtime.mcall',
      'runtime.park_m',
      'runtime.schedule',
      'runtime.resetspinning',
      'runtime.wakep',
      'runtime.startm',
      'runtime.notewakeup',
      'runtime.semawakeup',
      'runtime.pthread_cond_signal',
      'runtime.findrunnable',
      'runtime.netpoll',
      'runtime.kevent',
      'runtime.main',
      'main.main',
      'github.com/pyroscope-io/client/pyroscope.TagWrapper',
      'runtime/pprof.Do',
      'github.com/pyroscope-io/client/pyroscope.TagWrapper.func1',
      'main.main.func1',
      'main.slowFunction',
      'main.slowFunction.func1',
      'main.work',
      'runtime.asyncPreempt',
      'main.fastFunction',
      'main.fastFunction.func1',
    ],
    levels: [
      [0, 609, 0, 0],
      [0, 606, 0, 13, 0, 3, 0, 1],
      [0, 606, 0, 14, 0, 3, 0, 2],
      [0, 606, 0, 15, 0, 3, 0, 3],
      [0, 606, 0, 16, 0, 1, 0, 10, 0, 2, 0, 4],
      [0, 606, 0, 17, 0, 1, 0, 11, 0, 2, 0, 5],
      [0, 606, 0, 18, 0, 1, 1, 12, 0, 2, 0, 6],
      [0, 100, 0, 23, 0, 506, 0, 19, 1, 2, 0, 7],
      [0, 100, 0, 15, 0, 506, 0, 16, 1, 2, 0, 8],
      [0, 100, 0, 16, 0, 506, 0, 20, 1, 2, 2, 9],
      [0, 100, 0, 17, 0, 506, 493, 21],
      [0, 100, 0, 24, 493, 13, 13, 22],
      [0, 100, 97, 21],
      [97, 3, 3, 22],
    ],
    numTicks: 609,
    maxSelf: 493,
  },
  metadata: {
    appName: 'simple.golang.app.cpu',
    name: 'simple.golang.app.cpu 2022-09-06T12:16:31Z',
    startTime: 1662466591,
    endTime: 1662470191,
    query: 'simple.golang.app.cpu{}',
    maxNodes: 1024,
    format: 'single' as const,
    sampleRate: 100,
    spyName: 'gospy' as const,
    units: 'samples' as const,
  }
};

该数据结构是在pyroscope github上面的示例数据,也就是传入火焰图组件的数据结构,示例渲染出来的效果如下:

数据中大部分内容比较好理解,根据命名就可以判断,比较关键的是nameslevels代表什么意思。这一部分可以在源码中models/flamebearer.ts里推断出来。

levels是火焰图形状的数据结构,是一个二维数组,每一行对应火焰图中的每一行,在每一行中,Single类型火焰图4个数描述了一条bar,例如第一行是1个bar,第二行有2个bar。在描述bar的4个数字中,第一列代表offset,这个数代表了在当前行中,距离上一个bar需要空出来的距离;第二列代表这个bar的总长度;第三列代表这个bar的自身独占(self)长度,意思是除去该bar所有子调用栈之后,自身所占用的部分(可能多段)长度总和。第四列代表该bar上面的名称对应上方name数组的index是哪个。

Diff Format

Diff格式的火焰图和Single格式的类似,但是由一组4个,变成了一组7个数值。一组示例levels如下:

"levels": [
  [0, 20464695, 0, 0, 22639351, 0, 0],
  [
    0, 1573488, 0, 0, 0, 0, 1, 0, 524336, 0, 0, 524336, 0, 2, 0, 1049728, 0,
    0, 524864, 0, 3, 0, 3149185, 0, 0, 3674049, 0, 4, 0, 13643094, 0, 0,
    17391238, 0, 5, 0, 524864, 0, 0, 524864, 0, 6
  ],
  [
    0, 1573488, 0, 0, 0, 0, 7, 0, 524336, 524336, 0, 524336, 524336, 8, 0,
    1049728, 0, 0, 524864, 0, 9, 0, 3149185, 0, 0, 3674049, 0, 10, 0,
    13643094, 0, 0, 17391238, 0, 11, 0, 524864, 0, 0, 524864, 0, 12
  ],
  [
    0, 1573488, 0, 0, 0, 0, 13, 524336, 1049728, 0, 524336, 524864, 0, 14,
    0, 3149185, 0, 0, 3674049, 0, 15, 0, 524361, 524361, 0, 524360, 524360,
    16, 0, 2146687, 0, 0, 5366719, 0, 17, 0, 528394, 528394, 0, 528394,
    528394, 18, 0, 0, 0, 0, 524292, 0, 19, 0, 9387757, 0, 0, 9397105, 0, 20,
    0, 525440, 0, 0, 525440, 0, 21, 0, 0, 0, 0, 524928, 0, 22, 0, 530455, 0,
    0, 0, 0, 23, 0, 524864, 0, 0, 524864, 0, 24
  ],
  [
    0, 1573488, 1573488, 0, 0, 0, 25, 524336, 1049728, 0, 524336, 524864, 0,
    26, 0, 3149185, 0, 0, 3674049, 0, 14, 524361, 2146687, 0, 524360,
    5366719, 0, 27, 528394, 0, 0, 528394, 524292, 0, 28, 0, 9387757, 695248,
    0, 9397105, 695248, 29, 0, 525440, 0, 0, 525440, 0, 30, 0, 0, 0, 0,
    524928, 0, 31, 0, 530455, 0, 0, 0, 0, 32, 0, 524864, 0, 0, 524864, 0, 33
  ],
  [
    2097824, 1049728, 0, 524336, 524864, 0, 34, 0, 3149185, 0, 0, 3674049,
    0, 26, 524361, 2146687, 0, 524360, 5366719, 0, 35, 528394, 0, 0, 528394,
    524292, 0, 36, 695248, 8692509, 789507, 695248, 8701857, 526338, 37, 0,
    525440, 0, 0, 525440, 0, 38, 0, 0, 0, 0, 524928, 0, 39, 0, 530455, 0, 0,
    0, 0, 40, 0, 524864, 0, 0, 524864, 0, 14
  ],
  [
    2097824, 1049728, 0, 524336, 524864, 0, 41, 0, 3149185, 0, 0, 3674049,
    0, 34, 524361, 2146687, 0, 524360, 5366719, 0, 42, 528394, 0, 0, 528394,
    524292, 0, 43, 1484755, 7903001, 7903001, 1221586, 8175519, 8175519, 44,
    0, 525440, 525440, 0, 525440, 525440, 45, 0, 0, 0, 0, 524928, 0, 46, 0,
    530455, 0, 0, 0, 0, 47, 0, 524864, 0, 0, 524864, 0, 48
  ],
  [
    2097824, 1049728, 0, 524336, 524864, 0, 49, 0, 3149185, 0, 0, 3674049,
    0, 41, 524361, 2146687, 0, 524360, 5366719, 0, 50, 528394, 0, 0, 528394,
    524292, 0, 51, 9913197, 0, 0, 9922545, 524928, 0, 52, 0, 530455, 0, 0,
    0, 0, 53, 0, 524864, 0, 0, 524864, 0, 54
  ],
  [
    2097824, 1049728, 1049728, 524336, 524864, 524864, 55, 0, 3149185, 0, 0,
    3674049, 0, 49, 524361, 2146687, 2146687, 524360, 5366719, 5366719, 56,
    528394, 0, 0, 528394, 524292, 524292, 57, 9913197, 0, 0, 9922545,
    524928, 0, 58, 0, 530455, 530455, 0, 0, 0, 59, 0, 524864, 0, 0, 524864,
    0, 41
  ],
  [
    3147552, 3149185, 3149185, 1049200, 3674049, 3674049, 55, 13112639, 0,
    0, 16866310, 524928, 0, 60, 530455, 524864, 0, 0, 524864, 0, 49
  ],
  [
    19409376, 0, 0, 21589559, 524928, 524928, 61, 530455, 524864, 524864, 0,
    524864, 524864, 55
  ]
]

一组内7个数值具体含义为:

第几位数含义组合计算
0leftOffsetbarOffset = level[0] + level[3]barTotal = level[1] + level[4]barTotalDiff = level[4] - level[1]barSelf = level[2] + level[5]barSelfDiff = level[5] - level[2]
1barLeftTotal
2leftSelf
3rightOffset
4barRightTotal
5rightSelf
6name_index

相关源码

export type Flamebearer = {
  /**
   * List of names
   */
  names: string[];
  /**
   * List of level
   *
   * This is NOT the same as in the flamebearer
   * that we receive from the server.
   * As in there are some transformations required
   * (see deltaDiffWrapper)
   */
  levels: number[][];
  numTicks: number;
  maxSelf: number;

  /**
   * Sample Rate, used in text information
   */
  sampleRate: number;
  units: Units;

  spyName: SpyName;
  // format: 'double' | 'single';
  //  leftTicks?: number;
  //  rightTicks?: number;
} & addTicks;

export type addTicks =
  | { format: 'double'; leftTicks: number; rightTicks: number }
  | { format: 'single' };

export const singleFF = {
  format: 'single' as const,
  jStep: 4,
  jName: 3,
  getBarOffset: (level: number[], j: number) => level[j],
  getBarTotal: (level: number[], j: number) => level[j + 1],
  getBarTotalDiff: (level: number[], j: number) => 0,
  getBarSelf: (level: number[], j: number) => level[j + 2],
  getBarSelfDiff: (level: number[], j: number) => 0,
  getBarName: (level: number[], j: number) => level[j + 3],
};

export const doubleFF = {
  format: 'double' as const,
  jStep: 7,
  jName: 6,
  getBarOffset: (level: number[], j: number) => level[j] + level[j + 3],
  getBarTotal: (level: number[], j: number) => level[j + 4] + level[j + 1],
  getBarTotalLeft: (level: number[], j: number) => level[j + 1],
  getBarTotalRght: (level: number[], j: number) => level[j + 4],
  getBarTotalDiff: (level: number[], j: number) => {
    return level[j + 4] - level[j + 1];
  },
  getBarSelf: (level: number[], j: number) => level[j + 5] + level[j + 2],
  getBarSelfLeft: (level: number[], j: number) => level[j + 2],
  getBarSelfRght: (level: number[], j: number) => level[j + 5],
  getBarSelfDiff: (level: number[], j: number) => level[j + 5] - level[j + 2],
  getBarName: (level: number[], j: number) => level[j + 6],
};

火焰图取数据算法解析(xyToData)

Maybe模型简述

在pyroscope的数据模型中大量用到了Maybe(来自true-myth库,https://github.com/true-myth/true-myth),关于该模型解决的问题和常用的写法,在此简单阐述,并熟悉pyroscope用Maybe模型驱动火焰图状态判断的逻辑,但不过多展开,更多使用方法与细节见https://true-myth.js.org/

Maybe解决了什么痛点

Maybe主要解决了null/undefined问题。以一种规则性定义的方式,而不是在整个代码库中以一种临时性的方式解决的null/undefined问题。将值放入一个容器中,无论里面是否有东西,都可以保证安全地进行操作。这些容器让我们在编写函数时对参数值有了实际的安全假设,通过提取 "这个变量包含一个有效的值吗?"到API边界,而不是需要在每个函数的头部去额外处理这个问题。

个人认为Pyroscope采用Maybe驱动xyToData等一系列方法属于代码上的整洁与可维护性考虑,否则在边界条件非常复杂的火焰图交互中,应用6~7中取数据的方法,每种方法中还要写大量的if(undefined)-else,是令人绝望的。

Maybe怎么用

设A表示可能存在或可能不存在Maybe<T>的类型值。如果该值存在,则为Just(value)。如果不存在,则为Nothing,这提供了一个类型安全的容器来处理空值的可能性,就可以避免在你的代码库中进行检查null/undefined,像使用一个没有后顾之忧的数组一样去用了。这种类型的行为在编译时由 TypeScript 检查,除了容器对象和一些轻量级包装/解包功能的非常小的成本外,不承担任何运行时开销。

Maybe在用法上,是一种方法式调用规则。

import Maybe from 'true-myth/maybe';

// Construct a `Just` where you have a value to use, and the function accepts
// a `Maybe`.
const aKnownNumber = Maybe.just(12);

// Construct a `Nothing` where you don't have a value to use, but the
// function requires a value (and accepts a `Maybe`).
const aKnownNothing = Maybe.nothing<string>();

// Construct a `Maybe` where you don't know whether the value will exist or
// not, using `of`.
type WhoKnows = { mightBeAThing?: boolean[] };

const whoKnows: WhoKnows = {};
const wrappedWhoKnows = Maybe.of(whoKnows.mightBeAThing);
console.log(toString(wrappedWhoKnows)); // Nothing

const whoElseKnows: WhoKnows = { mightBeAThing: [true, false] };
const wrappedWhoElseKnows = Maybe.of(whoElseKnows.mightBeAThing);
console.log(toString(wrappedWhoElseKnows)); // "Just(true,false)"
import { isVoid } from 'true-myth/utils';
import Maybe, { Just, Nothing } from 'true-myth/maybe';

// Construct a `Just` where you have a value to use, and the function accepts
// a `Maybe`.
const aKnownNumber = new Just(12);

// Once the item is constructed, you can apply methods directly on it.
const fromMappedJust = aKnownNumber.map((x) => x * 2).unwrapOr(0);
console.log(fromMappedJust); // 24

// Construct a `Nothing` where you don't have a value to use, but the
// function requires a value (and accepts a `Maybe<string>`).
const aKnownNothing = new Nothing();

// The same operations will behave safely on a `Nothing` as on a `Just`:
const fromMappedNothing = aKnownNothing.map((x) => x * 2).unwrapOr(0);
console.log(fromMappedNothing); // 0

// Construct a `Maybe` where you don't know whether the value will exist or
// not, using `isVoid` to decide which to construct.
type WhoKnows = { mightBeAThing?: boolean[] };

const whoKnows: WhoKnows = {};
const wrappedWhoKnows = !isVoid(whoKnows.mightBeAThing)
  ? new Just(whoKnows.mightBeAThing)
  : new Nothing();

console.log(wrappedWhoKnows.toString()); // Nothing

const whoElseKnows: WhoKnows = { mightBeAThing: [true, false] };
const wrappedWhoElseKnows = !isVoid(whoElseKnows.mightBeAThing)
  ? new Just(whoElseKnows.mightBeAThing)
  : new Nothing();

console.log(wrappedWhoElseKnows.toString()); // "Just(true,false)"

组件内部数据结构与描述说明

  1. bar:描述火焰图中一个"条",同一行中有可能包含多个"条"。
  2. Node:指代了火焰图中一个bar的引用,数据结构是{i, j},也就是上述火焰图的index。
  3. XYWithinBounds:说的是在canvas范围内的XY坐标,数据结构是{x, y},是MouseEvent的XY。
  4. this.zoom:当前状态下,放大的那个节点。放大的意思是左键点击火焰图的那个操作带来的效果。

  1. this.focusedNode:当前状态下,聚焦的那个节点。右键Collapsed Nodes Above。

火焰图点击的全流程

从点击开始说起

绑定在canvas上的OnClick事件如下:

const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
  const opt = getFlamegraph().xyToBar(
    e.nativeEvent.offsetX,
    e.nativeEvent.offsetY
  );
  // opt 是根据xy位置取出的 Maybe<XYWithinBounds> 对象后,再根据一系列xyToData方法(后文详细介绍)构造了包含index、position、data的bar
  opt.match({
    // 点击在canvas中不合理的位置,就什么都不做
    Nothing: () => {},
    //  如果是合理的位置,则取出bar数据(信息包含index、position、data)
    Just: (bar) => {
      // zoom是当前已经被放大的 Maybe<Node> 对象(不一定是opt),放大的意思是左键点击火焰图的那个操作带来的效果
      zoom.match({
        // 如果当前不存在zoom,则在当前位置(opt处)执行zoom
        Nothing: () => {
          onZoom(opt);
        },
        // 如果当前存在已经被放大的节点z,则取出bar数据
        Just: (z) => {
          // 判断opt和zoom是否是相同的index
          if (bar.i === z.i && bar.j === z.j) {
            // 如果是,则取消被放大,复原
            onZoom(Maybe.nothing());
          } else {
            // 如果不是,则对这个当前被点击的opt进行zoom操作
            onZoom(opt);
          }
        },
      });
    },
  });
};

xyToIndex

xyToIndex方法是鼠标屏幕x、y到数据结构中的i、j的核心方法。

private xyToBarIndex(x: number, y: number) {
  if (x < 0 || y < 0) {
    throw new Error(`x and y must be bigger than 0. x = ${x}, y = ${y}`);
  }

  // 意思是点击了聚焦模式下的顶上的bar,或者是非聚焦模式下的Total,则返回{ i: 0, j: 0 }
  if (this.isFocused() && y <= BAR_HEIGHT) {
    return { i: 0, j: 0 };
  }

  // 当进行collapse的时候(聚焦操作),最顶上会有一个虚拟collapsed节点,因此这里需要减一下
  const computedY = this.isFocused() ? y - BAR_HEIGHT : y;

  const compensatedFocusedY = this.focusedNode.mapOrElse(
    () => 0,
    (node) => {
      return node.i <= 0 ? 0 : node.i;
    }
  );

  // 把它当做一组if-else即可
  const compensation = this.zoom.match({
    Just: () => {
      return this.focusedNode.match({
        Just: () => {
          // 有focus、也有zoom,以focus为主
          return compensatedFocusedY;
        },
        Nothing: () => {
          // 只有zoom
          return 0;
        },
      });
    },

    Nothing: () => {
      return this.focusedNode.match({
        Just: () => {
          // 只有focus
          return compensatedFocusedY;
        },
        Nothing: () => {
          // 既没有focus,也没有zoom
          return 0;
        },
      });
    },
  });

  // 可以根据以上信息,定位i的位置
  const i = Math.floor(computedY / PX_PER_LEVEL) + compensation;
  if (i >= 0 && i < this.flamebearer.levels.length) {
    const level = this.flamebearer.levels[i];
    if (!level) {
      throw new Error(`Could not find level: '${i}'`);
    }
    // j的位置,用到了一个二分查找的算法去找
    const j = this.binarySearchLevel(x, level);
    return { i, j };
  }
  return { i: 0, j: 0 };
}

xyToIndex方法中,通过对火焰图的状态分类讨论,计算出了i的位置,接下来需要在i所在的level中进行二分查找,把j找到。

// binary search of a block in a stack level
private binarySearchLevel(x: number, level: number[]) {
  const { ff } = this;
  let i = 0;
  let j = level.length - ff.jStep;

  while (i <= j) {
    /* eslint-disable-next-line no-bitwise */
    const m = ff.jStep * ((i / ff.jStep + j / ff.jStep) >> 1);
    const x0 = this.tickToX(ff.getBarOffset(level, m));
    const x1 = this.tickToX(
      ff.getBarOffset(level, m) + ff.getBarTotal(level, m)
    );

    if (x0 <= x && x1 >= x) {
      return x1 - x0 > COLLAPSE_THRESHOLD ? m : -1;
    }
    if (x0 > x) {
      j = m - ff.jStep;
    } else {
      i = m + ff.jStep;
    }
  }
  return -1;
}

该算法非常巧妙地将二分查找和火焰图的特性相结合,请注意,将二分查找的i、j与火焰图的i、j概念区分开,此处二分查找的i、j仅表示火焰图中一行所代表的Array的索引。在该Array上进行二分查找,但是通过jStep进行bar维度的跳转(Single jStep = 4;Diff jStep = 7),这样m的落点一定是i、j中间bar的起始点,确定m后,就可以通过数据结构中阐述的getBarTotalgetBarOffset获取相关bar信息,然后传入tickToX中。最后得到的是中间bar的真实X范围,和传入的x做范围比较,如果落在了范围中,则确定了bar的j-index,否则继续按照二分查找的方式继续。

tickToX方法在此不做过多展开,其中的判断逻辑比较复杂,但判断原理与xyToIndex类似,都是将zoomfocusedNode进行分类讨论,确定当下的Range(可能由于zoom和focus操作改变Range),进而确定每一个Tick所占Px,就可以计算出来了。

xyToAnything

有了xyToIndex的能力,配合Maybe与数据结构,可以让获取数据的能力轻松暴露。

private xyToBarPosition = (xy: XYWithinBounds) => {
    const { ff } = this;
    const { i, j } = this.xyToBarIndex(xy.x, xy.y);

    const topLevel = this.focusedNode.mapOrElse(
      () => 0,
      (node) => (node.i < 0 ? 0 : node.i - 1)
    );

    const level = this.flamebearer.levels[i];
    if (!level) {
      throw new Error(`Could not find level: '${i}'`);
    }
    const posX = Math.max(this.tickToX(ff.getBarOffset(level, j)), 0);

    // lower bound is 0
    const posY = Math.max((i - topLevel) * PX_PER_LEVEL, 0);

    const sw = Math.min(
      this.tickToX(ff.getBarOffset(level, j) + ff.getBarTotal(level, j)) - posX,
      this.getCanvasWidth()
    );

    return {
      x: posX,
      y: posY,
      width: sw,
    };
  };

  private xyToBarData = (xy: XYWithinBounds) => {
    const { i, j } = this.xyToBarIndex(xy.x, xy.y);
    const level = this.flamebearer.levels[i];
    if (!level) {
      throw new Error(`Could not find level: '${i}'`);
    }

    switch (this.flamebearer.format) {
      case 'single': {
        const ff = singleFF;

        return {
          format: 'single' as const,
          name: this.flamebearer.names[ff.getBarName(level, j)],
          self: ff.getBarSelf(level, j),
          offset: ff.getBarOffset(level, j),
          total: ff.getBarTotal(level, j),
        };
      }
      case 'double': {
        const ff = doubleFF;

        return {
          format: 'double' as const,
          barTotal: ff.getBarTotal(level, j),
          totalLeft: ff.getBarTotalLeft(level, j),
          totalRight: ff.getBarTotalRght(level, j),
          totalDiff: ff.getBarTotalDiff(level, j),
          name: this.flamebearer.names[ff.getBarName(level, j)],
        };
      }

      default: {
        throw new Error(`Unsupported type`);
      }
    }
  };

其中的逻辑较为简单,不再过多赘述。

日志服务-性能监控对火焰图的优化

日志服务(SLS)的性能监控功能基于Pyroscope v0.35.1版本(该版本开源协议为Apache 2.0)开发,并在其基础上进行了融合日志服务特色能力的优化。

对比概览

Pyroscope v0.35.1SLS
❌ ProfileTable 大量reRender问题✅ 性能优化:火焰图表格相比开源版本渲染性能总体提升约50%
❌ 标签选择无法在同一个Tag里面多选,UI侧标签支持能力较少✅ 逻辑优化:发挥SLS查询特色优势,支持更灵活的标签选择逻辑,并且不止支持SUM的聚合逻辑,还支持AVG的Profile聚合逻辑
☑️ 调用栈深时表格显示冗长,火焰图交互能力较单调✅ 交互优化:深栈优化、检索集成、一键diff、火焰图交互菜单
❌ 不涉及关联资源统一整合✅ 体验优化:融入强交互式开放性强的SLS仪表盘生态,提供更多想象空间。

具体特色

  1. 主界面

  1. ToolTip & contextPanel

  1. 标签

  1. 其他细节

  • PyroscopeSLS
    元数据筛选逻辑优化迷你图推拉后支持查看时间历史记录并复原搜索能力自定义,位置集成支持Single View携带标签、时间token一键diff
  • PyroscopeSLS
    元数据筛选逻辑优化迷你图推拉后支持查看时间历史记录并复原搜索能力自定义,位置集成支持Single View携带标签、时间token一键diff
  • PyroscopeSLS
    元数据筛选逻辑优化迷你图推拉后支持查看时间历史记录并复原搜索能力自定义,位置集成支持Single View携带标签、时间token一键diff
  • PyroscopeSLS
    元数据筛选逻辑优化迷你图推拉后支持查看时间历史记录并复原搜索能力自定义,位置集成支持Single View携带标签、时间token一键diff

参考

[1] Brendan Gregg博客上关于火焰图的介绍 https://www.brendangregg.com/flamegraphs.html

[2] 程序员精进之路:性能调优利器--火焰图 程序员精进之路:性能调优利器--火焰图 - 知乎

[3] 小鸟笔记-火焰图 https://www.lijiaocn.com/soft/linux/04-flame-graphs.html

[4] true-myth github 说明文档 https://true-myth.js.org/#maybe-with-the-method-style

[5] Pyroscope 官方文档 https://pyroscope.io/docs/

[6] 新功能:SLS支持持续性能数据采集与监控 https://mp.weixin.qq.com/s/GYJTdldPFVpOwURGnOrpQQ

[7] 深入解读基于Pyroscope构建Continuous Profiling https://ata.alibaba-inc.com/articles/253057

[8] 日志服务SLS性能监控-火焰图文档 https://help.aliyun.com/document_detail/609710.html

[9] 日志服务SLS性能监控-数据查询文档 https://help.aliyun.com/document_detail/609709.html

[10] 日志服务SLS性能监控-数据对比文档 https://help.aliyun.com/documen

/

在进行CPU性能优化的时候,我们经常先需要分析出来我们的应用程序中的CPU资源在哪些函数中使用的比较多,这样才能高效地优化。一个非常好的分析工具就是《性能之巅》作者 Brendan Gregg 发明的火焰图。

我们今天就来介绍下火焰图的使用方法,以及它的工作原理。

一、火焰图的使用
为了更好地展示火焰图的原理,我专门写了一小段代码,

int main() {
    for (i = 0; i < 100; i++) {
        if (i < 10) {
            funcA();
        } else if (i < 16) {
            funcB();
        } else {
            funcC();
        }
    }
}
完整的源码放到了Github上了:https://github.com/yanfeizhang/coder-kung-fu/blob/main/tests/cpu/test09/main.c。

接下来我们用这个代码实际体验一下火焰图是如何生成的。在本节中,我们只讲如何使用,原理后面的小节再展开。

# gcc -o main main.c
# perf record -g ./main
这个时候,在你执行命令的当前目录下生成了一个perf.data文件。接下来咱们需要把Brendan Gregg的生成火焰图的项目下载下来。我们需要这个项目里的两个perl脚本。

# git clone https://github.com/brendangregg/FlameGraph.git
接下来我们使用 perf script 解析这个输出文件,并把输出结果传入到 FlameGraph/stackcollapse-perf.pl 脚本中来进一步解析,最后交由 FlameGraph/flamegraph.pl 来生成svg 格式的火焰图。具体命令可以一行来完成。

# perf script | ./FlameGraph/stackcollapse-perf.pl | ./FlameGraph/flamegraph.pl > out.svg
这样,一副火焰图就生成好了。

之所以选择我提供一个 demo 代码来生成,是因为这个足够简单和清晰,方便大家理解。在上面这个火焰图中,可以看出 main 函数调用了 funcA、funcB、funcC,其中 funcA 又调用了 funcD、funcE,然后这些函数的开销又都不是自己花掉的,而是因为自己调用的一个 CPU 密集型的函数 caculate。整个系统的调用栈的耗时统计就十分清晰的展现在眼前了。

如果要对这个项目进行性能优化。在上方的火焰图中看虽然funcA、funcB、funcC、funcD、funcE这几个函数的耗时都挺长,但它们的耗时并不是自己用掉的。而且都花在执行子函数里了。我们真正应该关注的是火焰图最上方 caculate 这种又长又平的函数。因为它才是真正花掉 CPU 时间的代码。其它项目中也一样,拿到火焰图后,从最上方开始,把耗时比较长的函数找出来,优化掉。

另外就是在实际的项目中,可能函数会非常的多,并不像上面这么简单,很多函数名可能都被折叠起来了。这个也好办,svg 格式的图片是支持交互的,你可以点击其中的某个函数,然后就可以展开了只详细地看这个函数以及其子函数的火焰图了。

怎么样,火焰图使用起来是不是还挺简单的。接下来的小节中我们再来讲讲火焰图生成全过程的内部原理。理解了这个,你才能讲火焰图用的得心应手。

二、perf采样
2.1 perf 介绍
在生成火焰图的第一步中,就是需要对你要观察的进程或服务器进行采样。采样可用的工具有好几个,我们这里用的是 perf record。

# perf record -g ./main
上面的命令中 -g 指的是采样的时候要记录调用栈的信息。./main 是启动 main 程序,并只采样这一个进程。这只是个最简单的用法,其实 perf record 的功能非常的丰富。

它可以指定采集事件。当前系统支持的事件列表可以用过 perf list 来查看。默认情况下采集的是 Hardware event 下的 cycles 这一事件。假如我们想采样 cache-misses 事件,我们可以通过 -e 参数指定。

# perf record -e cache-misses  sleep 5 // 指定要采集的事件
还可以指定采样的方式。该命令支持两种采样方式,时间频率采样,事件次数发生采样。-F 参数指定的是每秒钟采样多少次。-c参数指定的是每发生多少次采样一次。

# perf record -F 100 sleep 5           // 每一秒钟采样100次
# perf record -c 100 sleep 5           // 每发生100次采样一次
还可以指定要记录的CPU核

# perf record -C 0,1 sleep 5           // 指定要记录的CPU号
# perf record -C 0-2 sleep 5           // 指定要记录的CPU范围
还可以采集内核的调用栈

# perf record -a -g ./main
在使用 perf record 执行后,会将采样到的数据都生成到 perf.data 文件中。在上面的实验中,虽然我们只采集了几秒,但是生成的文件还挺大的,有 800 多 KB。我们通过 perf script 命令可以解析查看一下该文件的内容。大概有 5 万多行。其中的内容就是采样 cycles 事件时的调用栈信息。

......
59848 main 412201 389052.225443:     676233 cycles:u:
59849             55651b8b5132 caculate+0xd (/data00/home/zhangyanfei.allen/work_test/test07/main)
59850             55651b8b5194 funcC+0xe (/data00/home/zhangyanfei.allen/work_test/test07/main)
59851             55651b8b51d6 main+0x3f (/data00/home/zhangyanfei.allen/work_test/test07/main)
59852             7f8987d6709b __libc_start_main+0xeb (/usr/lib/x86_64-linux-gnu/libc-2.28.so)
59853         41fd89415541f689 [unknown] ([unknown])
......
除了 perf script 外,还可以使用 perf report 来查看和渲染结果。

# perf report -n --stdio


 资料直通车:Linux内核源码技术学习路线+视频教程内核源码

学习直通车:Linux内核源码内存调优文件系统进程管理设备驱动/网络协议栈

2.2 内核工作过程
我们来简单看一下内核是如何工作的。

perf在采样的过程大概分为两步,一是调用 perf_event_open 来打开一个 event 文件,而是调用 read、mmap等系统调用读取内核采样回来的数据。整体的工作流程图大概如下

其中 perf_event_open 完成了非常重要的几项工作。

创建各种event内核对象
创建各种event文件句柄
指定采样处理回调
我们来看下它的几个关键执行过程。在 perf_event_open 调用的 perf_event_alloc 指定了采样处理回调函数为,比如perf_event_output_backward、perf_event_output_forward等

static struct perf_event *
perf_event_alloc(struct perf_event_attr *attr, ...)
{   
    ...
    if (overflow_handler) {
        event->overflow_handler = overflow_handler;
        event->overflow_handler_context = context;
    } else if (is_write_backward(event)){
        event->overflow_handler = perf_event_output_backward;
        event->overflow_handler_context = NULL;
    } else {
        event->overflow_handler = perf_event_output_forward;
        event->overflow_handler_context = NULL;
    }
    ...
}

当 perf_event_open 创建事件对象,并打开后,硬件上发生的事件就可以出发执行了。内核注册相应的硬件中断处理函数是 perf_event_nmi_handler。

//file:arch/x86/events/core.c
register_nmi_handler(NMI_LOCAL, perf_event_nmi_handler, 0, "PMI");
这样 CPU 硬件会根据 perf_event_open 调用时指定的周期发起中断,调用 perf_event_nmi_handler 通知内核进行采样处理

//file:arch/x86/events/core.c
static int perf_event_nmi_handler(unsigned int cmd, struct pt_regs *regs)
{    
    ret = x86_pmu.handle_irq(regs);
    ...
}
该终端处理函数的函数调用链经过 x86_pmu_handle_irq 到达 perf_event_overflow。其中 perf_event_overflow 是一个关键的采样函数。无论是硬件事件采样,还是软件事件采样都会调用到它。它会调用 perf_event_open 时注册的 overflow_handler。我们假设 overflow_handler 为 perf_event_output_forward

void
perf_event_output_forward(struct perf_event *event, ...)
{
    __perf_event_output(event, data, regs, perf_output_begin_forward);
}
在 __perf_event_output 中真正进行了采样处理

//file:kernel/events/core.c
static __always_inline int
__perf_event_output(struct perf_event *event, ...)
{
    ...
    // 进行采样
    perf_prepare_sample(&header, data, event, regs);
    // 保存到环形缓存区中
    perf_output_sample(&handle, &header, data, event);
}
如果开启了 PERF_SAMPLE_CALLCHAIN,则不仅仅会把当前在执行的函数名采集下来,还会把整个调用链都记录起来。

//file:kernel/events/core.c
void perf_prepare_sample(...)
{
 
    //1.采集IP寄存器,当前正在执行的函数
    if (sample_type & PERF_SAMPLE_IP)
        data->ip = perf_instruction_pointer(regs);
 
    //2.采集当前的调用链
    if (sample_type & PERF_SAMPLE_CALLCHAIN) {
        int size = 1;
 
        if (!(sample_type & __PERF_SAMPLE_CALLCHAIN_EARLY))
            data->callchain = perf_callchain(event, regs);
 
        size += data->callchain->nr;
 
        header->size += size * sizeof(u64);
    }
    ...
}

这样硬件和内核一起协助配合就完成了函数调用栈的采样。后面 perf 工具就可以读取这些数据并进行下一次的处理了。

三、FlameGraph工作过程
前面我们用 perf script 解析是看到的函数调用栈信息比较的长。

......
59848 main 412201 389052.225443:     676233 cycles:u:
59849             55651b8b5132 caculate+0xd (/data00/home/zhangyanfei.allen/work_test/test07/main)
59850             55651b8b5194 funcC+0xe (/data00/home/zhangyanfei.allen/work_test/test07/main)
59851             55651b8b51d6 main+0x3f (/data00/home/zhangyanfei.allen/work_test/test07/main)
59852             7f8987d6709b __libc_start_main+0xeb (/usr/lib/x86_64-linux-gnu/libc-2.28.so)
59853         41fd89415541f689 [unknown] ([unknown])
......
在画火焰图的前一步得需要对这个数据进行一下预处理。stackcollapse-perf.pl 脚本会统计每个调用栈回溯出现的次数,并将调用栈处理为一行。行前面表示的是调用栈,后面输出的是采样到该函数在运行的次数。

# perf script | ../FlameGraph/stackcollapse-perf.pl
main;[unknown];__libc_start_main;main;funcA;funcD;funcE;caculate 554118432
main;[unknown];__libc_start_main;main;funcB;caculate 338716787
main;[unknown];__libc_start_main;main;funcC;caculate 4735052652
main;[unknown];_dl_sysdep_start;dl_main;_dl_map_object_deps 9208
main;[unknown];_dl_sysdep_start;init_tls;[unknown] 29747
main;_dl_map_object;_dl_map_object_from_fd 9147
main;_dl_map_object;_dl_map_object_from_fd;[unknown] 3530
main;_start 273
main;version_check_doit 16041
上面 perf script 5 万多行的输出,经过 stackcollapse.pl 预处理后,输出只有不到 10 行。数据量大大地得到了简化。在 FlameGraph 项目目录下,能看到好多 stackcollapse 开头的文件

这是因为各种语言、各种工具采样输出是不一样的,所以自然也就需要不同的预处理脚本来解析。

在经过 stackcollapse 处理得到了上面的输出结果后,就可以开始画火焰图了。flamegraph.pl 脚本工作原理是:将上面的一行绘制成一列,采样数得到的次数越大列就越宽。另外就是如果同一级别如果函数名一样,就合并到一起。比如现在有一下数据文件:

funcA;funcB;funcC 2
funcA; 1
funcD; 1
我可以通过手工画一个类似的火焰图,如下:

其中 funcA 因为两行记录合并,所以占据了 3 的宽度。funcD 没有合并,占据就是1。另外 funcB、funcC都画在A上的上方,占据的宽度都是2。

总结
火焰图是一个非常好的用于分析热点函数的工具,只要你关注性能优化,就应该学会使用它来分析你的程序。我们今天的文章不光是介绍了火焰图是如何生成的,而且还介绍了其底层的工作原理。火焰图的生成主要分两步,一是采样,而是渲染。

在采样这一步,主要依赖的是内核提供的 perf_event_open 系统调用。该系统调用在内部进行了非常复杂的处理过程。最终内核和硬件一起协同合作,会定时将当前正在执行的函数,以及函数完整的调用链路都给记录下来。

在渲染这一步,Brendan Gregg 提供的脚本会出 perf 工具输出的 perf_data 文件进行预处理,然后基于预处理后的数据渲染成 svg 图片。函数执行的次数越多,在 svg 图片中的宽度就越宽。我们就可以非常直观地看出哪些函数消耗的 CPU 多了。

最后再补充说一句是,我们的火焰图只是一个采样的渲染结果,并不一定完全代表真实情况,但也够用了。
————————————————
版权声明:本文为CSDN博主「Linux内核站」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/youzhangjing_/article/details/130948551

//

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值