以下內容為結合李沐老師的課程和教材補充的學習筆記,以及對課后練習的一些思考,自留回顧,也供同學之人交流參考。
本節課程地址:無
本節教材地址:12.3. 自動并行 — 動手學深度學習 2.0.0 documentation
本節開源代碼:...>d2l-zh>pytorch>chapter_optimization>auto-parallelism.ipynb
自動并行
深度學習框架(例如,MxNet、飛槳和PyTorch)會在后端自動構建計算圖。利用計算圖,系統可以了解所有依賴關系,并且可以選擇性地并行執行多個不相互依賴的任務以提高速度。例如,12.2節?中的 圖12.2.2 獨立初始化兩個變量。因此,系統可以選擇并行執行它們。
通常情況下單個操作符將使用所有CPU或單個GPU上的所有計算資源。例如,即使在一臺機器上有多個CPU處理器,dot
操作符也將使用所有CPU上的所有核心(和線程)。這樣的行為同樣適用于單個GPU。因此,并行化對單設備計算機來說并不是很有用,而并行化對于多個設備就很重要了。雖然并行化通常應用在多個GPU之間,但增加本地CPU以后還將提高少許性能。例如, (Hadjis?et al., 2016) 則把結合GPU和CPU的訓練應用到計算機視覺模型中。借助自動并行化框架的便利性,我們可以依靠幾行Python代碼實現相同的目標。對自動并行計算的討論主要集中在使用CPU和GPU的并行計算上,以及計算和通信的并行化內容。
請注意,本節中的實驗至少需要兩個GPU來運行。
import torch
from d2l import torch as d2l
基于GPU的并行計算
從定義一個具有參考性的用于測試的工作負載開始:下面的run
函數將執行?10?次矩陣-矩陣乘法時需要使用的數據分配到兩個變量(x_gpu1
和x_gpu2
)中,這兩個變量分別位于選擇的不同設備上。
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])
現在使用函數來處理數據。通過在測量之前需要預熱設備(對設備執行一次傳遞)來確保緩存的作用不影響最終的結果。torch.cuda.synchronize()
函數將會等待一個CUDA設備上的所有流中的所有核心的計算完成。函數接受一個device
參數,代表是哪個設備需要同步。如果device參數是None
(默認值),它將使用current_device()
找出的當前設備。
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.4571 sec
GPU2 time: 1.4560 sec
如果刪除兩個任務之間的synchronize
語句,系統就可以在兩個設備上自動實現并行計算。
with d2l.Benchmark('GPU1 & GPU2'):run(x_gpu1)run(x_gpu2)torch.cuda.synchronize()
輸出結果:
GPU1 & GPU2: 1.5222 sec
在上述情況下,總執行時間小于兩個部分執行時間的總和,因為深度學習框架自動調度兩個GPU設備上的計算,而不需要用戶編寫復雜的代碼。
并行計算與通信
在許多情況下,我們需要在不同的設備之間移動數據,比如在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.8508 sec
復制到CPU: 3.1686 sec
這種方式效率不高。注意到當列表中的其余部分還在計算時,我們可能就已經開始將y
的部分復制到CPU了。例如,當計算一個小批量的(反傳)梯度時。某些參數的梯度將比其他參數的梯度更早可用。因此,在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: 2.6157 sec
兩個操作所需的總時間少于它們各部分操作所需時間的總和。請注意,與并行計算的區別是通信操作使用的資源:CPU和GPU之間的總線。事實上,我們可以在兩個設備上同時進行計算和通信。如上所述,計算和通信之間存在的依賴關系是必須先計算y[i]
,然后才能將其復制到CPU。幸運的是,系統可以在計算y[i]
的同時復制y[i-1]
,以減少總的運行時間。
最后,本節給出了一個簡單的兩層多層感知機在CPU和兩個GPU上訓練時的計算圖及其依賴關系的例子,如 圖12.3.1 所示。手動調度由此產生的并行程序將是相當痛苦的。這就是基于圖的計算后端進行優化的優勢所在。
小結
- 現代系統擁有多種設備,如多個GPU和多個CPU,還可以并行地、異步地使用它們。
- 現代系統還擁有各種通信資源,如PCI Express、存儲(通常是固態硬盤或網絡存儲)和網絡帶寬,為了達到最高效率可以并行使用它們。
- 后端可以通過自動化地并行計算和通信來提高性能。
練習
- 在本節定義的
run
函數中執行了八個操作,并且操作之間沒有依賴關系。設計一個實驗,看看深度學習框架是否會自動地并行地執行它們。
解:
run函數實際是執行了50個矩陣乘法操作,設計實驗比較單個矩陣乘法和用run函數執行50個矩陣乘法的時間,發現用run函數執行50個矩陣乘法的時間小于單個矩陣乘法執行50次的時間,證明深度學習框架會自動地并行地執行它們。
代碼如下:
# 單個矩陣乘法時間基準
with d2l.Benchmark('Single matmul'):x_gpu1.mm(x_gpu1)torch.cuda.synchronize()# 多個獨立矩陣乘法時間
with d2l.Benchmark('Multiple matmuls'):run(x_gpu1)torch.cuda.synchronize()
輸出結果:
Single matmul: 0.0457 sec
Multiple matmuls: 1.4930 sec
2. 當單個操作符的工作量足夠小,即使在單個CPU或GPU上,并行化也會有所幫助。設計一個實驗來驗證這一點。
解:
還是基于矩陣乘法,將x的尺寸設置為10×10的小尺寸,在單個CPU或GPU上,用run函數自動并行的計算時間都更少,說明當單個操作符的工作量足夠小,即使在單個CPU或GPU上,并行化也會有所幫助。
代碼如下:
def benchmark_matmul(size, device):x = torch.randn(size, size, device=device)# 順序執行基準with d2l.Benchmark(f'Size {size}x{size} (Sequential)'):for _ in range(50):_ = x.matmul(x)if device.type == 'cuda': torch.cuda.synchronize()# 自動并行執行(框架隱式優化)with d2l.Benchmark(f'Size {size}x{size} (Auto-Parallel)'):run(x)if device.type == 'cuda':torch.cuda.synchronize()
# 單個CPU
device = torch.device('cpu')
benchmark_matmul(10, device)
輸出結果:
Size 10x10 (Sequential): 0.0518 sec
Size 10x10 (Auto-Parallel): 0.0005 sec
# 單個GPU
devices = d2l.try_all_gpus()
benchmark_matmul(10, devices[0])
輸出結果:
Size 10x10 (Sequential): 0.0025 sec
Size 10x10 (Auto-Parallel): 0.0010 sec
3. 設計一個實驗,在CPU和GPU這兩種設備上使用并行計算和通信。
解:
本節的12.3.2中的實驗可以說明,在CPU和GPU這兩種設備上可以同時進行并行計算和通信,減少總體運行時間。
4. 使用諸如NVIDIA的Nsight之類的調試器來驗證代碼是否有效。
解:
沒有Nsight,改用Pytorch的Profiler驗證,從Profiler打印的結果表格中可以看到,多個任務的Self CUDA %都是100%,說明確實進行了并行計算。
代碼如下:
from torch.profiler import ProfilerActivitywith torch.profiler.profile(activities=[ProfilerActivity.CUDA, ProfilerActivity.CPU],schedule=torch.profiler.schedule(wait=1, warmup=1, active=3),on_trace_ready=torch.profiler.tensorboard_trace_handler('./log')
) as prof:for _ in range(5):run(x_gpu1)run(x_gpu2)torch.cuda.synchronize()prof.step()
print(prof.key_averages().table()
輸出結果:
------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ Name Self CPU % Self CPU CPU total % CPU total CPU time avg Self CUDA Self CUDA % CUDA total CUDA time avg # of Calls
------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ProfilerStep* 0.04% 1.745ms 100.00% 4.440s 1.480s 0.000us 0.00% 8.837s 2.946s 3 aten::mm 0.13% 5.705ms 0.17% 7.698ms 25.659us 8.837s 100.00% 8.837s 29.458ms 300 cudaOccupancyMaxActiveBlocksPerMultiprocessor 0.00% 202.240us 0.00% 202.240us 0.674us 0.000us 0.00% 0.000us 0.000us 300 cudaLaunchKernel 0.04% 1.790ms 0.04% 1.790ms 5.968us 0.000us 0.00% 0.000us 0.000us 300 ProfilerStep* 0.00% 0.000us 0.00% 0.000us 0.000us 8.838s 100.00% 8.838s 1.473s 6 volta_sgemm_128x64_nn 0.00% 0.000us 0.00% 0.000us 0.000us 8.837s 100.00% 8.837s 29.755ms 297 cudaDeviceSynchronize 99.79% 4.430s 99.79% 4.430s 1.108s 0.000us 0.00% 0.000us 0.000us 4
------------------------------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
Self CPU time total: 4.440s
Self CUDA time total: 8.837s
5. 設計并實驗具有更加復雜的數據依賴關系的計算任務,以查看是否可以在提高性能的同時獲得正確的結果。
解:
以下實驗可以證明,在利用并行提高計算性能的同時,獲得與串行一致的結果。
代碼如下:
# 構建復雜依賴關系:
# A → B → C
# │ │
# ↓ ↓
# D → E → Fimport timedef task(x, name):"""模擬不同計算任務"""if name == 'A': return x @ x.Telif name == 'B': return x * x.sum()elif name == 'C': return x.cos() + x.sin()elif name == 'D': return x.pow(2).mean()elif name == 'E': return x.norm(dim=1)elif name == 'F': return x.softmax(dim=0)def serial_execution(x):"""串行執行(嚴格按依賴順序)"""a = task(x, 'A')b = task(a, 'B')d = task(a, 'D')c = task(b, 'C')e = task(b, 'E')f = task(e, 'F')return c, d, fdef parallel_execution(x):"""并行執行(重疊無依賴任務)"""# 第一層并行stream1 = torch.cuda.Stream()stream2 = torch.cuda.Stream()with torch.cuda.stream(stream1):a = task(x, 'A')torch.cuda.synchronize() # 確保a完成with torch.cuda.stream(stream1):b = task(a, 'B')with torch.cuda.stream(stream2):d = task(a, 'D') # 與b無依賴,可并行torch.cuda.synchronize() # 等待b,d完成with torch.cuda.stream(stream1):c = task(b, 'C')with torch.cuda.stream(stream2):e = task(b, 'E') # 依賴b,但c/e之間無依賴torch.cuda.synchronize() # 等待e完成f = task(e, 'F')return c, d, f
def run_test(matrix_size=1000):x = torch.randn(matrix_size, matrix_size, device='cuda')# 串行執行torch.cuda.synchronize()start = time.time()c_serial, d_serial, f_serial = serial_execution(x)torch.cuda.synchronize()serial_time = time.time() - start# 并行執行torch.cuda.synchronize()start = time.time()c_parallel, d_parallel, f_parallel = parallel_execution(x)torch.cuda.synchronize()parallel_time = time.time() - start# 結果對比def check_equal(t1, t2):return torch.allclose(t1, t2, rtol=1e-4, atol=1e-6)is_correct = (check_equal(c_serial, c_parallel) and check_equal(d_serial, d_parallel) and check_equal(f_serial, f_parallel))print(f"矩陣大小: {matrix_size}x{matrix_size}")print(f"串行時間: {serial_time:.4f}s")print(f"并行時間: {parallel_time:.4f}s")print(f"加速比: {serial_time/parallel_time:.2f}x")print(f"結果一致: {is_correct}")
run_test(matrix_size=1000)
輸出結果:
矩陣大小: 1000x1000
串行時間: 0.3834s
并行時間: 0.0062s
加速比: 61.86x
結果一致: True