neděle 31. prosince 2006

Java 6 – co vězí za zrychlením (mikro benchmark)

V článku Java 6 – co vězí za zrychlením jsme se prokousali teorií a nyní je na čase se podívat na reálný dopad synchronizačních optimalizací.

Abstrakt

Celý benchmark byl koncipován na měření vlivu nové optimalizační techniky JVM zvané lock elision čili vypuštění zbytečných synchronizačních zámků. Pro srovnání výkonu byl zároveň proveden stejný benchmark proti Jave 5 a to včetně zapnutí techniky biased lockingu, která měla snížit synchronizační režii.

Benchmark

Následuje verze benchamrku se zapnutou synchornizací. Pokud se testovalo bez synchronizace, byla použita stejná verze s tím rozdílem, že synchronizační blok byl zakomentován.

public class TestSynOptimizations {
    public static void main(final String args[]) {
        for (int i = 0; i < 10; i++) {
            long startTime = System.nanoTime();
            for (int j = 0; j < 30000; j++) {
                compute();
            }
            long delta = System.nanoTime() - startTime;
            System.out.println("Total time [ns]:" + delta);
        }
    }

    public static void compute() {
        Object lock = new Object();
        int sum = 0;
        for (int j = 0; j < 5; j++) {
            synchronized (lock) {
                sum = sum + 1;
            }
        }
    }
}

Kód metody compute je koncipován tak, aby umožnil HotSpotu provedení odstranění zámku. Tím pádem by se měl setřít rozdíl mezi synchronizovanou nesynchornizovanou verzí.

Měřící prostředí

  • HW notebook Dell D610
  • CPU Intel Pentium M 760 (2.00Ghz w/ 533Mhz FSB)
  • RAM 2048 MB (dual channel)
  • OS Microsoft Windows XP Professional SP 2

Měření proběhlo na Jave 1.6.0-b105 a 1.5.0_10-b03 v HotSpot módu server a vyhrazená velikost heapu byla nastavena v rozmezí 256MB - 128MB (eliminace vlivu GC). Pro každou verzi Javy byl benchmark zkompilován příslušnou verzí kompilátoru. Benchmark byl spouštěn v následujících verzích a konfiguracích JVM (sync a nosync rozlišuje jestli byla v kódu benchmarku použita synchronizace).

  • Java 6.0, sync
  • Java 6.0, sync,-XX:+DoEscapeAnalysis
  • Java 1.5.0_10, sync, -XX:+UseBiasedLocking
  • Java 1.5.0_10, sync
  • Java 6.0, nosync
  • Java 1.5.0_10,nosync

Poznámka ke konfiguraci JVM

  • -XX:+DoEscapeAnalysis zapíná (defaultně vypnuto) od Javy 6 analýzu nutnou k provedení odstranění zámku.
  • -XX:+UseBiasedLocking zapína biased locking (dostupné i v Jave 5 od aktualizace 06)

Naměřené hodnoty

Následující tabulka obsahuje výsledky měření po jednotlivých měřících iteracích. Iterace je průchod tělem nejvyššího cyklu v metodě main. Poslední dva řádky tabulky obsahuje průměrné hodnoty, s tím rozdílem, že poslední řádek nemá do průměru započtené první dvě iterace, které jsou skreslující díky práci HotSpotu.

Výsledky měření
Plná velikost obrázku (cca. 37KB).

Naměřené výsledky v grafu

Výsledky měření
Plná velikost obrázku (cca. 22KB).

První graf je výrazně zkreslený optimalizací HotSpotu, která probíhala během prvních dvouch iteracích, proto má mnohem přesnější vypovídající hodontu graf číslo dva. Z výsledků jasně vyplývá, že odstranění zbytečných zámků v Jave 6 funguje. Rychlost synchronizované verze se zapnutou escape analýzou je totiž stejná s rychlostí nesynchronizované verze. Zajímavý je vliv biased lockingu v Jave 5, který rychlost posouvá na stejnou úroveň synchronizované verze Javy 6.0. Z tohoto výsledku se dá vyvodit, že v Jave 6.0 je biased locking zapnutý defaultně.

Bohužel nemám po ruce žádné konkrétní čísla dopadu těchto synchronizačních optimalizací na reálné aplikace. Schválně říkám reálné, protože vypovídající hodnota tohoto mikro benchmarku slouží spíše k ověření teoretických předpokladů. Nicméně i podle těchto výsledků bych si troufl říci, že pro většinu aplikací přináší Java 6 v podstatě zdarma solidní výkonnostní nárůst.

Tabulku s naměřenými hodnotami a grafy můžete také najít v PDF dokumentu, který je přece jenom vhodnějším médiem pro prezentaci naměřených dat.

Aktualizace: v článku Lži, větší lži a mikrobenchmarky:) uvádí Jarda Kameník na pravou míru jak je to s měřením nesynchronizované verze.

Java 6 – co vězí za zrychlením

O tom, že je Java 6, co do výkonu, opět dále, nemůže být pochyb, to dokazují například testy Davida Dagastine. Mě osobně trochu výkonnost Javy 6 nedala spát, takže jsem se jal hledat informace, co za tím vším je. Posledních několik dní jsem tak strávil, krom studia potřebné teorie, měřením výkonu nových synchronizačních technik JVM.

Type Checking Verifier

Začnu tím, co jsem nijak netestoval, nicméně vliv na výkon to má zcela určitě. Každá JVM má v sobě verifikátor byte kódu, který dělá dvě základní věci, typovou kontrolu a odvození typu. Proč odvození typu? Byte kód samozřejmě obsahuje typové informace (parametry metod, návratové typy), ale neobsahuje je pro lokální proměnné metod. Verifikátor je tak zodpovědný za jejich odvození.

Právě odvození typů je úkol, který je pomalý, paměťově náročný a celkem komplikovaný. Od Javy 6.0 byl verifikátor upraven tak, že odvození typů se přesunulo z odpovědnosti JVM na kompilátor. Děje se tedy ve chvíli kompilace do byte kódu. Díky tomuto rozdělení došlo k dvojnásobnému zrychlení práce verifikátoru.

Nový verifikátor, Type Checking Verifier, může samozřejmě běžet ve původním módu s odvozením typů, pokud byte kód neobsahuje potřebné typové informace a nebo tyto informace nejsou korektní. Pokud tedy testujete svůj kód pod Javou 6, nezapomeňte jej také zkompilovat kompilátorem z Javy 6, jinak se vliv nového verifikátoru neprojeví.

Zdroje

Optimalizace v oblasti synchronizace - teorie

Velký kus práce byl ovšem udělán na HotSpotu resp. JITu (kompilátor do nativního kódu). To se projevuje především v oblasti synchronizovaného přístupu. Synchonizovaným přístupem je myšleno použití klíčového slova Javy synchronized. Ve spojení s objektem, přes který se synchronizuje, dochází k takzvanému vzájemnému vyloučení (mutual exclusion). Nad kritickou sekcí, ohraničenou blokem synchronized a daným objektem, operuje vždy pouze a jedině jedno vlákno.

Objekt (Monitor), nad kterým se synchronizuje, obsahuje takzvaný zámek. Z hlediska JVM spadají zámky do dvou kategorií contended a uncontended (přeložit by se to dalo jako soupeřivé a nesoupeřivé, ale zůstanu u anglické terminologie).

contended
Jedná se o zámky, u kterých velice často dochází k souběhu vláken a k jejich "soupeření". Těmto zámkům lze také říkat horké. V aplikacích jsou to typicky zámky nad částmi kódu, které jsou hojně souběžně přistupované. Typicky například pool databázových připojení.
uncontended
Jedná se o zámky, na kterých nedochází k souběhu takřka vůbec. Tedy nedochází k tomu, že při pokusu o získání zámku, drží zámek jiné vlákno.

Valná většina zámků je podle dostupných informací uncontended. JVM má dvě rozdílné metody, pro získání contended a uncontended zámků. Pro získání contended zámku používá JVM takzvaný "fast path" způsob a pro získání uncontended zámku takzvaný "slow path" způsob. Rozdíl mezi "fast path" a "slow path" je v tom, že při "fast path" stačí použít dvě instrukce pro získání nebo uvolnění zámku (jednoduchý příznak owner, který nabývá hodnot null nebo not null). Oproti tomu "slow path" je mnohem složitější, protože se musí postarat o obsluhu (uspání/vzbuzení) vláken.

Jak jsem si řekli, většina zámků je uncontended, proto je důležitá rychlost "fast path" způsobu. "Fast path" sice používá jednu nebo dvě instrukce, ovšem instrukce atomické vzhledem k paměti, díky tomu dochází k CAS zpoždění (CAS latency). Proto jakékoliv zrychlení v oblasti uncontended zámků se počítá.

Java 6 má tři nové způsoby jak urychlit práci s uncontended zámky a tím pádem zrychlit běh většiny programů. Protože jak si ukážeme dále, uncontended zámků máte v programu mnoho a ani o tom možná nevíte.

Odstranění zámků (Lock elision)

V jednoduchosti je síla, mnoho uncontended zámků totiž ani nemusí být nutných, protože se synchronizovaný objekt nedostane mimo viditelnost daného vlákna (thread stack). Řekněme, že máme následující kód.

     
public String doSomething() {
    Object lock = new Object();
    synchronized(lock){
      //do something
      return result;           
    }
}    
    
    

Blok je sice synchronizovaný, ale synchronizace se děje přes objekt, který má každé vlákno vlastní. Navíc jsou všechny proměnné lokální a proto je synchronizace zbytečná, vlákna se nemohou vzájemně ovlivnit. Pokud se ptáte, proč by někdo takovouto zbytečnou synchronizaci používal, tak odpovím reálným kódem.

     
public String doSomething() {
    StringBuffer s = new StringBuffer();
    s.append("foo");
    s.append("hoo");
    s.append("bar");
    return s.toString();
}    
    
    

Tento kód je z hlediska synchronizace ekvivalentem předchozího kódu. Hodně core Java tříd je thread safe, tedy obsahují nutnou synchronizaci, aby bylo jejich použití ve vícevláknovém prostředí bezpečné. Právě synchronizace StringBufferu je v tomto případě zbytečnou režií, která nemá v tomto konkrétním případě smysl. Opět zopakuji, objekt s bude mít každé vlákno vlastní, čili vzájemně se nemohou ovlivnit.

JVM od verze 6 umí detekovat, že reference na objekt s nikdy neopustí metodu doSomething. Tím pádem může JVM za běhu zbytečnou synchronizaci odstranit. K tomu, aby JVM rozhodla jestli může synchronizaci odstranit, dělá analýzu útěku. Tenhle termín jsem si nevymyslel, protože jsem asi tři dny v kuse sledoval Prison break, ale protože v originále se to nazývá Escape analysis.

Analýza útěku umožňuje JVM nejenom odstranit zbytečnou synchronizaci, ale i úžasnou optimalizaci v podobě stack alokace objektů. Alokace objektů na heapu je totiž mnohem pomalejší než alokace přímo na stacku. Java všechny objekty alokuje na heapu, pouze primitivní datové typy a reference na objekty leží na stacku.

Díky analýze útěku může JVM optimalizovat vytváření objektů, které neopustí lokální rozsah, že je bude alokovat na stacku. Záměrně používám slovo může, i když Java umí analýzu útěku a umí jí využít pro eliminaci zbytečných synchronizací, stack alokací bohužel zatím neumí.

Lock coarsening

Lock coarsening by se dal volně přeložit jako zhrubnutí zámku. Jde o snížení režie na zamykání a odemykání v případě, že je možné dané části uzavřít do jednoho zámku. Pro ilustraci si pomůžeme lehkou modifikací předchozího příkladu.


public void doSomething(StringBuffer s) {
    s.append("foo");
    s.append("hoo");
    s.append("bar");
}    
    

Každé volání metody append způsobí zamčení a odemčení zámku na objektu s. JVM může kód optimalizovat tak, že nebude zamykat a odemykat pro každé volání metody append, ale uvnitř metody doSomething udělá jeden větší zámek přes všechny tři volání. Tím pádem se sníží náklady na synchronizační režii.

Lock coarsening může mít jeden vedlejší efekt. Sice se sníží synchronizační režie, ale může se zvýšit sekvenční část programu a tím pádem se zmenší část, kterou je možné vykonávat paralelně, pokud se z uncontended zámku stane contended zámek. To může mít neblahý vliv na paralelismus viz Amdahluv zákon (sekce paralelizace).

Lock biasing

Lock biasing je technika, která opět využívá faktu, že zámek je většinou držen pouze jedním vláknem. Lock biasing je v podstatě rezervování zámku. Když vlákno poprvé obdrží zámek, dojde k nastavení speciálního flagu. Při další žádosti o zámek, se nejdříve zkontroluje hodnota flagu společně s identifikátorem daného vláknu a pokud je thread vlastníkem, flag se nastaví. Tím je zámek aktivní.

Tomuto způsobu zamykání se také říká "ultra fast path", protože eliminuje CAS latency "fast path" způsobu. Lock biasing je v Jave 6 stndardně zapnutý, v Jave 5 (od verze 5.0_06) lze toto chování aktivovat parametrem -XX:+UseBiasedLocking. Vliv biased locku se projeví především na víceprocesorových systémech, kde by se jinak musela použít lock verze CAS instrukce, která by způsobila serializaci zpracování.

Reálný dopad na výkon

Pro otestování těchto vlastností jsem si napsal mikro benchmark, kterým jsem ověřil dopad odstranění zámků (Lock elision) na výkon. No nebudu vás napínat, dopadlo to přesně podle teorie. Díky runtime odstranění zámků byl výsledek stejný jako v případě, že tam zámky (synchornizace) nebyly. Pokud se o výsledky zajímáte, můžete pokračovat na detailní popis testu a naměřené hodnoty mikro benchmarku.

Zdroje