如何让程序跑得更快些?——试试Visual Studio中的性能分析工具 (By Jun Guo)

来源:互联网 发布:rnaseq数据分析流程 编辑:程序博客网 时间:2024/04/29 17:35

 咦,性能?我们又回到这个永恒的话题上了。Yep,大部分程序猿都对性能有着不懈追求。某国最喜欢“多快好省”,“多”和“省”我们是很难做到了,但让自己的程序跑得又快又好,则是我们最乐意干的活。干同样一件事情,别人的程序要跑1分钟,而自己的程序只要几秒钟,这是多爽的一件事啊(您打败了全国99%的程序猿……)!

      不过,话虽然这样说,但实际操作起来,效率优化并不是件容易的事。时间复杂度是最容易拉开效率差距的地方,但却也是最难拉开人与人之间差距的地方——毕竟很多问题的解决方案都比较成熟了,要能找到个时间复杂度更优的算法似乎不是一件容易的事情。然而,即便是复杂度相同的两份程序,由于程序常数不同,运行效率也往往有很大差异。试一下stdlib.h里的qsort,以及STL里的sort,就可以清楚地看出同样O(NlogN)的排序算法能有多大区别。不仅如此,实际程序往往还会涉及I/O、线程通信等操作,这些操作的快慢可不是靠复杂度分析就能得出结论的了。

      因此,如何在有限时间内尽可能地提高程序效率是个非常重要而复杂的问题。Visual Studio为我们提供了强大的性能分析工具,让我们能很快找出程序的性能瓶颈,从而能有针对性地改进程序常数。

        

      我们先看一个简单的例子。下述程序的功能非常简单:读入一个文本文件,统计各个单词出现的频率,并输出词频最高的100个单词。单词被简单定义为连续的大小写字母所组成的字符串,即I’m会被视为I和m两个单词。

复制代码
static void Main(string[] args){    const int MAX_WORD_NUM = 1000000;    const int BUFFER_SIZE = 100000;    const int OUTPUT_NUM = 100;     // 读入文件    StreamReader sr = new StreamReader(new BufferedStream(new FileStream(                "different.txt", FileMode.Open), BUFFER_SIZE));    string data = sr.ReadToEnd();      // 切割出单词    string[] words = Regex.Split(data, "[^a-zA-Z]");      // 统计单词词频    Dictionary<string, int> dict = new Dictionary<string, int>((int)(MAX_WORD_NUM * 1.5));    foreach (var word in words)    {        if (word == "")            continue;        if (dict.ContainsKey(word))            dict[word]++;        else            dict.Add(word, 1);    }    List<Tuple<int, string>> list = new List<Tuple<int, string>>(MAX_WORD_NUM);    foreach (var item in dict)    {        list.Add(Tuple.Create(item.Value, item.Key));    }    // 输出词频最高的前100个单词    list.Sort();    int count = 0;    for (int i = list.Count - 1; i >= 0; i--)    {        Console.WriteLine(list[i].Item2 + " " + list[i].Item1);
count
++; if (count > OUTPUT_NUM) break; } sr.Close();}
复制代码

      文本文件different.txt大约有100MB。好,现在我们来运行程序!大概过了10s,程序输出结果了。结果倒是正确的,但效率也未免太低了点(我之前的一篇博文有提到类似的词频统计程序,那个程序对320M的文本文件做词频统计大概只要4s,也就是说速度是上述程序的8倍)。OK,那上述程序的问题到底出在哪里?大家众说纷纭,有的人吐槽文件读入,因为I/O非常缓慢;有的人说是Hash表的查询与存储操作比较耗时(虽说理想情况下是O(1),但有冲突时会恶化);也有的人认为是最后的排序消耗了大量时间,毕竟其复杂度最高(不计字符串长度,其他操作是O(N),排序是O(NlogN),确实高了点)。

     

      为了阻止大家继续吐槽,我们还是来试试性能分析工具好了。

      首先要确保编译程序时采用Release编译,之后在Visual Studio 2012中选中“分析”-->“启动性能向导”,可以看到下图:     

      

      我们看到有两种性能分析方法:

  • CPU采样
  • 检测

      简单来说,CPU采样就是程序运行时,Visual Studio会定时查看当前程序正在运行哪个函数,并记录下来。当程序运行结束后,Visual Studio就会得出一个关于程序运行时间分布的大致印象。这种做法的优点是不需要改动程序,运行较快,可以很快得出性能瓶颈。但这种方法不能得出精确数据,有时可能会有误差。

      而检测则指Visual Studio会将检测代码注入到每一个函数中,这样整个程序的一举一动都将被记录在案,程序的所有性能数据都可以被精准地测量。然而这种方法会极大增加程序的运行时间,对数据的分析时间也会变得很漫长。

 

      一般来说,我们会先用CPU采样的方式找到性能瓶颈,然后对特定的模块采用检测的方法进行详细分析。由于这两者方式的操作很类似,所以下文仅展示CPU采样的用法。对上述程序进行CPU采样后,我们可以看到如下报告:

      

      点击上图中的Main函数,我们可以查看更具体的报告,如下图:

      

      在上图最右侧的“已调用函数”中点击相应函数还可以跳转到函数内各行代码的耗时统计。由于上面的函数耗时统计已经足够我进行性能优化,对我而言暂时没必要具体到代码行,这里就不再赘述了。

       可以看到,排序确实占了很多运行时间。然而,却有一个出乎我们意料的存在——Regex.Split函数居然占了将近30%的运行时间,与此对比Hash表的查询与插入操作却仅仅占了1%左右的时间,至于I/O操作的开销更是不见踪影。也许有些人在一开始也确实猜到Split函数会比较耗时,但占用30%的时间恐怕还是绝大多数人始料未及的。

 

      我们不妨着手自己实现这个Spilt函数(不要吐槽我为什么不优先改进Sort,我在这里仅仅是展示一下嘛)。代码如下:

复制代码
// 切割出单词List<string> words = new List<string>(MAX_DIFF_WORD_NUM * 10);int curPos = data.Length - 1, lastPos = curPos;while (curPos >= 0){      while (curPos >= 0 && !char.IsLetter(data[curPos]))            curPos--;      lastPos = curPos;      while (curPos >= 0 && char.IsLetter(data[curPos]))            curPos--;      words.Add(data.Substring(curPos + 1, lastPos - curPos));}
复制代码

      之后重新运行一次性能分析。

      

      嗯,这次就合理多了,整个程序的时间基本花费在Sort上(毕竟我们还没有改进Sort),I/O操作的开销开始体现出来,而Spit函数所带来的巨大开销已经减少了许多。(为什么手动实现的Split较快呢?因为Regex.Split里检测[^a-zA-Z]的开销要远远大于!char.isLetter()的开销,52次运算 vs 4次运算哦。)

 

      OK,那么接下来的优化目标显然是Sort了。如何优化相信各位算法大神肯定都很清楚,因为我们只要取词频前100的单词,所以没必要完整地做排序,用个可以容纳100个元素的最小堆滚一遍即可。具体就不再赘述了。

 

      就这样,我们可以沿着“性能分析-->改进-->再性能分析”的流程,逐步提高程序的性能和我们自己的编程水平。

      要注意一点的是,写程序时最好不要没做分析就过早地进行“性能优化”,正如上文所提到的,虽然有人提到Hash表和I/O操作会影响性能,但从性能分析的结果来看却非如此。这两者所带来的时间开销非常少。如果不经分析就盲目优化,也许只会事倍功半。

      另外还有一点要注意的是,虽然性能分析工具指明了程序各个部分的耗时,但这也不意味我们改进效率一定要优先改进耗时最多的部分。固然,改进耗时最多的部分往往能得到最明显的效果,但这并不意味耗时最多的部分很容易改进。像上文所示的Split函数,虽然其耗时并非最多,但由于其改进非常简单,有时反而会成为我们优先改进的对象。在实际项目中,我们要在改进所能得到的效果以及改进所要投入的精力之间妥协,优先完成有能力做而效果又比较明显的性能优化。

阅读全文
1 0
原创粉丝点击