目 录
第1章 实验基本信息
1.1 实验目的
理解程序优化的10个维度
熟练利用工具进行程序的性能评价、瓶颈定位
掌握多种程序性能优化的方法
熟练应用软件、硬件等底层技术优化程序性能
1.2 实验环境与工具
1.2.1 硬件环境
1.2.2 软件环境
Windows 10 64位;Vmware 15.5;Ubuntu 20.04.4
1.2.3 开发工具
Visual Studio 2022;CodeBlocks 20.03;
1.3 实验预习
首先复习课本中有关性能优化的相关知识。之后结合ppt,学习如何编写面向编译器、CPU、存储器友好的程序。进一步了解了cache和流水线的概念,知道了什么是面向cpu的优化,什么是面向寄存器的优化。
在了解完概念之后,学习怎么测试性能,学习如何使用time、RDTSC、clock等进行测试,同时还学习了vs中的测试方法并在Ubuntu中安装了oprofiler进行性能测试。
第2章 实验预习
2.1 程序优化的十大目标
- 更快
- 更省
- 更美
- 更正确
- 更可靠
- 可移植
- 更强大
- 更方便
- 更范
- 更易懂
2.2性能优化的方法概述
1.一般有用的优化
(1)代码移动
(2)复杂指令简化
(3)公共子表达式
2.面向编译器的优化:障碍
(1)函数副作用
(2)内存别名
3.面向超标量CPU的优化
(1)流水线、超线程、多功能部件、分支预测投机执行、乱序执行、多核:分离的循环展开!
(2)只有保持能够执行该操作的所有功能单元的流水线都是满的,程序才能达到这个操作的吞吐量界限
4.面向向量CPU的优化:MMX/SSE/AVR
5. CMOVxx等指令
(1)代替test/cmp+jxx
6. 嵌入式汇编
7.面向编译器的优化
(1)Ox:0 1 2 3 g
8.面向存储器的优化:Cache无处不在
(1)重新排列提高空间局部性
(2)分块提高时间局部性
9.内存作为逻辑磁盘:内存够用的前提下。
10.多进程优化
(1)fork,每个进程负责各自的工作任务,通过mmap共享内存或磁盘等进行交互。
11.文件访问优化:带Cache的文件访问
12.并行计算:多线程优化:第12章
13.网络计算优化:第11章、分布式计算、云计算
14.GPU编程、算法优化
15.超级计算
2.3 Linux下性能测试的方法
使用oProfiler、gprof等工具进行性能测试:
使用oProfiler:
oProfiler支持两种采样(sampling)方式:基于事件的采样(eventbased)和基于时间的采样(timebased)。
基于事件的采样是oProfile只记录特定事件(比如L2 cache miss)的发生次数,当达到用户设定的定值时oProfile就记录一下(采一个样)。这种方式需要CPU内部有性能计数器(performace counter)。
基于时间的采样是oProfile借助OS时钟中断的机制,每个时钟中断oProfile都会记录一次(采一次样),引入此种采样方式的目的在于提供对没有性能计数器的CPU的支持,其精度相对于基于事件的采样要低。因为要借助OS时钟中断的支持,对禁用中断的代码oProfile不能对其进行分析。
oProfile在Linux上分两部分,一个是内核模块(oprofile.ko),一个为用户空间的守护进程(oprofiled)。前者负责访问性能计数器或者注册基于时间采样的函数(使用register_timer_hook注册之,使时钟中断处理程序最后执行profile_tick时可以访问之),并采样置于内核的缓冲区内。后者在后台运行,负责从内核空间收集数据,写入文件。
2.4 Windows下性能测试的方法
可以使用VS自带的性能分析工具:性能探查器。
首先选择调试,再选择性能探查器,进入探查器界面,出现下图:
可以选择自己要进行的项目,如选择CPU使用率:
执行一段时间后,点击居中的 “停止收集” 按钮,生成分析数据。
第3章 性能优化的方法
逐条论述性能优化方法名称、原理、实现方案
3.1
名称:采取一般有用的优化
原理:通过减少重复运算的低效效代码以及一部分死代码来减少程序运行时间来提高性能。
实现方案:代码移动,复杂指令简化,公共子表达式
3.2
名称:面向编译器的优化:障碍
原理:编译器不检查存储器别名使用情况,如果程序中有别名使用,两个不同的内存引用很容易指向同一个位置导致程序出现错误。
实现方案:内存别名,变量暂存
3.3
名称:面向超标量CPU的优化
原理:超标量试图在一个周期取出多条指令并行执行,通过内置多条流水线来同时执行多个处理,其实质是以空间换取时间。
实现方案:流水线、超线程、多功能部件、分支预测投机执行、乱序执行、多核。
3.4
名称:面向向量CPU的优化
原理:向量化运算依赖CPU的SIMD指令集将多次for循环计算变成一次计算。
实现方案:MMX/SSE/AVR
3.5
名称: CMOVxx等指令
原理:test/cmp+jxx指令是使用控制的条件转移,这种方法要先测试数据值,再根据测试结果改变控制流或者数据流。而采用CMOVxx等指令是使用数据的条件转移,当可行时,可以直接用一条简单的条件传送指令实现,比较高效。
实现方案:使用CMOVxx指令代替test/cmp+jxx
3.6
名称:嵌入式汇编
原理:在不改变程序功能的情况下,通过修改原来程序的算法、结构,并利用软件开发工具对程序进行改进,使修改后的程序运行速度更高或代码尺寸更小。
实现方案:嵌入式汇编程序优化可分为运行速度优化和代码尺寸优化。运行速度优化是指在充分掌握软硬件特性的基础上,通过应用程序结构调整等手段来缩短完成指定任务所需的运行时间;代码尺寸优化则是指应用程序在能够正确实现所需功能的前提下,尽可能减小程序的代码量。
3.7
名称:面向编译器的优化
原理:指定优化级别,获取每个优化标识所启用的优化选项,根据不同的优化选项进行不同程度的优化。
实现方案:采用Ox:0 1 2 3 g
3.8
名称:面向存储器的优化
原理:通过合理划分得到合适的工作集使得工作集可以尽可能地由高速缓存L1,L2,L3来缓存以提高读吞吐量,从而优化程序性能。
实现方案:重新排列提高空间局部性,分块提高时间局部性
3.9
名称:内存作为逻辑磁盘
原理:在计算机这个系统中,高速小容量的内存与低速高容量的磁盘进行协同作业。计算机在运行程序时,必须将磁盘中的内容加载到内存中,不加载是不能运行程序的。
实现方案:当内存足够大时,使用大量内存充当磁盘缓存
3.10
名称:多进程优化
原理:使用多线程可以使得CPU的多个核心同时处理任务,提升程序的并发执行性能,提高计算资源利用率。
实现方案:fork,每个进程负责各自的工作任务,通过mmap共享内存或磁盘等进行交互。
第4章 性能优化实践
4.1 原始程序及说明
#include<stdlib.h>
#include<stdio.h>
#include<time.h>
int main()
{
clock_t start = clock();
size_t** img = (size_t**)malloc(sizeof(size_t*) * 1920);//防止栈溢出
srand((size_t)time(NULL));//随机种子
size_t number=0;
for (int i = 0; i < 1920; i++)
{
img[i] = (size_t*)malloc(sizeof(size_t) * 1080);//防止栈溢出
for (int j = 0; j < 1080; j++)
{
number = rand();
img[i][j] = number;
}
}
size_t *img1, *img2;
img1= (size_t*)malloc(sizeof(size_t) * 1080);//动态申请内存空间,记录上一行
img2 = (size_t*)malloc(sizeof(size_t) * 1080); //动态申请内存空间,记录当前行
img2 = img[0];
for (int i = 1; i < 1919; i++)
{
img1 = img2;//记录上一行数据
img2 = img[i];//记录当前行数据
for (int j = 1; j < 1079; j++)
{
img1[j] = (img[i + 1][j] + img1[j] + img2[j + 1] + img2[j - 1]) / 4;
}
img[i] = img1;//替换当前行数据
}
clock_t finish = clock();
printf("time:%f", (double)(finish - start) / CLOCKS_PER_SEC);
return 0;
}
功能:程序实现对一个1920*1080大小long类型数组的平滑算法。
流程:首先借助malloc动态申请1920*1080大小的内存空间,之后开始两次for循环,在循环时,使用两个1080大小的一维数组暂存二维数组当前行与原本上一行的数据,使用平滑算法计算每一个二维数组中新的数据值暂存于暂存二维数组上一行元素的一维数组中,在计算完一行数据后将img1上的数据传给二维数组的当前行,开始下一次循环,直至循环完成。
产生问题:一开始我直接采取创建二维数组的方式开辟1920*1080大小的数组,由于开辟栈空间太大,编译器直接报错。之后我采取了申请动态内存的方法,将img定义为指针,随后申请动态内存,成功开辟了一个1920*1080大小的数组。
在两次for循环中,我借助img1,img2俩个指针记录img当前行数据以及上一行数据,但在计算中由于对img数组的边缘数据忽略不计算,所以在计算时如不加以限制边界条件,很容易数据溢出,导致编译器报错。
4.2 优化后的程序及说明
1.面对cache的优化部分:
根据电脑cache的大小对二维数组进行分块:
#include<stdlib.h>
#include<stdio.h>
#include<time.h>
int main()
{
clock_t start = clock();
size_t** img = (size_t**)malloc(sizeof(size_t*) * 1920);
srand((size_t)time(NULL));
size_t number = 0;
for (int i = 0; i < 1920; i++)
{
img[i] = (size_t*)malloc(sizeof(size_t) * 1080);
for (int j = 0; j < 1080; j++)
{
number = rand();
img[i][j] = number;
}
}
size_t* img1, * img2;
img1 = (size_t*)malloc(sizeof(size_t) * 1080);
img2 = (size_t*)malloc(sizeof(size_t) * 1080);
img2 = img[0];
for (int i = 0; i < 4; i++)//三级缓存4MB:1080*1920*8=16,588,800
//16,588,800/1024/1024=15.82MB,本机三级缓存为4MB,故分成4份
{
for (int j = 0; j < 8; j++)//二级缓存512KB:4MB*1024/8=512KB
//本机二级缓存512KB,故把前面分的每一份再分成8份。
{
int x = i * 480;
int y = j * 60;
for (int p = 1; p <= 60; p++)
{
int e = p + x + y;
if (e == 1919)
break;
img1 = img2;
img2 = img[e];
for (int k = 1; k < 1079; k++)//一级缓存32kb,
//之前二三级缓存我们对行进行分割,一级缓存我们对列分割
//由于1080*8/1024=8.4375<32,所以我们不用分割。
{
img1[k] = (img1[k] + (img2[k - 1] + img2[k + 1]) + img[e + 1][k]) / 4
}
img[e] = img1;
}
}
}
clock_t finish = clock();
printf("time:%f", (double)(finish - start) / CLOCKS_PER_SEC);
return 0;
}
2.面对CPU的优化部分:
使用循环展开:提高cpu的并发性,极大利用cpu流水线的特性。
#include<stdlib.h>
#include<stdio.h>
#include<time.h>
int main()
{
clock_t start = clock();
size_t** img = (size_t**)malloc(sizeof(size_t*) * 1920);//防止栈溢出
srand((size_t)time(NULL));//随机种子
size_t number = 0;
for (int i = 0; i < 1920; i++)
{
img[i] = (size_t*)malloc(sizeof(size_t) * 1080);//防止栈溢出
for (int j = 0; j < 1080; j++)
{
number = rand();
img[i][j] = number;
}
}
size_t* img1, * img2;
img1 = (size_t*)malloc(sizeof(size_t) * 1080);//动态申请内存空间,记录上一行
img2 = (size_t*)malloc(sizeof(size_t) * 1080); //动态申请内存空间,记录当前行
img2 = img[0];
for (int i = 1; i < 1919; i++)
{
img1 = img2;//记录上一行数据
img2 = img[i];//记录当前行数据
for (int j = 1; j < 1078; j = j + 3)
{
img1[j] = (img[i + 1][j] + img1[j] + img2[j + 1] + img2[j - 1]) / 4;
img1[j + 1] = (img[i + 1][j + 1] + img1[j + 1] + img2[j + 2] + img2[j]) / 4;
img1[j + 2] = (img[i + 1][j + 2] + img1[j + 2] + img2[j + 3] + img2[j + 1]) / 4;
}
img1[1078] = (img[i + 1][1078] + img1[1078] + img2[1079] + img2[1077]) / 4;
img[i] = img1;//替换当前行数据
}
clock_t finish = clock();
printf("time:%f", (double)(finish - start) / CLOCKS_PER_SEC);
return 0;
}
3.其他优化:
(1)使用 (img1[j] + (img2[j + 1] + img2[j - 1]) + img[i + 1][j])代替img[i][j] = (img[i-1][j] + img[i+1][j]+img[i][j-1] +img[i][j+1]) /4。因为(img2[k + 1] + img2[k - 1])具有良好的局部性。
(2)使用“>>2”代替“/4”,因为使用移位操作效率高于除法操作。
#include<stdlib.h>
#include<stdio.h>
#include<time.h>
int main()
{
clock_t start = clock();
size_t** img = (size_t**)malloc(sizeof(size_t*) * 1920);//防止栈溢出
srand((size_t)time(NULL));//随机种子
size_t number = 0;
for (int i = 0; i < 1920; i++)
{
img[i] = (size_t*)malloc(sizeof(size_t) * 1080);//防止栈溢出
for (int j = 0; j < 1080; j++)
{
number = rand();
img[i][j] = number;
}
}
size_t* img1, * img2;
img1 = (size_t*)malloc(sizeof(size_t) * 1080);//动态申请内存空间,记录上一行
img2 = (size_t*)malloc(sizeof(size_t) * 1080); //动态申请内存空间,记录当前行
img2 = img[0];
for (int i = 1; i < 1919; i++)
{
img1 = img2;//记录上一行数据
img2 = img[i];//记录当前行数据
for (int j = 1; j < 1079; j++)
{
img1[j] = (img1[j] + (img2[j + 1] + img2[j - 1]) + img[i + 1][j]) >> 2;
}
img[i] = img1;//替换当前行数据
}
clock_t finish = clock();
printf("time:%f", (double)(finish - start) / CLOCKS_PER_SEC);
return 0;
}
4.3 优化前后的性能测试
使用clock()函数测试:
我们引入头文件time.h,使用clock()函数获得程序开始与结束的时间,从而进行程序优化前后的性能测试。
优化前:
在原始程序中,我们通过大量测试发现运行时间基本都在0.074s到0.081s这个范围内,其中出现在0.075s到0.081s的概率最大。
优化后:
(1)面向cache的优化:我们通过大量测试发现面向cache的优化程序执行时间大多在0.062s到0.073s之间,其中主要集中在0.065s到0.071s之间。
(2)面向cpu的优化:大量测试发现面向cpu的优化程序执行时间大多在0.071s到0.075s之间,其中主要集中于0.072s到0.075s。
(3)面向一般优化的优化的优化:大量测试发现一般优化的优化程序执行时间大多在0.070s到0.075s之间,其中主要集中于0.072s到0.075s。
4.4 面向泰山服务器优化后的程序与测试结果或4.2的优化方法分析:结合自己计算机的硬件参数,分析优化后程序的各个参数选择依据。
我们借助CPU-Z先了解电脑cache的级数和各级数大小,再对整个工作集进行分组,使其能够一个块整个的放入一个缓存,优先级别是:L1>L2>L3。所以我们先从L3着手。
L3可以缓存数据大小为4MB,我们的工作集整个数据大小为1080*1920*8/1024/1024=15.82MB,所以我们如果把整个工作集按行分成4组,每组480行,这480行的数据恰好能整个放进一个L3中。
再来看L2,一个L3能放进4MB数据,L2可以缓存的数据大小为512KB,所以我们把每个小组再分成8份,就是每个小组60行,一共32组,这样的话,先一个大组480行可以被一个L3整个缓存,之后这480行中每60行被一个L2整个缓存。
最后看L1,L1可以缓存的数据大小为32KB,我们一行的数据大小为1080*8/1024=8.4375KB<32KB,所以我们每一行都可以被一个L1整个缓存,所以对于列我们不用分组。
进行完cache的优化,我们再来看对CPU的优化,平滑算法的公式为:img[i][j] = (img[i-1][j] + img[i+1][j]+img[i][j-1] +img[i][j+1]) /4。我们使用了一个循环次数为1078的for循环,我们可以进行循环展开和重新结合变换来增加这一部分的并行性,增加流水线,这样就可以减少循环次数,进而减少不必要的循环开支,提高程序性能。
4.5 还可以采取的进一步的优化方案
1.我们的程序中有多个for循环,我们可以尝试使用CPU的SIMD指令集将多次for循环计算变成一次计算。
2.我们可以使用fork()函数创建子进程,使用多线程使得CPU的多个核心同时处理任务,提升程序的并发执行性能。