Optymalizacja Sieci Neuronowych I/O - DGX research w TEONITE.

W artykule znajdziesz wyniki badań nad sieciami neuronowymi, które przeprowadziliśmy między komercyjnymi projektami z Data Science i Machine Learning. Artykuł zawiera benchmarki kilku metod przechowywania danych. Głównym celem projektu była optymalizacja I/O, ponieważ prawie każdy projekt, który wykonujemy dla klientów wymaga procesowania znacznych ilości danych.

Jednym z kluczowych czynników efektywnej pracy nad sieciami neuronowymi jest czas, potrzebny na etap ich uczenia (w krótszym czasie jesteśmy w stanie przeprowadzić więcej iteracji). W naszych ostatnich badaniach połączyliśmy siły z Servodata, oficjalnym partnerem NVIDIA w Polsce i dzięki temu przeprowadziliśmy eksperymenty na DGX STation - jednej z najbardziej wydajnych i dostępnych maszyn, przystosowanych do deep learningu.

Oto specyfikacja tej maszyny:
alt

TUTAJ możecie przeczytać więcej lub pobrać raport, opisujący jego architekturę.

Największą zaletą tego układu są 4 karty Tesla V100 połączone przez NVLink, które oddają do użytku mniej więcej 500 teraflopów mocy obliczeniowej.

Podczas naszego badania szybko zorientowaliśmy się, że wydajność procesora graficznego nie była już wąskim gardłem. Właściwie, powinniśmy się skoncentrować albo na przeniesieniu wszystkiego na GPU, albo na limitowaniu użycia CPU, tak bardzo jak to tylko możliwe. Dlatego zrobiliśmy krok w tył, aby zastanowić się jak możemy przyspieszyć niektóre rozwiązania w naszym kodzie i w taki sposób powstał DGX Research w TEONITE.

Wybor Frameworka

Pierwszą częścią badań było znalezienie różnych sposobów na przechowywanie danych i sprawdzenie jak mogą zoptymalizować nasz czas uczenia - ich rezultaty możecie znaleźć poniżej.

Jako, że ciągle poszukujemy nowych technologii i pomysłów, chcieliśmy wiedzieć jaki framework będzie najlepszy do takiego typu badań.

Jak z pewnością wiecie, nVIDIA jest wiodącą firmą w dziedzinie badań nad Deep Learning’iem - musieliśmy dowiedzieć się, co rekomendują. Zapytaliśmy jaki framework pythonowy nVIDIA poleca, do efektywnego użycia zasobów stacji DGX, takich jak NV-Link.

Rekomendacja nVIDIA

Niedługo później otrzymaliśmy odpowiedź. Mówiąc ogólnie, wszystkie frameworki mogą korzystać z NVLink, używając backendu NCCL. Jest także istotne, aby biblioteka mogła wykorzystywać procesory typu Tensor Cores, gdyż daje to dodatkowy wzrost wydajności. NVIDIA precyzuje, że Caffe/nvCaffe/Caffe2, PyTorch i Tensorflow with Horovod i MXNet wspiera obie funkcjonalności, lecz jeszcze nie wspiera Tensor Cores.
W międzyczasie próbowałem nauczyć się podstaw najbardziej popularnych frameworków TensorFlow with Keras i PyTorch.

Keras z backendem TensorFlow i PyTorch

Pierwsze rozwiązanie (Keras) było zaskakująco proste i w pewien sposób magiczne. Każdy krok ma odrębną funkcję do użycia i nie musiałem “kopać” w dokumentacji, aby Deep Network działał. To było świetne i zdecydowanie polecałbym to komuś, kto jest nowy i chce stworzyć coś, co działa. Wiedziałem, że potrzebna będzie optymalizacja procesu uczenia się sieci i z tego powodu będę musiał grzebać w kodzie i implementacjach, aby dowiedzieć się, co mogę zoptymalizować. Nie o to mi chodziło. W związku z tym, że wiązało się to z pracą z kodem bibliotek na wielu poziomach abstrakcji, założyłem, że nie będzie to łatwe. Oczywiście, mogłem użyć niskopoziomowego API, ale nie chciałem spędzać zbyt wiele czasu przeglądając dokumentację i rozwiązując problemy, które mogły się pojawić. Potrzebowałem czegoś pomiędzy.

Drugie rozwiązanie (PyTorch) nie było tak proste jak pierwsze, ale było bardzo “pythonowe” - używało natywnych struktur Pythona, jak pętle czy interfejs listy - czyli czegoś na czym się znam. Właśnie dlatego łatwo było zmienić coś w kodzie, nawet podczas tutoriala. Na przykład: interfejs Dataset w PyTorch używamy tak samo jak listę w Pythonie, interfejs Dataloader to znany nam już z Pythona iterator. W przeciwieństwie do TensorFlow, który ułatwia działania na gradiencie metodą train and evaluate, PyTorch zmusza nas do samodzielnego przeprowadzania tych operacji. A jednak, nie było to takie trudne, gdyż jest wiele dobrych i sprawdzonych przykładów w sieci, które można skopiować. Jednak wymaga to sporo boilerplate’u. Nie polecałbym tego rozwiązania jeśli chcesz sprawić, aby coś po prostu działało. Dla moich celów takie działanie było odpowiednie.

Inne

Rozważałem także frameworki Caffe2 i MXNet. Oba oparte są o inne koncepty i są również obecne w wielu dokumentach i tutorialach. Na pewno wrócę do nich w innych badaniach i sprawdzę, który jest najwygodniejszy i może najlepiej wykorzystywać sprzęt.

We wszystkich frameworkach, których używałem, chciałem stworzyć sieć neuronową, która rozwiąże problem “Dogs vs Cats” - ma umiarkowany rozmiar danych, jest łatwy do zrozumienia, a dodatkowo jest całkiem zabawny.

Rozpoczynając research

Jako punkt startowy dla optymalizacji, wziąłem kod stąd i usunąłem z niego argument parsing, aby trochę go wyczyścić. Główną metryką, o którą się obawialiśmy był czas potrzebny na załadowanie jednej partii danych. Używając przykładowego kodu i prostej pythonowej klasy do mierzenia czasu, uruchomiłem sieć i to dało mi pierwsze wyniki jej wydajności.

fullstack_quora

Możecie zobaczyć, że prawie cały czas, potrzebny na iterację uczenia jest zajęty przez ładowanie danych. Zastanawiałem się - dlaczego jest taki wysoki? Dlaczego zajmuje 66ms, żeby załadować pojedynczą partię danych (360kB)? Czy implementacja dataloadera jest tak powolna? Czy może to wina datasetu?

Gdy usunąłem instrukcję print (której logi możecie zobaczyć powyżej) wyniki zmieniły się drastycznie!

alt

Dlaczego instrukcja print trwała tak długo? Wyraźnie widać, że czas ładowania danych nie jest przyczyną tego, że cały proces zajmował tyle czasu - trwał on tylko średnio 1,02ms na partię.

Co zabawne - odkryłem swoja pomyłkę, po tym jak zaimplementowałem kilka różnych implementacji datasetu i nawet swój własny, dedykowany loader!

Benchmark

Główny problem rozwiązany, ale nadal mam tam różne implementacje. Dlaczego by ich nie przetestować i stworzyć benchmarki? Oczywiście, istnieje benchmark opisujący czasy odczytu/zapisu i rozmiary różnych metod przechowywania macierzy, ale nie znalazłem żadnego benchmarka odnoszącego się do ładowania partii danych, a ostatecznie to jest to, co nas interesuje przy pracy z sieciami neuronowymi.

W chwili obecnej mam następujace implementacje bibliotek przechowywania danych do przetestowania:

  • ImageFolder (wbudowana implementacja PyTorch z torchvision.datasets.ImageFolder)
  • HDF5 (dostępne via h5py)
  • Zarr
  • Pliki .npy
  • Pliki .pt.

Ponadto, są trzy rożne dataloadery do przetestowania:

  • Wbudowany wielowątkowy
  • Wbudowany jednowątkowy
  • Customowy.

Jako, że nasz czas na eksperymenty na DGX Station dobiegł końca, musieliśmy użyć naszej maszyny deep learningowej do tego benchmarka. Jej specyfikacja jest podana poniżej.

Class          Description  
==========================
system         MS-7998  
bus            Z170A SLI PLUS (MS-7998)  
memory         64KiB BIOS  
memory         128KiB L1 cache  
memory         128KiB L1 cache  
memory         1MiB L2 cache  
memory         8MiB L3 cache  
processor      Intel(R) Core(TM) i7-6700K CPU @ 4.00GHz  
memory         16GiB DIMM DDR4 Synchronous 2400 MHz (0,4 ns)  
memory         16GiB DIMM DDR4 Synchronous 2400 MHz (0,4 ns)  
display        GP104 [GeForce GTX 1070]  
disk           525GB Crucial_CT525MX3  
disk           2TB WDC WD20EZRX-22D  

W następnych sekcjach znajdziecie więcej informacji o każdym datasecie i customowym loaderze, razem z uzasadnieniem dlaczego zostały wybrane. Jeśli jesteś zainteresowany/a tylko wynikami, przejdź do sekcji Wyniki.

Zbiory danych

Głównym celem pisania różnych implementacji datasetów, było przeprowadzenie tak wielu operacji (jak transformacje), ile się da, zanim jeszcze zaczniemy etap uczenia. W ten sposób zaoszczędzimy trochę czasu w samej pętli i “karmienie” danymi będzie odbywać się szybciej.

Oczywiście, jestem świadomy, że to także ogranicza różnorodność danych. Operacje, takie jak RandomCrop czy RandomHorizontalFlip, mogą polepszyć wyniki uczenia sieci. Operacje dekodowania obrazu czy normalizowania danych, dla nauczonej wcześniej sieci, mogą być przeprowadzone raz, bez negatywnego wpływu. To jest coś, co może być dalej usprawniane.

Wbudowane

Domyślne zachowanie wbudowanych dataset’ów polega na załadowaniu obrazu i wykonaniu transformacji za każdym razem, gdy próbka jest pobierana. To jest to, co chcielibyśmy zoptymalizować.

HDF5

Gdy zaczynałem szukać informacji o tym jak zoptymalizować I/O w PyTorch, znalazłem tę odpowiedź. Dałem jej szansę, bo miała dołączony kod źródłowy i była bardzo dobrze opisana.
Najpierw napisałem prosty notatnik, który stworzył pliki .hdf5 z moich danych. Gdy sprawdzałem, działał bez zarzutu, wartości były prawidłowo zapisywane i czytane, wszystko działo się dosyć szybko. Odczyt jednej macierzy transformowanego obrazu zajmował następujący czas:

CPU times: user 394 µs, sys: 16 µs, total: 410 µs  
Wall time: 413 µs  

Był tylko jeden problem - gdy użyłem tego w sieci neuronowej, pojawiły się błędy.

cuda runtime error (59) : device-side assert triggered at /.../THC/generic/THCStorage.c:32  

Co to jest? Po godzinach poszukiwań, znalazłem ten wątek, który dał mi wskazówkę.

Przewidywane klasy powinny znajdować się w zakresie [0, n_classes], nie powinny przyjmować wartości -1

Więc, sprawdźmy przewidywana klasy.

20184          Label:          0  
18036          Label:          0  
18180          Label:          4557061319748400051  
18139          Label:          0  
19968          Label:          0  
21361          Label:          4574336876509936858  
6011           Label:          4603346812213172903  
16288          Label:          0  
2402           Label:          0  
21021          Label:          0  
8688           Label:          -4641372210688022970  
16819          Label:          -4639454095471887411  
8812           Label:          -4658475019568654485  
17392          Label:          0  
20605          Label:          4592043660878312063  
2743           Label:          -4638287290297841520  
4968           Label:          0  
2162           Label:          -4653799677086183853  

Dlaczego te klasy są takie duże? Współbieżność. Wróciłem do wątku, w którym widziałem hdf5 i znalazłem taką odpowiedź:

Wygląda jakby HDF5 miało jakieś problemy ze współbieżnością. Moja sugestia, aby tego użyć nie jest odpowiednia, gdy kod jest wielowątkowy. Ja często używam jednego wątku, poniewaz moje sieci są intensywne obliczeniowo i nie jestem ograniczony iteratorem danych. Może powinieneś spróbować innych podejść jak Zarr, który został zaprojektowany by działać wielowątkowo.

W tym miejscu pojawiła się następna biblioteka przechowywania danych - Zarr.

Ale, nie skończyłem na tym z HDF5. Chciałem sprawdzić czy mogę sprawić, żeby działał wielowątkowo. Jak przeczytałem w dokumentacji, musiałem zainstalować HDF5 ze źródeł z flagami

--enable-parallel --enable-shared 

Po kilku godzinach miałem gotowy do użycia obraz Docker z HDF5 i h5py zainstalowanymi ze źródeł. Ale rezultat wciąż był ten sam - niezależnie jakiej flagi użyłem, cały czas dostawałem błędy wynikające ze współbieżności. Jeśli znacie odpowiedź jak mogę sprawić, żeby to zadziałało - skontaktujcie się ze mną.

To dlatego mocno odradzam używania HDF5 z więcej niż jednym wątkiem.

Zarr

Jak wspomniałem powyżej, mój pierwszy kontakt z Zarr miał miejsce w tym samym wątku, w którym znalazłem HDF5. Ponadto, podczas researchu o HDF5 znalazłem bloga o nazwie “To HDF5 and beyond”, który został napisany przez twórcę Zarr - Alistair’a Miles’a. Na blogu, opisuje on czego nauczył się z bibliotek HDF5 i Bcolz oraz jak połączył te podejścia w jedną bibliotekę, jednocześnie eliminując ich negatywne strony.

Jednym z głównych elementów tej biblioteki są kawałki (chunks). Dane są podzielone, a partia jest ładowana z dysku, wtedy gdy tego faktycznie potrzebujesz. Moim zdaniem to dobre rozwiązanie - połączenie wielkości kawałka (chunk), z wielkością partii (batch), tak aby jeden kawałek pokrywał wielkość jednej partii danych.

To powód, dla którego implementacja Zarr została podzielona na dwa podejścia:

  1. Pierwsze używa tylko pobierania losowych identyfikatorów danych, bez informacji na temat kawałków,
  2. Drugie, w którym randomizacja jest wykonywana na poziomie kawałka, a nie identyfikatora - wybierając losowo, który kawałek zostanie załadowany. To wymagało napisania całej implementacji Datastore’a, w której musisz najpierw przekazywać rozmiar partii, a cała reszta obraca się dookoła tej wartości.

Pliki .npy / .pt

Ostatnie podejście do zapisywania transformowanych macierzy na dysku i ładowania ich, gdy są potrzebne, było pierwszą rzeczą, która przyszła mi na myśl, gdy zaczynałem ten research. Całość łatwa do zrozumienia i skonfigurowania.

Dataloadery

Na początku nie chciałem nic robić z samymi loaderami, ale po przeczytaniu tego artykułu i spojrzeniu na interfejs oraz implementację Dataloadera, pomyślałem, że mogę zrobić coś prostszego; coś, co nie ma żadnego samplera czy iteratora danych, które mogłyby potencjalnie zwalniać samo ładowanie.

Aby dać możliwie najlepsze porównanie, jako że mój custom loader pracuje na pojedynczym wątku, użyłem wbudowanego loadera na dwa sposoby - pierwszy z 4 wątkami i drugi tylko z 1, dzięki czemu był na tym samym poziomie współbieżności co mój customowy.

Implementacja Zarr Datastore, o której wspominałem wcześniej, także potrzebowała oddzielnego Dataloadera, który wykorzystywał mechanizm kawałków (chunków).

Wyniki

Łącząc wszystko ze sobą, istnieje 6 różnych implementacji datasetów i 4 odrębne loadery. Biorąc pod uwagę, że Zarr Datastore ma swój własny loader i nie może być używany z wbudowanym, daje nam to razem 5 datasetów do testowania przeciwko 3 loaderom oraz datastore ze swoim loaderem.

Kod mierzący czas jest dosyć prosty:

def measure_time(loader):  
    own_data_time = AverageMeter()
    end = time.time()
    for index, (target, label) in enumerate(loader):
        own_data_time.update(time.time() - end)
        end = time.time()
    return own_data_time

Wszystko co robi, to mierzenie ile zajęło załadowanie każdej partii w pętli.

Średni czas ładowania partii (batch) jest pokazany poniżej.

fullstack_quora

Jak widzicie, najlepsza i najgorsza implementacja należy do Zarr i jest ogromna różnica w czasie przy używaniu kawałków (ponad 200 razy szybciej!)

Mój customowy loader działał praktycznie tak samo jak wbudowany z pojedynczym wątkiem i skoro ten drugi daje więcej opcji do konfiguracji, wydaje się być lepszym wyborem.

Co jest interesujące to fakt, że pokawałkowana implementacja Zarr z jednym wątkiem, jest dużo szybsza, niż 4 wątki czytające jednocześnie z czystego pliku Tensor czy wbudowanego datasetu. Jednakże jest to najbardziej skomplikowana implementacja, jaką przeprowadziłem podczas tych badań i może być trudna w utrzymaniu i modyfikacji.

Jest jeszcze jedna rzecz, którą powinniśmy uwzględnić - faktyczny czas na załadowanie danych, gdy sieć neuronowa się uczy. Ten czas jest używany przez wbudowany dataloader w procesie data pre-fetching.

To dlatego w wynikach powyżej, wbudowany współbieżny loader z wbudowanym datasetem potrzebował 17ms, aby załadować partię, podczas gdy, jak wspomniałem w sekcji “Rozpoczynając research”, podczas uczenia zajmowało to tylko 1,02ms na partię.

Dlatego zaktualizowałem kod mierzący czas w taki sposób, że nadal mierzy czas ładowania partii, ale także uczy nową sieć neuronową, używając tych danych, dając dataloaderowi trochę więcej czasu na prefetching.

fullstack_quora

Jak widzicie, najlepszy wynik osiągnął HFD5 - 0.84ms na partię. Porównując to do oryginalnej implementacji, która tym razem zajęła 1.1ms na partię, to o 30% szybciej.

Podsumowanie

Przede wszystkim, bardzo interesujące było kopanie w interfejsach PyTorch i próba zrozumienia jak to działa “pod maską”. Wszystko jest bardzo “pythonowe”, nie ma magicznych funkcji, wszystko jest dobrze udokumentowane i opisane na forach.

Mam też świadomość, że istnieją inne dobre formaty do przechowywania danych (jak LMDB, H2, LeveDB itp.), więc jeśli chcesz spróbować implementacji swojego własnego datasetu PyTorch i zobaczyć jak działa w porównaniu do innych, chętnie dowiemy się o Twoich wynikach, a ja zaktualizuję tę pracę, jak tylko pojawią się nowe rezultaty. Kod użyty do tego benchmarka, włącznie z implementacjami datasetów, funkcjami pomiaru czasu i plikami Dockera do skonfigurowania środowiska - będą dostępne jako open-source.

Ponadto, jeśli masz jakieś wskazówki lub pomysły jak ulepszyć ten benchmark, zgłoś to zagadnienie na GitHubie lub zostaw komentarz tutaj. Może stanie się on inspiracją dla innych, aby stworzyć lepszy benchmark lub implementację!

click to subscribe
hire us

Let’s talk about Mobile Apps

We’d love to design, develop and release them for you.

Highest DevOps Standards

Our team wield the right skills to make things work.

Angular magic in the making

Most flexible development technology for stunnig results.

Web Apps cooked the right way

The ultimate combination of code, design and user experience.

Django REST Framework

TEONITE develops, supports and donates open source projects.