Nucleus
Performance & native

AOT cache — le cold start quasi instantané de JDK 25

L'AOT cache de Project Leyden, généré automatiquement au build et embarqué dans le installateur. Un flag Gradle, ~1 s de cold boot avec un JIT pleinement tiered prêt à compiler.

Les apps desktop JVM payaient une taxe de cold start : des milliers de classes chargées, du bytecode vérifié, des chemins chauds JIT-compilés avant que l'UI ne réponde. C'est la raison originelle pour laquelle Nucleus existe. JDK 25 corrige ça avec l'AOT cache single-step de Project Leyden — classes, profils et code préchauffés hors ligne, rejoués au lancement. Nucleus génère ce cache pendant le build et l'embarque dans l'installateur.

TL;DR

  • Un flag Gradle : enableAotCache = true.
  • Le cold start tombe à ~1.2 s — HotSpot C2 reste le JIT, mais le warmup disparaît.
  • Le cache est généré par un run de le app en mode training pendant le build, puis embarqué dans chaque distribution.
  • nucleus.aot-runtime permet à le code de brancher selon training vs runtime.

Install

nucleus.aot-runtime est tiré transitivement par nucleus.nucleus-application. Ajoute-le explicitement si l'on n'utilises pas l'umbrella :

dependencies {
    implementation("dev.nucleusframework:nucleus.aot-runtime:<version>")
}

Quickstart

// build.gradle.kts
nucleus.application {
    mainClass = "com.example.MainKt"
    nativeDistributions {
        enableAotCache = true
    }
}

JDK 25+ requis

La génération de l'AOT cache exige JDK 25 ou supérieur. Les toolchains plus anciennes échouent au build.

C'est tout côté build. Le plugin va :

  1. Lancer le app en mode training après createDistributable.
  2. Enregistrer le chargement de classes et la compilation JIT dans app.aot (-XX:AOTCacheOutput).
  3. Injecter -XX:AOTCache=app.aot dans le launcher.
  4. Embarquer le cache dans chaque installateur (.dmg, .msi, .deb, …).

Mode training — le app doit s'auto-terminer

Pendant le training, la JVM observe un vrai run de le app. Le code doit sortir proprement pour que le build ne reste pas bloqué jusqu'au timeout de sécurité de 300 s. Avec nucleusApplication { … }, c'est une ligne :

import dev.nucleusframework.application.aotTraining
import dev.nucleusframework.application.nucleusApplication
import kotlin.time.Duration.Companion.seconds

fun main(args: Array<String>) = nucleusApplication(args) {
    // No-op hors training. Appelle exitApplication() après 45 s.
    aotTraining(duration = 45.seconds)

    if (isAotTraining) {
        // Plus le run touche d'écrans réels, meilleur le cache.
        // Parcours le graphe de navigation, précharge polices et images.
        preloadNavigationScreens()
        preloadFontsAndImages()
    }

    DecoratedWindow(onCloseRequest = ::exitApplication, title = "MyApp") {
        App()
    }
}

Si on appelle application { … } de Compose directement, gère le timer manuellement :

import dev.nucleusframework.aot.runtime.AotRuntime

fun main() {
    if (AotRuntime.mode() == AotRuntimeMode.TRAINING) {
        Thread({ Thread.sleep(45_000); System.exit(0) }, "aot-timer").start()
        preloadNavigationScreens()
        preloadFontsAndImages()
    }
    application { /* ... */ }
}

Comment ça marche

Le plugin met la propriété système nucleus.aot.mode à training pendant la génération et à runtime quand le cache est chargé. AotRuntime.mode() lit cette propriété et renvoie AotRuntimeMode.TRAINING, RUNTIME ou NONE.

Le run de training doit :

  • Sortir avec le code 0 (le DSL aotTraining s'en charge).
  • Finir avant le timeout de sécurité de 300 s.
  • Être représentatif d'une vraie session utilisateur — plus on touche d'écrans réels, plus de méthodes sont profilées, plus le cold start post-cache est rapide.

Sous Linux headless, le plugin démarre Xvfb automatiquement.

Verrouillage plateforme & JDK

L'AOT cache est spécifique à la plateforme et au JDK. Un cache produit sur macOS ARM64 avec JBR 25.0.2 ne fonctionne pas sous Linux x64 ni avec un autre build de JDK.

  • Build sur chaque plateforme cible — une matrice CI est la mise en place la plus propre.
  • Le plugin utilise toujours le JDK embarqué par createDistributable pour le training, donc le JDK runtime correspond au JDK training octet par octet.
  • setup-nucleus épingle la même version JBR sur tous les runners. Voir CI/CD.

Référence

DSL Gradle

PropriétéDéfautNotes
nativeDistributions.enableAotCachefalseUn seul switch. Déclenche training + bundling.

AotRuntime

APIRenvoie
AotRuntime.mode()AotRuntimeMode.TRAINING, RUNTIME ou NONE

Scope nucleusApplication

PropriétéÉquivalent
aotModeAotRuntime.mode()
isAotTrainingaotMode == TRAINING
isAotRuntimeaotMode == RUNTIME
aotTraining(duration, onTimeout?)Timer d'auto-sortie pour les runs training

Méthodologie de mesure

Quand on compare des chiffres de cold start, mesure depuis le spawn de process OS (time ./MyApp sous Linux/macOS, Measure-Command sous Windows) jusqu'au premier frame visible. Le chiffre de 1.2 s du landing vient d'un démarrage JBR 25 standard avec un AOT cache pré-chauffé par un run de training de 45 s qui parcourt l'écran d'accueil et un onglet réglages.

Notes

  • Le cache est par binaire — chaque release embarque un cache neuf. N'essaie pas de partager les caches entre versions.
  • Préfère la voie GraalVM native image si l'on veut un cold start sous 100 ms ou une empreinte résidente minuscule. L'AOT cache gagne sur le débit ; GraalVM gagne sur le démarrage et la taille.