MXNet/Gluon第五課:Gluon高級和優化演算法基礎筆記
這一講開始,就要介紹到一些有關Gluon的特性了。其實,通過前面幾節的講解,我第一次接觸gluon就感覺它和pytorch神似,很多東西都很pytorch很像。這節的目的,就是講講gluon繼承了pytorch的很多優點,同時,gluon還具有了pytorch沒有的特性。
Hybridize:更快和更好移植
到目前為止,我所學過的framework的相關知識都是以命令式的編程來支撐的。雖然,可能從來沒有聽過什麼是命令式編程,什麼是符號式編程。因為,一直以來,我們寫python就是通過命令式編程來寫的。
看下面一段代碼:
def add(A,B): return A+Bdef fancy_func(A,B,C,D): E = add(A,B) F = add(C,D) G = add(E,F) return Gres = fancy_func(1,2,3,4)print (res)
正如大家希望的這樣,在運行E = add(A,B)的時候,我們實際上會做加法運算並返回結果。之後的指令F = 和 G = 會跟在後面順序執行。每一步的指向都是調用相同的函數和開闢相同的內存空間。
因為,這樣寫代碼很符合我們大多數常人的思維模式,覺得這樣寫,就很自然,沒有什麼毛病。但是,這樣寫有一個很致命的缺陷,就是運行起來太慢。這是因為,我們不能跟python的運行環境多次打交道,也就是說,即使我們重複的調用了add函數三次,相當於我們和python的運行環境打了3次交道。而且,最後我們還要保存E和F這兩個中間結果,直到有變數調用了fancy_func()為止。
事實上,這裡不同的打開方式。其中一個叫做符號式編程,大部分深度學習框架:TensorFlow,Theano,Caffe都使用了這種符號式的編程模式。
並且,符號式的編程需要下面三個模式:
- 人為定義一個計算圖
- 編譯可執行的程序
- 給定輸入,然後調用編譯好的結果
那麼,通過介紹了符號式編程,我們重新實現上面的程序:
def add_str(): return def add(A, B): return A + Bdef fancy_func_str(): return def fancy_func(A, B, C, D): E = add(A, B) F = add(C, D) G = add(E, F) return Gdef evoke_str(): return add_str() + fancy_func_str() + print(fancy_func(1,2,3,4))prog = evoke_str()y = compile(prog,,exec)print(exec(y))
通過上面的代碼,我們可以看到。定義的三個函數都是返回計算的整個流程。之後我們編譯再執行。在編譯的時候,系統能夠看到整個程序,因此有很多的優化空間。
比如說,在編譯的時候,我們能夠將程序改寫成為print((1+2)+(3+4)),甚至直接print(10)。這裡,我們不僅減少了函數的調用,同時節省了大量的內存。
總結:
- 命令式編程更方便。很符合我們大多數人寫代碼的習慣和直觀,容易debug,可以隨時獲取中間變數,或者通過調試工具來觀察他們的變化。
- 符號式編程更加高效而且更容易移植。在編譯的時候,可以做更多的優化。還可以將整個程序變成與python無關的格式,從而我們可以在非python的環境下運行。
這裡,就要進入hybridize了。其實使用hybridize可以同時權衡兩者之間的優點。
- 大部分的深度學習框架通常在命令式和符號式之間二選一。
- 利於Theano和TensorFlow,使用了符號式的編程。
- Chainer和Pytorch使用了命令式的編程。
- 那麼gluon的設計初衷就是:
- 在researcher進行科研開發和調試的過程中,可以使用命令式編程。
- 當工業界使用產品級別的性能和部署的時候,我們可以將代碼,至少大部分的主體代碼,轉換成符號式來運行。
- 這一點,Amazon的開發團隊已經為我們做到了,我們可以使用HybridBlock或者HybridSequential來構建我們的神經網路。默認他們跟Block和Sequential一樣的執行方式。當我們調用xxx.hybridize()的時候,系統會將其轉換成為符號式來執行。
- 事實上,在Gluon里定義的層全是HybridBlock,這就意味著大部分的神經網路都可以受符號式執行的優勢。
HybridSequential
我們之前學過了Sequential來串聯多個layer,從而構架出我們的神經網路。如果你想讓他運行起來跑的再快一點,那麼,我們可以使用HybridSequential來執行。
考慮下面的代碼:
from mxnet import gluonfrom mxnet.gluon import nnfrom mxnet import ndarray as nddef get_net(): net = nn.HybridSequential() with net.name_scope(): net.add( nn.Dense(256,activation = relu), nn.Dense(128,activation = relu), nn.Dense(2) ) net.initialize() return netx = nd.random.normal(shape = (1,512))net = get_net()print (net(x))
這是我們前面講過的,通過nn.Sequential()來定義網路,然後在使用nn.HybridSequential()改進下的結果。
我們還可以通過hybridize來編譯和優化HybridSequential
net.hybridize()print (net(x))
其實,只有繼承了HybridBlock的層才會被優化。然而HybridSequential和Gluon提供的層都是它的子類。如果一個層只是單單的繼承自Block,那麼,我們就無法對其進行優化。
性能:
我們比較下hybridize前和後的計算時間,來通過數字體現下符號式編程所帶來的性能上的提升
看下面的代碼:
from mxnet import gluonfrom mxnet.gluon import nnfrom mxnet import ndarray as ndfrom time import timedef bench(net,x): start = time() for i in range(1000): y = net(x) nd.waitall() return time()-startdef get_net(): net = nn.HybridSequential() with net.name_scope(): net.add( nn.Dense(256,activation = relu), nn.Dense(128,activation = relu), nn.Dense(2) ) net.initialize() return netx = nd.random.normal(shape = (1,512))net = get_net()print (Before hybridizing: %.4f sec%(bench(net,x)))net.hybridize()print (After hybridizing: %.4f sec%(bench(net,x)))
這裡進行了1000次的前向計算,通過print的信息:
Before hybridizing: 0.2983 sec
After hybridizing: 0.1334 sec
我們可以看出,使用了net.hybridize()要比沒有使用,快了將近2倍的速度。
獲取符號式的程序
對於前面的代碼,我們喂進net的x,都是ndarray類型的變數,那麼net吐出來的結果,肯定也是ndarray類型的。
其實,我們也可以喂進net以Symbol類型的變數,那麼其返回的結果也同樣是Symbol類型的程序。
from mxnet import symx = sym.var(data)y = net(x)print (y)
我們可以通過export()來保存這個程序到硬碟。那麼,它之後不僅可以被python使用,同時還是可以被c++等語言來進行開發和讀取。
通過HybridBlock深入理解hybridize的工作機制
在前面的學習中,我們展示了如何通過hybridize,我們可以獲得更好的性能和更高的移植性。現在我們來分析分析,到底是如何實現了這種程度的加速。在前面的章節中,我們知道了gluon中的Sequential是Block的一個便捷實現形式。那麼對比到hybridize的機制,HybridSequential就是HybridBlock的一個子類。我們知道,在繼承nn.Block的時候,我們需要去實現forward()函數,但是,對於HybridBlock來說,我們需要去實現hybrid_forward()函數。
來看下面的代碼:
class HybridNet(nn.HybridBlock): def __init__(self,**kwargs): super(HybridNet,self).__init__(**kwargs) with self.name_scope(): self.fc1 = nn.Dense(10) self.fc2 = nn.Dense(2) def hybrid_forward(self,F,x): print (F) print (x) x = F.relu(self.fc1(x)) print (x) return self.fc2(x)net = HybridNet()net.initialize()x = nd.random.normal(shape = (1,4))y = net(x)print (y)
hybrid_forward()函數和forward()函數不同的是,該方法中加入了F,這就使用到了mxnet的一個獨有的特性。mxnet有一個符號式的API(symbol)和命令式的API(ndarray)。
這兩個介面裡面的函數基本上是一致的。系統會根據我們的輸入是哪種類型的,來決定F是使用symbol還是使用ndarray。
我們接下來實例化一個樣例,然後可以看到默認F是使用了ndarray的。而且我們列印了輸入和第一層relu的輸出。
那我們來進行下hybridize()後,看看會發生什麼樣的結果
net.hybridize()y = net(x)print(y)
我們發現,即使輸入的x是ndarray類型的,但是,使用了hybridize後,輸入和中間變數都變成了symbol類型的變數,但是輸出還依舊是ndarray類型的。
當我們再來運行一次
y = net(x)print (y)
可以發現,什麼輸出都沒有了。這是因為在我們第一次調用y=net(x)這樣的命令的時候,已經將輸入都替換替換成為了symbol來構建一個符號式的程序。之後所有有關y=net(x)的命令,都不在訪問構建其符號式編程的python代碼,而是直接在C++後端執行這個符號式程序。
這也就是為什麼使用了hybridize後,程序會變快的一個原因。
但是這樣做,並不是說就非常好了。它可能的問題就是損失了我們寫程序的靈活性。
因為python代碼僅僅執行一次,而且是符號式的執行,那麼使用print來進行測試,調試或者說程序中出現if,for來做複雜的控制都是不能達到的。
延遲執行:
mxnet使用延遲執行來提升系統的性能。絕大多數情況下,我們在寫代碼的時候,不用刻意去關注它的存在,因為大部分的時候,我們是不知道有這個工作在背後運行的。但是,理解它的工作原理,確實能夠幫助我們進行非常合理和有效的開發。
延遲執行的大體意思就是:指命令可以等到之後它的結果真正的需要的時候再去執行,不到最後一步,不去執行。
下面,通過代碼來講講這個道理:
a = 1+2# some other codesprint (a)
通過第一句對a進行了賦值,然後再執行一些其指令後列印a的結果。因為這裡我們可能很久以後才會用到a的值,所以,我們可以把它的執行延遲到最後真正執行它的時候。
這樣做的目的:執行之前,系統可以看到後面的一系列命令,從而就會有更多的機會來對程序進行優化。
假如說,a在被列印前,被重新賦值了,那麼,我們就可以不需要真正執行a=1+2這條語句了,因為a的值已經不再是3了。
在mxnet里,我們把用戶直接寫代碼進行交互的部分叫做「前端」,比如從課程一開始到現在, 我們一直在使用python寫各種各樣的方法和模型。。除了python以外,mxnet還支持Scala,R,C++等語言作為其前端功能。
不管使用什麼樣的前端語言,mxnet的程序執行主要還是在C++後端進行。前端僅僅負責把程序傳給後端。後端有自己的線程來不斷的收集任務,構造計算圖,優化,並且執行。
這一部分,就是要給小夥伴們介紹下後端的一個優化:延遲執行。
來看下面的代碼:
a = nd.ones((1,2))b = nd.ones((1,2))c = a*b+2print (c)
其中,我們在前端調用了4條語句。他們被後端的線程分析依賴並構建成計算圖的模式。
分析依賴就是圖中的箭頭所指,變數是什麼類型的,變數之間的運算關係。
計算圖,就是圖中由節點和邊所組成的一個網路。
延遲執行的過程是這樣的:
- 前端執行前三個語句的時候,它僅僅是把任務放進了後端的線程隊列里就返回了。
- 當真正需要列印最終的運算結果的時候,前端會等待後端線程把c的結果計算完畢
這樣設計的好處:
- 因為,我們的前端是使用python進行交互的,大家都知道python運行是很慢的,但是我們加入延遲執行後,它並沒有讓python在前端參與任何的運算,所以,python前端對於整個程序的影響將會很小。
- 我們只需要後端使用C++來高效的運行,就可以很大程度生提升我們程序的性能了。
下面,我們通過代碼來看看延遲執行的效果。可以看到當y=nd.dot(x,x)的時候,並沒有等待它真正的被計算完。
start = time()x = nd.random.uniform(shape = (2000,2000))y = nd.dot(x,x)print (workloads are queued: %.4f sec%(time()-start))print (y)print (workloads are finished: %.4f sec%(time()-start))
延遲執行對用戶來說是透明的,很多時候,雖然我們寫了很多代碼,但是並不知道有這麼一回事。除非我們需要列印或者保存結果外,我們根本是不關心目前是不是結果已經在內存中存放並且計算好了。
事實上,在我們寫代碼的過程中,只要把變數存在了ndarray里,而且使用了mxnet聽的運運算元。那麼後端將默認我們使用了延遲執行這個選項,從而最大程度上的獲取最大的性能。
立即獲取結果
除了前面介紹了的print功能外,我們還有別的方法可以讓前端線程等待直到結果完成。
我們可以使用nd.ndarray.wait_to_read()等待直到特定結果完成,或者使用nd.waitall()等待所有前面的結果完成。
start = time()x = nd.random.uniform(shape = (2000,2000))y = nd.dot(x,x)y.wait_to_read()print (%.4f%(time()-start))start = time()x = nd.random.uniform(shape = (2000,2000))y = nd.dot(x,x)z = nd.dot(x,x)nd.waitall()print (%.4f%(time()-start))
任何方法將內容從ndarray搬運到其他不支持延遲執行的數據結構里都會觸發等待,例如asnumpy(),asscalar()
start = time()y = nd.dot(x,x)y.asnumpy()print (%.4f%(time()-start))start = time()y = nd.dot(x,x)y.norm().asscalar()print (%.4f%(time()-start))
延遲執行帶來的便利
下面的例子中,我們對y進行了1000次的賦值,如果每次都需要等待y的值被計算出來,然後在進行下一次的迭代,那將會很慢。
如果我們使用了延遲執行,那麼系統中就會忽略掉一部分的內容,從而加速運算。
x = nd.random.uniform(shape=(2000,2000))start = time()for i in range(1000): y = x+1 y.wait_to_read()print (No lazy evaluation: %.4f sec%(time()-start))start = time()for i in range(1000): y = x+1nd.waitall()print (with evaluation: %.4f sec%(time()-start))
延遲執行帶來的影響
在延遲執行里,只要最終結果是一致的,系統可能使用跟代碼不一樣的順序來執行,例如,我們寫了如下代碼
a = 1b = 2c = a+b
由於a=1和b=2之間沒有任何依賴。所以說,我們使用b=2和a=1的順序,也是能達到最終c=3的結果的。雖然對於最終的結果沒有什麼影響,但是,這樣會導致內存使用的變化。
下面,我們來列舉幾個在訓練和預測中常見的現象。一般,對於每個批量我們都會評測一下,例如計算損失和精度,其中都會使用到asnumpy或者asscalar。這樣,我們就能僅僅將一個批量的任務放進後端來執行。但是,如果我們去掉這些同步函數的話,就會使得大批量數據一次性全部放進系統進行執行,從而可能導致系統佔用太多的資源而造成內存浪費。
下面,通過代碼來講講上述延遲執行所帶來的影響。
from mxnet import gluonfrom mxnet.gluon import nnfrom mxnet import ndarray as ndfrom time import timefrom mxnet import symimport osimport subprocessdef get_data(): start = time() batch_size = 1024 for i in range(60): if i%10==0 and i != 0: print (batch %d, time %f sec%(i,time()-start)) x = nd.ones((batch_size,1024)) y = nd.ones((batch_size,)) yield x,ynet = nn.Sequential()with net.name_scope(): net.add( nn.Dense(1024, activation=relu), nn.Dense(1024, activation=relu), nn.Dense(1), )net.initialize()trainer = gluon.Trainer(net.collect_params(), sgd, {})loss = gluon.loss.L2Loss()def get_mem(): res = subprocess.check_output([ps,u,-p,str(os.getpid())]) return int(str(res).split()[15])/1e3for x,y in get_data(): breakloss(y,net(x)).wait_to_read()mem = get_mem()for x,y in get_data(): loss(y,net(x)).wait_to_read()nd.waitall()print(Increased memory %f MB%(get_mem()-mem))
結果如下:
batch 10, time 0.213873 sec
batch 20, time 0.436659 sec
batch 30, time 0.663841 sec
batch 40, time 0.883148 sec
batch 50, time 1.115797 sec
Increased memory 0.148000 MB
假設,此時,我們不適用wait_to_read()。那麼前端就會將所有的批量一次性的全部添加進後端。可以看到,每個批量的數據都會在很短的時間內生成,同時在接下來的數秒內,我們看到了內存的顯著增長情況
mem = get_mem()for x,y in get_data(): loss(y,net(x))nd.waitall()print(Increased memory %f MB%(get_mem()-mem))
結果如下:
batch 10, time 0.006374 sec
batch 20, time 0.019963 sec
batch 30, time 0.025409 sec
batch 40, time 0.041614 sec
batch 50, time 0.050791 sec
Increased memory 55.120000 MB
同樣對於訓練,如果我們每次都計算損失,那麼就加入同步
from mxnet import gluonfrom mxnet.gluon import nnfrom mxnet import ndarray as ndfrom mxnet import autograd as agfrom time import timefrom mxnet import symimport osimport subprocessdef get_data(): start = time() batch_size = 1024 for i in range(60): if i%10==0 and i != 0: print (batch %d, time %f sec%(i,time()-start)) x = nd.ones((batch_size,1024)) y = nd.ones((batch_size,)) yield x,ynet = nn.Sequential()with net.name_scope(): net.add( nn.Dense(1024, activation=relu), nn.Dense(1024, activation=relu), nn.Dense(1), )net.initialize()trainer = gluon.Trainer(net.collect_params(), sgd, {})loss = gluon.loss.L2Loss()def get_mem(): res = subprocess.check_output([ps,u,-p,str(os.getpid())]) return int(str(res).split()[15])/1e3for x,y in get_data(): break# loss(y,net(x)).wait_to_read()mem = get_mem()total_loss = 0for x,y in get_data(): with ag.record(): L = loss(y,net(x)) total_loss += L.sum().asscalar() L.backward() trainer.step(x.shape[0])nd.waitall()print (Increased memory %f MB%(get_mem()-mem))
但是,如果不去掉同步。同樣會首先把數據全部生成好,導致佔用大量的內存
from mxnet import gluonfrom mxnet.gluon import nnfrom mxnet import ndarray as ndfrom mxnet import autograd as agfrom time import timefrom mxnet import symimport osimport subprocessdef get_data(): start = time() batch_size = 1024 for i in range(60): if i%10==0 and i != 0: print (batch %d, time %f sec%(i,time()-start)) x = nd.ones((batch_size,1024)) y = nd.ones((batch_size,)) yield x,ynet = nn.Sequential()with net.name_scope(): net.add( nn.Dense(1024, activation=relu), nn.Dense(1024, activation=relu), nn.Dense(1), )net.initialize()trainer = gluon.Trainer(net.collect_params(), sgd, {})loss = gluon.loss.L2Loss()def get_mem(): res = subprocess.check_output([ps,u,-p,str(os.getpid())]) return int(str(res).split()[15])/1e3for x,y in get_data(): break# loss(y,net(x)).wait_to_read()mem = get_mem()total_loss = 0for x,y in get_data(): with ag.record(): L = loss(y,net(x)) L.backward() trainer.step(x.shape[0])nd.waitall()print (Increased memory %f MB%(get_mem()-mem))
總結
延遲執行使得系統有更多的內存空間來做性能的優化,但我們推薦每個批量里至少有一個同步函數,例如對損失函數進行評估,來避免將過多任務同時丟進後端系統。
自動並行
多機訓練
....
由於缺少多張GPU,等去了公司,我會好好補這個部分的內容的。
優化演算法概述
這一部分是Aston Zhang講的,聽了後覺得相當不錯,準備專門寫一個文章加入到我的StepbyStepForDeepLearning專欄中
推薦閱讀:
TAG:深度學習DeepLearning | MXNet |