《C程序性能优化》学习笔记【四】—— 达人方法论

4.1 达人的关注点

第3章,研究了如何检查耗时的部分,之后需要着眼于何处实现高效编程。这里,从系统构造来看,遇到问题要先解决什么问题。

硬件篇

程序中不稳定的部分是程序的瓶颈。以下因素可能成为程序瓶颈:

  • 程序是否侧重于处理字符串;
  • 是否侧重于处理数值运算;
  • 是否侧重于访问底层硬件;
  • 程序是否与其他程序紧密关联。

无论怎样的程序,都有计算机系统中各部件协调运作执行。因此计算机各部件的运行速度和不协调构成了程序执行过程中的“人行横道”。

尽量不使用低效率资源

高效编程的一个重要宗旨是:不停检查程序,操作中尽量采取高效率的操作和快速的存取对象
存取对象时间,一般越远离CPU,存取越耗时,存取时间对比如下:网络存储器 > 直连辅助存储器 > 主存储器 > 缓存 > 寄存器

高效利用CPU:SIMD与多核的应用

如今,CPU主频已经接近极限,提升空间有限,可通过其他方式提高CPU性能:

  • 装载多个计算的核;
  • 并行处理多个数据的SIMD。

想有效利用多核,代码需要支持多线程,与其他核同时运行。这需要再OpenMP和TBB(Threading Building Blocks)环境中进行。
另外,SIMD可使用SSE指令来计算大量的数据。

SSE指令

Intel和AMD的x86系列CPU都具有用于SIMD计算的SSE指令,且具备了在各CPU核内使用SSE指令的128位宽的寄存器。
结构为8位x16、16位x8、32位x4的寄存器能搜集分散数据,通过SSE指令同时执行多个数据的计算。

编译器/中间件篇

操作系统和编译器的基础软件、数据库软件、网络协议栈、扩充文件系统这些软件叫做中间件
随着编译器技术的发展,将生成更合理的代码,删除多余的操作使编程更高效。如果能掌握编译器的运作,就能删除多余运作从而实现高效编程
中间件的性能优化依赖于安装环境。
下面,讨论使用频率高和处理速度慢的函数。

输入输出操作

  1. 输入输出操作十分耗时,若多次输入输出操作集中做一次归并处理,速度会快很多。
  2. C语言的处理系统中,功能相近的输入输出函数有若干种。例如输入行:fread/fgets/getline。从使用缓冲区(I/O缓冲区)读取数据直到完成读取,整个过程因函数而异。
  3. 字符串输出函数printf/fprintf,执行标准化字符串扫描和调整时,非常耗时。若不需要复杂的调整造作,尽量使函数简单且少量。

字符串操作

字符串的复制、比较和替换操作比较频繁。为此,C语言处理系统中具备了相似却不相同的函数,使用时选用用途相同、速度较快函数。例如,将strcpy替换为memcpy。

算法篇

数据结构和程序算法是决定程序性能的两大要素。
但是,即使选择了通用算法,想写出更合理的代码,需要了解编译器如何生成代码,以及CPU如何执行程序。

了解CPU和编译器的运作

  1. 删除程序中无用的循环和条件判断;
  2. 修改代码,减少因内存读取和条件判断指令引起的等待时间;
  3. 了解CPU合适产生等待时间,采取相应措施;
  4. 采用SSE指令。

结合数据来调整算法

众所周知,将算法放入程序内会提高性能,如果将算法与实际数据的特性结合调整,性能会进一步提高。

4.2 【硬件篇】数组和缓存的有效利用

若存取的数据在内存中是连续的,缓存的作用会得到很好发挥。但如果数据在内存中不连续,会发生缓存未命中,从而增加存取的时间。
常用的矩阵乘积,是否能实现高效编程,关键在于能否通过数据操作使缓存得到有效利用

矩阵的乘法运算

矩阵的乘法运算用以下算式定义
在这里插入图片描述在这里插入图片描述
将算式直接翻译成程序,结果如程序4-1。
在这里插入图片描述
程序的数组操作如图4-1。程序访问的b[k][j]的数据分散在内存上,所以缓存未命中的情况频繁发生。
在这里插入图片描述

调整数组操作的顺序

由于变量间不存在联系,可以更换循环内的顺序。程序4-2为替换后的代码。
在这里插入图片描述
更改后,对b[k][j]的数据访问可以在连续的内存上进行存取,程序的执行速度急剧提升,如图4-2。
在这里插入图片描述
CPU发生缓存未命中时,会从外部缓存或主存储器中将该数据附近的4词或者8个词数据一起都入缓存中。
因此,程序4-2中,将b[k][j]读入缓存后的3次或7次数据访问是可以在缓存中都导数据的。与程序4-1比,程序4-2的缓存未命中率将为1/4或1/8。

展开循环的方式

程序4-3为展开最内侧循环的结果。所谓”展开循环“,是将循环主体排列几次,减少循环次数。
在这里插入图片描述
以上三个方法,对1000x1000整数型矩阵进行乘法运算,结果如图4-3。
在这里插入图片描述
可见,更换循环顺序的方式可是性能提高7倍,展开最内侧循环可在更换循环顺序的基础上在提高30%的性能。

矩阵的分块

想更大程度减少缓存未命中的情况,可采取分块手法,即将全体矩阵分割成相同的子矩阵,然后求被分割后各个子矩阵的乘积,最后将结果汇总秋整体的乘积。
在这里插入图片描述
通过调整部分子矩阵的大小以便与缓存大小相吻合,这样能减少缓存未命中,大幅加快执行时间。

4.3 【库函数篇】缓慢函数的迂回战术

说到字符串的大小比较,会想到使用库函数strcmp,通过对字符串逐个比较来实现。若要对比的字符串比较长,执行时间也会比较长。因此,将strcmp函数替换为memcpy函数已实现高效编程。

strcmp函数为何缓慢

strcmp函数以字符串的对比为前提,通过NUL字符(’/0’)作为字符串的结束标志,因此不必对strcmp函数制定对比字符串的长度。
实际上,strcmp是对两个字符串进行逐一对比,当出现不一致时,将字符串大小关系作为结果返回。如图4-5所示。
在这里插入图片描述
memcpy函数将对比的字符串分割成每4byte或8byte一组的词为单位来进行对比操作,节省执行时间。当对比词中检查出不一致时,对该词内的字符逐一进行检查,找出不同,然后将字符串的大小关系作为结果输出。
strcmp函数需要检查到末尾字符才指导字符串的长度,因此无法以词为单位进行比较。否则,可能发生SIGSEGV错误。

优化的陷阱

图4-6为测试执行时间的结果,其中分别对长度为10byte、20byte、50byte、100byte的字符串进行对比。
在这里插入图片描述
memcpy函数执行10byte的字符串对比操作耗时是strcmp函数的2倍,字符串长度为100byte时,两者耗时相差不多。
从结果看,memcpy并不比strcmp快,查看编译器的汇编语言代码,了解缘由。程序4-4为memcpy函数的一部分,函数被内联展
开后对字符直接进行逐一对比。
在这里插入图片描述
刚才例子中,编译器在内联展开库函数后对字符进行了逐一对比,所以执行速度迟缓。可以通过-fno-builtin-memcpy或者-fno-builtin选项避免函数的内联展开。
另外,可以对用户自定义的函数进行内联展开以提高效率。
图4-7为使用优化选项后调用库函数memcpy的结果。
在这里插入图片描述
只有长度为10byte的情况下strcmp函数相对较快,随字符串长度增加,strcmp函数相对memcpy函数越来越慢。

调试策略的最优化

  1. fprintf函数常作为调试过程工具使用,理应设定断点。但执行时,也会出现没有中断程序也照常执行的情况。
  2. fprintf函数里标准字符串中没有可变更部分时,GCC会将fprintf替换为fputs。
  3. 当时用fprintf函数不能终端操作时,可使用刚介绍的-fno-builtin选项。

4.4 【硬件篇】使用SIMD进行字符串对比

若使用1条指令处理多个数据的SSE指令,memcmp的效率能进一步提高。
为了时memcmp更加高效,可将16byte的字符串放入128位宽的SSE指令寄存器中,通过一次性对比128位的字符串来实现高效编程。
程序4-5使用SSE指令进行字符串对比且进行改良后的mymemcmp函数。
在这里插入图片描述
该程序的要点如下:

  1. 使用pcmpeqb指令对方如寄存器的16字节字符串逐一进行对比(结果一致的字符串标识位0xFF,不一致的则为0x00)。
  2. 通过pmovmskb指令将各字符串的最高位同时放入整数寄存器中。此时,对寄存器中的值进行取反(通过与0xFFFF进行XOR操作获得),结果不为0则表示字符串不相同。
  3. 2的结果不为0时,用bsf指令来确认字符串中不相同字符的位置。

图4-8为改良memcmp函数执行时间的测试结果。
在这里插入图片描述
其中,将字符串长度从1字节逐步增加到100字节,重复执行一下函数:

  • ① strcmp函数;
  • ② 内联展开memcmp函数;
  • ③ 调用库函数memcmp;
  • ④ 将memcmp函数改良为以词为单位进行字符串对比,在每个以词为单位的字符串内找出不同字符串位置的mymemcmp函数;
  • ⑤ 使用SSE指令得到进一步改良的mymemcmp函数。

4.5 【库函数篇】对比各种输入输出方法

在处理大量数据时,尽量缩短输入输出的时间时提高性能的重要手法。

行输入函数的对比

首先对比以下输入行数据时所使用的三个函数。

  • fgets
  • getline
  • 自定义块大小的行输入函数

分析以上函数如何输入数据。

fgets函数

fgets是ISO C规定的库函数,通过文件指针stream将缓冲区stdio中的数据一行一行地输入到调用程序定义的缓冲区内。如图4-9所示。
在这里插入图片描述
缓冲区stdio的标准容量为4KB,可用setvbuf来扩充。fgets函数用read系统调用来将数据从文件指针中读入缓冲区。
当输入的行数据过大、参数指定的缓冲区的容量不够用时会中断输入。因此,使用fgets函数时,必须确认读入时缓冲区的空间是否足够。介绍以下方法:

  • 求输入行的长度,确认是否能在行的最后加入换行字符(’\n’)。
    -
  • 输入前,在缓冲区的结尾处将字符设置为NUL字符(’\0’),若在这之前没有换行字符的话,则结束本次读入操作。
    在这里插入图片描述
    方法2执行成本低,但调用fgets需要指导输入行的长度,所以实际操作中用得并不多。
    另外,可以用同样的方法来变换条件式,以确认“输入行是否完整”。
    在这里插入图片描述

getline函数

getline是GNU的扩展库函数,和fgets一样,从文件指针中读取数据放入stdio缓冲区中,并从中一行一行地读取数据,将数据放入调用函数所定义的缓冲区内。
getline有以下优点:

  • 可以反馈输入行的长度;
  • 当参数指定地缓冲区空间不够时会自动扩展。因此,不需要确认行数据是否完全被输入。

自定义块大小的行输入函数

函数使用fread库函数直接从文件指针stream中读取数据,将缓冲区的起始地址和行的长度返回给调用函数,并输入数据,如图4-10。
在这里插入图片描述
在这里插入图片描述
这种函数与fgets和getline不同,节省了将数据复制到调用函数定义缓冲区内的时间,效率提升,但受以下条件限制:

  • 不能在字符串的结尾处添加NUL字符;
  • 不能更改超出字符串的部分(不能通过程序上的处理来变更输入行的长度)。

哪种输入方法更为快速

图4-11为上述三种行输入方法处理500万行(每行77字节,即9个字段)的结果。
在这里插入图片描述
在块输入的测试程序中,考虑其移植性的特点,使用了fread函数来读入缓冲区。但是,当fread输入数据比stdio缓冲区大事,会直接调用read定义的缓冲区,所以等同于使用read进行输入。
从测试结果看,块输入函数的成绩最优秀。另外,fgets与getline在缓冲区长度扩充到4MB后速度也快了近2成

输出方法

同样,对比以下三个输出方法。

  • fputs函数
  • fwrite函数
  • 自定义块大小的行输出函数

fputs函数

fputs是与fgets相对的库函数,被调用时将接收到的数据复制到stdio缓冲区。缓冲区容量不足时,fputs将使用write系统调用来写入文件。
在这里插入图片描述

fwrite函数

已知行的长度时,可以采用fwrite输出数据。fputs可在内部计算输出的字符数,但对已知行长度的情况,只能用fwrite函数实现高效编程。

自定义块大小数据的输出函数

函数使用fwrite函数将输出行数据放入所准备的4MB缓冲区中,缓冲区容量不足时,将数据输出到4MB输出文件中。如图4-13。
在这里插入图片描述
程序4-7为使用函数时的大致情况,仅供参考。在这里插入图片描述
图4-14为以上三种方法输出1千万行(每行77字节,即9个字段)的结果,分别测试了按照字段和行进行输出的情况。
在这里插入图片描述
从图表中可以看出:对存取耗时的辅助存储器来说,按字段为单位输出是不利的。另外,快输出的执行时间与fwrite和fputs没有很大差别,估计因为两者数据输出过程大致相同。

管道输入输出的特殊案例

在Unix/Linux中,无论输入输出对象是存储设备上的文件,还是标准输入的管道,程序都进行相同处理。Linux 2.6.11以上的内核内部管道的缓冲区大小是64KB,若缓冲区大于64KB,速度会降低。因此,当输入输出对象为管道是,块输入输出的缓冲区长度控制在64KB会是性能有所提高。
实际上,可以通过变换stdio的缓冲区大小来测试管道输入输出的性能。进行以下性能测试,如图4-15:

  • 将归并程序用7个管道连接起来;
  • 500万行的文件(每行157字节)作为输入数据归并到一个文件输出;
  • 操作时使用8核CPU。

在这里插入图片描述
管道连接的程序,都是多个进程并列运行的。8核CPU环境下,各进程在不同的核内运行,执行时间非常短。

图4-16测试和比较这一连串操作从头到尾的执行时间。从结果看,输入输出缓冲区均为64KB时执行速度最快。
在这里插入图片描述
在管道处理中,前段数据输出速度不如后段进程迅速的画,后段进程必须等前段输出数据,因此耽误了时间。若能平衡好前段核后端的处理量,执行效率能得到很好提升。

管道输入输出与文件输入输出

图4-17,需将程序的输出结果在文件输出后在传递给后面的程序,所以后段的程序需等前段的处理全部结束后再开始,因此总执行时间变长。
在这里插入图片描述
为了比较,对管道的输入输出的执行时间也进行了测试。图4-18浅色部分的柱形图为直接将中间结果输出到文件的执行时间。从结果看,输入输出缓冲区长度设置为4MB比较好。
在这里插入图片描述

4.6 【算法篇】二分法查找与平衡二叉树

这里以搜索算法的选择为例,介绍在选择算法时需要考虑什么,如何选择以及效果等。

海量数据的分类

假定需要将各地连锁店的数据按商品或地区进行分类,并将结果分别放入文件中。可采取方法: 由于没有将输入数据进行分类,所以首先将数据按类别关键字的顺序来分类,然后将持有相同key的数据汇总放入文件中。

分类操作中,若处理数据不大,使用哪个算法时间都大致相同。但是若采用相同方法,将1亿数量的4GB大小的输入数据分类到十万个文件中,耗时可能时几天甚至数十天。因此需要进行改良。

截图4-1为输入数据的案例,空格将商品编号、地区、销售额等字段隔开。
在这里插入图片描述
截图4-2为分类后的输出文件,将第一字段作为key进行分类。
在这里插入图片描述
有效利用内存是提高分类操作效率的重要途径。 如果内存不能存放大量数据,先将数据输出到文件中,使内存空出空间来仅储存key,也可继续执行分类操作。
因此,改良此程序的策略,总结为以下两点:

  1. 将输入的数据一边分类一边放入数组中,将分类后的数组按相同key数据进行输出。
  2. 输入后的数据按顺序放入数组中,使用指针链接生成二分查找树。然后按设定的规则对二分查找树进行查找,将分类后的数据取出,将持相同key的数据输出。

采用二分法查找对数组上的数据进行排序

将输入数据按key值顺序插入数组,如图4-19。
在这里插入图片描述
插入的位置使用二分法进行查找,数组内存放的是结构体的指针。二分检索法的弊端在于:当输出文件的数目为n时,数据插入数组时的平均时间成本较大为 O ( n 2 ) O(n^2) O(n2)

使用二分查找树来生成数据

根据分类key将输入数据生成树,用指针链接数组上的数据。由此,数据插入数组的成本不存在,但输出数据是需要沿树枝遍历二分查找树。
二分查找树将最初数据作为根,之后数据根据key的大小关系,在满足以下规则的条件下,生成左右树枝,如图4-10。
3. 节点左侧,是比此节点值小的节点
4. 节点右侧,是比此节点值大的节点
在这里插入图片描述
若输入数据有所侧重,二分检索树的平衡会被打破,搜索时间会增加。因此,若采用平衡二叉树,可以将从根到叶的分配计算成本控制再 O ( l o g n ) O(logn) O(logn)以内,而平衡二叉树是指再每追加一次节点时,将跟到最末端树叶的节点数做一定调整的算法
二分检索树可以通过以下方法输出数据,如图4-21。

  1. (若有左边树杈)向左边部分树杈前进
  2. 若从左边返回则输出数据
  3. (若有右边树杈)向右边部分树杈前进
  4. 如从后面回来,则回到树根

在这里插入图片描述
图4-22为两种方法生成的程序执行1亿个数据的时间测试结果,测试中使用的平衡二叉树中的红黑书算法。对使用1个key(商品编号)和2个key值(商品编号+地区名)的分类进行测试。
在这里插入图片描述
图中可见,使用二分法查找进行数组数据的分类,当分类条件只有1个的情况下执行时间段,当分类条件增加到2个就变缓慢。这是因为,当分类条件增加到2个,分类的数量增多,二分法查找时插入数组的成本变高

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值