JEP 544: Ahead-of-Time Code Compilation
Samenvatting
Doelen
- Applicaties in staat stellen om sneller piekprestaties te bereiken.
- Applicaties in staat stellen om piekprestaties te behouden, zelfs wanneer werklasten veranderen.
- Geen wijzigingen vereisen in de code van applicaties, bibliotheken of frameworks.
- Geen wijzigingen vereisen in de configuratie van HotSpot, afgezien van het aanvragen van het gebruik van de AOT-cache.
- Ondersteuning blijven bieden voor de Serial, Parallel, G1 en ZGC garbage collectors.
- Geen nieuwe AOT-workflows introduceren, maar juist de bestaande workflow voor het maken van de AOT-cache uitbreiden.
- Ervoor zorgen dat de overgang van AOT-gecompileerde code naar JIT-gecompileerde code onzichtbaar is voor applicaties.
- Ondersteuning bieden voor de AArch64- en x64-processorarchitecturen.
Geen doelen (Non-Goals)
- Het is geen doel om een AOT-only modus te bieden. Applicaties zullen zowel AOT-gecompileerde code als JIT-gecompileerde code in dezelfde run gebruiken en hier automatisch tussen schakelen indien nodig.
- Het is geen doel om cross-compilatie te ondersteunen. Code die in een trainingsrun is gecompileerd, moet in daaropvolgende productieruns op dezelfde CPU-architectuur met dezelfde set functies worden uitgevoerd.
- Het is geen doel om alle CPU-architecturen die momenteel door HotSpot worden ondersteund, direct te ondersteunen. Er wordt verwacht dat normale portage-activiteiten uiteindelijk ondersteuning toevoegen voor alle belangrijke architecturen.
Motivatie
Wanneer een Java-applicatie wordt uitgevoerd door de HotSpot JVM, doorloopt deze drie fasen: opstarten, opwarmen en het bereiken van piekprestaties.
Tijdens het opstarten roept HotSpot de main-methode van de applicatie aan en laadt, koppelt en initialiseert het klassen on-demand. In eerste instantie wordt zowel de applicatie- als de JDK-bibliotheekcode uitgevoerd via de bytecode-interpreter, wat traag is. Binnen de interpreter profileert HotSpot het gedrag van de applicatie door gebeurtenissen te tellen, zoals methode-aanroepen en loop-iteraties. Met deze profielgegevens selecteert HotSpot veelvuldig aangeroepen methoden (de "hot spots"), die via de basis C1-compiler naar native code worden gecompileerd. Deze native code is slechts beperkt geoptimaliseerd.
Tijdens het opwarmen stabiliseert de applicatie zich in zijn werklast en neemt het laden, koppelen en initialiseren van klassen af. HotSpot blijft de applicatie profileren, zowel in de bytecode-interpreter als via instrumentatiecode die door C1 is ingevoegd. Er wordt rijkere profielinformatie verzameld, waaronder niet alleen aantallen aanroepen en iteraties, maar ook de types objecten die worden aangetroffen. Naarmate de profielgegevens zich ophopen, worden ze statistisch nuttiger. Uiteindelijk gebruikt HotSpot deze data om de meest actieve methoden te selecteren, die via de geavanceerde C2-compiler naar native code worden gecompileerd. Deze native code bevat geen instrumentatie en is zeer sterk geoptimaliseerd.
Het profileren van de applicatie en het genereren van native code is niet gratis. Niet alleen is de bytecode-interpreter traag, maar geïnstrumenteerde native code is langzamer dan niet-geïnstrumenteerde native code. Het compileren van methoden naar native code vereist zowel CPU-tijd als geheugen. Hoewel HotSpot methoden alleen compileert wanneer de profielgegevens aangeven dat dit de moeite waard is, haalt de JIT-compilatie geleidelijk de opkomende hot spots in en versnelt de applicatie. Uiteindelijk zijn alle hotte methoden gecompileerd naar volledig geoptimaliseerde native code en worden de compilers inactief.
De applicatie blijft in deze staat van piekprestaties zolang de hot spots niet veranderen. Echter, hot spots kunnen veranderen als reactie op wijzigingen in de werklast. In dat geval kan HotSpot dynamisch de-optimaliseren door eerder gegenereerde native code weg te gooien, en opnieuw optimaliseren door nieuwe native code te genereren voor nieuwe hotte methoden. Bijvoorbeeld, als een applicatie aanvankelijk twee typen verzoeken ontvangt, optimaliseert HotSpot de code voor die twee typen. Als de applicatie vervolgens een derde type verzoek begint te ontvangen, kan HotSpot de code dynamisch de-optimaliseren en opnieuw optimaliseren voor alle drie de typen verzoeken. De applicatie doorloopt in feite opnieuw een opwarmfase om de prestaties te behouden bij een veranderende werklast.
Hoe zit het met statische compilatie?
Statische compilatie is soms voorgesteld als alternatief voor de dynamische compilatie van Java-code. Een statische compiler converteert volledige applicaties vooraf (ahead-of-time) naar native code, vóór de runtime.
Statische compilatie heeft enkele voordelen: een statisch gecompileerde applicatie start op en bereikt direct piekprestaties, zonder opwarmfase. Tijdens runtime is er geen bytecode-interpreter, profilering of compilatie nodig. Piekprestaties kunnen zelfs concurrerend zijn met HotSpot als de optimalisatie van de statische compiler wordt geleid door nauwkeurige profielen uit eerdere runs.
Dynamische compilatie heeft echter drie belangrijke voordelen boven statische compilatie:
- Wendbaarheid: Dynamische compilatie reageert op veranderingen in de hot spots van de applicatie door te de-optimaliseren en opnieuw te optimaliseren, waardoor prestaties behouden blijven bij een variërende werklast. Een statisch gecompileerde applicatie kan dit niet; deze kan inherent slechts voor één set hot spots worden geoptimaliseerd.
- Portabiliteit: Dynamische compilatie genereert native code tijdens runtime die specifiek is voor de runtime-omgeving. Bij implementatie op een andere processorarchitectuur, een andere feature-set, een ander besturingssysteem of een andere JDK-versie, bereikt HotSpot piekprestaties voor die omgeving zonder dat de applicatie hoeft te worden gewijzigd. Een statisch gecompileerde applicatie moet bij dergelijke wijzigingen opnieuw worden gecompileerd.
- Compatibiliteit met het Java Platform: Functies zoals dynamisch laden van klassen, dynamische koppeling, dynamische dispatch en dynamische reflectie zijn fundamenteel voor het succes van het platform. HotSpot handelt deze functies natuurlijk af, terwijl statische compilers hiermee worstelen. Zelfs uitgebreide statische analyse kan niet compenseren voor het feit dat deze functies beslissingen vereisen die tijdens runtime moeten worden genomen. Implementaties van statische compilers voor Java wijken daarom vaak uit naar incompatibele beperkingen, zoals closed-world assumptions, of leggen een zware last op ontwikkelaars (zoals het vooraf moeten identificeren van klassen die geschikt zijn voor reflectie).
Verschuiven van compilatiewerk naar trainingsruns
Tijdens de opstart- en opwarmfasen balanceert HotSpot voortdurend meerdere taken: het uitvoeren van applicatie- en JDK-bibliotheekcode, het on-demand laden, koppelen en initialiseren van klassen, het profileren van de uitvoering en het compileren van hotte methoden naar native code.
De these van Project Leyden is dat de sleutel tot het verbeteren van de opstart- en opwarmtijd ligt in het uitvoeren van een deel van dit werk eerder, ahead of time, in plaats van alleen just in time. We verschuiven dit werk naar een trainingsrun en slaan de resultaten op in de AOT-cache voor direct gebruik in daaropvolgende productieruns.
- Het werk rondom het laden en koppelen van klassen is eerder naar voren verschoven via JEP 483 (geleverd in JDK 24). De AOT-cache slaat de geladen en gekoppelde vormen van klassen uit de trainingsrun op.
- Het profileringswerk is eerder naar voren verschoven via JEP 515 (geleverd in JDK 25). De AOT-cache slaat de executieprofielen van methoden op, waardoor de C2-compiler direct aan het begin van productieruns kan starten.
Deze verbeteringen vormen de basis voor het uiteindelijke doel: het verschuiven van compilatie- en optimalisatiewerk. De AOT-cache zal geoptimaliseerde native code opslaan die in de trainingsrun is gecompileerd, zodat HotSpot deze code direct kan laden in plaats van deze bij elke productierun opnieuw te moeten compileren. Dit verbetert zowel de opstart- als de opwarmtijd.
HotSpot zal niet altijd de gecachte code gebruiken; als de werklast van de applicatie verandert, kan HotSpot, zoals gebruikelijk, de-optimaliseren en opnieuw optimaliseren. Java-applicaties profiteren zo van de voordelen van statische compilatie, terwijl ze de wendbaarheid, portabiliteit en compatibiliteit van dynamische compilatie behouden.
Beschrijving
We breiden de bestaande AOT-cache uit om geoptimaliseerde native code op te slaan die in een trainingsrun is gegenereerd. Deze gecachte code wordt AOT-code genoemd. Tijdens een productierun kan een verzoek om geoptimaliseerde code voor een methode direct worden ingewilligd als er overeenkomende AOT-code in de cache wordt gevonden. Als AOT-code niet beschikbaar, incompatibel, ongeschikt is of later is gede-optimaliseerd, valt de uitvoering terug op de bestaande interpreter- en JIT-mechanismen. AOT-code en JIT-code kunnen naast elkaar bestaan en zijn volledig interoperabel, aangezien ze door dezelfde compilers (C1 en C2) zijn gemaakt.
Om een AOT-cache te maken, gebruikt u de optie AOTCacheOutput voor een trainingsrun van uw applicatie om AOT-code te genereren:
$ java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...
Deze workflow is ongewijzigd ten opzichte van vorige releases. De AOT-cache in het bestand app.aot bevat nu echter niet alleen vooraf gekoppelde klassen en profileringsgegevens, maar ook AOT-code voor geselecteerde hotte methoden. Vervolgens kunt u de applicatie in productie draaien met de cache:
$ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...
Er zijn geen extra opties of instellingen nodig om AOT-code te genereren of te gebruiken. HotSpot maakt standaard AOT-code aan en slaat deze op in de cache. Ook worden profielgegevens bewaard om de volgorde van het laden van AOT-code te bepalen en de daaropvolgende generatie van JIT-code te sturen.
Prestaties
Om het opstartvoordeel van AOT-code te evalueren, zijn vijf benchmark-applicaties uitgevoerd op een Linux/x64-systeem met twee cores om een microservice-omgeving te simuleren.
- Zonder AOT-code vermindert de AOT-cache de opstarttijd van deze applicaties met ongeveer 50% tot 70%.
- Met AOT-code vermindert de cache de opstarttijd met ongeveer 65% tot 80%.
Om het opwarmvoordeel te evalueren, is een javac-benchmark gebruikt die herhaaldelijk dezelfde 50 bronbestanden compileert:
- In elke curve toont de eerste iteratie de verbetering in opstarttijd: de AOT-cache zonder AOT-code verbetert de opstarttijd met ongeveer 30%; het toevoegen van AOT-code zorgt voor een extra verbetering van 45%, wat neerkomt op een totaal van ongeveer 75%.
- De opeenvolgende iteraties tonen de opwarmfase. De curve voor de AOT-cache zonder AOT-code daalt sneller dan die zonder cache en bereikt uiteindelijk ongeveer dezelfde stabiele toestand. De curve voor de cache met AOT-code is echter al bij de vierde iteratie dicht bij de stabiele toestand.
Verschillen tussen AOT-code en JIT-code
AOT-code en JIT-code kunnen verschillen, omdat trainingsruns en productieruns kunnen afwijken.
Een bron van verschillen is de volgorde waarin klassen worden geïnitialiseerd. Een methode die een statisch veld benadert of een statische methode in een andere klasse aanroept, moet ervoor zorgen dat die klasse is geïnitialiseerd. Bij het genereren van AOT-code met C2 compileert HotSpot daarom twee versies van dergelijke methoden:
- Een trage versie: Bevat extra code om de initialisatie van referentiekassen te garanderen.
- Een snelle versie: Bevat deze code niet en kan daardoor beter worden geoptimaliseerd.
HotSpot gebruikt aanvankelijk de trage versie en schakelt over naar de snelle versie zodra alle referentiekassen zijn geïnitialiseerd.
Een andere bron is de waarde van een static final veld, die per run kan variëren (bijv. initialisatie met de huidige datum en tijd). Bij JIT-compilatie is de klasse al geïnitialiseerd, dus kent C2 de waarde en kan deze als een constante direct in de native code embedden. Bij AOT-compilatie is de waarde onbekend, waardoor C2 code moet genereren die het veld expliciet laadt.
Ondanks deze verschillen levert AOT-code aanzienlijke prestatievoordelen op een manier die transparant is voor applicaties. De compilers kunnen op runtime JIT-code genereren om AOT-code te vervangen die niet langer optimaal is.
Consistentie van trainings- en productieruns
Om te profiteren van de AOT-cache, moeten de trainingsrun en alle daaropvolgende productieruns essentieel gelijk zijn. AOT-code wordt alleen gebruikt als aan twee extra voorwaarden wordt voldaan:
- CPU-architectuur: Alle runs maken gebruik van CPU's van dezelfde architectuur en met dezelfde functies. AOT-code gegenereerd voor een x64 CPU met AVX-512 zal bijvoorbeeld niet draaien op een x64 CPU zonder die functie.
- Garbage Collector: Alle runs gebruiken dezelfde garbage collector, aangezien AOT-code GC-specifieke read/write barriers bevat.
Als deze voorwaarden niet worden voldaan, geeft HotSpot een waarschuwing en wordt de AOT-code niet geladen. Er wordt teruggevallen op de gebruikelijke interpreter- en JIT-mechanismen, hoewel andere informatie uit de cache (zoals geladen klassen en profielgegevens) nog wel wordt gebruikt.
Het observeren van AOT-cachegebruik in productie
U kunt via de optie PrintCompilation zien of AOT-code wordt geladen:
$ java -XX:+PrintCompilation \
-XX:AOTCache=app.aot -cp app.jar com.example.App ...
Om te controleren of een cache bruikbaar is in een specifieke omgeving, kunt u AOTMode=required gebruiken. HotSpot zal dan een foutmelding geven en afsluiten als de cache een beperking schendt:
$ java -XX:AOTMode=required \
-XX:AOTCache=app.aot -cp app.jar com.example.App ...
Het laden van AOT-code kan worden uitgeschakeld via de diagnostische optie AOTCodeCaching:
$ java -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching \
-XX:AOTCache=app.aot -cp app.jar com.example.App ...
Het beheren van de generatie van AOT-code tijdens training
Het maken van een AOT-cache bestaat in feite uit twee stappen:
- Record mode: HotSpot observeert het gedrag van de applicatie en slaat dit op in een AOT-configuratie.
- Create mode: HotSpot stelt een AOT-cache samen vanuit die configuratie, inclusief het compileren van AOT-code.
De optie AOTCacheOutput voert beide stappen transparant uit. U kunt de stappen echter ook expliciet aanroepen:
$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
-cp app.jar com.example.App ...
$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot
Opties voor de C1- en C2-compilers gelden uniform voor zowel JIT- als AOT-code. Om JIT-activiteit tijdens de training en AOT-activiteit tijdens het maken van de cache te zien, gebruikt u:
$ java -XX:+PrintCompilation \
-XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...
Om alleen AOT-compilatieactiviteit te zien, kunt u de omgevingsvariabele JDKAOTVM_OPTIONS gebruiken:
$ JDK_AOT_VM_OPTIONS='-XX:+PrintCompilation' \
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...
Het genereren van AOT-code tijdens training kan worden uitgeschakeld via de diagnostische optie AOTCodeCaching om bijvoorbeeld de impact op de bestandsgrootte van de cache te evalueren.
Toekomstig werk
- Onderzoeken of de bytecode-interpretatie en JIT-compilatie geminimaliseerd kunnen worden ten gunste van AOT-code. Voorlopige experimenten suggereren dat dit kan leiden tot te grote cachebestanden of lagere piekprestaties.
- De standaardwaarden van bestaande compiler-opties afstemmen zodat deze beter passen bij AOT-code.
- Het overwegen van nieuwe opties, zoals expliciet beheer van de AOT-cachegrootte.
- Onderzoeken of portabiliteit tussen processoren van dezelfde architectuur (maar met verschillende feature-sets) kan worden vergroot, eventueel ten koste van prestaties of cachegrootte.
Testen
- Er worden nieuwe unit-tests gemaakt om te garanderen dat AOT-code correct functioneert.
- Bestaande AOT-cache tests worden uitgevoerd met deze feature ingeschakeld.
- In eerste instantie worden alleen AArch64- en x64-processoren ondersteund; unit-tests worden aangepast om het ontbreken van AOT-code op andere architecturen toe te staan.
Risico's en aannames
Er zijn geen nieuwe risico's bovenop die al zijn vermeld in JEP 483.
Er wordt aangenomen dat het organiserende principe van HotSpot nog steeds correct is: een Java-applicatie moet tijdens runtime worden gecompileerd om optimaal gebruik te maken van het werkelijke gedrag van de applicatie en de beschikbare processorfuncties.
Ook blijft de basisaanname van de AOT-cache overeind: een trainingsrun is een goede bron van observaties die, wanneer via een AOT-cache doorgegeven aan een productierun, de prestaties verbeteren. Dit geldt volledig voor AOT-code, die vergelijkbare productieruns ten goede komt zonder schade aan te richten aan afwijkende runs (die JIT-compilatie kunnen gebruiken voor andere code).
Groetjes,