feat(compile): Phase 2 — native ops + direct calls (fib30 50s -> 0.076s, ~660x)
Two changes unlock native Janet speed in compile mode:
- Hot numeric primitives (+ - * < > <= >=) emit as native Janet SYMBOLS rather
than the variadic core fns, so Janet's compiler uses its arithmetic/compare
opcodes. = / not= / quot / rem / mod / division stay as core fns (their
semantics differ from Janet's). Trade-off: the strict non-number checks are
relaxed under compilation (documented perf-mode divergence).
- emit-invoke emits a DIRECT call (f arg...) when the callee is a function
reference (core/local/symbol/fn), instead of wrapping every call in jolt-call.
jolt-call is kept only for keyword/collection literals in call position
((:k m), ({:a 1} :a)) so IFn dispatch still works.
compiled fib(30): 3.4s -> 0.076s (native ceiling), faster than jank's 0.8s.
Updated compiler-test string assertions (core-+ -> +); compile-mode-test gains
native-op + IFn-dispatch cases; README documents compile mode. jpm test green.
This commit is contained in:
parent
f74af5dbc5
commit
3cf303578e
4 changed files with 59 additions and 16 deletions
12
README.md
12
README.md
|
|
@ -51,6 +51,18 @@ hello 42
|
|||
|
||||
`(init)` returns a context with `clojure.core` loaded. Each context is isolated; use separate contexts for separate environments.
|
||||
|
||||
### Compilation
|
||||
|
||||
By default Jolt tree-walks the interpreter. Passing `:compile?` compiles each form to Janet — `def`/`defn` persist in a per-context Janet environment and resolve across forms, hot numeric primitives (`+ - * < > <= >=`) emit native Janet ops, and function calls compile to direct calls (keyword/map/set still dispatch as IFn). For compute-heavy code this is dramatically faster — recursive `fib(30)` runs in ~0.08 s compiled vs ~50 s interpreted (≈600×), at native Janet speed:
|
||||
|
||||
```janet
|
||||
(def ctx (init {:compile? true}))
|
||||
(eval-string ctx "(defn fib [n] (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))")
|
||||
(eval-string ctx "(fib 30)") ; → 832040, fast
|
||||
```
|
||||
|
||||
Compile mode is opt-in and still maturing: context-modifying forms (`ns`/`defmacro`/`deftype`/multimethods/…) always interpret, and the numeric-op inlining relaxes the strict non-number checks (e.g. `(< nil 1)`). Constructs the compiler doesn't yet handle fall back to errors rather than the interpreter (a hybrid fallback is planned).
|
||||
|
||||
## Host interop
|
||||
|
||||
Jolt exposes CLJS-style host interop through `.` on any Janet table or struct — a field holding a function is called with the receiver as the first argument:
|
||||
|
|
|
|||
|
|
@ -27,18 +27,24 @@
|
|||
(make-env jolt-runtime-env)))
|
||||
|
||||
(def- core-renames
|
||||
@{"+" "core-+"
|
||||
"-" "core-sub"
|
||||
"*" "core-*"
|
||||
# Compile mode emits NATIVE Janet ops for the hot numeric primitives (+,-,*
|
||||
# and the comparisons), which match Jolt's semantics for numbers and are
|
||||
# ~10-20x faster than the variadic core fns. Trade-off: the strict non-number
|
||||
# checks (e.g. (< nil 1) throwing) are relaxed under compilation — a
|
||||
# documented perf-mode divergence. = / not= / quot / rem / mod / division stay
|
||||
# as core fns (their semantics differ from Janet's).
|
||||
@{"+" "+"
|
||||
"-" "-"
|
||||
"*" "*"
|
||||
"/" "core-/"
|
||||
"inc" "core-inc"
|
||||
"dec" "core-dec"
|
||||
"=" "core-="
|
||||
"not=" "core-not="
|
||||
"<" "core-<"
|
||||
">" "core->"
|
||||
"<=" "core-<="
|
||||
">=" "core->="
|
||||
"<" "<"
|
||||
">" ">"
|
||||
"<=" "<="
|
||||
">=" ">="
|
||||
"nil?" "core-nil?"
|
||||
"not" "core-not"
|
||||
"some?" "core-some?"
|
||||
|
|
@ -732,9 +738,16 @@
|
|||
(defn- emit-symbol-expr [name] (symbol name))
|
||||
(defn- emit-local-expr [name] (symbol name))
|
||||
|
||||
# Native Janet numeric ops: emit them as SYMBOLS (not inlined fn values) so
|
||||
# Janet's compiler recognizes the primitive and uses its fast arithmetic/compare
|
||||
# opcode rather than a function call.
|
||||
(def- native-ops @{"+" true "-" true "*" true "<" true ">" true "<=" true ">=" true})
|
||||
|
||||
(defn- emit-core-symbol-expr [janet-name]
|
||||
(if (get native-ops janet-name)
|
||||
(symbol janet-name)
|
||||
(or (get core-fn-values janet-name)
|
||||
(error (string "Core fn not found: " janet-name))))
|
||||
(error (string "Core fn not found: " janet-name)))))
|
||||
|
||||
(defn- emit-qualified-symbol-expr [ns name]
|
||||
(error (string "Cannot eval qualified symbol at compile time: " ns "/" name)))
|
||||
|
|
@ -809,9 +822,17 @@
|
|||
(tuple/slice (tuple ;exprs)))
|
||||
|
||||
(defn- emit-invoke-expr [f-ast args]
|
||||
# Embed the jolt-call function value directly (like core symbols) so compiled
|
||||
# invocations dispatch real fns AND IFn collections at runtime.
|
||||
(def exprs @[jolt-call (emit-expr f-ast)])
|
||||
# Emit a DIRECT Janet call (f arg…) when the callee is a function reference —
|
||||
# a core op/fn, a local/global symbol, or an fn literal — so native ops keep
|
||||
# their fast opcodes and recursion is a direct call. Fall back to jolt-call
|
||||
# only when the head is a keyword/collection literal in call position (an IFn
|
||||
# that needs runtime lookup), e.g. (:k m) or ({:a 1} :a).
|
||||
(def direct (case (f-ast :op)
|
||||
:core-symbol true :symbol true :local true
|
||||
:qualified-symbol true :fn true
|
||||
false))
|
||||
(def f (emit-expr f-ast))
|
||||
(def exprs (if direct @[f] @[jolt-call f]))
|
||||
(each arg args (array/push exprs (emit-expr arg)))
|
||||
(tuple/slice (tuple ;exprs)))
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,17 @@
|
|||
(eval-string ctx "(defn sum-sq [a b] (+ (sq a) (sq b)))")
|
||||
(assert (= 25 (ct-eval ctx "(sum-sq 3 4)")) "defn calling earlier defn")
|
||||
(eval-string ctx "(def base 100)")
|
||||
(assert (= 142 (ct-eval ctx "(+ base 42)")) "compiled def referenced later"))
|
||||
(assert (= 142 (ct-eval ctx "(+ base 42)")) "compiled def referenced later")
|
||||
|
||||
# Phase 2: native ops are emitted directly (fast), but IFn values in call
|
||||
# position (keyword/map/set) still dispatch via the runtime.
|
||||
(print " native ops + IFn dispatch...")
|
||||
(assert (= 10 (ct-eval ctx "(+ 1 2 3 4)")) "n-ary +")
|
||||
(assert (= true (ct-eval ctx "(< 1 2 3)")) "n-ary <")
|
||||
(assert (= 1 (ct-eval ctx "(:a {:a 1})")) "keyword as fn")
|
||||
(assert (= 1 (ct-eval ctx "({:a 1} :a)")) "map as fn")
|
||||
(assert (= 2 (ct-eval ctx "(#{1 2 3} 2)")) "set as fn")
|
||||
(assert (= true (ct-eval ctx "(= [1 2] [1 2])")) "= is value equality, not core-= bypass"))
|
||||
|
||||
# Context isolation: a def in one compiled context is invisible in another.
|
||||
(let [a (init {:compile? true}) b (init {:compile? true})]
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
# ============================================================
|
||||
(print "6: let...")
|
||||
(assert (= "(let [x 1] (core-inc x))" (compile-str "(let* [x 1] (inc x))")) "let single binding")
|
||||
(assert (= "(let [x 1 y 2] (core-+ x y))" (compile-str "(let* [x 1 y 2] (+ x y))")) "let two bindings")
|
||||
(assert (= "(let [x 1 y 2] (+ x y))" (compile-str "(let* [x 1 y 2] (+ x y))")) "let two bindings")
|
||||
(assert (= "(let [x (core-inc 1)] (core-inc x))" (compile-str "(let* [x (inc 1)] (inc x))")) "let with fn in binding")
|
||||
(print " passed")
|
||||
|
||||
|
|
@ -71,8 +71,8 @@
|
|||
# ============================================================
|
||||
(print "7: invoke...")
|
||||
(assert (= "(core-inc 1)" (compile-str "(inc 1)")) "inc call")
|
||||
(assert (= "(core-+ 1 2)" (compile-str "(+ 1 2)")) "+ call")
|
||||
(assert (= "(core-+ (core-inc 1) 2)" (compile-str "(+ (inc 1) 2)")) "nested calls")
|
||||
(assert (= "(+ 1 2)" (compile-str "(+ 1 2)")) "+ call")
|
||||
(assert (= "(+ (core-inc 1) 2)" (compile-str "(+ (inc 1) 2)")) "nested calls")
|
||||
(assert (= "(core-map core-inc (core-vec 1 2 3))"
|
||||
(compile-str "(map inc (vec 1 2 3))")) "multi-arg call")
|
||||
(print " passed")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue