'Y'

Cum masuram performanta in aplicatiile Java?

Performanta este un criteriu important pe care fiecare software developer sau arhitect ar trebui sa o aiba in vedere atunci cand dezvolta si implementeaza cerinte non-functionale. Uneori este foarte greu sa faci tuning si sa imbunatatesti o aplicatie matura si complexa, deoarece performanta acesteia poate fi influentata de foarte multi factori.

May 3, 2017 2348
Performanta este un criteriu important pe care fiecare software developer sau arhitect ar trebui sa o aiba in vedere atunci cand dezvolta si implementeaza cerinte non-functionale. Uneori este foarte greu sa faci tuning si sa imbunatatesti o aplicatie matura si complexa, deoarece performanta acesteia poate fi influentata de foarte multi factori.

Solutia la aceasta situatie este sa stii exact cum sa izolezi componentele externe care nu pot fi imbunatatite prea mult deoarece nu intra in aria de control a aplicatiei (network sau middleware systems spre exemplu). Important este sa te concentrezi pe acele componente pe care le poti imbunatati. Dupa ce am decis ce anume vom masura, urmatorul pas este sa ne asiguram ca vom face acest lucru corect. Masurarea performantei are caracteristici specifice fiecarui limbaj de programare si implica o intelegere foarte buna atat din punct de vedere software cat si hardware.

In acest articol vom discuta despre o serie de reguli care sunt utile pentru masurarea performantei la nivelul aplicatiilor Java, independent de orice alte sisteme externe.

Cum masuram performanta in aplicatiile Java.jpeg


Metrici

Atunci cand masuram performanta, in principiu ne uitam la doua lucruri:

  1. Numarul de operatiuni efectuate intr-o anumita perioada de timp (throughput)
  2. Timpul de raspuns sau latenta, mai exact cat dureaza operatiunile (response time)
Uneori este mai usor sa imbunatatesti numarul de operatiuni (crescand numarul de thread-uri si ruland algoritmul in paralel) dar acest lucru poate duce la un timp de raspuns crescut (e.g. thread context scheduling la nivelul sistemului de operare). Exista alte situatii in care timpul de raspuns nu mai poate fi imbunatatit (e.g. dureaza o ora pentru a rula cu succes algoritmul pe un thread datorita faptului ca nu poate fi paralelizat). In schimb ne putem concentra pe cresterea numarului de operatiuni daca timpul de raspuns nu conteaza – din moment ce nu mai poate fi imbunatatit.

Incercati sa gasiti un model (e.g. throughput sau response time) acceptabil pe care sa il folositi ca referinta inainte sa masurati performanta. Fara o referinta este greu sa obtinem si sa imbunatatim performanta aplicatiei.

Warmup iterations

Indiferent de situatie, nu uitati de warmup iteration atunci cand testati si masurati performanta. Aceste interatii ar trebui sa fie scurte si declansate repetat in cicluri la inceputul fiecarui test pentru a duce codul intr-o situatie stabila. Un exemplu de buna practica este sa avem ~10 cicluri a cate ~15k iteratii per ciclu (deoarece ~15k este pragul pentru Just-In-time C2 Compiler). Astfel putem sa fim siguri de stabilitatea codului aplicatiei.

Daca aceasta etapa este omisa, testul masoara codul interpretat si nu cel compilat. Diferenta este ca un cod compilat este de aproximativ 10 ori (ajungand pana la 100 ori, in unele situatii) mai rapid ca unul interpretat. Mai mult, daca aceste warmup iterations nu sunt bine definite (in termeni de cicluri), atunci cand performanta este masurata, rezultatele ar putea fi influentate de Just-In-Time Compiler overhead (deoarece codul nu este intr-o faza stabila).

Cateva optimizari referitoate la Just-In-Time Compiler pe care ar trebui sa le avem in vedere atunci cand masuram performanta in Java sunt:

  1. Optimizations si de-optimizations pentru virtual method calls. Acest lucru se intampla cand un virtual method call este optimizat si inlined dar, dupa cateva invocari, este de-optimizat si re-optimizat pentru inca o implementare (imaginati-va ca avem o declaratie de metoda in cadrul unei interfete cu implementari multiple). Acest tip de comportament are loc la inceputul aplicatiei atunci cand Just-In-Time Compiler face foarte multe presupuneri si optimizari / de-optimizari intr-un mod agresiv.
  2. On-Stack-Replacement (OSR). Java Virtual Machine incepe sa execute codul in modul Interpreter. Daca avem un long running loop in interiorul unei metode care este interpretata, la un anumit punct executia sa va fi oprita si inlocuita cu codul compilat inainte ca loop-ul sa fie finalizat. Exista insa o diferenta intre codul compilat natural si un cod compilat datorita OSR (in acest caz codul din afara loop-ului nu este inca optimizat la maxim si nu exista suficiente detalii de profiling despre el). Din acest motiv codul compilat OSR ar trebui evitat deoarece este mai putin eficient in situatiile reale.
  3. Loop unroling si lock coarsening. In timpul unui loop unrolling, Compiler-ul va reduce numarul de jump instructions pentru a minimiza costul de executie. Lock coarsening include imbinarea blocurilor sincronizate pentru a executa mai putine sincronizari (care sunt costisitoare). Aceste optimizari sunt foarte puternice si idea este ca Just-In-Time Compiler nu doar optimizeaza codul dar poate sa aplice un grad diferit de optimizare care ar putea sa nu se intample intr-un scenariu real.

Varietate de platforme

Rezultatele testelor efectuate pe o singura platforma nu sunt destul de relevante. Chiar daca avem un punct de evaluare foarte bun, este recomandat sa rulam pe mai multe platforme, pentru a colecta si compara rezultatele inainte de a trage orice concluzii. Diversitatea de implementari de arhitectura hardware (Intel, AMD, Sparc) cu privire la intrinsics (e.g. compare-and-swap sau alte hardware concurrency primitives), CPU sau memorie, pot face diferenta.

Microbenchmark (la nivel de componente)

Testarea la nivel de componente inseamna ca va concentrati pe o arie specifica a codului ignorand orice altceva (ex: masurati cat de repede ruleaza o metoda din clasa). In multe cazuri, astfel de teste de performanta pot sa devina inutile deoarece aceste microbenchmarks optimizeaza agresiv bucati de cod (class method) intr-un fel in care optimizarile in situatii reale nu il pot face. Acest principiu este uneori numit testarea cu microscopul.

Spre exemplu, in cadrul unui test microbenchmark in Java HotSpot Virtual Machine s-ar putea sa avem optimizari specifice doar Just-In-Time C2 Compiler dar in realitate aceste optimizari nu se vor intampla deoarece aplicatia nu ajunge in acea etapa. Ar putea sa ruleze doar cu Just-In-Time C1 Compiler. Atunci cand efectuati teste microbenchmark incercati sa lansati Java Virtual Machine de mai multe ori si sa dati la o parte primele incercari pentru a evita efectele datorate OS caching-ul. Un alt sfat este sa activati testele de indeajuns de multe ori in asa fel incat sa obtineti rezultate relevante din punct de vedere statistic (ex: de 20 sau 30 de ori).

Microbenchmarking este util pentru testarea componentelor standalone (algoritmi de sortare, adaugarea/scoaterea elementelor dintr-o lista). Asta nu inseamna ca putem sa impartim o aplicatie complexa in bucati mai mici pe care sa le testam. Pentru aplicatii mari va sugerez sa utilizati testare macrobenchmark pentru rezultate mai precise.

Macrobenchmark (la nivel de sistem)

Exista cazuri in care testele microbenchmark nu ajuta foarte mult deoarece nu transmit informatii despre numarul de operatiuni sau timpii de raspuns a aplicatiei. In aceste cazuri trebuie sa ne bazam pe macrobenchmarks, pentru a scrie programe reale, pentru a dezvolta incarcari realiste plus configurari de mediu care sa ne permita sa masuram performanta. Datele de test trebuie sa fie similare cu cele folosite in situatii reale, altfel un dataset “fals” va duce la alte metode de optimizare a codului si vom aveam masuratori de performanta care nu sunt realiste.

Un aspect important legat de macrobenchmarks este ca s-ar putea sa declanseze Garbage Collectors intr-un mod nerealist. Spre exemplu, in cadrul testului macrobenchmark s-ar putea sa aveti fie doar Young Generation Collections sau doar cateva Old Generation Collections. In aplicatii reale ciclurile tipice si complete Garbage Collector ar putea fi activate la fiecare ora evitand aceasta latenta adaugata de Garbage Collector. Un alt aspect important este ca in timpul testarii I/O si a bazei de date s-ar putea ca masuratorile sa nu fie facute cum trebuie deoarece in situatii reale I/O si bazele de date sunt resurse commune (cu alte aplicatii sau module externe), si astfel orice fel de bottleneck sau intarziere nu sunt evidentiate in test.

In toate cazurile acesta abordare a testarii necesita foarte multa munca dar cu cat dezvoltarea este mai apropiata de scenariile reale cu atat rezultatele sunt mai bune din punct de vedere al performantei aplicatiei.

Ionut Balosin
Software Architect

Daca iti place acest articol, distribuie-l si prietenilor tai!




Mai ai intrebari?
Contacteaza-ne.
Thank you.
Your request has been received.