你好,游客 登錄
背景:
閱讀新聞

六合图库彩库开奖结果:MXNet設計筆記之:深度學習的編程模式比較

[日期:2015-10-13] 來源:mxnet  作者: [字體: ]

六合图库118万众图库 www.xorsm.icu   市面上流行著各式各樣的深度學習庫,它們風格各異。那么這些函數庫的風格在系統優化和用戶體驗方面又有哪些優勢和缺陷呢?本文旨在于比較它們在編程模式方面的差異,討論這些模式的基本優劣勢,以及我們從中可以學到什么經驗。

  我們主要關注編程模式本身,而不是其具體實現。因此,本文并不是一篇關于深度學習庫相互比較的文章。相反,我們根據它們所提供的接口,將這些函數庫分為幾大類,然后討論各類形式的接口將會對深度學習編程的性能和靈活性產生什么影響。本文的討論可能不只針對于深度學習,但我們會采用深度學習的例子來分析和優化。

  符號式編程 vs 命令式編程

  在這一節,我們先來比較符號式程序(symbolic style programs)和命令式程序(imperative style programs)兩種形式。如果你是一名Python或者C++程序員,那你應該很熟悉命令式程序了。命令式程序按照我們的命令來執行運算過程。大多數Python代碼都屬于命令式,例如下面這段numpy的計算。

  import numpy as np

  a = np.ones(10)

  b = np.ones(10) * 2

  c = b * a

  d = c + 1

  當程序執行到 c = b * a 這一行時,機器確實做了一次乘法運算。符號式程序略有不同。下面這段代碼屬于符號式程序,它同樣能夠計算得到d的值。

  A = Variable('A')

  B = Variable('B')

  C = B * A

  D = C + Constant(1)

  # compiles the function

  f = compile(D)

  d = f(A=np.ones(10), B=np.ones(10)*2)

  符號式程序的不同之處在于,當執行 C = B * A 這一行代碼時,程序并沒有產生真正的計算,而是生成了一張計算圖/符號圖(computation graph/symbolic graph)來描述整個計算過程。下圖就是計算得到D的計算圖。

  

 

  大多數符號式程序都會顯式地或是隱式地包含編譯步驟。這一步將計算圖轉換為能被調用的函數。在代碼的最后一行才真正地進行了運算。符號式程序的最大特點就是清晰地將定義運算圖的步驟與編譯運算的步驟分割開來。

  采用命令式編程的深度學習庫包括Torch,Chainer, Minerva。采用符號式編程的庫有Theano和CGT。一些使用配置文件的庫,例如cxxnet和Caffe,也都被視為是符號式編程。因為配置文件的內容定義了計算圖。

  現在你明白兩種編程模型了吧,我們接著來比較它們!

  命令式程序更加靈活

  這并不能算是一種嚴格的表述,只能說大多數情況下命令式程序比符號式程序更靈活。如果你想用Python寫一段命令式程序的代碼,直接寫就是了。但是,你若想寫一段符號式程序的代碼,則完全不同了??聰旅嬲舛蚊釷匠絳?,想想你會怎樣把它轉化為符號式程序呢。

  a = 2

  b = a + 1

  d = np.zeros(10)

  for i in range(d):

  d += np.zeros(10)

  你會發現事實上并不容易,因為Python的for循環可能并不被符號式程序的API所支持。你若用Python來寫符號式程序的代碼,那絕對不是真的Python代碼。實際上,你寫的是符號式API定義的領域特定語言(DSL)。符號式API是DSL的加強版,能夠生成計算圖或是神經網絡的配置。照此說法,輸入配置文件的庫都屬于符號式的。

  由于命令式程序比符號式程序更本地化,因此更容易利用語言本身的特性并將它們穿插在計算流程中。例如打印輸出計算過程的中間值,或者使用宿主語言的條件判斷和循環屬性。

  符號式程序更高效

  我們在上一節討論中提到,命令式程序更靈活,對宿主語言的本地化也更好。那為何大部分深度學習函數庫反而選擇了符號式呢?主要原因還是內存使用和運算時間兩方面的效率。我們再來回顧一下本文開頭的小例子。

  import numpy as np

  a = np.ones(10)

  b = np.ones(10) * 2

  c = b * a

  d = c + 1

  ...

  

 

  假設數組的每個單元占據8字節。如果我們在Python控制臺執行上述程序需要消耗多少內存呢?我們一起來做些算術題,首先需要存放4個包含10個元素的數組,需要4 * 10 * 8 = 320個字節。但是,若是運行計算圖,我們可以重復利用C和D的內存,只需要3 * 10 * 8 = 240字節的內存就夠了。

  符號式程序的限制更多。當用戶對D進行編譯時,用戶告訴系統只需要得到D的值。計算的中間結果,也就是C的值,對用戶是不可見的。這就允許符號式程序重復利用內存進行同址計算(in-place computation)。

  然而,命令式程序屬于未雨綢繆的類型。如果上述程序在Python控制臺執行,任何一個變量之后都有可能被用到,系統因此就不能對這些變量共享內存區間了。

  當然,這樣斷言有些理想化,因為命令式程序在變量超出作用域時會啟動垃圾回收機制,內存將得以重新利用。但是,受限于“未雨綢繆”這一特點,我們的優化能力還是有限。常見于梯度計算等例子,我們將在在下一節討論。

  符號式程序的另一個優化點是運算折疊。上述代碼中,乘法和加法運算可以被折疊為一次運算。如下圖所示。這意味著如果使用GPU計算,只需用到一個GPU內核(而不是兩個)。這也正是我們在cxxnet和Caffe這些優化庫中手工調整運算的過程。這樣做能提升計算效率。

  

 

  在命令式程序里我們無法做到。因為中間結果可能在未來某處被引用。這種優化在符號式程序里可行是因為我們得到了完整的計算圖,對需要和不需要的變量有一個明確的界線。而命令式程序只做局部運算,沒有這條明確的界線。

  Backprop和AutoDiff的案例分析

  在這一節,我們將基于自動微分或是反向傳播的問題對比兩種編程模式。梯度計算幾乎是所有深度學習庫所要解決的問題。使用命令式程序和符號式程序都能實現梯度計算。

  我們先看命令式程序。下面這段代碼實現自動微分運算,我們之前討論過這個例子。

  class array(object) :

  """Simple Array object that support autodiff."""

  def __init__(self, value, name=None):

  self.value = value

  if name:

  self.grad = lambda g : {name : g}

  def __add__(self, other):

  assert isinstance(other, int)

  ret = array(self.value + other)

  ret.grad = lambda g : self.grad(g)

  return ret

  def __mul__(self, other):

  assert isinstance(other, array)

  ret = array(self.value * other.value)

  def grad(g):

  x = self.grad(g * other.value)

  x.update(other.grad(g * self.value))

  return x

  ret.grad = grad

  return ret

  # some examples

  a = array(1, 'a')

  b = array(2, 'b')

  c = b * a

  d = c + 1

  print d.value

  print d.grad(1)

  # Results

  # 3

  # {'a': 2, 'b': 1}

  在上述程序里,每個數組對象都含有grad函數(事實上是閉包-closure)。當我們執行d.grad時,它遞歸地調用grad函數,把梯度值反向傳播回來,返回每個輸入值的梯度值??雌鵠此坪跤行└叢?。讓我們思考一下符號式程序的梯度計算過程。下面這段代碼是符號式的梯度計算過程。

  A = Variable('A')

  B = Variable('B')

  C = B * A

  D = C + Constant(1)

  # get gradient node.

  gA, gB = D.grad(wrt=[A, B])

  # compiles the gradient function.

  f = compile([gA, gB])

  grad_a, grad_b = f(A=np.ones(10), B=np.ones(10)*2)

  D的grad函數生成一幅反向計算圖,并且返回梯度節點gA和gB。它們對應于下圖的紅點。

  

 

  命令式程序做的事和符號式的完全一致。它隱式地在grad閉包里存儲了一張反向計算圖。當執行d.grad時,我們從d(D)開始計算,按照圖回溯計算梯度并存儲結果。

  因此我們發現無論符號式還是命令式程序,它們計算梯度的模式都一致。那么兩者的差異又在何處?再回憶一下命令式程序“未雨綢繆”的要求。如果我們準備一個支持自動微分的數組庫,需要保存計算過程中的grad閉包。這就意味著所有歷史變量不能被垃圾回收,因為它們通過函數閉包被變量d所引用。那么,若我們只想計算d的值,而不想要梯度值該怎么辦呢?

  在符號式程序中,我們聲明f=compiled([D>)來替換。它也聲明了計算的邊界,告訴系統我只想計算正向通路的結果。那么,系統就能釋放之前結果的存儲空間,并且共享輸入和輸出的內存。

  假設現在我們運行的不是簡單的示例,而是一個n層的深度神經網絡。如果我們只計算正向通路,而不用反向(梯度)通路,我們只需分配兩份臨時空間存放中間層的結果,而不是n份。由于命令式程序需要為今后可能用到的梯度值做準備,中間結果不得不保存,就需要用到n份臨時空間。

  正如我們所見,優化的程度取決于對用戶行為的約束。符號式程序的思路就是讓用戶通過編譯明確地指定計算的邊界。而命令式程序為之后所有情況做準備。符號式程序更充分地了解用戶需要什么和不想要什么,這是它的天然優勢。

  當然,我們也能對命令式程序施加約束條件。例如,上述問題的解決方案之一是引入一個上下文變量。我們可以引入一個沒有梯度的上下文變量,來避免梯度值的計算。這給命令式程序帶來了更多的約束條件,以換取性能上的改善。

  with context.NoGradient():

  a = array(1, 'a')

  b = array(2, 'b')

  c = b * a

  d = c + 1

  然而,上述的例子還是有許多可能的未來,也就是說不能在正向通路中做同址計算來重復利用內存(一種減少GPU內存的普遍方法)。這一節介紹的技術產生了顯式的反向通路。在Caffe和cxxnet等工具包里,反向傳播是在同一幅計算圖內隱式完成的。這一節的討論同樣也適用于這些例子。

  大多數基于函數庫(如cxxnet和caffe)的配置文件,都是為了一兩個通用需求而設計的。計算每一層的激活函數,或是計算所有權重的梯度。這些庫也面臨同樣的問題,若一個庫能支持的通用計算操作越多,我們能做的優化(內存共享)就越少,假設都是基于相同的數據結構。

  因此經常能看到一些例子在約束性和靈活性之間取舍。

  模型檢查點

  模型存儲和重新加載的能力對大多數用戶來說都很重要。有很多不同的方式來保存當前工作。通常保存一個神經網絡,需要存儲兩樣東西,神經網絡結構的配置和各節點的權重值。

  支持對配置文件設置檢查點是符號式程序的加分項。因為符號式的模型構建階段并不包含計算步驟,我們可以直接序列化計算圖,之后再重新加載它,無需引入附加層就解決了保存配置文件的問題。

  A = Variable('A')

  B = Variable('B')

  C = B * A

  D = C + Constant(1)

  D.save('mygraph')

  ...

  D2 = load('mygraph')

  f = compile([D2])

  # more operations

  ...

  因為命令式程序逐行執行計算。我們不得不把整塊代碼當做配置文件來存儲,或是在命令式語言的頂部再添加額外的配置層。

  參數更新

  大多數符號式編程屬于數據流(計算)圖。數據流圖能方便地描述計算過程。然而,它對參數更新的描述并不方便,因為參數的更新會引起變異(mutation),這不屬于數據流的概念。大多數符號式編程的做法是引入一個特殊的更新語句來更新程序的某些持續狀態。

  用命令式風格寫參數更新往往容易的多,尤其是當需要相互關聯地更新時。對于符號式編程,更新語句也是被我們調用并執行。在某種意義上來講,目前大部分符號式深度學習庫也是退回命令式方法進行更新操作,用符號式方法計算梯度。

  沒有嚴格的邊界

  我們已經比較了兩種編程風格。之前的一些說法未必完全準確,兩種編程風格之間也沒有明顯的邊界。例如,我們可以用Python的(JIT)編譯器來編譯命令式程序,使我們獲得一些符號式編程對全局信息掌握的優勢。但是,之前討論中大部分說法還是正確的,并且當我們開發深度學習庫時這些約束同樣適用。

  大操作 vs 小操作

  我們穿越了符號式程序和命令式程序激烈交鋒的戰場。接下去來談談深度學習庫所支持的一些操作。各種深度學習庫通常都支持兩類操作。

  大的層操作,如FullyConnected和BatchNormalize

  小的操作,如逐元素的加法、乘法。cxxnet和Caffe等庫支持層級別的操作,而Theano和Minerva等庫支持細粒度操作。

  更小的操作更靈活

  顯而易見,因為我們總是可以組合細粒度的操作來實現更大的操作。例如,sigmoid函數可以簡單地拆分為除法和指數運算。

  sigmoid(x)=1.0/(1.0+exp(-x))

  如果我們用小運算作為???,那就能表示大多數的問題了。對于更熟悉cxxnet和Caffe的讀者來說,這些運算和層級別的運算別無二致,只是它們粒度更細而已。

  SigmoidLayer(x) = EWiseDivisionLayer(1.0, AddScalarLayer(ExpLayer(-x), 1.0))

  因此上述表達式變為三個層的組合,每層定義了它們的前向和反向(梯度)函數。這給我們搭建新的層提供了便利,因為我們只需把這些東西拼起來即可。

  大操作更高效

  如你所見,直接實現sigmoid層意味著需要用三個層級別的操作,而非一個。

  SigmoidLayer(x)=EWiseDivisionLayer(1.0,AddScalarLayer(ExpLayer(-x),1.0))

  這會增加計算和內存的開銷(能夠被優化)。

  因此cxxnet和Caffe等庫使用了另一種方法。為了直接支持更粗粒度的運算,如BatchNormalization和SigmoidLayer,在每一層內人為設置計算內核,只啟動一個或少數幾個CUDA內核。這使得實現效率更高。

  編譯和優化

  小操作能被優化嗎?當然可以。這會涉及到編譯引擎的系統優化部分。計算圖有兩種優化形式

  內存分配優化,重復利用中間結果的內存。

  計算融合,檢測圖中是否包含sigmoid之類的模式,將其融合為更大的計算核。內存分配優化事實上也不止局限于小運算操作,也能用于更大的計算圖。

  然而,這些優化對于cxxnet和Caffe之類的大運算庫顯得無所謂。因為你從未察覺到它們內部的編譯步驟。事實上這些庫都包含一個編譯的步驟,把各層轉化為固定的前向、后向執行計劃,逐個執行。

  對于包含小操作的計算圖,這些優化是至關重要的。因為每次操作都很小,很多子圖模式能被匹配。而且,因為最終生成的操作可能無法完全枚舉,需要內核顯式地重新編譯,與大操作庫固定的預編譯核正好相反。這就是符號式庫支持小操作的開銷原因。編譯優化的需求也會增加只支持小操作庫的工程開銷。

  正如符號式與命令式的例子,大操作庫要求用戶提供約束條件(對公共層)來“作弊”,因此用戶才是真正完成子圖匹配的人。這樣人腦就把編譯時的附加開銷給省了,通常也不算太糟糕。

  表達式模板和靜態類型語言

  我們經常需要寫幾個小操作,然后把它們合在一起。Caffe等庫使用人工設置的內核來組裝這些更大???,否則用戶不得不在Python端完成這些組裝了。

  實際上我們還有第三種選擇,而且很好用。它被稱為表達式模板?;舅枷刖褪竊詒嘁朧庇媚0灞喑檀穎澩鍤絞?expression tree)生成通用內核。更多的細節請移步表達式模板教程。cxxnet是一個廣泛使用表達式模板的庫,它使得代碼更簡潔、更易讀,性能和人工設置的內核不相上下。

  表達式模板與Python內核生成的區別在于表達式模板是在c++編譯時完成,有現成的類型,所以沒有運行期的額外開銷。理論上其它支持模板的靜態類型語言都有該屬性,然而目前為止我們只在C++中見到過。

  表達式模板庫在Python操作和人工設置內核之間開辟了一塊中間地帶,使得C++用戶可以組合小操作成為一個高效的大操作。這是一個值得考慮的優化選項。

  混合各種風格

  我們已經比較了各種編程模型,接下去的問題就是該如何選擇。在討論之前,我們必須強調本文所做的比較結果可能并不會對你面臨的問題有多少影響,主要還是取決于你的問題。

  記得Amdahl定律嗎,你若是花費時間來優化無關緊要的部分,整體性能是不可能有大幅度提升的。

  我們發現通常在效率、靈活性和工程復雜度之間有一個取舍關系。往往不同的編程模式適用于問題的不同部分。例如,命令式程序對參數更新更合適,符號式編程則是梯度計算。

  本文提倡的是混合多種風格?;叵階mdahl定律,有時候我們希望靈活的這部分對性能要求可能并不高,那么簡陋一些以支持更靈活的接口也未嘗不可。在機器學習中,集成多個模型的效果往往好于單個模型。

  如果各個編程模型能以正確的方式被混合,我們取得的效果也很好于單個模型。我們在此列一些可能的討論。

  符號式和命令式程序

  有兩種方法可以混合符號式和命令式的程序。

  把命令式程序作為符號式程序調用的一部分。

  把符號式程序作為命令式程序的一部分。

  我們觀察到通常以命令式的方法寫參數更新更方便,而梯度計算使用符號式程序更有效率。

  目前的符號式庫里也能發現混合模式的程序,因為Python自身是命令式的。例如,下面這段代碼把符號式程序融入到numpy(命令式的)中。

  A = Variable('A')

  B = Variable('B')

  C = B * A

  D = C + Constant(1)

  # compiles the function

  f = compile(D)

  d = f(A=np.ones(10), B=np.ones(10)*2)

  d = d + 1.0

  它的思想是將符號式圖編譯為一個可以命令式執行的函數,內部對用戶而言是個黑盒。這就像我們常做的,寫一段c++程序并將其嵌入Python之中。

  然而,把numpy當做命令式部分使用并不理想,因為參數的內存是放在GPU里。更好的方式是用支持GPU的命令式庫和編譯過的符號式函數交互,或是在符號式程序中加入一小部分代碼幫助實現參數更新功能。

  小操作和大操作

  組合小操作和大操作也能實現,而且我們有一個很好的理由支持這樣做。設想這樣一個應用,如更換損失函數或是在現有結構中加入用戶自定義的層,我們通常的做法是用大操作組合現有的部件,用小操作添加新的部分。

  回想Amdahl定律,通常這些新部件不太會是計算瓶頸。由于性能的關鍵部分我們在大操作中已經做了優化,這些新的小操作一點不做優化也能接受,或是做一些內存的優化,而不是進行操作融合的優化。

  選擇你自己的風格

  我們已經比較了深度學習編程的幾種風格。本文的目的在于羅列這些選擇并比較他們的優劣勢。并沒有一勞永逸的方法,這并不妨礙保持你自己的風格,或是組合你喜歡的幾種風格,創造更多有趣的、智慧的深度學習庫。

  參與筆記制作

  這個筆記是我們深度學習庫的開源系統設計筆記的一部分。很歡迎大家提交申請,一起為這份筆記做貢獻。

  原文鏈接:Programming Models for Deep Learning(譯者/趙屹華 審校/劉帝偉、朱正貴、李子健)

  感謝李沐大神(微博:@李沐M)對本譯文的最終確認。

  延伸閱讀

  @antinucleon 中文博文解析MXNet技術特性

  李沐在知乎上對mxnet的解釋:

  mxnet是cxxnet的下一代,目前實現了cxxnet所有功能,但借鑒了minerva/torch7/theano,加入更多新的功能。

  ndarray編程接口,類似matlab/numpy.ndarray/torch.tensor。獨有優勢在于通過背后的engine可以在性能上和內存使用上更優。

  symbolic接口。這個可以使得快速構建一個神經網絡,和自動求導。

  更多binding 目前支持比較好的是python,馬上會有julia和R。

  更加方便的多卡和多機運行。

  性能上更優。目前mxnet比cxxnet快40%,而且gpu內存使用少了一半。

 

  目前mxnet還在快速發展中。這個月的主要方向有三,更多的binding,更好的文檔,和更多的應用(language model、語音,機器翻譯,視頻)。地址在 dmlc/mxnet · GitHub 歡迎使用。

推薦 打印 | 錄入: | 閱讀:
相關新聞      
本文評論   
評論聲明
  • 尊重網上道德,遵守中華人民共和國的各項有關法律法規
  • 承擔一切因您的行為而直接或間接導致的民事或刑事法律責任
  • 本站管理人員有權保留或刪除其管轄留言中的任意內容
  • 本站有權在網站內轉載或引用您的評論
  • 參與本評論即表明您已經閱讀并接受上述條款