diff --git a/bench/README.md b/bench/README.md index 6ecfba0..01ab8bc 100644 --- a/bench/README.md +++ b/bench/README.md @@ -18,17 +18,37 @@ absolute reference. | Benchmark | Axis | Pass it exercises | Source | |---|---|---|---| | `binary-trees` | allocation / GC pressure (escaping short-lived records) | jolt-15jq scalar-replace, jolt-8flj escape analysis | CLBG | -| `dispatch` | polymorphic (megamorphic) protocol dispatch | jolt-41m devirt, inline-cache | AWFY-style | -| `collections` | persistent map/vector churn (32-way tries) | persistent structures, transients | CLBG k-nucleotide-style | +| `dispatch` | polymorphic (**megamorphic**) protocol dispatch | jolt-41m devirt, inline-cache | AWFY-style | +| `mono-dispatch` | **monomorphic** protocol dispatch (devirt/inline-cache *can* fire) | jolt-41m devirt, jolt-ez5h inline-cache | AWFY-style | +| `collections` | persistent map/vector churn (HAMT / 32-way tries) | persistent structures (jolt-684u/0hbr), transients | CLBG k-nucleotide-style | +| `mandelbrot` | pure float compute (tight arith loops, no alloc/dispatch) | jolt-3pl native arith, loop codegen | CLBG | +| `fib` | recursion: function-call + integer-arith overhead | jolt-3pl native arith, jolt-826 small-fn inlining | CLBG | What the ray tracer does **not** capture and these do: allocation as the -bottleneck (~7% there), megamorphic dispatch (its dispatch is monomorphic and -cheap), and persistent-collection throughput (it uses fixed records, no -collections in the hot loop). +bottleneck (~7% there), megamorphic *and* monomorphic dispatch (its dispatch is +monomorphic and cheap), persistent-collection throughput (it uses fixed records, +no collections in the hot loop), and isolated compute/call overhead. -Planned additions: Richards / DeltaBlue (heavier OO dispatch), a **monomorphic** -dispatch variant (where devirt *can* fire — the megamorphic `dispatch` can't), -NBody (float control, parallels the ray tracer), k-nucleotide proper. +Planned additions: Richards / DeltaBlue (heavier OO dispatch), NBody (float +control with record state), k-nucleotide proper. + +## Holistic scorecard + +`JVM=1 bench/run.sh` runs each benchmark on jolt **and** JVM Clojure and prints +the jolt/JVM ratio — the epic's (jolt-ffn) absolute-reference scorecard. As of +the broadening (2026-06-16), ratios cluster by axis: + +- **pure compute** (`mandelbrot`) is the floor, ~15× — native arith (jolt-3pl) + already gets jolt closest to the JVM. +- **collections** ~28×, **fib** ~37×. +- **dispatch** ~75× (megamorphic), and `mono-dispatch` is *worse* (~110×): the + JVM inline-caches a runtime-monomorphic call site to near-free, while jolt does + a full registry dispatch regardless (devirt only fires on *statically* proven + receivers, which `reduce` over a vector doesn't give). This is the signal for + the call-site inline cache (jolt-ez5h). +- **allocation** (`binary-trees`) is the widest gap — but also the most inflated + by host memory pressure, so read it as "alloc is the worst axis," not a precise + multiple. Numbers are machine-specific; regenerate with `JVM=1 bench/run.sh`. ## Running diff --git a/bench/fib.clj b/bench/fib.clj new file mode 100644 index 0000000..ca355ba --- /dev/null +++ b/bench/fib.clj @@ -0,0 +1,29 @@ +;; fib — naive recursive Fibonacci: pure function-call + integer-arithmetic +;; throughput, with no allocation, dispatch, or collections. Isolates call +;; overhead and native integer arith (jolt-3pl), and is the natural target for +;; single-call-site / small-fn inlining (jolt-826) and self-call direct-linking. +;; +;; Portable Clojure (jolt + JVM Clojure). +;; jolt -m fib 32 (JOLT_DIRECT_LINK=1 JOLT_WHOLE_PROGRAM=1) +(ns fib) + +(defn fib [n] + (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2))))) + +(defn run [n] (fib n)) + +(defn -main [& args] + (let [n (if (seq args) (Integer/parseInt (first args)) 32)] + (dotimes [_ 2] (run (- n 6))) ; warmup + (let [runs 3 + times (mapv (fn [_] + (let [t0 (System/nanoTime) + r (run n) + ms (/ (- (System/nanoTime) t0) 1000000.0)] + [ms r])) + (range runs)) + mss (mapv first times) + mean (/ (reduce + mss) runs)] + (println "fib n" n "result" (second (first times))) + (println "runs:" (mapv (fn [t] (/ (Math/round (* t 10.0)) 10.0)) mss)) + (println "mean:" (/ (Math/round (* mean 10.0)) 10.0) "ms")))) diff --git a/bench/mandelbrot.clj b/bench/mandelbrot.clj new file mode 100644 index 0000000..7b1dfb4 --- /dev/null +++ b/bench/mandelbrot.clj @@ -0,0 +1,48 @@ +;; mandelbrot — pure floating-point compute: for each point of an NxN grid, +;; iterate z = z^2 + c up to a cap and count iterations. No allocation, no +;; dispatch, no collections in the hot loop — just double arithmetic and tight +;; recur loops. This isolates the irreducible-math axis the ray tracer is bound +;; on (where devirt/alloc passes measured flat), so it tracks native-arith codegen +;; (jolt-3pl) and loop quality directly. +;; +;; Portable Clojure (jolt + JVM Clojure). +;; jolt -m mandelbrot 1000 (JOLT_DIRECT_LINK=1 JOLT_WHOLE_PROGRAM=1) +(ns mandelbrot) + +(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))))) + +(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)))) + +(defn -main [& args] + (let [n (if (seq args) (Integer/parseInt (first args)) 1000)] + (dotimes [_ 2] (run (quot n 2))) ; warmup + (let [runs 3 + times (mapv (fn [_] + (let [t0 (System/nanoTime) + r (run n) + ms (/ (- (System/nanoTime) t0) 1000000.0)] + [ms r])) + (range runs)) + mss (mapv first times) + mean (/ (reduce + mss) runs)] + (println "mandelbrot n" n "result" (second (first times))) + (println "runs:" (mapv (fn [t] (/ (Math/round (* t 10.0)) 10.0)) mss)) + (println "mean:" (/ (Math/round (* mean 10.0)) 10.0) "ms")))) diff --git a/bench/mono_dispatch.clj b/bench/mono_dispatch.clj new file mode 100644 index 0000000..363c762 --- /dev/null +++ b/bench/mono_dispatch.clj @@ -0,0 +1,45 @@ +;; mono-dispatch — protocol dispatch where every call site sees ONE record type +;; (monomorphic). This is the regime where devirtualization (jolt-41m) and a +;; call-site inline cache CAN fire — the megamorphic `dispatch` bench deliberately +;; defeats them, so this is its complement: it measures how close a proven/cached +;; monomorphic dispatch gets to a direct call. Same per-call work as `dispatch`. +;; +;; Portable Clojure (jolt + JVM Clojure). +;; jolt -m mono-dispatch 20000 (JOLT_DIRECT_LINK=1 JOLT_WHOLE_PROGRAM=1) +(ns mono-dispatch) + +(defprotocol Shape + (area [s]) + (sides [s])) + +(defrecord Circle [r] Shape (area [_] (* (* 3.14159 r) r)) (sides [_] 0)) + +;; homogeneous: every element is a Circle -> the call site is monomorphic +(defn build-shapes [n] + (mapv (fn [i] (->Circle (+ 1 (mod i 7)))) (range n))) + +(defn sum-area [shapes] + (reduce (fn [acc s] (+ (+ acc (area s)) (sides s))) 0.0 shapes)) + +(defn run [iters] + (let [shapes (build-shapes 1000)] + (loop [i 0 acc 0.0] + (if (< i iters) + (recur (inc i) (+ acc (sum-area shapes))) + acc)))) + +(defn -main [& args] + (let [iters (if (seq args) (Integer/parseInt (first args)) 20000)] + (dotimes [_ 2] (run (quot iters 4))) ; warmup + (let [runs 3 + times (mapv (fn [_] + (let [t0 (System/nanoTime) + r (run iters) + ms (/ (- (System/nanoTime) t0) 1000000.0)] + [ms r])) + (range runs)) + mss (mapv first times) + mean (/ (reduce + mss) runs)] + (println "mono-dispatch iters" iters "result" (second (first times))) + (println "runs:" (mapv (fn [t] (/ (Math/round (* t 10.0)) 10.0)) mss)) + (println "mean:" (/ (Math/round (* mean 10.0)) 10.0) "ms")))) diff --git a/bench/run.sh b/bench/run.sh index ade7a09..8e0cac7 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -18,15 +18,24 @@ export JOLT_WHOLE_PROGRAM="${JOLT_WHOLE_PROGRAM:-1}" export JOLT_APP_PATHS="$PWD" export JOLT_PATH="$PWD" -# name:default-arg (arg sized to run in a few seconds each) -# NOTE collections is small because the persistent map is O(n)/assoc (jolt-684u); -# raise it once that's fixed to a HAMT. -BENCHES="binary-trees:14 dispatch:2000 collections:1500" +# name:default-arg (arg sized to run in a few seconds each). Axes: allocation +# (binary-trees), megamorphic vs monomorphic dispatch, persistent-collection +# churn (collections — now O(log n) via the HAMT, jolt-684u, so sized up), pure +# float compute (mandelbrot), call+arith recursion (fib). +BENCHES="binary-trees:14 dispatch:2000 mono-dispatch:2000 collections:30000 mandelbrot:200 fib:30" +# JVM=1 also runs each bench on JVM Clojure and prints a jolt/JVM ratio — the +# holistic absolute-reference scorecard for the optimization epic (jolt-ffn). run_one() { ns="${1%%:*}"; arg="${2:-${1##*:}}" - printf '%-16s ' "$ns" - jolt -m "$ns" "$arg" 2>&1 | awk '/^mean:/{print}' + jmean=$(jolt -m "$ns" "$arg" 2>&1 | awk '/^mean:/{print $2}') + if [ -n "$JVM" ]; then + vmean=$(clojure -Sdeps '{:paths ["."]}' -M -m "$ns" "$arg" 2>&1 | awk '/^mean:/{print $2}') + ratio=$(awk "BEGIN{ if ($vmean+0>0) printf \"%.1f\", ($jmean+0)/($vmean+0); else printf \"-\" }") + printf '%-16s jolt %9s ms jvm %8s ms %sx\n' "$ns" "${jmean:--}" "${vmean:--}" "$ratio" + else + printf '%-16s %9s ms\n' "$ns" "${jmean:--}" + fi } if [ -n "$1" ]; then @@ -34,6 +43,6 @@ if [ -n "$1" ]; then [ "${spec%%:*}" = "$1" ] && run_one "$spec" "$2" done else - echo "jolt benchmark suite (WP=$JOLT_WHOLE_PROGRAM)" + echo "jolt benchmark suite (WP=$JOLT_WHOLE_PROGRAM${JVM:+, vs JVM Clojure})" for spec in $BENCHES; do run_one "$spec"; done fi