為了更好的閱讀體驗,請點擊這里
12.1 編譯器和解釋器
原書主要關注的是命令式編程(imperative programming),Python 是一種解釋性語言,因此沒有編譯器給代碼優化,代碼會跑得很慢,
12.1.1 符號式編程
考慮另一種選擇符號式編程(symbolic programming),即代碼通常只在完全定義了程序之后才執行計算,這個策略被多個深度學習框架使用,包括 Theano 和 TensorFlow(后者已經獲得了命令式編程的擴展),一般包括以下步驟:
- 定義計算流程;
- 將流程編譯成可執行的程式;
- 給定輸入,呼叫編譯好的程式執行,
這將允許進行大量的優化,首先,在大多數情況下,我們可以跳過 Python 解釋器,從而消除因為多個更快的 GPU 與單個 CPU 上的單個 Python 執行緒搭配使用時產生的性能瓶頸,其次,編譯器可以將代碼優化和重寫,因為編譯器在將其轉換為機器指令之前可以看到完整的代碼,所以這種優化是可以實作的,例如,只要某個變數不再需要,編譯器就可以釋放記憶體(或者從不分配記憶體),或者將代碼轉換為一個完全等價的片段,下面,我們將通過模擬命令式編程來進一步了解符號式編程的概念,
def add_():
return '''
def add(a, b):
return a + b
'''
def fancy_func_():
return '''
def fancy_func(a, b, c, d):
e = add(a, b)
f = add(c, d)
g = add(e, f)
return g
'''
def evoke_():
return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'
prog = evoke_()
print(prog)
y = compile(prog, '', 'exec')
exec(y)
def add(a, b):
return a + b
def fancy_func(a, b, c, d):
e = add(a, b)
f = add(c, d)
g = add(e, f)
return g
print(fancy_func(1, 2, 3, 4))
10
里面出現了神奇的兩個函式 compile() 和 exec():
compile(source, filename, mode[, flags[, dont_inherit]])- source:字串或者 AST(Abstract Syntax Trees)物件
- filename:代碼檔案名稱,如果不是從檔案讀取代碼則傳遞一些可辨認的值
- mode:指定編譯代碼的種類,可以指定為 exec, eval, single
- flags:變數作用域,區域命名空間,如果被提供,可以是任何映射物件
- flags 和 dont_inherit 是用來控制編譯原始碼時的標志
exec(obj)- obj:要執行的運算式,
命令式(解釋型)編程和符號式編程的區別如下:
- 命令式編程更容易使用,在 Python 中,命令式編程的大部分代碼都是簡單易懂的,命令式編程也更容易除錯,這是因為無論是獲取和列印所有的中間變數值,或者使用 Python 的內置除錯工具都更加簡單;
- 符號式編程運行效率更高,更易于移植,符號式編程更容易在編譯期間優化代碼,同時還能夠將程式移植到與 Python 無關的格式中,從而允許程式在非 Python 環境中運行,避免了任何潛在的與 Python 解釋器相關的性能問題,
12.1.2 混合式編程
PyTorch 是基于命令式編程并且使用動態計算圖,為了能夠利用符號式編程的可移植性和效率,開發人員思考能否將這兩種編程模型的優點結合起來,于是就產生了 TorchScript,TorchScript 允許用戶使用純命令式編程進行開發和除錯,同時能夠將大多數程式轉換為符號式程式,以便在需要產品級計算性能和部署時使用,
接下來假設已經定義好了一個網路比如 net=MLP(),那么可以使用 net = torch.jit.script(net) 代碼使用 TorchScript:
torch.jit.script(obj, optimize=None, _frames_up=0, _rcb=None, example_inputs=None):- 撰寫一個函式或
nn.Module腳本將檢查源代碼,使用 TorchScript 編譯器將其編譯為 TorchScript 代碼,并回傳ScriptModule或ScriptFunction, TorchScript 本身是 Python 語言的一個子集,因此并非 Python 中的所有功能都有效,但我們提供了足夠的功能來計算張量并執行依賴于控制的操作,有關完整指南,請參閱 TorchScript 語言參考, - 撰寫字典或串列的腳本會將其中的資料復制到 TorchScript 實體中,隨后可以通過參考在 Python 和 TorchScript 之間以零復制開銷傳遞,
torch.jit.script()可以為模塊、函式、字典和串列用作函式,而且還可以被用作裝飾器,- 回傳:
- 如果
obj是nn.Module,script 會回傳一個ScriptModule,回傳的ScriptModule將與原來的nn.Module有相同的子模塊和引數集合, - 如果
obj是獨立的函式,一個ScriptFunction將會回傳, - 如果
obj是字典,將會回傳torch._C.ScriptDict, - 如果
obj是串列,將會回傳torch._C.ScriptList,
- 如果
- 撰寫一個函式或
在使用上面轉化成 TorchScript 的代碼后,一個三層的多層感知機大約增快了 20%,而且,還可以方便地使用 net.save('filepath.pt') 來保存網路結構,眾所周知,普通的 torch.save()/torch.load() 是不能在沒有原本的模塊類定義下讀取模型的,但是在 TorchScript 中,接下來即使我們洗掉了原本的多層感知機的類以及衍生的實體,也可以通過 torch.jit.load('filepath.pt') 重新載入模型,當然也不排除是我沒刪干凈
12.2 異步計算
PyTorch 使用了 Python 自己的調度器來實作不同的性能權衡,對 PyTorch 來說 GPU操 作在默認情況下是異步的,當呼叫一個使用 GPU 的函式時,操作會排隊到特定的設備上,但不一定要等到以后才執行,這允許并行執行更多的計算,包括在 CPU 或其他 GPU 上的操作,
因此,了解異步編程是如何作業的,通過主動地減少計算需求和相互依賴,有助于我們開發更高效的程式,這能夠減少記憶體開銷并提高處理器利用率,下面測驗一下 numpy(CPU) 和 PyTorch(GPU) 的速度,
# GPU計算熱身
device = d2l.try_gpu()
a = torch.randn(size=(1000, 1000), device=device)
b = torch.mm(a, a)
with d2l.Benchmark('numpy'):
for _ in range(10):
a = numpy.random.normal(size=(1000, 1000))
b = numpy.dot(a, a)
with d2l.Benchmark('torch'):
for _ in range(10):
a = torch.randn(size=(1000, 1000), device=device)
b = torch.mm(a, a)
numpy: 1.0981 sec
torch: 0.0011 sec
默認情況下,GPU 操作在 PyTorch 中是異步的,強制 PyTorch 在回傳之前完成所有計算,這種強制說明了之前發生的情況:計算是由后端執行,而前端將控制權回傳給了 Python,
例如下面呼叫 torch.cuda.synchronize(device),這個函式等待在一個 CUDA 設備上所有核的所有流都完成,
with d2l.Benchmark():
for _ in range(10):
a = torch.randn(size=(1000, 1000), device=device)
b = torch.mm(a, a)
torch.cuda.synchronize(device)
Done: 0.0089 sec
廣義上說,PyTorch 有一個用于與用戶直接互動的前端(例如通過 Python),還有一個由系統用來執行計算的后端,用戶可以用各種前端語言撰寫 PyTorch 程式,如 Python 和 C++,不管使用的前端編程語言是什么,PyTorch 程式的執行主要發生在 C++ 實作的后端,由前端語言發出的操作被傳遞到后端執行,后端管理自己的執行緒,這些執行緒不斷收集和執行排隊的任務,請注意,要使其作業,后端必須能夠跟蹤計算圖中各個步驟之間的依賴關系,因此,不可能并行化相互依賴的操作,
當陳述句的結果需要被列印出來時,Python 前端線程將等待 C++ 后端執行緒完成結果計算,這種設計的一個好處是 Python 前端執行緒不需要執行實際的計算,因此,不管 Python 的性能如何,對程式的整體性能幾乎沒有影響,
練習題
(1)在CPU上,對本節中相同的矩陣乘法操作進行基準測驗,仍然可以通過后端觀察異步嗎?
torch 觀察不到異步現象,反倒是 numpy 可以觀察到異步的現象,雖然 torch.cuda.synchronize(torch.device('cpu')) 會彈出報錯,但是仍然可以使用以下兩個代碼來測驗速度:
with d2l.Benchmark('numpy'):
for _ in range(10):
a = numpy.random.normal(size=(1000, 1000))
b = numpy.dot(a, a)
# time.sleep(5)
with d2l.Benchmark('torch'):
for _ in range(10):
a = torch.randn(size=(1000, 1000), device=device)
b = torch.mm(a, a)
numpy: 0.9737 sec
torch: 0.2859 sec
with d2l.Benchmark('numpy'):
for _ in range(10):
a = numpy.random.normal(size=(1000, 1000))
b = numpy.dot(a, a)
time.sleep(5)
with d2l.Benchmark('torch'):
for _ in range(10):
a = torch.randn(size=(1000, 1000), device=device)
b = torch.mm(a, a)
numpy: 0.9414 sec
torch: 0.2103 sec
經過多次嘗試,可以發現 torch 的執行時間有明顯差異,這說明有 numpy 有部分仍然占用設備的時候,已經開始對 torch 的矩陣乘法計時了,
而如果把這兩個矩陣乘法的順序反過來,numpy 的時間變化不大,因此 torch 幾乎沒有異步而 numpy 異步了,
最后,我發現 torch.cuda.synchronize() 直接呼叫不加引數就不會報錯了,如果它的 device 引數為 None,那么它將使用 current_device 函式找出當前設備,
12.3 自動并行
深度學習框架會在后端自動構建計算圖,利用計算圖,系統可以了解所有依賴關系,并且可以選擇性地并行執行多個不相互依賴的任務以提高速度,
通常情況下單個運算子將使用所有CPU或單個GPU上的所有計算資源,并行化對單設備計算機來說并不是很有用,而并行化對于多個設備就很重要了,
請注意,接下來的實驗至少需要兩個GPU來運行,
12.3.1 基于 GPU 的并行計算
測驗一下兩個 GPU 串行各執行 10 次矩陣乘法和并行各執行 10 次矩陣乘法的速度,
devices = d2l.try_all_gpus()
def run(x):
return [x.mm(x) for _ in range(50)]
x_gpu1 = torch.rand(size=(4000, 4000), device=devices[0])
x_gpu2 = torch.rand(size=(4000, 4000), device=devices[1])
run(x_gpu1)
run(x_gpu2) # 預熱設備
torch.cuda.synchronize(devices[0])
torch.cuda.synchronize(devices[1])
with d2l.Benchmark('GPU1 time'):
run(x_gpu1)
torch.cuda.synchronize(devices[0])
with d2l.Benchmark('GPU2 time'):
run(x_gpu2)
torch.cuda.synchronize(devices[1])
GPU1 time: 1.5491 sec
GPU2 time: 1.4804 sec
洗掉兩個任務之間的 torch.cuda.synchronize() 陳述句,系統就可以在兩個設備上自動實作并行計算,
with d2l.Benchmark('GPU1 & GPU2'):
run(x_gpu1)
run(x_gpu2)
torch.cuda.synchronize()
GPU1 & GPU2: 1.5745 sec
12.3.2 并行計算與通信
在許多情況下,我們需要在不同的設備之間移動資料,比如在CPU和GPU之間,或者在不同的GPU之間,
通過在 GPU 上計算,然后將結果復制回 CPU 來模擬這個程序,
def copy_to_cpu(x, non_blocking=False):
return [y.to('cpu', non_blocking=non_blocking) for y in x]
with d2l.Benchmark('在GPU1上運行'):
y = run(x_gpu1)
torch.cuda.synchronize()
with d2l.Benchmark('復制到CPU'):
y_cpu = copy_to_cpu(y)
torch.cuda.synchronize()
在GPU1上運行: 1.6285 sec
復制到CPU: 2.5801 sec
在 GPU 仍在運行時就開始使用 PCI-Express 總線帶寬來移動資料是有利的,在 PyTorch 中,to() 和 copy_() 等函式都允許顯式的 non_blocking 引數,這允許在不需要同步時呼叫方可以繞過同步,設定 non_blocking=True 以模擬這個場景,
with d2l.Benchmark('在GPU1上運行并復制到CPU'):
y = run(x_gpu1)
y_cpu = copy_to_cpu(y, True)
torch.cuda.synchronize()
在GPU1上運行并復制到CPU: 1.9456 sec
12.5 多 GPU 訓練
在多個 GPU 上并行總共分為三種:
- 網路并行
- 按層并行
- 資料并行
實際上,資料并行是最常用的方法,原書中也重點討論了資料并行,
12.5.2 資料并行性
假設一臺機器有 \(k\) 個 GPU, 給定需要訓練的模型,雖然每個 GPU 上的引數值都是相同且同步的,但是每個 GPU 都將獨立地維護一組完整的模型引數,
一般來說,\(k\) 個 GPU 并行訓練程序如下:
- 在任何一次訓練迭代中,給定的隨機的小批量樣本都將被分成 \(k\) 個部分,并均勻地分配到 GPU 上;
- 每個 GPU 根據分配給它的小批量子集,計算模型引數的損失和梯度;
- 將 \(k\) 個 GPU 中的區域梯度聚合,以獲得當前小批量的隨機梯度;
- 聚合梯度被重新分發到每個 GPU 中;
- 每個 GPU 使用這個小批量隨機梯度,來更新它所維護的完整的模型引數集,
在實踐中請注意,當在 \(k\) 個 GPU 上訓練時,需要擴大小批量的大小為 \(k\) 的倍數,這樣每個 GPU 都有相同的作業量,就像只在單個 GPU 上訓練一樣, 因此,在 16-GPU 服務器上可以顯著地增加小批量資料量的大小,同時可能還需要相應地提高學習率,
12.5.4 資料同步
對于高效的多 GPU 訓練,我們需要兩個基本操作,首先,我們需要向多個設備分發引數并附加梯度(get_params),如果沒有引數,就不可能在 GPU 上評估網路,第二,需要跨多個設備對引數求和,也就是說,需要一個 allreduce 函式,
get_params() 函式定義如下:
def get_params(params, device):
new_params = [p.to(device) for p in params]
for p in new_params:
p.requires_grad_()
return new_params
假設現在有一個向量分布在多個 GPU 上,下面的 allreduce 函式將所有向量相加,并將結果廣播給所有GPU,請注意,需要將資料復制到累積結果的設備,才能使函式正常作業,
def allreduce(data):
for i in range(1, len(data)):
data[0][:] += data[i].to(data[0].device)
for i in range(1, len(data)):
data[i][:] = data[0].to(data[i].device)
12.5.5 資料分發
nn.parallel.scatter() 是一個簡單的工具函式,將一個小批量資料均勻地分布在多個 GPU 上,用法如下所示:
data = https://www.cnblogs.com/bringlu/archive/2023/05/04/torch.arange(20).reshape(4, 5)
devices = [torch.device('cuda:0'), torch.device('cuda:1')]
split = nn.parallel.scatter(data, devices)
print('input :', data)
print('load into', devices)
print('output:', split)
input : tensor([[ 0, 1, 2, 3, 4],
[ 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14],
[15, 16, 17, 18, 19]])
load into [device(type='cuda', index=0), device(type='cuda', index=1)]
output: (tensor([[0, 1, 2, 3, 4],
[5, 6, 7, 8, 9]], device='cuda:0'), tensor([[10, 11, 12, 13, 14],
[15, 16, 17, 18, 19]], device='cuda:1'))
12.5.6 訓練
def train_batch(X, y, device_params, devices, lr):
X_shards, y_shards = split_batch(X, y, devices)
# 在每個GPU上分別計算損失
ls = [loss(lenet(X_shard, device_W), y_shard).sum()
for X_shard, y_shard, device_W in zip(
X_shards, y_shards, device_params)]
for l in ls: # 反向傳播在每個GPU上分別執行
l.backward()
# 將每個GPU的所有梯度相加,并將其廣播到所有GPU
with torch.no_grad():
for i in range(len(device_params[0])):
allreduce(
[device_params[c][i].grad for c in range(len(devices))])
# 在每個GPU上分別更新模型引數
for param in device_params:
d2l.sgd(param, lr, X.shape[0]) # 在這里,我們使用全尺寸的小批量
與前幾章中略有不同:訓練函式需要分配 GPU 并將所有模型引數復制到所有設備,顯然,每個小批量都是使用 train_batch 函式來處理多個 GPU,我們只在一個 GPU 上計算模型的精確度,而讓其他 GPU 保持空閑,盡管這是相對低效的,但是使用方便且代碼簡潔,
def train(num_gpus, batch_size, lr):
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
devices = [d2l.try_gpu(i) for i in range(num_gpus)]
# 將模型引數復制到num_gpus個GPU
device_params = [get_params(params, d) for d in devices]
num_epochs = 10
animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
timer = d2l.Timer()
for epoch in range(num_epochs):
timer.start()
for X, y in train_iter:
# 為單個小批量執行多GPU訓練
train_batch(X, y, device_params, devices, lr)
torch.cuda.synchronize()
timer.stop()
# 在GPU0上評估模型
animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(
lambda x: lenet(x, device_params[0]), test_iter, devices[0]),))
print(f'測驗精度:{animator.Y[0][-1]:.2f},{timer.avg():.1f}秒/輪,'
f'在{str(devices)}')
12.6 多 GPU 的簡潔實作
12.6.1 DataParallel()
原書出現了一個有趣的函式 net = nn.DataParallel(net, device_ids=devices),這個函式可以說是本節的重點,
torch.nn.DataParallel(module, device_ids=None, output_device=None, dim=0) 這個函式:
在模塊的層級上實作了資料并行,
這個容器通過在批次維度中分塊將輸入拆分到指定設備,從而并行化給定模塊的應用程式(其他物件將在每個設備上復制一次),在前向傳遞中,模塊在每個設備上被復制,每個副本處理一部分輸入,在向后傳遞期間,來自每個副本的梯度被匯總到原始模塊中,
批量大小應大于使用的 GPU 數量,
另外,PyTorch 推薦使用 nn.parallel.DistributedDataParallel() 來代替 nn.Parallel(),原因如下:
大多數涉及批量輸入和多個 GPU 的用例應默認使用
DistributedDataParallel來利用多個 GPU,使用具有多處理功能的 CUDA 模型有一些重要的注意事項;除非注意準確地滿足資料處理要求,否則您的程式很可能會出現不正確或未定義的行為,
建議使用
DistributedDataParallel,而不是DataParallel進行多 GPU 訓練,即使只有一個設備,
DistributedDataParallel和DataParallel之間的區別是:DistributedDataParallel使用多行程,其中為每個 GPU 創建一個行程,而 DataParallel 使用多執行緒,通過使用 multiprocessing,每個 GPU 都有自己的專用行程,這避免了 Python 解釋器的 GIL 帶來的性能開銷,如果您使用
DistributedDataParallel,您可以使用torch.distributed.launch實用程式來啟動您的程式,請參閱第三方后端,
允許將任意位置和關鍵字輸入傳遞到 DataParallel 中,但某些型別需要特殊處理,張量將依托指定的維度被分開(默認為 0),元組、串列和字典型別將被淺拷貝,其他型別將在不同的執行緒之間共享,如果寫入模型的正向傳播,則可能會被破壞,
在運行此 DataParallel 模塊之前,并行化模塊必須在 device_ids[0] 上具有其引數和緩沖區,原因在于:在每個 forward 中,模塊在每個設備上被復制,因此對 forward 中正在運行的模塊的任何更新都將丟失,例如,如果模塊有一個計數器屬性,在每次轉發時遞增,它將始終保持初始值,因為更新是在轉發后銷毀的副本上完成的,但是,DataParallel 保證 device[0] 上的副本的引數和緩沖區將與基本并行化模塊共享存盤,因此,將記錄對設備 [0] 上的引數或緩沖區的就地更新,例如,BatchNorm2d 和 spectral_norm() 依賴于此行為來更新緩沖區,
當模塊在 forward() 中回傳一個標量時,此 wrapper 將回傳一個長度等于資料并行中使用的設備數量的向量,其中包含每個設備的結果,
引數:
- module (Module) – 要并行的模塊
- device_ids (list of python:int or torch.device) – CUDA 設備(默認:全部設備)
- output_device (int or torch.device) – 輸出的設備位置(默認:device_ids[0])
于是,原書中訓練這段代碼寫成了這樣:
def train(net, num_gpus, batch_size, lr):
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
devices = [d2l.try_gpu(i) for i in range(num_gpus)]
def init_weights(m):
if type(m) in [nn.Linear, nn.Conv2d]:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights)
# 在多個GPU上設定模型
net = nn.DataParallel(net, device_ids=devices)
trainer = torch.optim.SGD(net.parameters(), lr)
loss = nn.CrossEntropyLoss()
timer, num_epochs = d2l.Timer(), 10
animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
for epoch in range(num_epochs):
net.train()
timer.start()
for X, y in train_iter:
trainer.zero_grad()
X, y = X.to(devices[0]), y.to(devices[0])
l = loss(net(X), y)
l.backward()
trainer.step()
timer.stop()
animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(net, test_iter),))
print(f'測驗精度:{animator.Y[0][-1]:.2f},{timer.avg():.1f}秒/輪,'
f'在{str(devices)}')
注意第 \(19\) 行中是把資料傳到了 \(0\) 號 GPU 上,然后它就會自動切成 GPU 個資料塊然后傳過去了,
運行代碼測驗一下!首先是只使用 \(1\) 塊 GPU 的代碼:
train(net, num_gpus=1, batch_size=256, lr=0.1)
測驗精度:0.90,222.1秒/輪,在[device(type='cuda', index=0)]
然后是使用 \(2\) 塊 GPU 的代碼:
train(net, num_gpus=2, batch_size=512, lr=0.2)
測驗精度:0.87,111.8秒/輪,在[device(type='cuda', index=0), device(type='cuda', index=1)]
接近一倍的速度提升,原書中跑一輪居然只需要 \(10\) 秒左右,不禁令人感慨,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/551649.html
標籤:其他
下一篇:返回列表
