Deep Dive Into the New Java JIT Compiler - Graal

1. Oversikt

I denne opplæringen vil vi se nærmere på den nye Java Just-In-Time (JIT) kompilatoren, kalt Graal.

Vi får se hva prosjektet Graal er og beskrive en av dens deler, en høyytelses dynamisk JIT-kompilator.

2. Hva er en JIT Kompilator?

La oss først forklare hva JIT-kompilatoren gjør.

Når vi kompilerer Java-programmet vårt (f.eks. Ved hjelp av javac kommando), vil vi ende opp med kildekoden vår som er samlet inn i den binære representasjonen av koden vår - en JVM bytecode. Denne bytekoden er enklere og mer kompakt enn kildekoden vår, men konvensjonelle prosessorer på datamaskinene våre kan ikke utføre den.

For å kunne kjøre et Java-program, tolker JVM bytekoden. Siden tolker vanligvis er mye langsommere enn innfødt kode som kjøres på en ekte prosessor, er JVM kan kjøre en annen kompilator som nå vil kompilere bytekoden vår i maskinkoden som kan kjøres av prosessoren. Denne såkalte just-in-time kompilatoren er mye mer sofistikert enn javac kompilatoren, og den kjører komplekse optimaliseringer for å generere maskinkode av høy kvalitet.

3. Mer detaljert titt på JIT Compiler

JDK-implementeringen av Oracle er basert på OpenJDK-prosjektet med åpen kildekode. Dette inkluderer HotSpot virtuell maskin, tilgjengelig siden Java versjon 1.3. Den inneholder to konvensjonelle JIT-kompilatorer: klientkompilatoren, også kalt C1 og serverkompilatoren, kalt opto eller C2.

C1 er designet for å kjøre raskere og produsere mindre optimalisert kode, mens C2 derimot tar litt mer tid å kjøre, men produserer en bedre optimalisert kode. Klientkompilatoren passer bedre for stasjonære applikasjoner, siden vi ikke vil ha lange pauser for JIT-kompilering. Serverkompilatoren er bedre for langvarige serverapplikasjoner som kan bruke mer tid på kompilering.

3.1. Trinnvis kompilering

I dag bruker Java-installasjonen begge JIT-kompilatorene under normal programutførelse.

Som vi nevnte i forrige avsnitt, vårt Java-program, samlet av javac, starter utførelsen i en tolket modus. JVM sporer hver ofte kalt metode og kompilerer dem. For å gjøre det bruker den C1 til kompilering. Men HotSpot holder fortsatt et øye med de fremtidige samtalene til disse metodene. Hvis antall samtaler øker, vil JVM kompilere disse metodene på nytt, men denne gangen ved hjelp av C2.

Dette er standardstrategien som brukes av HotSpot, kalt tiered kompilering.

3.2. Serverkompilatoren

La oss nå fokusere litt på C2, siden det er den mest komplekse av de to. C2 har blitt ekstremt optimalisert og produserer kode som kan konkurrere med C ++ eller være enda raskere. Selve serverkompilatoren er skrevet i en bestemt dialekt av C ++.

Imidlertid kommer det med noen problemer. På grunn av mulige segmenteringsfeil i C ++ kan det føre til at VM krasjer. Dessuten har ingen større forbedringer blitt implementert i kompilatoren de siste årene. Koden i C2 har blitt vanskelig å vedlikeholde, så vi kunne ikke forvente nye store forbedringer med dagens design. Med det i tankene blir den nye JIT-kompilatoren opprettet i prosjektet kalt GraalVM.

4. Prosjekt GraalVM

Project GraalVM er et forskningsprosjekt opprettet av Oracle. Vi kan se på Graal som flere tilknyttede prosjekter: en ny JIT-kompilator som bygger på HotSpot og en ny polyglot virtuell maskin. Det tilbyr et omfattende økosystem som støtter et stort sett med språk (Java og andre JVM-baserte språk; JavaScript, Ruby, Python, R, C / C ++ og andre LLVM-baserte språk).

Vi vil selvfølgelig fokusere på Java.

4.1. Graal - en JIT-kompilator Skrevet i Java

Graal er en JIT-kompilator med høy ytelse. Den godtar JVM-bytekoden og produserer maskinkoden.

Det er flere viktige fordeler med å skrive en kompilator i Java. Først og fremst sikkerhet, noe som betyr ingen krasj, men unntak i stedet og ingen ekte minnelekkasjer. Videre vil vi ha god IDE-støtte, og vi vil kunne bruke debuggere eller profilere eller andre praktiske verktøy. Dessuten kan kompilatoren være uavhengig av HotSpot, og den kan produsere en raskere JIT-kompilert versjon av seg selv.

Graal-kompilatoren ble opprettet med tanke på disse fordelene. Den bruker det nye JVM Compiler Interface - JVMCI for å kommunisere med VM. For å muliggjøre bruk av den nye JIT-kompilatoren, må vi angi følgende alternativer når du kjører Java fra kommandolinjen:

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

Hva dette betyr er at vi kan kjøre et enkelt program på tre forskjellige måter: med de vanlige lagdelte kompilatorene, med JVMCI-versjonen av Graal på Java 10 eller med selve GraalVM.

4.2. JVM kompilatorgrensesnitt

JVMCI er en del av OpenJDK siden JDK 9, så vi kan bruke hvilken som helst standard OpenJDK eller Oracle JDK til å kjøre Graal.

Det JVMCI faktisk tillater oss å gjøre, er å ekskludere standard lagdelt kompilering og plugge inn vår splitter nye kompilator (dvs. Graal) uten behov for å endre noe i JVM.

Grensesnittet er ganske enkelt. Når Graal kompilerer en metode, vil den passere bytekoden til denne metoden som inngang til JVMCI '. Som utgang får vi den kompilerte maskinkoden. Både inngangen og utgangen er bare byte-matriser:

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

I virkelige scenarier trenger vi vanligvis mer informasjon som antall lokale variabler, stabelstørrelse og informasjon som er samlet inn fra profilering i tolken, slik at vi vet hvordan koden kjører i praksis.

I hovedsak når du ringer til kompilere metode() av JVMCICompiler grensesnitt, må vi passere en CompilationRequest gjenstand. Deretter returnerer du Java-metoden vi vil kompilere, og i den metoden finner vi all informasjonen vi trenger.

4.3. Graal i aksjon

Graal selv utføres av VM, så det blir først tolket og JIT-kompilert når det blir varmt. La oss sjekke ut et eksempel, som også finnes på GraalVMs offisielle side:

offentlig klasse CountUppercase {static final int ITERATIONS = Math.max (Integer.getInteger ("iterations", 1), 1); public static void main (String [] args) {String setning = 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 (), siste = start; for (int i = 1; i <10_000_000; i ++) {total + = setning. tegn () .filter (Character :: isUpperCase) .count (); hvis (i% 1_000_000 == 0) {lenge nå = System.currentTimeMillis (); System.out.printf ("% d (% d ms)% n", i / 1_000_000, nå - siste); siste = nå; }} System.out.printf ("total:% d (% d ms)% n", total, System.currentTimeMillis () - start); }}}

Nå skal vi kompilere det og kjøre det:

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

Dette vil resultere i utdata som ligner på 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) totalt: 59999994 (3436 ms)

Vi kan se det det tar mer tid i begynnelsen. Den oppvarmingstiden avhenger av forskjellige faktorer, for eksempel mengden kode med flere tråder i applikasjonen eller antall tråder VM bruker. Hvis det er færre kjerner, kan oppvarmingstiden være lengre.

Hvis vi ønsker å se statistikken over Graal compilations, må vi legge til følgende flagg når vi kjører programmet vårt:

-Dgraal.PrintCompilation = sant

Dette vil vise dataene relatert til den kompilerte metoden, tiden det tar, bytekodene som er behandlet (som også inkluderer inline metoder), størrelsen på produsert maskinkode og mengden minne som tildeles under kompilering. Utgangen av utførelsen tar ganske mye plass, så vi vil ikke vise det her.

4.4. Sammenligning med Top Tier Compiler

La oss nå sammenligne de ovennevnte resultatene med utførelsen av det samme programmet som er kompilert med toppnivåkompilatoren i stedet. For å gjøre det, må vi fortelle VM at den ikke bruker 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) totalt: 59999994 (4004 ms)

Vi kan se at det er en mindre forskjell mellom de enkelte tidene. Det resulterer også i en kortere starttid.

4.5. Datastrukturen bak Graal

Som vi sa tidligere, gjør Graal i utgangspunktet en byte-array til en annen byte-array. I denne delen vil vi fokusere på hva som ligger bak denne prosessen. Følgende eksempler er avhengige av Chris Seatons tale på JokerConf 2017.

Grunnleggende kompilators jobb er generelt å handle på programmet vårt. Dette betyr at den må symbolisere den med en passende datastruktur. Graal bruker en graf for et slikt formål, den såkalte programavhengighetsgrafen.

I et enkelt scenario, hvor vi vil legge til to lokale variabler, dvs. x + y, vi ville ha en node for å laste inn hver variabel og en annen node for å legge dem til. Foruten det, vi vil også ha to kanter som representerer datastrømmen:

Datastrømskantene vises i blått. De påpeker at når de lokale variablene lastes inn, går resultatet inn i tilleggsoperasjonen.

La oss nå introdusere en annen type kanter, de som beskriver kontrollflyten. For å gjøre dette utvider vi eksemplet vårt ved å ringe metoder for å hente variablene våre i stedet for å lese dem direkte. Når vi gjør det, må vi holde rede på metodene som ringer. Vi representerer denne rekkefølgen med de røde pilene:

Her kan vi se at nodene ikke endret seg faktisk, men vi har kontrollflytekanter lagt til.

4.6. Faktiske grafer

Vi kan undersøke de virkelige Graal-grafene med IdealGraphVisualiser. For å kjøre den bruker vi mx igv kommando. Vi må også konfigurere JVM ved å sette -Dgraal.Dump flagg.

La oss sjekke ut et enkelt eksempel:

int gjennomsnitt (int a, int b) {retur (a + b) / 2; }

Dette har en veldig enkel dataflyt:

I grafen over kan vi se en tydelig fremstilling av metoden vår. Parametrene P (0) og P (1) strømmer inn i tilsetningsoperasjonen som går inn i delingsoperasjonen med konstanten C (2). Til slutt returneres resultatet.

Vi endrer nå det forrige eksemplet slik at det gjelder en rekke tall:

int gjennomsnitt (int [] verdier) {int sum = 0; for (int n = 0; n <verdier.lengde; n ++) {sum + = verdier [n]; } retursum / verdier. lengde; }

Vi kan se at å legge til en sløyfe førte oss til den mye mer komplekse grafen:

Det vi kan legge merke til her er:

  • start- og sluttsløyfenodene
  • nodene som representerer matriselesing og matriselengdeavlesning
  • data og kontroll flyt kanter, akkurat som før.

Denne datastrukturen kalles noen ganger et hav av noder eller en suppe av noder. Vi må nevne at C2-kompilatoren bruker en lignende datastruktur, så det er ikke noe nytt, innovert eksklusivt for Graal.

Det er bemerkelsesverdig å huske at Graal optimaliserer og kompilerer programmet vårt ved å endre ovennevnte datastruktur. Vi kan se hvorfor det var et faktisk godt valg å skrive Graal JIT-kompilatoren i Java: en graf er ikke mer enn et sett med objekter med referanser som forbinder dem som kantene. Denne strukturen er perfekt kompatibel med det objektorienterte språket, som i dette tilfellet er Java.

4.7. Ahead-of-Time Compiler Mode

Det er også viktig å nevne det vi kan også bruke Graal-kompilatoren i Ahead-of-Time kompilatormodus i Java 10. Som vi allerede har sagt, er Graal-kompilatoren skrevet fra bunnen av. Det samsvarer med et nytt rent grensesnitt, JVMCI, som gjør det mulig for oss å integrere det med HotSpot. Det betyr ikke at kompilatoren er bundet til det skjønt.

En måte å bruke kompilatoren på er å bruke en profildrevet tilnærming for å kompilere bare de varme metodene, men Vi kan også bruke Graal til å gjøre en total samling av alle metodene i frakoblet modus uten å utføre koden. Dette er en såkalt “Ahead-of-Time Compilation”, JEP 295, men vi går ikke dypt inn i AOT-kompileringsteknologien her.

Hovedårsaken til at vi vil bruke Graal på denne måten er å øke hastigheten på oppstartstid til den vanlige Tiered Compilation-tilnærmingen i HotSpot kan ta over.

5. Konklusjon

I denne artikkelen undersøkte vi funksjonalitetene til den nye Java JIT-kompilatoren som en del av prosjektet Graal.

Vi beskrev først tradisjonelle JIT-kompilatorer og diskuterte deretter nye funksjoner i Graal, spesielt det nye JVM Compiler-grensesnittet. Deretter illustrerte vi hvordan begge kompilatorene fungerer og sammenlignet forestillingene sine.

Etter det har vi snakket om datastrukturen som Graal bruker for å manipulere programmet vårt, og til slutt om AOT-kompilatormodus som en annen måte å bruke Graal på.

Som alltid kan kildekoden bli funnet på GitHub. Husk at JVM må konfigureres med de spesifikke flaggene - som ble beskrevet her.