使用 cProfile 和火焰图调优 Python 程序性能
前幾天調(diào)試程序,發(fā)現(xiàn) QPS 總是卡在 20 左右上不去。開始以為是 IO 問題,就多開了些并發(fā),然并卵,這才想到可能是 CPU 的問題。看了看監(jiān)控,發(fā)現(xiàn)程序某一步的延遲在 400ms 左右,而且這一步是 CPU 密集的。當(dāng)時開了 4 臺雙核的機器:(1s / 400ms) * 2 * 4 = 20 啊。看來需要優(yōu)化下這一步的代碼了,那么第一步就是找到可以優(yōu)化的地方。
測量程序的性能之前并沒有實際做過,Google 了一番,感覺標(biāo)準(zhǔn)庫的 cProfile 似乎值得一試。
要測量的代碼邏輯也很簡單,使用 lxml 解析 HTML,然后提取一些字段出來,這些基本都是調(diào)用的 C 庫了,解析的算法也不在 Python 中。看起來似乎沒有什么能改進(jìn)的地方,不管怎樣,還是先跑一下吧。
cProfile 有多種調(diào)用方法,可以直接從命令行調(diào)用:
python -m cProfile -s tottime your_program.py其中的?-s?的意思是 sort。常用的 sort 類型有兩個:
要獲得對程序性能的全面理解,經(jīng)常需要兩個指標(biāo)都看一下。
不過在這里,我們并不能直接使用命令行方式調(diào)用,因為我的代碼中還需要一些比較繁重的配置加載,如果把這部分時間算進(jìn)去了,多少有些干擾,那么我們也可以直接在代碼中調(diào)用 cProfile。
使用 cProfile 的代碼如下:
import cProfile, pstats, iopr = cProfile.Profile() pr.enable()extractor.extract(crawl_doc=doc, composition=PageComposition.row, rule=rule)pr.disable() s = io.StringIO() sortby = "cumtime" # 僅適用于 3.6, 3.7 把這里改成常量了 ps = pstats.Stats(pr, stream=s).sort_stats(sortby) ps.print_stats() print(s.getvalue())把需要 profile 的代碼放到 pr.enable 和 pr.disable 中間就好了。注意這里我們使用了 cumtime 排序,也就是累計運行時間。
結(jié)果如下:
我們可以看到總的運行時間是 200ms,而其中紅框內(nèi)的部分就占了 100ms! 如果能夠優(yōu)化調(diào)的話,性能一下子就能提高一倍。紅框內(nèi)的代碼是做什么的呢?我們知道解析一個 html 文檔,第一步是建立 DOM 樹,通常情況下,我們可能會從其中抽取一些鏈接。在網(wǎng)頁中,鏈接不一定是絕對路徑,也可能是?/images/2018-12-31-xxx.jpg?這樣的相對路徑。lxml 庫幫我們做了一個貼心的默認(rèn)值,那就是在構(gòu)造 DOM 樹的時候,根據(jù)傳入的 url 來吧頁面中的所有 url 都重寫成絕對路徑。看起來這是個很貼心的功能,但是在這里卻成了性能瓶頸。為什么很耗時呢?大概是因為需要遍歷整個 DOM 樹,重寫所有的鏈接吧。這顯然不是我們需要的,我們只需要把抽取之后的鏈接還原成絕對路徑就好了,而不是事先把所有鏈接都重寫一遍。所以在這里我們直接去掉這個功能就好了。
修改代碼之后,再次運行 profile 腳本,時長變成了 100ms:
這時候我們接著看,程序中下一個比較大頭的時間占用:jsonfinder 和 json decode/encode。
jsonfinder 是一個有意思的庫,它自動從 HTML 中查找 json 字符串并嘗試解析,但是有時候也不太準(zhǔn)。經(jīng)常為了找到特定的值,還是需要使用正則。那么對于這個可有可無的功能,性能有這么差,還是刪掉好了。
通過刪代碼,現(xiàn)在性能已經(jīng)是原來的四倍了。
這時候發(fā)現(xiàn)代碼里面有正則還挺花費時間的,不過還好,暫時先不管了。
剛剛都是只運行了一遍,測量結(jié)果難免有隨機性,必定有失偏頗,實際上應(yīng)該使用多個測試用例,成千上萬次的跑,才能得到一個比較準(zhǔn)確地結(jié)果。
上面這個小步驟基本沒有什么可以優(yōu)化的了,下面我們把優(yōu)化目標(biāo)擴大一點,并把次數(shù)先定為100.
下面這種圖是按照 tottime 來排序的:
注意其中最耗時的步驟是 parseUnicodeDoc,也就是建樹了,這是符合預(yù)期的,然而旁邊的 ncalls 一欄卻不太對勁了。我們明明只運行了 100 次,為什么這個函數(shù)調(diào)用了 300 次呢?顯然代碼中有重復(fù)建樹的地方,也就是有隱藏的 bug。這種地方不經(jīng)過 profile 很難浮現(xiàn)出來,因為程序本身的邏輯是對的,只是比較耗時而已。
優(yōu)化之后,終于變成了 100. 從 cProfile 的表格現(xiàn)在已經(jīng)看不出什么結(jié)果來了,下一步我們開始使用火焰圖,可視化往往能讓我們更容易注視到性能瓶頸。(為什么不一開始就用火焰圖呢?因為我以為很麻煩。。實際很簡單)
Python 中有一個第三方包(見參考文獻(xiàn))可以直接從 cProfile 的結(jié)果生成火焰圖:
然后打開 SVG 文件就可以了:
其中火焰的寬度代表了運行的時長,我們現(xiàn)在的優(yōu)化目標(biāo)就是這些耗時比較長的步驟。
可以看大其中 mysql 的訪問占了絕對的大頭,按理說跑100次的話,不應(yīng)該每次都花費時間在建立連接上啊,這里一定有問題。經(jīng)過排查發(fā)現(xiàn)在某處鏈接是使用了?close_old_connections?來保證不會拋出數(shù)據(jù)庫斷開的異常,這還是在頭條帶來的習(xí)慣。。close_old_connections 的功能是關(guān)閉已經(jīng)失效的鏈接,看來我的理解還是有誤的。先把這塊刪掉,最終解決應(yīng)該是這塊放到一個隊列里,統(tǒng)一存入數(shù)據(jù)庫。
去掉之后:
現(xiàn)在的大頭又變成 lxml 的,又動了優(yōu)化它的心思,lxml 是 libxml2 的一個 Python binding,查了下應(yīng)該是最快的 html parser 了,這塊真的沒有什么優(yōu)化空間。盯了一會兒,眼睛最終看到了一個小角落:
一個正則匹配居然占用了 8% 的運行時間,太不像話了。老早之前就聽說 Python 的標(biāo)準(zhǔn)庫正則性能不行,現(xiàn)在才發(fā)現(xiàn)原來是真的挺差勁的。Python 標(biāo)準(zhǔn)庫的 re 模塊采用的是 PCRE 的處理方式,而采用 NFA 的處理方式的正則要快很多,這塊還需要再看一下。不過眼下倒是可以直接換一個庫來解決。regex 模塊是 re 模塊的一個 drop-in replacement.
pip install regex?and?import regex as re,就搞定了
可以看到正則那塊直接消失了。提升還是很大的。時間不早了,當(dāng)天的優(yōu)化就到此結(jié)束了。上線之后,積壓一下子就下去了:
后記
要想調(diào)試的時候方便,在寫代碼的時候就要注意,盡量使自己的代碼 mock-friendly 一點。如果需要引入外部的數(shù)據(jù)庫、服務(wù)、API等等各種資源,最好有一個開關(guān)或者選項能夠不加載外部資源,或者至少能夠很方便地 mock 這些外部服務(wù),這樣方便對每一個小單元進(jìn)行 profile。
總有人吐槽 Python 的性能低下,但是 Python 本來就不是做計算任務(wù)的呀,Python 是一門膠水語言,是用來寫業(yè)務(wù)邏輯的,而不是用來寫CPU密集的算法的。事實上復(fù)雜的解析一般都會用 C++ 這種硬核語言來寫了,比如 numpy TensorFlow lxml。大多數(shù)程序員一天 90% 的工作除了和產(chǎn)品經(jīng)理撕逼以外,也就是在寫 CRUD,也就是調(diào)用這些包。所以瓶頸一般在 IO 上而不在 CPU 上,而解決 IO 的瓶頸手段就多了,Python 中至少有 多進(jìn)程、多線程、AsyncIO、Gevent 等多種方法。不過方法多其實也是一個弊端,這幾種方法可以說是基本互不兼容,對各種第三方庫的支持也參差不齊。而 Go 在這方面就做地很好了,語言直接內(nèi)置了 go 關(guān)鍵字,甚至都不支持多線程。所有的庫都是支持一個統(tǒng)一的并發(fā)模型,對于使用者來說更簡單。
Zen of Python 中有一句:There should be one way -- preferably only one way -- to do a thing. 這點上 Python 本身沒有做到,反倒是 Go 實踐地非常好。
扯遠(yuǎn)了,程序的瓶頸其實不外乎CPU、內(nèi)存和 IO 三個方面,而 cProfile 和火焰圖是判斷 CPU 瓶頸的一把利器。
后面還發(fā)現(xiàn)了一些性能瓶頸,也列在這里:
性能這個問題其實是典型的木桶理論的場景,系統(tǒng)的整體性能是由最差的一塊決定的。所以也是一個不斷迭代的過程。
祝大家新年快樂~
參考文獻(xiàn)
總結(jié)
以上是生活随笔為你收集整理的使用 cProfile 和火焰图调优 Python 程序性能的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 阿里妈妈搜索广告CTR模型的“瘦身”之路
- 下一篇: 什么是协程?