jz 0.3.1 → 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 |
@@ -86,8 +86,7 @@ jz program.js # → program.wasm
86
86
  jz program.js --wat # → program.wat
87
87
  jz program.js -o out.wasm # custom output (- for stdout)
88
88
 
89
- # Optimization level: -O0 off, -O1 size-only, -O2 default, -O3 aggressive
90
- # aliases: -Os/--optimize size, -Ob/balanced, -Of/speed
89
+ # Optimization level: -O0 off, -O1 size, -O2 balanced, -O3 speed
91
90
  jz program.js -O3
92
91
 
93
92
  # Runtime-service lowering: js (default) or wasi
@@ -100,7 +99,6 @@ jz -e "1 + 2" # 3
100
99
  jz --help
101
100
  ```
102
101
 
103
- Other flags: `--strict` (no auto-`jzify`, reject dynamic fallbacks), `--jzify` (transform JS → jz, no compile), `--no-alloc` (omit `_alloc`/`_clear`), `--names` (emit wasm `name` section), `--resolve` (Node.js bare-specifier resolution), `--imports <file.json>` (host import specs).
104
102
 
105
103
  ## Language
106
104
 
@@ -478,18 +476,19 @@ cc program.c -o program
478
476
 
479
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/) |
480
478
  |---|---|---|---|---|---|---|---|---|---|---|
481
- | [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 |
482
- | [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 |
483
- | [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 |
484
- | [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 |
485
- | [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 | — |
486
- | [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 |
487
- | [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 |
488
- | [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 |
489
- | [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 |
490
- | [watr](bench/watr/watr.js) | 1.04ms<br>169.8kB | 1.05ms<br>2.6kB | fails | | — | | | | | |
491
-
492
- _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.
493
492
 
494
493
  <details>
495
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)
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'))