Skip to content

v0.2.0

Choose a tag to compare

@buvinghausen buvinghausen released this 02 Sep 22:40
· 207 commits to master since this release

The native call was never the bottleneck. Every binding already made exactly one call into the Rust core per cast — what cost real time was what each language wrapped around it: a copied input, heap scratch for a 16-byte out-value, an object built to be thrown away. This release strips that carrier back in every binding and measures each one before and after on one machine in one session. The core is untouched; no door changed its verdict on any input, and the shared corpus still replays byte-identical through all eight packages.

Alongside that: the seventh Ruby gem (aarch64-mingw-ucrt), per-platform Native AOT receipts, provenance instructions in every README, and the release pipeline brought in step with what HyperUuid 0.2.x learned.

The scoreboard, again

The RFC 3339 timestamp door against each platform's own parser, linux-arm64, crossing and transcode included in every HyperCast figure, with 0.1.0's number beside it. Full receipts and methodology in the root README and each binding's own.

Language 0.1.0 0.2.0 Platform parser, same run
Swift 281 ns, 3 mallocs 55 ns, 0 mallocs 817 ns Date.ISO8601FormatStyle 14.9x faster
Java 62.9 ns 52.5 ns (46.2 from byte[]) 596 ns Instant.parse 11.4x faster
C# 71 ns 76.9 ns from string, 51.2 ns from UTF-8 282 ns DateTimeOffset.TryParse 3.7x / 5.5x faster
Ruby (Magnus) 713 ns 771 ns 3.32 µs Time.iso8601 4.3x faster
PHP 487 ns 567 ns 1.57 µs DateTimeImmutable 2.8x faster
Go 174 ns, 2 allocs 112 ns, 0 allocs 67 ns time.Parse(RFC3339Nano) slower — still on purpose, now without an allocation

Where the carrier was the cost, the door moved. Where it wasn't — Ruby and PHP, whose numeric doors are what changed — the timestamp row is within noise of 0.1.0 and is printed as such.

Swift: zero mallocs on every door

Every String door copied the input into a fresh [UInt8], and every door then allocated three more heap arrays for the out-value, the fault span and the format — four mallocs before the native call. The input now crosses as a view of the string's own UTF-8 (withUTF8), the scratch is a tuple of fixed-width integers on the stack, and the library handle is a class reference rather than a 21-field struct copied out of a Result per call.

Door 0.1.0 0.2.0 mallocs
Cast.timestamp 281 ns 55 ns 3 → 0
Cast.uuid 222 ns 37 ns 3 → 0
Cast.f64 354 ns 45 ns 4 → 0
Cast.i32 349 ns 33 ns 4 → 0
Cast.dateTime (messy civil) 330 ns 129 ns 3 → 0

The Foundation controls in the same run are unchanged between the two tapes, which is what makes the comparison a receipt. Every door also gained an UnsafeRawBufferPointer overload — the primitive the String and [UInt8] forms now wrap.

Go: the verdict comes back by value

0.1.0's doors passed &out and &fault into the foreign call, and any Go pointer handed to cgo escapes to the heap. The README called that "a floor for this call shape" — it was a floor for that shape, not for the ABI. The C shims now declare the out-value, fault span and format on their own stack and return one struct, so no Go pointer crosses at all:

Door 0.1.0 0.2.0
Bool 111 ns, 1 alloc 78 ns, 0 allocs
I32 172 ns, 3 allocs 87 ns, 0 allocs
F64 218 ns, 3 allocs 106 ns, 0 allocs
Uuid 155 ns, 2 allocs 99 ns, 0 allocs
Timestamp 174 ns, 2 allocs 112 ns, 0 allocs
DateTime (1/7/2026 3:04 PM) 173 ns, 2 allocs 122 ns, 0 allocs — parity with time.Parse now

purego is unchanged within noise: its allocations are the trampoline's own argument boxing, not the out-params. Go still loses per-call to its stdlib on the plain doors, and still says so.

Java: the input no longer copies

Every downcall is linked Linker.Option.critical(true), so the caller's byte[] crosses as a pinned heap segment instead of being copied into a per-thread native staging buffer — that buffer and its arena are gone. Sound because every door is a short, non-blocking parse over those bytes that never calls back into Java, which is the profile the option exists for. reachability-metadata.json registers it, and the GraalVM Native Image smoke test passes on it, so a consumer's own native-image build inherits it with no configuration.

Every door gained a MemorySegment overload: slice one buffer holding many values — a mapped file, a direct buffer, one line of a CSV — and cast a value out of it with nothing copied. That is the shape round three's chunk layer will hand down. The UUID door reads its sixteen bytes as two big-endian longs instead of one at a time, which turned the one row that lost in 0.1.0 into a win:

Door 0.1.0 0.2.0 JDK
Cast.timestamp 62.9 ns 52.5 ns 596 ns Instant.parse
Cast.uuid 53.8 ns 38.3 ns 45.9 ns UUID.fromString
Cast.time 66.4 ns 45.1 ns 393 ns LocalTime.parse
Cast.dateTime (messy civil) 81.8 ns 64.9 ns 386 ns DateTimeFormatter

Full-length JMH, 2 forks × (5 warmup + 10 measurement), -prof gc.

Python, Ruby, PHP, C#

  • Python — cast_uuid builds uuid.UUID the way HyperUuid pinned: UUID.__new__ plus object.__setattr__ of the int and is_safe slots, skipping an __init__ whose validation the core had already done. 1.18 µs → 730 ns, ahead of uuid.UUID()'s 979 ns. The door used to sit at parity because both sides were bounded by __init__.
  • Ruby (Magnus) — the three reason Symbols and the option-symbol tables are cached, DETECT is identity-matched like INVARIANT, and any other format is resolved once per thread and memoized by identity, anchored in a thread-variable so the key can never be a recycled address. The numeric doors under a declared eurozone format or DETECT drop from ~990 to ~580 ns; every format other than INVARIANT used to pay three method dispatches and two String allocations per call to read its separators back out.
  • Ruby (Fiddle) — ~20% off the numeric doors (i32 3.37 → 2.62 µs) by not building per call what never changes: the interpolated :cast_* Symbol, the splat dispatcher, the 12-byte format copy into scratch. Still the zero-compile fallback, still on Fiddle's 1.6 µs floor.
  • PHP — the eight integer doors are flat (one literal FFI call each, no dynamic symbol lookup and string match per call), and Cast::uuidBytes returns the sixteen RFC-ordered octets for a BINARY(16) bind. Within noise on phpbench; recorded as structure, not speed.
  • C# — the string doors try the stack buffer first and rent from the pool only when the encoder says the text did not fit. New UTF-8 benchmark rows measure the ReadOnlySpan<byte> doors without the transcode every string row includes: Cast.Uuid 36.9 ns against Guid.TryParse's 51.2, which the string row had reported as a wash.

Ruby: the seventh gem

aarch64-mingw-ucrt joins the platform gems, with the Magnus extension for both Ruby ABIs, so Windows-on-ARM leaves the Fiddle fallback like every other mainstream platform. Both Windows extensions build the gnullvm targets. This one is CI's receipt so far — built and tested on the win-arm64 leg — and no Magnus-versus-Fiddle numbers have been taken on Windows-on-ARM hardware for this crate yet. gem install hypercast resolves the right one; anything outside the grid still gets the universal zero-compile gem.

Provenance, and the receipts behind it

Every README now has a Verifying provenance section: which artifact, which signer, and why --signer-repo SkunkWerkx/.github is needed on some and not others.

# A package — signed by release.yml, which lives in this repo.
gh attestation verify hypercast-0.2.0-aarch64-mingw-ucrt.gem --repo SkunkWerkx/HyperCast

# A native library, or a Magnus extension — signed by the shared forge workflow,
# so the signer's own repo has to be named too.
gh attestation verify libhypercast.so \
  --repo SkunkWerkx/HyperCast --signer-repo SkunkWerkx/.github

CI also publishes HyperCast.AotSmokeTest under Native AOT on all six RIDs, fails on any trim diagnostic, runs the binary, and uploads each leg's log as aot-report-{rid} — the AOT claim as a per-platform receipt rather than a README sentence. The crate is packaged and attested before its irreversible push, and publishes tokenless through Trusted Publishing.

Not in this release

A batch door. docs/roadmap.md places it in round three as new exports beside the scalar ABI; a binding-level loop over N crossings would be a fake win, and every change above is the per-call shape that layer will inherit. Two platform controls moved between toolchains and are reported as measured rather than carried over: Swift's DateFormatter read 810 ns on 0.1.0's tape and 28 µs on Swift 6.3.3, and the JDK's ISO_OFFSET_DATE_TIME control was unstable in the full-length run and is not quoted.

Install

Language
Rust cargo add hypercast crates.io
C# dotnet add package HyperCast nuget.org
Java io.github.skunkwerkx:hypercast:0.2.0 Maven Central
Python pip install hypercast PyPI
Ruby gem install hypercast RubyGems
PHP composer require skunkwerkx/hypercast Packagist
Swift .package(url: "https://github.com/SkunkWerkx/HyperCast", from: "0.2.0") git tag
Go go get github.com/SkunkWerkx/HyperCast/go@go/v0.2.0 git tag

Drop-in from 0.1.0 for every binding: nothing is removed or renamed, every new door is an overload or a sibling beside the existing surface, and the verdict types are untouched. Full entry in CHANGELOG.md.