王潛升 余南 張梅山 韓子嘉 付國宏
黑龍江大學(xué)計(jì)算機(jī)科學(xué)技術(shù)學(xué)院, 哈爾濱 150080; ? 通信作者, E-mail: ghfu@hotmail.com
近年來, 深度學(xué)習(xí)方法在自然語言處理領(lǐng)域很多任務(wù)中的性能超越了傳統(tǒng)的統(tǒng)計(jì)機(jī)器學(xué)習(xí)方法,得到廣泛的應(yīng)用[1]。在訓(xùn)練階段, 深度學(xué)習(xí)模型的執(zhí)行步驟包括前向傳播、反向傳播和更新參數(shù)等,深度學(xué)習(xí)庫能方便地執(zhí)行這些步驟。Theano[2]、CNTK[3]、Caffe[4]、TensorFlow[5]和PyTorch[6]等已得到廣泛的應(yīng)用[7-10]。
Theano[2]、CNTK[3]、Caffe[4]和TensorFlow[5]在訓(xùn)練前靜態(tài)地定義計(jì)算圖, 在訓(xùn)練時(shí)對所有實(shí)例執(zhí)行同一個(gè)計(jì)算圖。然而, 在自然語言處理任務(wù)中,構(gòu)建適應(yīng)所有實(shí)例的計(jì)算圖存在額外的困難, 體現(xiàn)在以下兩方面。
1)各實(shí)例的長度不一致。補(bǔ)零可使各實(shí)例長度一致, 然而補(bǔ)零操作可能影響計(jì)算結(jié)果。為了避免這個(gè)影響, 需對計(jì)算結(jié)果做裁剪。
2)實(shí)例含有結(jié)構(gòu)化信息, 如句法結(jié)構(gòu)。有時(shí)我們希望基于這些結(jié)構(gòu)化信息動(dòng)態(tài)地構(gòu)建計(jì)算圖, 比如在基于句法的遞歸神經(jīng)網(wǎng)絡(luò)中, 不同的句子實(shí)例有著不同的句法結(jié)構(gòu), 也就對應(yīng)不同的計(jì)算圖。
PyTorch等深度學(xué)習(xí)庫則根據(jù)不同的實(shí)例, 動(dòng)態(tài)地構(gòu)建不同的計(jì)算圖。為了利用多核CPU或GPU加速計(jì)算, PyTorch要求使用者將可以批量化計(jì)算的數(shù)據(jù)手動(dòng)合并為張量。比如在計(jì)算機(jī)視覺任務(wù)中,使用者須將多個(gè)圖像實(shí)例合并為一個(gè)張量后作為模型的輸入。
然而, 在自然語言處理任務(wù)中, 手動(dòng)批量化合并數(shù)據(jù)存在以下額外的困難: 1)各句子實(shí)例須在補(bǔ)零后才能合并為張量; 2)在樹結(jié)構(gòu)模型(如遞歸神經(jīng)網(wǎng)絡(luò))中, 須分析計(jì)算圖執(zhí)行步驟后, 將同一執(zhí)行步驟中處于不同實(shí)例上的計(jì)算過程批量化。
Looks等[11]提出一種自動(dòng)批量化方法, 允許深度學(xué)習(xí)庫構(gòu)建完整個(gè)計(jì)算圖后, 自動(dòng)地發(fā)現(xiàn)當(dāng)前可執(zhí)行的同類型計(jì)算過程, 并將其批量化執(zhí)行。為方便自然語言處理領(lǐng)域的研究者使用, 我們也實(shí)現(xiàn)了動(dòng)態(tài)計(jì)算圖和自動(dòng)批量化。如同多數(shù)深度學(xué)習(xí)庫[2-6],我們還實(shí)現(xiàn)了自動(dòng)微分。N3LDG將向量視為計(jì)算的對象, 將卷積、池化等視為基于向量的各種操作,而在自然語言處理任務(wù)中, 深度學(xué)習(xí)模型的輸入通常是詞向量, 或拼接了其他特征的向量, 這樣N3LDG滿足了自然語言處理任務(wù)的要求。為提高執(zhí)行速度, N3LDG使用C++語言來實(shí)現(xiàn)。與其他深度學(xué)習(xí)庫相比, N3LDG更容易使用, 只需在項(xiàng)目中包含頭文件即可使用。在Apache 2.0協(xié)議下, N3LDG在https://github.com/zhangmeishan/N3LDG發(fā)布。
近年出現(xiàn)很多通用深度學(xué)習(xí)庫。Zhang等[12]提出一種自然語言處理深度學(xué)習(xí)庫LibN3L, 實(shí)現(xiàn)深度學(xué)習(xí)模型中的常見操作, 但是該庫不支持自動(dòng)批量化。針對深度學(xué)習(xí)模型的計(jì)算圖的自動(dòng)批量化研究尚不多見。Looks等[11]首先提出基于節(jié)點(diǎn)在計(jì)算圖中的深度的自動(dòng)批量化方法后, Neubig等[13]認(rèn)為這個(gè)方法在處理RNN模型時(shí)難以充分批量化。為緩解這個(gè)問題, Neubig等[14]提出一種將同類型節(jié)點(diǎn)在計(jì)算圖中的平均深度作為啟發(fā)式規(guī)則的方法, 并應(yīng)用在他們的深度學(xué)習(xí)庫DyNet中。由于RNN模型在自然語言處理任務(wù)中較常用, 為了高效地訓(xùn)練RNN模型, 我們仿照Neubig等[13]的方法。
多數(shù)的深度學(xué)習(xí)庫能夠利用GPU加速訓(xùn)練模型[2-6,14]。Chetlur等[15]提出cuDNN庫, 高效地實(shí)現(xiàn)深度學(xué)習(xí)中的各基本操作。為了高效地分配顯存,DyNet在庫初始化時(shí)創(chuàng)建了3個(gè)顯存塊[14], 其中一個(gè)顯存塊在前向傳播中使用, 另一個(gè)在反向傳播中使用, 最后一個(gè)用于存儲參數(shù)和相關(guān)的梯度。這樣,可通過指針的加減運(yùn)算, 實(shí)現(xiàn)分配和釋放顯存的操作。我們利用顯存池來高效地分配顯存, 這種做法不要求使用者預(yù)估顯存占用空間, 而是在需要時(shí)動(dòng)態(tài)地向系統(tǒng)申請新的顯存塊。
對于一個(gè)簡單的線性分類器y=Wx, 只需以x為自變量做線性變換, 便可得到分類結(jié)果。為說明計(jì)算圖的優(yōu)點(diǎn), 我們引入更復(fù)雜的模型。圖1描述一種循環(huán)神經(jīng)網(wǎng)絡(luò)(RNN)模型。該模型可表示為y=f(x1,x2, …,xn), 我們難以直接表示f, 因此將f分解為多個(gè)簡單的計(jì)算步驟, 每步計(jì)算結(jié)果存儲在一個(gè)中間向量中。將向量視為圖的節(jié)點(diǎn), 向量之間形成有向邊, 分解后的各計(jì)算步驟和向量構(gòu)成計(jì)算圖G。
圖1 一種RNN模型Fig.1 An RNN model
為實(shí)現(xiàn)計(jì)算圖, 首先定義Node類作為計(jì)算圖節(jié)點(diǎn)。以圖1中hi=tan h (Whhi-1+zi)為例, 為了計(jì)算hi,Node類需包含以下信息: 1)前向傳播的計(jì)算方法;2)本節(jié)點(diǎn)向量(hi); 3)各輸入向量(hi-1,zi); 4)參數(shù)(Wh)。當(dāng)給定各輸入向量x1,x2, …,xn時(shí), 某節(jié)點(diǎn)即可執(zhí)行前向傳播過程, 求得本節(jié)點(diǎn)向量y=f(x1,x2, …,xn), 稱該節(jié)點(diǎn)為可執(zhí)行節(jié)點(diǎn)。y又可作為其子節(jié)點(diǎn)的輸入向量, 使子節(jié)點(diǎn)成為可執(zhí)行節(jié)點(diǎn)。若某節(jié)點(diǎn)不含輸入向量(如圖1中xi所在節(jié)點(diǎn)), 則該節(jié)點(diǎn)成為計(jì)算圖G的初始可執(zhí)行節(jié)點(diǎn)。這樣, 以初始可執(zhí)行節(jié)點(diǎn)為始, 重復(fù)執(zhí)行前向傳播過程, 直至計(jì)算圖中所有節(jié)點(diǎn)都被執(zhí)行, 即完成模型的前向傳播過程。圖 1 描述了這個(gè)過程。
為執(zhí)行反向傳播, Node類還須包含以下信息: 1)反向傳播的計(jì)算方法; 2)損失函數(shù)L對y的導(dǎo)數(shù);3)L對各輸入向量的導(dǎo)數(shù)4)L對各參數(shù)的導(dǎo)數(shù)我們在Node類中定義前向傳播和反向傳播的接口, 在其各個(gè)子類中實(shí)現(xiàn)這兩個(gè)接口。我們實(shí)現(xiàn)了常用的節(jié)點(diǎn)類型,包括tanh, concat和線性變換等, 列舉在表 1 中。使用者也可自己定義新的節(jié)點(diǎn)類型, 實(shí)現(xiàn)前向傳播和反向傳播。
表1 N3LDG中的常用節(jié)點(diǎn)類型Table 1 Commonly used node types in N3LDG
計(jì)算圖中往往有多個(gè)可執(zhí)行節(jié)點(diǎn)。為了提高執(zhí)行速度, 需要批量化地執(zhí)行同類型的計(jì)算過程。具體而言, 有兩類計(jì)算過程可批量化執(zhí)行: 1)共享參數(shù)的同類型計(jì)算, 如y1=Wx1+b和y2=Wx2+b; 2)不含參數(shù)矩陣的同類型計(jì)算, 如y1=tanh(x1)和y2=tanh(x2)。
N3LDG自動(dòng)發(fā)現(xiàn)當(dāng)前可執(zhí)行節(jié)點(diǎn), 并批量化執(zhí)行同類型的計(jì)算過程。當(dāng)這些節(jié)點(diǎn)被執(zhí)行完后, 即從計(jì)算圖中移除, 此時(shí)可得新的可執(zhí)行節(jié)點(diǎn)集合。這樣, 我們總能得到當(dāng)前可執(zhí)行節(jié)點(diǎn)的集合, 直到計(jì)算圖執(zhí)行完畢。以圖1中RNN模型為例, 執(zhí)行步驟如下。
1)[x1x2…]=[emb(大家)emb(好)… emb(!)]
2)[z1z2…]=Wx[x1x2…]+[b b…b]
3)[h1]=tanh([z1)
4)[h2]=tanh(Wh[h1]+[z2])
5)[h3]=tanh(Wh[h2h2′]+[z3])
8)[p p′ ]=[pool(h1,h2,h3)pool
9)[y y′ ]=Wp[p p′]
Eigen是通用的C++線性代數(shù)計(jì)算庫[16], 因此我們使用Eigen實(shí)現(xiàn)CPU上的線性代數(shù)計(jì)算。由于CPU能高效地處理內(nèi)存中連續(xù)存放的向量, 所以N3LDG對常用的計(jì)算過程做了優(yōu)化。具體的步驟如下: 1)計(jì)算前, 將各節(jié)點(diǎn)中的輸入向量合并為一個(gè)矩陣, 將矩陣與多個(gè)向量的乘法運(yùn)算轉(zhuǎn)換為矩陣與矩陣的乘法運(yùn)算; 2)執(zhí)行矩陣和矩陣的乘法運(yùn)算,得到結(jié)果矩陣; 3)將該結(jié)果矩陣拆分后, 賦值給各節(jié)點(diǎn)的向量。
我們以y1=tanh(Wx1+b),y2=tanh(Wx2+b)...yn=tanh(Wxn+b)為例, 首先將x1,x2,…,xn合并為矩陣[x1x2…xn], 記為X, 將同一個(gè)向量b擴(kuò)展為n列矩陣[b b...b], 記為B。將各向量拷貝至連續(xù)的內(nèi)存區(qū)域中, 然后執(zhí)行Y=tanh(WX+B), 計(jì)算完成后,將矩陣Y拆分拷貝至各節(jié)點(diǎn)的向量。
cuBLAS是英偉達(dá)發(fā)布的CUDA線性代數(shù)計(jì)算庫, 我們使用cuBLAS實(shí)現(xiàn)GPU上的線性代數(shù)計(jì)算,并編寫kernel函數(shù)實(shí)現(xiàn)其余計(jì)算過程。為充分利用GPU的并行計(jì)算能力, 我們并行執(zhí)行所有批量化之后的計(jì)算過程。我們的實(shí)現(xiàn)不依賴cuDNN[15], 使用者無需安裝cuDNN。
我們發(fā)現(xiàn)GPU中有兩類操作存在性能瓶頸: 1)顯存分配與釋放; 2)顯存和內(nèi)存間的I/O。當(dāng)動(dòng)態(tài)構(gòu)建計(jì)算圖時(shí), 參與前向傳播和反向傳播計(jì)算過程的各向量地址也隨之動(dòng)態(tài)地變化, 計(jì)算前, 須將這些信息傳輸?shù)斤@存, 這會頻繁涉及上述兩類操作。我們通過以下方法來緩解性能瓶頸。
在實(shí)驗(yàn)中測量顯存的分配與釋放時(shí)間, 發(fā)現(xiàn)它們占總訓(xùn)練時(shí)間相當(dāng)大的比例, 成為性能瓶頸。通過專用模塊(顯存池), 持有并管理空閑的顯存塊,當(dāng)不持有合適的空閑塊時(shí), 才向系統(tǒng)申請顯存塊,從而減少向系統(tǒng)分配與釋放顯存的次數(shù)。英偉達(dá)實(shí)現(xiàn)了顯存池庫cnmem, 在https://github.com/NVIDIA/cnmem發(fā)布。Knowlton等[17]提出伙伴系統(tǒng), 用于快速分配存儲空間。受伙伴系統(tǒng)啟發(fā), 我們也實(shí)現(xiàn)了顯存池。
對于同樣大小的數(shù)據(jù), 只調(diào)用一次庫函數(shù), 將其傳輸至顯存, 顯著地快于分成多次傳輸[18]。因此,對需傳輸至顯存的多個(gè)數(shù)據(jù), 我們在內(nèi)存中將其連續(xù)存放后, 再調(diào)用一次庫函數(shù)傳至顯存。比如, 批量化執(zhí)行y1=tanh(x1),y2=tanh(x2)...yn=tanh(xn)時(shí), 需將x1,x2, …,xn的地址傳輸至顯存, 我們在內(nèi)存中連續(xù)存放x1,x2, …,xn的地址后, 調(diào)用一次庫函數(shù), 將這些地址傳輸至顯存。與調(diào)用多次庫函數(shù)而分別傳輸它們的地址相比, 我們的方法顯著地減少了顯存與內(nèi)存間的I/O次數(shù)。
我們通過一個(gè) 5 分類情感分類任務(wù), 在 3 個(gè)模型上做基準(zhǔn)測試: 1)卷積神經(jīng)網(wǎng)絡(luò)(CNN); 2)雙向長短時(shí)記憶網(wǎng)絡(luò)(Bi-LSTM); 3)樹結(jié)構(gòu)長短時(shí)記憶網(wǎng)絡(luò)(Tree-LSTM)[19]。以上模型的詞向量和隱層的維度都設(shè)置為200。訓(xùn)練數(shù)據(jù)包括8544個(gè)句子實(shí)例,共163563個(gè)詞(包括標(biāo)點(diǎn)符號), 測試代碼在https://github.com/chncwang/n3ldg-benchmark發(fā)布。記錄訓(xùn)練一輪epoch的時(shí)長, 包括: 1)構(gòu)建計(jì)算圖; 2)規(guī)劃執(zhí)行步驟; 3)前向傳播; 4)反向傳播; 5)更新參數(shù)。實(shí)驗(yàn)中, CPU的型號是Intel (R)Core (TM)i7-6800K CPU @ 3.40 GHz, GPU型號是GeForce GTX 1080 Ti。我們還用PyTorch實(shí)現(xiàn)一致的模型結(jié)構(gòu),并在 3 個(gè)模型上實(shí)現(xiàn)手動(dòng)批量化, 以便與N3LDG對比訓(xùn)練速度。在N3LDG中, 我們未對LSTM做特別的優(yōu)化, 為公平對比, 用PyTorch以同樣方式實(shí)現(xiàn)LSTM。
首先在單線程CPU上, 對N3LDG和PyTorch做基準(zhǔn)測試, 測試結(jié)果見表2。
表2 顯示, 在所有設(shè)置下, N3LDG在單線程CPU上的訓(xùn)練速度高于PyTorch。當(dāng)我們訓(xùn)練CNN時(shí),N3LDG訓(xùn)練速度達(dá)到PyTorch的9.40~42.47倍, 訓(xùn)練Bi-LSTM時(shí)達(dá)到PyTorch的4.43~9.71倍, 訓(xùn)練Tree-LSTM時(shí)達(dá)到PyTorch的1.28~3.10倍。這表明我們構(gòu)建計(jì)算圖、自動(dòng)批量化和CPU計(jì)算過程是高效的。
表2 單線程CPU上N3LDG和PyTorch的基準(zhǔn)測試Table 2 Benchmarks of N3LDG and PyTorch on single thread CPU
我們在GPU上對N3LDG、不使用cuDNN的PyTorch (稱為PyTorch CUDA)以及使用cuDNN的PyTorch (PyTorch cuDNN)做了基準(zhǔn)測試, 測試結(jié)果見表 3。
表3 顯示, 在訓(xùn)練CNN和Tree-LSTM時(shí), N3LDG在GPU上的訓(xùn)練速度高于PyTorch CUDA和PyTorch cuDNN。在訓(xùn)練CNN時(shí), N3LDG的訓(xùn)練速度達(dá)到PyTorch CUDA的3.40~18.38倍, PyTorch cuDNN的3.10~8.74倍, 訓(xùn)練Tree-LSTM時(shí)速度達(dá)到PyTorch CUDA的1.78~3.03倍, PyTorch cuDNN的1.67~2.79倍。訓(xùn)練Bi-LSTM模型時(shí), N3LDG在較大的minibatch下有優(yōu)勢。當(dāng)mini-batch=1時(shí), N3LDG的訓(xùn)練速度低于PyTorch, 是PyTorch CUDA的77.46%,PyTorch cuDNN的80.18%。當(dāng)mini-batch=16時(shí),N3LDG的訓(xùn)練速度與PyTorch幾乎相同。當(dāng)minibatch=256時(shí), N3LDG的訓(xùn)練速度達(dá)到PyTorch CUDA的1.71倍, PyTorch cuDNN的1.77倍??傮w而言, 我們構(gòu)建計(jì)算圖、自動(dòng)批量化和GPU計(jì)算過程是高效的。
為了分析自動(dòng)批量化對訓(xùn)練速度的影響, 以Bi-LSTM (MB=256)為例, 分別在單線程CPU和GPU上測試是否做自動(dòng)批量化時(shí)各步驟的時(shí)長。實(shí)驗(yàn)結(jié)果見圖 2。
圖2顯示, 自動(dòng)批量化顯著地提升了訓(xùn)練速度。在單線程CPU上提升4.76倍, 在GPU上提升52.27倍。提速主要來自前向傳播和反向傳播。
我們猜測在單線程CPU上提速的部分原因在于合并了矩陣與向量的乘法, 比如將y1=Wx1和y2=Wx2轉(zhuǎn)換為[y1y2]=W[x1x2]后, 計(jì)算速度更快。我們還對比了在相同設(shè)置下, 一輪epoch中矩陣乘法的總執(zhí)行時(shí)間、執(zhí)行次數(shù)及平均執(zhí)行時(shí)間, 結(jié)果見表 4。
表3 GPU上N3LDG, PyTorch CUDA和PyTorch cuDNN的基準(zhǔn)測試Table 3 Benchmarks of N3LDG, PyTorch CUDA and PyTorch cuDNN on GPU
圖2 做自動(dòng)批量化與否時(shí)各訓(xùn)練階段時(shí)長Fig.2 Time in per training stage when auto-batch enabled or not
表4 顯示自動(dòng)批量化顯著地提升了單線程CPU矩陣乘法的執(zhí)行速度, 提升幅度為9.28倍。與不做批量化相比, 盡管自動(dòng)批量化時(shí)平均執(zhí)行時(shí)間更長,但執(zhí)行次數(shù)僅為0.98%。
為分析自動(dòng)批量化對CUDA核函數(shù)執(zhí)行速度的影響, 我們對比了在相同設(shè)置下, 一輪epoch中核函數(shù)的總執(zhí)行時(shí)間、執(zhí)行次數(shù)及平均執(zhí)行時(shí)間, 結(jié)果見表 5。
表5 顯示, 自動(dòng)批量化顯著提升了CUDA核函數(shù)的執(zhí)行速度, 提升幅度為70.52倍。與不做批量化相比, 執(zhí)行次數(shù)僅為1.28%。值得注意的是, 平均執(zhí)行時(shí)間只是不做批量化時(shí)的1.11倍, 表明自動(dòng)批量化充分利用了GPU的并行計(jì)算能力。
表4 單線程CPU矩陣乘法的總執(zhí)行時(shí)間、執(zhí)行次數(shù)和平均執(zhí)行時(shí)間Table 4 Total execution duration, times and average duration of matrix multiplication
表5 CUDA核函數(shù)的總執(zhí)行時(shí)間、執(zhí)行次數(shù)和平均執(zhí)行時(shí)間Table 5 Total execution duration, times and average duration of CUDA kernel functions
圖3 無顯存池、cnmem和我們的顯存池訓(xùn)練時(shí)間對比Fig.3 Comparison of training time among the absence of the memory pool, cnmem and ours
為測試顯存池的有效性, 我們在訓(xùn)練Bi-LSTM時(shí), 分別統(tǒng)計(jì)一輪epoch中, 不使用顯存池、使用cnmem以及使用我們的顯存池時(shí)的訓(xùn)練時(shí)間以及分配與釋放顯存的時(shí)長, 實(shí)驗(yàn)結(jié)果見圖 3。
圖3 顯示, 當(dāng)不使用顯存池時(shí), 顯存的分配與釋放占訓(xùn)練時(shí)間的56.87%~74.72%, 成為性能瓶頸,而顯存池顯著降低時(shí)長。使用我們的顯存池時(shí), 分配與釋放顯存的速度是使用cnmem時(shí)的5.11~37.11倍, 訓(xùn)練速度是無顯存池時(shí)的3.19~3.37倍, 使用cnmem時(shí)的1.13~2.63倍, 表明我們的顯存池是高效的。
為方便在自然語言處理任務(wù)中應(yīng)用深度學(xué)習(xí),移除手動(dòng)批量化過程, 本文提出一種輕量級自然語言處理深度學(xué)習(xí)庫N3LDG。我們仿照Neubig等[13]的方法, 實(shí)現(xiàn)自動(dòng)批量化, 并在CPU和GPU上都高效實(shí)現(xiàn)常見的深度學(xué)習(xí)計(jì)算過程。實(shí)驗(yàn)表明, 自動(dòng)批量化顯著提高了CPU和GPU上的執(zhí)行速度。我們的庫在CNN, Bi-LSTM和Tree-LSTM模型中的CPU性能以及在CNN和Tree-LSTM模型中的GPU性能都優(yōu)于PyTorch。作為一種輕量級的庫, 我們開發(fā)的N3LDG為自然語言處理領(lǐng)域的研究者提供了新的選擇。