摘要:華為雲專家從優化規划 / 執行 / 多進程 / 開發心理等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: https://en.wikipedia.org/wiki/Thread_(computing)
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://www.liaoxuefeng.com/wiki/1016959663602400/1017627212385376
- https://en.wikipedia.org/wiki/Thread_(computing)
- https://en.wikipedia.org/wiki/Global_interpreter_lock#:~:text=A global interpreter lock (GIL,on%20a%20multi%2Dcore%20processor.
- https://git.huawei.com/x00349737/nqutils
- https://docs.python.org/3/library/profile.html