|
| 1 | +# DSL → JS transpiler: coverage gaps & future work |
| 2 | + |
| 3 | +Status of the `blosc2.dsl_js` transpiler (the `jit_backend="js"` path) versus the |
| 4 | +miniexpr + WASM-JIT backend. Everything listed below as *unsupported* currently rides on |
| 5 | +**miniexpr + jit-wasm** instead of the JS bridge. |
| 6 | + |
| 7 | +## Implemented |
| 8 | + |
| 9 | +- **P1 — Index / shape symbols** (`_i0`/`_n0`/`_flat_idx`, ...). The transpiler emits them |
| 10 | + as trailing kernel params and the runtime driver reconstructs per-block global coordinates |
| 11 | + from `(off, gshape, cshape)`; see `_module_with_index` in `src/blosc2/dsl_js.py`. The |
| 12 | + whole-array shape is threaded `chunked_eval → _maybe_js_backend → _as_js_udf → js_kernel`. |
| 13 | + Requires ≥1 array operand (zero-input DSL kernels stay on miniexpr) and a known output |
| 14 | + shape; without a shape such kernels fall back. Covered by `tests/ndarray/test_dsl_js.py` |
| 15 | + (`test_index_*`) and `tests/ndarray/test_wasm_dsl_jit.py::test_wasm_dsl_index_symbols_via_js`. |
| 16 | +- **P2 (input side) — Integer inputs with a floating output.** The JS bridge already |
| 17 | + float64-converts every operand, which is exactly miniexpr's promotion of integer inputs for |
| 18 | + a float result (so values above 2**53 lose precision identically). `_js_dtypes_ok` |
| 19 | + (`src/blosc2/lazyexpr.py`) now admits integer inputs when the output dtype is floating. |
| 20 | + Integer/complex *output* still goes to miniexpr — see the remaining P2 work below. |
| 21 | + |
| 22 | +## Performance characteristics (and where the residual cost is) |
| 23 | + |
| 24 | +Measured with `bench/js-transpiler/dsl-js-node.mjs` (Pyodide, ms/frame). JS beats miniexpr's |
| 25 | +TinyCC JIT (`tcc`) on **compute-heavy** kernels and lands at parity / slightly behind on |
| 26 | +**compute-light, vectorizable** ones: |
| 27 | + |
| 28 | +``` |
| 29 | +kernel js/tcc |
| 30 | +newton 2.80x (heavy: loop + complex arithmetic) |
| 31 | +deepar 2.78x |
| 32 | +idxgrad 2.00x (P1 index symbols) |
| 33 | +deep 1.30x |
| 34 | +trans 0.99x |
| 35 | +intmix 0.87x (P2 int inputs; light, vectorizable) |
| 36 | +poly 0.86x (light, vectorizable) |
| 37 | +``` |
| 38 | + |
| 39 | +Two cost components matter, and only the second remains: |
| 40 | + |
| 41 | +- **Per-evaluation transpile + `js.eval` (amortized away).** Each `lazyudf` evaluation used to |
| 42 | + re-parse the kernel AST and re-`eval` the JS module, while miniexpr caches its compiled |
| 43 | + program by source. Now memoized: `_TRANSPILE_CACHE` (by kernel source) and `_RUN_CACHE` (the |
| 44 | + V8-compiled `__run`, by module string) in `src/blosc2/dsl_js.py`. This lifted every ratio |
| 45 | + (e.g. newton 2.20→2.80x, poly 0.77→0.86x) and is a real win for repeated / animation-loop use. |
| 46 | + |
| 47 | +- **Per-block marshaling (the residual).** The bridge copies each block across the Python↔JS |
| 48 | + boundary: in via `ascontiguousarray(float64) → tobytes → Float64Array`, out via |
| 49 | + `to_bytes → np.frombuffer`. miniexpr's prefilter computes **in place** with zero copies. For |
| 50 | + light kernels (~2 ms compute) these two copies are a meaningful fraction with no compute to |
| 51 | + hide them behind, so JS sits at parity or just behind `tcc` there. For compute-bound kernels |
| 52 | + (the reason the JS backend exists) it is negligible. |
| 53 | + |
| 54 | +**Future lever — zero-copy block I/O.** Replace the `tobytes`/`frombuffer` copies with a |
| 55 | +`HEAPF64` view onto WASM linear memory so operands/output alias the block buffers (the |
| 56 | +"ponytail" note in `js_kernel`). This would mostly close the gap on marshaling-bound (light) |
| 57 | +kernels but needs care around WASM-heap lifetime/alignment, and does nothing for compute-bound |
| 58 | +kernels — so build it only if a real marshaling-bound workload appears. |
| 59 | + |
| 60 | +## How routing works today |
| 61 | + |
| 62 | +Under WebAssembly with `jit_backend` unset (and `jit != False`, no `strict_miniexpr`), |
| 63 | +blosc2 *prefers* JS for float DSL kernels and **silently falls back to miniexpr+jit-wasm** |
| 64 | +for anything it can't transpile — see `_maybe_js_backend` (`src/blosc2/lazyexpr.py:1475`): |
| 65 | + |
| 66 | +```python |
| 67 | +try: |
| 68 | + bridge = _as_js_udf(expression) # transpiles; raises on any unsupported construct |
| 69 | +except Exception: |
| 70 | + return expression, jit, jit_backend # fall back to miniexpr, no regression |
| 71 | +``` |
| 72 | + |
| 73 | +With an **explicit** `jit_backend="js"`, the same gaps instead **raise** rather than fall |
| 74 | +back (the user asked for JS specifically, so we don't second-guess them). |
| 75 | + |
| 76 | +The JS backend today covers *float64/float32 element-wise scalar kernels* using arithmetic, |
| 77 | +`where`, comparisons, `if/elif/else`, `range` loops, and whitelisted math functions. |
| 78 | + |
| 79 | +## Remaining P2 — Integer *output* |
| 80 | + |
| 81 | +`_js_dtypes_ok` still sends any non-floating *output* dtype to miniexpr, because the JS |
| 82 | +bridge computes in **float64** and can't reproduce integer semantics for the result: |
| 83 | + |
| 84 | +- **Integer division / modulo / truncation**: `//`, `%`, `int(...)` must match C/miniexpr |
| 85 | + integer rules, not float `Math.floor`/`pymod`. |
| 86 | +- **Overflow / wraparound**: miniexpr wraps at the integer width; float64 doesn't. |
| 87 | +- **int64 range**: float64 can't represent int64 above 2**53 exactly. |
| 88 | + |
| 89 | +Options, in rough order of effort: |
| 90 | +- **int32 and smaller output**: representable exactly in float64; could be allowed for kernels |
| 91 | + that provably stay within ±2^53 with integer-valued ops and an explicit safe-range / no- |
| 92 | + overflow contract. Still needs integer-correct `//`/`%`/`int()` codegen. |
| 93 | +- **int64 output**: requires BigInt or a typed-array split-word scheme — significantly more |
| 94 | + work and likely slower; probably not worth it until a real workload needs it. |
| 95 | + |
| 96 | +## Other unsupported constructs (lower priority) |
| 97 | + |
| 98 | +All of these raise `_DSLToJSError` in the transpiler → fall back (or raise under explicit |
| 99 | +`jit_backend="js"`). |
| 100 | + |
| 101 | +**Reductions** — any `reduce_args` (`sum`, `prod`, …) → miniexpr. Explicit |
| 102 | +`jit_backend="js"` raises `'jit_backend="js" does not support reductions'`. A JS reduction |
| 103 | +path would need a fundamentally different driver (accumulate, not map). |
| 104 | + |
| 105 | +**Statements** — only `Assign, AugAssign, Return, Expr, If, For(range), While, Break, |
| 106 | +Continue` are emitted (`_stmt`, `src/blosc2/dsl_js.py:151`). Not supported: |
| 107 | +- Tuple / multiple / subscript assignment targets — only a single `Name` target is handled |
| 108 | + (`node.targets[0].id`). `a, b = ...`, `a = b = ...`, `arr[i] = ...` all fail. |
| 109 | +- `with`, nested `def`, `try`, etc. |
| 110 | + |
| 111 | +**Expressions** — only `Name, Constant, UnaryOp, BinOp, BoolOp, Compare, Call` (`_expr`). |
| 112 | +Not supported: |
| 113 | +- Python ternary `a if cond else b` (`ast.IfExp`) — must be written as `where(cond, a, b)`. |
| 114 | +- Chained comparisons `a < b < c` — only `ops[0]`/`comparators[0]` are read. |
| 115 | +- Subscript / indexing, attribute access (except `np.`/`numpy.`/`math.` call targets), |
| 116 | + tuples, lists, dicts, comprehensions, slices. |
| 117 | + |
| 118 | +**Calls** — only `where`, `int`, `float`, `bool`, and the `_MATH` whitelist (`sin, cos, exp, |
| 119 | +log, sqrt, pow, floor, abs, min/max, …`, see `src/blosc2/dsl_js.py:27`). Any other call name, |
| 120 | +or a call through a non-`np`/`numpy`/`math` target → fall back. |
| 121 | + |
| 122 | +**For-loops** — only `for v in range(...)`. Iterating over arrays/other iterables is |
| 123 | +unsupported. |
| 124 | + |
| 125 | +## Environment gate (by design) |
| 126 | + |
| 127 | +Browser/Pyodide only. `_as_js_udf` raises `RuntimeError` off-WASM (`js_kernel` imports |
| 128 | +Pyodide's `js` at run time). On native/CI, DSL kernels always go to miniexpr+jit. |
| 129 | + |
| 130 | +## Known semantic ceilings (supported, but lossy) |
| 131 | + |
| 132 | +These transpile but with caveats worth tracking, since miniexpr may differ: |
| 133 | +- 64-bit integer bitwise ops degrade to int32 (JS number semantics). |
| 134 | +- `%` uses a Python-sign helper (`pymod`); large-magnitude float edge cases may differ. |
| 135 | +- `range()` with a non-literal step assumes a positive step (loop-direction guess). |
| 136 | +- float64/float32 are the target; exotic dtypes untested. |
| 137 | + |
| 138 | +## See also |
| 139 | + |
| 140 | +- `plans/dsl-js.md` — original design, perf numbers, and the "Deferred" / "Known ceilings" |
| 141 | + notes this document expands on. |
| 142 | +- `src/blosc2/dsl_js.py` — the transpiler. |
| 143 | +- `src/blosc2/lazyexpr.py` — `_maybe_js_backend`, `_js_dtypes_ok`, `_as_js_udf` (routing). |
0 commit comments