介绍
作者:David Levinthal本文是有关面向英特尔® 安腾® 处理器的软件优化系列文章中的最后一篇(共 5 篇),旨在帮助开发人员通过目测检查生成的汇编代码来判别代码的编译优劣(特别是在大量运用循环结构的应用中,不了解生成的指令序列也无所谓), 目标是评估编译器是否能够充分利用高性能微架构的各项特性,如在将高级语言转换为有效指令流过程中的软件管线、预测和预取。
通过检查程序中 CPU 密集型关键段(通过英特尔® VTune™ 可视化性能分析器确定)的汇编代码,开发人员可发现是否有某代码区域中语言局限(如指针歧义或由编译器造成的优化失败)限制了应用程序的性能。
在下面讨论中,假定所研究的汇编器为主要执行流,如英特尔® VTune™ 可视化性能分析器的源代码和汇编器显示中较高的事件数所标识的那样。
有关本主题的介绍和更多信息,请参阅本系列的其它部分:
架构研究
如果一切进行顺利,开发人员只需设计算法和数据结构,将它们编入程序并进行编译即可。编译器将变魔术般地将高级指令转换成可最佳利用微处理器架构特性的汇编代码,得到最高的性能。然而令人遗憾的是,实际情况中,由于算法和数据结构设计拙劣、语言限制和编译器缺陷,我们的执行结果常常不尽如人意。接受这一现实的同时,我们面临着一个机遇 - 程序优化的机遇。就此,应用执行的微架构研究是一种非常有效的方法,但必须交叉验证,以保证处理器的基本架构特性得到编译器的合理调度。性能监视单元(PMU)可发现执行较差的代码段,包括识别大量占用 cpu 的代码段,及明细 cup 周期的精确使用。然而,PMU 无法识别指令调度是否以最佳方式利用了架构特性。这对于大量采用循环结构的应用极其重要。在这些应用中,英特尔® 安腾® 架构固有的并行性能够利用循环展开和软件管线等特性来创建可同时执行的独立指令。如果编译器生成的指令流没有在循环结构代码中利用这些特性,性能损耗会轻松升级。程序优化阶段务必要确保这些特性能够以最佳方式包括在最终指令调度之内,这一点非常关键。
英特尔安腾 处理器的高性能基于并行指令执行之上。在基于循环结构的代码中,生成独立指令流的最简单方法是展开循环体,以并行方式执行单个迭代。此外,您可以使用软件管线架构和旋转寄存器来将循环体高级语言指令迭代分解为几个级(stage)。然后,您可以并行执行不同迭代的多个级。您可以轻松同时或单独运用这两种并行模式(展开循环体和将各迭代的多个级作管线化处理),因为各种软硬件约束是平衡的。
循环体展开可通过两种方式提高性能。它创建了一个更大的独立指令流,编译器可用之以最佳方式填充六个指令时槽(每时钟周期执行)。更隐蔽些的受益来自数据复用,即数据元素一次加载,可由展开的迭代多次使用和/或在不同的任务中利用。实例见矩阵相乘(其最终形式中使用了第二个矩阵的转置矩阵),每个矩阵中的四个元素加载后可得出 16 种组合,即每个加载元素都有 4 次数据复用。或者,考虑网格上的微分方程估算,在此需计算网格点值间的差值。展开循环体允许在减号两边使用同一个值。为确保这两个例子能够正常运行,循环体迭代必须彼此独立。
需要注意的关键一点是,只有最内部的循环体才能进行软件管线化处理。为了确保软件管线的有效性,循环次数(tripcount)(即迭代次数)应尽可能长。这或许会要求进行循环体交换,以保证最内部的循环体具有合理的循环次数。或将短的、固定的内部循环体显性展开(手动展开或由编译器展开),将具有高循环次数的循环体作为最内部的循环体,作软件管线处理后以最佳方式执行该循环体。在本文的结尾处我们提供了一个相关实例。
软件管线化
软件管线化的工作机理更为基础。循环体迭代可被分成几个级,典型的可分为一个数据加载级、一个计算级,可能还有一个结果存储级。如果高级语言循环体迭代逐句进行编码,这些级会依次序逐一发生,当一个迭代的所有级全部完成后,才会调用下一迭代的第一级。然而,如果循环体迭代彼此独立,则不同高级语言迭代的各级将能够同时执行,这就是管线化。以 daxpy 函数为例:该循环体有三个级:加载级,加载阵列 a 和 b;计算级,计算 a[I]+X*b[I];最后是存储级,用计算结果覆盖阵列a。英特尔®安腾® 处理器家族采用旋转寄存器方案来调用软件管线。Daxpy 的例子演示了软件管线的工作方式,帮您熟悉如何识别在编译器生成的指令序列中是否使用了该硬件特性。
这一循环体最少可用五条指令编写:两个地址增量加载、一个浮点乘加(fma)、一个地址增量存储以及一个分支指令。我们假设所有这些指令如在英特尔® 安腾® 处理器上那样,可在单个周期内调用。同时,它们要求有可用输入值,否则在等待时处理器将被迫停顿。
举例来说,考虑稳态情形,我们假定加载占用 6 个周期,fma 占用 4 个周期。启用后向定时,从高级语言循环体的迭代存储开始,I=N,N 远远大于 1。五个指令的每个循环体迭代占用 1 个周期,因此必须提前 4 个周期调用产生待存储结果的 fma。如此,在该迭代中,循环体必须为第 N+4 个迭代调用 fma。类似地,fma 对应的加载必须在 fma 之前 6 个周期发生,该迭代必须为第 N+4+6 或第 N+10 个迭代调用加载。通过这种方式,处理器能够在每个时钟周期产生一个运算结果(而不是每十个时钟周期产生一个运算结果),并在开始下一个迭代前,完成已开始的迭代。
凭借旋转寄存器方案,在英特尔安腾 处理器上构建此类编码非常简单。每次调用四个专用分支指令之一时,标识寄存器编号方案基数的指针都会以循环方式递减。这样,如果加载指向浮点(fp)寄存器 f32,循环体下一个迭代上的目标寄存器(当加载完成后,该寄存器将保存数据)将标识为 f33。按先前举例中的假设,如果在迭代 N 上将数据载入 f32,则能够在 6 个迭代后,在寄存器 f38 中访问到。实例近似编码如下,假定 a 的地址存入 r32 和 r31,b 存入 r33,常数 X 存入 f10,数据为单精度:
标签:
Ldfs f32 = [r32],4 Ldfs f39 = [r33],4 Fma f50 = f47, f10, f38 Stfs [r31] = f54,4 Br.ctop.sptk label: |
f32 和 f39 中的加载各自在 f38 和 f47 后 6 个周期消耗。Fma 的结果(指向 f50)实际上是在 4 个周期后进行存储,从寄存器 f54 检索获得。整数寄存器 r31、r32 和 r33 增加“4”,这样在下一次迭代中能够引用单精度阵列的下一个元素。该编码演示了如何使用旋转寄存器实现软件管线化,以吸收操作延迟。 开发人员可通过两个特性来辨别循环体是否编码以利用软件管线化硬件。第一,利用以下 4 个特殊指令之一来终止循环体,br.ctop、br.wtop、br.cexit 和 br.wexit,这些指令可造成寄存器旋转。如果最内部的循环体没有使用这些指令中的一个来终止,那么编译器不会对循环体进行软件管线化。第二,操作延迟借助级的使用而被隐藏,此时延迟借使用汇编器编码循环体的多个迭代而被吸收。这一点可通过用作载入目标的寄存器和数据检索寄存器之间的差异而表明。类似地,还有作为操作(ex: fma)输出的寄存器和结果检索寄存器之间的差异。有时如果循环体足够长且复杂,单个循环体迭代就能够吸收整个操作延迟。查看数据输入寄存器和检索寄存器号之间的差值泛化通常很精确。
稳态允许同时执行所有级。但是如何达到稳态?尤其是如何为准备稳态而载入管线,以及当循环体将其全部的迭代运行完毕后,应如何排出管线?
利用谓词(Predicate)载入和排出管线
过去,在大型向量超级计算机上,软件管线循环体编码有序言(prologue)、稳态内核(steady state kernel)和结尾(epilogue)。序言负责准备管线,在各级的输入值可用时顺序启用它们;稳态内核启用后会以最大速度执行工作;当循环体即将结束时,结尾会排出管线中剩余的迭代。这样,在最初,序言会为第一批迭代调用数据载入指令,并在时间允许范围内尽可能多地发出指令,直到完成第一批载入。此时,将启动用于第一个高级语言迭代的数学运算,消耗第一次载入中的数据(现在已可用)。这将启用越来越多的级,直到所有级都被激活,此时将调用稳态内核。在理想情况下,稳态内核将完成大部分的工作,通过并行处理多个迭代的多个级来实现最大的计算效能。最终,当管线完成最后迭代的第一个级时,它将被依次关闭,从而完成计算每个所需的计算结果。该步骤通过显性编码的结尾来处理,结尾将以相同的次序关闭先前由序言所打开的各个级。在英特尔® 安腾® 处理器上使用谓词逻辑来启用和禁用管线级,如载入和排出管线的序言和结尾中所要求的那样。因此,单个指令流能够执行所有这三个部分--序言、内核与结尾。同时,谓词逻辑还为此使用了旋转寄存器方案,利用上部 48 个谓词(16-63)构成一个旋转集。再一次地,这里需用到 4 个专用分支指令。因此,这 48 个谓词将以上文所述的方式按基数递减旋转。随着分支指令的每次执行,基数递减,p16 中的值能够在下一迭代中从 p17 进行访问。
与谓词的不同之处在于,分支指令同样还利用两个应用寄存器:循环体计数器(ar.lc)和结尾计数器(ar.ec),来为每个新的循环体迭代设置 p16 的值,从而控制其工作。循环体计数器控制循环体的长度。每次执行 4 个分支指令中的任意一个,ar.lc 都会递减。在分支指令执行时,如该值大于 0,则会使用“true”值来为下一个循环体迭代预设 p16。这样,true 值将在谓词范围内旋转,以启用管线各级的执行。如果在循环体开始前,将 p16 初始化设为 true,p17 至 p48 初始化为 false, 则可在第一次通过循环体时调用管线第一级的那些指令。随着每次新加迭代,另一个谓词变为 true,从而能够启用下一级。通过这种方式,管线完成载入,序言得到执行。当启用了所有的级后,循环体将执行所有指令,序言进入管线内核。
一旦 ar.lc 递减为 0,结尾计数器(ar.ec)会接管其工作。现在每次执行分支指令将开始递减结尾计数器。当结尾计数器的值大于 0 时,寄存器同先前一样旋转,但是 p16 初始化为“false”或为 0。该 false 值在用以控制级的谓词范围内旋转,依次关闭并排出管线。随着依次禁用各个级,结尾产生与序言完全相反的行为。此处的妙处在于所有这些都能通过单条指令流实现。
在我们的 daxpy 实例中,谓词可如下编写:
标签:
(p16) Ldfs f32 = [r32],4 (p16) Ldfs f39 = [r33],4 (p22) Fma f50 = f47, f10, f38 (p26) Stfs [r31] = f54,4 Br.ctop.sptk label: |
谓词标识符之间的差值必须与用于吸收延迟的旋转数量相符。通过以并列方式修改二者,能够吸收更多延迟。例如,调度循环体以其 13 个周期延迟耗尽 L3 高速缓存,如下所示,从而允许更多的寄存器用作 a 和 b 的旋转集。
标签:
(p16) Ldfs f32 = [r32],4 (p16) Ldfs f48 = [r33],4 (p29) Fma f63 = f61, f10, f47 (p33) Stfs [r31] = f67,4 Br.ctop.sptk label: |
识别有效的软件管线
识别软件管线非常简单:- 有一个循环体利用 4 个特殊分支指令之一来结束。
- 各级以不同的谓词启用。
- 在载入和消耗指令之间调度寄存器标识符的旋转,从而能够不需任何停顿地吸收延迟。
软件管线可通过并行地执行不同高级语言迭代的不同级操作而实现并行,循环体展开则通过并行执行不同高级语言迭代的相同级而实现并行。两种模式如有一种可行,即可同时实现。二者都需要迭代独立性,这样赋值才不会覆盖以后的迭代输入。
有一点至关重要,即将要研究的汇编器是由英特尔® VTune™ 可视化性能分析器所标识的关键、cpu 周期消耗路径。编译器将生成多条路径来充分利用数据对齐(data alignment)。此外,当编译器展开循环体时,它将生成代码来执行展开因子相乘之外所需的任何剩余迭代。
通过目测检查识别是否打开了循环体通常非常简单。您只需计算输入数量(必需读取的变量)和载入数量,或者如果您能够估算 fma 的数量,计量调度次数即可。其比率应为整数倍。如果不是,通常您可以利用 NOUNROLL pragma 来确定单个迭代所需识别指令的数量。
在规则合计情形中,循环体打开不是必需的。当实变量的 fortran 程序有规律地由编译器以 8 为因子展开时,如果数据类型为复或(复)双精度,展开因子常常为 2。类似地,有时 C 和 C++ 程序不能像用 fortran 编写的相同算法那样有效展开。英特尔® 编译器开发小组正积极地研究这些问题。
利用 Lfetch 指令进行数据预取
实现基于循环体应用高性能的第三个关键问题是良好的数据预取。在 O3 优化中,编译器应能够提前一定时间预取所需的数据.这段时间对于载入和存储指令来说,应足以找到处理器缓存中所需的高速缓存线。您可以在编译器中的 lfetch 指令看到这一点。预取间隔(prefetch distance)在循环体之前设置,范围通常为 1500 至 4000 个字节,这取决于数据存取的跨距和每(汇编器)循环体迭代消耗的数据数量。您可根据三点来快速估算预取间隔:每个迭代中的周期数量、数据存取的跨距(通常等于每迭代消耗的字节数量)以及主内存的延迟。对于在消耗前预取的数据,其预取间隔为:
预取间隔 > 延迟 *(跨距/128)/(每个叠代的周期数)
在 daxpy 例子中,每个循环体迭代需要两个单精度输入和一个输出。此例中,输出同样为输入之一,因此不需要额外的输出预取。循环体的每个迭代都消耗阵列高速缓存线的 1/32,因此跨距为 4 个字节。假设延迟为:
延迟 ~ 200 个周期 *(处理器频率,单位为 Ghz),基于 1.5 Ghz 英特尔® 安腾® 处理器的平台为 300 个周期(这对于采用英特尔® 870 芯片组的英特尔® 安腾® 架构系统来说是相当精确的)。
因此,预取间隔为(包含单位):
预取间隔 >
300(周期)* 4(字节/迭代)/128(字节/缓存线)/(1 周期/迭代)
> 10 缓存线
> 1280 字节
在实际编译代码中,将展开循环体,调度载入对指令(load pair instruction),所需的预取间隔会稍微大一些。根据数据类型和源语言的不同,英特尔® 编译器对 daxpy 循环体的预取间隔通常为 2500 至 3300 字节。
在不需要为每个迭代进行预取的情形中通常可使用旋转寄存器方案。英特尔编译器通常使用一个利用通用寄存器的阵列指针旋转堆栈。这样,各迭代中,堆栈顶端地址被预取作为 lfetch 指令的参数,按跨距更新,并放入栈底。在 daxpy 范例中,这会带来两个阵列,迭代轮流对它们进行预取。
值得注意的是,在更新预取地址和载入地址到同一变量之间有某种关联。这种关联常可用于将数据载入和其预取指令关联起来,从而用来确定编译器就各种数据元素所生成的预取间隔。软件开发人员不会发现任何问题,在此仅作为在展开管线循环体中一种有用的载入与预取关联技术而进行讨论。下一节将描述功能更加强大的技术。
编译器内部符号变量名称
我们常常希望能够查看编译器中更多细节。在打开的、软件管线化代码区中反跟踪寄存器赋值到其来源,以及理清多个内存地址间的关系是非常困难的。通过使用一项未正式收录和不受支持的编译器选项,这一操作可大大简化。该标志支持8.0.39和早期的英特尔® 编译器版本,但是并不包括在支持的编译器选项中,所以无法保证未来仍可用。然而,它的有效性是业经验证的。当调用 –Fa 或 –S 选项时,编译器将会生成一个汇编器列表。如果同样调用了 –use_asm 选项,则编译会从汇编器处理得到的汇编列表中明确生成对象文件(ias)。这将保证寄存器选择在英特尔® VTune™ 可视化性能分析器或调试器的反汇编列表中可见,在生成的汇编列表中的寄存器选择同样也能看到。这样开发人员便可在文本编辑器中查看汇编列表,同时使用英特尔 VTune 可视化性能分析器或调试器的分析功能。
在编辑器中查看汇编列表几乎未对反汇编窗口中显示的内容作任何的添加。然而,源代码行数要比通过图形用户界面反汇编视图显示的内容精确得多,特别是在使用旧版面向 Windows* 操作系统的英特尔编译器时更是如此。源代码行数在汇编和反汇编视图中显示,位于“//”的右侧,前面为“:”。
下例摘自一个通过英特尔编译器运行的双精度 fortran daxpy,汇编器的程序行与源代码行数 5 相关联。软件开发人员也许希望知道正在载入的是哪个变量,即在“r56”中包含的是哪个变量的地址。这可能是要查看相关的预取间隔是否正确,或许因为数据监听事件分析表明这是一个强制执行载入(execution constraining load)。 当编译器选项“-mGLOB_rosetta=1”同 –S (-Fa) 和 –use_asm 一同调用时,编译器会生成一个带有注释的汇编列表。注释包括由编译器内部使用的内部符号名称。这允许软件开发人员查看在代码生成和优化阶段,编译器使用的一系列独特的、由计算机生成的符号名称。其优点在于能够借助符号名称编辑器进行一系列的向上“寻找”,快速确定与高级语言符号关联的基数(base)。请看下面摘自 SPEC2000 benchmark 之 FMA3D 组件的范例。
首先假定,我们正在处理载入解除引用(dereferencing)r41
推测(Speculation)和编译器检测歧义(Ambiguity)
阵列、指针和存储指令
等式左边的阵列或指针符号常常迫使编译器将数据存储至内存,甚至在数据能够留在寄存器的情况下也是如此。这样做的原因是存取所需的索引变量与阵列和指针相关,以及编译器难以识别相同的索引值。在载入与存储以及存储与预取之间会发生数据访问冲突。通常,通过使用本地变量来帮助编译器避免不必要的存储,即能够完全避免这些冲突。一个不错的建议就是查看 cpu 周期密集型代码序列中生成的存储,判断是否能够将其避免。这些额外的存储通常与指针歧义问题有关。下一节我们将讨论一些用以消除地址歧义的策略。解决地址歧义问题
利用前面所述的各项技术,开发人员能够相当轻松地确定由于可知数据寻址歧义或冲突而对优化造成限制的循环体。之后,需设法明确消除指针歧义,就此有多种指针消除歧义标记可供编译器使用,如 –no-alias、ansi_alias、Oa 等等。更具局部性的方式是为指针声明添加“restrict”属性(为编译器选项添加限制标记),或添加“ivdep”类型的编译指示(pragma)。作为替代方案,计算的显性“registerization”也许是最好选择。此处需要在循环体中所有等式的左端有一个局部变量,并在循环体结束后,将它们复制到阵列指针/解除引用指针。见下面摘自 solver 代码的一个范例:其功能是将指针传递给矩阵 G 和向量 Y,BLK 定义为 4。Yi 和 Yk 的消除歧义遇到了问题,结果内部循环体没有展开,使用了推测性指令。此外,如果内部循环体能够从索引 “k”开始则最好,因为它具有合理的循环次数。
解决方案为创建 4 个局部变量 Y0、Y1、Y2 和 Y3,并在外部循环体的每个迭代上,将它们初始化为 Yi[L]。通过使用临时变量,sums(总计)在寄存器中累加,不用编译器来消除指针歧义。此外,将变量引用排列在等式的左端,消除了额外的存储。 上文实例摘自一个真实代码,4个循环体中显性打开和临时变量的使用,将整体性能提升超过200%。编译问题能够通过查看编码分支的类型(br.cloop,面向初始内部循环体)和推测性指令调度而全部识别。英特尔® Vtune™ 可视化性能分析器可识别哪些代码序列实际消耗着所有周期,以及大部分与初始有保留调度序列相关的停顿周期。整个练习在几天之内完成。
结论
汇编程序目测检查的基本技巧与性能分析相结合,揭示了程序执行低效的根本原因。解决办法快速而有效,对代码开发人员只有简单的模式识别要求。如欲了解英特尔® 安腾® 处理器性能优化的更多信息,请参见该系列文章的其它部分:
相关链接
- 卓越的英特尔® 安腾® 处理器性能:基础(第 1 部分,共 5 部分)
- 卓越的英特尔® 安腾® 处理器性能:性能监控能力(第 2 部分,共 5 部分)
- 卓越的英特尔® 安腾® 处理器性能:数据模块化和多级高速缓存的使用(第 3 部分,共 5 部分)
- 卓越的英特尔® 安腾® 处理器性能:约束型事件收集的使用(第 4 部分,共 5 部分)
- 英特尔® 安腾® 处理器的微架构软件优化介绍
(PDF 466KB) - 英特尔® 安腾® 处理器软件规范

- 英特尔® 安腾® 处理器软件开发和优化参考手册

作者简介
David Levinthal,是英特尔® 安腾® 处理器首席软件性能支持工程师,他的工作内容主要是致力于英特尔® VTune™ 可视化性能分析器项目。他于 1995 年加盟英特尔公司,最初任职于超级计算机系统事业部,而后为微处理器软件实验室效力。1997年起,他便是英特尔® 安腾® 处理器家族的首席软件支持工程师。在加盟英特尔公司前,他曾任教于佛罗里达州立大学、担任物理学教授。曾获奖项包括:美国能源部开放 Java 虚拟机*集成奖(DOE OJI (Open JVM* Integration) award )、美国国家科学基金会总统奖(NSF PYI award)以及斯隆基金会助学金(Sloan foundation fellowship)。
