咦,效能?我們又回到這個永恒的話題上了。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採樣就是程式運行時,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函數,雖然其耗時並非最多,但由於其改進非常簡單,有時反而會成為我們優先改進的對象。在實際項目中,我們要在改進所能得到的效果以及改進所要投入的精力之間妥協,優先完成有能力做而效果又比較明顯的效能最佳化。