Skip to content

ZIPP v0.0.25

Choose a tag to compare

@github-actions github-actions released this 15 Oct 12:24
· 150 commits to main since this release

ZIPP 0.0.25

Fixes for the ten issues @takahashim reported since 0.0.24 (#46 to #55)
and for further wrong results; an embedding API that frees
the code a state evaluated, recovers from its heap ceiling and keeps
script globals in their slots; and a faster interpreter. In the
safe-sandbox profile that embedders and the WebAssembly modules build on,
the 13 benchmark rows take 0.58x of 0.0.24's time, 0.84x of Bellard's
QuickJS's (0.0.24: 1.44x), with about half 0.0.24's peak memory; the
native CLI's interpreter takes 0.85x of 0.0.24's time. With its JIT the
native CLI is level with 0.0.24, and a new profile-guided capture puts it
at 0.795x of Node 24's time on the headline ten and 0.566x on all 13
rows, faster than Node on each. The WebAssembly modules are 0.6% to 1.8%
larger.

Thank you, @takahashim, for the ten reports behind this release's first
ten fixes, each with a minimal reproduction and, for the slow paths, a
measurement. The embedding they describe (the safe-sandbox profile with
limits on, scripts run through eval_in_context, a Proxy on the global's
prototype chain, wrappers kept in WeakMaps) set most of the performance
work below.

Downloads

  • zipp-0.0.25-x86_64-pc-windows-msvc.zip: native CLI for Windows, complete: JavaScript, Python, torch and native GPU.
  • zipp-0.0.25-x86_64-unknown-linux-gnu.tar.gz: the same native CLI for Linux.
  • zipp-wasm-0.0.25-web.zip: JavaScript-only browser engine, TypeScript bindings and Worker host SDK.
  • zipp-wasm-0.0.25-web-lite.zip: ZIPP Lite, the JavaScript core without Intl and Temporal, built for size, with the same bindings and host SDK.
  • zipp-wasm-0.0.25-web-python-base.zip: JavaScript + Python without torch; add torch from web-torch when a page needs it.
  • zipp-wasm-0.0.25-web-python.zip: JavaScript + Python complete, torch built in, with the browser GPU adapter and CPU WASM kernels.
  • zipp-wasm-0.0.25-web-torch.zip: the torch package, zipp_torch.wasm and its zipp_torch.js loader, added to web-python-base through addPythonPackage.
  • SHA256SUMS for every asset.

Every WASM archive includes BUILD-INFO.txt, PROFILE.json and SHA256SUMS.
Publishing requires CI and the full security workflow at the exact tagged commit.

Use the web-torch package of the same release as web-python-base.

Fixes

Reported by @takahashim. Each has a regression test, in
crates/zipp-vm/tests/reported_semantics_bugs.rs unless another file is
named; the tests that print are checked against Node 24's output.

  • A write to a builtin on the global object reached a Proxy on the
    global's prototype chain
    (#46). Builtins and a script's var and
    function declarations are own properties of the global object, but they
    live in slots, so Reflect.set(globalThis, "Math", Math) and
    globalThis.JSON = JSON asked the chain instead: the Proxy's set trap
    received them and could swallow them, and globalThis.declared = 2
    never reached a declared binding. Both paths now see the slot as the own
    property it is.
  • A sloppy assignment to an unresolvable name skipped a set trap on
    the global's prototype chain
    (#47). It is a [[Set]] on the global
    object, which walks the chain; when the chain holds a Proxy the store
    now goes through it, so its set trap runs even when its has trap
    denied the name. With an ordinary chain nothing changes.
  • A promise's resolving functions did not inherit Function.prototype
    (#48). The resolve and reject an executor receives, and the element
    functions of Promise.all, allSettled and any, read call, apply
    and bind as undefined; a property assigned to one was never read back;
    delete, defineProperty, enumeration and in missed it; and
    Function.prototype.toString threw for one. They now behave as the
    built-in function objects they are, and console.log prints them as
    Node does.
  • WeakMap#delete made the next lookup rebuild the index (#49), so
    alternating delete and get cost O(n) each. A delete now removes one
    entry from the index, as a Map's does, and the collector removes a weak
    collection's dead entries the same way.
  • Global reads slowed down as a realm grew (#50): naming an
    eval-introduced global scanned every one of them; it is one lookup now.
    In the safe-sandbox and WebAssembly profiles the report's program could
    not run at all: they held only 1,024 globals created at run time (see
    below).
  • Re-assigning a builtin on the global object made every later read of
    that name slower
    (#51). A script's var and function declarations, a
    value published as globalThis.x = v (the report's globalThis.Math = Math), and a property the global object held before script code named
    it now stay in their slots, presented as the data properties they are,
    so compiled code can read them directly. Per million operations from
    script code with the JIT (Node: 0 to 1 ms): reading a published global
    44 ms to 0, calling a published function 70 to 2, writing a script
    var 137 to 0.
  • Merely naming eval made a function's global accesses dynamic
    (#52), with every local boxed: const indirect = eval; was enough, for
    the function and every function nested in it. Only a call whose callee
    is the identifier eval can be a direct eval, and only that now counts.
  • Code compiled by new Function or eval was never freed (#53),
    even without the JIT, so a host that evaluated a runtime into a state per
    page grew by about 1.2 MB a page. A state's evaluated code now lives in
    an arena dropped with the state: 400 functions evaluated into 20 states,
    each then dropped, grew the heap by 9.8 MB and now by under 256 KB
    (tests/eval_code_freed.rs). Inside one state, a repeated new Function source is compiled once: 20,000 calls with the report's
    200-parameter source peaked at 279 MB and took 9.0 s, and now 43.5 MB
    and 0.74 s (Node: 47 MB). Each call still returns a distinct function.
  • A top-level let was not initialized when a Proxy was on the global's
    prototype chain
    (#54). This one was never released: the fix for #47
    introduced it on main after 0.0.24, sending the declaration's store
    through the chain, and @takahashim caught it there. A store into an
    uninitialized script let/const is now always its initialization,
    and a read before it stays in the temporal dead zone.
  • After the heap limit was reached, dropping references did not let the
    VM allocate again
    (#55): the ceiling was enforced against the heap's
    all-time peak, so space the collector freed never counted again. It is
    enforced against the live heap now, a VM over its ceiling collects once
    before deciding, and re-arming the limit between entries collects a heap
    past half of it. The report's sequence succeeds each round; a heap that
    stays at the ceiling is still stopped (tests/heap_ceiling_recovery.rs).
    ScriptState::heap_bytes still reports the peak.

The reports' own reproductions, in milliseconds, 0.0.24 against this
release on one Windows machine (median of three runs): the native CLI with
its JIT, the same CLI as an interpreter (ZIPP_NOJIT=1), and the
safe-sandbox embedding with limits on.

Reproduction Mode 0.0.24 0.0.25 Node 24 (one run)
#49: 2,000 delete/get pairs over 1,000 / 10,000 / 50,000 entries JIT 2 / 94 / 619 0 / 0 / 0 0 / 0 / 0
interpreter 3 / 94 / 619 0 / 0 / 1
safe-sandbox 26 / 1,324 / 8,168 1 / 0 / 0
#50: a million global reads, before / after 5,000 more script globals JIT 35 / 1,357 1 / 0 2 / 1
interpreter 36 / 1,080 15 / 15
safe-sandbox fails at 1,024 globals 12 / 12
#51: 3 million Math.PI reads, before / after globalThis.Math = Math JIT 3 / 145 3 / 2 3 / 1
interpreter 46 / 131 47 / 47
safe-sandbox 48 / 166 35 / 36
#52: the same loop, plain / in a function holding const indirect = eval JIT 2 / 145 3 / 2 3 / 2
interpreter 46 / 140 46 / 47
safe-sandbox 47 / 198 36 / 35

Found by our own testing:

  • The safe-sandbox and WebAssembly profiles held only 1,024 globals
    created at run time
    : those an indirect eval or new Function
    introduces, the var and function declarations of every script run
    through eval_in_context, and module bindings. Past them every new one
    threw "RangeError: too many global bindings", so #50's reproduction
    (5,000 script globals) failed there, in 0.0.24 too. Those profiles now
    hold 16,384, as the native one does; they still reserve 1,024 slots up
    front and grow into the rest, so a fresh VM's memory is unchanged
    (tests/eval_global_pool.rs).
  • Native CLI: arr.push = fn; arr.push(x) in a hot loop appended to
    the array
    instead of calling fn; the compiled push lane missed an
    own push. tests/array_own_method_shadow.rs runs 25 Array methods
    hot with an own shadow, against Node.
  • The global object's slot-backed bindings ignored its integrity
    level
    : after Object.freeze(globalThis) every var, function,
    implicit global and builtin stayed writable and configurable. And a
    strict x = v or x++ to a declared global wrote its slot even after
    Object.defineProperty(globalThis, "x", {writable: false}), where Node
    throws a TypeError.
  • globalThis.x = 1; $262.evalScript("const x = 2") wrote 2 into the
    property and left the const uninitialized; and Object.keys(globalThis)
    left out the main program's implicit globals.
  • A write far past an array's end filled the gap with holes, 8 bytes
    each, up to the 4M-element (safe-sandbox) or 1M cap: a[70000] = 1
    on an empty array allocated 560 KB. An element more than 1,024 holes
    past the end now goes to the sparse side table with a virtual length,
    as in V8, and the array turns dense again once its elements would fill
    half the storage. In the safe-sandbox embedding the sparse-array-v2
    row's heap peak went from 48 MB in 0.0.24 to 10 MB, sparse-array's
    from 79 MB to 26 MB.
  • Garbage piled up in an embedding without a reporting allocator
    (every safe-sandbox embedding, since a reporting GlobalAlloc needs
    unsafe): collections ran on slot counts alone. Sixteen rounds of
    2,000 strings of 1.5 KB collected nothing and held 94 MB; objects given
    their properties one by one, and arrays filled by push, were not
    counted at all, so parse-large-js peaked at a 109 MB working set
    (Bellard's QuickJS: 37). Allocated payload, properties added in place
    and, under a heap ceiling, arrays grown in place now count toward the
    schedule: the same strings collect after 16 MB and parse-large-js
    peaks at 52 MB.
  • Under a heap ceiling, a large string or JSON builder walked the heap
    quadratically
    : regex-log-scan's 150,000-part join took 1,758 ms with
    a 1 GiB ceiling, and takes 24 ms.

Other changes a program can see

  • Script globals live in the global object's slots. The global
    object presents them as the same data properties, but
    Object.keys(globalThis) and for-in list them after its other own
    properties, where Node keeps creation order. An implicit global a script
    created reports enumerable: true, as in Node. A later script's
    let/const over an implicit global of the same name now declares, as
    in Node, instead of throwing.
  • eval, new Function and eval_in_context code reaches the native
    CLI's JIT
    (ZIPP_NO_EVAL_JIT=1 keeps it interpreted). A VM with
    limits set (set_limits) keeps such code in the interpreter, so step
    budgets and heap ceilings apply to it exactly as before.
  • Collections can come at different points in an embedding without a
    reporting allocator, so FinalizationRegistry callbacks and WeakRef
    clearing can run earlier than in 0.0.24.
  • Embedding API: ScriptState::runtime_function_count reports the
    functions a state has compiled at run time; set_heap_limit collects a
    heap past half of the new limit while no frame runs.
  • Same-binary switches added: ZIPP_NO_EVAL_JIT,
    ZIPP_NO_JS_FRAME_NATIVE (a compiled getter or call enters the callee's
    compiled body directly; this restores the interpreter-loop entry).

Embedding: the safe-sandbox profile

An embedding built with the safe-sandbox profile (no unsafe, no JIT:
the profile of @takahashim's embedding, and the one the WebAssembly
modules build on), counting steps against a budget and with a 1 GiB heap
ceiling set, ran
the 13 benchmark rows through the embedding API (compile_script,
run_init). One Windows machine, every engine pinned to the same eight
cores, median of five runs of the whole process:

Row Bellard's QuickJS QuickJS-NG ZIPP 0.0.24 ZIPP 0.0.25 0.0.25 / 0.0.24
async-promise-chain 4,080 ms 3,944 ms 627 ms 542 ms 0.86
class-prototype-hot 3,831 ms 7,283 ms 5,220 ms 4,435 ms 0.85
json-large 777 ms 1,471 ms 1,087 ms 781 ms 0.72
map-set-heavy 1,473 ms 2,202 ms 2,285 ms 983 ms 0.43
markdown-render 1,055 ms 2,075 ms 1,644 ms 882 ms 0.54
parse-large-js 2,003 ms 3,647 ms 3,045 ms 2,041 ms 0.67
polymorphic-objects 863 ms 1,408 ms 2,871 ms 1,213 ms 0.42
polymorphic-objects-v2 402 ms 846 ms 768 ms 569 ms 0.74
property-ic-shapes 2,021 ms 5,177 ms 4,233 ms 3,049 ms 0.72
regex-log-scan 2,516 ms 4,164 ms 5,471 ms 2,384 ms 0.44
sparse-array 385 ms 813 ms 754 ms 366 ms 0.49
sparse-array-v2 1,846 ms 875 ms 1,148 ms 587 ms 0.51
typedarray-math 3,941 ms 6,885 ms 12,359 ms 5,624 ms 0.46
  • Time: 0.583x of 0.0.24's (geometric mean). Against Bellard's
    QuickJS 2026-06-04, 0.843x of its time, faster on six rows, where
    0.0.24 took 1.444x and was faster on two; against QuickJS-NG 0.17.0,
    0.525x, faster on all 13 (0.0.24: 0.899x, eight). Bellard's QuickJS is
    still faster on the property-heavy rows (property-ic-shapes 1.51x,
    the two polymorphic rows 1.41x, class-prototype-hot 1.16x) and on
    typedarray-math (1.43x); QuickJS meters nothing, while these runs
    count every step.
  • Memory: the peak working set is 1.18x Bellard's QuickJS's
    (geometric mean), where 0.0.24's was 2.16x: polymorphic-objects 249
    MB to 14 MB (QuickJS: 7), parse-large-js 109 MB to 52 (37),
    markdown-render 62 MB to 35 (26), map-set-heavy 248 MB to 183
    (140), sparse-array 70 MB to 30 (54).

Where the time went: Map, Set, WeakMap and WeakSet look keys up through
short hash chains, falling back to the tree only when a chain would grow
past 16 entries; an object's property index is ordered by key hash
rather than by cloned key strings; the step meter counts down to the next
due event (its cost on property-ic-shapes went from 11% to about 5%);
under a heap ceiling, a builder's appends no longer schedule heap walks
quadratically, and the walk sizes an object in constant time; small
integer ===, boolean branches, i in arr on a hole, hasOwnProperty
with a string key, Math.max/min/hypot, Math.imul on two int32s,
bitwise operators on doubles, DataView getters and other intrinsic
method reads are answered in place; for-in over an array lists its own
keys directly; and 96 instructions that none of the measured workloads
dispatches moved out of the interpreter's loop.

WebAssembly size

The modules are larger than 0.0.24's, by 0.6% to 1.8% after Brotli: this
release's fixes and interpreter work added code, and no size pass followed
them. Module sizes on a Windows build of 3e8a5d07, the
release's code, raw and Brotli-11, beside 0.0.24's source (35e6d1dc)
built the same way:

Module 0.0.24 0.0.25 Larger by, raw / Brotli
JavaScript 4,435,704 / 1,133,411 4,465,884 / 1,146,012 0.7% / 1.1%
Lite 2,450,334 / 671,251 2,491,268 / 683,540 1.7% / 1.8%
Python without torch 6,146,540 / 1,524,284 6,186,350 / 1,535,746 0.6% / 0.8%
Complete, torch built in 8,178,282 / 1,898,530 8,219,147 / 1,909,367 0.5% / 0.6%

The torch package's source is unchanged (about 0.40 MB after Brotli).

WebAssembly speed

The browser engines interpret, in the checked profile. The size pass's
screen under Node 24 (24 cases, 10 warm-up pairs and 31 alternating paired
samples, both orders) against 0.0.24's modules reads level: Lite 0.995 of
0.0.24's time and the JavaScript module 0.998. Some cases are faster
(array-hof 0.85 and promise-resolve 0.83 in the JavaScript module) and
some slower: run alone in fresh processes, three times in each order,
Lite's prop-mono takes about 1.08 of 0.0.24's time, the JavaScript
module's prop-poly 1.07 and alloc-object 1.06, Lite's prop-poly and
the JavaScript module's error-range 1.03 to 1.04. The benchmark rows of
the section above were not run on the WebAssembly modules for this
release.

Native CLI

  • With the JIT, level with 0.0.24. Ordinary release builds of 0.0.24's
    source and this release's, paired, 12 repetitions: the 13 rows take
    0.994x of 0.0.24's time (95% interval 0.988 to 1.002), every row
    printing the same bytes; sparse-array takes 4.5% less time,
    sparse-array-v2 2.7% less.
  • Against Node, Bun and Deno, a new canonical capture (a
    profile-guided build of fa8e3b86, before this release's last four
    code commits, which change the interpreter loop and the safe-sandbox
    global pool; 15 counterbalanced repetitions, each engine with its
    production JIT): the headline ten at 0.795x of Node 24's time, 0.663x of Bun's and
    0.701x of Deno's; all 13 normal rows at 0.566x of Node's, faster than
    Node on every one; the 17 hostile rows at 0.829x; all 30 at 0.703x,
    faster than Node on 23. The seven slower rows are hostile: closure
    calls (1.11x), the two shape stressors (1.08x, 1.13x), allocation
    survival (1.37x), React reconcile (1.28x), the warm router (1.38x), and
    the JavaScript bytecode VM (3.13x), which has been slower since early
    September (between the 2 and 11 September captures; not yet traced).
  • As an interpreter (ZIPP_NOJIT=1, ordinary builds, pinned, five
    runs): the 13 rows take 0.846x of 0.0.24's time, and 0.805x of Bellard's
    QuickJS's, faster on six (0.0.24: 0.952x, three). typedarray-math
    takes 44% less time than in 0.0.24, sparse-array 30% less,
    parse-large-js, markdown-render and regex-log-scan 23% to 25%
    less. The micro kernels remain behind: recursive fib at 1.80x of
    Bellard's QuickJS's time. The README's interpreter table (the
    profile-guided build above) also compares QuickJS-NG and Node, Deno and
    Bun with their JITs off.

Python and torch

Unchanged: over the Python benchmark suite's 44 programs this release
takes 1.00x of 0.0.24's time, and on the 36 that CPython also runs 1.10x
of CPython 3.13's (0.0.24: 1.09x). The 195 programs of
tests/python_corpus print what CPython 3.13 prints, except the five
that need a GPU or a transport. The torch package's source is unchanged.

Validation

Test262 passes with the same nine documented expected failures as before
(95,671 of 95,680 executions). Locally, on the release's code: the
default zipp-vm suite (349 test binaries), the Python and safe-sandbox
suites, five -D warnings profiles, the whole workspace as CI builds it,
the Python corpus in four modes, and the WebAssembly host boundary suite
(36 of 36) against the release's JavaScript module. The regression tests
for #46 to #55 are in crates/zipp-vm/tests/reported_semantics_bugs.rs,
heap_ceiling_recovery.rs and eval_code_freed.rs, and the pool's in
eval_global_pool.rs. These figures are from one Windows machine; the
measurements, the gates and what was not measured are in the
validation notes.

Publishing

As for 0.0.24, the engines the site serves are refreshed AFTER this release
exists, in one commit that:

  1. sets zippEngineRelease in landing/package.json to 0.0.25;
  2. refreshes landing/public/playground-runtime from the
    zipp-wasm-0.0.25-web-python asset (sync-playground.mjs --refresh-engine)
    and landing/public/wasm from zipp-wasm-0.0.25-web, both SHA256SUMS-verified;
  3. updates the version, size and SHA-256 that landing/src/App.tsx and
    landing/README.md state about the bundled engine.

Full Changelog: v0.0.24...v0.0.25