aot: marshal compiled namespaces to bytecode images

Adds src/jolt/aot.janet: save-ns / load-ns-image marshal a namespace's compiled
var cells to a Janet bytecode image and load them back, skipping
parse/analyze/emit/compile on reload.

Compiled functions close over core fns (cfunctions) that can't marshal by value,
so we marshal against a dictionary built from the baked-in runtime env
(env-lookup jolt-runtime-env, which chains to the Janet boot env): core fns and
builtins are referenced by name, only user bytecode and its var cells are
serialized. The runtime env is identical at save and load time (same binary), so
the dictionaries match.

Test round-trips a namespace (constant, fn over it, core-fn user, recursion) into
a fresh context and confirms the loaded vars run and stay redefinable.
This commit is contained in:
Yogthos 2026-06-06 02:57:32 -04:00
parent 62c5060093
commit 877373b7e6
2 changed files with 90 additions and 0 deletions

47
src/jolt/aot.janet Normal file
View file

@ -0,0 +1,47 @@
# Ahead-of-time images for compiled namespaces.
#
# Compile-by-default turns each form into Janet bytecode at load time. AOT skips
# that work on subsequent runs by serializing a namespace's compiled vars to a
# bytecode image and loading them back.
#
# The trick is the marshal dictionary. A compiled jolt function closes over core
# fns (core-map, +, …) and var cells; those core fns are Janet cfunctions/closures
# that can't be marshaled by value. But the runtime env that holds them is baked
# into the binary and is byte-for-byte identical at save and load time, so we
# marshal *against* it: core fns are referenced by name, and only the user's
# bytecode plus its var cells are actually serialized.
(use ./compiler) # jolt-runtime-env
(use ./types)
# Forward dict (key -> value) for unmarshal; reverse (value -> key) for marshal.
# Built from the runtime env, which chains to the Janet boot env, so both core fns
# and Janet builtins resolve by name.
(defn- fwd-dict [] (env-lookup jolt-runtime-env))
(defn- rev-dict [] (invert (env-lookup jolt-runtime-env)))
(defn marshal-ns
"Marshal namespace `ns-name`'s var mappings to a byte buffer. The whole mappings
table is marshaled in one call so var cells shared between defs stay shared."
[ctx ns-name]
(marshal ((ctx-find-ns ctx ns-name) :mappings) (rev-dict)))
(defn unmarshal-ns!
"Install mappings produced by marshal-ns into `ns-name` in ctx, overwriting
same-named vars. Returns ns-name."
[ctx ns-name bytes]
(let [mappings (unmarshal bytes (fwd-dict))
ns (ctx-find-ns ctx ns-name)]
(each [sym v] (pairs mappings) (put (ns :mappings) sym v))
ns-name))
(defn save-ns
"Write an AOT image of compiled namespace `ns-name` to `path`."
[ctx ns-name path]
(spit path (marshal-ns ctx ns-name)))
(defn load-ns-image
"Read an AOT image written by save-ns back into ctx under `ns-name`. Skips
parse/analyze/emit/compile entirely — the bytecode is already built."
[ctx ns-name path]
(unmarshal-ns! ctx ns-name (slurp path)))