Summary
On the js backend, %bytes.unsafe_read_uint64_{le,be} and %bytes.unsafe_write_uint64_{le,be} are not lowered — the MoonBit fallback bodies in builtin/bytes_unsafe.mbt are emitted as real functions and called. Since UInt64 compiles to BigInt on js, those fallbacks are 12–18× slower than what the backend could emit with DataView.getBigUint64 / setBigUint64.
This needs no core change. The four intrinsic names are already declared, native already lowers them, and the fallback bodies must stay anyway for backends that don't. The ask is purely that the js backend start lowering these four existing intrinsics.
Measurements
Prototyped with extern "js" and measured with moon bench --release, 4096-byte buffer swept at every valid offset (~4090 ops), 2 runs in agreement:
| operation |
current (fallback) |
DataView, cached |
|
Bytes::unsafe_read_uint64_be |
451.2 µs |
35.8 µs |
12.6× faster |
FixedArray::unsafe_write_uint64_le |
556.8 µs |
30.7 µs |
18.2× faster |
Even the naive lowering — new DataView(...) constructed per call, no caching — measures 182.4 µs on the read, still 2.5× faster than today. So caching is an optimization, not a precondition for the win.
Why 64-bit only
The same experiment on the narrower widths goes the other way, and it should not be done there:
| operation |
current |
DataView, cached |
|
Bytes::unsafe_read_uint32_le |
3.77 µs |
29.7 µs |
7.9× slower |
Bytes::unsafe_read_uint16_le |
2.36 µs |
30.1 µs |
12.8× slower |
A DataView accessor call costs roughly 7.3 ns regardless of width — every DataView row above lands at ~30 µs. That is a bargain against the 64-bit path's ~110 ns/read and ~136 ns/write of BigInt churn, and a disaster against the 16/32-bit paths, which are plain Uint8Array indexing and constant shifts at 0.58–0.92 ns/op after #4097.
Control: the extern "js" boundary itself is free. A minimal extern doing one index read measures 2.38 µs against 2.41 µs for the identical read written in MoonBit — V8 inlines the extern closure completely — so the numbers above are real DataView costs and would persist as compiler-emitted code.
Why the current output is so expensive
Read, per 8-byte value — 7 intermediate BigInt allocations, 14 BigInt.asUintN(64, …) masks, and a BigInt allocated for each constant shift amount:
BigInt.asUintN(64, BigInt.asUintN(64, /* … 7 deep … */
to__uint64(bytes[index]) | BigInt.asUintN(64, to__uint64(bytes[index + 1 | 0]) << BigInt(8 & 63))) | …)
Write is worse — a BigInt→Number conversion per byte, eight per call:
bytes[index] = (Number(BigInt.asIntN(32, value)) | 0) & 255;
getBigUint64 allocates exactly one BigInt (the result) and does the byte assembly in C++.
Implementation notes
- Both receiver types are typed arrays at runtime, so one emission covers them:
Bytes is a Uint8Array ($makebytes returns new Uint8Array(a)), and I confirmed FixedArray[Byte] also exposes .buffer. Note the read intrinsic is declared on Bytes and reused by the private fixedarray_read_uint64_{le,be} helpers on FixedArray[Byte] (builtin/bytes_unsafe.mbt:641,655), so both need to lower.
- Index with
bytes.byteOffset + index, not bare index — subarray-derived views have a non-zero byteOffset.
- Cache the
DataView. A last-buffer guard (if (bytes.buffer !== lastBuf) { … }) is what I measured and is enough for monomorphic loops; a WeakMap keyed on the buffer would be more general.
DataView handles unaligned offsets natively, which matters — these reads are unaligned by nature.
- Semantic difference worth a deliberate decision:
DataView throws RangeError past the end of the buffer, whereas the current fallback silently reads undefined. Arguably an improvement, but it is a behaviour change to an explicitly-unsafe API, so it should be chosen rather than inherited.
Context
Found while investigating why Hasher::combine_bytes regressed on wasm-gc in #3278, which led to #4097 (unrolling all 18 intrinsic fallback bodies). #4097 took the 64-bit js helpers from 386.8 µs to 342.5 µs on the read — real, but ~11%, because the loop was never the bottleneck there. DataView is what actually closes that gap.
Summary
On the js backend,
%bytes.unsafe_read_uint64_{le,be}and%bytes.unsafe_write_uint64_{le,be}are not lowered — the MoonBit fallback bodies inbuiltin/bytes_unsafe.mbtare emitted as real functions and called. SinceUInt64compiles toBigInton js, those fallbacks are 12–18× slower than what the backend could emit withDataView.getBigUint64/setBigUint64.This needs no core change. The four intrinsic names are already declared, native already lowers them, and the fallback bodies must stay anyway for backends that don't. The ask is purely that the js backend start lowering these four existing intrinsics.
Measurements
Prototyped with
extern "js"and measured withmoon bench --release, 4096-byte buffer swept at every valid offset (~4090 ops), 2 runs in agreement:DataView, cachedBytes::unsafe_read_uint64_beFixedArray::unsafe_write_uint64_leEven the naive lowering —
new DataView(...)constructed per call, no caching — measures 182.4 µs on the read, still 2.5× faster than today. So caching is an optimization, not a precondition for the win.Why 64-bit only
The same experiment on the narrower widths goes the other way, and it should not be done there:
DataView, cachedBytes::unsafe_read_uint32_leBytes::unsafe_read_uint16_leA
DataViewaccessor call costs roughly 7.3 ns regardless of width — everyDataViewrow above lands at ~30 µs. That is a bargain against the 64-bit path's ~110 ns/read and ~136 ns/write of BigInt churn, and a disaster against the 16/32-bit paths, which are plainUint8Arrayindexing and constant shifts at 0.58–0.92 ns/op after #4097.Control: the
extern "js"boundary itself is free. A minimal extern doing one index read measures 2.38 µs against 2.41 µs for the identical read written in MoonBit — V8 inlines the extern closure completely — so the numbers above are realDataViewcosts and would persist as compiler-emitted code.Why the current output is so expensive
Read, per 8-byte value — 7 intermediate BigInt allocations, 14
BigInt.asUintN(64, …)masks, and a BigInt allocated for each constant shift amount:Write is worse — a BigInt→Number conversion per byte, eight per call:
getBigUint64allocates exactly one BigInt (the result) and does the byte assembly in C++.Implementation notes
Bytesis aUint8Array($makebytesreturnsnew Uint8Array(a)), and I confirmedFixedArray[Byte]also exposes.buffer. Note the read intrinsic is declared onBytesand reused by the privatefixedarray_read_uint64_{le,be}helpers onFixedArray[Byte](builtin/bytes_unsafe.mbt:641,655), so both need to lower.bytes.byteOffset + index, not bareindex— subarray-derived views have a non-zerobyteOffset.DataView. A last-buffer guard (if (bytes.buffer !== lastBuf) { … }) is what I measured and is enough for monomorphic loops; aWeakMapkeyed on the buffer would be more general.DataViewhandles unaligned offsets natively, which matters — these reads are unaligned by nature.DataViewthrowsRangeErrorpast the end of the buffer, whereas the current fallback silently readsundefined. Arguably an improvement, but it is a behaviour change to an explicitly-unsafe API, so it should be chosen rather than inherited.Context
Found while investigating why
Hasher::combine_bytesregressed on wasm-gc in #3278, which led to #4097 (unrolling all 18 intrinsic fallback bodies). #4097 took the 64-bit js helpers from 386.8 µs to 342.5 µs on the read — real, but ~11%, because the loop was never the bottleneck there.DataViewis what actually closes that gap.