Przejdź do treści

03. Dobre praktyki

Ogólne

  1. Przed implementacją - sprawdzić czy istnieje już gotowe rozwiązanie:

    • łatwiej dostosować coś, co już istnieje, niż tworzyć wszystko od zera,
    • włączenie się w opensource, zarówno przez korzystanie z dostępnego oprogramowania jak i udostępnianie swojego kodu.
  2. Stopniowo, pisać tak by umożliwić sobie późniejszą optymalizację.

  3. Często profilować kod aby poprawnie identyfikować fragmenty, które “zajmują dużo czasu” i w razie potrzeby należy optymalizować je.

  4. Weryfikować stopień wykorzystania zasobów sprzętowych przez narzędzia takie jak htop, nvidia-smi, nvtop, roc-smi, itp.

  5. Wykrywanie opóźnień - najczęściej związane z:

    • pobieraniem danych z RAM do CPU,
    • operacjami I/O - np. wczytywaniem danych z dysku,
    • komunikacją sieciową.
  6. Wielowątkowość “zadaniowa”: niezależne części aplikacji (I/O, preprocessing, processing, kroki algorytmu) albo w ogóle całość mogą przebiegać równolegle w różnych wątkach na przykład dla kolejnych porcji danych. Nie wymaga zrównoleglania samego algorytmu, a może przynieść korzyści. Można wykorzystać API do wielowątkowości dostępne w C++ i/lub MPI.

  7. Zalecamy weryfikację wskaźników efektywności obliczeniowej (efficiency) będący stosunkiem sumy skonsumowanego czasu CPU do czasu trwania zadania przemnożonego przez ilość rdzeni. Wartości poniżej 90% warto zbadać w celu określenia przyczyn. Najczęstszymi powodami niskiej efektywności są:

    • nadmierna ilość czasu oczekiwania w operacjach dyskowych I/O związana z brakiem optymalizacji tego aspektu działania aplikacji,
    • zbyt duży narzut komunikacyjny związany np z operacjami MPI lub inną formą wymiany danych między procesami,
    • zbyt duża ilość procesów w stosunku do możliwości zrównoleglenia się algorytmu.

    Rekomendowane narzędzia pomocne w określeniu przyczyn niskiej efektywności w takim przypadku to Linaro MAP oraz perf. W przypadku podejrzenia problemów z I/O pomocnymi narzędziami są pakiety strace oraz darshan.

  8. Weryfikacja sposobu prowadzenia obliczeń - dla każdego nowego typu zadań wskazane jest przeprowadzenie testu skalowalności i dobór zasobów tak aby stosunek szybkości przetwarzania i kosztu obliczeń (ilości godzin cpu) był optymalny. Przykładami niech będzie:

    • Oprogramowanie chemiczne - granice skalowalności dla niektórych kodów zależą od użytych metod obliczeniowych, wielkości układu oraz baz funkcyjnych go opisujących oraz dostępnych zasobów (m.in. zbyt mało pamięci operacyjnej może powodować zmianę algorytmu obliczeniowego) . Zmiana metody - intencjonalna bądź automatyczna może powodować istotny spadek wydajności po przekroczeniu pewnej ilości rdzeni (tak się dzieje np. dla programu Amber),
    • Ilość dostępnej pamięci RAM może powodować zmianę algorytmu na mniej efektywną wersję. Zmiana specyfikacji zadania może spowodować degradację lub poprawę szybkości działania pakietu (tak się dzieje np. dla programu ORCA).
  9. Niezwykle ważne jest odpowiednie przypinaniu procesów i wątków do odpowiednich rdzeni obliczeniowych. Większość nowoczesnych systemów komputerowych ma wiele kontrolerów pamięci (również w pojedynczym procesorze) przez co czas dostęp do różnych fragmentów pamięci będzie różny (tzw. architektura NUMA), a wykorzystanie wszystkich dostępnych kontrolerów i kanałów dostępu zwiększy przepustowość pamięci. Dodatkowo grupy rdzeni zwykle współdzielą pamięć podręczną L3, zatem efektywne rozłożenie procesów i wątków może przyspieszyć dostęp do danych. Jest to niezwykle ważne przy rozkładaniu procesów i wątków w obrębie węzła obliczeniowego.

  10. Przy testach skalowalności należy sprawdzać nie tylko parametry obciążenia zasobów lecz przede wszystkim uzyskiwane przyspieszenie obliczeń mierzone jako stosunek całkowitego czasu wykorzystanego przez obliczenia na różnej ilości zasobów. Generowanie dużego obciążenia zasobów jak CPU, GPGPU, storage nie musi być jednoznaczne z efektywnym prowadzeniem obliczeń. Przykłady działań kiedy występuje taka niekorzystna sytuacja to np. tzw. busy wait dla CPU, transfery do i z pamięci GPGPU, odczyt losowych danych małym blokiem. Najlepszym wskaźnikiem efektywności obliczeń jest to jak szybko program uzyska końcowy wynik.

Obliczenia na GPU

  1. Dzielenie obliczeń na małe, niezależne fragmenty, tak aby zmaksymalizować liczbę wątków (elementów gridu obliczeniowego). Długie pętle w kernelach często oznaczają możliwość dalszego zrównoleglania obliczeń.

  2. Należy maksymalnie wysycić jednostki obliczeniowe GPU maksymalizując liczbę obliczeń mogących być przeprowadzanych równolegle.

  3. Używanie najniższej precyzji obliczeń (DP/SP/HP, bfloat itp. lub mieszanej) możliwej do zastosowania dla danego problemu przy akceptowalnym poziomie błędów numerycznych.

  4. Transfery pamięci pomiędzy pamięcią CPU a GPU zajmują stosunkowo dużo czasu. Warto minimalizować objętość transferowanych danych oraz, w miarę możliwości, przeprowadzać transfery równolegle z obliczeniami.

  5. Korzystanie z rozwiązań typu unified memory wprowadza automatyczne transfery pamięci. Warto sprawdzić czy przeprowadzane są one w sposób efektywny.

  6. Jeśli tylko to możliwe, należy utrzymywać sekwencyjny dostęp do pamięci.

  7. Należy korzystać z dostępnych poziomów pamięci (global, shared, constant).

  8. Po przeniesieniu danych na GPU należy przeprowadzić na nich jak najwięcej operacji numerycznych, tak by tzw. koszt obliczeń znacząco przekraczał koszt/czas transferu danych.

  9. Jeśli to tylko możliwe, należy unikać nadmiernego używania instrukcji warunkowych w kernelach. Wątki grupowane są w tzw. warpy/wavefronty wykonujące równolegle te same instrukcji. Jeśli wątki w warpie rozbiegają się, wydłuża to wykonanie całego warpa.

  10. Niezwykle ważna jest znajomość topologii systemu obliczeniowego, tak by wykorzystywać do obliczeń najbliższe sobie rdzenie obliczeniowe, karty GPU oraz karty sieciowe (NIC).

  11. Podczas wykorzystywania wielu kart GPU w obliczeniach należy komunikować się bezpośrednio między nimi (tzw. peer-to-peer, GPU-Direct dla CUDA).

Dostęp do danych (I/O)

  1. Rozproszone systemy plików takie jak Lustre, stosowane w HPC posiadają duży narzut związany z operacjami na metadanych oraz operacjami otwarcia, zamknięcia pliku wynikający z mechanizmów synchronizacji. Narzut ten jest znacznie większy (rząd wielkości) niż w przypadku lokalnych dysków SSD. Wielokrotne otwieranie i zamykanie tego samego pliku może w bardzo istotny sposób spowolnić obliczenia.

  2. Z punktu widzenia wydajności rozproszonych systemów plików najlepszym sposobem dostępu jest dostęp sekwencyjny. Należy zadbać aby blok danych w operacji I/O byl odpowiednio duży. Dla Lustre optymalnymi wartościami są 1MB lub 4MB.

  3. Używanie archiwizera tar na systemie plików Lustre w domyślnej konfiguracji programu nie jest optymalne z uwagi na mały blok ustawiony dla operacji I/O. Zaleca się korzystanie z parametru --blocking-factor=2048 oznaczającego prace z blokami 1M. Rekomendowane ustawienie pozwala uzyskać nawet kilkunastokrotne przyspieszenie pracy z archiwami .tar.

  4. Należy używać przestrzeni dyskowej zgodnie z przeznaczeniem:

    1. Przestrzeń Scratch powinna być używana przy większości obliczeń, w szczególności do obliczeń wymagających najszybszych transferów i najniższych opóźnień.
    2. Przestrzeń projektowa powinna być używana do długotrwałego przechowywania danych.
    3. Przestrzeń katalogu domowego nie powinna być używana podczas obliczeń.
  5. Podczas pracy na wielu małych plikach warto użyć kontenera by odczytać je szybko większymi blokami.

  6. Należy unikać korzystania z dużej liczby małych plików. Lepiej zastosować formaty zapisu danych (np. HDF) umożliwiające odpowiednie ustrukturyzowanie i opisywanie danych wewnątrz pojedynczych dużych plików, które umożliwiają efektywny dostęp i zapis danych, w tym zapis równoległy oparty o MPI-IO.

  7. Jeżeli dane mieszczą się w pamięci węzła obliczeniowego, a z jakichś powodów program wymaga by były w postaci plików zapisanych w systemie plików, wykorzyrzystaj RAMdysk.

Systemy kolejkowe

  1. Unikaj zadań długotrwałych, których czas przekracza limit standardowej kolejki. Długie zadania są trudniejsze do zaplanowania, spędzą więcej czasu w kolejce i czas uzyskania wyniku wydłuża się.

  2. Dobierz odpowiedni rozmiar zadania, mając na uwadze efektywność wykorzystania zasobów, czas trwania obliczeń i spodziewany czas w kolejce. Im mniej zasobów tym zadanie zostanie uruchomione szybciej, ale obliczenia będą przebiegać wolniej. Przy większym zadaniu czas kolejkowania będzie dłuższy, zapewnienie dobrej efektywności obliczeń może być trudniejsze, ale jest szansa na istotne skrócenie czasu do uzyskania wyniku.

  3. Upewnij się, że wszystkie zasoby zadeklarowane dla zadania, np. rdzenie obliczeniowe, karty GPGPU i pamięć są używane przez aplikację. Nieużywane, a zadeklarowane zasoby niepotrzebnie zwiększają rozmiar zadania, wydłużają czas w kolejce i prowadzą do marnowania zasobów.

  4. Unikaj zadań które używają zasobów nieproporcjonalnie do konfiguracji węzła obliczeniowego. Np. zajęcie jednego rdzenia i całej pamięci spowoduje, że pozostałe rdzenie obliczeniowe nie będą mogły pracować nad zadaniami. Najlepsze proporcje zasobów można poznać analizując konfigurację węzła np. ile pamięci przypada na rdzeń obliczeniowy, w ogólności można wszystkie zasoby jednego węzła podzielić na n części. Ma to zastosowanie do rdzeni CPU, kart GPGPU i pamięci operacyjnej.

  5. Używaj kolejek zgodnie z przeznaczeniem, najczęściej występuje kolejka standardowa, dla zadań długotrwających, dla zadań testowych, do pracy interaktywnej i specyficzne kolejki dla danego typu akceleratora jak karty GPGPU.

  6. Weryfikuj czas wykonania zadań danego typu i zadbaj aby deklarowany czas dla zadania w systemie kolejkowym był nie większy niż czas rzeczywisty z marginesem np 30%. Ułatwi to zaplanowanie wykonania zadania i może przyspieszyć jego start.

  7. Używanie zadań tablicowych (Slurm Array Jobs) pozwala na utworzenie wielu instancji jednego zadania. W przypadku krótkich obliczeń narzut czasowy związany z uruchomieniem zadania może stanowić istotną część czasu potrzebnego do uzyskania wyniku. Najlepsza, minimalna długość zadania to kilka lub kilkanaście minut, jeżeli zadanie nie spełnia tego warunku warto rozważyć:

    • użycie pojedynczego zadania wraz ze zrównolegleniem obliczeń wewnątrz w oparciu mechanizm stepów, tj. srun w kombinacji z xargs z parametrem -P lub pakietem gnu parallel,
    • użycie rozwiązania aleternatywnego z własnym schedulingiem (np. HyperQueue).

Linki


Ostatnia aktualizacja: 18 grudnia 2023