Repository navigation
ZIPP v0.0.25
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 withoutIntlandTemporal, 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 fromweb-torchwhen 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.wasmand itszipp_torch.jsloader, added toweb-python-basethroughaddPythonPackage.SHA256SUMSfor 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'svarand
function declarations are own properties of the global object, but they
live in slots, soReflect.set(globalThis, "Math", Math)and
globalThis.JSON = JSONasked the chain instead: the Proxy'ssettrap
received them and could swallow them, andglobalThis.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
settrap 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 itssettrap runs even when itshastrap
denied the name. With an ordinary chain nothing changes. - A promise's resolving functions did not inherit
Function.prototype
(#48). Theresolveandrejectan executor receives, and the element
functions ofPromise.all,allSettledandany, readcall,apply
andbindas undefined; a property assigned to one was never read back;
delete,defineProperty, enumeration andinmissed it; and
Function.prototype.toStringthrew for one. They now behave as the
built-in function objects they are, andconsole.logprints them as
Node does. WeakMap#deletemade the next lookup rebuild the index (#49), so
alternatingdeleteandgetcost 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'svarand function declarations, a
value published asglobalThis.x = v(the report'sglobalThis.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
var137 to 0. - Merely naming
evalmade 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 identifierevalcan be a direct eval, and only that now counts. - Code compiled by
new Functionorevalwas 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 repeatednew Functionsource 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
letwas not initialized when a Proxy was on the global's
prototype chain (#54). This one was never released: the fix for #47
introduced it onmainafter 0.0.24, sending the declaration's store
through the chain, and @takahashim caught it there. A store into an
uninitialized scriptlet/constis 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_bytesstill 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 indirectevalornew Function
introduces, thevarand function declarations of every script run
througheval_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 callingfn; the compiled push lane missed an
ownpush.tests/array_own_method_shadow.rsruns 25 Array methods
hot with an own shadow, against Node. - The global object's slot-backed bindings ignored its integrity
level: afterObject.freeze(globalThis)everyvar, function,
implicit global and builtin stayed writable and configurable. And a
strictx = vorx++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 theconstuninitialized; andObject.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 thesparse-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 reportingGlobalAllocneeds
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 bypush, were not
counted at all, soparse-large-jspeaked 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 andparse-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)andfor-inlist them after its other own
properties, where Node keeps creation order. An implicit global a script
created reportsenumerable: true, as in Node. A later script's
let/constover an implicit global of the same name now declares, as
in Node, instead of throwing. eval,new Functionandeval_in_contextcode reaches the native
CLI's JIT (ZIPP_NO_EVAL_JIT=1keeps 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, soFinalizationRegistrycallbacks andWeakRef
clearing can run earlier than in 0.0.24. - Embedding API:
ScriptState::runtime_function_countreports the
functions a state has compiled at run time;set_heap_limitcollects 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-shapes1.51x,
the two polymorphic rows 1.41x,class-prototype-hot1.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-objects249
MB to 14 MB (QuickJS: 7),parse-large-js109 MB to 52 (37),
markdown-render62 MB to 35 (26),map-set-heavy248 MB to 183
(140),sparse-array70 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-arraytakes 4.5% less time,
sparse-array-v22.7% less. - Against Node, Bun and Deno, a new canonical capture (a
profile-guided build offa8e3b86, 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-array30% less,
parse-large-js,markdown-renderandregex-log-scan23% to 25%
less. The micro kernels remain behind: recursivefibat 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:
- sets
zippEngineReleaseinlanding/package.jsonto0.0.25; - refreshes
landing/public/playground-runtimefrom the
zipp-wasm-0.0.25-web-pythonasset (sync-playground.mjs --refresh-engine)
andlanding/public/wasmfromzipp-wasm-0.0.25-web, both SHA256SUMS-verified; - updates the version, size and SHA-256 that
landing/src/App.tsxand
landing/README.mdstate about the bundled engine.
Full Changelog: v0.0.24...v0.0.25