Diepe duik in de nieuwe Java JIT-compiler - Graal

1. Overzicht

In deze tutorial gaan we dieper in op de nieuwe Java Just-In-Time (JIT) -compiler, genaamd Graal.

We zullen zien wat het project Graal is en een van de onderdelen ervan beschrijven, een krachtige dynamische JIT-compiler.

2. Wat is een JIT Compiler?

Laten we eerst uitleggen wat de JIT-compiler doet.

Wanneer we ons Java-programma compileren (bijvoorbeeld met behulp van de Javac commando), eindigen we met onze broncode gecompileerd in de binaire weergave van onze code - een JVM-bytecode. Deze bytecode is eenvoudiger en compacter dan onze broncode, maar conventionele processors in onze computers kunnen deze niet uitvoeren.

Om een ​​Java-programma te kunnen uitvoeren, interpreteert de JVM de bytecode. Omdat tolken meestal een stuk langzamer zijn dan native code die op een echte processor wordt uitgevoerd, is de JVM kan een andere compiler uitvoeren die nu onze bytecode compileert in de machinecode die door de processor kan worden uitgevoerd. Deze zogenaamde just-in-time-compiler is veel geavanceerder dan de Javac compiler, en het voert complexe optimalisaties uit om machinecode van hoge kwaliteit te genereren.

3. Meer gedetailleerd onderzoek naar de JIT-compiler

De JDK-implementatie door Oracle is gebaseerd op het open-source OpenJDK-project. Dit omvat de HotSpot virtuele machine, beschikbaar sinds Java-versie 1.3. Het bevat twee conventionele JIT-compilers: de client-compiler, ook wel C1 genoemd en de servercompiler, opto of C2 genaamd.

C1 is ontworpen om sneller te werken en minder geoptimaliseerde code te produceren, terwijl C2 daarentegen iets meer tijd nodig heeft om uit te voeren, maar een beter geoptimaliseerde code produceert. De client-compiler is beter geschikt voor desktoptoepassingen omdat we geen lange pauzes willen hebben voor de JIT-compilatie. De servercompiler is beter voor langlopende servertoepassingen die meer tijd aan de compilatie kunnen besteden.

3.1. Gelaagde compilatie

Tegenwoordig gebruikt de Java-installatie beide JIT-compilers tijdens de normale uitvoering van het programma.

Zoals we in de vorige sectie al zeiden, ons Java-programma, samengesteld door Javac, begint de uitvoering ervan in een geïnterpreteerde modus. De JVM volgt elke veelgebruikte methode en compileert ze. Om dat te doen, gebruikt het C1 voor de compilatie. Maar de HotSpot houdt nog steeds de toekomstige oproepen van die methoden in de gaten. Als het aantal aanroepen toeneemt, zal de JVM deze methoden nogmaals compileren, maar deze keer met behulp van C2.

Dit is de standaardstrategie die wordt gebruikt door de HotSpot, genaamd gelaagde compilatie.

3.2. De servercompiler

Laten we ons nu even concentreren op C2, aangezien dit de meest complexe van de twee is. C2 is extreem geoptimaliseerd en produceert code die kan concurreren met C ++ of zelfs sneller kan zijn. De servercompiler zelf is geschreven in een specifiek dialect van C ++.

Er zijn echter enkele problemen. Vanwege mogelijke segmentatiefouten in C ++ kan dit ervoor zorgen dat de VM crasht. Ook zijn er de afgelopen jaren geen grote verbeteringen doorgevoerd in de compiler. De code in C2 is moeilijk te onderhouden geworden, dus we konden geen nieuwe grote verbeteringen verwachten met het huidige ontwerp. Met dat in gedachten wordt de nieuwe JIT-compiler gemaakt in het project met de naam GraalVM.

4. Project GraalVM

Project GraalVM is een onderzoeksproject gemaakt door Oracle. We kunnen Graal beschouwen als verschillende verbonden projecten: een nieuwe JIT-compiler die voortbouwt op HotSpot en een nieuwe polyglot virtuele machine. Het biedt een uitgebreid ecosysteem dat een groot aantal talen ondersteunt (Java en andere op JVM gebaseerde talen; JavaScript, Ruby, Python, R, C / C ++ en andere op LLVM gebaseerde talen).

We zullen ons natuurlijk concentreren op Java.

4.1. Graal - een JIT-compiler geschreven in Java

Graal is een krachtige JIT-compiler. Het accepteert de JVM-bytecode en produceert de machinecode.

Het schrijven van een compiler in Java heeft verschillende belangrijke voordelen. Allereerst veiligheid, wat betekent dat er geen crashes zijn, maar uitzonderingen en geen echte geheugenlekken. Verder hebben we een goede IDE-ondersteuning en kunnen we debuggers of profilers of andere handige tools gebruiken. De compiler kan ook onafhankelijk zijn van de HotSpot en zou in staat zijn om een ​​snellere JIT-gecompileerde versie van zichzelf te produceren.

De Graal-compiler is gemaakt met die voordelen in gedachten. Het gebruikt de nieuwe JVM Compiler Interface - JVMCI om te communiceren met de VM. Om het gebruik van de nieuwe JIT-compiler mogelijk te maken, moeten we de volgende opties instellen bij het uitvoeren van Java vanaf de opdrachtregel:

-XX: + UnlockExperimentalVMOptions -XX: + EnableJVMCI -XX: + UseJVMCICompiler

Dit betekent dat we kunnen een eenvoudig programma op drie verschillende manieren uitvoeren: met de reguliere gelaagde compilers, met de JVMCI-versie van Graal op Java 10 of met de GraalVM zelf.

4.2. JVM-compilerinterface

De JVMCI maakt deel uit van de OpenJDK sinds JDK 9, dus we kunnen elke standaard OpenJDK of Oracle JDK gebruiken om Graal uit te voeren.

Wat JVMCI ons in feite laat doen, is de standaard gelaagde compilatie uitsluiten en onze gloednieuwe compiler (d.w.z. Graal) aansluiten zonder dat er iets in de JVM hoeft te worden gewijzigd.

De interface is vrij eenvoudig. Wanneer Graal een methode compileert, geeft het de bytecode van die methode door als invoer voor de JVMCI '. Als uitvoer krijgen we de gecompileerde machinecode. Zowel de invoer als de uitvoer zijn slechts byte-arrays:

interface JVMCICompiler {byte [] compileMethod (byte [] bytecode); }

In real-life scenario's hebben we meestal wat meer informatie nodig, zoals het aantal lokale variabelen, de stackgrootte en de informatie die is verzameld via profilering in de interpreter, zodat we weten hoe de code in de praktijk werkt.

In wezen, wanneer u het compileMethod() van de JVMCICompiler interface, moeten we een CompilationRequest voorwerp. Het retourneert dan de Java-methode die we willen compileren, en in die methode vinden we alle informatie die we nodig hebben.

4.3. Graal in actie

Graal zelf wordt uitgevoerd door de VM, dus het zal eerst worden geïnterpreteerd en JIT-gecompileerd wanneer het hot wordt. Laten we eens kijken naar een voorbeeld, dat ook te vinden is op de officiële site van GraalVM:

openbare klasse CountUppercase {statische laatste int ITERATIONS = Math.max (Integer.getInteger ("iteraties", 1), 1); public static void main (String [] args) {String zin = String.join ("", args); voor (int iter = 0; iter <ITERATIES; iter ++) {if (ITERATIES! = 1) {System.out.println ("- iteratie" + (iter + 1) + "-"); } lang totaal = 0, start = System.currentTimeMillis (), last = start; voor (int i = 1; i <10_000_000; i ++) {totaal + = zin .chars () .filter (Character :: isUpperCase) .count (); if (i% 1_000_000 == 0) {nu lang = System.currentTimeMillis (); System.out.printf ("% d (% d ms)% n", i / 1_000_000, nu - laatste); last = nu; }} System.out.printf ("totaal:% d (% d ms)% n", totaal, System.currentTimeMillis () - start); }}}

Nu gaan we het compileren en uitvoeren:

javac CountUppercase.java java -XX: + UnlockExperimentalVMOptions -XX: + EnableJVMCI -XX: + UseJVMCICompiler

Dit zal resulteren in de output vergelijkbaar met het volgende:

1 (1581 ms) 2 (480 ms) 3 (364 ms) 4 (231 ms) 5 (196 ms) 6 (121 ms) 7 (116 ms) 8 (116 ms) 9 (116 ms) totaal: 59999994 (3436 Mevrouw)

Dat kunnen we zien het kost in het begin meer tijd. Die opwarmtijd is afhankelijk van verschillende factoren, zoals de hoeveelheid multi-threaded code in de applicatie of het aantal threads dat de VM gebruikt. Als er minder kernen zijn, kan de opwarmtijd langer zijn.

Als we de statistieken van Graal-compilaties willen zien, moeten we de volgende vlag toevoegen bij het uitvoeren van ons programma:

-Dgraal.PrintCompilation = waar

Dit toont de gegevens met betrekking tot de gecompileerde methode, de tijd die nodig is, de bytecodes die zijn verwerkt (inclusief inline-methoden), de grootte van de geproduceerde machinecode en de hoeveelheid geheugen die tijdens het compileren is toegewezen. De uitvoer van de uitvoering neemt behoorlijk veel ruimte in beslag, dus we laten het hier niet zien.

4.4. In vergelijking met de Top Tier-compiler

Laten we nu de bovenstaande resultaten vergelijken met de uitvoering van hetzelfde programma dat is gecompileerd met de bovenste compiler. Om dat te doen, moeten we de VM vertellen om de JVMCI-compiler niet te gebruiken:

java -XX: + UnlockExperimentalVMOptions -XX: + EnableJVMCI -XX: -UseJVMCICompiler 1 (510 ms) 2 (375 ms) 3 (365 ms) 4 (368 ms) 5 (348 ms) 6 (370 ms) 7 (353 ms ) 8 (348 ms) 9 (369 ms) totaal: 59999994 (4004 ms)

We kunnen zien dat er een kleiner verschil is tussen de individuele tijden. Het resulteert ook in een kortere initiële tijd.

4.5. De gegevensstructuur achter Graal

Zoals we eerder zeiden, verandert Graal in feite een byte-array in een andere byte-array. In dit gedeelte zullen we ons concentreren op wat er achter dit proces zit. De volgende voorbeelden zijn gebaseerd op de toespraak van Chris Seaton op JokerConf 2017.

De taak van de basissamensteller is over het algemeen om naar ons programma te handelen. Dit betekent dat het het moet symboliseren met een passende datastructuur. Graal gebruikt hiervoor een grafiek, de zogenaamde programma-afhankelijkheidsgrafiek.

In een eenvoudig scenario, waar we twee lokale variabelen willen toevoegen, d.w.z. x + y, we zouden een knooppunt hebben om elke variabele te laden en een ander knooppunt om ze toe te voegen. Naast dat, we zouden ook twee randen hebben die de gegevensstroom vertegenwoordigen:

De randen van de gegevensstroom worden blauw weergegeven. Ze wijzen erop dat wanneer de lokale variabelen worden geladen, het resultaat naar de optelbewerking gaat.

Laten we nu introduceren een ander type randen, degenen die de controlestroom beschrijven. Om dit te doen, breiden we ons voorbeeld uit door methoden aan te roepen om onze variabelen op te halen in plaats van ze rechtstreeks te lezen. Als we dat doen, moeten we de methoden voor het aanroepen van order bijhouden. We vertegenwoordigen deze bestelling met de rode pijlen:

Hier kunnen we zien dat de knooppunten niet echt zijn veranderd, maar we hebben de besturingsstroomranden toegevoegd.

4.6. Werkelijke grafieken

We kunnen de echte Graal-grafieken bekijken met de IdealGraphVisualiser. Om het uit te voeren, gebruiken we de mx igv opdracht. We moeten ook de JVM configureren door de -Dgraal.Dump vlag.

Laten we eens kijken naar een eenvoudig voorbeeld:

int gemiddelde (int a, int b) {return (a + b) / 2; }

Dit heeft een heel eenvoudige gegevensstroom:

In de bovenstaande grafiek zien we een duidelijke weergave van onze methode. Parameters P (0) en P (1) stromen naar de optelbewerking die de deelbewerking met de constante C (2) ingaat. Ten slotte wordt het resultaat geretourneerd.

We zullen nu het vorige voorbeeld wijzigen om van toepassing te zijn op een reeks getallen:

int gemiddelde (int [] waarden) {int som = 0; voor (int n = 0; n <waarden.lengte; n ++) {som + = waarden [n]; } retourneert som / waarden.lengte; }

We kunnen zien dat het toevoegen van een lus ons naar de veel complexere grafiek heeft geleid:

Wat we kunnen opmerken hier zijn:

  • de knooppunten van de begin- en eindlus
  • de knooppunten die de array-lezing en de array-lengte-lezing vertegenwoordigen
  • data en controle stroomranden, net als voorheen.

Deze datastructuur wordt soms een zee-van-knooppunten of een soep-van-knooppunten genoemd. We moeten vermelden dat de C2-compiler een vergelijkbare gegevensstructuur gebruikt, dus het is niet iets nieuws, exclusief voor Graal geïnnoveerd.

Het is opmerkelijk dat Graal ons programma optimaliseert en compileert door de bovengenoemde datastructuur aan te passen. We kunnen zien waarom het eigenlijk een goede keuze was om de Graal JIT-compiler in Java te schrijven: een grafiek is niets meer dan een verzameling objecten met verwijzingen die ze als randen verbinden. Die structuur is perfect compatibel met de objectgeoriënteerde taal, in dit geval Java.

4.7. Ahead-of-Time Compiler-modus

Het is ook belangrijk om dat te vermelden we kunnen de Graal-compiler ook gebruiken in de Ahead-of-Time-compilermodus in Java 10. Zoals we al zeiden, is de Graal-compiler helemaal opnieuw geschreven. Het komt overeen met een nieuwe schone interface, de JVMCI, waardoor we het kunnen integreren met de HotSpot. Dat betekent echter niet dat de compiler eraan gebonden is.

Een manier om de compiler te gebruiken is om een ​​profielgestuurde benadering te gebruiken om alleen de hete methoden te compileren, maar we kunnen Graal ook gebruiken om een ​​totale compilatie van alle methoden in een offline modus te doen zonder de code uit te voeren. Dit is een zogenaamde "Ahead-of-Time Compilation", JEP 295, maar we zullen hier niet diep ingaan op de AOT compilatietechnologie.

De belangrijkste reden waarom we Graal op deze manier zouden gebruiken, is om de opstarttijd te versnellen totdat de reguliere Tiered Compilation-benadering in de HotSpot het kan overnemen.

5. Conclusie

In dit artikel hebben we de functionaliteiten van de nieuwe Java JIT-compiler onderzocht als onderdeel van het project Graal.

We hebben eerst traditionele JIT-compilers beschreven en vervolgens nieuwe functies van de Graal besproken, vooral de nieuwe JVM Compiler-interface. Vervolgens hebben we geïllustreerd hoe beide samenstellers werken en hun uitvoeringen vergeleken.

Daarna hebben we het gehad over de datastructuur die Graal gebruikt om ons programma te manipuleren en ten slotte over de AOT-compilermodus als een andere manier om Graal te gebruiken.

Zoals altijd is de broncode te vinden op GitHub. Onthoud dat de JVM moet worden geconfigureerd met de specifieke vlaggen - die hier werden beschreven.