jz 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +66 -43
  2. package/bench/README.md +128 -78
  3. package/bench/bench.svg +32 -42
  4. package/cli.js +73 -7
  5. package/dist/interop.js +1 -0
  6. package/dist/jz.js +6024 -0
  7. package/index.d.ts +126 -0
  8. package/index.js +190 -34
  9. package/interop.js +71 -27
  10. package/layout.js +5 -0
  11. package/module/array.js +71 -39
  12. package/module/collection.js +41 -5
  13. package/module/core.js +2 -4
  14. package/module/math.js +138 -2
  15. package/module/number.js +21 -0
  16. package/module/object.js +26 -0
  17. package/module/simd.js +37 -5
  18. package/module/string.js +41 -12
  19. package/module/typedarray.js +415 -26
  20. package/package.json +21 -6
  21. package/src/autoload.js +3 -0
  22. package/src/compile/analyze-scans.js +174 -11
  23. package/src/compile/analyze.js +38 -3
  24. package/src/compile/emit-assign.js +9 -6
  25. package/src/compile/emit.js +347 -36
  26. package/src/compile/index.js +307 -29
  27. package/src/compile/infer.js +36 -1
  28. package/src/compile/loop-divmod.js +155 -0
  29. package/src/compile/narrow.js +108 -11
  30. package/src/compile/peel-stencil.js +261 -0
  31. package/src/compile/plan/advise.js +55 -1
  32. package/src/compile/plan/index.js +4 -0
  33. package/src/compile/plan/inline.js +5 -2
  34. package/src/compile/plan/literals.js +220 -5
  35. package/src/compile/plan/scope.js +115 -39
  36. package/src/compile/program-facts.js +21 -2
  37. package/src/ctx.js +45 -7
  38. package/src/ir.js +55 -7
  39. package/src/kind-traits.js +27 -0
  40. package/src/kind.js +65 -3
  41. package/src/optimize/index.js +344 -45
  42. package/src/optimize/vectorize.js +2968 -182
  43. package/src/prepare/index.js +64 -7
  44. package/src/prepare/lift-iife.js +149 -0
  45. package/src/reps.js +3 -2
  46. package/src/static.js +9 -0
  47. package/src/type.js +10 -6
  48. package/src/wat/assemble.js +195 -13
  49. package/src/wat/optimize.js +363 -185
package/index.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ // Type declarations for jz — minimal functional JS subset compiling to WASM.
2
+ // The public surface is small: `jz` (compile + instantiate), `compile` (raw bytes),
3
+ // `compileModule` (compile once), `instantiate` (run a module), and `jz.memory`.
4
+ // See README for semantics and the JS↔WASM value ABI.
5
+
6
+ /** Optimization level / preset. `2` is the default (all stable passes). */
7
+ export type OptimizeLevel = boolean | 0 | 1 | 2 | 3 | 'speed' | 'size'
8
+
9
+ /** Runtime-service lowering target. */
10
+ export type Host = 'js' | 'wasi'
11
+
12
+ /** Value injectable as a compile-time `define` constant. */
13
+ export type DefineValue = number | boolean | string | null | DefineValue[] | { [k: string]: DefineValue }
14
+
15
+ export interface CompileOptions {
16
+ /** Static ES imports to bundle: `{ './dep.js': 'export let x = 1' }`. */
17
+ modules?: Record<string, string>
18
+ /** Host imports wired at runtime: `{ math: Math, host: { log: console.log } }`. */
19
+ imports?: Record<string, unknown>
20
+ /** `N` initial pages of owned memory, or a shared `jz.memory()` / `WebAssembly.Memory`. */
21
+ memory?: number | WebAssembly.Memory | JzMemory
22
+ /** Cap memory growth at this many 64 KiB pages (default: unbounded). */
23
+ maxMemory?: number
24
+ /** Import `env.memory` instead of exporting own memory. */
25
+ importMemory?: boolean
26
+ /** Runtime-service lowering. Default `'js'`. */
27
+ host?: Host
28
+ /** Optimization level or named preset. Default `2`. */
29
+ optimize?: OptimizeLevel
30
+ /** Compile-time constants injected as top-level bindings. */
31
+ define?: Record<string, DefineValue>
32
+ /** Enforce the pure canonical subset: skip jzify lowering and reject dynamic fallbacks. */
33
+ strict?: boolean
34
+ /** Omit `_alloc`/`_clear` allocator exports for standalone scalar modules. */
35
+ alloc?: boolean
36
+ /** Disable auto-vectorization (no jz-emitted `v128`). Explicit intrinsics still compile. */
37
+ noSimd?: boolean
38
+ /** Use ordinary call frames instead of `return_call` tail calls. */
39
+ tailCall?: boolean
40
+ /** `Math.random` seeding: a number fixes the stream; `true` forces host entropy. */
41
+ randomSeed?: number | boolean
42
+ /** Emit a WASM `name` section (function symbols) for profilers/debuggers. */
43
+ names?: boolean
44
+ /** `compile()` returns WAT text instead of a WASM binary. */
45
+ wat?: boolean
46
+ /** Resolve bare specifiers via Node.js module resolution (CLI/build use). */
47
+ resolve?: boolean
48
+ /** Mutable sink that collects per-stage compile timings. */
49
+ profile?: { entries?: unknown[]; totals?: Record<string, number> } & Record<string, unknown>
50
+ }
51
+
52
+ /**
53
+ * An enhanced `WebAssembly.Memory` that marshals JS values across the WASM boundary.
54
+ * Allocators (`String`/`Array`/`Object`/typed-array ctors) return NaN-boxed `f64`
55
+ * pointers; `read` decodes one back. The heap never frees implicitly — call `reset`
56
+ * between independent batches (all previously returned pointers become invalid).
57
+ */
58
+ export interface JzMemory extends WebAssembly.Memory {
59
+ /** Allocate a UTF-8 string; returns a pointer. */
60
+ String(str: string): number
61
+ /** Allocate an array (numbers, strings, nested arrays/objects); returns a pointer. */
62
+ Array(data: ArrayLike<unknown>): number
63
+ /** Allocate a fixed-layout object (keys must match a compiled schema); returns a pointer. */
64
+ Object(obj: Record<string, unknown>): number
65
+ Float64Array(data: ArrayLike<number>): number
66
+ Float32Array(data: ArrayLike<number>): number
67
+ Int32Array(data: ArrayLike<number>): number
68
+ Uint32Array(data: ArrayLike<number>): number
69
+ Int16Array(data: ArrayLike<number>): number
70
+ Uint16Array(data: ArrayLike<number>): number
71
+ Int8Array(data: ArrayLike<number>): number
72
+ Uint8Array(data: ArrayLike<number>): number
73
+ /** Decode one pointer (or a multi-value tuple of pointers) back to a JS value. */
74
+ read(ptr: number | number[]): unknown
75
+ /** Reserve `bytes` of raw heap; returns the offset. */
76
+ alloc(bytes: number): number
77
+ /** Rewind the bump pointer — drops every allocation since the last reset. */
78
+ reset(): void
79
+ }
80
+
81
+ /** A compiled-and-instantiated jz module. */
82
+ export interface JzInstance {
83
+ /** JS-wrapped exports: marshals arguments in, decodes pointer returns, throws real `Error`s. */
84
+ exports: Record<string, (...args: any[]) => any> & Record<string, any>
85
+ /** The value codec, or `null` for a pure-scalar module with no heap. */
86
+ memory: JzMemory | null
87
+ /** The raw `WebAssembly.Instance` (numbers pass through; pointers come back NaN-boxed). */
88
+ instance: WebAssembly.Instance
89
+ /** The underlying `WebAssembly.Module`. */
90
+ module: WebAssembly.Module
91
+ }
92
+
93
+ /** Create a shared memory that modules compile into (schemas accumulate across modules). */
94
+ export interface MemoryFactory {
95
+ (src?: WebAssembly.Memory): JzMemory
96
+ }
97
+
98
+ /** The default export: compile + instantiate, as a call or a tagged template. */
99
+ export interface Jz {
100
+ /** Compile and instantiate a source string. */
101
+ (code: string, opts?: CompileOptions): JzInstance
102
+ /** Tagged-template form: interpolations are baked into the source at compile time. */
103
+ (strings: TemplateStringsArray, ...values: unknown[]): JzInstance
104
+ /** Compile only — raw WASM binary (or WAT text with `{ wat: true }`). */
105
+ compile: typeof compile
106
+ /** Shared-memory factory: `jz.memory()` or `jz.memory(existing)`. */
107
+ memory: MemoryFactory
108
+ }
109
+
110
+ declare const jz: Jz
111
+ export default jz
112
+ export { jz }
113
+
114
+ /** Compile to a raw WASM binary. */
115
+ export function compile(code: string, opts?: CompileOptions & { wat?: false }): Uint8Array
116
+ /** Compile to WAT text. */
117
+ export function compile(code: string, opts: CompileOptions & { wat: true }): string
118
+
119
+ /** Compile once to a `WebAssembly.Module` (pays AOT + validate cost once); instantiate many. */
120
+ export function compileModule(code: string, opts?: CompileOptions): WebAssembly.Module
121
+
122
+ /** Instantiate a compiled module or raw WASM bytes, wiring the allocator and value codec. */
123
+ export function instantiate(
124
+ module: WebAssembly.Module | Uint8Array | ArrayBuffer,
125
+ opts?: CompileOptions,
126
+ ): JzInstance
package/index.js CHANGED
@@ -44,17 +44,20 @@ import { parse } from './src/parse.js'
44
44
  import watrCompile from "watr/compile";
45
45
  import watrPrint from "watr/print";
46
46
  import watOptimize from "./src/wat/optimize.js";
47
+ import { appendLateStdlib } from './src/wat/assemble.js'
47
48
  import { ctx, reset, err, initWarnings, assertCtxInvariants } from './src/ctx.js'
48
49
  import prepare, { GLOBALS } from './src/prepare/index.js'
50
+ import { liftIIFEs } from './src/prepare/lift-iife.js'
49
51
  import compile from './src/compile/index.js'
50
52
  import { resetProgramFactsCache } from './src/compile/program-facts.js'
51
53
  import { emit, emitter, emitVoid as flat, emitBlockBody as body, emitBoolStr as bool, emitIndex as idx, buildArrayWithSpreads as spread } from './src/compile/emit.js'
52
- import { optimizeFunc, collectVolatileGlobals, collectReachableGlobalWrites, hoistGlobalPtrOffset, stablePtrGlobalNames, resolveOptimize } from './src/optimize/index.js'
54
+ import { optimizeFunc, foldStrDispatchF64, collectVolatileGlobals, collectReachableGlobalWrites, hoistGlobalPtrOffset, stablePtrGlobalNames, resolveOptimize } from './src/optimize/index.js'
55
+ import { findBodyStart } from './src/ir.js'
53
56
  import { VAL } from './src/reps.js'
54
57
  import jzify from './jzify/index.js'
55
58
  import { T } from './src/ast.js'
56
59
  import {
57
- memory as enhanceMemory, instantiate as instantiateRuntime,
60
+ memory as enhanceMemory, instantiate as instantiateRuntime, toModule,
58
61
  } from './interop.js'
59
62
 
60
63
  // A host import that's a JS function may hand back any value, including a host
@@ -68,11 +71,47 @@ const importsMayReturnExternal = (imports) =>
68
71
  // directly — keeps the self-host kernel free of host-only built-ins.
69
72
  const resolveUrl = (spec, base) => new URL(spec, base).href
70
73
 
74
+ // Serialize a JS value to a jz source literal (numbers/booleans/strings/null/
75
+ // undefined + literal arrays/objects). Returns null for anything not expressible
76
+ // as a compile-time literal (functions, host objects, circular). Shared by the
77
+ // `jz\`…${val}\`` template tag (hoists complex args) and opts.define.
78
+ const serialize = (v) => {
79
+ if (v === undefined) return 'undefined'
80
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
81
+ if (v === null) return 'null'
82
+ if (typeof v === 'string') return JSON.stringify(v)
83
+ if (Array.isArray(v)) {
84
+ const elems = v.map(serialize)
85
+ return elems.every(e => e !== null) ? `[${elems.join(', ')}]` : null
86
+ }
87
+ if (typeof v === 'object') {
88
+ const props = Object.keys(v).map(k => {
89
+ const s = serialize(v[k])
90
+ return s !== null ? `${k}: ${s}` : null
91
+ })
92
+ return props.every(p => p !== null) ? `{${props.join(', ')}}` : null
93
+ }
94
+ return null
95
+ }
96
+
97
+ // opts.define → a one-line `let K = V; …` prelude prepended to source. Kept on a
98
+ // single line (no trailing newline) so user line numbers past line 1 stay exact —
99
+ // same convention the template tag uses for hoisted literals.
100
+ const defineBindings = (define) => {
101
+ const parts = []
102
+ for (const [k, v] of Object.entries(define)) {
103
+ const s = serialize(v)
104
+ if (s === null) err(`opts.define['${k}'] is not a compile-time constant — use a number, boolean, string, null, or a literal array/object`)
105
+ parts.push(`let ${k} = ${s}`)
106
+ }
107
+ return parts.length ? parts.join('; ') + '; ' : ''
108
+ }
109
+
71
110
  const nowMs = () => globalThis.performance?.now ? globalThis.performance.now() : Date.now()
72
111
 
73
112
  const compileProfiler = (profile) => {
74
113
  if (profile == null) return null
75
- if (typeof profile !== 'object') throw new TypeError('opts.profile must be an object sink; use { profile: { names: true } } to emit a wasm name section')
114
+ if (typeof profile !== 'object') throw new TypeError('opts.profile must be an object sink (populated with compile-phase entries/totals); for a wasm name section use opts.names')
76
115
  profile.entries ||= []
77
116
  profile.totals ||= {}
78
117
  return {
@@ -168,12 +207,20 @@ jz.memory = enhanceMemory
168
207
  * (so full-JS syntax like var/function/class is rejected, not lowered) and reject
169
208
  * dynamic features (obj[k], for-in, unknown receiver method calls) at compile time.
170
209
  * Avoids pulling dynamic-dispatch stdlib into output; large size win for static programs.
171
- * @param {WebAssembly.Memory|number} [opts.memory] - Shared memory import, or
172
- * initial page count. Prefer `memory: N` for owned memory.
210
+ * @param {WebAssembly.Memory|number} [opts.memory] - Owned memory's initial page
211
+ * count (`memory: N`, 64 KiB/page), or a `WebAssembly.Memory` to share across modules.
212
+ * @param {number} [opts.maxMemory] - Maximum memory pages — emits a ceiling on the
213
+ * memory type so growth traps past it (sandbox cap). Must be ≥ the initial size.
214
+ * Default: unbounded.
215
+ * @param {boolean} [opts.importMemory] - Import `env.memory` instead of exporting an
216
+ * owned memory. For embedding into a host that provides the memory.
173
217
  * @param {boolean} [opts.alloc=true] - Export raw allocator helpers
174
218
  * (`_alloc`, `_clear`) for JS memory wrapping. Set false for standalone host-run
175
219
  * modules that only call exported wasm functions.
176
- * @param {boolean|number|object} [opts.optimize] - Optimization level/config.
220
+ * @param {Object<string,*>} [opts.define] - Compile-time constants injected as
221
+ * top-level bindings before parse, e.g. `{ DEBUG: false, PORT: 8080 }`. Values may
222
+ * be numbers, booleans, strings, null, or literal arrays/objects.
223
+ * @param {boolean|number|string|object} [opts.optimize] - Optimization level/config.
177
224
  * - `false` / `0`: nothing. Fastest compile, largest output (live coding).
178
225
  * - `1`: encoding-compactness only (treeshake + sortLocalsByUse + fusedRewrite-inline).
179
226
  * - `true` / `2` (default): every stable jz pass + full watr (inlineOnce +
@@ -181,16 +228,32 @@ jz.memory = enhanceMemory
181
228
  * - `3` / `'speed'`: level 2 + larger array/hash initial caps (`arrayMinCap`,
182
229
  * `hashSmallInitCap`) + `hoistConstantPool` off (inline `f64.const` over
183
230
  * mutable globals); trades size for speed.
184
- * - `{ level?: 0|1|2|3, watr?: bool, hoistAddrBase?: bool, ... }`: per-pass
185
- * overrides on top of the chosen level. See PASS_NAMES in src/optimize.js.
231
+ * - `'size'`: full passes with unrolling/SIMD off and tight scalar caps — smallest wasm.
232
+ * - `{ level?, <pass>?: bool, ... }`: per-pass overrides on top of a base level.
233
+ * INTERNAL/unstable — pass names track compiler internals (PASS_NAMES in
234
+ * src/optimize/index.js) and change between versions; prefer the level/string forms.
235
+ * @param {boolean} [opts.noSimd] - Disable auto-vectorization (no jz-emitted v128) for
236
+ * engines without the SIMD proposal. Explicit f32x4/i32x4 intrinsics still compile.
237
+ * @param {boolean} [opts.whyNotSimd] - Diagnostic: emit a `simd-why-not` warning (via
238
+ * opts.warnings) for each canonical loop the auto-vectorizer declined, naming the
239
+ * first blocking op. Finds loops one op away from SIMD. Noisy — off by default.
240
+ * @param {boolean} [opts.experimentalStencil] - Opt-in: vectorize neighbour-load
241
+ * stencils (`b[i]=f(a[i-1],a[i],a[i+1])`, 2-D 5-point) to f64x2. Bit-exact vs scalar.
242
+ * Unstable — off by default until proven across the corpus.
243
+ * @param {boolean} [opts.experimentalOuterStrip] - Opt-in: strip-mine a pixel loop whose
244
+ * per-pixel value is an inner reduction (metaballs-shape) into f64x2 lanes (2 pixels at
245
+ * once). Bit-exact vs scalar. Unstable — off by default.
246
+ * @param {boolean} [opts.experimentalToneMap] - Mixed-lane log-tonemap vectorizer: a flat
247
+ * `i32 dens[i] → f64 Math.log → i32 pack → px[i]` loop lifts to a 2-wide f64x2 island
248
+ * (fern/bifurcation/attractors). Bit-exact vs scalar; default-on at speed, pass `false` to disable.
186
249
  * @param {object} [opts.warnings] - Optional mutable warning sink populated with
187
250
  * `entries: [{ code, message, fn?, line?, column? }]`. Heap-growth advisories
188
251
  * fire when a module uses the bump allocator and an export or loop retains
189
252
  * allocations without a host-side memory.reset().
190
253
  * @param {object} [opts.profile] - Optional mutable profile sink populated with
191
254
  * `entries` and `totals` for parse / jzify / prepare / compile / plan / watr phases.
192
- * Set `profile.names = true` to also emit a standard wasm `name` custom section
193
- * for profiler/debugger symbolication.
255
+ * @param {boolean} [opts.names] - Emit a standard wasm `name` custom section (function
256
+ * symbols) for profiler/debugger symbolication. (Legacy: `profile.names = true`.)
194
257
  * @param {Object<string,string>} [opts.modules] - Map of module specifier → source
195
258
  * for compile-time `import`/`export` bundling: jz resolves the module graph
196
259
  * in-process from this map instead of reading from disk.
@@ -305,10 +368,16 @@ const detectOptimizeConfig = (ast, code) => {
305
368
 
306
369
  const cfg = {}
307
370
  // Machine-generated or large code: watr's WAT-level CSE/DCE/inline fights
308
- // jz's already-optimized IR and inflates output. Disable it automatically.
371
+ // jz's already-optimized IR and inflates output. Disable it automatically
372
+ // EXCEPT when the module uses SIMD intrinsics. A v128 helper call (e.g.
373
+ // f64x2.sin → $math.sin2) leaves v128 params/results across the call boundary;
374
+ // watr's inliner folds the helper into the loop and coalesces those vectors,
375
+ // and without it the v128 spills to memory every iteration (~2× slower on the
376
+ // trig-bound attractors kernel). Explicit SIMD is a perf opt-in — keep watr on.
377
+ const usesSimd = !!ctx.module?.modules?.simd
309
378
  const isLarge = s.sourceChars > 4000 || s.funcCount > 40 || s.maxFuncBodySize > 300
310
379
  const isMachineLike = s.callSites > 300 && s.stringLiteralCount < 10
311
- if (isLarge || isMachineLike) { cfg.watr = false; cfg.splitCharScan = false }
380
+ if ((isLarge || isMachineLike) && !usesSimd) { cfg.watr = false; cfg.splitCharScan = false }
312
381
  // Typed-array heavy: tighten scalarization thresholds when we see large
313
382
  // fixed-size arrays; keep defaults for small/dynamic ones.
314
383
  if (s.typedArrayCount > 0 && s.maxTypedArrayLen > 0) {
@@ -362,6 +431,15 @@ const setupCtx = (code, opts) => {
362
431
  initWarnings(opts.warnings)
363
432
  if (typeof opts.memory === 'number') ctx.memory.pages = opts.memory
364
433
  else if (opts.memory) ctx.memory.shared = true
434
+ if (opts.importMemory) ctx.memory.shared = true // import env.memory instead of exporting own
435
+ if (opts.maxMemory != null) {
436
+ if (!Number.isInteger(opts.maxMemory) || opts.maxMemory < 1)
437
+ err(`opts.maxMemory must be a positive integer page count (each page is 64 KiB); got ${opts.maxMemory}`)
438
+ const initialPages = ctx.memory.pages || 1
439
+ if (opts.maxMemory < initialPages)
440
+ err(`opts.maxMemory (${opts.maxMemory}) is below the initial memory size (${initialPages} pages)`)
441
+ ctx.memory.max = opts.maxMemory
442
+ }
365
443
  if (opts.modules) ctx.module.importSources = opts.modules
366
444
  if (opts.imports) {
367
445
  ctx.module.hostImports = opts.imports
@@ -421,6 +499,7 @@ const rejectReservedPrefix = (node) => {
421
499
  }
422
500
 
423
501
  const jzCompileInner = (code, opts = {}) => {
502
+ if (opts.define) code = defineBindings(opts.define) + code
424
503
  const profiler = compileProfiler(opts.profile)
425
504
  const time = (name, fn) => profiler ? profiler.time(name, fn) : fn()
426
505
 
@@ -429,6 +508,12 @@ const jzCompileInner = (code, opts = {}) => {
429
508
 
430
509
  let parsed = time('parse', () => parse(code))
431
510
  if (typeof code === 'string' && code.includes(T)) rejectReservedPrefix(parsed)
511
+ // Lambda-lift immediately-invoked arrow literals to typed direct calls — lets SIMD
512
+ // flow through the f64-only closure ABI and drops the closure for every IIFE. Runs
513
+ // BEFORE jzify so it only sees USER arrow IIFEs, not jzify's synthetic wrapper IIFEs
514
+ // (named/recursive function expressions, method shorthand), which keep the closure
515
+ // path. A no-op when there are none.
516
+ parsed = time('liftIIFE', () => liftIIFEs(parsed))
432
517
  if (!opts.strict) parsed = time('jzify', () => jzify(parsed))
433
518
  const ast = time('prepare', () => prepare(parsed))
434
519
  assertCtxInvariants('post-prepare')
@@ -449,6 +534,33 @@ const jzCompileInner = (code, opts = {}) => {
449
534
  }
450
535
  }
451
536
 
537
+ // opts.noSimd: force auto-vectorization off regardless of opt level — a
538
+ // portability escape hatch for engines without the SIMD proposal (parallels
539
+ // opts.noTailCall). Disabling the vectorizeLaneLocal pass suppresses every
540
+ // jz-emitted v128 (lane maps, reductions incl. reduceUnroll, byte scans);
541
+ // explicit f32x4/i32x4 intrinsics in source are the user's own opt-in and stay.
542
+ // Applied after auto-config so it wins over any re-resolved preset.
543
+ if (opts.noSimd) ctx.transform.optimize.vectorizeLaneLocal = false
544
+
545
+ // opts.whyNotSimd (CLI --why-not-simd): emit a `simd-why-not` warning per
546
+ // canonical loop that the auto-vectorizer declined, naming the blocking op —
547
+ // a diagnostic to find loops that are "one op away" from SIMD. Rides the
548
+ // resolved optimize cfg to the vectorizer; off by default (the report is noisy).
549
+ if (opts.whyNotSimd && ctx.transform.optimize) ctx.transform.optimize.whyNotSimd = true
550
+
551
+ // opts.experimentalStencil: the neighbour-load stencil vectorizer (a[i±1] / 2-D 5-point).
552
+ // Now default-on at optimize:'speed' (proven bit-exact corpus-wide); the opt is two-way so an
553
+ // explicit `false` can still disable it (e.g. to A/B against the scalar path).
554
+ if (opts.experimentalStencil !== undefined && ctx.transform.optimize) ctx.transform.optimize.experimentalStencil = !!opts.experimentalStencil
555
+
556
+ // opts.experimentalOuterStrip: the outer-loop strip-mine vectorizer (2 adjacent pixels in f64x2
557
+ // lanes over an inner reduction). Default-on at speed; two-way like experimentalStencil.
558
+ if (opts.experimentalOuterStrip !== undefined && ctx.transform.optimize) ctx.transform.optimize.experimentalOuterStrip = !!opts.experimentalOuterStrip
559
+
560
+ // opts.experimentalToneMap: the mixed-lane log-tonemap vectorizer (i32 dens[i] → f64 log →
561
+ // i32 pack → px[i], 2-wide f64x2 island). Default-on at speed; two-way like the others.
562
+ if (opts.experimentalToneMap !== undefined && ctx.transform.optimize) ctx.transform.optimize.experimentalToneMap = !!opts.experimentalToneMap
563
+
452
564
  const module = time('compile', () => compile(ast, profiler))
453
565
  assertCtxInvariants('post-compile')
454
566
 
@@ -477,6 +589,14 @@ const jzCompileInner = (code, opts = {}) => {
477
589
  if (watrOpts === true) watrOpts = { devirt: true }
478
590
  else if (typeof watrOpts === 'object' && watrOpts.devirt === undefined) watrOpts.devirt = true
479
591
  }
592
+ // Multi-caller small-function inlining (size-for-speed): on at the 'speed'/level-3
593
+ // tier only, like devirt above. Removes call overhead from hot inner loops at the
594
+ // cost of duplicating tiny bodies; level 2 (and the size budgets measured there)
595
+ // stay untouched.
596
+ if (cfg.inlineFns) {
597
+ if (watrOpts === true) watrOpts = { inline: true }
598
+ else if (typeof watrOpts === 'object' && watrOpts.inline === undefined) watrOpts.inline = true
599
+ }
480
600
  const optimized = cfg.watr ? time('watOptimize', () => watOptimize(module, watrOpts)) : module
481
601
  // Stable-pointee module globals: resolve the __ptr_offset once per function.
482
602
  // Never-forwarding kinds — every PTR tag outside __ptr_offset's forwarding
@@ -502,12 +622,45 @@ const jzCompileInner = (code, opts = {}) => {
502
622
  if (cfg.watr) {
503
623
  // Build global name→type map from ctx.scope.globalTypes for promoteGlobals
504
624
  const globalTypesMap = ctx.scope.globalTypes ? new Map([...ctx.scope.globalTypes].map(([k, v]) => [`$${k}`, v])) : null
625
+ // Build pure-function map for Phase 2 user-function inline in tryPerPixelColor.
626
+ // A function is "pure for SIMD inline" if its body contains no side effects:
627
+ // no global.set, no memory stores, no calls outside $math.*.
628
+ // foldStrDispatchF64 is run first (idempotent) so the purity check sees the
629
+ // folded body — dead __is_str_key dispatch would otherwise look impure.
630
+ if (cfg.vectorizeLaneLocal === true) {
631
+ const allFuncs = optimized.filter(node => Array.isArray(node) && node[0] === 'func')
632
+ const pureFuncMap = new Map()
633
+ const hasSideEffect = (node) => {
634
+ if (!Array.isArray(node)) return false
635
+ const op = node[0]
636
+ if (op === 'global.set') return true
637
+ if (typeof op === 'string' && (op.endsWith('.store') || op.startsWith('memory.'))) return true
638
+ if (op === 'call' && typeof node[1] === 'string' && !node[1].startsWith('$math.')) return true
639
+ if (op === 'call_indirect' || op === 'call_ref') return true
640
+ return node.some((c, i) => i > 0 && hasSideEffect(c))
641
+ }
642
+ for (const fn of allFuncs) {
643
+ const name = fn[1]
644
+ if (typeof name !== 'string' || name.startsWith('$math.') || name.startsWith('$__')) continue
645
+ // Fold dead str-dispatch blocks so purity check sees the clean form.
646
+ foldStrDispatchF64(fn)
647
+ const bodyStart = findBodyStart(fn)
648
+ if (bodyStart < 0) continue
649
+ let pure = true
650
+ for (let i = bodyStart; i < fn.length; i++) if (hasSideEffect(fn[i])) { pure = false; break }
651
+ if (pure) pureFuncMap.set(name, fn)
652
+ }
653
+ if (pureFuncMap.size) cfg._pureFuncMap = pureFuncMap
654
+ }
505
655
  time('watrReopt', () => {
506
656
  const funcs = optimized.filter(node => Array.isArray(node) && node[0] === 'func')
507
657
  const volatileGlobals = collectVolatileGlobals(funcs)
508
658
  const reach = collectReachableGlobalWrites(funcs)
509
659
  for (const node of funcs) optimizeFunc(node, cfg, globalTypesMap, volatileGlobals, 'post', reach)
510
660
  })
661
+ // The 'post' lane vectorizer can inject stdlib calls (e.g. the f64x2 trig mirror $math.cos2)
662
+ // absent from the already-pulled+treeshaken module — append any now-referenced helper body.
663
+ appendLateStdlib(optimized)
511
664
  }
512
665
  try {
513
666
  if (opts.wat) {
@@ -516,7 +669,9 @@ const jzCompileInner = (code, opts = {}) => {
516
669
  }
517
670
  const wasm = time('watrCompile', () => watrCompile(optimized))
518
671
  let bytes = wasm
519
- if (opts.profile?.names) bytes = appendFunctionNames(bytes, optimized)
672
+ // opts.names emits a wasm `name` custom section (symbols for profilers/
673
+ // debuggers). opts.profile.names is the older spelling — still honored.
674
+ if (opts.names || opts.profile?.names) bytes = appendFunctionNames(bytes, optimized)
520
675
  return opts.inspect ? { wasm: bytes, inspect: ctx.inspect } : bytes
521
676
  } catch (e) {
522
677
  // watr surfaces dangling identifiers as "Unknown local|func|global|table|memory $X".
@@ -546,27 +701,6 @@ export default function jz(code, ...args) {
546
701
  if (Array.isArray(code)) {
547
702
  const interp = {}, data = {}, hoisted = []
548
703
 
549
- // Serialize JS value to jz source literal. Returns null if not serializable.
550
- const serialize = (v) => {
551
- if (v === undefined) return 'undefined' // else falls through → treated as a
552
- // host object → memory.Object(undefined) → Object.keys(undefined) crash
553
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
554
- if (v === null) return 'null'
555
- if (typeof v === 'string') return JSON.stringify(v)
556
- if (Array.isArray(v)) {
557
- const elems = v.map(serialize)
558
- return elems.every(e => e !== null) ? `[${elems.join(', ')}]` : null
559
- }
560
- if (typeof v === 'object') {
561
- const props = Object.keys(v).map(k => {
562
- const s = serialize(v[k])
563
- return s !== null ? `${k}: ${s}` : null
564
- })
565
- return props.every(p => p !== null) ? `{${props.join(', ')}}` : null
566
- }
567
- return null
568
- }
569
-
570
704
  let src = code[0]
571
705
  for (let i = 0; i < args.length; i++) {
572
706
  const v = args[i]
@@ -618,3 +752,25 @@ export default function jz(code, ...args) {
618
752
  export { jz }
619
753
  const jzCompile = jz.compile
620
754
  export { jzCompile as compile }
755
+
756
+ /**
757
+ * Compile source to a cached `WebAssembly.Module` (compile + validate once).
758
+ * Pair with `instantiate(module, opts)` to spin up many instances without
759
+ * re-validating the bytes each time — the AOT compile is paid once, so repeated
760
+ * instantiation is cheap. Returns a `WebAssembly.Module`, built with the native
761
+ * `wasm:js-string` builtins fast path where the engine supports it.
762
+ *
763
+ * import { compileModule, instantiate } from 'jz'
764
+ * const mod = compileModule('export let f = x => x * 2')
765
+ * const { exports } = instantiate(mod) // repeat cheaply, no recompile
766
+ *
767
+ * @param {string} code
768
+ * @param {object} [opts] - same options as `compile()`
769
+ * @returns {WebAssembly.Module}
770
+ */
771
+ export const compileModule = (code, opts = {}) => {
772
+ const out = jzCompile(code, opts)
773
+ return toModule(out && typeof out === 'object' && 'wasm' in out ? out.wasm : out)
774
+ }
775
+
776
+ export { instantiateRuntime as instantiate }
package/interop.js CHANGED
@@ -132,10 +132,24 @@ _u32[1] = ATOM_HI[ATOM.TRUE]; _u32[0] = 0; export const TRUE_NAN = _f64[0]
132
132
  // Coerce JS null/undefined → NaN-boxed sentinels for WASM boundary
133
133
  export const coerce = v => v === null ? NULL_NAN : v === undefined ? UNDEF_NAN : v
134
134
 
135
- // Decode f64 return value: null/undefined sentinels → JS values, numbers pass through
135
+ // Memory-free decode of an f64 boundary value: numbers pass through, the reserved
136
+ // atoms become null/undefined/false/true, and an SSO string unpacks its ≤4 inline
137
+ // ASCII bytes straight from the NaN payload. These are exactly the value forms a
138
+ // *memoryless* module can carry across the boundary — a heap string/array/object
139
+ // would need linear memory to exist, so it can't reach here. Heap-carrying modules
140
+ // route through `mem.read` instead (which also handles these, via memory).
136
141
  const decode = v => {
137
- if (v === v) return v // fast path: non-NaN
142
+ if (v === v) return v // fast path: non-NaN number
138
143
  _f64[0] = v
144
+ // STRING (type 4) + SSO bit: content is the low 32 bits, length the low aux bits.
145
+ if (decodePtrType(_u32[1]) === 4) {
146
+ const a = decodePtrAux(_u32[1])
147
+ if (a & LAYOUT.SSO_BIT) {
148
+ const len = a & 0x7, off = _u32[0]; let s = ''
149
+ for (let i = 0; i < len; i++) s += String.fromCharCode((off >>> (i * 8)) & 0xFF)
150
+ return s
151
+ }
152
+ }
139
153
  if (_u32[0] !== 0) return v
140
154
  if (_u32[1] === ATOM_HI[ATOM.NULL]) return null
141
155
  if (_u32[1] === ATOM_HI[ATOM.UNDEF]) return undefined
@@ -144,6 +158,14 @@ const decode = v => {
144
158
  return v
145
159
  }
146
160
 
161
+ // Decode a boundary value that arrives as i64 bits (BigInt carrier) into a JS
162
+ // value. Heap-carrying modules go through `mem.read`; a memoryless module's value
163
+ // is a number/atom/SSO string, decodable from the bits alone.
164
+ const readArgBits = (state, big) => {
165
+ const f = i64ToF64(big)
166
+ return state.mem ? state.mem.read(f) : decode(f)
167
+ }
168
+
147
169
  export const ptr = (type, aux, offset) => {
148
170
  _u32[1] = encodePtrHi(type, aux)
149
171
  _u32[0] = offset >>> 0; return _f64[0]
@@ -196,7 +218,11 @@ export const memory = (src) => {
196
218
  // Instance result: { module, instance, exports, extMap }
197
219
  const raw = src?.instance?.exports || src?.exports || src
198
220
  mem = src?.exports?.memory || raw.memory
199
- if (!mem) return null // pure scalar module — no memory
221
+ // Memoryless module (SSO strings / atoms / numbers only — no linear memory):
222
+ // hand back a minimal reader instead of null so callers can still decode its
223
+ // boundary values from bits. `read`/`wrapVal` cover the value forms that exist
224
+ // without memory; `scalar` flags the fast path that skips heap marshaling.
225
+ if (!mem) return { read: decode, wrapVal: coerce, scalar: true }
200
226
  wasmExports = { ...raw, memory: mem }
201
227
  extMap = src.extMap || null
202
228
  mod = src.module || null
@@ -249,8 +275,7 @@ export const memory = (src) => {
249
275
  if (_enhanced.has(mem)) {
250
276
  mem.schemas = schemas
251
277
  if (wasmExports?._alloc) { alloc = wasmExports._alloc; mem.alloc = alloc }
252
- if (wasmExports?._clear) mem.reset = wasmExports._clear
253
- else if (!mem.reset) mem.reset = jsReset
278
+ mem.reset = jsReset // post-init rewind — see the note at the first-enhance path
254
279
  if (extMap) mem._extMap = extMap
255
280
  return mem
256
281
  }
@@ -471,7 +496,14 @@ export const memory = (src) => {
471
496
  }
472
497
 
473
498
  mem.alloc = alloc
474
- mem.reset = wasmExports?._clear || jsReset
499
+ // Rewind to the JS-captured post-init heap mark, NOT the wasm `_clear` (which
500
+ // rewinds to the static-data end and would clobber module-global heap values —
501
+ // a top-level `let o = {…}` — on the first alloc after reset). `jsReset`'s base
502
+ // is `$__heap` read after instantiation (start ran), i.e. exactly the high-water
503
+ // mark above all module-init allocations. Both share `$__heap`, so a wasm `_alloc`
504
+ // and this reset stay consistent. (Shared memory has no `$__heap` global → base
505
+ // is the fixed start, preserving prior behavior.)
506
+ mem.reset = jsReset
475
507
 
476
508
  // TypedArray constructors: memory.Float64Array(data), etc.
477
509
  // Bulk-copy path: when input is a TypedArray whose element type matches
@@ -536,7 +568,9 @@ export const wrap = (memSrc, inst) => {
536
568
  const bits = lastErrBits.value
537
569
  _u32[0] = Number(bits & 0xffffffffn)
538
570
  _u32[1] = Number((bits >> 32n) & 0xffffffffn)
539
- const value = mem ? mem.read(_f64[0]) : _f64[0]
571
+ // Memoryless module: the thrown value is a number/atom/SSO string decode it
572
+ // from bits. (A heap Error/string can only exist when the module has memory.)
573
+ const value = mem ? mem.read(_f64[0]) : decode(_f64[0])
540
574
  if (value instanceof Error) throw value
541
575
  const wrapped = new Error(typeof value === 'string' ? value : String(value))
542
576
  wrapped.cause = error
@@ -554,7 +588,7 @@ export const wrap = (memSrc, inst) => {
554
588
  ext?.has(i) ? (x === undefined && ext.def?.has(i) ? ext.def.get(i) : x) : box(x)
555
589
 
556
590
  // Pure scalar module (no memory): pass f64 values directly, no marshaling
557
- if (!mem) {
591
+ if (!mem || mem.scalar) {
558
592
  for (const [name, fn] of Object.entries(realInst.exports)) {
559
593
  if (typeof fn !== 'function') { exports[name] = fn; continue }
560
594
  const ext = extExp.get(name)
@@ -649,7 +683,7 @@ const installDefaultEnvImports = (mod, imports, state) => {
649
683
  // across the wasm→JS boundary (see module/console.js header). Reinterpret
650
684
  // the BigInt's bits as f64 here so mem.read sees the original NaN-box.
651
685
  const write = (valBig, fd, sep) => {
652
- const v = state.mem.read(i64ToF64(valBig))
686
+ const v = readArgBits(state, valBig)
653
687
  buf[fd] += String(v)
654
688
  if (sep === 32) buf[fd] += ' '
655
689
  else if (sep === 10) flush(fd)
@@ -679,13 +713,13 @@ const installDefaultEnvImports = (mod, imports, state) => {
679
713
  }
680
714
  if (envFns.has('parseFloat') && !imports.env.parseFloat) {
681
715
  imports.env.parseFloat = (valBig) => {
682
- const s = state.mem.read(i64ToF64(valBig))
716
+ const s = readArgBits(state, valBig)
683
717
  return parseFloat(s)
684
718
  }
685
719
  }
686
720
  if (envFns.has('parseInt') && !imports.env.parseInt) {
687
721
  imports.env.parseInt = (valBig, radix) => {
688
- const s = state.mem.read(i64ToF64(valBig))
722
+ const s = readArgBits(state, valBig)
689
723
  return parseInt(s, radix || undefined)
690
724
  }
691
725
  }
@@ -834,7 +868,11 @@ const finishInstantiation = (mod, inst, imports, needsWasi, opts, state) => {
834
868
  const enhanced = memory(memSrc)
835
869
  state.mem = enhanced
836
870
  state.flushPrint?.()
837
- return { exports: wrap(memSrc), memory: enhanced, instance: inst, module: mod }
871
+ // A memoryless module keeps a minimal reader internally (state.mem, for decoding
872
+ // its SSO/atom boundary values), but the result's `.memory` stays null — the
873
+ // module genuinely exposes no linear memory. `jz.memory(result)` still hands back
874
+ // a usable reader on demand.
875
+ return { exports: wrap(memSrc), memory: enhanced?.scalar ? null : enhanced, instance: inst, module: mod }
838
876
  }
839
877
 
840
878
  /**
@@ -849,23 +887,29 @@ const finishInstantiation = (mod, inst, imports, needsWasi, opts, state) => {
849
887
  * @param {object} [opts] host options: imports, memory, _interp, host-shape flags
850
888
  * @returns {{ exports, memory, instance, module }}
851
889
  */
890
+ /**
891
+ * Compile wasm bytes to a `WebAssembly.Module`, preferring native
892
+ * `wasm:js-string` builtins when the engine honors the option (V8 17+/Safari
893
+ * 18.4+; older engines throw or ignore it — try-fallback handles both). A
894
+ * `WebAssembly.Module` passed in is returned as-is. Factor it out so callers
895
+ * can compile once and instantiate many times without re-validating the bytes
896
+ * (`instantiate(toModule(wasm))` skips the per-call compile on hot loops).
897
+ *
898
+ * @param {Uint8Array|ArrayBuffer|WebAssembly.Module} wasm
899
+ * @returns {WebAssembly.Module}
900
+ */
901
+ export const toModule = (wasm) => {
902
+ if (wasm instanceof WebAssembly.Module) return wasm
903
+ if (jssProbeNative()) {
904
+ try { return new WebAssembly.Module(wasm, { builtins: ['js-string'] }) }
905
+ catch { return new WebAssembly.Module(wasm) }
906
+ }
907
+ return new WebAssembly.Module(wasm)
908
+ }
909
+
852
910
  export const instantiate = (wasm, opts = {}) => {
853
911
  const state = prepareInterop(opts)
854
- // Prefer native `wasm:js-string` builtins when the engine honors the option.
855
- // The option is silently accepted by V8 17+/Safari 18.4+; older engines that
856
- // don't recognize it either throw or ignore it — try-fallback handles both.
857
- let mod
858
- if (wasm instanceof WebAssembly.Module) {
859
- mod = wasm
860
- } else if (jssProbeNative()) {
861
- try {
862
- mod = new WebAssembly.Module(wasm, { builtins: ['js-string'] })
863
- } catch {
864
- mod = new WebAssembly.Module(wasm)
865
- }
866
- } else {
867
- mod = new WebAssembly.Module(wasm)
868
- }
912
+ const mod = toModule(wasm)
869
913
  const { imports, needsWasi } = buildImports(mod, opts, state)
870
914
  const hasImports = Object.keys(imports).some(k => k !== '_setMemory')
871
915
  const inst = new WebAssembly.Instance(mod, hasImports ? imports : undefined)