cgen: build-time AOT — native fns without a toolchain on the target (jolt-a7ds) (#148)
Splits native codegen into a build phase (needs cc) and a deploy phase (none):
- gen-c-module/compile-module compile MANY numeric-leaf fns into ONE native
module (the AOT shape), generalizing the one-fn-per-.so JIT path.
- Backend :cgen-collect? records each numeric-leaf defn's IR while the app loads
as bytecode; cgen/aot-build compiles them into one module and write-manifest
persists {sopath, [{ns name sym}]}.
- Backend :cgen-prebuilt + cgen/load-aot: the deploy run loads the prebuilt .so
(via the native builtin, no cc) and installs each cfunction as the var root
with the same timing as the JIT path, so callers direct-link to native code.
- toolchain-available? no longer crashes when cc is off PATH (os/execute raises
on a missing exe) — a toolchain-less target now gets false.
Proven end-to-end in two processes (spike/native/aot-demo.janet): build with cc,
then deploy with cc removed from PATH -> count-point still native, mandelbrot
3288753 at 12.4ms (full 18x). Test: test/integration/cgen-aot-test.janet. Default
path unchanged; the modes are opt-in. Gate green (118 files).
Remaining for a literal single binary: fuse the .so + manifest into the jpm exe.
Co-authored-by: Yogthos <yogthos@gmail.com>
This commit is contained in:
parent
ce3c7df24b
commit
bffb492c1c
5 changed files with 282 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_<c-name>(...) { ... }` 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 <janet.h>\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 <C identifier> :ir <numeric-leaf fn 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 <janet.h>\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 <C id> :ir <numeric-leaf IR>} ...]. Returns
|
||||
{:sopath <abs .so> :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 <abs .so> :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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue