工具篇
tensorboard的使用
graph的可視化, 以及獲取必要的運行時的統(tǒng)計數(shù)據(jù), 請參考: 官方教程, 通過對graph以及運行時的統(tǒng)計數(shù)據(jù)的可視化,我們可以看看了解更多的更加直觀的信息. 下圖是一個例子:
運行時的統(tǒng)計信息統(tǒng)計的是每一個step(或者一次運行)過程中, 每個op的耗時. 結(jié)合compute time 圖, 我們可以分析一個圖中不同的op大概的耗時是什么樣子的, 這樣可以定位出熱點, 針對性的優(yōu)化. 通過結(jié)合device圖, 觀察在不同的device上op是怎么分布的, tensor是怎么在不同device上流動的, 是否有跨設(shè)備的大的tensor流動. 結(jié)合memory圖, 我們可以看看內(nèi)存/顯存的使用情況.
這個圖可以給出一個定性的觀察, 讓你確定真實的model和你心目中的model沒有太大的差別. 如果發(fā)現(xiàn)了一些預(yù)料之外的數(shù)據(jù)流動, 或者預(yù)料之外的耗時的點, 又或是一些op的device不如所期望, 那么, 你可能發(fā)現(xiàn)了一個優(yōu)化的點.
這個圖的問題在于, 不夠細致和全面. 比如, 這個圖里面就沒有體現(xiàn)不同設(shè)備之間的數(shù)據(jù)的send/recv的op和耗時, 不同設(shè)備的帶寬不同, 所以僅僅從tensor大小并不足以體現(xiàn)時間的消耗. 也沒有辦法分辨出哪些op是并行的. 在這種情況下, 去找整個運行的關(guān)鍵路徑比較麻煩. 如果想要知道這些op在不同設(shè)備上是怎么依次執(zhí)行的, 就需要timeline的幫助.
timeline的使用
timeline是一個時序圖, 將每個op的運行開始時間, 結(jié)束時間, 運行設(shè)備, op的前置op, 后置op都在圖上列舉出來了. 怎樣在代碼里面生成timeline信息, 請參考HowTo profile TensorFlow.
下面以單機的一個timeline的例子來說明怎么看timeline圖:
從上圖可以看出, 這個step耗時約為1400ms. 其中,gpu的利用率比較低, 因為gpu只有一小段時間在運行計算. cpu的主要的一個耗時的操作為一個叫InTopK的op, 這個op消耗了大部分的時間. 里面還有一些Recv op, 這些op的意思是從別的設(shè)備(不同的機器/同一臺機器不同的設(shè)備, 在這里是從內(nèi)存)接收數(shù)據(jù), 這種op雖然顯示耗時很長, 但是因為這些op都是從step的一開始就運行, 然后等待數(shù)據(jù)ready, 所以在timeline上的耗時并不一定是真實的接收數(shù)據(jù)的耗時, 而是 等待+接收數(shù)據(jù)
的耗時. 另一個需要注意的是名為:/job:localhost/replica:0/task:0/gpu:0 compute
的信息, 這個部分雖然標記了gpu, 它實際上是運行在cpu上, 作用是把運算發(fā)送到gpu上計算. 真是的gpu計算在/gpu:0/stream:all compute
部分.
點擊一個op, 比如圖中的QueueDequeueManyV2, 我們可以得到這個op的信息(注意下圖和上面的圖不是同一個timeline). 見下圖.
timeline還有一些其他的操作, 比如不同的模式下的操作,比如選擇一個區(qū)間看這個區(qū)間的總的op耗時等等. 這些大家可以點擊timeline窗口右上角的問號查看幫助, 慢慢探索.
對于一個timeline:
- 我們可以先看看整個timeline的時間消耗, 這個時間應(yīng)該和我們通過代碼測量出的時間差不多, 如果這個時間和代碼中測量的每個step的平均時間差得比較遠, 那說明這個step里面可能包括了一些普通的step里面沒有的東西. 比如我們每隔一些step會做summary和evaluation, 如果timeline的step正好是這種step, 測量出的時間就會偏差較大. 一般的,盡量讓timeline所在的step和普通step的是一樣的, 便于發(fā)現(xiàn)問題.
- 查看看看cpu和gpu的使用情況. 在整個step中, 了解cpu和gpu的運算大概運行了多少時間. 因為gpu的高效性, 一般情況下, 盡可能的優(yōu)先利用gpu, 提高gpu的使用率. 下面是一個好的例子, gpu的利用率很高, cpu完成必要的操作, 數(shù)據(jù)io和計算并行良好.
在cpu或者gpu上, 因為會存在op并行的情況, 我們可以大概看一下哪些op是關(guān)鍵路徑, 哪些op的耗時比較多. 對于常規(guī)的任務(wù)而言, 大體可以分為io和計算兩部分. io包括數(shù)據(jù)io和網(wǎng)絡(luò)通信等, 而計算部分則是各種計算操作. 可以先了解這兩部分的耗時, 做到心里有數(shù).
單機優(yōu)化例子
這個部分, 記錄了我們借助timeline, 對于一個單機的模型進行優(yōu)化的情況.
優(yōu)化IO
下圖是模型的初始timeline.
從上圖可以看出, 一個step的耗時約為300ms.這個和我們代碼測量出的是一致的. 說明這個timeline可以反映普遍的情況.
觀察timeline可以看到:
QueueDequeueMany耗時占比超過90%(277/300, 近似計算). 而gpu和gpu上的計算在這個op完成之后才開始進行. QueueDequeueMany可以近似的認為是io讀取數(shù)據(jù), 所以在這個timeline中, 實際的cpu的計算和gpu的計算耗時比較少, io是一個瓶頸. 針對這個問題, 我們進行了數(shù)據(jù)讀取的優(yōu)化. 優(yōu)化后的timeline如下:
雖然QueueDequeueMany看起來在timeline中的占比還是很大, 但是整體的耗時變成了約100ms, QueueDequeueMany操作時間優(yōu)化到了77ms. timeline的結(jié)果和代碼測量是結(jié)果也是相符的.
可以看到, 通過減少關(guān)鍵路徑的耗時, 我們將性能提升了100%.
避免feeddict
一般的, 使用feeddict的方式, 會將所有的輸入數(shù)據(jù)先全部讀取到內(nèi)存中, 然后在feed進去, 執(zhí)行計算. 這樣, 數(shù)據(jù)io和計算被完全分開了, 所有的計算都必須在數(shù)據(jù)讀取完成之后才能進行. 但是如果全部交給tf調(diào)度, tf會嘗試將各種op做一定程度的并行, 這會帶來性能的提升, 也就是數(shù)據(jù)讀取的操作和其他的操作其實可以有一定程度上的并行(尤其是有多種輸入數(shù)據(jù)的時候). 從別人的經(jīng)驗來看, 不使用feeddict可以帶來一定幅度的性能提升, 這是一個優(yōu)化feedict帶來30%+性能提升的例子. 在我們的實踐中, 避免使用feeddict方式也會帶來一些性能的提升, 但是幅度沒有那么高.
注意summary的耗時
這也是新手常犯的一個錯誤, 因為新手對于tf不熟悉, 所以并不太清楚不同的函數(shù)會添加什么op. 而summary在對于一些變量做summary的時候, 會對相應(yīng)的變量進行計算. 因為最后運行的時候一般會把所有的sumamry打包運行, 所以通過run的參數(shù)看不出你做了哪些summary. 你需要確保這些操作是你預(yù)料之中的. 比如在我們的情況中, 我們遇到了一個非常耗時的inTopK操作:
而這個InTopK操作只有在計算recall的時候才會有, 而我們無意中把recall加了summary. 因為我們在計算loss的時候會做summary, 所以每次都帶上了計算recall. 在tf的程序中, 很多的地方都會加summary, 便于在tensorboard中做可視化, 但是稍微不注意, 就可能執(zhí)行很多你意料之外的op.
根據(jù)應(yīng)用量體裁衣優(yōu)化
對于使用雙gpu組成塔式結(jié)構(gòu), 一個常見的行為是將變量定義在cpu上, 然后在cpu上做梯度的Average. 很多的網(wǎng)上的代碼都是這個套路. 但是實際上, 根據(jù)模型的不同, 這個經(jīng)驗并不一定是最優(yōu)的. 在我們的模型中,我們發(fā)現(xiàn),當模型的參數(shù)變大之后, 如果適度把一些變量和計算挪動到gpu上, 那么可以省下非尘唬可觀的時間. 下面是不同方案每個batch的時間.
下面兩個分別是cpu方案和gpu方案的timeline.可以看到, 原來的方案中, 會在cpu上會做大量的運算.
優(yōu)化后, cpu上的運算被挪動到了gpu上, gpu的計算耗時遠遠小于gpu, 而整體的耗時也因此得到了優(yōu)化.
對特定的應(yīng)用和模型, 需要識別出相應(yīng)的熱點, 合理安排運算的次序和位置. 對于網(wǎng)上的代碼和經(jīng)驗,在使用的同時, 心里多帶個問號.
分布式的優(yōu)化例子
相對于單機的程序, 分布式的程序除了優(yōu)化單機效率之外, 另外一個重點是優(yōu)化分布式通信. tensorflow的grpc通信本身存在一定的性能上的問題,參看這個issue, 但是很多的應(yīng)用的慢并不一定是因為這個原因?qū)е碌男阅軉栴}. 所以不要覺得分布式的性能慢是理所當然的, 除非你能確定問題是因為底層的grpc引起的(即使這樣,也有優(yōu)化的途徑), 否則, 不要讓這種成見影響了你對整體的理解和性能的追求.
在我們的實踐中, 我們針對我們模型采用between-graph方式做了分布式, 這里, 我們把關(guān)注點放在了對于通信的優(yōu)化. 通過控制通信,我們達到了和單機一樣的性能和線性的加速比. 從下圖可以看到,采用10個ps, 10個worker的情況下, 相比起單機獲得了10倍的計算加速.
以下是實踐中的一些經(jīng)驗.
每個worker使用多個GPU
為了減少通信, 如果每個機器上有多個個gpu, 因為gpu與cpu的帶寬遠遠大于網(wǎng)絡(luò)帶寬, 所以讓每個worker使用多個gpu有助于降低通信開銷, 提升性能. 在我們的例子中, 每個機器有兩個gpu, 所以我們采取了每個worker使用兩個GPU的方式, 這樣10個worker可以利用20張卡, 而機器之間的通信開銷可以減少一半. 同時我們保持ps和worker的比例為1:1. 這樣, 對worker和ps, 分布式帶來的額外的通信的壓力基本上是一樣的, 避免出現(xiàn)瓶頸. 對于每個變量, 使用partitioner進行分片, 使得ps的負載更為均衡.
push和pull優(yōu)化
每個step, 因為變量定義在ps上, 所以理論情況下, 每個worker需要將特定的變量從ps, pull到本地進行計算; 計算好的梯度需要push到ps上. 對于這個push和pull操作, 需要大概清楚它的通信量的大小, 做到心中有數(shù). 你可以利用你的知識和你對模型的理解, 將這個通信量降低.
pull優(yōu)化.
在下面的timeline中, 可以看到,ps和worker的耗時都非常長.
進一步細看可以發(fā)現(xiàn), gpu的計算在數(shù)據(jù)io完成之后遲遲不能開始.
進一步結(jié)合ps上的數(shù)據(jù)發(fā)送和估計數(shù)據(jù)的大小, 可以發(fā)現(xiàn), 時間消耗在從ps接受數(shù)據(jù)上.
除此之外, 我們還發(fā)現(xiàn)一個現(xiàn)象, 就是我們觀察到分布式每個step的worer和ps的通信耗時隨著batchsize的變大而變大(這里需要一點計算和假設(shè)去分離各種時間), 而導(dǎo)致整個計算非常緩慢. 這個與直覺不相符, 因為每次通信應(yīng)該獲取的數(shù)據(jù)應(yīng)該是常量才對, 就算batchsize變大也不應(yīng)該劇烈變化.
我們查看了一下相關(guān)的實現(xiàn)源碼, 發(fā)現(xiàn)在tf的一些函數(shù)中,比如embeding_lookup中,會要求一些op和變量定義到同樣的device上, 這是tf的優(yōu)化操作, 這個優(yōu)化會無視外層的device placement. 這種優(yōu)化導(dǎo)致每次從PS獲取的數(shù)據(jù)量并不是固定的, 而是和batchsize相關(guān).
在embedding_lookup函數(shù), 它有一個params參數(shù), 也就是我們的embedding tensor, 我們會從這個大的tensor中, 根據(jù)ids, 選出一部分tensor進行計算.
embedding_lookup(
params, # 我們的embedding tensor.
ids,
partition_strategy="mod",
name=None,
validate_indices=True, # pylint: disable=unused-argument
max_norm=None)
根據(jù)這個函數(shù)的實現(xiàn)代碼, 它會要求params(embedding tensor)和相應(yīng)的gather op定義到同一個device上.
在分布式情況下,也就是PS上. 這樣做的目的是優(yōu)化通信, 因為一般情況下, embedding tensor非常大, 但是每次需要lookup取出的tensor在一般情況下比較小, 所以這個操作放到PS上, 使得最后傳輸?shù)絯orker上的數(shù)據(jù)量會變小很多.
但是我們發(fā)現(xiàn)在我們的模型中, 這個優(yōu)化變成了一個負優(yōu)化. 因為我們每次需要取出 $batchsize * 10$ 個向量, embedding tensor的大小和batchsize差不多(我們用了比較大的batchsize), 所以這個tf的優(yōu)化操作導(dǎo)致通信的代價增加了10倍,而且會隨著batchsize的增大而增大. 下面是相應(yīng)的timeline.
從這個圖上可以看出,recv耗時較長. 但是因為recv在timeline上的耗時并不一定是是真實的耗時,有可能是在等待. 所以這個耗時長只是一個疑點, 沿著這個線索, 通過分析ps的send發(fā)生的時間, 結(jié)合我們模型的數(shù)據(jù)大小, 計算通信的開銷之后我們發(fā)現(xiàn), 這個Recv確實很耗時, 通信開銷占了整個step的大頭.
了解了問題的原因之后,對于這種情況,我們采取了將embedding pull到本地的方法:
#下面的一行identity將變量pull到本地. 這里的variable就是上面提到的的embedding tensor
variable_copy = tf.identity(variable)
x = tf.embedding_lookup(variable_copy)
identify讀取variable, 產(chǎn)生一個等價tensor. 在分布式的情況下, 這個op定義在worker上, 相當于把variable從ps讀取到本地. 通過這樣的操作, 我們把通信量變成一個固定值, 優(yōu)化了網(wǎng)絡(luò)通信. 優(yōu)化后的timeline如下:
可以看到, gpu上的計算在數(shù)據(jù)IO完成之后立即開始(說明一些變量已經(jīng)在數(shù)據(jù)io的同時pull到本地了), 并且很快計算完成. 說明我們的pull優(yōu)化起效果了.
push優(yōu)化
通過觀察pull優(yōu)化之后的timeline, 我們發(fā)現(xiàn)worker計算完成之后, ps確遲遲不結(jié)束. 我們分析ps的timeline, 見下圖:
原因和pull的情況類似, 因為我們是從embedding_lookup中獲取tensor進行計算, 所以tf將最后的梯度用IndexedSlices表示, 這種表示導(dǎo)致梯度的大小不僅和變量相關(guān),而是和輸入相關(guān). 因為我們的batchsize比較大, 相比起所以帶了了一些不必要的通信量, 從timeline看, 耗時較多. 所以我們采取了如下操作:
grads_and_vars = [(tf.multiply(grad, 1), var) for grad, var in grads_and_vars]
這個做的是將應(yīng)用于同一個變量的梯度變成一個固定大小的tensor, 不使用稀疏表示 去控制通信.
最終優(yōu)化了push和pull之后的pipeline如下:
可以看到, gpu的利用率大大提高, cpu中耗時的操作為數(shù)據(jù)io, 而gpu上的op在數(shù)據(jù)io完成后即開始, ps的時間消耗僅僅是略多于worker(因為最后需要在ps上做apply grandent
).
后記
上述就是我們優(yōu)化的過程.
單機的部分注意一下op和變量的placement, 手動控制的時候不要迷信經(jīng)驗, 要有充分的理由, 大部分情況下tensorflow的placement做的其實還不錯, 在顯存足夠并且cpu上的op不多的時候, 都放到gpu上即可. 分布式的情況, tf會做一些性能優(yōu)化, 但是tf的placement存在一定的問題, 算法其實可以比現(xiàn)在更加智能和數(shù)據(jù)驅(qū)動, 因為placement對通信影響很大, 如果發(fā)現(xiàn)tf犯了錯誤, 可以適度進行手工干預(yù). 不管是單機還是分布式, 更快的io永遠是值得追求的, 也有很多的優(yōu)化的方法.
總結(jié)一下, 性能調(diào)優(yōu)需要注意細節(jié), 對每個操作的情況做到心中有數(shù). 對于一些異骋鳎現(xiàn)象(比如我們上面碰到的因為colocation, 外層的device placement被無視),可以求諸源碼. 對于可能存在的性能問題, 需要大膽假設(shè), 小心求證. 在優(yōu)化的時候, 注意投入產(chǎn)出, 隨著優(yōu)化的進行, 系統(tǒng)的瓶頸也會變化. 比如把數(shù)據(jù)io時間從1000ms優(yōu)化到100ms可以帶來性能提升2倍, 花更大的精力繼續(xù)從100ms優(yōu)化到5ms可能并不會帶來整體性能的提升, 因為這個時候數(shù)據(jù)io可能已經(jīng)不是瓶頸了.