From bb0acedb8919912099d7160bc5d30fb00367ff8a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:14:09 -0600 Subject: [PATCH 1/5] Save the JIT's generated code in warm snapshots and compile it on each method's first call after a restore --- docs/browser.md | 2 +- docs/node.md | 4 +++- src/jit.ts | 19 ++++++++++++++++- src/jvm.ts | 11 ++++++++++ src/snapshot.ts | 25 ++++++++++++++++++++++ test/warm-jit.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/warm-jit.test.ts diff --git a/docs/browser.md b/docs/browser.md index 58a1e72..1e5ca30 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -135,7 +135,7 @@ compile loads over a thousand) and no library handles are live. Later library starts with the same class path restore that image, after checking that the files, missing paths and directory listings those calls used are unchanged; the first javac compile after it took 2.7 s instead of 5.3 s in Chromium on a -Raspberry Pi 5. An image is about 18 MB. IndexedDB keeps the 2 most recent warm images separately +Raspberry Pi 5. An image is about 21 MB; it holds the JS the JIT generated for the methods compiled so far, and a restored method compiles that code on its first call. IndexedDB keeps the 2 most recent warm images separately from boot snapshots. With `debug: true`, the console says whether a VM was saved, skipped as busy (with the reason) or restored, and why a restore missed. diff --git a/docs/node.md b/docs/node.md index bb3e525..08e18c9 100644 --- a/docs/node.md +++ b/docs/node.md @@ -75,7 +75,9 @@ compile loads over a thousand) and no library handles are live. A later library start with the same class path and working directory restores it, after checking that the files, missing paths and directory listings those calls used are unchanged; a javac compile from it takes about 1 s instead of 6 s on a -Raspberry Pi 5. An image is about 18 MB; warm snapshots share the snapshot +Raspberry Pi 5. An image is about 21 MB, as it also holds the JS the JIT generated for the +methods compiled so far; a restored method compiles that code on its first +call instead of generating it again. Warm snapshots share the snapshot directory and keep the 2 most recent. They build on boot snapshots, so with `snapshotDir: null` there are none. diff --git a/src/jit.ts b/src/jit.ts index 255f21a..8ea8e01 100644 --- a/src/jit.ts +++ b/src/jit.ts @@ -22,6 +22,7 @@ import { memberTarget } from "./natives/invoke.ts"; import { concatStrings } from "./natives/strings.ts"; import { Frame, JavaThrow, NativeFrame, PendingThrow, fromNative } from "./thread.ts"; import type { JThread } from "./thread.ts"; +import type { JVM } from "./jvm.ts"; export const RET = 0, SUSPEND = 1; export type Compiled = (t: JThread, f: Frame) => number; @@ -64,10 +65,26 @@ export function noteBackedge(t: JThread, m: JMethod) { if (m.hot >= BACKEDGE_THRESHOLD && m.jit === null && !m.noJit) compile(t, m); } +export interface SavedJit { src: string; consts: any[] } + +// Gives m the code saved with a warm snapshot, compiled on its first call. +export function restoreJit(vm: JVM, m: JMethod, code: SavedJit) { + if (disabled || noInline || stats) return; + vm.jitCode.set(m, code); + m.hot = THRESHOLD - 1; +} + function compile(t: JThread, m: JMethod) { if (disabled || m.noJit || !m.code || m.name === "" || m.code.code.length > MAX_CODE) return; + const vm = t.vm; try { - const { src, consts } = generate(t, m); + let code = vm.keepJit ? undefined : vm.jitCode.get(m); + if (code) vm.jitCode.delete(m); + else { + code = generate(t, m); + if (vm.keepJit && !stats && !noInline) vm.jitCode.set(m, code); + } + const { src, consts } = code; if (dump && m.toString().includes(dump)) console.error(`// ${m}\n${src}`); m.jit = new Function("rt", "K", src)(runtime, consts) as Compiled; if (stats) stats.set(`compiled ${m}`, src.length); diff --git a/src/jvm.ts b/src/jvm.ts index 4e8359c..070ccbb 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -25,6 +25,7 @@ import { hostNative, jniNames } from "./hostnatives.ts"; import { Library } from "./library.ts"; import { dumpVM, restoreVM, snapshotKey } from "./snapshot.ts"; import type { SnapshotImage, WarmSnapshotImage } from "./snapshot.ts"; +import type { SavedJit } from "./jit.ts"; import type { LibraryRequest } from "./library.ts"; import type { HostNative } from "./hostnatives.ts"; import { PROXY_HOST, PROXY_PORT } from "./http-proxy.ts"; @@ -143,6 +144,10 @@ export class JVM { classCount = 0; // every class in creation order, until the snapshot after boot is taken created: JClass[] | null; + // Generated JS of compiled methods: kept until the warm snapshot is saved, + // then (after a warm restore) compiled on each method's first call. + keepJit: boolean; + jitCode = new Map(); threadsStarted = 0; peakThreads = 0; exitCode: number | null = null; @@ -159,6 +164,7 @@ export class JVM { constructor(opts: JvmOptions) { this.created = opts.snapshots ? [] : null; + this.keepJit = !!(opts.warmSnapshot && opts.warmSnapshots && opts.snapshots); this.opts = opts; this.fs = opts.fs; const extra = ipv4Literal(opts.hostName); @@ -1153,6 +1159,8 @@ export class JVM { if (!trace) { this.fs.endWarmTrace(); this.warmTraceActive = false; + this.keepJit = false; + this.jitCode.clear(); this.debug("warm snapshot busy: access record too large"); return; } @@ -1164,6 +1172,8 @@ export class JVM { this.fs.endWarmTrace(); this.warmTraceActive = false; this.warmSaved = true; + this.keepJit = false; + this.jitCode.clear(); for (const c of this.created ?? []) c.bytes = undefined; this.created = null; this.debug("warm snapshot saved"); @@ -1259,6 +1269,7 @@ export class JVM { return false; } try { + this.keepJit = false; restoreVM(this, image, t); this.warmRestored = true; this.warmSaved = true; diff --git a/src/snapshot.ts b/src/snapshot.ts index d2661dd..bb5dd0c 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -12,6 +12,7 @@ import { MAX_HEAP } from "./runtime.ts"; import type { WarmTrace } from "./fs.ts"; import { reopenSnapshotFile, OpenFile, streams } from "./natives/io.ts"; import { JArray, JClass, JField, JMethod, JObject, Monitor } from "./runtime.ts"; +import { restoreJit } from "./jit.ts"; import { Frame, JThread, NativeFrame, STATUS_RUNNABLE } from "./thread.ts"; import type { NativeGen } from "./thread.ts"; @@ -48,6 +49,8 @@ interface ClassRec { nestHost: unknown; links: [string, unknown][]; caches: [string, unknown][]; + // generated JS and constants of compiled methods, in warm snapshots + jit?: [string, string, unknown][]; } interface VmRec { @@ -112,6 +115,18 @@ class Encoder { private work: (() => void)[] = []; + // enc, or null with nothing recorded when v cannot be saved + tryEnc(v: any, path: string): any { + const before = this.twins.size; + try { + return this.enc(v, path); + } catch { + this.work.length = 0; + [...this.twins.keys()].slice(before).forEach((k) => this.twins.delete(k)); + return null; + } + } + // the twin of v; containers are filled in by drain, so deep object graphs // need no deep recursion enc(v: any, path: string): any { @@ -271,6 +286,15 @@ export function dumpVM(vm: JVM, main: JThread, key: string, library = false): Sn links: c.methods.filter((m) => m.link).map((m) => [m.key, e.enc(m.link, `${p}.${m.key}.link`)]), caches: c.methods.filter((m) => m.cache).map((m) => [m.key, e.enc(m.cache, `${p}.${m.key}.cache`)]), }; + if (library) { + const jit: [string, string, unknown][] = []; + for (const m of c.methods) { + const code = m.jit && vm.jitCode.get(m); + const consts = code && e.tryEnc(code.consts, `${p}.${m.key}.jit`); + if (code && consts) jit.push([m.key, code.src, consts]); + } + if (jit.length) rec.jit = jit; + } if (c.initThread || c.initWaiters.length) throw new Error(`snapshot: ${c.name} is being initialized`); if (rec.kind === "prim") rec.desc = c.prim!; else if (rec.kind === "array") rec.component = e.cls(c.component!, p); @@ -397,6 +421,7 @@ export function restoreVM(vm: JVM, image: SnapshotImage, main: JThread) { c.nestHost = d.dec(r.nestHost); for (const [k, l] of r.links) c.methodMap.get(k)!.link = d.dec(l); for (const [k, cache] of r.caches ?? []) c.methodMap.get(k)!.cache = d.dec(cache); + for (const [k, src, consts] of r.jit ?? []) restoreJit(vm, c.methodMap.get(k)!, { src, consts: d.dec(consts) }); }); d.flush(); const v = image.vm; diff --git a/test/warm-jit.test.ts b/test/warm-jit.test.ts new file mode 100644 index 0000000..f0fcd38 --- /dev/null +++ b/test/warm-jit.test.ts @@ -0,0 +1,49 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startLibrary } from "../src/node.ts"; +import { hotspot } from "../scripts/hotspot.ts"; + +// Own file: a warm image restores reliably only in a process that has run no other VM. +const root = join(import.meta.dirname, ".."); +const classes = join(root, "build", "test-warm-snapshot"); +mkdirSync(classes, { recursive: true }); +execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "warm-snapshot", "WarmJavac.java")]); + +async function compile(cache: string, args: string[]) { + const vm = await startLibrary({ classpath: [classes], snapshotDir: cache, warmSnapshot: true }); + try { + const before = vm.jitCode.size; + const result = await vm.callStatic("WarmJavac", "compile", args); + return { result, before, after: vm.jitCode.size }; + } finally { + vm.halt(0); + } +} + +test("a warm restore compiles methods from their saved code on first call", async () => { + const dir = mkdtempSync(join(tmpdir(), "java-web-warm-jit-")); + try { + const src = join(dir, "Result.java"); + writeFileSync(src, "public class Result { public static int value() { return 7; } }\n"); + const reference = join(dir, "reference"); + mkdirSync(reference); + execFileSync(hotspot("javac"), ["-g:none", "-d", reference, src]); + const expected = readFileSync(join(reference, "Result.class")).toString("base64"); + const args = [src, join(dir, "not-created", "Absent.class"), join(dir, "out")]; + + const first = await compile(dir, args); + assert.equal(first.result, expected); + assert.ok(first.before < 100, "the first run has no saved code"); + + const restored = await compile(dir, args); + assert.equal(restored.result, expected); + assert.ok(restored.before > 100, `saved code for ${restored.before} methods`); + assert.ok(restored.after < restored.before / 2, `${restored.after} of ${restored.before} saved methods were not compiled`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From a57ab23c7706cc36cdb4abea94fba397d3b6c9f9 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:03:38 -0600 Subject: [PATCH 2/5] Drop kept JIT source once no warm snapshot will be taken. --- src/jvm.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/jvm.ts b/src/jvm.ts index 09ce993..4fac507 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -1147,10 +1147,17 @@ export class JVM { this.warmTraceActive = this.fs.beginWarmTrace(); this.warmTraceClasses = this.classCount; } + // no warm snapshot will be taken (a restored VM keeps its saved code) + if (!this.warmTraceActive && this.keepJit) this.dropJit(); started(); yield* this.library.loop(t); } + private dropJit() { + this.keepJit = false; + this.jitCode.clear(); + } + libraryIdle(t: JThread) { const cache = this.opts.warmSnapshots; if (!this.opts.warmSnapshot || !this.warmTraceActive || this.warmSaved || !cache) return; @@ -1164,8 +1171,7 @@ export class JVM { if (!trace) { this.fs.endWarmTrace(); this.warmTraceActive = false; - this.keepJit = false; - this.jitCode.clear(); + this.dropJit(); this.debug("warm snapshot busy: access record too large"); return; } @@ -1177,8 +1183,7 @@ export class JVM { this.fs.endWarmTrace(); this.warmTraceActive = false; this.warmSaved = true; - this.keepJit = false; - this.jitCode.clear(); + this.dropJit(); for (const c of this.created ?? []) c.bytes = undefined; this.created = null; this.debug("warm snapshot saved"); @@ -1339,6 +1344,7 @@ export class JVM { if (!this.opts.warmSnapshot || !this.libraryStarting) { for (const c of this.created ?? []) c.bytes = undefined; this.created = null; + this.dropJit(); } if (cmd !== undefined) { this.props.set("sun.java.command", cmd); From 566be54c7128dedb434ebde8c6382da8d4c6b843 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:15:27 -0600 Subject: [PATCH 3/5] Drop the machine name from the warm snapshot docs. --- docs/browser.md | 8 +++++--- docs/node.md | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/browser.md b/docs/browser.md index 4d0a50b..57030b7 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -160,9 +160,11 @@ With `warmSnapshot: true`, `cheerpjRunLibrary` also saves its VM after a call returns once its calls have loaded at least 200 classes since boot (one javac compile loads over a thousand) and no library handles are live. Later library starts with the same class path restore that image, after checking that the -files, missing paths and directory listings those calls used are unchanged; the -first javac compile after it took 2.7 s instead of 5.3 s in Chromium on a -Raspberry Pi 5. An image is about 21 MB; it holds the JS the JIT generated for the methods compiled so far, and a restored method compiles that code on its first call. IndexedDB keeps the 2 most recent warm images separately +files, missing paths and directory listings those calls used are unchanged. In +Chromium, the first javac compile after it takes about half the time of one +after a boot snapshot. An image is about 21 MB; it holds the JS the JIT +generated for the methods compiled so far, and a restored method compiles that +code on its first call. IndexedDB keeps the 2 most recent warm images separately from boot snapshots. With `debug: true`, the console says whether a VM was saved, skipped as busy (with the reason) or restored, and why a restore missed. diff --git a/docs/node.md b/docs/node.md index 36ebd2d..ec8a495 100644 --- a/docs/node.md +++ b/docs/node.md @@ -74,10 +74,10 @@ returns once its calls have loaded at least 200 classes since boot (one javac compile loads over a thousand) and no library handles are live. A later library start with the same class path and working directory restores it, after checking that the files, missing paths and directory listings those calls used -are unchanged; a javac compile from it takes about 1 s instead of 6 s on a -Raspberry Pi 5. An image is about 21 MB, as it also holds the JS the JIT generated for the -methods compiled so far; a restored method compiles that code on its first -call instead of generating it again. Warm snapshots share the snapshot +are unchanged. A first javac compile from it takes about a sixth of the time +of one after a boot snapshot. An image is about 21 MB, as it also holds the JS +the JIT generated for the methods compiled so far; a restored method compiles +that code on its first call instead of generating it again. Warm snapshots share the snapshot directory and keep the 2 most recent. They build on boot snapshots, so with `snapshotDir: null` there are none. From a20037f22948fe40ae1beb18b1ce3ed55a4a3a97 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:11:03 -0600 Subject: [PATCH 4/5] Check the warm JIT test against a fixed count of methods compiled from saved code. --- test/warm-jit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/warm-jit.test.ts b/test/warm-jit.test.ts index f0fcd38..4f66bf6 100644 --- a/test/warm-jit.test.ts +++ b/test/warm-jit.test.ts @@ -42,7 +42,8 @@ test("a warm restore compiles methods from their saved code on first call", asyn const restored = await compile(dir, args); assert.equal(restored.result, expected); assert.ok(restored.before > 100, `saved code for ${restored.before} methods`); - assert.ok(restored.after < restored.before / 2, `${restored.after} of ${restored.before} saved methods were not compiled`); + // compile removes a method's saved code only when it compiles from it + assert.ok(restored.before - restored.after >= 50, `${restored.before - restored.after} methods compiled from saved code`); } finally { rmSync(dir, { recursive: true, force: true }); } From c3b360d8c07ff4963b189cfadcfa36f2c99aff87 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:37:36 -0600 Subject: [PATCH 5/5] Drop stale JIT entries before a warm restore, skip saved code for missing methods, and keep pending encoder work on a failed constant. --- src/jvm.ts | 2 +- src/snapshot.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/jvm.ts b/src/jvm.ts index e9817af..75f2eb6 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -1277,7 +1277,7 @@ export class JVM { return false; } try { - this.keepJit = false; + this.dropJit(); restoreVM(this, image, t); this.warmRestored = true; this.warmSaved = true; diff --git a/src/snapshot.ts b/src/snapshot.ts index d381679..d74d6c0 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -117,11 +117,11 @@ class Encoder { // enc, or null with nothing recorded when v cannot be saved tryEnc(v: any, path: string): any { - const before = this.twins.size; + const before = this.twins.size, pending = this.work.length; try { return this.enc(v, path); } catch { - this.work.length = 0; + this.work.length = pending; [...this.twins.keys()].slice(before).forEach((k) => this.twins.delete(k)); return null; } @@ -421,7 +421,10 @@ export function restoreVM(vm: JVM, image: SnapshotImage, main: JThread) { c.nestHost = d.dec(r.nestHost); for (const [k, l] of r.links) c.methodMap.get(k)!.link = d.dec(l); for (const [k, cache] of r.caches ?? []) c.methodMap.get(k)!.cache = d.dec(cache); - for (const [k, src, consts] of r.jit ?? []) restoreJit(vm, c.methodMap.get(k)!, { src, consts: d.dec(consts) }); + for (const [k, src, consts] of r.jit ?? []) { + const m = c.methodMap.get(k); + if (m) restoreJit(vm, m, { src, consts: d.dec(consts) }); + } }); d.flush(); const v = image.vm;