PyTorch提速四倍!提高DALI利用率,創建基於CPU的Pipeline
- 2020 年 2 月 21 日
- 筆記

大數據文摘出品
來源:medium
編譯:趙吉克
在過去的幾年裡,深度學習硬體方面取得了巨大的進步,Nvidia的最新產品Tesla V100和Geforce RTX系列包含專用的張量核,用於加速神經網路中常用的操作。
特別值得一提的是,V100有足夠的能力以每秒數千張圖的速度訓練神經網路,這使得基於ImageNet數據集小模型在單GPU上訓練只需幾小時,與2012年在ImageNet上訓練AlexNet模型所花費的5天時間相比簡直是天壤之別!
然而,強大的GPU使數據預處理管道不堪重負。為了解決這個問題,Tensorflow發布了一個新的數據載入器:tf.data.Dataset,用C++編寫,並使用基於圖的方法將多個預處理操作鏈接在一起。
另一方面,PyTorch使用在PIL庫上用Python編寫的數據載入器,既方便優靈活,但在速度上有欠缺(儘管PIL-SIMD庫確實稍微改善了這種情況)。
進入NVIDIA數據載入器(DALI):旨在消除數據預處理瓶頸,允許訓練和推理全速運行。DALI主要用於在GPU上的預處理,但是大多數操作也在CPU上有快速實現。本文主要關注PyTorch,但是DALI也支援Tensorflow、MXNet和TensorRT,尤其是TensorRT有高度支援。它允許訓練和推理步驟使用完全相同的預處理程式碼。需注意,不同的框架(如Tensorflow和PyTorch)通常在數據載入器之間有很小的差異,這可能會影響準確性。
本文是Medium上一位部落客展示了一些技術來提高DALI的使用率並創建了一個完全基於CPU的管道。這些技術用於保持長期的記憶體穩定,並且與DALI包提供的CPU和GPU管道相比,可以增加50%的批處理大小。
DALI長期記憶體使用
第一個問題是,RAM的使用隨著訓練時間的增加而增加,這會導致OOM錯誤(即使是在擁有78GB RAM的VM上),並且尚未修正。

唯一解決方案是重新import DALI並每次重構訓練和驗證通道:
del self.train_loader, self.val_loader, self.train_pipe, self.val_pipe torch.cuda.synchronize() torch.cuda.empty_cache() gc.collect() importlib.reload(dali) from dali import HybridTrainPipe, HybridValPipe, DaliIteratorCPU, DaliIteratorGPU <rebuild DALI pipeline>
請注意,使用這種方法,DALI仍然需要大量RAM才能獲得最好的結果。考慮到如今RAM的價格,這並不是什麼大問題。從下表可以看出,DALI的最大批大小可能比TorchVision低50%:

接下來的部分涉及降低GPU佔用率的方法。
構建一個完全基於CPU的Pipeline
讓我們首先看看示例CPU管道。當不考慮峰值吞吐量時,基於CPU的管道非常有用。CPU訓練管道只在CPU上執行解碼和調整大小的操作,而CropMirrorNormalize操作則在GPU上運行。由於僅僅是傳輸輸出到GPU與DALI就使用了大量的GPU記憶體,為了避免這種情況,我們修改了示例CPU管道,使其完全運行在CPU上:
class HybridTrainPipe(Pipeline): def __init__(self, batch_size, num_threads, device_id, data_dir, crop, mean, std, local_rank=0, world_size=1, dali_cpu=False, shuffle=True, fp16=False, min_crop_size=0.08): # As we're recreating the Pipeline at every epoch, the seed must be -1 (random seed) super(HybridTrainPipe, self).__init__(batch_size, num_threads, device_id, seed=-1) # Enabling read_ahead slowed down processing ~40% self.input = ops.FileReader(file_root=data_dir, shard_id=local_rank, num_shards=world_size, random_shuffle=shuffle) # Let user decide which pipeline works best with the chosen model if dali_cpu: decode_device = "cpu" self.dali_device = "cpu" self.flip = ops.Flip(device=self.dali_device) else: decode_device = "mixed" self.dali_device = "gpu" output_dtype = types.FLOAT if self.dali_device == "gpu" and fp16: output_dtype = types.FLOAT16 self.cmn = ops.CropMirrorNormalize(device="gpu", output_dtype=output_dtype, output_layout=types.NCHW, crop=(crop, crop), image_type=types.RGB, mean=mean, std=std,) # To be able to handle all images from full-sized ImageNet, this padding sets the size of the internal nvJPEG buffers without additional reallocations device_memory_padding = 211025920 if decode_device == 'mixed' else 0 host_memory_padding = 140544512 if decode_device == 'mixed' else 0 self.decode = ops.ImageDecoderRandomCrop(device=decode_device, output_type=types.RGB, device_memory_padding=device_memory_padding, host_memory_padding=host_memory_padding, random_aspect_ratio=[0.8, 1.25], random_area=[min_crop_size, 1.0], num_attempts=100) # Resize as desired. To match torchvision data loader, use triangular interpolation. self.res = ops.Resize(device=self.dali_device, resize_x=crop, resize_y=crop, interp_type=types.INTERP_TRIANGULAR) self.coin = ops.CoinFlip(probability=0.5) print('DALI "{0}" variant'.format(self.dali_device)) def define_graph(self): rng = self.coin() self.jpegs, self.labels = self.input(name="Reader") # Combined decode & random crop images = self.decode(self.jpegs) # Resize as desired images = self.res(images) if self.dali_device == "gpu": output = self.cmn(images, mirror=rng) else: # CPU backend uses torch to apply mean & std output = self.flip(images, horizontal=rng) self.labels = self.labels.gpu() return [output, self.labels]DALI管道現在在CPU上輸出一個8位張量。我們需要使用PyTorch來完成CPU-> GPU的傳輸、浮點數的轉換和歸一化。這最後兩個操作是在GPU上完成的,快速並且減少了CPU -> GPU記憶體頻寬需求。在轉移到GPU之前,嘗試過固定這個張量,但是沒有得到任何性能提升,把它和一個預存器放在一起:
def _preproc_worker(dali_iterator, cuda_stream, fp16, mean, std, output_queue, proc_next_input, done_event, pin_memory): """ Worker function to parse DALI output & apply final preprocessing steps """ while not done_event.is_set(): # Wait until main thread signals to proc_next_input -- normally class="ql-long-20347411" style="line-height: 1.7;margin-bottom: 0pt;margin-top: 0pt;font-size: 11pt;color: #494949;"> proc_next_input.wait() proc_next_input.clear() if done_event.is_set(): print('Shutting down preproc thread') break try: data = next(dali_iterator) # Decode the data output input_orig = data[0]['data'] target = data[0]['label'].squeeze().long() # DALI should already output target style="line-height: 1.7;margin-bottom: 0pt;margin-top: 0pt;font-size: 11pt;color: #494949;"> # Copy to GPU and apply final processing in separate CUDA stream with torch.cuda.stream(cuda_stream): input = input_orig if pin_memory: input = input.pin_memory() del input_orig # Save memory input = input.cuda(non_blocking=True) input = input.permute(0, 3, 1, 2) # Input tensor is kept as 8-bit integer for transfer to GPU, to save bandwidth if fp16: input = input.half() else: input = input.float() input = input.sub_(mean).div_(std) # Put the result class="ql-long-20347411" style="line-height: 1.7;margin-bottom: 0pt;margin-top: 0pt;font-size: 11pt;color: #494949;"> output_queue.put((input, target)) except StopIteration: print('Resetting DALI loader') dali_iterator.reset() output_queue.put(None) class DaliIteratorCPU(DaliIterator): """ Wrapper class to decode the DALI iterator output & provide iterator that functions in the same way as TorchVision. Note that permutation to channels first, converting from 8-bit integer to float & normalization are all performed style="line-height: 1.7;margin-bottom: 0pt;margin-top: 0pt;font-size: 11pt;color: #494949;"> pipelines (Pipeline): DALI pipelines size (int): Number of examples in set fp16 (bool): Use fp16 as output format, f32 otherwise mean (tuple): Image mean value for each channel std (tuple): Image standard deviation value for each channel pin_memory (bool): Transfer input tensor to pinned memory, before moving to GPU """ def __init__(self, fp16=False, mean=(0., 0., 0.), std=(1., 1., 1.), pin_memory=True, **kwargs): super().__init__(**kwargs) print('Using DALI CPU iterator') self.stream = torch.cuda.Stream() self.fp16 = fp16 self.mean = torch.tensor(mean).cuda().view(1, 3, 1, 1) self.std = torch.tensor(std).cuda().view(1, 3, 1, 1) self.pin_memory = pin_memory if self.fp16: self.mean = self.mean.half() self.std = self.std.half() self.proc_next_input = Event() self.done_event = Event() self.output_queue = queue.Queue(maxsize=5) self.preproc_thread = threading.Thread( target=_preproc_worker, kwargs={'dali_iterator': self._dali_iterator, 'cuda_stream': self.stream, 'fp16': self.fp16, 'mean': self.mean, 'std': self.std, 'proc_next_input': self.proc_next_input, 'done_event': self.done_event, 'output_queue': self.output_queue, 'pin_memory': self.pin_memory}) self.preproc_thread.daemon = True self.preproc_thread.start() self.proc_next_input.set() def __next__(self): torch.cuda.current_stream().wait_stream(self.stream) data = self.output_queue.get() self.proc_next_input.set() if data is None: raise StopIteration return data def __del__(self): self.done_event.set() self.proc_next_input.set() torch.cuda.current_stream().wait_stream(self.stream) self.preproc_thread.join()基於GPU的Pipeline
測試中,在類似最大批處理大小下,上述CPU管道的速度大約是TorchVision數據載入器的兩倍。CPU管道可以很好地與像ResNet50這樣的大型模型一起工作;然而,當使用像AlexNet或ResNet18這樣的小模型時,CPU更好。GPU管道的問題是最大批處理大小減少了近50%,限制了吞吐量。
一種顯著減少GPU記憶體使用的方法是將驗證管道與GPU隔離直到最後再調用。這很容易做到,因為我們已經重新導入DALI,並在每個epoch中重新創建數據載入器。
更多小提示
在驗證時,將數據集均分的批處理大小效果最好,這避免了在驗證數據集結束時還需要進行不完整的批處理。
與Tensorflow和PyTorch數據載入器類似,TorchVision和DALI管道不會產生相同的輸出—您將看到驗證精度略有不同。我發現這是由於不同的JPEG影像解碼器。另一方面,DALI支援TensorRT,允許使用完全相同的預處理來進行訓練和推理。
對於峰值吞吐量,嘗試將數據載入器的數量設置為number_of_virtual_CPU核心,2個虛擬核對應1個物理核。
如果你想要絕對最好的性能並且不需要有類似TorchVision的輸出,嘗試關閉DALI三角形插值。
不要忘記磁碟IO。確保您有足夠的記憶體來快取數據集和/或一個非常快的SSD。DALI讀取高達400Mb/s !
合併
為了方便地集成這些修改,我創建了一個data loader類,其中包含這裡描述的所有修改,包括DALI和TorchVision後端。使用很簡單。實例化數據載入程式:
dataset = Dataset(data_dir, batch_size, val_batch_size workers, use_dali, dali_cpu, fp16)
然後得到訓練和驗證數據裝載器:
train_loader = dataset.get_train_loader() val_loader = dataset.get_val_loader()
在每個訓練周期結束時重置數據載入器:
dataset.reset()
或者,驗證管道可以在模型驗證之前在GPU上重新創建:
dataset.prep_for_val()
基準
以下是使用ResNet18的最大批量大小:

因此,通過應用這些修改,DALI可以在CPU和GPU模式下使用的最大批處理大小增加了約50%!
這裡是一些使用Shufflenet V2 0.5和批量大小512的吞吐量圖:

這裡是一些使用DALI GPU管道訓練各種網路,包括在TorchVision:

所有測試都在GoogleCloud V100實例上運行,該實例有12個vcpu(6個物理核心),78GB RAM,並使用Apex FP16培訓。要重現這些結果,請使用以下參數:
— fp16 — batch-size 512 — workers 10 — arch 「shufflenet_v2_x0_5 or resnet18」 — prof — use-dali
所以,DALI使得單核特斯拉V100可以達到接近4000張/秒的影像處理速度!這達到了Nvidia DGX-1的一半多一點(它有8個V100 gpu),儘管我們使用了小模型。對我來說,能夠在幾個小時內在一個GPU上運行ImageNet是生產力進步。
本文中提供的程式碼如下:
https://github.com/yaysummeriscoming/DALI_pytorch_demo
相關報道:
https://towardsdatascience.com/nvidia-dali-speeding-up-pytorch-876c80182440

