Intel 编译器内幕
Linux 在开发者和研究人员中日益普及,但可用的开发工具数量的增长却未能与之相提并论。最近发布的 Intel C++ 和 Fortran Linux 编译器旨在弥合这一差距,为应用程序开发者提供针对 Intel IA-32 和 Itanium 处理器系列的高度可优化编译器。这些编译器提供严格的 ANSI 支持,以及对一些流行的扩展的可选支持。本文重点介绍针对 Intel IA-32 处理器的编译器的优化和功能。在本文的其余部分,我们将 Intel C++ 和 Fortran Linux IA-32 编译器统称为“Intel 编译器”。
Intel 编译器在所有级别优化程序,从高级循环和过程间优化到标准编译器数据流优化,以及高效的低级优化,例如指令调度、基本块布局和寄存器分配。在本文中,我们主要关注 Intel 编译器特有的编译器优化。然而,为了完整起见,我们还简要概述了 Intel 编译器支持的一些更传统的优化。
减少动态执行的指令数量,并用更快的等效指令替换指令,可能是提高性能的两种最明显的方法。许多传统的编译器优化都属于这一类:复制和常量传播、冗余表达式消除、死代码消除、窥孔优化、函数内联、尾递归消除等等。
Intel 编译器提供了多种类型的优化。许多局部优化都基于静态单赋值 (SSA) 形式。例如,冗余(或部分冗余)表达式根据 Chow 算法(参见资源 6)消除,其中如果表达式在执行路径上不必要地计算多次,则认为该表达式是冗余的。例如,在语句中
x[i] += a[i+j*n] + b[i+j*n];
表达式 i+j*n 是冗余的,只需要计算一次。当表达式在某些路径上是冗余的,但在并非所有路径上都是冗余的时,就会发生部分冗余。在代码中
if (c) { x = y+a*b; } else { x = a; } z = a*b;表达式 a*b 是部分冗余的。如果采用 else 分支,则 a*b 只计算一次;但如果采用 then 分支,则计算两次。代码可以修改如下
t = a*b; if (c) { x = y+t; } else { x = a; } z = t;因此,无论采用哪条路径,a*b 都只计算一次。
显然,这种转换必须谨慎使用,因为临时值的增加(理想情况下存储在寄存器中)会增加生命周期,从而增加寄存器压力。一种类似于 Chow 算法的算法(参见资源 9)用于消除死存储,其中一个存储之后是另一个存储到同一位置的存储,然后在获取之前,以及部分死存储,这些存储在某些路径上是死的,但在并非所有路径上都是死的。基于 SSA 形式的其他优化是常量传播(参见资源 7)和条件传播。考虑以下示例
if (x>0) { if (y>0) { . . . if (x == 0) { . . . } } }
由于 x>0 在最外层的 if 内部成立,除非 x 被更改,否则我们知道 x != 0,因此内部 if 中的代码是死的。尽管这个和之前的示例可能看起来是人为设计的,但在存在地址计算、宏或内联函数的情况下,这种情况实际上非常常见。
Intel 编译器使用强大的内存消歧(参见资源 8)来确定内存引用是否可能重叠。此分析对于增强寄存器分配以及启用代码中隐式并行性的检测和利用非常重要,如下节所述。Intel 编译器还提供广泛的过程间优化,包括手动和自动函数内联、仅内联例程的热点部分的部分内联、过程间常量优化和异常处理优化。通过可选的“整程序”分析,可以修改某些数据结构(例如 Fortran 中的 COMMON BLOCKS)的数据布局,以增强各种处理器上的内存访问。例如,可以填充数据布局以提供更好的数据对齐。此外,为了更明智地决定何时何地进行内联,Intel 编译器依赖于两种类型的性能分析信息:静态性能分析和动态性能分析。静态性能分析是指可以在编译时推导或估计的信息。动态性能分析是从程序的实际执行中收集的信息。这两种类型的性能分析将在下一节中讨论。
首先,我们将看看静态性能分析。考虑以下代码片段
g(); for (i=0; i<10; i++) { g(); }
显然,循环内部的调用比循环外部的调用执行频率高十倍。然而,在许多情况下,无法做出好的估计。在以下代码中
for (i=0; i<10; i++) { if (condition) { g(); } else { h(); } }很难说哪个条件更可能发生。如果 h() 碰巧是一个退出或其他已知不会返回的例程,则可以安全地假设 then 分支更可能被采用,并且内联 g() 可能是值得的。然而,如果没有此类信息,决定内联一个调用还是另一个调用(或两者都内联)就会变得更加复杂。另一种选择是使用动态性能分析。
动态性能分析从程序的实际执行中收集信息。这允许编译器利用程序实际运行的方式来优化它。在三步过程中,首先构建带有嵌入式性能分析工具的应用程序。然后,使用具有代表性的数据样本(或多个样本)运行生成的应用程序,这将生成一个数据库,供编译器在后续构建应用程序时使用。最后,此数据库中的信息用于指导优化,例如代码放置或将频繁执行的基本块分组在一起、函数或部分内联以及寄存器分配。Intel 编译器中的寄存器分配基于图融合(参见资源 5),它将代码分解为区域。这些区域通常是循环体或其他有凝聚力的单元。通过性能分析信息,可以更有效地选择区域,并且这些区域基于块的实际频率,而不是句法猜测。这允许将溢出推送到程序中执行频率较低的部分。
在现代架构中,利用并行性是提高应用程序性能的重要方法。Intel 编译器可以通过促进自动向量化、自动并行化和对 OpenMP 指令的支持等优化,在利用程序中潜在并行性方面发挥关键作用。让我们看看将串行循环自动转换为利用 Intel MMX 技术或 SSE/SSE2(流式 SIMD 扩展)提供的指令的形式的过程,我们将此过程称为“寄存器内向量化”(参见资源 1)。例如,给定函数
void vecadd(float a[], float b[], float c[], int n) { int i; for (i = 0; i < n; i++) { c[i] = a[i] + b[i]; } }
Intel 编译器将转换循环,以允许使用 addps 指令同时执行四个单精度浮点加法。简而言之,使用伪向量表示法,结果将如下所示
for (i = 0; i < n; i+=4) { c[i:i+3] = a[i:i+3] + b[i:i+3]; }如果循环次数 n 不能被 4 整除,则会跟随一个标量清理循环来执行剩余的指令。此过程涉及几个步骤。首先,由于可能不存在关于数组基地址的信息,因此必须插入运行时代码以确保数组不重叠(动态依赖性测试),并且循环的大部分以每个向量迭代沿 16 字节边界对齐的地址运行(用于对齐的动态循环剥离)。为了有效地向量化,只向量化足够大小的循环。如果迭代次数太少,则使用简单的串行循环代替。除了简单循环外,向量化器还支持带有归约的循环(例如,对数字数组求和或在数组中搜索最大值或最小值)、条件构造、饱和算术和其他习语。甚至矢量化带有三角数学函数的循环也通过向量数学库支持。
为了让您了解寄存器内向量化可以获得的实际性能提升,我们报告了 Linpack 基准测试的双精度版本的性能数据(Fortran 和 C 版本均可在 www.netlib.org/benchmark 获得)。此基准测试报告了线性方程求解器的性能,该求解器使用例程 DGEFA 和 DGESL 分别用于因式分解和求解阶段。此基准测试的大部分运行时来自在因式分解期间重复调用 Level 1 BLAS 例程 DAXPY 以处理系数矩阵的不同子列。在通用优化(开关 -O2)下,此基准测试报告在 2.66GHz Pentium 4 处理器上求解 100×100 系统时的性能为 1,049 MFLOPS。当启用 Pentium 4 处理器的寄存器内向量化(开关 -xW)时,性能会提高到 1,292 MFLOPS,性能提升约 20%。
C/C++ 和 Fortran 的 OpenMP 标准 (www.openmp.org) 最近已成为共享内存并行编程的事实标准。它允许用户指定并行性,而无需深入了解迭代分区、数据共享、线程调度和同步的细节。基于这些指令,Intel 编译器将转换代码以自动生成多线程代码。Intel 编译器支持 OpenMP C++ 2.0 和 OpenMP Fortran 2.0 标准指令,用于显式并行化。应用程序可以使用这些指令通过利用任务和数据并行性来提高多处理器系统上的性能。
以下是一个示例程序,说明了 OpenMP 指令与 Intel C++ Linux OpenMP 编译器的使用
#define N 10000 void ploop(void) { int k, x[N], y[N], z[N]; #pragma omp parallel for private(k) shared(x,y,z) for (k=0; k<N; k++) { x[k] = x[k] * y[k] + workunit(z[k]); } }
for 循环将由一组线程并行执行,这些线程在循环体中划分迭代。变量 k 被标记为私有——每个线程都将拥有自己的 k 副本——而数组 x、y 和 z 在线程之间共享。
生成的多线程代码如下所示。Intel 编译器为线程创建和管理以及同步生成 OpenMP 运行时库调用(参见资源 1 和 2)
#define N 10000 void ploop(void) { int k, x[N], y[N], z[N]; __kmpc_fork_call(loc, 3, T-entry(_ploop_par_loop), x, y, z) goto L1: T-entry _ploop_par_loop(loc, tid, x[], y[], z[]) { lower_k = 0; upper_k = N; __kmpc_for_static_init(loc, tid, STATIC, &lower_k, &upper_k, ...); for (local_k=lower_k; local_k<=upper_k; local_k++) { x[local_k] = x[local_k] * y[local_k] + workunit(z[local_k]); } __kmpc_for_static_fini(loc, tid); T-return; } L1: return; }
多线程代码生成器为每个循环插入线程调用 __kmpc_fork_call,其中包含 T 入口点和数据环境(例如,线程 id tid)。对 Intel OpenMP 运行时库的此调用会派生多个线程,这些线程并行执行循环的迭代。
用 OpenMP 指令注释的串行循环通过本地化循环下限和上限以及私有化迭代变量转换为多线程代码。最后,为由 [T-entry, T-ret] 对定义的每个 T 区域生成多线程运行时初始化和同步代码。调用 __kmpc_for_static_init 根据调度策略计算每个线程的本地化循环下限、上限和步幅。在此示例中,生成的代码使用静态调度。库调用 __kmpc_for_static_fini 通知运行时系统当前线程已完成一个循环块。
与 OpenMP NanosCompiler 和 OdinMP 等其他编译器中完成的源到源转换不同,Intel 编译器在内部执行这些转换。这允许 OpenMP 实现与其他高级、高级编译器优化紧密集成,从而提高单处理器性能,例如向量化和循环转换。
除了编译器支持利用 OpenMP 指令引导的显式并行性外,用户还可以尝试使用选项 -parallel 进行自动并行化。在此选项下,编译器自动分析程序中的循环,以检测那些没有循环携带依赖性并且可以并行高效执行的循环。编译器中的自动并行化阶段依赖于高级内存消歧技术进行分析,以及性能分析信息进行启发式决策,以决定何时并行化。
Intel 编译器的独特功能之一是 CPU 调度,它允许用户通过手动 CPU 调度或自动 CPU 调度为多个 IA-32 架构定位单个对象。手动 CPU 调度允许用户编写单个函数的多个版本。每个函数要么被分配一个特定的 IA-32 架构平台,要么被认为是通用的,这意味着它可以在任何 IA-32 架构上运行。Intel 编译器生成代码,该代码动态确定代码在哪个架构上运行,并相应地选择将实际执行的函数的特定版本。这种运行时确定允许程序员利用特定于架构的优化,例如 SSE 和 SSE2,而不会牺牲灵活性,从而允许在不支持较新指令的架构上执行相同的二进制文件。
自动 CPU 调度与之类似,但具有编译器自动生成给定函数的多个版本的额外好处。在编译期间,编译器决定哪些例程将从特定于架构的优化中获益。然后自动复制这些例程以生成特定于架构的优化版本以及通用版本。此功能的优点是,它不需要程序员进行任何重写。普通源文件可以通过简单地使用命令行选项来利用自动 CPU 调度功能。例如,给定函数
void init(float b[], double c[], int n) { int i; for (i = 0; i < n; i++) { b[i] = (float)i; } for (i = 0; i < n; i++) { c[i] = (double)i; } }
Intel 编译器最多可以生成该函数的三个版本。生成一个通用版本的函数,该函数将在任何 IA-32 处理器上运行。另一个版本将针对 Pentium III 处理器进行调整,通过使用 SSE 指令向量化第一个循环。第三个版本将针对 Pentium 4 处理器进行优化,通过向量化两个循环以利用 SSE2 指令。
生成的函数以如下调度代码开始
.L1 testl $-512, __intel_cpu_indicator jne init.J testl $-128, __intel_cpu_indicator jne init.H testl $-1, __intel_cpu_indicator jne init.A call __intel_cpu_indicator_init jmp .L1
其中 init.A、init.H 和 init.J 分别是通用版本、SSE 优化版本和 SSE2 优化版本。
虽然 Intel 编译器严格遵守 ANSI 标准,但也有一些选项可以涵盖许多 GCC 扩展,例如 long long int、零长度数组或带有可变数量参数的宏。还支持 GCC 风格的内联汇编代码。提供 DWARF2 调试信息以与标准调试器(如 GDB)一起使用。还启用了某些 Microsoft 扩展,例如 __declspec 属性,以及对 Microsoft 风格的内联汇编代码的支持。
除了内联汇编代码外,Intel 编译器还支持 MMX 和 SSE/SSE2 内联函数。这些允许访问处理器特定的扩展,而不会出现通常由使用内联汇编引起的性能和正确性问题,这些问题可能会干扰 Intel 编译器的分析和转换。通过使用提供的内联函数,程序员可以利用特定的指令,但仍然可以获得寄存器分配、调度和其他优化的好处。
适用于 Linux 的 Intel 编译器是一款最先进的编译器,它使用复杂的技术来实现 Intel IA-32 架构的高级功能,从而提供业界最佳的性能。更多信息请访问 developer.intel.com/software/products/compilers。
感谢 Zia Ansari 和 David Kreitzer 在描述编译器的某些技术细节方面提供的帮助。我们还要感谢 Intel 编译器团队的所有其他成员。
Intel、Pentium、Itanium 和 MMX 是 Intel Corporation 或其在美国和其他国家/地区子公司的商标或注册商标。



