CSAPP:第5章 优化程序性能

CSAPP:第5章 优化程序性能

5.1 优化编译器的能力和局限性
  • GCC提供不同等级的优化(-Og,-O1,-O2,-O3),其中-O2是被接受的标准
  • 我们可能会发现,在某些特定的情况下,-O2优化等级的到的程序性能可能比最高优化等级的到的要好
  • 对于下面一个例子:
    • image-20210131131909450
    • 对于twiddle1我们不难分析共有6次内存操作(每行都是xp读+写,yp读),而对于twiddle2则是三次(每行都是xp读+写,yp读),所以我们理所应当地认为2比1更加高效,问题是编译器是否可以将1优化成2?
    • 显而易见:不行。
      • 由于编译器不知道twiddle1是如何调用的,因此,为了安全起见必须考虑内存别名使用的情况(memory aliasing),即xp,yp指向同一个地址,这就会出现计算结果的不同,如下所示
        • image-20210131132324018
        • 由于第4行的xp读取的是更新之后的xp故是原来的两倍,总结果是4倍,则与twiddle2结果产生歧义
5.2 表示程序性能
  • 度量标准:CPE(Cycles Per Element,没元素的周期数)
  • 最小二乘拟合:image-20210131141139510
5.3 程序示例
  • 略,主要给出一段程序的优化前后CPE数据对比(-Og,-O1)
5.4 消除循环的低效率
  • 代码移动:对比下面两图中vec_length(v)的位置
    • image-20210131141816222
    • image-20210131141836991
    • 就复杂度而言,若不做优化每次计算vec_length(v)都需要O(n),若放在for循环中那就是O(n2),因此一个常用的优化方法是把它提出来,效率对比如下(CPE)
      • image-20210131142132051
  • 其实(据传,弹幕上说的)上面程序的优化GCC可以完成,只要不改变v的值(任何一个都不改),若改了则GCC就会每次都求,优化不了(就如本节后面lower函数的优化例子,大写转小写,优化就需要用到代码移动的技巧)。
5.5 减少过程调用
  • 证明了过程调用有开销,少用就是了。
5.6 消除不必要的内存引用
  • 比较combine3(5.5的图,相比combine2加了个过程调用)和combine4的区别
    • image-20210131143353760image-20210131143417429
    • image-20210131143417429
    • 由于dest是存在内存之中的,所以每次对dest进行操作都需要访问内存,这会降低程序的性能,故如combine4,通过引入临时变量acc,让写操作在cpu内部进行最后再写回内存就可以减少很多内存调用,下面给出两程序的CPE:
      • image-20210131143636176
5.7 理解现代处理器
5.7.1 整体操作
  • image-20210131154444819
  • ICU从Cache中取指,一般来说,当前执行的指令是很早之前就取好的(只有这样才有足够时间译码,并发送到EU)
  • ICU= 指令控制单元(Instruction Control Unit):
    • 取指控制:包括分支预测,完成确定取哪些指令的任务。
      • 遇到分支:使用分支预测(branch prediction),猜测一种结果直接运行(投机执行speculative execution),若错误则会将状态重新这只到分支点的状态,并执行另一个分支的指令。
    • 指令译码:就。。。翻译成微指令
    • 退役单元(retirement unit):记录正在进行的处理,并确保它遵守机器级程序的顺序语义。
      • 指令译码时,关于指令的信息被放置在一个先进先出的队列中。这个信息会一直保持在队列中,直到发生以下两个结果中的一个。
      • 首先,一旦一条指令的操作完成了,而且所有引起这条指令的分支点也都被确认为预测正确,那么这条指令就可以退役(retired)了,所有对程序寄存器的更新都可以被实际执行了。
      • 另一方面,如果引起该指令的某个分支点预测错误,这条指令会被清空(flushed), 丢弃所有计算出来的结果。通过这种方法,预测错误就不会改变程序的状态了。
  • EU = 执行单元(Execution Unit), 接收来自取指单元的操作。通常,每个时钟周期会接收多个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。这些功能单元专门用来处理不同类型的操作
    • 读写内存是由加载和存储单元实现
      • 加载:加载单元处理从内存读数据到处理器的操作。这个单元有一个加法器来完成地址计算。
      • 存储:存储单元处理从处理器写数据到内存的操作。它也有一个加法器来完成地址计算。
      • 加载和存储单元通过数据高速缓存(data cache)来访问内存。
    • 数据高速缓存是一个高速存储器,存放着最近访问的数据值。
    • 投机执行:对操作求值,但是最终结果不会存放在程序寄存器或数据内存中,直到处理器能确定应该实际执行这些指令。如果预测错误,EU 会丢弃分支点之后计算出来的结果。它还会发信号给分支单元,说预测是错误的,并指出正确的分支目的。在这种情况中,分支单元开始在新的位置取指。
    • 算数运算:元通常是专门用来执行整数和浮点数操作的不同组合。
5.7.2 功能单元的性能
  • 衡量单位:
    • 延迟(latency):表示完成原酸所需要的总时间。
    • 发射时间(issue time):表示连续的同类型的运算之间需要的最小时钟周期数。
    • 容量(capacity):表示能够执行该运算的功能单元的数量。
  • image-20210131203001424
  • 分析上图
    • 加法、乘法运算,浮点数的延迟>整数
    • 发射很短是通过流水线实现的(发射时间为1的单元被称为完全流水线化(fully pipelined):每个时钟周期可以开始一个新的运算)
      • 表达发射时间的一种更常见的方法是指明这个功能单元的最大吞吐量,定义为发射时间的倒数
    • 除法器:不是完全流水线化——发射时间=延迟==》开始一条新运算之前,触发器必须完成整个除法
    • ====〉乘法和加法是比较常用,除法不常用且难以实现低延迟的流水线化
5.7.3 处理器操作的抽象模型
  • (这段没怎么读懂,这里参考:知乎笔记

  • 这里介绍一种非正式的程序的**数据流(Data-flow)表示,可以展示不同操作之间的数据相关是如何限制操作的执行顺序,并且图中的关键路径(Cirtical Path)**给出了执行这些指令所需的时钟周期数的下界。

    • 从机器级代码到数据流图

      • 由于对于大向量而言,循环执行的计算是决定性能的主要因素,我们这里主要考虑循环的数据流图。首先可以得到循环对应的机器级代码

      • image-20210131212311450
      • 我们根据机器级代码可以获得寄存器在执行指令时进行的操作,然后可以得到以下对应的数据流图。

      • image-20210131212326298
      • 上方寄存器为输入的寄存器,下方寄存器为输出的寄存器,从寄存器指向操作的箭头表示该操作读取寄存器的值,从操作指向寄存器表示操作的结果保存在寄存器中,如果某些操作产生的值不对应于任何寄存器,就在操作间用弧线连接起来。

      • 其中,vmulsd (%rdx), %xmm0, %xmm0包含从内存中读取(%rdx)的值,然后计算浮点数乘法的基本操作。

      • 根据数据流图可以将寄存器分成以下几种:

        • **只读:**只包含寄存器指向操作的箭头,不包含从操作指向该寄存器的箭头。比如%rax
        • **只写:**只包含从操作指向该寄存器的箭头,不包含从寄存器指向操作的箭头。
        • **局部:**寄存器值在循环内部修改和使用,迭代与迭代之间不相关。比如条件码寄存器。
        • **循环:**寄存器既作为源又作为目的,并且一次迭代中产生的值会在另一次迭代中使用。比如%rdx%xmm0
      • **注意:**因为迭代之间是数据相关的,必须保证循环寄存器在上一轮迭代中计算完成,才能在下一轮迭代中使用该循环寄存器,所以循环寄存器之间的操作链决定了限制性能的数据相关。

      • 我们可以对数据流图进行修改,上方寄存器只有只读寄存器和循环寄存器,下方寄存器只有只写寄存器和循环寄存器,得到如下图所示的数据流图

      • image-20210131212626711
      • 所以同时出现在上方和下方的寄存器为循环寄存器。我们删除非循环寄存器以外的寄存器,并删除不在循环寄存器之间的操作,得到以下简化的数据流图

      • image-20210131212734834
      • 其中下方每个寄存器代表了一个数据相关:

        • %xmm0:当前迭代中%xmm0的计算,需要上一轮计算出来的%xmm0以及%rdx
        • %rdx:当前迭代中%rdx的计算,需要上一轮计算出来的%rdx

        我们可以将上图中的数据流重复n次,就得到了函数中循环n次的数据流图

      • image-20210131213008164
      • 可以发现里面有两个数据相关链,只有当上方的计算完成时才会计算下一个。并且由于相邻迭代的循环寄存器存在数据相关,所以只能顺序执行,所以要独立地考虑操作对应的延迟。由于参考机中浮点数乘法的延迟为5个时钟周期,而整数加法的延迟为1个时钟周期,所以左侧数据相关链是关键路径,限制了程序的性能。只要左侧操作的延迟大于1个时钟周期(比右侧的延迟大),则程序的CPE就是该操作的延迟。

      • **注意:**数据流中的关键路径只是提供程序需要周期数的下界,还有很多其他因素会限制性能。比如当我们将左侧的操作变为整数加法时,根据数据流预测的CPE应该为1,但是由于这里的操作变得很快,使得其他操作供应数据的速度不够快,造成实际得到的CPE为1.27。

5.8 循环展开
  • 是一种程序变换:通过增加每次迭代计算的元素数量,减少循环的迭代次数,它能从两方面优化程序的性能:

    • 减少不直接有助于程序结果的操作数量,如:循环索引计算和条件分支
    • 提供一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量
  • 下面给出combine的2*1展开版本

    • image-20210131220742905
    • 可以看到每次计算两个元素用以减少循环计算,(limit=length-1是为了避免data[i+1]越界)

    • 得到指令

      • image-20210131221039224
    • 数据流为:

      • image-20210131220659789
    • 整形(为了深入理解上一小节我手动画了一下)

      • E4F3D2D6E7D4C3BD820A9D97ECF98028
    • 连接后就是

      • D515F274076634EB572DC3859A6B570C
  • 可以看到,关键路径上的add操作明显减少了(大约一半)。因此可以得到这一行

    • image-20210131221458676
  • 拓展到K,可得性能折现

    •  771C18685EFF9D777A153817A99E2905
5.9 提高并行性
5.9.1 多个累积变量
  • 多个临时变量积累:对于一个可结合和可交换的合并运算来说,比如说整数加法或乘法,我们可以通过将
    一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。
  • 下面给出2x2展开:
    • image-20210131233647581
  • 参考上文的求法,可得
    • image-20210131234120835
    • image-20210131234140182
    • image-20210131234152405image-20210131234205135
    • image-20210131234205135
    • image-20210131234231977
    • 可以看到所有情况都得到了改进,整数乘、浮点加、浮点乘改进了约 2 倍,而整数加也有所改进。最棒的是,我们打破了由延迟界限设下的限制。处理器不再需要延迟一个加法或乘法操作以待前一个操作完成。
    • 同样,推广得折现趋势:
      • 8 2BF644120D6C9B1FB19195FF873C443
5.9.2 重新结合变换
  • 改变合并顺序,如下:
    • image-20210131234500717
  • 完整的combine7为:
    • image-20210131234522432
  • 这里省略数据流推理过程,直接给出结果
    • image-20210131235106737image-20210131235209358
    • 于是性能如下所示:整数加的性能几乎与使用kXI 展开的版本(combine5)的性能相同,而其他三种情况则与使用并行累积变量的版本(combine5)相同,是kX1 扩展的性能的两倍。这些情况已经突破了延迟界限造成的限制。
    •  image-20210131235209358
    • 同样,推广为K:
    • C6A43958737 FB5EAC909C4B576264B6C

5.10 优化合并代码的结果小结
  • 主要讨论对各种基本优化方式,循环展开后的性能分析,结论:循环展开🐂。
5.11 一些限制因素
5.11.1 寄存器溢出
  • x86-64有16个寄存器,并可以用16个YMM寄存器保存浮点数,一旦循环变量个数超过了可用寄存器数,程序就必须在栈上分配一些变量,这就是为什么20x20展开比10x10展开性能低的原因(栈在内存)
    • image-20210201004022360
5.11.2 分支预测和预测错误的处罚
  • 关于分支这点前文(或许是上几章)以及讲过,就不再赘述,主要关键词是:分支预测,投机执行
  • 不要过分关心可预测分支
    • 现代处理器中的分支预测逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势
    • 边界测试可能对性能有影响(如:插排算法(无哨兵)就需要边界检测,这就会浪费很多性能)
  • 书写适合用条件传送实现的代码
    • 观察下面两段代码:
      • image-20210201005301000
      • image-20210201005315628
      • 前者CPE 大约为 13.50,后者CPE 都大约为 4.0。
      • 就我个人理解,后者在某种程度上减小了分支预测错误的惩罚力度,即先赋值到局部变量中(在寄存器中),这样即使错误也只需要改寄存器的内容即可,且虽然看上去是比较两次ab的大小,实际上由于处理器的对分支预测的统计特性,max这句大概率会预测正确,所以最多错min这句,因此,惩罚力度就小了很多。
5.12 理解内存性能
5.12.1 加载的性能
  • 对于每个被计算的元素都有一个从内存被加载的过程
  • 测试加载性能的方法就是用一个只有加载的函数测试,书中给了一段找链表尾部的代码:
    • image-20210201150713082
.l3:
	addq $1, %rax						Increment length
	movq (%rdi),%rdi				ls = li->next
	testq %rdi, %rdi				Test ls
	jne .l3									If nonnull, goto loop
  • 这个函数的 CPE 等于 4.00, 是由加载操作的延迟决定的。事实上,这个测试结果与文档中参考机的 L1 级 cache 的 4 周期访问时间是一致的。
5.12.2 存储的性能
  • 高效地处理内存操作对许多程序的性能来说至关重要。内存子系统使用了很多优化,例如当操作可以独立地进行时,就利用这种潜在的并行性。

  • 书中给了个比较有意思的例子:

    • image-20210201152544313
    • 对于write_read函数,给了AB两组输入
      • 其中A的src,dst不同,所以从 src 读出的结果不受对 dest 的写的影响。在较大次数的迭代上测试这个示例得到 CPE 等于 1.3。
      • 而B的src和dst相同,这里存在一个现象——写/读相关(write/read dependency),一个内存读的结果依赖于一个最近的内存写。我们的性能测试表明示例 B 的 CPE 为 7.3。写/读相关导致处理速度下降约 6 个时钟周期。
    • 读写相关一方面是由于写和读寄存器的操作之间的数据相关:
      • image-20210201162341574
    • 还与某些操作的数据相关有关,下面给出数据流,观察s_data 与load之间的虚线
      • load必须啊等到s_data 将数据存到存储缓冲区中,加入两地址不同,则可独立进行(相同则不行)
      • image-20210201162619727
      • 下图有三个标号:
      • image-20210201162850716 - 标号为(1)的弧线表示存储地址必须在数据被存储之前计算出来。 - 标号为(2)的弧线表示需要 load 操作将它的地址与所有未完成的存储操作的地址进行比较。 - 标号为(3)的虚弧线表示条件数据相关,当加载和存储地址相同时会出现。
      • 图b)则是删去不直接影响迭代与迭代之间的数据流的操作之后的两个相关链:左边的一条,存储、加载和增加数据值(只对地址相同的情况有效),右边的一条,减小变量 cnt。
      • 于是AB两种情况的到的数据流为:
        • i mage-20210201163418393
        • 可以看到,对于示例A来说,由于有不同的输入(源和目的地址),加载和存储操作可以独立运行,因此唯一的关键路径是由减少变量cnt形成的===》CPE=1
        • 而对于示例B来说,由于输入相同,s_data与load存在数据相关,使得关键路径形成了包括存储、加载和增加数据,这三个顺序执行需要7个时钟周期
5.13 应用:性能提高技术
  • 高级设计:避免使用那些会渐进地产生糟糕性能的算法或编码技术。
  • 基本编码原则:避免限制优化的因素,这样编译器就能产生高效的代码。
    • 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获得更大的效率。
    • 消除不必要的内存引用。引人临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中。
  • 低级优化
    • 展开循环,降低开销,并且使得进一步的优化成为可能。
    • 通过使用例如多个累积变量和重新结合等技术,找到方法提高指令级并行。
    • 用功能性的风格重写条件操作,使得编译采用条件数据传送。
5.14 确认和消除性能瓶颈(程序剖析)
  • Unix 系统提供了一个剖析程序 GPROF。这个程序产生两种形式的信息。

    • 首先,它确定程序中每个函数花费了多少 CPU 时间。
    • 其次,它计算每个函数被调用的次数,以执行调用的函数来分类。
  • GPROF具有以下特点:

    • 计时不准确。编译过的程序为每个函数维护一个计时器,操作系统每隔x会中断一次程序,当中断时,会确定程序正在执行什么函数,然后将该函数的计时器加上x。
    • 假设没有执行内联替换,则调用信息是可靠的
    • 默认情况下,不会对库函数计时,将库函数的执行时间算到调用该库函数的函数上
  • 使用方法:

    • 程序要为剖析而编译和连接,加上-pg,并且确保没有进行内联替换优化函数调用
    gcc -Og -pg prog.c -o prog
    
    • 正常执行程序,会产生一个文件gmon.out
    • 使用GPROF分析gmon.out的数据
    gprof prog
    

    书中的几个建议:

    • 使用快速排序来进行排序
    • 通常使用迭代来代替递归
    • 使用哈希函数来对链表进行划分,减少链表扫描的时间
    • 链表的创建要注意插入位置的影响
    • 要尽量使得哈希函数分布均匀,并且要产生较大范围
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

椰子奶糖

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值