Skip to content

js backend: lower %bytes.unsafe_{read,write}_uint64_{le,be} to DataView get/setBigUint64 (12-18x) #4098

Description

@bobzhang

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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions