jz 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -22,7 +22,7 @@ JZ distills the modern functional core – the "good parts" ([Crockford](https:/
22
22
 
23
23
  * **Static AOT** – no runtime, no GC, no dynamic constructs.
24
24
  * **Valid jz = valid js** — test in browser, compile to wasm.
25
- * **Minimal** — output is close to hand-written WAT.
25
+ * **Minimal** — output is close to hand-written WAT; CI gates it to stay at least as small and fast as AssemblyScript and Porffor on the bench corpus ([CONTRIBUTING](CONTRIBUTING.md#performance--size-invariant)).
26
26
  <!-- * **Realtime** — compiles faster than `eval`, useful for live-coding and REPL. -->
27
27
 
28
28
  | Good for | Not for |
@@ -82,7 +82,15 @@ Options are passed as `jz(source, opts)` or `compile(source, opts)`. Common ones
82
82
 
83
83
  ```sh
84
84
  # Compile
85
- jz program.js # → program.wasm
85
+ jz program.js # → program.wasm
86
+ jz program.js --wat # → program.wat
87
+ jz program.js -o out.wasm # custom output (- for stdout)
88
+
89
+ # Optimization level: -O0 off, -O1 size, -O2 balanced, -O3 speed
90
+ jz program.js -O3
91
+
92
+ # Runtime-service lowering: js (default) or wasi
93
+ jz program.js --host wasi
86
94
 
87
95
  # Evaluate
88
96
  jz -e "1 + 2" # 3
@@ -91,6 +99,7 @@ jz -e "1 + 2" # 3
91
99
  jz --help
92
100
  ```
93
101
 
102
+
94
103
  ## Language
95
104
 
96
105
  JZ is a strict functional JS subset. Built-in `jzify` transform extends support to legacy patterns.
@@ -467,18 +476,19 @@ cc program.c -o program
467
476
 
468
477
  | | jz | [Node](https://nodejs.org/) | [Porffor](https://github.com/CanadaHonk/porffor) | [AS](https://github.com/AssemblyScript/assemblyscript) | WAT | C | [Go](https://go.dev/) | [Zig](https://ziglang.org/) | [Rust](https://www.rust-lang.org/) | [NumPy](https://numpy.org/) |
469
478
  |---|---|---|---|---|---|---|---|---|---|---|
470
- | [biquad](bench/biquad/biquad.js) | 4.63ms<br>4.0kB | 8.68ms<br>3.2kB | fails | 6.59ms<br>1.9kB | 6.45ms<br>767 B | 5.30ms | 8.91ms<br>fma | 5.06ms | 5.28ms | 3.12s |
471
- | [tokenizer](bench/tokenizer/tokenizer.js) | 0.07ms<br>1.8kB | 0.12ms<br>1.4kB | 0.34ms<br>2.6kB | 0.05ms<br>1.5kB | 0.08ms<br>344 B | 0.14ms | 0.07ms | 0.12ms | 0.12ms | 5.15ms |
472
- | [mat4](bench/mat4/mat4.js) | 2.12ms<br>3.7kB | 11.80ms<br>1.2kB | 88.54ms<br>2.4kB<br>diff | 9.21ms<br>1.6kB | 8.06ms<br>414 B | 2.73ms | 11.93ms | 2.73ms | 1.77ms | 387.60ms |
473
- | [aos](bench/aos/aos.js) | 1.11ms<br>2.3kB | 1.26ms<br>1.1kB | fails | 1.33ms<br>2.2kB | 1.07ms<br>481 B | 1.20ms | 0.91ms | 0.91ms | 1.20ms | 2.57ms |
474
- | [mandelbrot](bench/mandelbrot/mandelbrot.js) | 8.02ms<br>1.2kB | 9.06ms<br>1.8kB | 9.71ms<br>3.0kB | 8.00ms<br>1.3kB | — | 8.31ms | 8.80ms | 7.83ms | 8.52ms | — |
475
- | [bitwise](bench/bitwise/bitwise.js) | 0.98ms<br>1.3kB | 3.76ms<br>1005 B | fails | 8.79ms<br>1.5kB | 4.86ms<br>355 B | 1.30ms | 5.20ms | 4.15ms | 1.30ms | 14.72ms |
476
- | [poly](bench/poly/poly.js) | 0.27ms<br>1.4kB | 1.62ms<br>1014 B | fails | 0.73ms<br>1.3kB | 0.81ms<br>359 B | 0.57ms | 0.79ms | 0.89ms | 0.63ms | 0.60ms |
477
- | [callback](bench/callback/callback.js) | 0.03ms<br>1.6kB | 0.69ms<br>828 B | fails | 1.04ms<br>1.9kB | 0.24ms<br>267 B | 0.08ms | 0.23ms | 0.01ms | 0.12ms | 1.78ms |
478
- | [json](bench/json/json.js) | 0.25ms<br>10.9kB | 0.36ms<br>1.2kB | fails | — | — | 0.25ms | 1.16ms | 0.64ms | 0.65ms | 1.20ms |
479
- | [watr](bench/watr/watr.js) | 1.04ms<br>169.8kB | 1.05ms<br>2.6kB | fails | | — | | | | | |
480
-
481
- _Numbers from `node bench/bench.mjs` on Apple Silicon. Porffor cells were refreshed with `porf` 0.61.13; `fails` means the latest Porffor compiler/runtime did not complete that benchmark._
479
+ | [biquad](bench/biquad/biquad.js) | 6.40ms<br>4.1kB | 12.02ms<br>3.2kB | fails | 8.88ms<br>1.9kB | 6.44ms<br>767 B | 5.30ms | 8.96ms<br>fma | 5.04ms | 5.27ms | 3.09s |
480
+ | [tokenizer](bench/tokenizer/tokenizer.js) | 0.10ms<br>1.9kB | 0.20ms<br>2.0kB | 0.41ms<br>3.2kB | 0.08ms<br>1.6kB | 0.10ms<br>344 B | 0.13ms | 0.08ms | 0.14ms | 0.12ms | 5.13ms |
481
+ | [mat4](bench/mat4/mat4.js) | 2.12ms<br>3.0kB | 11.90ms<br>1.2kB | 88.68ms<br>2.4kB<br>diff | 9.44ms<br>1.6kB | 8.10ms<br>414 B | 2.76ms | 12.51ms | 2.74ms | 1.78ms | 389.44ms |
482
+ | [aos](bench/aos/aos.js) | 1.61ms<br>2.0kB | 1.82ms<br>1.1kB | fails | 1.91ms<br>2.2kB | 1.07ms<br>481 B | 1.20ms | 0.90ms | 0.90ms | 1.20ms | 2.55ms |
483
+ | [mandelbrot](bench/mandelbrot/mandelbrot.js) | 12.24ms<br>1.2kB | 13.53ms<br>1.8kB | 13.47ms<br>3.0kB | 12.23ms<br>1.3kB | — | 12.26ms | 12.46ms | 12.31ms | 12.23ms | — |
484
+ | [bitwise](bench/bitwise/bitwise.js) | 1.62ms<br>1.3kB | 5.25ms<br>1005 B | fails | 11.97ms<br>1.5kB | 4.95ms<br>355 B | 1.30ms | 5.23ms | 4.16ms | 1.30ms | 14.77ms |
485
+ | [poly](bench/poly/poly.js) | 0.37ms<br>1.4kB | 2.29ms<br>1014 B | fails | 1.13ms<br>1.3kB | 0.81ms<br>359 B | 0.52ms | 0.80ms | 0.80ms | 0.57ms | 0.61ms |
486
+ | [callback](bench/callback/callback.js) | 0.05ms<br>1.5kB | 0.86ms<br>828 B | fails | 1.48ms<br>1.9kB | 0.25ms<br>267 B | 0.10ms | 0.20ms | 0.01ms | 0.09ms | 1.81ms |
487
+ | [json](bench/json/json.js) | 0.23ms<br>10.9kB | 0.37ms<br>1.2kB | fails | — | — | 0.21ms | 1.17ms | 0.69ms | 0.68ms | 1.20ms |
488
+ | [sort](bench/sort/sort.js) | 8.44ms<br>1.9kB | 9.98ms<br>1.6kB | fails | 10.18ms<br>1.9kB | — | 8.85ms | 10.36ms | 8.84ms | 9.37ms | 5.05ms |
489
+ | [crc32](bench/crc32/crc32.js) | 11.96ms<br>1.5kB | 13.35ms<br>1.8kB | 80.76ms<br>3.1kB | 12.04ms<br>1.4kB | — | 10.69ms | 9.30ms | 9.45ms | 9.38ms | 0.24ms |
490
+
491
+ _Numbers from `node bench/bench.mjs` on Apple Silicon._ `optimize: 'size'|'speed'|'balanced'` provides a size/speed tradeoff leverage.
482
492
 
483
493
  <details>
484
494
  <summary><strong>Optimizations</strong></summary>
package/cli.js CHANGED
@@ -10,7 +10,8 @@ import { pathToFileURL } from 'url'
10
10
  import { execFileSync } from 'child_process'
11
11
  import { parse } from 'subscript/feature/jessie'
12
12
  import jz, { compile } from './index.js'
13
- import jzifyFn, { codegen } from './src/jzify.js'
13
+ import jzifyFn from './src/jzify.js'
14
+ import { codegen } from './src/codegen.js'
14
15
  import { createRequire } from 'module'
15
16
 
16
17
  const jzRequire = createRequire(import.meta.url)
@@ -32,13 +33,21 @@ Examples:
32
33
  jz program.js --wat # → program.wat
33
34
  jz program.js -o out.wasm # custom output name
34
35
  jz program.js -o - # write to stdout
36
+ jz program.js -O3 # aggressive optimization
37
+ jz program.js -Os # optimize for size
38
+ jz program.js --host wasi # emit WASI Preview 1 imports
35
39
  jz --strict program.js # strict mode
36
40
  jz --jzify lib.js # → lib.jz
37
41
  jz -e "1 + 2"
38
42
 
39
43
  Options:
40
44
  --output, -o <file> Output file (.wat, .wasm, or - for stdout)
41
- --strict Strict jz mode (no auto-transform)
45
+ -O<n>, --optimize <n> Optimization level: 0 off, 1 size-only, 2 default,
46
+ 3 aggressive. Aliases: -Os/size, -Ob/balanced, -Of/speed.
47
+ --host <js|wasi> Runtime-service lowering (default js)
48
+ --no-alloc Omit _alloc/_clear allocator exports (standalone wasm)
49
+ --names Emit wasm name section for profilers/debuggers
50
+ --strict Strict jz mode (no auto-transform), reject dynamic fallbacks
42
51
  --jzify Transform JS to jz (no compilation)
43
52
  --eval, -e Evaluate expression or file
44
53
  --wat Output WAT text instead of binary
@@ -109,16 +118,31 @@ async function handleJzify(args) {
109
118
  }
110
119
  }
111
120
 
121
+ // -O<n>/-Os/-Ob/-Of and --optimize <val> → value accepted by compile()'s `optimize` opt
122
+ const OPT_ALIAS = { s: 'size', b: 'balanced', f: 'speed' }
123
+ function parseOptimize(v) {
124
+ if (v == null) return undefined
125
+ if (/^\d+$/.test(v)) return +v
126
+ return OPT_ALIAS[v] ?? v
127
+ }
128
+
112
129
  async function handleCompile(args) {
113
130
  let inputFile = null, outputFile = null, wat = false, strict = false, resolveNode = false, importsFile = null
131
+ let optimize, host, alloc = true, names = false
114
132
 
115
133
  for (let i = 0; i < args.length; i++) {
116
- if (args[i] === '--output' || args[i] === '-o') outputFile = args[++i]
117
- else if (args[i] === '--wat') wat = true
118
- else if (args[i] === '--strict') strict = true
119
- else if (args[i] === '--resolve') resolveNode = true
120
- else if (args[i] === '--imports') importsFile = args[++i]
121
- else if (!inputFile) inputFile = args[i]
134
+ const a = args[i]
135
+ if (a === '--output' || a === '-o') outputFile = args[++i]
136
+ else if (a === '--wat') wat = true
137
+ else if (a === '--strict') strict = true
138
+ else if (a === '--resolve') resolveNode = true
139
+ else if (a === '--imports') importsFile = args[++i]
140
+ else if (a === '--optimize' || a === '-O') optimize = parseOptimize(args[++i])
141
+ else if (/^-O.+/.test(a)) optimize = parseOptimize(a.slice(2))
142
+ else if (a === '--host') host = args[++i]
143
+ else if (a === '--no-alloc') alloc = false
144
+ else if (a === '--names') names = true
145
+ else if (!inputFile) inputFile = a
122
146
  }
123
147
 
124
148
  if (!inputFile) throw new Error('No input file specified')
@@ -178,7 +202,12 @@ async function handleCompile(args) {
178
202
  const opts = {
179
203
  wat,
180
204
  jzify: !strict && !inputFile.endsWith('.jz'),
205
+ strict,
181
206
  importMetaUrl: pathToFileURL(resolve(inputFile)).href,
207
+ ...(optimize !== undefined && { optimize }),
208
+ ...(host && { host }),
209
+ ...(alloc === false && { alloc: false }),
210
+ ...(names && { profileNames: true }),
182
211
  ...(Object.keys(modules).length && { modules }),
183
212
  }
184
213
 
package/index.js CHANGED
@@ -51,18 +51,11 @@ import {
51
51
  memory as enhanceMemory, instantiate as instantiateRuntime,
52
52
  } from './src/host.js'
53
53
 
54
- const importSpecMayReturnExternal = (spec) => {
55
- if (typeof spec === 'function') return true
56
- return false
57
- }
58
-
59
- const importsMayReturnExternal = (imports) => {
60
- if (!imports) return false
61
- for (const mod of Object.values(imports))
62
- for (const spec of Object.values(mod || {}))
63
- if (importSpecMayReturnExternal(spec)) return true
64
- return false
65
- }
54
+ // A host import that's a JS function may hand back any value, including a host
55
+ // object which arrives in wasm as a PTR.EXTERNAL ref. Constants/typed specs can't.
56
+ const importsMayReturnExternal = (imports) =>
57
+ !!imports && Object.values(imports).some(mod =>
58
+ Object.values(mod || {}).some(spec => typeof spec === 'function'))
66
59
 
67
60
  const nowMs = () => globalThis.performance?.now ? globalThis.performance.now() : Date.now()
68
61
 
@@ -227,8 +220,13 @@ jz.compile = (code, opts = {}) => {
227
220
  const ast = time('prepare', () => prepare(parsed))
228
221
 
229
222
  // Auto-detect optimization tuning from source characteristics when the user
230
- // hasn't provided any optimize option.
231
- if (opts.optimize == null) {
223
+ // hasn't provided any optimize option. At the default level its only *live*
224
+ // output is the typed-array scalarization thresholds (every other override
225
+ // watr off, sortStrPool/hoistPtr on — already matches the level-2 preset), so
226
+ // skip the AST scan entirely unless the program touches typed arrays.
227
+ // NOTE: if the level-2 default ever flips watr on, drop this guard so the
228
+ // machine-generated-code heuristic in detectOptimizeConfig can take effect.
229
+ if (opts.optimize == null && ctx.module.modules.typedarray) {
232
230
  const autoCfg = detectOptimizeConfig(ast, code)
233
231
  if (Object.keys(autoCfg).length) {
234
232
  ctx.transform.optimize = resolveOptimize(autoCfg)
@@ -253,7 +251,26 @@ jz.compile = (code, opts = {}) => {
253
251
  }
254
252
 
255
253
  const cfg = ctx.transform.optimize
256
- const optimized = cfg.watr ? time('watrOptimize', () => watrOptimize(module)) : module
254
+ // watr's `loopify` collapses the `(block $brk (loop … (br_if $brk !cond) (br $loop)))`
255
+ // idiom into `(loop … (if cond …))` — sound, but destroys the exact shape jz's
256
+ // post-watr vectorizer scans for. Disable loopify when vectorize is going to run.
257
+ //
258
+ // watr config:
259
+ // true / 'full' → all default passes (includes `inlineOnce` / `inline` / `coalesce`)
260
+ // 'light' → everything except inlining + coalesce. Default at level 2.
261
+ // `inlineOnce` reshapes codegen the way slot-type / LICM tests scan for,
262
+ // and miscompiles regex `split(/\s+/)`. `coalesceLocals` on its own (i.e.
263
+ // without the cleanup that follows inlining) breaks `/a.+b/.test("ab")`
264
+ // — likely a stale alias the post-inline propagate sweep normally tidies
265
+ // up at L3. Dropping both still keeps treeshake / dedupe / dedupTypes /
266
+ // propagate / packData / fold / peephole / vacuum / mergeBlocks / brif /
267
+ // loopify / offset / unbranch / identity / strength / globals etc.
268
+ // false → skip watr entirely (level 0/1).
269
+ let watrOpts
270
+ if (cfg.watr === 'light') watrOpts = { inline: false, inlineOnce: false, coalesce: false, loopify: !cfg.vectorizeLaneLocal }
271
+ else if (cfg.vectorizeLaneLocal) watrOpts = { loopify: false }
272
+ else watrOpts = true
273
+ const optimized = cfg.watr ? time('watrOptimize', () => watrOptimize(module, watrOpts)) : module
257
274
  // Final peephole pass: watrOptimize's inliner can re-introduce rebox/unbox at boundaries
258
275
  // (e.g. inlined closure body's `i32.wrap_i64 (i64.reinterpret_f64 __env)` next to caller's
259
276
  // `boxPtrIR(g)` rebox). Our fusedRewrite folds these, watr's peephole doesn't.
package/module/array.js CHANGED
@@ -251,7 +251,13 @@ export default (ctx) => {
251
251
  ctx.core.emit['new.Array'] = (len) => {
252
252
  const n = tempI32('alen')
253
253
  const nIR = ['local.get', `$${n}`]
254
- const out = allocPtr({ type: PTR.ARRAY, len: nIR, cap: nIR, tag: 'newarr' })
254
+ // L3/'speed' bumps the cap floor to skip the first growth cycles (default 0
255
+ // → grow on first push). Length stays exactly what the user requested.
256
+ const minCap = ctx.transform.optimize?.arrayMinCap | 0
257
+ const capIR = minCap > 0
258
+ ? ['select', ['i32.const', minCap], nIR, ['i32.gt_s', ['i32.const', minCap], nIR]]
259
+ : nIR
260
+ const out = allocPtr({ type: PTR.ARRAY, len: nIR, cap: capIR, tag: 'newarr' })
255
261
  return typed(['block', ['result', 'f64'],
256
262
  ['local.set', `$${n}`, len == null ? ['i32.const', 0] : asI32(emit(len))],
257
263
  out.init,
@@ -378,7 +384,7 @@ export default (ctx) => {
378
384
  ctx.core.stdlib['__arr_from'] = `(func $__arr_from (param $src i64) (result f64)
379
385
  (local $len i32) (local $dst i32)
380
386
  (local.set $len (call $__len (local.get $src)))
381
- (local.set $dst (call $__alloc_hdr (local.get $len) (local.get $len) (i32.const 8)))
387
+ (local.set $dst (call $__alloc_hdr (local.get $len) (local.get $len)))
382
388
  (memory.copy (local.get $dst) (call $__ptr_offset (local.get $src)) (i32.shl (local.get $len) (i32.const 3)))
383
389
  (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $dst)))`
384
390
 
@@ -434,7 +440,7 @@ export default (ctx) => {
434
440
  (i32.lt_u (local.get $off) (i32.const 8)))
435
441
  (then
436
442
  (local.set $newCap (select (local.get $minCap) (i32.const 4) (i32.gt_s (local.get $minCap) (i32.const 4))))
437
- (local.set $newOff (call $__alloc_hdr (i32.const 0) (local.get $newCap) (i32.const 8)))
443
+ (local.set $newOff (call $__alloc_hdr (i32.const 0) (local.get $newCap)))
438
444
  (return (call $__mkptr (i32.const ${PTR.ARRAY}) (i32.const 0) (local.get $newOff)))))
439
445
  (local.set $oldCap (i32.load (i32.sub (local.get $off) (i32.const 4))))
440
446
  (if (i32.ge_s (local.get $oldCap) (local.get $minCap))
@@ -444,7 +450,7 @@ export default (ctx) => {
444
450
  (i32.shl (local.get $oldCap) (i32.const 1))
445
451
  (i32.gt_s (local.get $minCap) (i32.shl (local.get $oldCap) (i32.const 1)))))
446
452
  (local.set $len (i32.load (i32.sub (local.get $off) (i32.const 8))))
447
- (local.set $newOff (call $__alloc_hdr (local.get $len) (local.get $newCap) (i32.const 8)))
453
+ (local.set $newOff (call $__alloc_hdr (local.get $len) (local.get $newCap)))
448
454
  (memory.copy (local.get $newOff) (local.get $off) (i32.shl (local.get $len) (i32.const 3)))
449
455
  ${headerPropsCopyIR()}
450
456
  ${maybeDynMoveIR()}
@@ -464,7 +470,7 @@ export default (ctx) => {
464
470
  (i32.shl (local.get $oldCap) (i32.const 1))
465
471
  (i32.gt_s (local.get $minCap) (i32.shl (local.get $oldCap) (i32.const 1)))))
466
472
  (local.set $len (i32.load (i32.sub (local.get $off) (i32.const 8))))
467
- (local.set $newOff (call $__alloc_hdr (local.get $len) (local.get $newCap) (i32.const 8)))
473
+ (local.set $newOff (call $__alloc_hdr (local.get $len) (local.get $newCap)))
468
474
  (memory.copy (local.get $newOff) (local.get $off) (i32.shl (local.get $len) (i32.const 3)))
469
475
  ${headerPropsCopyIR()}
470
476
  ${maybeDynMoveIR()}
@@ -522,7 +528,9 @@ export default (ctx) => {
522
528
  const slots = elems.map(e => extractF64Bits(asF64(emit(e))))
523
529
  if (slots.every(b => b !== null)) return staticArrayPtr(slots)
524
530
  }
525
- const a = allocArray(len, Math.max(len, 4)) // min cap=4 for small pushes
531
+ // L3/'speed' bumps the cap floor (default 4) to skip more growth cycles.
532
+ const minCap = Math.max(ctx.transform.optimize?.arrayMinCap | 0, 4)
533
+ const a = allocArray(len, Math.max(len, minCap))
526
534
  const body = [...a.setup]
527
535
  for (let i = 0; i < len; i++)
528
536
  body.push(['f64.store', slotAddr(a.local, i), asF64(emit(elems[i]))])
@@ -530,7 +538,8 @@ export default (ctx) => {
530
538
  return typed(['block', ['result', 'f64'], ...body], 'f64')
531
539
  }
532
540
 
533
- const a = allocArray(0, Math.max(elems.length, 4))
541
+ const minCap = Math.max(ctx.transform.optimize?.arrayMinCap | 0, 4)
542
+ const a = allocArray(0, Math.max(elems.length, minCap))
534
543
  const out = temp('sa'), pos = tempI32('sp')
535
544
  inc('__arr_set_idx_ptr')
536
545
 
@@ -11,6 +11,7 @@
11
11
  import { typed, asF64, asI64, asI32, NULL_NAN, UNDEF_NAN, temp, tempI32, tempI64, allocPtr, undefExpr } from '../src/ir.js'
12
12
  import { emit, emitFlat } from '../src/emit.js'
13
13
  import { valTypeOf, lookupValType, VAL } from '../src/analyze.js'
14
+ import { hasOwnContinue } from '../src/ast.js'
14
15
  import { inc, PTR, LAYOUT } from '../src/ctx.js'
15
16
 
16
17
  const SET_ENTRY = 16 // hash + key
@@ -186,7 +187,7 @@ function genUpsertGrow(name, entrySize, hashFn, eqExpr, typeConst, strict = fals
186
187
  (if (i32.ge_s (i32.mul (local.get $size) (i32.const 4)) (i32.mul (local.get $cap) (i32.const 3)))
187
188
  (then
188
189
  (local.set $newcap (i32.shl (local.get $cap) (i32.const 1)))
189
- (local.set $newptr (call $__alloc_hdr (i32.const 0) (local.get $newcap) (i32.const ${entrySize})))
190
+ (local.set $newptr (call $__alloc_hdr_n (i32.const 0) (local.get $newcap) (i32.const ${entrySize})))
190
191
  (local.set $i (i32.const 0))
191
192
  (block $rd (loop $rl
192
193
  (br_if $rd (i32.ge_s (local.get $i) (local.get $cap)))
@@ -335,14 +336,14 @@ export default (ctx) => {
335
336
  __hash_has: () => ctx.features.external
336
337
  ? ['__str_hash', '__str_eq', '__ptr_type', '__ext_has']
337
338
  : ['__str_hash', '__str_eq', '__ptr_type'],
338
- __hash_new: ['__alloc_hdr'],
339
- __hash_new_small: ['__alloc_hdr', '__mkptr'],
339
+ __hash_new: ['__alloc_hdr_n'],
340
+ __hash_new_small: ['__alloc_hdr_n', '__mkptr'],
340
341
  __hash_get_local: ['__str_hash', '__str_eq'],
341
342
  __hash_get_local_h: ['__str_eq'],
342
343
  __hash_set_local_h: ['__str_eq'],
343
- __hash_set_local: ['__str_hash', '__str_eq'],
344
+ __hash_set_local: ['__str_hash', '__str_eq', '__alloc_hdr_n', '__mkptr'],
344
345
  __ihash_get_local: ['__map_hash'],
345
- __ihash_set_local: ['__map_hash', '__alloc_hdr', '__mkptr'],
346
+ __ihash_set_local: ['__map_hash', '__alloc_hdr_n', '__mkptr'],
346
347
  __dyn_get_t: ['__dyn_get_t_h', '__str_hash'],
347
348
  __dyn_get_t_h: ['__ihash_get_local', '__str_eq', '__is_nullish'],
348
349
  __dyn_get: ['__dyn_get_t', '__ptr_type'],
@@ -437,7 +438,7 @@ export default (ctx) => {
437
438
  // __map_new() → f64 — allocate empty Map (for JSON.parse, runtime creation)
438
439
  ctx.core.stdlib['__map_new'] = `(func $__map_new (result f64)
439
440
  (call $__mkptr (i32.const ${PTR.MAP}) (i32.const 0)
440
- (call $__alloc_hdr (i32.const 0) (i32.const ${INIT_CAP}) (i32.const ${MAP_ENTRY}))))`
441
+ (call $__alloc_hdr_n (i32.const 0) (i32.const ${INIT_CAP}) (i32.const ${MAP_ENTRY}))))`
441
442
 
442
443
  // === Set ===
443
444
 
@@ -567,14 +568,17 @@ export default (ctx) => {
567
568
 
568
569
  ctx.core.stdlib['__hash_new'] = `(func $__hash_new (result f64)
569
570
  (call $__mkptr (i32.const ${PTR.HASH}) (i32.const 0)
570
- (call $__alloc_hdr (i32.const 0) (i32.const ${INIT_CAP}) (i32.const ${MAP_ENTRY}))))`
571
+ (call $__alloc_hdr_n (i32.const 0) (i32.const ${INIT_CAP}) (i32.const ${MAP_ENTRY}))))`
571
572
 
572
573
  // Small initial capacity for propsPtr-style hashes (per-object dyn props).
573
574
  // Most receivers in real code carry 0-2 dyn props; paying 8-slot up-front
574
575
  // is wasted memory + probe-loop cache pressure. Grows to 4/8/... on demand.
576
+ // L3/'speed' opts into a larger initial cap (default 8) to skip 2→4→8 growth
577
+ // when AST-style nodes carry 3-5 props (watr.compile's profile).
578
+ const smallCap = Math.max(ctx.transform.optimize?.hashSmallInitCap | 0, 2)
575
579
  ctx.core.stdlib['__hash_new_small'] = `(func $__hash_new_small (result f64)
576
580
  (call $__mkptr (i32.const ${PTR.HASH}) (i32.const 0)
577
- (call $__alloc_hdr (i32.const 0) (i32.const 2) (i32.const ${MAP_ENTRY}))))`
581
+ (call $__alloc_hdr_n (i32.const 0) (i32.const ${smallCap}) (i32.const ${MAP_ENTRY}))))`
578
582
 
579
583
  ctx.core.stdlib['__hash_get_local'] = genLookupStrict('__hash_get_local', MAP_ENTRY, '$__str_hash', strEq, PTR.HASH)
580
584
  ctx.core.stdlib['__hash_get_local_h'] = genLookupStrictPrehashed('__hash_get_local_h', MAP_ENTRY, strEq, PTR.HASH)
@@ -709,6 +713,11 @@ export default (ctx) => {
709
713
  (br_if $haveProps (i32.eq
710
714
  (i32.wrap_i64 (i64.and (i64.shr_u (local.get $props) (i64.const ${LAYOUT.TAG_SHIFT})) (i64.const ${LAYOUT.TAG_MASK})))
711
715
  (i32.const ${PTR.HASH})))
716
+ ;; Fresh array (header propsPtr=0, no shift, no props): skip the
717
+ ;; global hash probe — there's nothing to find. Shifted arrays read
718
+ ;; forwarding bytes here (low32=newOff, high32=-1) → non-zero, so
719
+ ;; this br_if doesn't fire and they fall through to __dyn_props.
720
+ (br_if $dynDone (i64.eqz (local.get $props)))
712
721
  (local.set $props (i64.const 0))))
713
722
  ;; OBJECT: heap-allocated (off >= __heap_start) carries propsPtr at
714
723
  ;; off-16 from __alloc_hdr. The slot is either 0 (no dyn props yet) or
@@ -1032,8 +1041,14 @@ export default (ctx) => {
1032
1041
  const ptrI64 = tempI64('hp'), srcOff = tempI32('hso'), srcType = tempI32('hst')
1033
1042
  if (!ctx.func.locals.has(varName)) ctx.func.locals.set(varName, 'f64')
1034
1043
  const id = ctx.func.uniq++
1044
+ const brk = `$brk${id}`, loop = `$loop${id}`, cont = `$cont${id}`
1035
1045
  const va = asF64(emit(src))
1036
- const bodyFlat = emitFlat(body)
1046
+ const needsCont = hasOwnContinue(body)
1047
+ ctx.func.stack.push({ brk, loop: needsCont ? cont : loop })
1048
+ let bodyFlat
1049
+ try { bodyFlat = emitFlat(body) }
1050
+ finally { ctx.func.stack.pop() }
1051
+ const bodyBlock = needsCont ? [['block', cont, ...bodyFlat]] : bodyFlat
1037
1052
  inc('__ptr_type')
1038
1053
  return [
1039
1054
  // Save source ptr as i64
@@ -1054,16 +1069,16 @@ export default (ctx) => {
1054
1069
  ['local.set', `$${off}`, ['call', '$__ptr_offset', ['local.get', `$${ptrI64}`]]],
1055
1070
  ['local.set', `$${cap}`, ['call', '$__cap', ['local.get', `$${ptrI64}`]]],
1056
1071
  ['local.set', `$${i}`, ['i32.const', 0]],
1057
- ['block', `$brk${id}`, ['loop', `$loop${id}`,
1058
- ['br_if', `$brk${id}`, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${cap}`]]],
1072
+ ['block', brk, ['loop', loop,
1073
+ ['br_if', brk, ['i32.ge_s', ['local.get', `$${i}`], ['local.get', `$${cap}`]]],
1059
1074
  ['local.set', `$${slot}`, ['i32.add', ['local.get', `$${off}`],
1060
1075
  ['i32.mul', ['local.get', `$${i}`], ['i32.const', MAP_ENTRY]]]],
1061
1076
  ['if', ['i64.ne', ['i64.load', ['local.get', `$${slot}`]], ['i64.const', 0]],
1062
1077
  ['then',
1063
1078
  ['local.set', `$${varName}`, ['f64.reinterpret_i64', ['i64.load', ['i32.add', ['local.get', `$${slot}`], ['i32.const', 8]]]]],
1064
- ...bodyFlat]],
1079
+ ...bodyBlock]],
1065
1080
  ['local.set', `$${i}`, ['i32.add', ['local.get', `$${i}`], ['i32.const', 1]]],
1066
- ['br', `$loop${id}`]]]]]
1081
+ ['br', loop]]]]]
1067
1082
  ]
1068
1083
  }
1069
1084
  }
package/module/core.js CHANGED
@@ -42,6 +42,7 @@ export default (ctx) => {
42
42
  __length: ['__ptr_type', '__ptr_offset', '__str_len', '__len'],
43
43
  __typeof: ['__ptr_type', '__is_nullish'],
44
44
  __alloc_hdr: ['__alloc'],
45
+ __alloc_hdr_n: ['__alloc'],
45
46
  })
46
47
 
47
48
  ctx.core.stdlib['__is_nullish'] = `(func $__is_nullish (param $v i64) (result i32)
@@ -334,7 +335,21 @@ export default (ctx) => {
334
335
  // (NaN-boxed PTR.HASH) for ARRAY/HASH/MAP/SET; 0 means "no dyn props yet". This lets
335
336
  // __dyn_get_t / __dyn_set sidestep the global __dyn_props lookup on the hot path.
336
337
  // Read offsets relative to the returned data ptr stay unchanged (-8 len, -4 cap).
337
- ctx.core.stdlib['__alloc_hdr'] = `(func $__alloc_hdr (param $len i32) (param $cap i32) (param $stride i32) (result i32)
338
+ // Default stride=8 (f64 NaN-boxed slot) used by every Array/HASH/OBJECT alloc.
339
+ // Specialized over a generic (len, cap, stride) helper to drop a fat (i32.const 8)
340
+ // immediate at every call site (~20+) plus a param/local.get pair in the body.
341
+ // Non-8 strides (Set: 16, Map/HASH probe: 24, TypedArray raw: 1) use __alloc_hdr_n.
342
+ ctx.core.stdlib['__alloc_hdr'] = `(func $__alloc_hdr (param $len i32) (param $cap i32) (result i32)
343
+ (local $ptr i32)
344
+ (local.set $ptr (call $__alloc (i32.add (i32.const 16) (i32.shl (local.get $cap) (i32.const 3)))))
345
+ (i64.store (local.get $ptr) (i64.const 0))
346
+ (i32.store (i32.add (local.get $ptr) (i32.const 8)) (local.get $len))
347
+ (i32.store (i32.add (local.get $ptr) (i32.const 12)) (local.get $cap))
348
+ (i32.add (local.get $ptr) (i32.const 16)))`
349
+
350
+ // Generic header allocator for non-8 strides: Set (16), Map probe (24), TypedArray raw (1).
351
+ // Same 16-byte header layout as __alloc_hdr; per-entry stride is passed dynamically.
352
+ ctx.core.stdlib['__alloc_hdr_n'] = `(func $__alloc_hdr_n (param $len i32) (param $cap i32) (param $stride i32) (result i32)
338
353
  (local $ptr i32)
339
354
  (local.set $ptr (call $__alloc (i32.add (i32.const 16) (i32.mul (local.get $cap) (local.get $stride)))))
340
355
  (i64.store (local.get $ptr) (i64.const 0))
package/module/json.js CHANGED
@@ -87,12 +87,12 @@ export default (ctx) => {
87
87
  const keys = Object.keys(v)
88
88
  // Empty object: minimal OBJECT with no slots.
89
89
  if (keys.length === 0) {
90
- return mkPtrIR(PTR.OBJECT, 0, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', 1], ['i32.const', 8]])
90
+ return mkPtrIR(PTR.OBJECT, 0, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', 1]])
91
91
  }
92
92
  const schemaId = ctx.schema.register(keys)
93
93
  const t = tempI32('jobj')
94
94
  const body = [
95
- ['local.set', `$${t}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', keys.length], ['i32.const', 8]]],
95
+ ['local.set', `$${t}`, ['call', '$__alloc_hdr', ['i32.const', 0], ['i32.const', keys.length]]],
96
96
  ]
97
97
  for (let i = 0; i < keys.length; i++) {
98
98
  body.push(['f64.store', slotAddr(t, i), asF64(emitJsonConstValue(v[keys[i]]))])
@@ -582,10 +582,10 @@ export default (ctx) => {
582
582
  ;; miss: register new schema.
583
583
  (local.set $sid (global.get $__schema_next))
584
584
  (global.set $__schema_next (i32.add (local.get $sid) (i32.const 1)))
585
- ;; Allocate jz Array of n keys. __alloc_hdr(len, cap, stride) returns base
586
- ;; of slot region with len@-8 and cap@-4. The schema dispatch arm reads
585
+ ;; Allocate jz Array of n keys. __alloc_hdr(len, cap) returns base of
586
+ ;; slot region with len@-8 and cap@-4. The schema dispatch arm reads
587
587
  ;; nkeys from -8, so len must equal cap=n.
588
- (local.set $karr (call $__alloc_hdr (local.get $n) (local.get $n) (i32.const 8)))
588
+ (local.set $karr (call $__alloc_hdr (local.get $n) (local.get $n)))
589
589
  (local.set $i (i32.const 0))
590
590
  (block $cd (loop $cl
591
591
  (br_if $cd (i32.ge_s (local.get $i) (local.get $n)))
@@ -623,7 +623,7 @@ export default (ctx) => {
623
623
  (then ${ADV(1)}
624
624
  (local.set $sid (call $__jp_schema_get (local.get $kbuf) (i32.const 0) (local.get $hh)))
625
625
  (return (call $__mkptr (i32.const ${PTR.OBJECT}) (local.get $sid)
626
- (call $__alloc_hdr (i32.const 0) (i32.const 1) (i32.const 8))))))
626
+ (call $__alloc_hdr (i32.const 0) (i32.const 1))))))
627
627
  (block $d (loop $l
628
628
  ${WS()}
629
629
  (if (i32.eq ${PEEK} (i32.const 34))
@@ -661,7 +661,7 @@ export default (ctx) => {
661
661
  (local.set $sid (call $__jp_schema_get (local.get $kbuf) (local.get $kn) (local.get $hh)))
662
662
  ;; Allocate OBJECT slot region: kn × 8 bytes, with header (size at -8,
663
663
  ;; cap at -4) matching the static-fold path's emitJsonConstValue layout.
664
- (local.set $obj (call $__alloc_hdr (i32.const 0) (local.get $kn) (i32.const 8)))
664
+ (local.set $obj (call $__alloc_hdr (i32.const 0) (local.get $kn)))
665
665
  ;; Copy values into OBJECT slots.
666
666
  (local.set $i (i32.const 0))
667
667
  (block $vd (loop $vl
@@ -749,7 +749,7 @@ export default (ctx) => {
749
749
  const sid = ctx.schema.register(keys)
750
750
  let body = `${WS()}
751
751
  ${expect(123)}
752
- (local.set $${obj} (call $__alloc_hdr (i32.const 0) (i32.const ${Math.max(1, keys.length)}) (i32.const 8)))`
752
+ (local.set $${obj} (call $__alloc_hdr (i32.const 0) (i32.const ${Math.max(1, keys.length)})))`
753
753
  keys.forEach((k, i) => {
754
754
  body += `
755
755
  ${WS()}
package/module/math.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * @module math
14
14
  */
15
15
 
16
- import { typed, asF64, asI32, temp, arrayLoop } from '../src/ir.js'
16
+ import { typed, asF64, asI32, toI32, temp, arrayLoop } from '../src/ir.js'
17
17
  import { emit } from '../src/emit.js'
18
18
  import { inc } from '../src/ctx.js'
19
19
  import { repOf } from '../src/analyze.js'
@@ -76,7 +76,22 @@ export default (ctx) => {
76
76
  for (const x of rest) r = typed(['f64.max', r, asF64(emit(x))], 'f64')
77
77
  return r
78
78
  }
79
- ctx.core.emit['math.round'] = a => fInt('f64.nearest', a)
79
+ // f64.nearest is roundTiesToEven; JS Math.round is roundTiesToward+∞. They agree
80
+ // everywhere except exact half-integers n+0.5 with n even (nearest→n, JS→n+1).
81
+ // Detect that one case — `nearest(x) === x - 0.5` — and bump by one. (The −0.5→−0
82
+ // and 0.49999…94→0 edges already match `f64.nearest`.)
83
+ ctx.core.emit['math.round'] = a => {
84
+ if (typeof a === 'string' && repOf(a)?.intCertain === true) return asF64(emit(a))
85
+ const t = temp('rnd'), n = temp('rnd')
86
+ return typed(['block', ['result', 'f64'],
87
+ ['local.set', `$${t}`, asF64(emit(a))],
88
+ ['local.set', `$${n}`, ['f64.nearest', ['local.get', `$${t}`]]],
89
+ ['select',
90
+ ['f64.add', ['local.get', `$${n}`], ['f64.const', 1]],
91
+ ['local.get', `$${n}`],
92
+ ['f64.eq', ['local.get', `$${n}`], ['f64.sub', ['local.get', `$${t}`], ['f64.const', 0.5]]]],
93
+ ], 'f64')
94
+ }
80
95
  ctx.core.emit['math.fround'] = a => typed(['f64.promote_f32', ['f32.demote_f64', asF64(emit(a))]], 'f64')
81
96
 
82
97
  // Sign
@@ -129,8 +144,10 @@ export default (ctx) => {
129
144
  // Integer/bit operations: return i32 directly. Consumers `asF64`-rebox at
130
145
  // store/return boundaries; consumers staying in i32 (bit chains, i32 locals)
131
146
  // skip the convert/trunc round-trip entirely.
132
- ctx.core.emit['math.clz32'] = a => typed(['i32.clz', asI32(emit(a))], 'i32')
133
- ctx.core.emit['math.imul'] = (a, b) => typed(['i32.mul', asI32(emit(a)), asI32(emit(b))], 'i32')
147
+ // Operands take ECMAScript ToInt32 (wrapping), not saturation — `Math.imul(x, k)`
148
+ // with a literal k 2³¹ must wrap to negative, matching JS, not clamp to INT_MAX.
149
+ ctx.core.emit['math.clz32'] = a => typed(['i32.clz', toI32(emit(a))], 'i32')
150
+ ctx.core.emit['math.imul'] = (a, b) => typed(['i32.mul', toI32(emit(a)), toI32(emit(b))], 'i32')
134
151
 
135
152
  // Random
136
153
  ctx.core.emit['math.random'] = () => (inc('math.random'), typed(['call', '$math.random'], 'f64'))