Skip to content

Commit a306a00

Browse files
committed
Default to the JS DSL backend under WebAssembly (prefer-js with fallback)
Under WASM, a float/transpilable/non-reduction DSL kernel now auto-routes to jit_backend="js" unless the user opts out; anything JS can't do (non-float dtypes, reductions, unsupported constructs) silently falls back to miniexpr, so there's no regression. Since JS is itself a JIT, jit=True prefers it too — only jit=False, strict_miniexpr=True, or an explicit jit_backend opts out (force miniexpr with jit_backend="tcc"/"cc"). - lazyexpr.py: _maybe_js_backend prefer-js logic + _js_dtypes_ok gating; "js" documented and listed for the jit_backend param. - test_dsl_kernels.py: autouse fixture keeps this miniexpr-semantics module on miniexpr (the prefer-js default would bypass its _set_pref_expr assertions). - test_wasm_dsl_jit.py: native-CI coverage for explicit js, the prefer-js default, and the int fallback (counterpart of the node overlay harness). - bench/js-transpiler: kernel sweep (newton/poly/trans/deep/deepar) showing the js-vs-tcc win is kernel-dependent (~2x arithmetic, ~1x transcendental/light).
1 parent 964de90 commit a306a00

7 files changed

Lines changed: 273 additions & 49 deletions

File tree

bench/js-transpiler/README.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,28 @@ 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. 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):
25+
mismatch *or* a broken default fallback, so it works as a smoke test. It benches five kernel
26+
shapes so the js-vs-tcc ratio can be read against the kernel, not generalized from one. The
27+
`default` column (no `jit`/`jit_backend` set) shows the prefer-js-with-fallback default, and
28+
it also checks that an int kernel and an index-symbol kernel fall back cleanly to miniexpr.
29+
Representative (Apple M2, 4.6.0):
2730

2831
```
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
32+
default fallback (no jit_backend): int=ok index-symbol=ok -> falls back cleanly
33+
kernel default js tcc nojit js/tcc
34+
newton 12.0 11.4 23.9 104.5 2.10x
35+
poly 3.0 2.9 2.7 3.2 0.91x
36+
trans 4.3 4.3 4.4 5.7 1.01x
37+
deep 116.0 116.8 143.4 103.3 1.23x
38+
deepar 22.3 22.0 50.4 52.7 2.29x
3539
```
3640

41+
Columns: `default` = prefer-js-with-fallback, `js` = forced `jit_backend="js"`, `tcc` =
42+
miniexpr JIT (`jit_backend="tcc"`), `nojit` = miniexpr interpreter (`jit=False`). `default ≈
43+
js` here (all float + transpilable) → prefer-js engaged. Note `jit=True` *also* prefers js
44+
(it's a JIT); to force miniexpr use `jit_backend="tcc"`/`"cc"`, and `jit=False` selects the
45+
interpreter — that's what the `tcc`/`nojit` columns pin.
46+
3747
**The takeaway: there is no single "js is N× the JIT" number — it depends on what the kernel
3848
is bottlenecked on.**
3949

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

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ def deepar_dsl(a, b):
9393
acc = acc * 0.5 + t * 0.25 + 0.1
9494
return acc + t
9595
96+
# Fallback-path kernels (used only by fallback_check, not the float sweep):
97+
@blosc2.dsl_kernel # int output/operands -> default must fall back to miniexpr (float64 bridge unsafe)
98+
def int_dsl(a, b):
99+
return a * 2 + b * 3
100+
101+
@blosc2.dsl_kernel # index symbol -> transpiler rejects -> default must fall back to miniexpr
102+
def idx_dsl(a):
103+
return a + float(_i0) # noqa: F821
104+
96105
_x = np.linspace(-SPANX / 2, SPANX / 2, WIDTH, dtype=DTYPE)
97106
_y = np.linspace(-SPANX * ASPECT / 2, SPANX * ASPECT / 2, HEIGHT, dtype=DTYPE)
98107
A_NP, B_NP = np.meshgrid(_x, _y)
@@ -111,14 +120,16 @@ KERNELS = [
111120
("deepar", deepar_dsl, (A_B2, B_B2)),
112121
]
113122
114-
def run(func, ops, backend):
115-
kw = {"dtype": DTYPE, "cparams": _cp}
123+
def run(func, ops, backend, dtype=DTYPE):
124+
kw = {"dtype": dtype, "cparams": _cp}
116125
if backend == "js":
117126
kw["jit_backend"] = "js"
118-
elif backend == "jit":
127+
elif backend == "tcc":
119128
kw["jit"] = True
120-
else:
129+
kw["jit_backend"] = "tcc" # miniexpr JIT, TinyCC backend (explicit)
130+
elif backend == "nojit":
121131
kw["jit"] = False
132+
# "default": pass nothing -> under WASM this prefers js, falling back to miniexpr.
122133
return blosc2.lazyudf(func, ops, **kw)[:]
123134
124135
def bench(func, ops, backend, reps):
@@ -142,17 +153,33 @@ def debug_bridge():
142153
t = time.perf_counter(); bridge(inp, out, 0); best = min(best, (time.perf_counter() - t) * 1000)
143154
return {"first_ms": first, "warm_ms": best}
144155
156+
def fallback_check():
157+
# Default backend must transparently fall back to miniexpr where js can't go, with no
158+
# error and the same result. int dtype -> dtype-gated; index symbol -> transpiler rejects.
159+
Ai = blosc2.asarray((A_NP * 10).astype(np.int64), chunks=_chunks, blocks=_blocks, cparams=_cp)
160+
Bi = blosc2.asarray((B_NP * 10).astype(np.int64), chunks=_chunks, blocks=_blocks, cparams=_cp)
161+
int_def = run(int_dsl, (Ai, Bi), "default", dtype=np.int64)
162+
int_tcc = run(int_dsl, (Ai, Bi), "tcc", dtype=np.int64)
163+
idx_def = run(idx_dsl, (A_B2,), "default")
164+
idx_tcc = run(idx_dsl, (A_B2,), "tcc")
165+
return {
166+
"int_ok": bool(np.array_equal(int_def, int_tcc)),
167+
"idx_ok": bool(np.allclose(idx_def, idx_tcc)),
168+
}
169+
145170
def result(reps):
146171
import math
147172
kernels = []
148173
for name, func, ops in KERNELS:
149174
rj = run(func, ops, "js")
150-
rjit = run(func, ops, "jit")
151-
diff = float(np.max(np.abs(rj - rjit)))
175+
rtcc = run(func, ops, "tcc")
176+
diff = float(np.max(np.abs(rj - rtcc)))
152177
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")}
178+
ms = {b: bench(func, ops, b, reps) for b in ("default", "js", "tcc", "nojit")}
154179
kernels.append({"name": name, "ms": ms, "diff": diff})
155-
return json.dumps({"kernels": kernels, "bridge": debug_bridge(), "reps": reps})
180+
return json.dumps({
181+
"kernels": kernels, "bridge": debug_bridge(), "fallback": fallback_check(), "reps": reps,
182+
})
156183
`;
157184

158185
const py = await loadPyodide();
@@ -186,15 +213,19 @@ kernel_bench.result(${NFRAMES})
186213
const r = JSON.parse(out);
187214
const fmt = (x, w) => String(x).padStart(w);
188215
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");
216+
console.log(`\ncorrectness (js vs tcc maxdiff): ${bad.length ? "MISMATCH " + bad.map((k) => k.name) : "OK"}`);
217+
const fb = r.fallback;
218+
const fbOk = fb.int_ok && fb.idx_ok;
219+
console.log(`default fallback (no jit_backend): int=${fb.int_ok ? "ok" : "FAIL"} ` +
220+
`index-symbol=${fb.idx_ok ? "ok" : "FAIL"} -> ${fbOk ? "falls back cleanly" : "BROKEN"}`);
221+
console.log("\nper-kernel bench (ms/frame, lower is better; 'default' = prefer-js w/ fallback,");
222+
console.log("'tcc' = miniexpr JIT, 'nojit' = miniexpr interpreter):");
223+
const cols = ["default", "js", "tcc", "nojit", "js/tcc"];
224+
console.log(" " + "kernel".padEnd(8) + cols.map((c) => fmt(c, 8)).join(""));
192225
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-
);
226+
const { default: def, js, tcc, nojit } = k.ms;
227+
const cells = [def, js, tcc, nojit].map((v) => v.toFixed(1)).concat((tcc / js).toFixed(2) + "x");
228+
console.log(" " + k.name.padEnd(8) + cells.map((c) => fmt(c, 8)).join(""));
198229
}
199230
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);
231+
if (bad.length || !fbOk) process.exit(1);

plans/dsl-js.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,17 @@ the user rewriting the kernel. This is browser/Pyodide-only by nature.
2727
(tiling driver, COOP/COEP headers, Atomics join) mostly *outside* blosc2. Single-threaded
2828
JS already beats the JIT 3.3×; parallelism is deferred until measured need. Generalizing
2929
the per-block bridge to **per-chunk** is the natural next rung.
30-
- **Shipped as `src/blosc2/dsl_js.py`** behind `jit_backend="js"` (a new backend alongside
31-
no-JIT and miniexpr-JIT). Wiring is one swap in `chunked_eval`; no compiled-code changes.
32-
Started as a repo-root prototype, graduated once benches confirmed the ~2× win.
30+
- **Shipped as `src/blosc2/dsl_js.py`**, a new `jit_backend="js"` alongside no-JIT and
31+
miniexpr-JIT. Wiring is one swap in `chunked_eval`; no compiled-code changes. Started as a
32+
repo-root prototype, graduated once benches confirmed the ~2× win.
33+
- **Default under WebAssembly is prefer-js-with-fallback.** Unless `jit=False`, a float,
34+
transpilable, non-reduction DSL kernel auto-routes to js; anything js can't do
35+
(integer/complex dtypes, reductions, unsupported DSL constructs) *silently falls back to
36+
miniexpr*, so there is no regression. Because js is itself a JIT (the JS engine compiles
37+
it), `jit=True` prefers it too — only `jit=False` (interpreter) or an explicit
38+
`jit_backend` opts out; force miniexpr with `jit_backend="tcc"`/`"cc"` (see
39+
`_maybe_js_backend`, `_js_dtypes_ok`). Off-WASM, `jit_backend="js"` raises; the default is
40+
unchanged (miniexpr).
3341

3442
## Feasibility summary
3543

@@ -143,7 +151,7 @@ Measured on Apple M-series, blosc2 4.6.0 under Pyodide 314, Newton 320×213 / ma
143151
Correctness exact: `js` and JIT both `maxdiff=0.00` vs numpy.
144152

145153
**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:
154+
`bench/js-transpiler/README.md`). The js-vs-tcc (miniexpr JIT) win tracks what the kernel is bottlenecked on:
147155
**arithmetic / control-flow** bound → ~2× (newton 2.15×, a deep pure-arithmetic loop 2.23×);
148156
**transcendental** bound (`sin`/`exp`/`log`) → ~1× (libm cost is engine-independent; `nojit`
149157
can even edge `js`); **light / trivial** → <1× (blosc2 pipeline + marshaling dominate, `js`

src/blosc2/lazyexpr.py

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,8 @@ def compute(
479479
480480
- ``strict_miniexpr`` (bool): controls whether miniexpr compilation/execution
481481
failures are raised instead of silently falling back to regular chunked eval
482-
for non-DSL expressions.
482+
for non-DSL expressions. Setting it ``True`` also opts a DSL kernel out of the
483+
WebAssembly prefer-js default, keeping it on miniexpr.
483484
484485
- ``jit`` (bool | None): enable (``True``) or disable (``False``) JIT compilation
485486
of the expression via miniexpr. When ``None`` (default), JIT is only used
@@ -488,9 +489,21 @@ def compute(
488489
kernels.
489490
490491
- ``jit_backend`` (str | None): select the JIT compiler backend. Valid
491-
values are ``"tcc"`` (bundled Tiny C Compiler) and ``"cc"`` (system C
492-
compiler, e.g. gcc or clang). ``None`` (default) defers to the miniexpr
493-
default (``"tcc"``).
492+
values are ``"tcc"`` (bundled Tiny C Compiler), ``"cc"`` (system C
493+
compiler, e.g. gcc or clang), and ``"js"`` (transpile the DSL kernel to
494+
JavaScript; browser/Pyodide only — see below). ``None`` (default) defers
495+
to the miniexpr default (``"tcc"``), except under WebAssembly where — unless
496+
``jit=False`` — it *prefers* ``"js"`` for transpilable float DSL kernels and
497+
falls back to miniexpr otherwise. Since ``"js"`` is itself JIT-compiled by
498+
the JS engine, ``jit=True`` prefers it too; force miniexpr with
499+
``jit_backend="tcc"``/``"cc"``.
500+
501+
- ``"js"`` backend (WebAssembly/Pyodide only): transpiles a
502+
:func:`blosc2.dsl_kernel` to JavaScript so it runs at the browser engine's
503+
optimized native speed. It tends to beat the WASM miniexpr JIT (~2x) for
504+
float kernels dominated by arithmetic and control flow, and is roughly a
505+
wash for transcendental-heavy or trivial kernels. Outside WebAssembly,
506+
``jit_backend="js"`` raises. Forcing ``"tcc"``/``"cc"`` always uses miniexpr.
494507
495508
- ``BLOSC_ME_JIT`` environment variable: when set to ``"1"``, ``"true"``,
496509
``"on"``, ``"tcc"``, or ``"cc"``, it forces ``jit=True`` and overrides
@@ -1407,8 +1420,11 @@ def fill_chunk_operands(
14071420
def _apply_jit_backend_pragma(expression: str, inputs: dict, jit_backend: str | None) -> str:
14081421
if jit_backend is None:
14091422
return expression
1423+
if jit_backend == "js":
1424+
# "js" is handled earlier (DSL kernels -> JS bridge); it never carries a C pragma.
1425+
return expression
14101426
if jit_backend not in ("tcc", "cc"):
1411-
raise ValueError("jit_backend must be one of: None, 'tcc', 'cc'")
1427+
raise ValueError("jit_backend must be one of: None, 'tcc', 'cc', 'js'")
14121428

14131429
pragma = f"# me:compiler={jit_backend}\n"
14141430
stripped = expression.lstrip()
@@ -1444,14 +1460,52 @@ def _as_js_udf(expression):
14441460
return js_kernel(expression)
14451461

14461462

1447-
def _maybe_js_backend(expression, jit, jit_backend, reduce_args):
1448-
"""For jit_backend="js", swap the DSL kernel for its JS bridge (a plain per-block
1449-
callable) and disable miniexpr/backend-pragma. Otherwise a no-op passthrough."""
1450-
if jit_backend != "js":
1463+
def _js_dtypes_ok(operands, kwargs) -> bool:
1464+
"""True only if the JS bridge (which computes in float64) is safe for these operands:
1465+
floating-point NDArray inputs and a floating/inferred output dtype. Integer/complex go
1466+
to miniexpr instead (float64 can't represent int64 exactly)."""
1467+
dt = kwargs.get("dtype")
1468+
if dt is not None and not np.issubdtype(np.dtype(dt), np.floating):
1469+
return False
1470+
return all(
1471+
np.issubdtype(op.dtype, np.floating) for op in operands.values() if isinstance(op, blosc2.NDArray)
1472+
)
1473+
1474+
1475+
def _maybe_js_backend(expression, jit, jit_backend, reduce_args, operands, kwargs):
1476+
"""Resolve the JS backend for a DSL kernel.
1477+
1478+
- ``jit_backend="js"`` (explicit): transpile to the JS bridge, or raise if it can't.
1479+
- ``jit_backend=None`` under WebAssembly, unless ``jit=False``: *prefer* JS (it is a
1480+
JIT too, and the fastest one here) for transpilable float DSL kernels, silently
1481+
falling back to miniexpr for anything it can't do (non-float dtypes, reductions, or
1482+
unsupported DSL constructs). ``jit=True`` and ``jit=None`` both prefer JS; only
1483+
``jit=False`` (interpreter), ``strict_miniexpr=True``, or an explicit ``jit_backend``
1484+
opts out.
1485+
1486+
Returns ``(expression, jit, jit_backend)`` — expression becomes a plain per-block
1487+
callable when JS is chosen, else everything passes through unchanged.
1488+
"""
1489+
if jit_backend == "js":
1490+
if reduce_args:
1491+
raise ValueError('jit_backend="js" does not support reductions')
1492+
return _as_js_udf(expression), None, None
1493+
prefer_js = (
1494+
jit is not False # jit=True/None prefer the best JIT (js); only jit=False forces interpreter
1495+
and jit_backend is None
1496+
and not kwargs.get("strict_miniexpr") # explicit strict_miniexpr=True keeps miniexpr
1497+
and blosc2.IS_WASM
1498+
and _is_dsl_kernel_expression(expression)
1499+
and not reduce_args
1500+
and _js_dtypes_ok(operands, kwargs)
1501+
)
1502+
if not prefer_js:
14511503
return expression, jit, jit_backend
1452-
if reduce_args:
1453-
raise ValueError('jit_backend="js" does not support reductions')
1454-
return _as_js_udf(expression), None, None
1504+
try:
1505+
bridge = _as_js_udf(expression) # transpiles; raises on any unsupported construct
1506+
except Exception:
1507+
return expression, jit, jit_backend # fall back to miniexpr, no regression
1508+
return bridge, None, None
14551509

14561510

14571511
def _format_dsl_parse_error_hint(expr_text: str, backend_msg: str):
@@ -2983,8 +3037,11 @@ def chunked_eval(
29833037
operands = {**operands, **where}
29843038

29853039
reduce_args = kwargs.pop("_reduce_args", {})
2986-
# jit_backend="js": swap the DSL kernel for its JS bridge (a plain per-block callable).
2987-
expression, jit, jit_backend = _maybe_js_backend(expression, jit, jit_backend, reduce_args)
3040+
# Resolve the JS backend: explicit jit_backend="js", or prefer-js-with-fallback under
3041+
# WebAssembly when the user left jit_backend unset (see _maybe_js_backend).
3042+
expression, jit, jit_backend = _maybe_js_backend(
3043+
expression, jit, jit_backend, reduce_args, operands, kwargs
3044+
)
29883045

29893046
fast_path = _validate_chunked_eval_inputs(operands, out, shape, reduce_args)
29903047

@@ -4839,9 +4896,13 @@ def myudf(inputs_tuple, output, offset):
48394896
jit: bool or None, optional
48404897
JIT policy for miniexpr-backed execution:
48414898
``None`` uses default behavior (currently, JIT is tried out), ``True`` prefers JIT, ``False`` disables JIT.
4842-
jit_backend: {"tcc", "cc"} or None, optional
4843-
JIT backend selection for miniexpr-backed execution:
4844-
``None`` uses backend defaults (currently "tcc"), ``"tcc"`` forces libtcc, ``"cc"`` forces C compiler backend.
4899+
jit_backend: {"tcc", "cc", "js"} or None, optional
4900+
JIT backend selection. ``None`` uses backend defaults (miniexpr "tcc"), except under
4901+
WebAssembly where — unless ``jit=False`` — it *prefers* ``"js"`` for transpilable
4902+
float DSL kernels and falls back to miniexpr otherwise (``jit=True`` prefers ``"js"``
4903+
too, since it is JIT-compiled by the JS engine). ``"tcc"`` forces libtcc, ``"cc"``
4904+
forces the C compiler backend, and ``"js"`` transpiles a :func:`blosc2.dsl_kernel`
4905+
to JavaScript (browser/Pyodide only; raises elsewhere).
48454906
kwargs: Any, optional
48464907
Keyword arguments that are supported by the :func:`empty` constructor.
48474908
These arguments will be used by the :meth:`LazyArray.__getitem__` and

0 commit comments

Comments
 (0)