diff --git a/docs/foundational-runtime-lever1-native-codegen.md b/docs/foundational-runtime-lever1-native-codegen.md index 55e9e01..17db927 100644 --- a/docs/foundational-runtime-lever1-native-codegen.md +++ b/docs/foundational-runtime-lever1-native-codegen.md @@ -96,6 +96,28 @@ Known limitation: building *core* with `JOLT_CGEN=1` would try to cgen core numeric-leaf fns into the cached ctx image, where embedded cfunctions may not serialize — keep cgen for app/user code until image-cache interaction is handled. +## Build-time AOT: native speed without a toolchain on the target (jolt-a7ds) + +The JIT path above runs `cc` at runtime. The AOT path moves compilation to build +time so the deploy target needs no `cc`/`janet.h`: + +- **Build phase** (`:cgen-collect?`, needs cc): loading the app records every + numeric-leaf defn's IR; `cgen/aot-build` compiles them all into ONE native + module (`gen-c-module`) and `write-manifest` persists `{sopath, [{ns name sym}]}`. +- **Deploy phase** (`:cgen-prebuilt`, NO cc): `cgen/load-aot` loads the prebuilt + `.so` (via the `native` builtin — no compiler) into a qname→cfunction map; the + backend's `:def` hook installs each as the var root with the same timing as the + JIT path, so callers direct-link to native code. + +**Proven** (`spike/native/aot-demo.janet`, two processes): build with cc, then +deploy with `cc` removed from PATH → `count-point` is still native, mandelbrot = +3288753 at **12.4 ms** (full 18×). Test: `test/integration/cgen-aot-test.janet`. + +This removes the runtime-toolchain dependency — the core of the deployment story. +What remains for a literal single binary: fuse the prebuilt `.so` + manifest into +the `jpm`-built executable (declare-native/static-lib link + an uberscript-style +source bundle), so it ships as one file instead of an exe + sidecar `.so`. + ## Open questions for the implementation (next beads) 1. **IR→C for the numeric subset.** Translate jolt IR → C for proven-double diff --git a/spike/native/aot-demo.janet b/spike/native/aot-demo.janet new file mode 100644 index 0000000..231c449 --- /dev/null +++ b/spike/native/aot-demo.janet @@ -0,0 +1,47 @@ +# AOT build/deploy demo (jolt-a7ds). Two phases, run as separate processes: +# janet spike/native/aot-demo.janet build # needs cc; compiles + writes manifest +# janet spike/native/aot-demo.janet deploy # run with cc REMOVED from PATH +# Proves the deploy target needs no C toolchain: it only loads the prebuilt .so. +(import ../../src/jolt/api :as api) +(import ../../src/jolt/cgen :as cgen) + +(def cp "(defn count-point [cr ci cap] (loop [i 0 zr 0.0 zi 0.0] (if (or (>= i cap) (> (+ (* zr zr) (* zi zi)) 4.0)) i (recur (inc i) (+ (- (* zr zr) (* zi zi)) cr) (+ (* 2.0 (* zr zi)) ci)))))") +(def run "(defn run [n] (let [cap 200 nd (* 1.0 n)] (loop [y 0 acc 0] (if (< y n) (let [ci (- (/ (* 2.0 y) nd) 1.0) row (loop [x 0 a 0] (if (< x n) (let [cr (- (/ (* 2.0 x) nd) 1.5)] (recur (inc x) (+ a (count-point cr ci cap)))) a))] (recur (inc y) (+ acc row))) acc))))") +(def manifest (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-aot-demo.jdn")) +(def dir "spike/native/build/aot") + +(defn setup [ctx] + (put (ctx :env) :direct-linking? true) + (api/eval-string ctx "(ns demo)")) + +(def phase (get (dyn :args) 1)) +(cond + (= phase "build") + (do + (def ctx (api/init-cached {:compile? true})) + (put (ctx :env) :cgen-collect? true) + (setup ctx) + (api/eval-string ctx cp) + (api/eval-string ctx run) + (def build (cgen/aot-build (get (ctx :env) :cgen-collected) {:dir dir})) + (cgen/write-manifest manifest build) + (printf "BUILD: cc-available? %p -> %s (%d fn)" (cgen/toolchain-available?) + (build :sopath) (length (build :entries)))) + + (= phase "deploy") + (do + (printf "DEPLOY: cc-available? %p (should be false with cc off PATH)" + (cgen/toolchain-available?)) + (def ctx (api/init-cached {:compile? true})) + (setup ctx) + (put (ctx :env) :cgen-prebuilt (cgen/load-aot manifest)) + (api/eval-string ctx cp) + (api/eval-string ctx run) + (def native? (cfunction? (api/eval-string ctx "count-point"))) + (def t0 (os/clock)) + (def total (api/eval-string ctx "(run 200)")) + (def ms (* 1000 (- (os/clock) t0))) + (printf "DEPLOY: count-point native? %p total %d (%s) %.1f ms" + native? total (if (= total 3288753) "OK" "MISMATCH") ms)) + + (eprint "usage: aot-demo.janet build|deploy")) diff --git a/src/jolt/backend.janet b/src/jolt/backend.janet index c82a5e7..94358d2 100644 --- a/src/jolt/backend.janet +++ b/src/jolt/backend.janet @@ -614,13 +614,34 @@ # candidate / redefable / toolchain absent / cc failed -> normal bytecode path). # Skip ^:redef / ^:dynamic: those must stay redefinable bytecode. (defn- cgen-root [ctx node] - (when (get (ctx :env) :cgen?) + (def env (ctx :env)) + (def meta (node :meta)) + (def redefable (and meta (or (get meta :redef) (get meta :dynamic)))) + (when (not redefable) + (def prebuilt (get env :cgen-prebuilt)) + (def qn (string (node :ns) "/" (node :name))) + (cond + # AOT deploy: install the build-time-compiled cfunction as the root (no cc). + # Same timing as the JIT path, so callers compiled later direct-link to it. + (and prebuilt (get prebuilt qn)) (get prebuilt qn) + # JIT: compile to C now (runs cc). + (and (get env :cgen?) (cgen/numeric-leaf? (node :init))) + (let [r (protect (cgen/compile-fn (node :init) {:name (string (node :ns) "_" (node :name))}))] + (and (r 0) (r 1)))))) + +# Build-time collection (jolt-a7ds): under :cgen-collect?, record each numeric- +# leaf defn's full fn IR (with its ns/name) so an AOT driver can compile them all +# into one native module. Does NOT swap the root — the app still loads as bytecode +# during collection; the native version is wired at the later deploy run. +(defn- cgen-collect! [ctx node] + (def env (ctx :env)) + (when (get env :cgen-collect?) (def meta (node :meta)) (def redefable (and meta (or (get meta :redef) (get meta :dynamic)))) (when (and (not redefable) (cgen/numeric-leaf? (node :init))) - (def r (protect (cgen/compile-fn (node :init) - {:name (string (node :ns) "_" (node :name))}))) - (and (r 0) (r 1))))) + (def coll (or (get env :cgen-collected) + (let [a @[]] (put env :cgen-collected a) a))) + (array/push coll {:ns (node :ns) :name (node :name) :ir (node :init)})))) (set emit (fn emit [ctx raw] @@ -670,6 +691,7 @@ :def (let [cell (cell-for ctx (node :ns) (node :name)) meta (node :meta) setter (if (and meta (not (empty? meta))) (var-setter-meta cell meta) (var-setter cell)) + _ (cgen-collect! ctx node) cfn (cgen-root ctx node)] # cgen path: install the native cfunction as the root and DON'T # inline-stash — callers must call the C fn, not inline the bytecode diff --git a/src/jolt/cgen.janet b/src/jolt/cgen.janet index 6776d0f..4cac298 100644 --- a/src/jolt/cgen.janet +++ b/src/jolt/cgen.janet @@ -169,32 +169,46 @@ (and (>= c 48) (<= c 57)) (= c 95)) c 95))) (string b)) -(defn gen-c-fn - "ir (a numeric-leaf :def-of-fn or :fn node), c-name -> a C source string for a - Janet native module exporting `c-name` as a cfunction. Assumes (numeric-leaf? ir)." - [ir c-name] +# Emit just the `static Janet cfun_(...) { ... }` definition for one fn +# into buf. Shared by the single-fn and multi-fn (AOT module) generators. +(defn- emit-cfun [buf ir c-name] (def fnode (fn-ir-of ir)) (def ar (first (ct/vview (fnode :arities)))) (def params (map string (ct/vview (ar :params)))) (def namer (new-namer)) (def scope @{}) - (def pre @"") - (buffer/push pre "#include \n") - (buffer/push pre "static Janet cfun_" c-name "(int32_t argc, Janet *argv) {\n") - (buffer/push pre "janet_fixarity(argc, " (string (length params)) ");\n") + (buffer/push buf "static Janet cfun_" c-name "(int32_t argc, Janet *argv) {\n") + (buffer/push buf "janet_fixarity(argc, " (string (length params)) ");\n") (eachp [i pn] params (def cv (namer)) - (buffer/push pre "double " cv " = janet_getnumber(argv, " (string i) ");\n") + (buffer/push buf "double " cv " = janet_getnumber(argv, " (string i) ");\n") (put scope pn cv)) - (def out @"") (def ret (namer)) - (buffer/push out "double " ret ";\n") - (emit-to (ar :body) ret scope nil out namer) - (string pre out - "return janet_wrap_number(" ret ");\n}\n" - "static const JanetReg cfuns[] = {{\"" c-name "\", cfun_" c-name - ", NULL}, {NULL, NULL, NULL}};\n" - "JANET_MODULE_ENTRY(JanetTable *env) { janet_cfuns(env, \"cg\", cfuns); }\n")) + (buffer/push buf "double " ret ";\n") + (emit-to (ar :body) ret scope nil buf namer) + (buffer/push buf "return janet_wrap_number(" ret ");\n}\n")) + +(defn gen-c-module + "entries: [{:sym :ir } ...] -> C source for + ONE Janet native module exporting every entry's :sym as a cfunction. This is + the AOT/build-time shape (jolt-a7ds): all of an app's hot fns in a single .so, + compiled once at build time. Assumes each :ir is a numeric leaf." + [entries] + (def buf @"") + (buffer/push buf "#include \n") + (each e entries (emit-cfun buf (e :ir) (e :sym))) + (buffer/push buf "static const JanetReg cfuns[] = {\n") + (each e entries + (buffer/push buf " {\"" (e :sym) "\", cfun_" (e :sym) ", NULL},\n")) + (buffer/push buf " {NULL, NULL, NULL}};\n") + (buffer/push buf "JANET_MODULE_ENTRY(JanetTable *env) { janet_cfuns(env, \"cg\", cfuns); }\n") + (string buf)) + +(defn gen-c-fn + "ir (a numeric-leaf :def-of-fn or :fn node), c-name -> C source for a Janet + native module exporting `c-name` as a cfunction. Assumes (numeric-leaf? ir)." + [ir c-name] + (gen-c-module [{:sym c-name :ir ir}])) # --- toolchain --- (defn header-dir @@ -208,11 +222,15 @@ found) (defn toolchain-available? - "True when a C compiler and janet.h are present (so compile-fn can work)." + "True when a C compiler and janet.h are present (so compile-fn can work). Never + throws: a missing cc (not on PATH) makes os/execute raise, so guard it — a + toolchain-less deploy target must get false, not a crash." [] (and (header-dir) - (= 0 (os/execute ["cc" "--version"] :px - {:out (file/open "/dev/null" :w) :err (file/open "/dev/null" :w)})))) + (let [r (protect (os/execute ["cc" "--version"] :p + {:out (file/open "/dev/null" :w) + :err (file/open "/dev/null" :w)}))] + (and (r 0) (= 0 (r 1)))))) (defn- mkdirs [path] (def parts (filter |(not (empty? $)) (string/split "/" path))) @@ -233,32 +251,103 @@ (def r (os/execute cmd :p)) (unless (= 0 r) (error (string "cgen: cc failed (" r ") for " cpath)))) +(defn- abspath [p] (if (string/has-prefix? "/" p) p (string (os/cwd) "/" p))) + +# Build a .so from C source, content-addressed and cached. The C + Janet ABI + +# platform fully determine the .so, so a matching cache entry is always valid. +# Returns the absolute .so path. JOLT_CGEN_NO_CACHE=1 forces a rebuild. +(defn- build-so [src dir] + (def stamp (string src "|" janet/version "-" janet/build "|" (os/which))) + (def key (string (band (hash stamp) 0x7FFFFFFF) "-" (length src))) + (mkdirs dir) + (def base (string dir "/cg-" key)) + (def sopath (string base ".so")) + (def abs (abspath sopath)) + (unless (and (not (os/getenv "JOLT_CGEN_NO_CACHE")) (os/stat abs)) + (def cpath (string base ".c")) + (spit cpath src) + (cc-compile cpath sopath)) + abs) + +(defn load-module + "Load a prebuilt cgen .so (NO cc — works on a target with no toolchain) and + return a table of symbol-string -> cfunction for every cfunction it exports. + This is the runtime side of the build-time/AOT path (jolt-a7ds)." + [sopath] + (def env @{}) + (native (abspath sopath) env) + (def out @{}) + (eachp [k v] env + (when (and (symbol? k) (table? v) (or (cfunction? (v :value)) (function? (v :value)))) + (put out (string k) (v :value)))) + out) + +(defn compile-module + "AOT path: compile MANY numeric-leaf fns into ONE native module, once, at build + time. entries: [{:sym :ir } ...]. Returns + {:sopath :fns {sym -> cfunction}}, or nil if the toolchain is absent + or any entry isn't a numeric leaf. opts: :dir (cache dir)." + [entries &opt opts] + (default opts {}) + (when (and (header-dir) (all |(numeric-leaf? ($ :ir)) entries)) + (def src (gen-c-module entries)) + (def sopath (build-so src (get opts :dir (cache-dir)))) + {:sopath sopath :fns (load-module sopath)})) + (defn compile-fn - "Compile a numeric-leaf fn IR to a native Janet cfunction. Returns the + "Compile a single numeric-leaf fn IR to a native Janet cfunction. Returns the cfunction, or nil if ir isn't a candidate or the toolchain is unavailable. opts: :dir (cache dir, default a persistent jolt-cgen under TMPDIR), :name (C - identifier base). The .so is content-addressed by a hash of the generated C + - the Janet ABI + platform, so cc only runs on the first build of a given fn; - later runs (same source) reuse the cached .so. JOLT_CGEN_NO_CACHE=1 forces a - rebuild. cc errors propagate (a compiler crash is a cgen bug, not a punt)." + identifier base). Content-addressed/cached like compile-module." [ir &opt opts] (default opts {}) (when (and (numeric-leaf? ir) (header-dir)) (def c-name (sanitize (get opts :name "jolt_cfn"))) (def src (gen-c-fn ir c-name)) - # content address: the C (semantics + symbol name) + ABI + platform fully - # determine the .so, so a matching cache entry is always valid. - (def stamp (string src "|" janet/version "-" janet/build "|" (os/which))) - (def key (string (band (hash stamp) 0x7FFFFFFF) "-" (length src))) - (def dir (get opts :dir (cache-dir))) - (mkdirs dir) - (def base (string dir "/cg-" key)) - (def sopath (string base ".so")) - (def abs (if (string/has-prefix? "/" sopath) sopath (string (os/cwd) "/" sopath))) - (unless (and (not (os/getenv "JOLT_CGEN_NO_CACHE")) (os/stat abs)) - (def cpath (string base ".c")) - (spit cpath src) - (cc-compile cpath sopath)) + (def sopath (build-so src (get opts :dir (cache-dir)))) (def env @{}) - (native abs env) + (native sopath env) ((get env (symbol c-name)) :value))) + +# --- AOT build/deploy (jolt-a7ds) --------------------------------------------- +# Build phase (needs cc): collect an app's numeric-leaf fns (the backend's +# :cgen-collect? mode fills (ctx :env) :cgen-collected with [{:ns :name :ir}]), +# compile them all into ONE native module, and write a manifest. Deploy phase +# (NO cc, target needs no toolchain): load the prebuilt module + manifest into a +# qname->cfunction map that the backend installs as var roots (:cgen-prebuilt). + +(defn aot-build + "collected: [{:ns :name :ir} ...] (numeric-leaf fns). Compile them into one + native module and return {:sopath :entries [{:ns :name :sym}]}, or + nil (toolchain absent / a non-leaf / empty). opts: :dir (cache dir). + Syms are positional (f0, f1, ...) so they're collision-free." + [collected &opt opts] + (default opts {}) + (when (and (header-dir) (not (empty? collected))) + (def entries @[]) + (def manifest @[]) + (eachp [i e] collected + (def sym (string "f" i)) + (array/push entries {:sym sym :ir (e :ir)}) + (array/push manifest {:ns (e :ns) :name (e :name) :sym sym})) + (def m (compile-module entries opts)) + (and m {:sopath (m :sopath) :entries manifest}))) + +(defn write-manifest + "Persist an aot-build result to path as jdn (the deploy side reads it)." + [path build] + (spit path (string/format "%j" build))) + +(defn load-aot + "Deploy side (NO cc): read a manifest written by write-manifest, load its + prebuilt .so, and return a qname (\"ns/name\") -> cfunction map suitable for + (ctx :env) :cgen-prebuilt. nil if the manifest/.so is missing." + [manifest-path] + (when (os/stat manifest-path) + (def build (parse (slurp manifest-path))) + (def fns (load-module (build :sopath))) + (def out @{}) + (each e (build :entries) + (when-let [f (get fns (e :sym))] + (put out (string (e :ns) "/" (e :name)) f))) + out)) diff --git a/test/integration/cgen-aot-test.janet b/test/integration/cgen-aot-test.janet new file mode 100644 index 0000000..8037450 --- /dev/null +++ b/test/integration/cgen-aot-test.janet @@ -0,0 +1,59 @@ +# Native codegen AOT build/deploy (jolt-a7ds): compile an app's numeric-leaf fns +# into ONE native module at build time, then deploy with NO cc — load the +# prebuilt module and install the cfunctions as var roots. Proves the build-time +# path that removes the runtime-toolchain dependency. Skips where cc/janet.h are +# absent. +(import ../../src/jolt/api :as api) +(import ../../src/jolt/cgen :as cgen) + +(print "Native codegen AOT build/deploy (jolt-a7ds)...") + +(var failures 0) +(defn check [label ok] (unless ok (++ failures) (eprintf " FAIL: %s" label))) + +(def cp-src "(defn count-point [cr ci cap] (loop [i 0 zr 0.0 zi 0.0] (if (or (>= i cap) (> (+ (* zr zr) (* zi zi)) 4.0)) i (recur (inc i) (+ (- (* zr zr) (* zi zi)) cr) (+ (* 2.0 (* zr zi)) ci)))))") +(def run-src "(defn run [n] (let [cap 200 nd (* 1.0 n)] (loop [y 0 acc 0] (if (< y n) (let [ci (- (/ (* 2.0 y) nd) 1.0) row (loop [x 0 a 0] (if (< x n) (let [cr (- (/ (* 2.0 x) nd) 1.5)] (recur (inc x) (+ a (count-point cr ci cap)))) a))] (recur (inc y) (+ acc row))) acc))))") + +(if (cgen/toolchain-available?) + (do + # --- build phase: collect numeric-leaf fns, compile one module, write manifest + (def bctx (api/init-cached {:compile? true})) + (put (bctx :env) :direct-linking? true) + (put (bctx :env) :cgen-collect? true) + (api/eval-string bctx "(ns aot)") + (api/eval-string bctx cp-src) + (api/eval-string bctx run-src) + (def collected (get (bctx :env) :cgen-collected)) + (check "collected exactly the numeric-leaf fn (count-point, not run)" + (and collected (= 1 (length collected)) + (= "count-point" ((first collected) :name)))) + (def manifest-path (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-aot-test.jdn")) + (def build (cgen/aot-build collected {:dir "build/cgen-aot-test"})) + (check "aot-build produced a module" (and build (build :sopath))) + (cgen/write-manifest manifest-path build) + + # --- deploy phase: fresh ctx, load prebuilt (NO cc), install roots, run + (def dctx (api/init-cached {:compile? true})) + (put (dctx :env) :direct-linking? true) + (def prebuilt (cgen/load-aot manifest-path)) + (check "load-aot maps the qname to a cfunction" + (cfunction? (get prebuilt "aot/count-point"))) + (put (dctx :env) :cgen-prebuilt prebuilt) + (api/eval-string dctx "(ns aot)") + (api/eval-string dctx cp-src) + (api/eval-string dctx run-src) + (check "deployed count-point root is the prebuilt cfunction" + (cfunction? (api/eval-string dctx "count-point"))) + (check "deployed run stays bytecode" (not (cfunction? (api/eval-string dctx "run")))) + (check "AOT-deployed mandelbrot computes the right total" + (= 3288753 (api/eval-string dctx "(run 200)"))) + + # a fn NOT in the manifest must stay bytecode in deploy + (api/eval-string dctx "(defn sq [x] (* x x))") + (check "unlisted numeric-leaf stays bytecode in deploy (no cc)" + (not (cfunction? (api/eval-string dctx "sq"))))) + (print " (toolchain absent — skipping AOT legs)")) + +(if (= 0 failures) + (print "All tests passed.") + (do (eprintf "%d cgen-aot check(s) failed" failures) (os/exit 1)))