1. 程式人生 > 實用技巧 >必須收藏:20個開發技巧教你開發高效能運算程式碼

必須收藏:20個開發技巧教你開發高效能運算程式碼

摘要:華為雲專家從優化規劃 / 執行 / 多程序 / 開發心理等20個要點,教你如何開發高效能程式碼。

高效能運算,是一個非常廣泛的話題,可以從專用硬體/處理器/體系結構/GPU,說到作業系統/執行緒/程序/並行/併發演算法,再到叢集/網格計算,最後到天河二號(TH-1)。

我們這次的分享會從個人的實踐專案探索出發,與大家分享自己摸爬滾打得出的心得體會,一如既往的堅持原創。其中內容涉及到優化規劃 / 執行 / 多程序 / 開發心理等約20個要點,其中例子程式碼片段,使用Python。

高效能運算,在商業軟體應用開發過程中,要解決的核心問題,用很白話的方式來說,“在有限的硬體條件下,如何讓一段原本跑不動的程式碼,跑起來,甚至飛起來。”

效能提升經驗

舉2個例子,隨意感受下。

(1)635萬條使用者閱讀文件的歷史行為資料,資料處理時間,由50小時,優化到15秒。(是的,你沒有看錯)

(2)基於Mongo的寬表建立,由20小時,優化到出去打杯水的功夫。

在大資料的時代,一個優秀的程式設計師,可以寫出效能比其他人的程式高出數百倍,甚至數千倍,具備這樣的技能,對產品的貢獻無疑是很大的,對個人而言,也是自己履歷上亮點和加分項。

聊聊歷史

2000年前後,由於PC硬體限制,那一代的程式設計師,比如,國內的求伯君 / 雷軍,國外的比爾蓋茨 / 卡馬特,都是可以從機器碼 / 彙編的角度來提升程式效能。

到2005年前後,PC硬體效能發展迅速,高效能優化常常聽到,來自嵌入式裝置和移動裝置。那個年代的移動裝置主流使用J2ME開發,可用記憶體128KB。那個年代的程式設計師,需要對程式大小(OTA下載,有資料流量限制,如128KB),記憶體使用都精打細算,真的是掐著指頭算。比如,通常一個程式,只有一個類,因為新增一個類,會多使用幾K記憶體。資料檔案會合併為一個,減少檔案數,這樣需要算,比如從第幾個位元組開始,是什麼資料。

2008年前後,第一代iOS / Android智慧手機上市,App可用記憶體達到1GB,App可以通過WIFI下載,App大小也可以達到一百多MB。我剛才看了下我的P30,就儲存空間而言,QQ使用了4G,而微信使用了10G。裝置效能提升,可用記憶體和儲存空間大了,程式設計師們終於“解放”了,直到–大資料時代的到來。

在大資料時代下,資料量瘋狂增長,一個大的資料集操作,你的程式跑一晚上才出結果,是常有的事。

基礎知識

本次分享假設讀者已經瞭解了執行緒/程序/GIL這些概念,如果不瞭解,也沒有關係,可以讀下以下的摘要,並記住下面3點基礎知識小結即可。

什麼是程序?什麼是執行緒?兩者的差別?

以下內容來自Wikipedia:

Threads differ from traditional multitasking operating-system processes in several ways:

  • processes are typically independent, while threads exist as subsets of a process
  • processes carry considerably more state information than threads, whereas multiple threads within a process share process state as well as memory and other resources
  • processes have separate address spaces, whereas threads share their address space
  • processes interact only through system-provided inter-process communication mechanisms
  • context switching between threads in the same process typically occurs faster than context switching between processes

著名的GIL (Global interpreter lock)

以下內容來自 wikipedia.

A global interpreter lock (GIL) is a mechanism used in computer-language interpreters to synchronize the execution of threads so that only one native thread can execute at a time.[1] An interpreter that uses GIL always allows exactly one thread to execute at a time, even if run on a multi-core processor. Some popular interpreters that have GIL are CPython and Ruby MRI.

基礎知識小結:

  • 因為著名的GIL,為了執行緒安全,Python裡的執行緒,只能跑在同一個CPU核,無法做到真正的並行
  • 計算密集型應用,選用多程序
  • IO密集型應用,選用多執行緒

實踐要點

以上都是一些鋪墊,從現在開始,我們進入正題,如何開發高效能程式碼。

一直以來,我都在思考,如何做有效的分享?首先,我堅持原創,如果同樣的內容可以在網路上找到,那就沒有分享的必要,浪費自己和其他人的時間。其次,對不同的人,採用不同的方法,講不同的內容。

所以,這次分享,聽眾大都是有開發經驗的python程式設計師,所以,我們不在一些基礎的內容上花太多時間,不瞭解也沒關係,下來自已看看也都能看懂。這次我們更多來從實踐問題出發,我總結了約20個要點和開發技巧,希望能對大家今後的工作有幫助。

規劃和設計儘可能早,而實現則儘可能晚

接到一個專案時,我們可以先識別下,哪些部分可能會出現效能問題,做到心裡有數。在設計上,可以早點想著,比如,選用合適的資料結構,把類和方法設計解耦,便於將來做優化。

在我們以前的專案中,見過有些專案,因為早期沒有去提前設計,後期想優化,發現改動太大,風險非常高。

但是,這裡一個常見的錯誤是,上來就優化。在軟體開發的世界裡,這點一直被經常提起。我們需要控制自己想早優化的心理,而應優先把大框架搭起來,實現主要功能,然後再考慮效能優化。

先簡單實現,再評估,做好計劃,再優化實施

評估改造成本和收益,比如,一個模組費時一小時,如果優化,需要花費開發和測試時間3小時,可能節省30分鐘,效能提升50%;另一模組,費時30秒,如果優化,開發和測試需要花費同樣的時間,可以節省20秒,效能提升67%。你會優先優化哪個模組?

我們建議優先考慮第一個模組,因為收益更大,可節省30分鐘;而第二個模組,費時30秒,不優化也能接受,應該把優化優先順序放到最低。

另一個情況,如第2個模組被其它模組高頻呼叫,那我們又要重新評估優先順序。

優化時,我們要控制我們可能產生的衝動:優化一切能優化的部分。

當我們沒有“錘子”時,我們遇到問題很苦惱,缺乏技能和工具;但是,當我們擁有“錘子”時,我們又很容易看一切事物都像“釘子”。

開發除錯時,使用Sampling資料,並配合開關配置

開發時,對費時的計算,可以設定sampling引數,調動時,傳入不同的引數,既可以快速測試,又可以安全管理除錯和生產程式碼。千萬不要用註釋的方式,來開/關程式碼。

參考以下示意程式碼:

	# Bad
	def calculate_bad():
	    # uncomment for debugging
	    # data = load_sampling_data()
	    data = load_all_data()
	 
	# Good
	def calculate(sampling=False):
	    if sampling:
	        data = load_sampling_data()
	    else:
	        data = load_all_data()

梳理清楚資料Pipeline,建立效能評估機制

我自己寫了個Decorator@timeit可以很方便地列印程式碼的用時。

	@timeit
	def calculate():
	    pass

這樣生成的log,菜市場大媽都看的懂。上了生產後,也可以通知配置來控制是否列印。

[2020-07-09 14:44:09,138] INFO: TrialDataContainer.load_all_data - Start
...
[2020-07-09 14:44:09,158] INFO: preprocess_demand - Start
[2020-07-09 14:44:09,172] INFO: preprocess_demand - End - Spent: 0.012998 s
...
[2020-07-09 14:44:09,186] INFO: preprocess_warehouse - Start
[2020-07-09 14:44:09,189] INFO: preprocess_warehouse - End - Spent: 0.002611 s
...
[2020-07-09 14:44:09,454] INFO: preprocess_substitution - Start
[2020-07-09 14:44:09,628] INFO: preprocess_substitution - End - Spent: 0.178258 s
...
[2020-07-09 14:44:10,055] INFO: preprocess_penalty - Start
[2020-07-09 14:44:20,823] INFO: preprocess_penalty - End - Spent: 10.763566 s

[2020-07-09 14:44:20,835] INFO: TrialDataContainer.load_all_data - End - Spent: 11.692677 s
[2020-07-09 14:44:20,836] INFO: ObjectModelsController.build - Start
[2020-07-09 14:44:20,836] INFO: ObjectModelsController.build_penalties - Start
[2020-07-09 14:44:20,836] INFO: ObjectModelsController.build_penalties - End - Spent: 0.000007 s
[2020-07-09 14:44:20,837] INFO: ObjectModelsController.build_warehouses - Start
[2020-07-09 14:44:20,848] INFO: ObjectModelsController.build_warehouses - End - Spent: 0.011002 s

另外,Python也提供了Profiling工具,可以用於費時函式的定位。

優先處理資料讀取效能

一個完整的專案,可能會有很多效能提升的部分,我建議,優先處理資料讀取,原因是,問題容易定位,修改程式碼相對獨立,見效快。

舉例來說,很多機器學習專案,都需要建立資料樣本資料,用於模型訓練。而資料樣本的建立,常通過建立一個寬表來實現。很多DB都提供了很多提升操作效能的方法。假設我們使用MongoDB,其提供了pipeline函式,可以把多個數據操作,放在一個語句中,一次傳給DB。

如果我們粗暴地單條處理,在一個專案中我們試過,需要近20個小時,花了半天的時間來優化,跑起來,離開座位去接杯水,回來就已經跑完了,費時降為1分鐘。

注意,很多時候我們沒有動力去優化資料讀取的效能,因為資料讀取可能次數並不多,但事實上,特別是在試算階段,資料讀取的次數其實並不少,因為我們總是沒有停止過對資料的改變,比如加個欄位,加個特徵什麼的,這時候,資料讀取的程式碼就要經常被用到,那麼優化的收益就體現出來了。

再考慮降低時間複雜度,考慮使用預處理,用空間換時間

我們如果把效能優化當做一桌宴席,那麼可以把資料讀取部分的效能優化,當作開胃小菜。接下來,我們進入更好玩的部分,優化時間複雜度,用空間換時間。

舉例來說,如果你的程式的複雜度為O(n^2),在資料很大時,一定會非常低效,如果能優化為複雜度為O(n),甚至O(1),那就會帶來幾個資料級的效能提升。

比如上面提到的,使用倒排表,來做資料預處理,用空間換時間,達到從50小時到15秒的效能提升。

因著名的GIL,使用多程序提升效能,而非多執行緒

在Python的世界裡,由於著名的GIL,如果要提升計算效能,其基本準則為:對於I/O操作密集型應用,使用多執行緒;對於計算密集型應用,使用多程序。

一個多程序的例子:

我們準備了一個長陣列,並準備了一個相對比較費時的等差數列求和計算函式。

	MAX_LENGTH = 20_000
	data = [i for i in range(MAX_LENGTH)]
	 
	def calculate(num):
	    """Calculate the number and then return the result."""
	    result = sum([i for i in range(num)])
	    return result

單程序執行例子程式碼:

	def run_sinpro(func, data):
	    """The function using a single process."""
	    results = []
	    
	    for num in data:
	        res = func(num)
	        results.append(res)
	        
	    total = sum(results)
	    
	    return total
	 
	%%time
	result = run_sinpro(calculate, data)
	result

CPU times: user 8.48 s, sys: 88 ms, total: 8.56 s
Wall time: 8.59 s

1333133340000

從這裡我們可以看到,單程序需要 ~9 秒。

接下來,我們來看看,如何使用多程序來優化這段程式碼。

	# import multiple processing lib
	import sys
	 
	from multiprocessing import Pool, cpu_count
	from multiprocessing import get_start_method, \
	                            set_start_method, \
	                            get_all_start_methods
	 
	def mulp_map(func, iterable, proc_num):
	    """The function using multi-processes."""
	    with Pool(proc_num) as pool:
	        results = pool.map(func, iterable)
	        
	    return results
	 
	def run_mulp(func, data, proc_num):
	    results = mulp_map(func, data, proc_num)
	    total = sum(results)
	    
	    return total
	 
	%%time
	result = run_mulp(calculate, data, 4)
	result

CPU times: user 14 ms, sys: 19 ms, total: 33 ms
Wall time: 3.26 s

1333133340000

同樣的計算,使用單程序,需要約9秒;在8核的機器上,如果我們使用多程序則只需要3秒,耗時節省了 66%。

多程序:設計好計算單元,應儘可能小

我們來設想一個場景,假設你有10名員工,同時你有10項工作,每項工作中,都由相同的5項子工作組成。你會如何來做安排呢?理所當然的,我們應該把這10名員工,分別安排到這10項工作中,讓這10項工作並行執行,沒毛病,對吧?但是,在我們的專案中,如果這樣來設計平行計算,很可能出問題。

這裡是一個真實的例子,最後效能提升的效果很差。原因是什麼呢?(此處可按Pause鍵,思考一下)

主要的原因有2個,並行的計算單元顆粒度不應太大,大了以後,通常會有資料交換或共享問題。其次,顆粒度大了以後,完成時間會差別比較大,形成短板效應。也就是,顆粒度大了以後,任務完成時間可能會差別很大。

在一個真實的例子中,平行計算需要1個小時,最後分析後才發現,只有一個程序需要1小時,而其他程序的任務都在5分鐘內完成了。

另一個好處是,出錯了,好定位,程式碼也好維護。所以,計算單元應儘可能小。

多程序:避免程序間通訊或同步

當我們把計算單元設計的足夠小後,應該儘量避免程序間通訊或同步,避免造成等待,影響整體執行時間。

多程序:除錯是個問題,除了log外,嘗試gdb / pdb

平行計算的公認問題是,難除錯。通常的IDE只可以中斷一個程序。通過列印log,並加上pid,來定位問題,會是一個比較好的方法。注意,平行計算時,不要打太多log。如果你按照上面講的,先調通了單程序的實現,那麼這時,最重要是,列印程序的啟動點,程序資料和關閉點,就可以了。比如,觀測到某個程序拖了大家的後腿,那就要好好看看那個程序對應的資料。

這是個細緻活,特別是,當多程序啟動後,可能跑著數小時,你也不知道在發生什麼?可以使用linux下的top,或windows下的activity等工具來觀測程序的狀態。也可以使用gdb / pdb這樣的工具,進入某個程序中,看看卡在哪裡。

多程序:避免大量資料作為引數傳輸

在真實的專案中,我們設計的計算單元,不會像上面的簡單例子一樣,通常都會帶有不少引數。這時需要注意,當大資料作為引數傳輸時,會導致記憶體消耗很大,並且,子程序的建立也會很慢。

多程序:Fork? Spawn?

Python的多程序支援3種模式去啟一個程序,分別是,spawn, fork, forkserver。他們之間的差別是啟動速度,和繼承的資源。spawn只繼承必要的資源,而fork和forkserver則與父程序完全相同。

依賴於不同的作業系統,和不同版本的python,其預設模式也不同。對python 3.8,Windows預設spawn;從python 3.8開始,macOS也預設使用spawn;Unix類OS預設fork;fork和forkserver在windows上不可用。

靈魂拷問:多程序一定比單程序快嗎?

講到這裡,我們的分享基本可以結束了,對吧?按照python multiprocessing API,找幾個例子,並參考我上面說的幾點,能解決80%以上的問題。夠了,畢竟效能優化也不是天天需要。以下內容可能要從事效能優化一年後,才會思考到,這裡寫出來,供參考,幫助以後少走些彎路。

比如,多程序一定更快嗎?

正如第一點所說,任何優化都有開銷。當多程序解決不了你的問題時,別忘了試試,改回單程序,說不定就解決了。(這也是一個真實的例子,花了2周去優化一個,10程序也需要3小時才能執行完的程式,改回單程序後,直接跑進30分鐘內了。)

優化心理:手裡有了錘子,一切都長的像釘子

同上要點,有時候需要的,可能是優化資料結構,而不是多程序。

優化心理:不要迷信“專家”

相信很多團隊都這樣,當專案遇到重大技術問題,比如效能需要優化,管理者都會召集一些專家來幫忙。根據我的觀察,80%的情況下,沒有太多幫助,有時甚至更糟。

原因很簡單,用一句話來說,你花了20個小時解決不了的問題,其他人用5分鐘,根據你提供的資訊,指出問題所在,可能性很低,無論他相關的經驗有多麼豐富。如果不信,你可以回想下自己的經驗,或將來注意觀察下,再回過頭來看這個觀點。為什麼可能更糟?因為依賴心理。有了專家的依賴,人們是不會真拼的,“反正有專家指引”。就像尼采說過,“人們要完成一件看似不可能的事時,需要鼓脹到超過自己的能力。”,所以,如果這件事真的很難,你“瘋狂”地相信,“這件事只有你能解決,只能靠你自己,其他人都無法解決”,說不定效果更好。

在一個持續近一個月的效能優化專案中,我腦海中時常響起《名偵探柯南》中的一句臺詞:真相只有一個。我堅定無比地相信,解法離我越來越近,哪怕事實是,一次又一次地失敗,但這份信念到最後的成功幫助很大。

優化心理:優化可能是一個長期過程,每天都在迷茫中掙扎

效能優化的過程,漫長而煎熬,如果能有一個耐心的聽眾,會幫助很大。他/她可能不會幫你指出問題的解決辦法,只是耐心地聽著,只說,“it will be fine.” 但這樣的述說,會幫助理清思路,能靈感迸發也說不定。這跟生活中其它事情的道理,應該也是一樣的吧。

優化心理:管理者幫助爭取時間,減輕心理壓力

比如,有經驗的管理者,會跟業務協商,分階段交付。而有些同學,則會每隔幾小時就過來問下,“效能有提升嗎?” 然後臉上露出一種詭異的表情:“真的有那麼難?”

目前我所有知道的一個案例,其效能優化持續了近一年,期間幾撥外協人員,來了,又走了,搞得奔潰。

所以,我們呼籲,專案管理者應該多理解開發人員,幫助開發人員擋住外部壓力,而不是直接透傳壓力,或者甚至增大壓力。

References

https://baike.baidu.com/item/高效能運算

  • https://en.wikipedia.org/wiki/Global_interpreter_lock#:~:text=A global interpreter lock(GIL,on%20a%20multi%2Dcore%20processor.

點選關注,第一時間瞭解華為雲新鮮技術~