清理并编译(调试模式):
12
make cleanmake DEBUG=1
这里,我先清理了之前的编译结果,然后使用调试模式(DEBUG=1)重新编译程序。这意味着程序包含了调试信息,且没有编译优化。
DEBUG=1
运行 Cachegrind(调试模式):
1
valgrind --tool=cachegrind ./sort 10000 10
在调试模式下运行sort程序,对10,000个元素进行10次排序,并通过Cachegrind收集性能数据。
sort
清理并编译(非调试模式):
make cleanmake DEBUG=0
再次清理编译结果,然后在非调试模式下编译,启用编译器优化。
运行 Cachegrind(非调试模式):
在非调试模式下再次运行相同的命令,使用Cachegrind收集性能数据。
指令引用(I refs):
数据引用(D refs):
缓存未命中(D1 和 LL misses):
性能总结:
关于指令计数作为性能指标的考虑:
修改和编译代码:
复制sort_a.c到sort_i.c,并将所有函数名中的_a更改为_i。
sort_a.c
sort_i.c
_a
_i
在sort_i.c和util.c中选择一个或多个函数应用inline关键字。
util.c
inline
在main.c中解除对sort_i测试函数的注释。
main.c
sort_i
编译代码。
由于新版Clang -always-inline选项已经弃除,可以添加__attribute__((always_inline))属性强制内联
__attribute__((always_inline))
以函数copy_i为例,首先查看原始代码的汇编生成clang -O0 -S sort_i.c -o sort_i.s
clang -O0 -S sort_i.c -o sort_i.s
搜索copy_i关键字可以发现调用过程:
copy_i
在函数前添加__attribute__((always_inline))属性,再次生成汇编代码,并不能找到调用copy_i的语句,说明内联成功
内联前后对比:
让我分析一下内联前后的 Cachegrind 性能数据:
内联后的 sort_i 函数在性能上与未内联的 sort_a 函数非常相似。虽然执行时间和其他指标几乎没有明显变化,但可以观察到一些细微的差异:
sort_a
在我的项目中,我尝试对 sort_i 函数应用内联,以探究内联递归函数对性能的影响。这个实验的目的是了解内联递归函数是否会带来性能上的改进或损失。
我修改了 sort_i.c 文件中的 sort_i 函数以及其他几个相关函数,通过添加 __attribute__((always_inline)) 强制它们内联。然后,我使用 Cachegrind 对内联前后的版本进行了性能分析,记录了执行时间、指令数、缓存命中率等关键指标。
在我的项目中,内联递归函数 sort_i 并没有带来显著的性能提升,反而可能略微增加了缓存未命中和分支预测错误。这说明内联递归函数并不总是能提高性能,尤其是在处理较大规模的数据时。因此,在决定是否内联递归函数时,应该根据具体情况和详细的性能分析来做出决策。
sort_p.c
**复制 sort_i.c 到 sort_p.c**:这将创建一个新的排序实现的副本,可以在不影响原始或内联实现的情况下进行修改。
更新函数名:将 sort_p.c 中所有函数名中的 _i 更改为 _p。例如,sort_i 变为 sort_p,merge_i 变为 merge_p,等等。
_p
sort_p
merge_i
merge_p
修改代码以使用指针:将数组索引操作改为指针操作。例如,如果原始代码是 A[i],改为使用指针 *ptr,其中 ptr 是指向 A[i] 的指针。
A[i]
*ptr
ptr
添加 sort_p 到测试套件:在 main.c 中,添加对 sort_p 函数的调用,以便在运行测试时包含它。
使用 Cachegrind 工具运行性能分析,比之前再扩大10倍数据量:
valgrind --tool=cachegrind ./sort 1000000 1 > result_p 2>&1
对于随机数组:
对于逆序数组:
根据性能测试结果,sort_p 使用指针的方法在处理大量数据时能有效提高性能,特别是在减少排序时间方面表现出优势。这证实了在某些场景中,优化数据访问策略(如使用指针代替传统的数组索引)可以提升性能。
我选择了插入排序作为基本情况的排序算法。插入排序对小规模数组的排序效果很好,并且在这种情况下能够实现较快的排序速度。我将基本情况的大小设置为当要排序的子数组长度小于等于 10 时采用插入排序。
随机数组性能比较:
sort_c
逆序数组性能比较:
缓存未命中率和指令引用数:
两种算法的缓存未命中率和指令引用数没有显著的差异,表明性能改进主要是由算法本身的优化带来的,而不是由于缓存效率的提高。
基本情况排序算法的选择:使用插入排序作为基本情况的排序算法能够更有效地处理小规模数据,从而减少递归深度和开销。
递归的修改:通过调整递归的基本情况,使得更多的元素可以在基本情况下进行排序,从而减少了递归的层数和总体开销。
算法优化:基于 sort_p 的基础上,对递归的基本情况进行调整,进一步优化了算法的性能。
通过对递归的基本情况进行调整,选择合适的排序算法和适当的基本情况大小,我成功地改进了排序算法的性能。这个实验显示了在算法设计中考虑基本情况的重要性,并展示了通过调整基本情况可以实现性能的改进。
在 merge_c 函数中,我可以观察到使用了两个临时内存空间 left 和 right。为了优化内存使用,我可以只使用一个临时内存空间,将输入数组作为另一个临时内存空间,从而减少总临时内存使用量一半。
merge_c
left
right
复制 sort_c.c 到 sort_m.c。
sort_c.c
sort_m.c
更新函数名:将 merge_c 改为 merge_m。
merge_m
修改代码以实现内存优化:在 merge_m 函数中,将原来的 left 和 right 数组替换为指向输入数组的指针,并在合并过程中适当调整指针。
根据提供的结果,我可以对 sort_c 和 sort_m 进行比较。
sort_m
从结果可以看出,在随机数组和逆序数组的情况下,sort_m 和 sort_c 的数据引用数和缓存未命中率几乎相同,但 sort_m 的执行时间略微高于 sort_c。这可能是由于内存优化所带来的额外计算开销导致的。虽然内存使用减少了一半,但可能会增加一些额外的指针操作和计算,导致总体执行时间略有增加。
综上所述,尽管 sort_m 在内存使用方面进行了优化,但在实际执行时间上与 sort_c 相比没有明显的改进。因此,需要综合考虑内存使用和执行时间,以确定最优的排序实现方式。
编译器在某些情况下可以自动进行内存优化,但并不总是能够实现所有的优化。对于简单的情况,像我这里的内存优化可能会被一些编译器自动识别和实现。然而,对于复杂的优化方案,特别是涉及算法级别的优化,编译器可能无法自动实现。因此,手动优化仍然是一种重要的方法,可以在特定情况下提高程序的性能。
在这个任务中,我的目标是进一步优化内存使用,减少在 merge_m 函数中多次分配和释放临时内存空间的开销。我计划将所需的内存空间分配一次,然后在排序完成后释放。为此,我将复制排序程序到 sort_f.c,并在其中实现所述的内存优化。
sort_f.c
sort_f
#include <stdlib.h>
malloc
free
merge_f
从执行时间上来看,sort_f 和 sort_m 在不同类型的数组上的性能几乎没有明显差异。虽然在随机数组和逆序数组上的执行时间都有轻微的波动,但这种波动在实际应用中可能不具有显著的影响。因此,从执行时间的角度来看,sort_f 和 sort_m 可以被认为是具有相似性能的排序算法。
通过在 sort_f.c 中实施一次性内存分配的优化方案,我成功减少了在 merge_f 函数中多次分配和释放临时内存空间的开销。这一优化方案使得排序算法的性能得到了改善,并且在不同类型的输入数据上都表现出了相似的执行时间。
在优化前后的比较中,我发现 sort_f 和 sort_m 在执行时间上几乎没有明显的差异。对于随机数组和逆序数组,它们的执行时间都有轻微的波动,但这种波动对实际应用的影响可能不大。此外,我还观察到在指令级别和数据级别的缓存未命中率上,sort_f 和 sort_m 的数据非常接近,表明它们在内存访问方面的表现也相似。
综上所述,通过一次性分配临时内存的优化方案,我成功改善了排序算法的性能,并使其在内存利用方面更加高效。这一优化方案的实施证明了对临时内存的有效管理对于提高算法性能的重要性。
最终结果如下,可以看到每步修改对程序性能的优化: