MXNet設計筆記之:深度學習的編程模式比較

市面上流行着各式各樣的深度學習庫,它們風格各異。那麼這些函數庫的風格在系統優化和用戶體驗方面又有哪些優點和缺陷呢?本文旨在於比較它們在編程模式方面的差別,討論這些模式的基本優劣勢,以及咱們從中能夠學到什麼經驗。html

咱們主要關注編程模式自己,而不是其具體實現。所以,本文並非一篇關於深度學習庫相互比較的文章。相反,咱們根據它們所提供的接口,將這些函數庫分爲幾大類,而後討論各種形式的接口將會對深度學習編程的性能和靈活性產生什麼影響。本文的討論可能不僅針對於深度學習,但咱們會採用深度學習的例子來分析和優化。node

符號式編程 vs 命令式編程

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

 

import numpy as np
a = np.ones(10)
b = np.ones(10) * 2
c = b * a
d = c + 1

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

 

 

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的計算圖。git

 

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

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

如今你明白兩種編程模型了吧,咱們接着來比較它們!shell

命令式程序更加靈活

這並不能算是一種嚴格的表述,只能說大多數狀況下命令式程序比符號式程序更靈活。若是你想用Python寫一段命令式程序的代碼,直接寫就是了。可是,你若想寫一段符號式程序的代碼,則徹底不一樣了。看下面這段命令式程序,想一想你會怎樣把它轉化爲符號式程序呢。express

 

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端完成這些組裝了。

實際上咱們還有第三種選擇,並且很好用。它被稱爲表達式模板。基本思想就是在編譯時用模板編程從表達式樹(expression tree)生成通用內核。更多的細節請移步表達式模板教程。cxxnet是一個普遍使用表達式模板的庫,它使得代碼更簡潔、更易讀,性能和人工設置的內核不相上下。

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

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

混合各類風格

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

記得Amdahl定律嗎,你如果花費時間來優化可有可無的部分,總體性能是不可能有大幅度提高的。

咱們發現一般在效率、靈活性和工程複雜度之間有一個取捨關係。每每不一樣的編程模式適用於問題的不一樣部分。例如,命令式程序對參數更新更合適,符號式編程則是梯度計算。

本文提倡的是混合多種風格。回想Amdahl定律,有時候咱們但願靈活的這部分對性能要求可能並不高,那麼簡陋一些以支持更靈活的接口也何嘗不可。在機器學習中,集成多個模型的效果每每好於單個模型。

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

符號式和命令式程序

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

 

  • 把命令式程序做爲符號式程序調用的一部分。
  • 把符號式程序做爲命令式程序的一部分。

 

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

目前的符號式庫裏也能發現混合模式的程序,由於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,加入更多新的功能。

  1. ndarray編程接口,相似matlab/numpy.ndarray/torch.tensor。獨有優點在於經過背後的engine能夠在性能上和內存使用上更優。
  2. symbolic接口。這個可使得快速構建一個神經網絡,和自動求導。
  3. 更多binding 目前支持比較好的是python,立刻會有julia和R。
  4. 更加方便的多卡和多機運行。
  5. 性能上更優。目前mxnet比cxxnet快40%,並且gpu內存使用少了一半。

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

 

(責編/周建丁)

相關文章
相關標籤/搜索