跳过导航链接 | |
退出打印视图 | |
Oracle Solaris Studio 12.3:性能分析器 Oracle Solaris Studio 12.3 Information Library (简体中文) |
调用堆栈是一系列程序计数器 (program counter, PC) 地址,表示来自程序内的指令。第一个 PC 称为 叶 PC,它位于堆栈的底部,是要执行的下一条指令的地址。下一个 PC 是对包含叶 PC 的函数的调用的地址;下一个 PC 是对该函数的调用的地址,依此类推,直至到达堆栈的顶部。每个这样的地址称为返回地址。记录调用堆栈的过程涉及从程序堆栈获取返回地址,这称为展开堆栈。有关展开失败的信息,请参见不完全的堆栈展开。
调用堆栈中的叶 PC 用于将独占度量从性能数据分配到该 PC 所在的函数。堆栈中的每个 PC(包括叶 PC)用于将非独占度量分配到它所在的函数。
大多数时候,已记录调用堆栈中的 PC 自然地对应于出现在程序源代码中的函数,而且性能分析器的已报告度量直接对应于这些函数。但是,有时程序的实际执行并不与程序执行方式的简单直观模型相对应,而且性能分析器的已报告度量可能会引起混淆。有关此类情况的更多信息,请参见将地址映射到程序结构。
程序执行的最简单案例是单线程程序调用它自己的装入对象内的函数。
将程序装入到内存中开始执行时,会为其建立上下文,包括要执行的初始地址、初始寄存器集和堆栈(用于存储临时数据和用于跟踪函数如何相互调用的内存区域)。初始地址始终位于函数 _start()(它内置于每个可执行程序中)的开头。
程序运行时,将按顺序执行指令,直至遇到分支指令,该指令以及其他指令可能表示函数调用或条件语句。在分支点上,控制权转移到分支目标指定的地址,然后从该地址继续执行。(通常,已提交分支后的下一条指令以供执行:此指令称为分支延迟槽指令。但是,有些分支指令会取消分支延迟槽指令的执行。
当执行表示调用的指令序列时,返回地址被放入寄存器,且在被调用函数的第一条指令处继续执行。
在大多数情况下,在被调用函数的前几个指令中的某个位置,一个新帧(用于存储有关函数的信息的内存区域)会被推到堆栈上,而返回地址被放入该帧。然后,在被调用函数本身调用其他函数时可以使用用于返回地址的寄存器。函数即将返回时,将其帧从堆栈中弹出,而且控制权返回到从其调用该函数的地址。
一个共享对象中的函数调用另一个共享对象中的函数时,其执行情况比在程序内对函数的简单调用更复杂。每个共享对象都包含一个程序链接表 (Program Linkage Table, PLT),该表包含位于该共享对象外部并从该共享对象引用的每个函数的条目。最初,PLT 中每个外部函数的地址实际上是 ld.so(即动态链接程序)内的地址。第一次调用这样的函数时,控制权将转移到动态链接程序,该动态链接程序会解析对实际外部函数的调用并为后续调用修补 PLT 地址。
如果在执行三个 PLT 指令之一的过程中发生分析事件,则 PLT PC 会被删除,并将独占时间归属到调用指令。如果在通过 PLT 条目首次调用期间发生分析事件,但是叶 PC 不是 PLT 指令之一,PLT 和 ld.so 中的代码引起的任何 PC 都将归属到人工函数 @plt 中,该函数将累计非独占时间。每个共享对象都存在一个这样的人工函数。如果程序使用 LD_AUDIT 接口,则可能从不修补 PLT 条目,而且来自 @plt 的非叶 PC 可能发生得更频繁。
将信号发送到进程时,会发生各种寄存器和堆栈操作,使得发送信号时的叶 PC 看起来好像是对系统函数 sigacthandler() 的调用的返回地址。sigacthandler() 调用用户指定的信号处理程序,就像任何函数调用另一个函数一样。
性能分析器将信号传送产生的帧视为普通帧。传送信号时的用户代码显示为调用系统函数 sigacthandler(),而 sigacthandler() 又显示为调用用户的信号处理程序。来自 sigacthandler() 和任何用户信号处理程序以及它们调用的任何其他函数的非独占度量,都显示为中断函数的非独占度量。
收集器通过插入 sigaction(),以确保其处理程序在收集时钟数据时是 SIGPROF 信号的主处理程序,而在收集硬件计数器溢出数据时是 SIGEMT 信号的主处理程序。
陷阱可以由指令或硬件发出,而且由陷阱处理程序捕获。系统陷阱是指通过指令启动的陷阱,它们会陷入内核。所有系统调用均使用陷阱指令实现。一些硬件陷阱示例包括:当浮点单元无法完成指令或者当指令无法在硬件中实现时,浮点单元会发出硬件陷阱。
当发出陷阱时,内核将进入系统模式。在 Oracle Solaris 上,微状态通常从用户 CPU 状态切换到陷阱状态,再切换到系统状态。处理陷阱所用的时间可以显示为系统 CPU 时间和用户 CPU 时间的组合,具体取决于切换微状态的时间点。该时间被归属到用户代码中从其启动陷阱的指令(或归属到系统调用)。
对于某些系统调用,提供尽可能高效的调用处理被认为是很关键的。由这些调用生成的陷阱称为快速陷阱。生成快速陷阱的系统函数包括 gethrtime 和 gethrvtime。在这些函数中,由于涉及到的开销,所以不会切换微状态。
在其他情况下,提供尽可能高效的陷阱处理也被认为是很关键的。其中的一些示例是 TLB(translation lookaside buffer,转换后备缓冲器)未命中以及寄存器窗口溢出和填充,其中不切换微状态。
在这两种情况下,所用的时间都记录为用户 CPU 时间。但是,由于 CPU 模式已切换为系统模式,所以将关闭硬件计数器。因此,通过求出用户 CPU 时间和周期时间(最好在同一实验中记录)之间的差值,可以估算处理这些陷阱所用的时间。
有一种陷阱处理程序切换回用户模式的情况,那是 Fortran 中在 4 字节边界上对齐的 8 字节整数的未对齐内存引用陷阱。陷阱处理程序的帧出现在堆栈上,而对处理程序的调用可以出现在性能分析器中,归属到整数装入或存储指令。
指令陷入内核后,陷阱指令后的指令看起来要使用很长时间,这是因为它在内核完成陷阱指令的执行之前无法启动。
只要特定函数执行的最后一个操作是调用另一个函数,编译器就可以执行一种特定的优化。被调用方可重用来自调用方的帧,而不是生成新的帧,而且可从调用方复制被调用方的返回地址。此优化的动机是减小堆栈的大小,以及(在 SPARC 平台上)减少对寄存器窗口的使用。
假定程序源代码中的调用序列与如下所示类似:
A -> B -> C -> D
对 B 和 C 进行尾部调用优化后,调用堆栈看起来好像是函数 A 直接调用函数 B、C 和 D。
A -> B A -> C A -> D
也就是说,调用树被展平。使用 -g 选项编译代码时,尾部调用优化仅发生在编译器优化级别 4 或更高级别上。在不使用 -g 选项的情况下编译代码时,尾部调用优化发生在编译器优化级别 2 或更高级别上。
简单的程序在单线程中执行。多线程可执行程序调用线程创建函数(执行的目标函数会传递到该函数)。目标退出时,会销毁线程。
Oracle Solaris 支持两种线程实现:Solaris 线程和 POSIX 线程 (Pthread)。从 Oracle Solaris 10 开始,这两种线程实现都包括在 libc.so 中。
对于 Solaris 线程,新创建的线程从名为 _thread_start() 的函数开始执行,该函数调用在线程创建调用中传递的函数。对于涉及此线程执行的目标的任何调用堆栈,堆栈的顶部是 _thread_start(),与线程创建函数的调用方没有任何联系。因此,与所创建的线程关联的非独占度量最多仅向上传播至 _thread_start() 和 <Total> 函数。除了创建线程外,Solaris 线程实现还在 Solaris 上创建 LWP 以执行线程。每个线程都绑定到特定的 LWP。
Oracle Solaris 和 Linux 中都提供了用于显式多线程的 Pthread。
在这两种环境中,为创建新线程,应用程序会调用 Pthread API 函数 pthread_create(),将指针作为函数参数之一传递到应用程序定义的启动例程。
在早于 Oracle Solaris 10 的 Solaris 版本上,新的 pthread 开始执行时,将会调用 _lwp_start() 函数。从 Oracle Solaris 10 开始,_lwp_start() 调用中间函数 _thrp_setup(),该中间函数随后调用在 pthread_create() 中指定的应用程序定义的启动例程。
在 Linux 操作系统上,新的 pthread 开始执行时,将会运行 Linux 特定的系统函数 clone(),该系统函数调用另一个内部初始化函数 pthread_start_thread(),该初始化函数又调用在 pthread_create() 中指定的应用程序定义的启动例程。可用于收集器的 Linux 度量收集函数是线程特定的。因此,collect 实用程序运行时,会在 pthread_start_thread() 和应用程序定义的线程启动例程之间插入一个名为 collector_root() 的度量收集函数。
对于典型的开发者,基于 Java 技术的应用程序就像任何其他程序那样运行。此类应用程序从主入口点(通常名为 class.main,可以调用其他方法)开始,就像 C 或 C++ 应用程序那样。
对于操作系统,使用 Java 编程语言(纯 Java 或与 C/C++ 混合)编写的应用程序作为实例化 JVM 软件的进程运行。JVM 软件是从 C++ 源代码编译的,从 _start(它会调用 main 等)开始执行。它从 .class 和/或 .jar 文件读取字节码,并执行在该程序中指定的操作。可以指定的操作包括动态装入本机共享对象以及调用该对象内包含的各种函数或方法。
JVM 软件可以执行许多用传统语言编写的应用程序通常不能执行的操作。启动时,该软件会在其数据空间中创建许多动态生成的代码的区域。其中一个区域是用于处理应用程序的字节码方法的实际解释器代码。
在执行基于 Java 技术的应用程序期间,JVM 软件解释大多数方法;这些方法称为已解释的方法。Java HotSpot 虚拟机会在解释字节码以检测频繁执行的方法时监视性能。然后,Java HotSpot 虚拟机可能编译重复执行的方法,以生成这些方法的机器码。生成的方法称为已编译的方法。之后,虚拟机执行更高效的已编译方法,而不是解释方法的原始字节码。已编译的方法会被装入应用程序的数据空间,并且可能会在之后的某个时间点卸载它们。此外,还会在数据空间中生成其他代码以执行已解释代码和已编译代码之间的转换。
用 Java 编程语言编写的代码还可以直接调用本机编译的代码(C、C++ 或 Fortran);此类调用的目标称为本机方法。
用 Java 编程语言编写的应用程序本身就是多线程的,对于用户程序中的每个线程,都具有一个 JVM 软件线程。Java 应用程序还具有若干个内务处理线程,用于信号处理、内存管理和 Java HotSpot 虚拟机编译。
在 J2SE 中的 JVMTI 上,可通过各种方法实现数据收集。
性能工具通过记录每个线程生存期中的事件,以及发生事件时的调用堆栈来收集其数据。在执行任何应用程序的任意点上,调用堆栈表示程序在其执行中所处的位置以及它如何到达该位置。混合模型 Java 应用程序区别于传统 C、C++ 和 Fortran 应用程序的一个重要方面是,在运行目标的过程中的任何瞬间都存在两个有意义的调用堆栈:Java 调用堆栈和机器调用堆栈。这两个调用堆栈都在配置期间进行记录,并在分析期间进行协调。
用于 Java 程序的基于时钟的分析和硬件计数器溢出分析的工作方式与用于 C、C++ 和 Fortran 程序的情况基本相同,不同之处在于前者会同时收集 Java 调用堆栈和机器调用堆栈。
性能分析器为用 Java 编程语言编写的应用程序提供了三种显示性能数据的视图模式:用户模式、专家模式和计算机模式。缺省情况下,将显示用户模式(前提是数据支持它)。下一节汇总了这三种视图模式的主要差异。
用户模式按名称显示已编译的和已解释的 Java 方法,并以其自然形式显示本机方法。在执行过程中,可能存在已执行的特定 Java 方法的许多实例:已解释的版本,也许还有一个或多个已编译的版本。在用户模式中,所有方法会被聚集显示为一个方法。缺省情况下,在分析器中选定此视图模式。
用户视图模式中 Java 方法的 PC 与该方法中的方法 id 和字节码索引相对应;本机函数的 PC 与机器 PC 相对应。Java 线程的调用堆栈可能同时具有 Java PC 和机器 PC。它没有对应于 Java 内务处理代码(无 Java 表示法)的任何帧。在某些情况下,JVM 软件无法展开 Java 堆栈,将返回单个帧及特殊函数 <no Java callstack recorded>。通常,它占总时间的比例不会超过 5-10%。
用户模式中的函数列表针对所调用的 Java 方法和任何本机方法显示度量。调用方-被调用方标签显示用户模式中的调用关系。
Java 方法的源代码对应于 .java 文件(从中编译源代码,每个源代码行上都有度量)中的源代码。任何 Java 方法的反汇编显示为其生成的字节码,以及针对每个字节码的度量和交错的 Java 源代码(如果可用)。
Java 表示法中的时间线仅显示 Java 线程。每个线程的调用堆栈与其 Java 方法一起显示。
当前不支持 Java 表示法中的数据空间分析。
专家模式类似于用户模式,只不过在用户模式下禁止的一些 JVM 内部详细信息会在专家模式中公开。对于专家模式,时间线显示所有线程;内务处理线程的调用堆栈是本机调用堆栈。
计算机模式显示来自 JVM 软件本身而不是来自 JVM 软件解释的应用程序的函数。该表示法还显示所有已编译方法和本机方法。计算机模式看起来与用传统语言编写的应用程序的计算机模式相同。调用堆栈显示 JVM 帧、本地帧和编译方法帧。一些 JVM 帧表示已解释的 Java、已编译的 Java 和本机代码之间的转换代码。
针对 Java 源代码显示已编译方法的源代码;数据表示所选已编译方法的特定实例。已编译方法的反汇编显示生成的机器汇编程序代码,而不是 Java 字节码。调用方-被调用方关系显示所有开销帧,以及表示已解释方法、已编译方法和本机方法之间的转换的所有帧。
计算机视图模式中的时间线以条形图显示所有线程、LWP 或 CPU,而其中每项的调用堆栈都是调用堆栈的计算机模式。
OpenMP 应用程序的实际执行模型在 OpenMP 规范中进行了描述(例如,请参见《OpenMP Application Program Interface, Version 3.0》中的 1.3 节)。但是,该规范未描述对用户可能很重要的一些实现详细信息,Oracle 的实际实现是这样的:通过直接记录的分析信息,用户并不能轻松了解线程是如何交互的。
在任何单线程程序运行时,其调用堆栈会显示其当前位置,以及如何到达那里的跟踪,跟踪从名为 _start 的例程(该例程调用 main,后者又继续调用程序内的各种子例程)中的起始指令开始。如果子例程包含循环,则程序会重复执行循环内的代码,直至达到循环退出条件。然后继续执行下一个代码序列,依此类推。
通过 OpenMP(或通过自动并行化)并行化程序时,行为是不同的。该并行化程序的直观模型具有像单线程程序那样执行的主线程。当它到达并行循环或并行区域时,将出现其他从属线程(每个都是主线程的克隆),它们都并行执行循环或并行区域的内容,每个从属线程执行不同的工作块。在完成所有工作块时,所有线程都是同步的,从属线程将消失,而主线程继续运行。
并行化程序的实际行为并非如此简单。在 Oracle 实现中,当编译器为并行区域或循环(或任何其他 OpenMP 构造)生成代码时,将提取其内部的代码,并使之成为一个名为 mfunction 的独立函数。(也可以将它称为外联函数或循环体函数。)mfunction 的名称是对 OpenMP 构造类型、从中提取它的函数的名称以及该构造所在源代码行的行号进行编码的结果。这些函数的名称在分析器的专家模式和计算机模式下按以下形式显示,其中方括号中的名称是函数的实际符号表名称:
bardo_ -- OMP parallel region from line 9 [_$p1C9.bardo_] atomsum_ -- MP doall from line 7 [_$d1A7.atomsum_]
还有其他形式的此类函数,它们是从其他源代码构造派生的,名称中的 OMP parallel region 会被替换为 MP construct、MP doall 或 OMP sections。在下面的讨论中,所有这些都统称为并行区域。
执行并行循环内代码的每个线程都可以多次调用其 mfunction,每次调用执行循环内的一个工作块。在所有工作块完成时,每个线程调用库中的同步或归约例程;然后主线程继续,而从属线程变为空闲状态,等待主线程进入下一个并行区域。所有调度和同步都是通过对 OpenMP 运行时的调用处理的。
在其执行过程中,并行区域内的代码可能执行一个工作块,或者它可能与其他线程同步,或者选择要执行的其他工作块。它还可能调用其他函数,而这些函数又可能会再调用其他函数。在并行区域内执行的从属线程(或主线程)可能本身(或者通过它调用的函数)充当主线程,并进入它自己的并行区域,从而导致嵌套并行操作。
分析器基于调用堆栈的统计抽样收集数据,跨所有线程聚集其数据,并针对函数、调用方和被调用方、源代码行和指令,基于所收集数据的类型显示性能度量。分析器按以下三种视图模式之一提供有关 OpenMP 程序性能的信息:用户模式、专家模式和计算机模式。
有关 OpenMP 程序的数据收集的更多详细信息,请参见 OpenMP 用户社区 Web 站点上的《An OpenMP Runtime API for Profiling》。
分析数据的用户模式显示尝试提供信息,好像程序按照OpenMP 软件执行概述中所述的直观模型实际执行一样。在计算机模式下显示的实际数据捕获运行时库 libmtsk.so(它不对应于模型)的实现详细信息。专家模式显示为匹配模型而改变的混合数据和实际数据。
在用户模式下,更改了分析数据的显示以便更好地匹配模型,在以下三个方面不同于记录的数据和机器模式显示:
从 OpenMP 运行时库的角度来看,构造的人工函数表示每个线程的状态。
处理调用堆栈以报告对应于代码运行方式模型的数据,如上所述。
为基于时钟的分析实验构造另外两个性能度量,它们分别对应于执行有用工作所用的时间和在 OpenMP 运行时中等待所用的时间。度量为“OpenMP 工作”和“OpenMP 等待”。
对于 OpenMP 3.0 程序,又构造了一个度量“OpenMP 开销”。
构造人工函数,并将其放置在用户模式和专家模式调用堆栈上,以反映线程在 OpenMP 运行时库中处于某个状态的事件。
定义了以下人工函数:
|
当线程处于对应于其中一个人工函数的 OpenMP 运行时状态时,会将该人工函数作为堆栈上的叶函数添加。当线程的实际叶函数处于 OpenMP 运行时中的任意位置时,<OMP-overhead> 将作为叶函数替换它。否则,从用户模式堆栈中忽略 OpenMP 运行时中的所有 PC。
对于 OpenMP 3.0 程序,不使用 <OMP-overhead> 人工函数。由“OpenMP 开销”度量替换人工函数。
对于 OpenMP 实验,用户模式显示重构的调用堆栈,这些重构的调用堆栈类似于在不使用 OpenMP 的情况下编译程序时获取的调用堆栈。目的在于以与程序的直观了解相匹配的方式提供分析数据,而不是显示实际处理的所有详细信息。当 OpenMP 运行时库执行特定操作时,将协调主线程与从线程的调用堆栈并将人工 <<OMP-*> 函数添加到此调用堆栈。
处理 OpenMP 程序的时钟分析事件时,将显示两个度量(它们分别对应于 OpenMP 系统中的两种状态所用的时间):“OpenMP 工作”和“OpenMP 等待”。
只要从用户代码执行线程(不管串行执行还是并行执行),就会在“OpenMP 工作”中累计时间。只要线程正在等待某项,之后才能继续,就会在“OpenMP 等待”中累计时间,而不管等待是忙等待(自旋等待)还是休眠。这两个度量的总和与时钟分析中的“总线程”度量相匹配。
在用户模式、专家模式和计算机模式下显示“OpenMP 等待”和“OpenMP 工作”度量。
查看专家视图模式下的 OpenMP 实验时,如果 OpenMP 运行时正在执行某些特定操作,您将看到格式为 <OMP-*> 的人工函数,这一点类似于用户视图模式。但是,专家视图模式单独显示表示并行化循环、任务等的编译器生成的 mfunction。用户模式会将这些编译器生成的 mfunction 与用户函数聚集在一起。
机器模式显示所有线程和编译器生成的外联函数的本机调用堆栈。
在执行的各个阶段中,程序的实际调用堆栈与上面在直观模型中描述的有很大差异。计算机模式将调用堆栈显示为已度量,没有进行转换,且没有构造人工函数。但是,仍显示时钟分析度量。
在下面的每个调用堆栈中,libmtsk 表示 OpenMP 运行时库内调用堆栈中的一个或多个帧。就像屏障代码的内部实现或执行归约一样,出现哪些函数以及出现顺序的详细信息随 OpenMP 的发行版的不同而不同。
在第一个并行区域之前
在进入第一个并行区域之前,只有一个线程,即主线程。调用堆栈与用户模式和专家模式下的完全相同。
|
在并行区域中执行时
|
在机器模式下,从属线程显示为在 _lwp_start 中启动,而不是在 _start(主线程在其中启动)中启动。(在线程库的某些版本中,该函数可能显示为 _thread_start。对 foo-OMP... 的调用表示为并行化区域生成的 mfunction。
所有线程都在屏障处时
|
与线程在并行区域中执行时不同,当线程在屏障处等待时,在 foo 和并行区域代码 foo-OMP... 之间没有来自 OpenMP 运行时的帧。原因是实际执行中不包括 OMP 并行区域函数,但 OpenMP 运行时处理寄存器,以便堆栈展开显示从最后执行的并行区域函数到运行时屏障代码的调用。如果没有它,在机器模式下将无法确定哪个并行区域与屏障调用相关。
离开并行区域之后
|
在从属线程中,没有用户帧位于调用堆栈上。
在嵌套并行区域中时
|
堆栈展开在调用堆栈和程序执行中定义。
堆栈展开可能由于多种原因而失败:
如果堆栈已被用户代码破坏;如果是这样,则程序可能进行信息转储,或者数据收集代码可能进行信息转储,具体取决于堆栈被破坏的确切方式。
如果用户代码不遵循函数调用的标准 ABI 约定。特别是,在 SPARC 平台上,如果在执行保存指令之前更改了返回寄存器 %o7。
在任何平台上,手工编写的汇编程序代码都可能违反约定。
如果在从堆栈中弹出被调用方的帧之后,但在函数返回之前,叶 PC 位于函数中。
如果调用堆栈包含的帧超过 250 个,则收集器没有用于完全展开调用堆栈的空间。在这种情况下,调用堆栈中从 _start 到某个点的函数的 PC 不会记录在实验中。人工函数 <Truncated-stack> 显示为从 <Total> 调用,以清点所记录的最上面的帧。
如果收集器无法在 x86 平台上展开优化的函数的帧。
如果使用 -E 或 -P 编译器选项生成中间文件,则分析器将中间文件用于带注释的源代码,而不是原始源文件。使用 -E 生成的 #line 指令可能会导致为源代码行分配度量时出现问题。
如果函数中的指令没有行号(这些行号引用为生成该函数而编译的源文件),则带注释的源代码中会出现以下行:
function_name -- <instructions without line numbers>
在以下情况下可能缺少行号:
编译时未指定 -g 选项。
调试信息在编译后被剥离,或者包含该信息的可执行文件或目标文件被移动或删除或者随后被修改。
函数包含从 #include 文件而不是从原始源文件生成的代码。
在进行较高级别优化时,如果代码从不同文件中的函数内联。
源文件具有引用某个其他文件的 #line 指令;使用 -E 选项进行编译,然后再编译生成的 .i 文件是出现此情况的一种方式。使用 -P 标志编译时也可能会出现此情况。
找不到读取行号信息的目标文件。
所用的编译器生成不完整的行号表。