Deep Dive Into the New Java JIT Compiler - Graal

1. Oversigt

I denne tutorial tager vi et dybere kig på den nye Java Just-In-Time (JIT) compiler, kaldet Graal.

Vi ser, hvad projektet Graal er, og beskriver en af ​​dens dele, en højtydende dynamisk JIT-kompilator.

2. Hvad er en JIT Kompilator?

Lad os først forklare, hvad JIT-kompilatoren gør.

Når vi kompilerer vores Java-program (f.eks. Ved hjælp af javac kommando), ender vi med vores kildekode samlet i den binære repræsentation af vores kode - en JVM-bytecode. Denne bytecode er enklere og mere kompakt end vores kildekode, men konventionelle processorer på vores computere kan ikke udføre den.

For at kunne køre et Java-program fortolker JVM bytekoden. Da tolke normalt er meget langsommere end oprindelig kode, der udføres på en rigtig processor, er JVM kan køre en anden compiler, som nu vil kompilere vores bytecode i maskinkoden, der kan køres af processoren. Denne såkaldte just-in-time kompilator er meget mere sofistikeret end javac compiler, og den kører komplekse optimeringer for at generere maskinkode af høj kvalitet.

3. Mere detaljeret se på JIT Compiler

JDK-implementeringen af ​​Oracle er baseret på OpenJDK-projektet med open source. Dette inkluderer HotSpot virtuel maskine, tilgængelig siden Java version 1.3. Det indeholder to konventionelle JIT-compilere: klientcompileren, også kaldet C1 og servercompileren, kaldet opto eller C2.

C1 er designet til at køre hurtigere og producere mindre optimeret kode, mens C2 på den anden side tager lidt mere tid at køre, men producerer en bedre optimeret kode. Klientcompileren passer bedre til desktop-applikationer, da vi ikke vil have lange pauser til JIT-kompilering. Servercompileren er bedre til langvarige serverapplikationer, der kan bruge mere tid på kompileringen.

3.1. Niveauet kompilering

I dag bruger Java-installation begge JIT-compilere under den normale programudførelse.

Som vi nævnte i det foregående afsnit, vores Java-program, udarbejdet af javac, starter dens udførelse i en fortolket tilstand. JVM sporer hver ofte kaldte metode og kompilerer dem. For at gøre det bruger den C1 til kompilering. Men HotSpot holder stadig øje med de fremtidige opkald af disse metoder. Hvis antallet af opkald øges, kompilerer JVM disse metoder igen, men denne gang ved hjælp af C2.

Dette er standardstrategien, der bruges af HotSpot, kaldet differentieret kompilering.

3.2. Server Compiler

Lad os nu fokusere lidt på C2, da det er den mest komplekse af de to. C2 er blevet ekstremt optimeret og producerer kode, der kan konkurrere med C ++ eller være endnu hurtigere. Selve servercompileren er skrevet i en bestemt dialekt af C ++.

Det kommer dog med nogle problemer. På grund af mulige segmenteringsfejl i C ++ kan det få VM til at gå ned. Der er heller ikke implementeret større forbedringer i kompilatoren i løbet af de sidste mange år. Koden i C2 er blevet vanskelig at vedligeholde, så vi kunne ikke forvente nye større forbedringer med det nuværende design. Med det i tankerne oprettes den nye JIT-kompilator i projektet GraalVM.

4. Projekt GraalVM

Project GraalVM er et forskningsprojekt oprettet af Oracle. Vi kan se på Graal som flere forbundne projekter: en ny JIT-kompilator, der bygger på HotSpot og en ny polyglot virtuel maskine. Det tilbyder et omfattende økosystem, der understøtter et stort sæt sprog (Java og andre JVM-baserede sprog; JavaScript, Ruby, Python, R, C / C ++ og andre LLVM-baserede sprog).

Vi vil selvfølgelig fokusere på Java.

4.1. Graal - en JIT-kompilator skrevet i Java

Graal er en højtydende JIT-kompilator. Det accepterer JVM-bytecode og producerer maskinkoden.

Der er flere vigtige fordele ved at skrive en compiler i Java. Først og fremmest sikkerhed, hvilket betyder ingen nedbrud, men undtagelser i stedet og ingen reel hukommelseslækage. Derudover har vi en god IDE-support, og vi kan bruge debuggere eller profiler eller andre praktiske værktøjer. Compileren kan også være uafhængig af HotSpot, og den ville være i stand til at producere en hurtigere JIT-kompileret version af sig selv.

Graal-kompilatoren blev oprettet med disse fordele i tankerne. Det bruger det nye JVM Compiler Interface - JVMCI til at kommunikere med VM. For at muliggøre brugen af ​​den nye JIT-kompilator skal vi indstille følgende muligheder, når du kører Java fra kommandolinjen:

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

Hvad dette betyder er, at vi kan køre et simpelt program på tre forskellige måder: med de almindelige tierede compilers, med JVMCI-versionen af ​​Graal på Java 10 eller med selve GraalVM.

4.2. JVM Compiler-interface

JVMCI er en del af OpenJDK siden JDK 9, så vi kan bruge enhver standard OpenJDK eller Oracle JDK til at køre Graal.

Hvad JVMCI faktisk tillader os at gøre, er at udelukke den standarddelt niveau-kompilering og tilslutte vores helt nye compiler (dvs. Graal) uden behov for at ændre noget i JVM.

Interfacet er ret simpelt. Når Graal kompilerer en metode, sender den bytekoden for denne metode som input til JVMCI '. Som output får vi den kompilerede maskinkode. Både input og output er bare byte-arrays:

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

I virkelige scenarier har vi normalt brug for nogle flere oplysninger som antallet af lokale variabler, stakstørrelsen og den information, der er indsamlet fra profilering i tolken, så vi ved, hvordan koden kører i praksis.

I det væsentlige, når du ringer til compileMethod() af JVMCICompiler interface, skal vi videregive en CompilationRequest objekt. Det returnerer derefter den Java-metode, vi vil kompilere, og i den metode finder vi alle de oplysninger, vi har brug for.

4.3. Graal i aktion

Graal selv udføres af VM, så det fortolkes først og JIT-kompileres, når det bliver varmt. Lad os tjekke et eksempel, som også kan findes på GraalVM's officielle side:

public class CountUppercase {static final int ITERATIONS = Math.max (Integer.getInteger ("iterations", 1), 1); public static void main (String [] args) {String sætning = String.join ("", args); for (int iter = 0; iter <ITERATIONS; iter ++) {if (ITERATIONS! = 1) {System.out.println ("- iteration" + (iter + 1) + "-"); } lang total = 0, start = System.currentTimeMillis (), sidste = start; for (int i = 1; i <10_000_000; i ++) {total + = sætning. tegn () .filter (tegn :: isUpperCase). antal (); hvis (i% 1_000_000 == 0) {lang nu = System.currentTimeMillis (); System.out.printf ("% d (% d ms)% n", i / 1_000_000, nu - sidste); sidste = nu; }} System.out.printf ("total:% d (% d ms)% n", total, System.currentTimeMillis () - start); }}}

Nu kompilerer vi det og kører det:

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

Dette vil resultere i output svarende til følgende:

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) i alt: 59999994 (3436 Frk)

Det kan vi se det tager mere tid i starten. Opvarmningstiden afhænger af forskellige faktorer, såsom mængden af ​​multi-threaded kode i applikationen eller antallet af tråde, som den virtuelle computer bruger. Hvis der er færre kerner, kan opvarmningstiden være længere.

Hvis vi vil se statistikkerne over Graal-kompileringer, skal vi tilføje følgende flag, når vi udfører vores program:

-Dgraal.PrintCompilation = sandt

Dette viser data relateret til den kompilerede metode, den tid det tager, de behandlede bytekoder (som også inkluderer inline-metoder), størrelsen på den producerede maskinkode og mængden af ​​hukommelse, der er allokeret under kompilering. Output af udførelsen tager ret meget plads, så vi viser det ikke her.

4.4. Sammenligning med Top Tier Compiler

Lad os nu sammenligne ovenstående resultater med udførelsen af ​​det samme program, der er kompileret med den øverste niveau-kompilator i stedet. For at gøre det skal vi fortælle den virtuelle maskine, at den ikke bruger JVMCI-kompilatoren:

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) i alt: 59999994 (4004 ms)

Vi kan se, at der er en mindre forskel mellem de enkelte tider. Det resulterer også i en kortere starttid.

4.5. Datastrukturen bag Graal

Som vi sagde tidligere, forvandler Graal stort set et byte-array til et andet byte-array. I dette afsnit vil vi fokusere på, hvad der ligger bag denne proces. De følgende eksempler er afhængige af Chris Seatons tale på JokerConf 2017.

Grundlæggende kompilators job er generelt at handle på vores program. Dette betyder, at det skal symbolisere det med en passende datastruktur. Graal bruger en graf til et sådant formål, den såkaldte programafhængighedsgraf.

I et simpelt scenario, hvor vi vil tilføje to lokale variabler, dvs. x + y, Vi ville have en node til at indlæse hver variabel og en anden node til at tilføje dem. Ved siden af ​​det, Vi har også to kanter, der repræsenterer datastrømmen:

Datastrømskanterne vises med blåt. De påpeger, at når de lokale variabler indlæses, går resultatet i tilføjelsesoperationen.

Lad os nu introducere en anden type kanter, dem der beskriver kontrolflowet. For at gøre det udvider vi vores eksempel ved at kalde metoder til at hente vores variabler i stedet for at læse dem direkte. Når vi gør det, er vi nødt til at holde styr på metoderne, der kalder orden. Vi repræsenterer denne rækkefølge med de røde pile:

Her kan vi se, at noderne faktisk ikke ændrede sig, men vi har tilføjet kontrolflowkanterne.

4.6. Faktiske grafer

Vi kan undersøge de rigtige graalgrafer med IdealGraphVisualiser. For at køre det bruger vi mx igv kommando. Vi skal også konfigurere JVM ved at indstille -Dgraal.Dump flag.

Lad os tjekke et simpelt eksempel:

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

Dette har en meget enkel datastrøm:

I grafen ovenfor kan vi se en klar gengivelse af vores metode. Parametrene P (0) og P (1) flyder ind i tilføjelsesoperationen, der går ind i delingsoperationen med konstant C (2). Endelig returneres resultatet.

Vi ændrer nu det foregående eksempel, så det gælder for en række tal:

int gennemsnit (int [] værdier) {int sum = 0; for (int n = 0; n <værdier.længde; n ++) {sum + = værdier [n]; } returneringssum / værdier. længde; }

Vi kan se, at tilføjelse af en løkke førte os til den meget mere komplekse graf:

Hvad vi kan bemærke her er:

  • start- og slutsløjfeknudepunkterne
  • noderne, der repræsenterer array-læsning og array-længdeaflæsning
  • data og kontrol flow kanter, som før.

Denne datastruktur kaldes undertiden et hav af noder eller en suppe af noder. Vi skal nævne, at C2-kompilatoren bruger en lignende datastruktur, så det er ikke noget nyt, innoveret udelukkende til Graal.

Det er bemærkelsesværdigt at huske, at Graal optimerer og kompilerer vores program ved at ændre ovennævnte datastruktur. Vi kan se, hvorfor det var et faktisk godt valg at skrive Graal JIT-kompilatoren i Java: en graf er intet andet end et sæt objekter med referencer, der forbinder dem som kanterne. Denne struktur er perfekt kompatibel med det objektorienterede sprog, som i dette tilfælde er Java.

4.7. Ahead-of-Time Compiler Mode

Det er også vigtigt at nævne det Vi kan også bruge Graal-kompilatoren i Ahead-of-Time compiler-tilstand i Java 10. Som vi allerede sagde, er Graal-kompilatoren skrevet fra bunden. Det overholder en ny ren grænseflade, JVMCI, som gør det muligt for os at integrere det med HotSpot. Det betyder dog ikke, at compileren er bundet til det.

En måde at bruge compileren på er at bruge en profildrevet tilgang til kun at kompilere de hotte metoder, men Vi kan også bruge Graal til at foretage en samlet samling af alle metoder i offline-tilstand uden at udføre koden. Dette er en såkaldt “Ahead-of-Time Compilation”, JEP 295, men vi går ikke dybt ind i AOT-kompileringsteknologien her.

Hovedårsagen til, at vi bruger Graal på denne måde, er at fremskynde opstartstiden, indtil den almindelige Tiered Compilation-tilgang i HotSpot kan overtage.

5. Konklusion

I denne artikel udforskede vi funktionaliteterne i den nye Java JIT-kompilator som en del af projektet Graal.

Vi beskrev først traditionelle JIT-kompilatorer og diskuterede derefter nye funktioner i Graal, især den nye JVM Compiler-grænseflade. Derefter illustrerede vi, hvordan begge kompilatorer fungerer og sammenlignede deres forestillinger.

Derefter har vi talt om datastrukturen, som Graal bruger til at manipulere vores program, og endelig om AOT-kompilatortilstanden som en anden måde at bruge Graal på.

Som altid kan kildekoden findes på GitHub. Husk, at JVM skal konfigureres med de specifikke flag - som blev beskrevet her.