Skip to content

v0.3.0 - A wasm backend in Java, Ruby, Python and Go

Choose a tag to compare

@buvinghausen buvinghausen released this 04 Sep 00:47
· 72 commits to master since this release

The core built as a wasm32-wasip1 module, hyperuuid.wasm, ships beside the native libraries in the jar, the gems and the wheels, and is committed under go/native/. Each binding runs it behind its existing backend switch, with the engine an optional dependency the consumer adds only if they want this path:

  • Java — GraalWasm. -Dhyperuuid.backend=wasm, or automatic when the jar has no native build for the platform. org.graalvm.polyglot:wasm is compileOnly and never reaches the POM; UuidGenerator.backend() reports which path won.
  • Ruby — the wasmtime gem. HYPERUUID_WASM=1, or automatic when no native library exists for the platform. HyperUuid::BACKEND reports :wasm; spec/wasm_backend_spec.rb pins the outputs byte-for-byte against Fiddle.
  • Python — wasmtime-py via pip install hyperuuid[wasm]. HYPERUUID_WASM=1, or automatic when the PyO3 extension fails to import. hyperuuid.BACKEND reports "wasm" or "native".
  • Go — wasmtime-go behind -tags hyperuuid_wasm. Opt-in only, never selected automatically; the tag compilo throughout, so no win-arm64 build.

Measured on one box, through each shipped binding:

Binding new_v7 wasm new_v7 native 1000-UUID byte fill wasm native
Java (GraalVM JIT) 420 ns 64 ns
Java (Native Image) 181 ns — — —
Ruby 867 ns ~450 ns 40.6 µs 24 µs
Python 6.2 µs 850 ns 41 µs 18.
Go 3.1 µs 142 ns 41 µs 17.6 µs

On a stock JDK with no GraalVM JIT the modulwV7lands at 3.1 µs. Every wasm call isserialized under a lock, because neither a GraalWasmContextnor a wasmtimeStore` is safe for concurrent use; the
native backends stay lock-free.

One detail was load-bearing rather than tidy: the module exports wasi-libc's malloc/free through two linker flags in rust/.cargo/config.toml. A wasm host cannot hand this library a pointer into its own memory, so every embedder has to ask the guest for a buffer — and a host-picked offset past the data segments collided with dlmalloc's first allocation and corrupted a batch mid-buffer in the spike that led here. The export is what makes the guest's own
allocator the only one in play. Nothing abountract changes on any native target.

CI builds the module on every leg and runs the four suites a second time through it.

The carrier diet: Java, Go, Swift

Java: nothing is copied on the way across. Every door used to open an Arena.ofConfined() per call and copy every
input into it. All twelve downcalls are now l(true), so a caller's byte[]— a v5name, a batch destination, sixteen bytes to reorder in place — is pinned and handed to the native side directly. The single-UUID doors use one per-thread 16-byte in/out scratch, written and read as two big-endian longs.reachability-metadata.json` registers the option and the GraalVM Native Image smoke test passes on it.

JMH Before After
newV4 155 ns 102 ns
newV5 230 ns 102 ns
newV6 128 ns 67 ns
newV7 125 ns 77 ns
each, allocation 112 B/op 32 B/op
fillV7(byte[]) 0 B/op

Go: the UUID crosses by value. Every single-UUID door handed the core &out[0] of a Go local, and any Go pointer passed to a cgo call escapes to the heap. The C shims now keep the sixteen bytes on their own stack and return them as a struct, and take a UUID argument the same way, so no Go pointer crosses except a caller's own slice. NewV4/NewV6At/NewV7At go 1 → 0 allocs, NewV5String 3 → 1 (the one left is Go's own []byte(name)), V6/V7UnixMillis 75 → 58 ns, 0 allocs. Per-call time on the generators barely moves, because entropy, not the
crossing, is what those doors cost — the REAm is corrected accordingly. purego isunchanged.

Swift: zero mallocs per call. Every doore out-value and another per input, and theString v5 form copied the name into an Array. uuid_t is sixteen RFC-ordered bytes already, so it is now the scratch and the result; the v5 name crosses via withUTF8, the batch object doors fill their result array in place
through the existing fill path, and the librce rather than a 13-field struct copied percall. newV4 1 → 0 mallocs, newV5 3 → 0, newV7Batch(1000) 86 → 17 µs and 1002 → 1 malloc. newV5(namespace:name:) gains an UnsafeRawBufferPointer primitive that the String and [UInt8] forms now wrap.

The wasm module is attested like every native library

hyperuuid.wasm carries the same build-prov native builds, signed by the reusableworkflow in SkunkWerkx/.github, and stage-native-binaries.yml refuses to commit it under go/native/ unless that attestation verifies:

gh attestation verify hyperuuid.wasm \
  --repo SkunkWerkx/HyperUuid --signer-repo SkunkWerkx/.github

Every README now has a Verifying provenance section with the exact command and flags for its artifact.

Ruby: the gemspec declares no wasmtime

The engine is a Gemfile group for this repo's own suite, not a development dependency of the gem, so gem install hyperuuid and bundle install against the gem pull in nothing new.

Upgrade note

Drop-in for every binding. Nothing is removed or renamed. The wasm backends are opt-in and change nothing until asked for: no new runtime dependency in any package — Java's GraalWasm is compileOnly, Ruby's wasmtime a Gemfile group for the suite, Python's an extra, Go's behind a ge: wasmtime-go now appears in go.mod, soit enters a consumer's module graph without entering their binary. The only addition under rust/ is the .cargo/config.toml that exports malloc/free on wasm32-wasip1, which applies to builds run from that directory and to nothing a consumer compiles.