Skip to content

Commit 964de90

Browse files
committed
More experiments for different numerical kernels
1 parent b7084ca commit 964de90

3 files changed

Lines changed: 119 additions & 81 deletions

File tree

bench/js-transpiler/README.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,37 @@ then benches a 24-frame `relax` sweep.
1717

1818
```sh
1919
npm i # pulls pyodide@314 (see package.json)
20-
node bench/js-transpiler/dsl-js-node.mjs # correctness + 24-frame bench
21-
node bench/js-transpiler/dsl-js-node.mjs 48 # N frames
20+
node bench/js-transpiler/dsl-js-node.mjs # correctness + kernel sweep, 12 reps
21+
node bench/js-transpiler/dsl-js-node.mjs 24 # N reps
2222
```
2323

2424
Needs network on first run (PyPI wheel via micropip). Exits non-zero on a correctness
25-
mismatch, so it works as a smoke test. Typical output (Apple M-series, blosc2 4.6.0):
25+
mismatch, so it works as a smoke test. It benches four kernel shapes so the js-vs-JIT ratio
26+
can be read against the kernel, not generalized from one. Representative (Apple M2, 4.6.0):
2627

2728
```
28-
correctness vs numpy: js maxdiff=0.00e+0 jit maxdiff=0.00e+0 OK
29-
jit_backend="js" : ~16 ms/frame
30-
jit (miniexpr) : ~31 ms/frame -> js ~2x faster
31-
no-JIT : ~130 ms/frame -> js ~8x faster
29+
kernel js jit nojit js/jit js/nojit
30+
newton 12.1 26.1 114.6 2.15x 9.44x arithmetic + branches + early-exit
31+
deepar 22.5 50.2 53.1 2.23x 2.36x deep pure-arithmetic loop
32+
deep 120.1 143.1 104.4 1.19x 0.87x deep loop, libm sin every iter
33+
trans 5.0 5.0 6.8 1.00x 1.34x transcendental-heavy
34+
poly 3.4 3.0 3.6 0.87x 1.05x light, branch-free
3235
```
3336

37+
**The takeaway: there is no single "js is N× the JIT" number — it depends on what the kernel
38+
is bottlenecked on.**
39+
40+
- **Arithmetic / control-flow bound** (newton, deepar) → V8's optimizing JIT beats blosc2's
41+
miniexpr WASM codegen by **~**. This is the sweet spot.
42+
- **Transcendental bound** (trans, deep) → **~**: time is spent in `sin`/`exp`/`log` (libm),
43+
which costs about the same whoever runs the loop — `nojit` even edges `js` on `deep`.
44+
- **Light / trivial** (poly) → **<1×**: the kernel does almost no compute, so the blosc2
45+
pipeline + per-call JS marshaling dominate, and `js` can be *slightly slower* than the JIT.
46+
47+
So the honest generalization is qualitative: transpiling to JS wins (~2×, single-threaded)
48+
for **compute-bound float kernels dominated by arithmetic and control flow**, and is roughly
49+
a wash for transcendental-bound or trivial kernels.
50+
3451
> The overlay pins `blosc2==4.6.0` to keep the compiled `blosc2_ext` ABI in step with the
3552
> pure-Python we drop on top. Once these changes ship in a Pyodide-installable wheel, the
3653
> overlay can go away. If the overlay import ever breaks on version skew, overlay all of

bench/js-transpiler/dsl-js-node.mjs

Lines changed: 88 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,22 @@
33
// working tree's pure-Python (src/blosc2/dsl_js.py + lazyexpr.py) on top of it before
44
// importing blosc2 -- so the wired path runs without waiting for a new wheel.
55
//
6+
// Benches a spread of kernel shapes to show how the js-vs-JIT ratio depends on the kernel:
7+
// branchy + early-exit (newton), branch-free light (poly), transcendental-heavy (trans),
8+
// deep no-exit loop (deep). Reports js / jit / no-jit per kernel.
9+
//
610
// npm i # pulls pyodide@314 (see package.json)
7-
// node bench/js-transpiler/dsl-js-node.mjs # correctness + 24-frame bench
8-
// node bench/js-transpiler/dsl-js-node.mjs 48 # bench with N frames
11+
// node bench/js-transpiler/dsl-js-node.mjs # correctness + bench, 12 reps
12+
// node bench/js-transpiler/dsl-js-node.mjs 24 # N reps
913
import { loadPyodide } from "pyodide";
1014
import { readFileSync } from "node:fs";
1115
import { fileURLToPath } from "node:url";
1216

1317
// Resolve paths from this file, so the harness runs from any CWD (repo root is ../../).
1418
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
15-
const NFRAMES = Number(process.argv[2]) || 24;
19+
const NFRAMES = Number(process.argv[2]) || 12;
1620

17-
// Kernel + bench live in a real module file: @blosc2.dsl_kernel runs inspect.getsource(),
21+
// Kernels + bench live in a real module file: @blosc2.dsl_kernel runs inspect.getsource(),
1822
// which needs the function to be file-backed (not exec'd from a string).
1923
const PYSRC = String.raw`
2024
import json, time
@@ -26,7 +30,8 @@ SPANX = 3.4
2630
ASPECT = HEIGHT / WIDTH
2731
DTYPE = np.float64
2832
29-
@blosc2.dsl_kernel
33+
# --- kernels spanning the cost/control-flow spectrum -------------------------------------
34+
@blosc2.dsl_kernel # branchy, deep, per-pixel early exit
3035
def newton_dsl(a, b, max_iter, relax):
3136
za = a
3237
zb = b
@@ -59,27 +64,34 @@ def newton_dsl(a, b, max_iter, relax):
5964
root = 2.0
6065
return root + 0.9 * (it / mif)
6166
62-
def newton_numpy(a, b, max_iter, relax):
63-
za = a.copy(); zb = b.copy()
64-
mif = float(max_iter)
65-
it = np.full(a.shape, mif)
66-
alive = np.ones(a.shape, dtype=bool)
67-
for k in range(max_iter):
68-
a2 = za * za; b2 = zb * zb
69-
fr = za * a2 - 3.0 * za * b2 - 1.0; fi = 3.0 * a2 * zb - zb * b2
70-
dr = 3.0 * (a2 - b2); di = 6.0 * za * zb; den = dr * dr + di * di + 1e-12
71-
qr = relax * (fr * dr + fi * di) / den; qi = relax * (fi * dr - fr * di) / den
72-
za = za - qr; zb = zb - qi
73-
c = alive & ((qr * qr + qi * qi) < 1e-6); it[c] = k; alive &= ~c
74-
if not alive.any():
75-
break
76-
d0 = (za - 1.0) ** 2 + zb ** 2
77-
d1 = (za + 0.5) ** 2 + (zb - 0.8660254) ** 2
78-
d2 = (za + 0.5) ** 2 + (zb + 0.8660254) ** 2
79-
root = np.zeros(a.shape); md = d0.copy()
80-
m = d1 < md; md = np.where(m, d1, md); root = np.where(m, 1.0, root)
81-
m = d2 < md; root = np.where(m, 2.0, root)
82-
return root + 0.9 * (it / mif)
67+
@blosc2.dsl_kernel # light, branch-free, vectorizable arithmetic
68+
def poly_dsl(a, b):
69+
a2 = a * a
70+
b2 = b * b
71+
return a2 * a - 3.0 * a * b2 + 2.0 * b2 * b - a + 0.5 * b
72+
73+
@blosc2.dsl_kernel # transcendental-heavy (exercises each engine's libm). miniexpr wants
74+
def trans_dsl(a, b): # bare sin/cos/... (not np.sin); the transpiler maps both to Math.*
75+
msq = a * a + b * b
76+
sc = sin(a * 3.0) * cos(b * 2.0)
77+
ex = exp(msq * -0.5)
78+
return sc + ex + sqrt(msq + 1.0) + log(msq + 2.0)
79+
80+
@blosc2.dsl_kernel # deep fixed loop, transcendental-bound (libm sin every iter)
81+
def deep_dsl(a, b):
82+
acc = a
83+
for k in range(64):
84+
acc = acc * 0.99 + sin(acc + b)
85+
return acc
86+
87+
@blosc2.dsl_kernel # deep fixed loop, pure arithmetic (no libm, no branches); contractive
88+
def deepar_dsl(a, b):
89+
acc = a * 0.1
90+
t = b * 0.1
91+
for k in range(64):
92+
t = t * 0.5 - acc * 0.25 + 0.1
93+
acc = acc * 0.5 + t * 0.25 + 0.1
94+
return acc + t
8395
8496
_x = np.linspace(-SPANX / 2, SPANX / 2, WIDTH, dtype=DTYPE)
8597
_y = np.linspace(-SPANX * ASPECT / 2, SPANX * ASPECT / 2, HEIGHT, dtype=DTYPE)
@@ -90,40 +102,36 @@ _cp = blosc2.CParams(codec=blosc2.Codec.LZ4, clevel=1)
90102
A_B2 = blosc2.asarray(A_NP, chunks=_chunks, blocks=_blocks, cparams=_cp)
91103
B_B2 = blosc2.asarray(B_NP, chunks=_chunks, blocks=_blocks, cparams=_cp)
92104
93-
def run(relax, backend):
105+
# (name, kernel, operand tuple). Fixed inputs -> each rep does identical work.
106+
KERNELS = [
107+
("newton", newton_dsl, (A_B2, B_B2, MAXITER, 1.37)),
108+
("poly", poly_dsl, (A_B2, B_B2)),
109+
("trans", trans_dsl, (A_B2, B_B2)),
110+
("deep", deep_dsl, (A_B2, B_B2)),
111+
("deepar", deepar_dsl, (A_B2, B_B2)),
112+
]
113+
114+
def run(func, ops, backend):
94115
kw = {"dtype": DTYPE, "cparams": _cp}
95116
if backend == "js":
96117
kw["jit_backend"] = "js"
97118
elif backend == "jit":
98119
kw["jit"] = True
99120
else:
100121
kw["jit"] = False
101-
return blosc2.lazyudf(newton_dsl, (A_B2, B_B2, MAXITER, relax), **kw)[:]
102-
103-
def correctness():
104-
rx = 1.37
105-
ref = newton_numpy(A_NP, B_NP, MAXITER, rx)
106-
return {
107-
"diff_js": float(np.max(np.abs(run(rx, "js") - ref))),
108-
"diff_jit": float(np.max(np.abs(run(rx, "jit") - ref))),
109-
}
110-
111-
def bench(nframes):
112-
relaxes = [float(v) for v in np.linspace(1.0, 1.85, nframes)]
113-
def sweep_ms(backend):
114-
for r in relaxes[:2]:
115-
run(r, backend) # warm
116-
best = float("inf")
117-
for _ in range(3):
118-
t = time.perf_counter()
119-
for r in relaxes:
120-
run(r, backend)
121-
best = min(best, (time.perf_counter() - t) * 1000)
122-
return best
123-
return {b: sweep_ms(b) for b in ("js", "jit", "nojit")}
122+
return blosc2.lazyudf(func, ops, **kw)[:]
123+
124+
def bench(func, ops, backend, reps):
125+
run(func, ops, backend) # warm
126+
best = float("inf")
127+
for _ in range(3):
128+
t = time.perf_counter()
129+
for _ in range(reps):
130+
run(func, ops, backend)
131+
best = min(best, (time.perf_counter() - t) * 1000 / reps)
132+
return best
124133
125134
def debug_bridge():
126-
# Call the JS bridge directly (no lazyudf/blosc2 machinery) to isolate marshal+compile+compute.
127135
import blosc2.dsl_js as dj
128136
bridge = dj.js_kernel(newton_dsl)
129137
out = np.empty((HEIGHT, WIDTH), dtype=DTYPE)
@@ -134,12 +142,17 @@ def debug_bridge():
134142
t = time.perf_counter(); bridge(inp, out, 0); best = min(best, (time.perf_counter() - t) * 1000)
135143
return {"first_ms": first, "warm_ms": best}
136144
137-
def result(nframes):
138-
out = correctness()
139-
out["ms"] = bench(nframes)
140-
out["bridge"] = debug_bridge()
141-
out["nframes"] = nframes
142-
return json.dumps(out)
145+
def result(reps):
146+
import math
147+
kernels = []
148+
for name, func, ops in KERNELS:
149+
rj = run(func, ops, "js")
150+
rjit = run(func, ops, "jit")
151+
diff = float(np.max(np.abs(rj - rjit)))
152+
diff = diff if math.isfinite(diff) else 1e30 # keep JSON valid; flags as mismatch
153+
ms = {b: bench(func, ops, b, reps) for b in ("js", "jit", "nojit")}
154+
kernels.append({"name": name, "ms": ms, "diff": diff})
155+
return json.dumps({"kernels": kernels, "bridge": debug_bridge(), "reps": reps})
143156
`;
144157

145158
const py = await loadPyodide();
@@ -160,27 +173,28 @@ import sys, blosc2
160173
assert hasattr(sys.modules["blosc2.lazyexpr"], "_as_js_udf"), "overlay did not take"
161174
`);
162175
console.log("blosc2", await py.runPythonAsync("blosc2.__version__"),
163-
"| Pyodide", py.version, "| frames", NFRAMES);
176+
"| Pyodide", py.version, "| reps", NFRAMES);
164177

165-
py.FS.writeFile("/newton_bench.py", new TextEncoder().encode(PYSRC));
178+
py.FS.writeFile("/kernel_bench.py", new TextEncoder().encode(PYSRC));
166179
const out = await py.runPythonAsync(`
167180
import sys
168181
if "/" not in sys.path: sys.path.insert(0, "/")
169-
import newton_bench
170-
newton_bench.result(${NFRAMES})
182+
import kernel_bench
183+
kernel_bench.result(${NFRAMES})
171184
`);
172185

173186
const r = JSON.parse(out);
174-
const ok = r.diff_js < 1e-9 && r.diff_jit < 1e-9;
175-
console.log(`\ncorrectness vs numpy: js maxdiff=${r.diff_js.toExponential(2)} ` +
176-
`jit maxdiff=${r.diff_jit.toExponential(2)} ${ok ? "OK" : "MISMATCH"}`);
177-
const per = ms => `${ms.toFixed(0)} ms total (${(ms / r.nframes).toFixed(2)} ms/frame)`;
178-
console.log(`\nperf (${r.nframes}-frame relax sweep, best of 3):`);
179-
console.log(` jit_backend="js" : ${per(r.ms.js)}`);
180-
console.log(` jit (miniexpr) : ${per(r.ms.jit)}`);
181-
console.log(` no-JIT : ${per(r.ms.nojit)}`);
182-
console.log(` js vs jit : ${(r.ms.jit / r.ms.js).toFixed(2)}x ` +
183-
`js vs no-jit : ${(r.ms.nojit / r.ms.js).toFixed(2)}x`);
184-
console.log(`\nbridge probe (direct call, no blosc2 machinery): ` +
185-
`first=${r.bridge.first_ms.toFixed(1)} ms warm=${r.bridge.warm_ms.toFixed(1)} ms`);
186-
if (!ok) process.exit(1);
187+
const fmt = (x, w) => String(x).padStart(w);
188+
const bad = r.kernels.filter((k) => k.diff > 1e-5);
189+
console.log(`\ncorrectness (js vs JIT maxdiff): ${bad.length ? "MISMATCH " + bad.map((k) => k.name) : "OK"}`);
190+
console.log("\nper-kernel bench (ms/frame, lower is better):");
191+
console.log(" kernel js jit nojit js/jit js/nojit diff");
192+
for (const k of r.kernels) {
193+
const { js, jit, nojit } = k.ms;
194+
console.log(
195+
` ${k.name.padEnd(7)} ${fmt(js.toFixed(1), 6)} ${fmt(jit.toFixed(1), 6)} ${fmt(nojit.toFixed(1), 7)}` +
196+
` ${fmt((jit / js).toFixed(2) + "x", 6)} ${fmt((nojit / js).toFixed(2) + "x", 8)} ${k.diff.toExponential(1)}`,
197+
);
198+
}
199+
console.log(`\nnewton bridge probe (no blosc2 machinery): first=${r.bridge.first_ms.toFixed(1)} ms warm=${r.bridge.warm_ms.toFixed(1)} ms`);
200+
if (bad.length) process.exit(1);

plans/dsl-js.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ Measured on Apple M-series, blosc2 4.6.0 under Pyodide 314, Newton 320×213 / ma
142142

143143
Correctness exact: `js` and JIT both `maxdiff=0.00` vs numpy.
144144

145+
**The ~2× is kernel-dependent, not a flat rule** (kernel sweep in `dsl-js-node.mjs`, see
146+
`bench/js-transpiler/README.md`). The js-vs-JIT win tracks what the kernel is bottlenecked on:
147+
**arithmetic / control-flow** bound → ~2× (newton 2.15×, a deep pure-arithmetic loop 2.23×);
148+
**transcendental** bound (`sin`/`exp`/`log`) → ~1× (libm cost is engine-independent; `nojit`
149+
can even edge `js`); **light / trivial** → <1× (blosc2 pipeline + marshaling dominate, `js`
150+
slightly loses). So JS helps for compute-bound float kernels heavy on arithmetic and branches.
151+
145152
**The PyProxy gotcha (the one real bug the headless harness caught).** The bridge must pass
146153
the per-call operands to the JS driver as real **JS `Array`s**, not Python lists. A Python
147154
list arrives in JS as a `PyProxy`, so every `ops[k][i]` in the hot inner loop crosses the

0 commit comments

Comments
 (0)