第五章 优化程序性能
5.1 优化编译器的能力和局限性
什么是内联替换?
- 定义: 内联替换是编译器的一种优化技术,用于减少函数调用的开销。当你将一个函数标记为inline,编译器尝试将该函数的代码直接嵌入到每个调用点,而不是进行常规的函数调用。
- 如何工作: 在正常的函数调用中,程序在调用函数时需要跳转到函数的代码块,执行完毕后再跳回。这个过程涉及到一些开销,如保存调用点的信息、传递参数等。内联替换通过将函数的代码直接放置在调用点来避免这些开销。
它的作用是什么?
- 减少开销: 内联可以减少函数调用的开销,特别是对于小型、频繁调用的函数。
- 提高性能: 通过减少跳转和参数传递,内联可以提升程序的运行速度。
- 代码膨胀: 内联可能会导致代码大小的增加,因为相同的函数代码会被复制到每个调用点。因此,编译器通常会评估是否进行内联,以平衡性能和代码大小。
5.2 表示程序性能
**“每元素的周期数”(CPE, Cycles Per Element)**是一个衡量计算性能的指标,用于评估在处理数据元素(如数组中的每个数)时,计算机花费了多少个处理器周期。
通俗解释
想象你在工厂流水线上工作,每个产品(元素)都需要通过你进行一些处理。CPE就好比是你处理每个产品所花费的时间。如果你每处理一个产品需要5秒,那么你的“每产品的时间”是5秒。同理,在计算机中,如果处理每个数据元素需要5个处理器周期,那么CPE就是5。
为什么重要
- 性能评估: CPE是理解程序性能的一个关键指标。通过测量处理每个元素所需的周期数,可以判断程序的效率。
- 优化参考: 通过分析CPE,开发者可以识别性能瓶颈,并进行针对性的优化。比如,如果CPE很高,可能意味着需要优化算法或者改善数据访问方式。
影响CPE的因素
- 处理器架构: 不同的处理器有不同的性能特征,这直接影响CPE。
- 内存访问: 数据访问模式(如缓存命中和缺失)会显著影响CPE。缓存命中率高,CPE通常较低。
- 编译器优化: 编译器的优化设置会改变代码的执行方式,从而影响CPE。
- 并行化: 利用并行处理技术(如多线程或向量化)可以降低CPE,因为可以同时处理多个数据元素。
实际应用
在性能调优和算法分析中,CPE是一个很实用的指标。比如,在对科学计算、大数据处理等任务进行优化时,了解并尽量降低CPE是提高整体性能的关键。
总之,CPE提供了一个量化的方法来衡量和理解程序在处理每个数据元素时的效率,它是性能分析和优化的一个重要工具。
最小二乘拟合是一种数学方法,用于找到一组数据点的最佳拟合线或曲线。这种方法广泛应用于统计学、数据分析和工程领域,用于从实验或观测数据中提取趋势或模式。
通俗解释
想象你在一张纸上随机画了很多点,这些点代表你收集的数据(比如一周内每天的温度和时间)。现在,你想找到一条直线,尽可能接近这些点,以此来总结温度随时间变化的大致趋势。最小二乘拟合就是帮助你找到这样一条直线的方法。
最小二乘拟合是一种数学方法,用于找到一组数据点的最佳拟合线或曲线。这种方法广泛应用于统计学、数据分析和工程领域,用于从实验或观测数据中提取趋势或模式。
通俗解释
想象你在一张纸上随机画了很多点,这些点代表你收集的数据(比如一周内每天的温度和时间)。现在,你想找到一条直线,尽可能接近这些点,以此来总结温度随时间变化的大致趋势。最小二乘拟合就是帮助你找到这样一条直线的方法。
最小二乘拟合的工作原理
目标: 最小化所有数据点到拟合线(或曲线)的距离的平方和。这些距离称为“残差”。
过程: 算法通过调整拟合线的斜率和截距,尽量减少所有点到这条线的垂直距离的总和。
它的作用
- 数据趋势分析: 最小二乘拟合帮助我们理解数据的总体趋势。例如,它可以显示随时间变化的温度或股票价格的趋势。
- 预测: 一旦我们有了拟合线,就可以使用它来预测未来的值。比如,根据过去的销售数据预测未来的销售趋势。
- 误差估计: 它还提供了一种估计模型与实际数据匹配程度的方法,通过查看残差,我们可以了解模型的准确性。
实际应用
最小二乘拟合在科学和工程领域非常有用。比如,它被用于从实验数据中提取物理定律,或在经济学中分析消费者行为。它也是机器学习和数据科学中重要的工具之一,用于建立和验证模型。
总的来说,最小二乘拟合是一种强大的工具,可以帮助我们从杂乱的数据中提取有用的信息,理解数据背后的趋势和模式,并进行有效的预测。
5.4 消除循环的低效率
未经优化的代码是从C语言代码到机器代码的直接翻译,通常效率明显较低。推荐养成至少用“-O1”级别的优化,可以显著提高程序性能。
在循环for的判断中,禁止在里面调用返回结果一样的函数,因为这样会显著增加性能开支,造成性能瓶颈。
5.6 消除不必要的内存引用
通过使用局部变量来减少对内存的频繁访问。
5.7 理解现代处理器
当代码中的数据相关限制了处理器利用指令集并行的能力时,延迟界限(latency bound)能够限制程序性能。
5.7.1 整体操作
流水线并行执行(Pipelining)和乱序并行执行(Out-of-Order Execution)执行的区别:
- 执行顺序:
- 流水线并行:遵循指令的原始顺序。
- 乱序并行:允许改变指令的执行顺序。
- 性能优化:
- 流水线并行:通过并行化指令的不同阶段来提高性能。
- 乱序并行:通过智能地调整指令执行顺序来减少等待和闲置时间。
- 复杂性:
- 流水线并行:结构相对简单。
- 乱序并行:结构更复杂,需要复杂的逻辑来跟踪和管理指令的依赖性和状态。
- 应用场景:
- 流水线并行:适用于指令顺序性强、依赖关系简单的场景。
- 乱序并行:更适用于指令之间依赖关系复杂或存在许多数据等待的场景。
超标量处理器(superscalar),它可以在一个时钟周期内同时执行多条指令,而且是乱序的(out-of-order),意思是执行的顺序不一定要与它们在机器级程序中的顺序一致。整体设计有两个主要部分:指令控制单元(Instruction Control Unit,ICU)和执行单元(Execution Unit,EU)。前者负责从内存中读取指令序列,并根据这些指令序列生成一组对程序数据的基本操作;而后者执行这些操作。
指令控制单元ICU和执行单元EU的关系可以看作是计划者和执行者的关系。ICU负责“计划”阶段,即理解和准备指令的执行。一旦指令被解码并准备好,它们就被送往EU进行“执行”阶段。这个过程涉及到紧密的协作和通信,因为ICU需要确保EU始终有指令可执行,而EU需要向ICU反馈执行状态,以便ICU可以做出适当的调整和优化。
**分支预测技术(branch prediction)**是现代计算机处理器用来提高效率的一种方法。要理解分支预测,我们可以用一个日常生活中的比喻来说明。
想象你每天早上出门上班,有两条路可以选择:一条是经常堵车的主路,另一条是较少人知道的小路。如果你每天都选择同一条路,你可能会根据过去的经验来判断哪条路更快。比如,如果你发现小路通常不堵车,你可能会倾向于选择小路。这就是一种“预测”。
在计算机处理器中,分支预测的原理也类似。处理器在执行程序时会遇到很多“分支”——这就像是你每天早上选择路线一样。这些分支通常是基于某些条件的决策点,比如“如果这个条件成立,就执行这部分代码,否则执行另一部分代码”。
处理器使用分支预测技术来“猜测”这些分支将如何执行。它会根据以前遇到的类似情况来预测这次分支会怎样。如果预测正确,那么处理器就可以提前准备好接下来要执行的指令,这样就不需要等待实际的分支决策了。就像你提前选择了小路一样,这样可以节省时间。
但是,如果预测错误,就像你选错了路一样,处理器就需要回到分支点,选择正确的路径,这会造成一些时间上的损失。
**投机执行(Speculative Execution)**是一种现代计算机处理器中用来提高性能的技术。要理解这个概念,我们可以用一个日常生活的比喻来帮助说明。
想象你正在准备晚餐,而你的食谱包含多个步骤。有些步骤需要等待之前的步骤完成(比如,等烤箱预热完成)。如果你按照食谱上的步骤顺序一步一步来,你可能会在等待的时候无所事事。但是,如果你预测接下来的步骤,并在等待的同时开始准备接下来的食材(比如切蔬菜),你可以节省时间。如果你的预测是正确的,当烤箱预热完成时,你已经准备好了下一步要用的食材。但如果你的预测是错误的(比如,你忘记了你需要先搅拌食材而不是切它们),你可能需要重新调整。
在计算机处理器中,投机执行的原理类似。处理器在执行程序时会遇到需要等待的情况,比如等待数据从内存加载。在这种情况下,处理器会尝试“预测”接下来的指令,并开始执行这些预测的指令,而不是空闲等待。
例如,当遇到一个分支指令(比如一个“如果”语句)时,处理器会尝试预测这个分支将会如何执行,并在等待实际结果的同时,提前执行接下来的指令。如果预测正确,这就节省了时间,因为处理器已经提前完成了一些工作。但如果预测错误,处理器就需要撤销它所做的工作,并转而执行正确的指令。
总的来说,投机执行是一种通过提前执行可能需要的指令来减少等待时间的技术,它可以显著提高处理器的效率。不过,这种技术也带来了一些安全隐患,比如著名的“幽灵”(Spectre)和“熔断”(Meltdown)漏洞,这些漏洞利用了投机执行的特点来攻击计算机系统。
在现代微处理器的设计中,指令的执行过程通常被分解为多个阶段,这些阶段包括取指令(fetching instructions)、指令译码(decoding instructions)、执行(executing)和退役(retiring)。在这个上下文中,**退役单元(retirement unit)**是处理器中负责管理指令生命周期末尾阶段的组件。
退役单元的主要功能包括:
- 指令结果的最终确认:一旦指令执行完成,退役单元负责确保执行的结果是正确的,并将结果提交到处理器的状态中。这意味着将执行结果写入寄存器或内存。
- 维护程序顺序的完整性:尽管现代处理器可能会并行或乱序执行多个指令,但退役单元需要确保从程序员的角度看,所有指令似乎是按顺序执行的。这涉及处理任何数据依赖和处理器状态的更新。
- 处理异常和中断:如果在执行指令的过程中发生了异常或中断,退役单元负责处理这些情况,并确保处理器能够正确响应。
- 清理和资源回收:在指令执行完毕后,退役单元还负责清理和释放在执行过程中使用的资源,比如清空指令队列中的条目,释放寄存器等。
总体而言,退役单元是保证指令正确、有效执行的重要部分,同时确保即使在复杂的执行环境(如乱序执行、并行处理等)下,处理器的行为仍符合程序的预期逻辑。
指令译码时,关于指令的信息被放置在一个先进先出的队列中。这个信息会一直保持在队列中,直到发生以下两个结果中的一个。首先,一旦一条指令的操作完成了,而且所有引起这条指令的分支点都被确认为预测正确,那么这条指令就可以退役(retired)了,所有对程序寄存器的更新都可以被实机执行了。如果引起该指令的某个分支点预测错误,这条指令会被清空(flushed)丢弃所有计算出来的结果。任何对程序寄存器的更新都只会在指令退役时才发生。
5.7.2 功能单元的性能
从图中可以看到,浮点运算的延迟比整数运算更高,发射时间为1说明该功能单元是完全流水线化的,即每个时钟周期可以开始一个新的运算。
在计算机处理器的设计中,**功能单元(Functional Unit)**是执行特定操作的硬件部分。每个功能单元专门用于处理特定类型的操作,如算术运算、逻辑运算、数据访问等。这些功能单元是现代微处理器中实现高效指令执行的关键组件。以下是一些常见的功能单元类型及其作用:
- 算术逻辑单元(ALU):这是最常见的功能单元之一,用于执行基本的算术运算(如加、减、乘、除)和逻辑运算(如与、或、非)。几乎所有的处理器都至少包含一个ALU。
- 浮点单元(FPU):专门用于执行浮点运算,即涉及小数的运算。这种类型的运算在科学计算和图形处理中非常重要。
- 载入/存储单元:这些单元负责从内存中载入数据到处理器中,或者将数据从处理器存储到内存中。
- 分支预测单元:用于优化程序中的条件分支指令的处理。它通过预测程序执行的路径来减少等待分支决策的时间。
- 乱序执行单元:在支持乱序执行的处理器中,这些单元负责执行指令的调度和缓冲,以允许指令以非程序顺序执行。
- 向量处理单元:在一些处理器中,特别是用于图形处理和科学计算的处理器,向量处理单元可以同时处理一组数据,这通常称为SIMD(单指令多数据)操作。
功能单元的设计和数量直接影响处理器的性能和效率。在现代多核和超标量处理器中,通常会有多个这样的单元,允许处理器同时执行多个操作,从而提高了处理器的并行处理能力。在处理器设计中,功能单元的配置和优化是实现高性能计算的关键。
5.8 循环展开
**循环展开(Loop Unrolling)**是一种编程优化技术,用于减少程序运行时循环的迭代次数,从而提高效率。这种技术通过减少循环中的控制指令(如跳转和比较)的执行频率,以及通过增加每次迭代中执行的操作数量来实现性能提升。
在没有展开的循环中,每次迭代通常只完成一小部分工作。循环展开通过在每次迭代中完成更多的工作来减少循环的总迭代次数。这可以通过几种方式实现,例如:
- 手动循环展开:程序员直接在代码中重写循环,以在每次迭代中执行更多的操作。例如,一个简单的循环,每次迭代加1,可以被展开为每次迭代加2、加4等。
- 自动循环展开:一些现代编译器能够自动识别循环展开的机会,并在编译时对循环进行优化。
循环展开的优点
- 减少循环开销:通过减少迭代次数,循环展开减少了每次迭代所需的控制指令的执行(如循环的比较和跳转指令)。
- 提高流水线效率:在每次迭代中执行更多的工作可以更好地利用处理器的指令流水线,尤其是在处理器可以同时执行多个独立操作的情况下。
- 增加指令级并行:循环展开有时可以提高指令级并行性,允许现代处理器同时执行更多独立的操作。
循环展开的缺点
- 增加代码大小:展开循环会增加程序的代码大小,这可能会导致更大的缓存占用和潜在的缓存不命中率增加。
- 可能影响缓存利用:更大的代码量可能不适合缓存,从而影响整体性能。
- 并非总是有效:在某些情况下,如循环体本身非常大或者循环迭代次数很少时,循环展开可能不会带来性能提升。
总之,循环展开是一种在许多情况下都能有效提升性能的技术,但它需要谨慎使用,特别是在处理器的缓存和流水线行为多样化的现代计算环境中。
由于acc值必须等待前面的计算完成后才能计算新值,因此它只会每 L (合并操作的延迟)个周期开始一条心操作。
让编译器展开循环
编译器可以很容易地执行循环展开。只要优化级别设置得足够高,许多编译器都能做到这一点。
5.9 提高并行性
5.9.1 多个累积变量
只有保持能够执行该操作的所有功能单元的流水线都是满的,程序才能达到这个操作的吞吐量界限。
5.9.2 重新结合变换
5.11 一些限制因素
5.11.1 寄存器溢出
如果并行度p超过了可用的寄存器数量,那么编译器会诉诸溢出(spilling)。
一旦循环变量的数量超过了可用寄存器的数量,程序就必须在栈上分配一些变量。
分支预测和预测错误处罚
当分支预测错误时,处理器会遇到几个问题:
- 指令流中断:处理器需要丢弃错误预测路径上已加载的指令。
- 流水线清空:处理器流水线中的指令需要被清空,重新从正确的分支加载指令。
- 时间损耗:重新加载正确指令路径导致的延迟。
这些处罚会降低处理器的执行效率,特别是在深流水线和高性能处理器中更为显著。
5.12 理解内存性能
现代处理器有专门的功能单元来执行加载和存储操作,这些单元有内部的缓冲区来保存未完成的内存操作请求集合。
5.12.1 加载的性能
加载操作不会成为限制性能的关键路径的一部分。
存储缓冲区的作用使得一系列存储操作不必等待每个操作都更新高速缓存就能够执行。当执行一个加载操作时,首先检查存储缓冲区中的条目,如果有地址相匹配(写的字节与读的字节有相同的地址),它就取出相应的数据条目作为加载操作的结果。
标号1的弧线表示存储地址必须在数据被存储之前计算出来。标号2的弧线表示需要load操作将它的地址与所有未完成的存储操作的地址进行比较。标号3的虚弧线表现条件数据相关,当加载和存储地址相同时会出现。
上图可以看出,当操作的地址相同时,s_data和load指令之间的数据相关使得运行周期更长。
内存操作的实现包括许多细微之处。对于寄存器操作,在指令被译码成操作的时候,处理器就可以确定哪些指令会影响其他那哪些指令。对于内存操作,只有到加载和内存的地址被计算出来以后,处理器才能确定哪些指令会影响其他的哪些。
5.13 应用:性能提高技术
策略:
- 高级设计。为遇到的问题选择适当的算法和数据结构。
- 基本编码原则。避免限制优化因素,这样编译器就能产生高效的代码。
- 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效率。
- 消除不必要的内存引用。引用临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中。
- 低级优化。结构化代码以利用硬件功能。
- 展开循环,降低开销。
- 通过使用例如多个累积变量和重新结合等技术,找到方法提高指令级并行。
- 用功能性的风格重写条件操作,使得编译采用条件数据传送。
5.14 确认和消除性能瓶颈
- 基本编码原则。避免限制优化因素,这样编译器就能产生高效的代码。
- 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效率。
- 消除不必要的内存引用。引用临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中。
- 低级优化。结构化代码以利用硬件功能。
- 展开循环,降低开销。
- 通过使用例如多个累积变量和重新结合等技术,找到方法提高指令级并行。
- 用功能性的风格重写条件操作,使得编译采用条件数据传送。
5.14 确认和消除性能瓶颈
使用 GPROF 程序来对程序进行性能剖析。