Scopul acestui laborator este de a vă familiariza cu infrastructura de calcul de înaltă performanță (HPC – High Performance Computing) a facultății.
Concret, veți învăța:
La finalul laboratorului ar trebui să puteți lansa un job pe cluster fără ajutor.
Modelul de calcul secvențial (arhitectura von Neumann) a fost suficient pentru multă vreme. Legea lui Moore (o predicție economică din 1965) s-a dovedit adevărată, iar numărul de tranzistori de pe un cip s-a dublat (aproximativ) la fiecare 2 ani. Pe măsură ce tranzistorii se micșorau, energia dinamică necesară comutării lor scădea, la fel și tensiunea de alimentare a cipului. Astfel, procesoarele puteau funcționa, de la o generație la alta, la frecvențe tot mai mari, fără ca puterea consumată să crească. Această observație este cunoscută drept legea de scalare a lui Dennard.
În jurul anului 2004, scalarea Dennard s-a oprit, iar frecvența procesoarelor s-a plafonat. Energia dinamică de comutare a continuat să scadă, dar energia statică (leakage) nu, până când aceasta din urmă a ajuns să domine consumul. Numărul de tranzistori a continuat să crească conform legii lui Moore, dar, odată ce scalarea Dennard a încetat, singura cale de a valorifica această creștere a rămas inovația arhitecturală: mai multe nuclee (core-uri), unități vectoriale mai late, acceleratoare specializate, etc.
În consecință, hardware-ul a devenit tot mai paralel și eterogen. Accentul în designul calculatoarelor s-a mutat de la creșterea continuă a frecvenței procesorului, la ahitecturi de prelucrări paralele: clustere cu memorie distribuită, GPU-uri programabile, unități vectoriale SIMD (Single Instruction Multiple Data), procesoare multi-core. Spre deosebire de paralelismul la nivel de instrucțiune (de exemplu, execuția superscalară), de care programatorul este, în marea majoritate a cazurilor, agnostic, lăsând procesorul să se ocupe de paralelizare, aceste forme de paralelism trebuie exprimate explicit în cod (direct, sau prin compilator). Pentru inginerii software interesați de performanță, programarea paralelă NU mai este opțională.
Domeniul care a dezvoltat și folosește la scară largă aceste arhitecturi se numește calcul de înaltă performanță (high-performance computing, sau, abreviat, HPC).
Un cluster HPC este format din mai multe sisteme interconectate, numite noduri. Înainte de a vedea cum colaborează, trebuie să înțelegem ce se află în interiorul unui singur nod, pentru că aici se întâlnește prima formă de paralelism pe care o vom folosi.
Un nod este, în esență, un sistem multiprocesor: conține mai multe procesoare care partajează același spațiu de adrese. Memoria este văzută de toate aceste procesoare ca un singur bloc, iar orice procesor poate citi sau scrie la orice adresă. Din acest motiv, astfel de sisteme se mai numesc și calculatoare cu memorie partajată (shared memory).
Cel mai simplu sistem de acest fel este modelul SMP (Symmetric Multiprocessor) (Vezi Figura 1). Un SMP are N procesoare care partajează o singură memorie. Sistemul de operare le tratează pe toate la fel, iar costul accesării oricărei variabile din memorie este același, indiferent de procesorul care solicită accesul. Procesoarele sunt, deci, „simetrice”, din punctul de vedere al sistemului de operare și al memoriei.
Figura 1: SMP cu N procesoare care partajează o memorie
Modelul SMP este, însă, o simplificare drastică și nerealistă. Un CPU modern (Vezi Figura 2) este, la bază, tot un sistem multiprocesor (în care fiecare procesor este denumit core). Mai multe nuclee (cores) sunt plasate pe aceeași plăcuță de siliciu și montate într-un singur socket pe placa de bază. Un CPU este optimizat pentru latență mică, adică pentru a finaliza cât mai repede fiecare operație în parte.
Figura 2: Un CPU multi-core tipic, cu 8 nuclee. Fiecare nucleu are cache-uri de nivel 1 pentru date (L1D) și pentru instrucțiuni (L1I), un cache unificat de nivel 2 (L2) și un cache de nivel 3 (L3) partajat. CPU-ul are două controlere de memorie, fiecare cu trei canale de acces la memoria din afara CPU-ului (DRAM).
Memoria unui sistem multi-core este mult mai complicată decât cea din modelul SMP. Toate nucleele văd memoria DRAM ca un singur spațiu de adrese, dar aceasta este mult mai lentă decât nucleele. De aceea, CPU-urile moderne includ regiuni mici de memorie foarte rapidă, integrate în CPU, numite cache-uri.
Cache-urile nu reprezintă spații de adrese separate, ci conțin copii ale unor zone de memorie din DRAM, pe care programatorul nu le accesează direct. Copierea între cache și DRAM se face în blocuri mici, numite linii de cache. O linie de cache tipică are 64 de octeți (suficient pentru 8 numere în virgulă mobilă cu precizie dublă).
Cache-urile sunt organizate în linii pentru că, în multe programe, dacă se accesează valoarea de la adresa N, este probabil ca următoarea valoare accesată să fie cea de la adresa N + 1. Această proprietate se numește localitate spațială: când aducem o linie întreagă din DRAM, aducem și vecinii valorii cerute, iar accesele următoare sunt rapide.
Memoria cache este ierarhică și cuprinde:
Deoarece liniile de cache se mișcă constant prin ierarhie în timpul execuției programului, iar mai multe nuclee pot accesa oricând aceeași linie, există posibilitatea să apară conflicte de acces la memorie. De aceea, CPU-ul are integrat un protocol de coerență a cache-urilor (cache coherency protocol), care se asigură că, în final, toate nucleele vor vedea aceleași valori în memorie.
Cache-urile au dimenisuni reduse. De exemplu, un nod al clusterului haswell al UPB are 2 CPU-uri a câte 8 nuclee fiecare, iar cache-urile au următoarele dimensiuni:
Cu cât ne depărtăm de nucleu, cu atât memoria este mai mare, dar și lentă. Pentru un CPU generic, de performanță ridicată, timpul necesar accesării unei singure valoari din memorie (latența accesului) este:
Aceste valori sunt orientative. Diferența de peste 60 de ori dintre L1 și DRAM justifică existența întregii ierarhii. Un program care reutilizează datele din cache poate rula de zeci de ori mai repede decât unul care accesează mai des DRAM-ul.
Prin urmare, sistemele multiprocesor moderne nu au timp de acces uniform la memorie (ca în modelul SMP), ci o arhitectură de memorie neuniformă, numită NUMA (Nonuniform Memory Architecture). Situația este și mai complicată decât în cazul unui singur CPU multi-core. Serverele performante, în special cele folosite în HPC, conectează adesea mai multe CPU-uri în același nod, prin interconexiuni P2P (point to point) de mare viteză. Un exemplu este prezentat în Figura 3, cu 4 CPU-uri.
Figura 3: Un sistem NUMA cu 4 CPU-uri conectate P2P. DRAM-ul este partajat, iar costul accesării diferitelor regiuni de memorie variază semnificativ.
Fiecare CPU are propriul controler de memorie și propriul bloc de DRAM. Toată memoria nodului este organizată într-un singur spațiu de adrese (partajat) și poate fi accesată de orice nucleu din sistem. Costul accesului variază însă mult: un nucleu accesează rapid liniile de cache mapate la DRAM-ul propriului CPU și mai lent pe cele mapate la DRAM-ul altui CPU, aflat la celălalt capăt al unei interconexiuni.
Datorită protocoalelor de cache coherence și a compilatoarelor moderne, când scriem un program, ne putem gândi inițial că îl scriem pe un model SMP. Ulterior, pentru optimizare, ne folosim de faptul că lucrăm pe un sistem NUMA (de exemplu reordonarea buclelor la înmulțirea matricelor, pentru a refolosi date din cache, Blocked Matrix Multiplication, inițializarea datelor chiar pe nucleele care le vor procesa ulterior, etc.).
Dar ce facem atunci când nevoia de calcul depășește posibilitatea rulării optime pe un sistem NUMA? Aplicațiile moderne din știință și inginerie, precum simulări numerice, antrenare AI, procesare Big Data, necesită resurse de calcul care depășesc cu mult capacitatea unui astfel de sistem, care deja este foarte costisitor de realizat. Soluția este conectarea acestor servere (noduri) în rețea, realizând un cluster HPC. Combinând puterea a zeci sau sute de noduri de calcul, interconectate printr-o rețea internă de mare viteză (de obicei InfiniBand sau Ethernet RDMA, de ordinul sutelor de Gbps), un cluster HPC satisface noile cerințe de calcul intensiv.
Atunci când nodurile unui cluster sunt specializate pentru calcul intensiv (de exemplu, fiecare nod reprezintă un sistem NUMA de CPU-uri multi-core, conectate la GPU-uri performante și RAM de ordinul TB), ele se numesc noduri de calcul (compute nodes), pentru a le deosebi de nodurile utilizate în afara calcului (server nodes). Nodurile nu partajează memorie fizică. Memoria este distribuită în sistem, fiecare nod având propriul spațiu de adrese. Pentru a comunica, nodurile își transmit mesaje. API-ul standard pentru programarea distribuită este MPI (Message Passing Interface).
Modelul cel mai răspândit în HPC este MPI între noduri și OpenMP în interiorul unui nod, numit și modelul hibrid MPI + OpenMP. Cele două niveluri de paralelism din model corespund direct celor două tipuri de memorie de mai sus: memoria partajată din interiorul nodului și memoria distribuită dintre noduri.
Universitățile tehnice dispun adesea de clustere HPC proprii, folosite pentru activități de cercetare și educație. Universitatea Politehnica din București deține un astfel de cluster modern, echipat cu GPU-uri performante (precum NVIDIA H100, A100).
Componentele principale ale clusterului UPB sunt:
Nodurile de calcul sunt organizate în partiții - grupuri de noduri cu configurație hardware omogenă. Partițiile sunt, în schimb, heterogene între ele (de exemplu: o partiție doar cu CPU-uri, una cu GPU-uri A100, alta cu H100, etc.).
Exemplu de afișare a partițiilor:
[nume.student@fep10 ~]$ sinfo -o "%10P %30N %8c %10m %20G %10a" PARTITION NODELIST CPUS MEMORY GRES AVAIL dgxa100 dgxa100-ncit-wn[01-04] 256 2063510 gpu:tesla_a100:8 up dgxh100 dgxh100-precis-wn[01-03] 224 1998908+ gpu:tesla_h100:8 up h200 ucsc-precis-h200-wn[11-17] 256 2321395+ gpu:nvidia_h200:8 up haswell* haswell-wn[29-42] 32 127309 (null) up hd xl675dg10-wn175 96 773225 gpu:tesla_a100:10 up ml sprmcrogpu-wn[140-141] 112 128224 gpu:tesla_a100:2 up sprmcrogpu sprmcrogpu-wn13 64 515093 gpu:rtx_2080ti:8 up ucsx ucsx-ncit-gpu-wn100 64 257158 gpu:tesla_a100:3 up xl xl270-wn[161-162] 56 257138 gpu:tesla_p100:2 up
Explicații coloane:
Asteriscul din dreptul partiției (haswell* în cazul nostru) indică partiția implicită. Dacă nu specificați o partiție, SLURM va aloca jobul pe partiția implicită.
Nu toate partițiile sunt accesibile tuturor utilizatorilor! Puteți verifica accesul vostru folosind următoarele comenzi:
# Vedeți account-ul vostru per partiție, inclusiv timpul maxim de rulare a unui job. # Rândurile fără partiție specificată arată limitele implicite ale account-ului vostru, care se aplică pe orice partiție unde nu există o regulă specifică. [nume.student@fep10 ~]$ sacctmgr show user $USER withassoc format=user%20,account,partition,MaxWallDurationPerJob User Account Partition MaxWall -------------------- ---------- ---------- ----------- nume.student asc 00:10:00 # Vedeți restricțiile unei partiții (de exemplu, ucsx) [nume.student@fep10 ~]$ scontrol show partition ucsx | grep -Ei 'allow|deny' AllowGroups=ALL DenyAccounts=ml,nlp,nlp_highprio,ml_test,dataset-proc,hria AllowQos=ALL
Contul din care faceți parte la această materie (asc) are acces pe partițiile: dgxa100, dgxh100, haswell, ucsx și xl. Celelalte partiții (h200, hd, ml, sprmcrogpu) sunt rezervate unor echipe de cercetare specifice.
asc, student, prof, ml). Fiecare utilizator este asociat la unul sau mai multe account-uri, iar partițiile pot restricționa accesul pe baza acestora prin AllowAccounts / DenyAccounts. Separat, AllowGroups controlează accesul pe baza grupurilor Linux (cele vizibile prin comanda id). Pe clusterul UPB, toate partițiile au AllowGroups=ALL, deci accesul este controlat exclusiv prin account-uri SLURM.
| Partiție | CPU (arhitectură) | Topologie CPU¹ | Noduri NUMA | Frecvență nucleu (bază; boost maxim) |
|---|---|---|---|---|
| dgxh100 | Intel Xeon Platinum 8480C (Sapphire Rapids) | 2 × 56 × 2 = 224 | 2 | 2.0 GHz; 3.8 GHz |
| dgxa100 | AMD EPYC 7742 (Zen 2) | 2 × 64 × 2 = 256 | 8 | 2.25 GHz; 3.4 GHz |
| ucsx | Intel Xeon Gold 6326 (Ice Lake) | 2 × 16 × 2 = 64 | 2 | 2.9 GHz; 3.5 GHz |
| xl | Intel Xeon E5-2680 v4 (Broadwell) | 2 × 14 × 2 = 56 | 2 | 2.4 GHz; 3.3 GHz |
| haswell | Intel Xeon E5-2640 v3 (Haswell) | 2 × 8 × 2 = 32 | 2 | 2.6 GHz; 3.4 GHz |
¹ Socket-uri per nod × nuclee per socket × thread-uri per nucleu = thread-uri per nod.
| Partiție | L1 (date / instrucțiuni)¹ | L2¹ | L3² | DRAM/nod |
|---|---|---|---|---|
| dgxh100 | 48 KB / 32 KB | 2 MB | 105 MB | ~2 TB (DDR5) |
| dgxa100 | 32 KB / 32 KB | 512 KB | 256 MB | ~2 TB (DDR4) |
| ucsx | 48 KB / 32 KB | 1.25 MB | 24 MB | ~512 GB (DDR4) |
| xl | 32 KB / 32 KB | 256 KB | 35 MB | ~256 GB (DDR4) |
| haswell | 32 KB / 32 KB | 256 KB | 20 MB | ~128 GB (DDR4) |
¹ Per nucleu
² Per socket
Pe toate partițiile, linia de cache are 64 B.
Pe AMD EPYC, L3 este împărțit în blocuri de 16 MB, fiecare partajat de câte 4 nuclee; un nucleu nu vede decât 16 MB.
Pe Intel, L3 este partajat de toate nucleele unui socket.
| Partiție | GPU | Arhitectură | GPUs/nod | Topologie GPU¹ | Tensor Cores | Compute Capability | Memorie GPU² |
|---|---|---|---|---|---|---|---|
| dgxh100 | NVIDIA H100 80GB HBM3 | Hopper | 8 | 132 SM/GPU x 128 C/SM = 16896 C/GPU | 528 (Gen 4) | sm_90 | 80 GB HBM3; 5120-bit bus; ~3350 GB/s |
| dgxa100 | NVIDIA A100-SXM4-80GB | Ampere | 8 | 108 SM/GPU x 64 C/SM = 6912 C/GPU | 432 (Gen 3) | sm_80 | 80 GB HBM2e; 5120-bit bus; ~2039 GB/s |
| ucsx | NVIDIA A100-PCIE-40GB | Ampere | 3 | 108 SM/GPU x 64 C/SM = 6912 C/GPU | 432 (Gen 3) | sm_80 | 40 GB HBM2; 5120-bit bus; ~1555 GB/s |
| xl | Tesla P100-PCIE-16GB | Pascal | 2 | 56 SM/GPU x 64 C/SM = 3584 C/GPU | 0 | sm_60 | 16 GB HBM2; 4096-bit bus; ~732 GB/s |
¹ Streaming Multiprocessors (SMs)/GPU x CUDA cores/SM = CUDA cores/GPU
² capacitate tip; bus; bandwidth teoretic
FEP-ul reprezintă nodul de acces către clusterul HPC al universității. Pe scurt, este serverul prin intermediul căruia utilizatorii se conectează la infrastructură pentru a pregăti și lansa job-urile pe resursele de calcul ale clusterului.
FEP-ul are un rol limitat, oferind:
Execuția efectivă a joburilor are loc exclusiv pe nodurile de calcul, care dispun de resursele reale de procesare (CPU, GPU, RAM extins).
Conectarea la FEP se realizează prin SSH:
ssh -X -o ServerAliveInterval=100 user.name@fep.grid.pub.ro
Pe un cluster sunt instalate simultan mai multe versiuni de compilatoare și biblioteci (GCC, CUDA, OpenMPI, etc.). Pentru a evita conflicte și pentru a permite fiecărui utilizator să aleagă versiunile dorite, clusterele HPC folosesc Environment Modules — un sistem care modifică temporar variabilele de mediu (PATH, LD_LIBRARY_PATH) la cerere. Astfel, utilizatorul poate folosi exact versiunea dorită a unui compilator sau a unei biblioteci, fără a afecta sistemul global.
Comenzi uzuale:
module help module avail # afișează modulele disponibile module load libraries/cuda-13.0 # încarcă modulul de CUDA corespunzător module list # arată modulele active în sesiunea curentă module unload libraries/cuda-13.0 # dezactivează un modul module purge # dezactivează toate modulele încărcate
SLURM este scheduler-ul de joburi folosit pe clusterul UPB. Rolul său este să primească cererile utilizatorilor, să le pună în coadă și să aloce resursele disponibile (CPU-uri, GPU-uri, memorie) în mod echitabil. Fiecare cont are restricții per partiție: în primul rând dacă are acces sau nu, timp maxim per job (de exemplu maximum 10 minute pentru conturile voastre, după cum ați văzut mai sus), număr maxim de joburi simultane, număr maxim de noduri folosite per job, limită de RAM per job, etc.
Există două moduri de a lansa joburi pe cluster: sbatch și srun.
sbatch trimite un job în coada SLURM și returnează imediat controlul — jobul rulează asincron, în fundal. Outputul standard se salvează în fișierul specificat prin --output (implicit slurm-<JOB_ID>.out), iar erorile în --error (implicit tot în slurm-<JOB_ID>.out dacă nu e specificat separat).
[nume.student@fep10 ~]$ sbatch hello_pthreads.sh Submitted batch job 125771 [nume.student@fep10 ~]$ cat slurm-125771.out # output-ul apare aici după finalizare Hello from thread 0 Hello from thread 3 Hello from thread 1 ...
srun lansează comanda sincron — terminalul se blochează și așteaptă finalizarea, iar outputul apare direct în consolă. Dacă închideți terminalul, job-ul se oprește. Este util pentru testare rapidă sau sesiuni interactive (srun –pty bash).
[nume.student@fep10 ~]$ srun --cpus-per-task=8 ./hello_pthreads Hello from thread 0 Hello from thread 3 Hello from thread 1 ...
sbatch | srun |
|
|---|---|---|
| Mod execuție | Asincron (fundal) | Sincron (blocant) |
| Output | Fișiere .out / .err | Direct în terminal |
| La închiderea terminalului | Jobul continuă | Jobul se oprește |
| Utilizare tipică | Aplicații complexe | Testare, debugging, interactiv |
Mai jos sunt două programe pe care le vom folosi pentru a înțelege cum funcționează alocarea resurselor în SLURM.
Script sbatch corespunzător:
#!/bin/bash # Nu specificăm --partition, deci SLURM va folosi partiția implicită (haswell) #SBATCH --cpus-per-task=8 # programul creează 8 thread-uri, deci cerem 8 CPU-uri logice gcc -o hello_pthreads hello_pthreads.c -lpthread ./hello_pthreads
Script sbatch corespunzător:
#!/bin/bash #SBATCH --partition=ucsx #SBATCH --gres=gpu:1 module load libraries/cuda-13.0 nvcc -o hello_cuda hello_cuda.cu ./hello_cuda
Submitere:
sbatch hello_pthreads.sh # lansare job CPU sbatch hello_cuda.sh # lansare job GPU
Pentru programul Pthreads, partiția implicită (haswell) este suficientă. Pentru programul CUDA, trebuie specificată o partiție cu GPU (de exemplu ucsx).
# === Submitere și rulare === sbatch hello_cuda.sh # submitere job; returnează un job_id sbatch --partition=ucsx hello_cuda.sh # override partiție din linia de comandă srun --partition=ucsx --gres=gpu:1 ./hello_cuda # rulare interactivă simplă srun --partition=ucsx --gres=gpu:1 --pty bash # sesiune interactivă pe nodul de calcul srun --partition=haswell -w haswell-wnxx --pty bash # rulare interactivă simplă pe un anumit nod (xx se inlocuieste cu un numar anume de sistem) de pe partitia haswell # === Informații despre cluster === sinfo # afișare simplă a partițiilor sinfo -o "%20P %6a %8D %8c %10m %20G %10l %8t %N" # format detaliat sinfo -o '%9P %4c %8z %8X %8Y %8Z' # distribuție sockets/cores/threads per partiție # === Monitorizare joburi === squeue # afișare toate joburile squeue --me # afișare doar joburile tale squeue -p ucsx --state=R --format="%.8i %.10P %.15u %.10T %.10M %.8C %.10m %.5b %.20R" # joburi active pe o partiție # === Oprire joburi === scancel <job_id> # oprește un job specific scancel --me # oprește TOATE joburile tale # === Detalii despre resurse === scontrol show partition <nume> # detalii complete despre o partiție scontrol show node <nume> # detalii despre un nod specific scontrol show job <job_id> # detalii și stare completă a unui job # === Account și limite === sacctmgr show user $USER withassoc format=user%20,account,partition,MaxWallDurationPerJob
scontrol show partition)lscpu și pe un nod GPU comanda nvidia-smi -q. Ce informații ați putut extrage despre CPU/GPU? Comparați cu datele din tabelele de mai sus.scontrol show partition <nume>)sbatch, cât și cu srun. Verificați rezultatele. Care este diferența dintre cele două comenzi?--cpus-per-task=N și o dată fără --cpus-per-task=N, unde N este numărul de thread-uri din program. Comparați timpii de execuție (hint: time ./program) și explicați diferența.