浅谈优化程序性能(下)

前言

上一篇随笔中,我们谈到最小化一个计算中的操作数量不一定会提高它的性能。现在,就让我们来解开为什么会出现这种情况的原因吧。

处理器体系结构

在计算机的处理器中,处理一条指令包括很多操作,可以分为取指(fetch)、译码(decode)、执行(execute)、访存(memory)、写回(write back)和更新程序计数器(PC update)等几个阶段。这些阶段可以在流水线上同时进行,如下图所示:

上图中,F、D、E、M 和 W 分别代表上述五个阶段。当然,现代的处理器比这个示例要复杂得多,但是原理是一样的。

  • 双精度浮点数乘法: 延迟 5 发射时间 1
  • 双精度浮点数加法: 延迟 3 发射时间 1
  • 单精度浮点数乘法: 延迟 4 发射时间 1
  • 单精度浮点数加法: 延迟 3 发射时间 1
  • 整数乘法:延迟 3 发射时间 1
  • 整数加法:延迟 1 发射时间 0.33

上面是 Intel Core i7 的一些算术运算的性能。这些时间对于其他处理器来说也是具有代表性的。每个运算都是由两个周期计数值来刻画的:

  1. 延迟(latency),表示完成运算所需要的总时间。
  2. 发射时间(issue time),表示两个连续的同类型运算之间需要的最小时钟周期数。

我们看到,大多数形式的算术运算的发射时间为 1,意思是说在每个时钟周期,处理器都可以开始一条新的这样的运算。这种很短的发射时间是通过使用流水线实现的。流水线化的功能单元实现为一系列的阶段(stage),每个阶段完成一部分的运算。例如,一个典型的浮点加法器包含三个阶段(所以有三个周期的延迟):

  1. 处理指数值
  2. 将小数相加
  3. 对结果进行舍入

算术运算可以连续地通过各个阶段,而不用等待一个操作完成后再开始下一个。只有当要执行的运算是连续的、逻辑上独立的时候,才能利用这种功能。发射时间为 1 的功能单元被称为完全流水线化的(fully pipelined):每个时钟周期都可以开始一个新的运算。整数加法的发射时间为 0.33,这是因为硬件有三个完全流水线化的能够执行整数加法的功能单元。处理器有能力每个时钟周期执行三个加法。

上述内容来源于《深入理解计算机系统(原书第2版)》。更详细的内容请参阅该书,特别是“第四章 处理器体系结构”和“第五章 优化程序性能”。我们这篇文章讨论的两个算法就来源于该书的“练习题 5.5”和“练习题 5.6”。

分析 poly 函数

下面就是 poly 函数的 C 语言源程序代码:


1

2

3

4

5

6

double poly(double a[], double x)

{

  double result
= 0, p = 1;

  for (int i
= 0; i < N; i++, p *= x) result += a[i] * p;

  return result;

}

我们在 openSuSE 12.1 操作系统中使用 objdump -d a.out 命令对上一篇随笔中的测试程序进行反汇编,找出其中的 poly 函数的汇编代码如下所示:


1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

0000000000400640
<poly>:

  400640:  
66 0f 57 d2             xorpd  %xmm2,%xmm2

  400644:  
31 c0                   xor    %eax,%eax

  400646:  
f2 0f 10 0d 92 01 00    movsd  0x192(%rip),%xmm1        # 4007e0 <_IO_stdin_used+0x10>

  40064d:  
00

  40064e:  
66 90                   xchg   %ax,%ax

  400650:  
f2 0f 10 1c 07          movsd  (%rdi,%rax,1),%xmm3

  400655:  
48 83 c0 08             add    $0x8,%rax

  400659:  
48 3d a8 60 2f 0b       cmp    $0xb2f60a8,%rax

  40065f:  
f2 0f 59 d9             mulsd  %xmm1,%xmm3

  400663:  
f2 0f 59 c8             mulsd  %xmm0,%xmm1

  400667:  
f2 0f 58 d3             addsd  %xmm3,%xmm2

  40066b:  
75 e3                   jne    400650 <poly+0x10>

  40066d:  
66 0f 28 c2             movapd %xmm2,%xmm0

  400671:  
c3                      retq  

  400672:  
66 66 66 66 66 2e 0f    data32 data32 data32 data32 nopw %cs:0x0(%rax,%rax,1)

  400679:  
1f 84 00 00 00 00 00

可以看出,poly 函数从地址 0x400640 处开始,这与上一篇随笔中测试程序的运行结果相符。我们重点分析循环语句对应的从地址 0x400650 到 0x40066b 之间的代码:


1

2

3

4

5

6

7

8

9

#
for (int i = 0; i < N; i++, p *= x) result += a[i] * p;

#
i in %rax, a in %rdi, x in %xmm0, p in %xmm1, result in %xmm2, z in %xmm3

400650:
movsd  (%rdi,%rax,1),%xmm3  # z = a[i]

400655:
add    $0x8,%rax            # i++, for 8-byte pointer

400659:
cmp    $0xb2f60a8,%rax      # compare N : i

40065f:
mulsd  %xmm1,%xmm3          # z *= p

400663:
mulsd  %xmm0,%xmm1          # p *= x

400667:
addsd  %xmm3,%xmm2          # result += z

40066b:
jne    400650 <poly+0x10>   # if !=, goto loop

在 x86-64 体系结构中,%rax, %rdi 是 64-bit 的寄存器,%xmm0, %xmm1, %xmm2, %xmm3 是 128-bit 的浮点寄存器。

在本例中:

  • 整型循环变量 i 存放在 %rax 寄存器中。
  • 双精度浮点型数组 a 第一个元素的地址存放在 %rdi 寄存器中,注意这个地址是一个 64-bit 的指针,是整数值,而不是浮点值。
  • 双精度浮点型输入参数 x 存放在 %xmm0 寄存器中。
  • 中间变量 p 存放在 %xmm1 寄存器中。
  • 最终结果 result 存放在 %xmm2 寄存器中。
  • 此外,GCC C 编译器还使用了一个临时变量 z,存放在 %xmm3 寄存器中。

上述代码中的立即数的含义:

  • 0x08: 在 add 指令中使用,使 %rax 加上字长 8-byte,达到 i++ 的目的。
  • 0xb2f60a8: 在 cmp 指令中使用,它等于 187654312,除以字长 8-byte 就是 23456789,即 N 的值。
  • 0x400650: 在 jne 指令中使用,指明跳转的目的地。

我们可以看到,这里限制性能的计算是反复地计算表达式 p *= x。这需要一个双精度浮点数乘法(5个时钟周期),并且直到前一次迭代完成,下一次迭代的计算才能开始。两次连续的迭代之间,还要计算表达式 z *= p, 这需要一个双精度浮点乘法(5个时钟周期),以及计算表达式 result += z, 这需要一个双精度浮点加法(3个时钟周期)。这三个涉及浮点数运算的表达式的计算都可以在流水线上同时地进行。最终,完成一次循环迭代需要5个时钟周期。

在这个汇编程序中,C 语言编译器充分利用了处理器提供的指令级并行(instruction-level parallelism)能力,同时执行多条指令,以达到优化程序性能的目的。

分析 polyh 函数

这下面就是 polyh 函数的 C 语言源程序代码:


1

2

3

4

5

6

double polyh(double a[], double x)

{

  double result
= 0;

  for (int i
= N - 1; i >= 0; i--) result = result * x + a[i];

  return result;

}

对应的汇编语言代码如下所示:


1

2

3

4

5

6

7

8

9

10

11

12

13

14

0000000000400680
<polyh>:

  400680:  
66 0f 57 c9             xorpd  %xmm1,%xmm1

  400684:  
31 c0                   xor    %eax,%eax

  400686:  
66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)

  40068d:  
00 00 00

  400690:  
f2 0f 59 c8             mulsd  %xmm0,%xmm1

  400694:  
f2 0f 58 8c 07 a0 60    addsd  0xb2f60a0(%rdi,%rax,1),%xmm1

  40069b:  
2f 0b

  40069d:  
48 83 e8 08             sub    $0x8,%rax

  4006a1:  
48 3d 58 9f d0 f4       cmp    $0xfffffffff4d09f58,%rax

  4006a7:  
75 e7                   jne    400690 <polyh+0x10>

  4006a9:  
66 0f 28 c1             movapd %xmm1,%xmm0

  4006ad:  
c3                      retq  

  4006ae:  
66 90                   xchg   %ax,%ax

同样可以看出,polyh 函数从地址 0x400680 处开始,也与上一篇随笔中测试程序的运行结果相符。需要重点分析的循环语句位于地址 0x400690 到 0x4006a7 之间:


1

2

3

4

5

6

7

#
for (int i = N - 1; i >= 0; i--) result = result * x + a[i];

#
i in %rax, a in %rdi, x in %xmm0, result in %xmm1

400690:
mulsd  %xmm0,%xmm1                   # result *= x

400694:
addsd  0xb2f60a0(%rdi,%rax,1),%xmm1  # result += a[i]

40069d:
sub    $0x8,%rax                     # i--, for 8-byte pointer

4006a1:
cmp    $0xfffffffff4d09f58,%rax      # compare 0 : i

4006a7:
jne    400690 <polyh+0x10>           # if !=, goto loop

上述程序中几个立即数的含义:

  • 0x8: 在 sub 指令中使用,从 %rax 中减去字长 8-byte,达到 i-- 的目的。
  • 0xb2f60a0: 在 addsd 指令中使用,它等于 187654304,除以字长 8-byte 就是 23456788,即 N - 1 的值。
  • 0xfffffffff4d09f58: 在 cmp 指令中使用,它加上 0xb2f60a0 就是 0xfffffffffffffff8,再加上 0x8 就等于 0。
  • 0x400690: 在 jne 指令中使用,指明该指令跳转的目的地。

类似地:

  • 整型循环变量 i 存放在 %rax 寄存器中。
  • 双精度浮点型数组 a 第一个元素的地址存放在 %rdi 寄存器中。
  • 双精度浮点型输入参数 x 存放在 %xmm0 寄存器中。
  • 最终结果 result 存放在 %xmm1 寄存器中。

我们可以看到,这里限制性能的计算是求表达式 result *= x 和 result += a[i] 的值。从来自上一次迭代的 result 的值开始,我们必须先把它乘以 x (需要 5 个时钟周期),然后把它加上 a[i] (需要 3 个时钟周期),然后得到本次迭代的值。因此,完成一次循环迭代需要 8 个时钟周期,比原始的算法需要的 5 个时钟周期更慢。注意,由于后一个表达式 result += a[i] 的计算需要前一个表达式 result *= x 的值,所以这两个表达式的计算不能在流水线上同时进行。这里由于数据相关(data
dependency),无法利用处理器提供的指令级并行能力来优化程序性能。

结论

优化程序性能不是一件简单的事,必须对计算机系统的核心概念有所了解。现代计算机用复杂的技术来处理机器级程序,并行地执行许多指令,执行顺序还可能不同于它们在程序中现出的顺序。程序员必须理解这些处理器是如何工作的,从而调整他们的程序以获得最大的速度。强烈推荐《深入理解计算机系统(原书第2版)》这本书。

版权声明:本文为博主http://www.zuiniusn.com原创文章,未经博主允许不得转载。

时间: 2024-11-06 03:39:03

浅谈优化程序性能(下)的相关文章

浅谈优化程序性能(上)

前言 我们知道,多项式定义为: 在几何学中,多项式是最简单的平滑曲线.简单是指它仅由乘法及加法构成,平滑是因为它类同口语中的平滑,以数学术语来说,它是无限可微,即它的所有高次微分都存在.事实上,多项式的微分也是多项式.简单及平滑的特点,使多项式在数值分析.图论,以及电脑绘图等,都发挥极大的作用.多项式求值是解决许多问题的核心技术.以数值分析为例,多项式函数常常用作对数学库中的三角函数求近似值. 现在,让我们来用 C 语言写一个对多项式求值的函数吧. 直接的算法 直接按照多项式的定义使用循环求值:

【菜鸟学php】小菜鸟由帝国备份王在Wamp环境下打开500错误浅谈PHP程序员

===================问题情况描述=================== 小弟一直在玩discuz论坛开源程序,这个论坛程序经常涉及到论坛搬家的问题. 今天我在本地Wamp环境下,用开源软件帝国备份王2010进行数据库备份数据,结果打开发现报错500! 这真是坑爹了,回想下以前自己使用这个开源程序进行备份也不下于十几次了,大部分都正常成功, 但也不乏出现这种情况的,小弟之前一般遇到这种问题, 都是直接忽略,换其他办法来进行备份,但是用惯了帝国备份王,换其他的方法备份数据,总感觉难

浅谈 Python 程序和 C 程序的整合

源地址:http://www.ibm.com/developerworks/cn/linux/l-cn-pythonandc/ 概览 Python 是一种用于快速开发软件的编程语言,它的语法比较简单,易于掌握,但存在执行速度慢的问题,并且在处理某些问题时存在不足,如对计算机硬件系统的访问,对媒体文件的访问等.而作为软件开发的传统编程语言—— C 语言,却能在这些问题上很好地弥补 Python 语言的不足.因此,本文通过实例研究如何在 Python 程序中整合既有的 C 语言模块,包括用 C 语言

浅谈mapreduce程序部署

尽管我们在虚拟机client上能非常快通过shell命令,进行运行一些已经封装好实例程序,可是在应用中还是是自己敲代码,然后部署到server中去,以下,我通过程序进行浅谈一个程序的部署过程. 在启动Hadoop之后,然后把程序达成可运行的jar包,并把对应的第三方jar包 包括进去.运行hadoop    jar   XXX. +驱动名称. package com.mapred; import java.io.IOException; import java.io.PrintStream; i

深入理解计算机系统(5.1)------优化程序性能

你能获得的对程序最大的加速比就是当你第一次让它工作起来的时候. 在讲解如何优化程序性能之前,我们首先要明确写程序最主要的目标就是使它在所有可能的情况下都能正常工作,一个运行的很快的程序但是却是错误的结果是没有任何用处的,所以我们在进行程序性能优化之前,首先要保证程序能正常运行,且结果是我们需要的. 而且在很多情况下,让程序跑的更快是我们必须要解决的问题.比如一个程序要实时处理视频帧或者网络包,那么一个运行的很慢的程序就不能解决此问题.再比如一个计算任务计算量非常大,需要数日或者数周,如果我们哪怕

浅谈线程池(下):相关试验及注意事项

三个月,整整三个月了,我忽然发现我还有三个月前的一个小系列的文章没有结束,我还欠一个试验!线程池是.NET中的重要组件,几乎所有的异步功能依赖于线程池.之前我们讨论了线程池的作用.独立线程池的存在意义,以及对CLR线程池和IO线程池进行了一定说明.不过这些说明可能有些"抽象",于是我们还是要通过试验来"验证"这些说明.此外,我认为针对某个"猜想"来设计一些试验进行验证是非常重要的能力,如果您这方面的能力略有不足的话,还是尽量加以锻炼并提高吧. C

【Vue】浅谈Vue不同场景下组件间的数据交流

浅谈Vue不同场景下组件间的数据“交流” Vue的官方文档可以说是很详细了.在我看来,它和react等其他框架文档一样,讲述的方式的更多的是“方法论”,而不是“场景论”,这也就导致了:我们在阅读完文档许多遍后,写起代码还是不免感到有许多困惑,因为我们不知道其中一些知识点的运用场景.这就是我写这篇文章的目的,探讨不同场景下组件间的数据“交流”的Vue实现 父子组件间的数据交流 父子组件间的数据交流可分为两种: 1.父组件传递数据给子组件 2.子组件传递数据给父组件 父组件传递数据给子组件——pro

《深入理解计算机系统》 优化程序性能的几个方法

本文几个优化程序性能的方法出自CSAPP第五章,通过不断修改源代码,试图欺骗编译器产生有效的代码 我们先引入度量标准每元素的周期数(CPE),表示程序性能. 我们先定义一个数据结构   data_t 代表数据类型 1 typedef struct{ 2 long len; 3 data_t *data; 4 }vec_rec,*vec_prt; 以及常数IDENT和OP以便在后续的代码中进行不同的操作 //对所有向量的元素求和 #define IDENT 0 #define OP + //对所有

浅谈在ES5环境下实现const

最近看到一个面试题--用ES5实现const.作为JS初学者的笔者知道在ES6中有const命令,可以用来声明常量,一旦声明,常量的值就不可改变.例如: 1234567891011 const Pi = 3.1415;Pi Pi = 3;// TypeError: Assignment to constant variable. const foo = {};// 为 foo 添加一个属性,可以成功foo.prop = 123;foo.prop // 123// 将 foo 指向另一个对象,就会