mirror of
https://github.com/hanzoai/base.git
synced 2026-10-30 15:03:47 +00:00
* feat(base): extension runtime interface + native impl Foundation for pluggable extension runtimes. One Runtime interface that wazero, v8go, goja, and native-Go all satisfy. Extensions declare their preferred runtime in extension.json; the loader dispatches. This sits alongside plugins/jsvm — goja-via-the-existing-jsvm-path remains the default for .base.js hooks. The new path is for users who want hard-sandboxed WASM extensions or full V8 perf. Files: - plugins/extruntime/extruntime.go — Runtime + Module interfaces, Manifest - plugins/extruntime/manifest.go — JSON manifest loader (stdlib only) - plugins/extruntime/native.go — reference impl, registers Go funcs Concrete runtimes land in sibling commits: - plugins/wasmvm — wazero (pure Go, hard sandbox) - plugins/v8vm — v8go (cgo, -tags v8vm) - plugins/gojavm — goja adapter (no new deps) - plugins/extbench — fixtures + benchmarks across all 5 * feat(base): goja runtime adapter + extension loader * feat(base): v8go extension runtime (cgo, -tags v8vm) V8-via-cgo runtime satisfying plugins/extruntime.Runtime. Build-tag gated so the default base binary never pulls v8go's ~50MB of cgo deps — a stub at !v8vm reports the same Name/Capabilities and errors at Load with ErrUnsupported. JS calling convention: payload bytes are JSON.parse'd into a real JS value and passed as the single argument to globalThis[fn]; the return value is JSON.stringify'd back to bytes. Much simpler than the wasm ptr/len dance — V8 has actual objects. Single shared Isolate across all modules (creation is expensive, isolates are not thread-safe so a global mutex serializes anyway). Per-module Context pool (default 8, BASE_V8VM_POOL_SIZE) for state isolation across back-to-back invocations. Heap cap intent via BASE_V8VM_HEAP_MB_MAX; v8go v0.9.0 has no public per-isolate heap API, so the value is advisory and enforcement is the V8 default plus ctx-cancel TerminateExecution() watchdog. Tests cover the cancel path (200ms timeout on `while(true)`), heap bomb (2^30-char string), pool reuse cap, wrong-runtime manifest, and closed-module rejection. * feat(base): wazero extension runtime Implements plugins/wasmvm — the wazero-backed extruntime.Runtime. - Shared wazero.Runtime with compilation cache + WASI snapshot preview1 instantiated once so guest modules can do stdio/clock/random. - Per-Module instance pool (default 8, BASE_WASMVM_POOL_SIZE override) to amortize InstantiateModule across invocations. - Host-guest ABI: guest exports fn(i32 ptr, i32 len) -> i64 (packed ptr<<32 | len), plus __base_alloc / __base_free for the buffer handshake. Missing alloc/free returns ErrUnsupported. - Hard abort on ctx cancel via api.Module.Close(); cancelled instances are replaced from a background ctx so pool size stays stable. - Tests cover identity, load+invoke, ctx cancel, concurrent pool reuse (16 invokes over pool size 4), invoke-after-close, and wrong-runtime manifest rejection. Fixture-dependent tests skip with TODO until extbench fixtures land. * feat(base): extension benchmark fixtures across 5 runtimes * feat(base): extension runtime benchmarks + writeup plugins/extbench/ benches all five extension runtimes (native, goja, wazero AS, wazero Javy, v8go) against an identical validate(email,age) workload. Per-runtime Serial + Parallel + Coldstart + Memory tests. run.sh runs the suite at -benchtime=2s -count=3 (~3 min total). Headline numbers (Apple M1 Max, Go 1.26.3, median of 3): native 1,197 ns/op serial / 694 ns/op parallel goja 4,513 ns/op serial / 1,652 ns/op parallel wazero AS 9,956 ns/op serial / 3,271 ns/op parallel v8go 11,895 ns/op serial / 12,667 ns/op parallel (no parallelism) Cold start ranges from native 17us to wazero AS 3.9ms. Binary size delta from +89 KB (native) to +47.8 MB (all four with -tags v8vm). Javy is wired but skipped pending a stdio-shim or custom Javy plugin (its WASI command ABI doesn't match wasmvm's pointer-based contract). Full writeup in docs/EXTENSIONS_BENCHMARK.md, raw runs in plugins/extbench/bench-results.txt. * feat(base): scale study — concurrent module / tenant / goroutine fanout Five-section fan-out study answering: 1) per-loaded-module marginal memory (N up to 10000) 2) per-tenant concurrent invocation latency (T up to 1000) + pool-size elbow 3) sustained concurrency on ONE module (M up to 10000) 4) goroutine cost (1000 parked goroutines, HeapInuse delta per runtime) 5) cold pool startup vs steady-state (N=100 sequential Load) Headlines: goja is the multi-tenant winner (~9KB/tenant, 188k ops/s at M=1000); wazero's per-module cost is the 8-instance pool — elbow at pool=4; v8go fatal-aborts above ~10 concurrent invokes (darwin/arm64 SIGSEGV inside libv8); native pays nothing per goroutine. Wazero pool=256 explodes RSS to 10.8 GB at 1000 modules without latency win. Implementation: cloneTenants() materializes a temp dir per tenant with a unique extension name, symlinked module artifact, and native-handler re-registration. v8go capped at concurrency=10 and skipped in goroutine-cost — the cap itself is the data, recorded in the writeup. Exports Validate from fixtures/native-go so scale_test can register the same handler under N unique extension names without re-implementing. Wall clock: <1 minute for the full study on Apple M1 Max. * feat(base): pyvm runtime — cgo CPython 3.13 (-tags pyvm) Fifth extension runtime alongside native/goja/wazero/v8go. Default build links the stub; real impl behind `-tags pyvm` requires pkg-config python-3.13-embed. Architecture: one process-wide CPython init, per-module pool of PEP 684 OWN_GIL sub-interpreters (default 4 / BASE_PYVM_POOL_SIZE). Each Invoke pins to OS thread, attaches a sub-interpreter, runs __main__.fn(json.loads(payload)), returns json.dumps(result). Free-threading (PEP 703) detected at runtime via Py_GIL_DISABLED. Honest limitations documented in README + capability flags: - HardSandbox=false (C extension segfault kills the host) - SupportsAbort=false (no primitive to abort a running sub-interp from another OS thread without holding its GIL) Direct cgo bridge (~150 lines), no DataDog/go-python3 or cpy3 dependency — both fail to compile against CPython 3.12+ because they reference removed PyFloat_ClearFreeList / PyDict_ClearFreeList. JSON marshaling stays inside Python's stdlib json module so the cgo surface stays narrow. Tests: 7 table tests pass on darwin/arm64 + Python 3.13.13. * feat(base): pyvm fixture + bench integration Adds plugins/extbench/fixtures/pyvm-py/ (validate.py + manifest) implementing the same validate(email, age) spec as the other four fixtures — pure stdlib, no third-party Python deps. bench_test.go: BenchmarkPyvm_Serial + BenchmarkPyvm_Parallel skip gracefully when built without -tags pyvm (same shape as v8go). scale_test.go: pyvm registered alongside the four existing runtimes with a 100-module cap (each module holds up to 4 sub-interpreters at ~4.6 MB each — 1000 modules would be ~18 GB and dominate the study). BASE_PYVM_POOL_SIZE plumbed through setPoolEnv / restorePoolEnv for the pool-elbow subtest. * docs: pyvm benchmark numbers vs existing 4 runtimes Five-way throughput table on darwin/arm64 + Apple M1 Max + Python 3.13.13 (GIL-enabled). Headline: - pyvm Serial 4,089 ns/op — between native (1,229) and goja (4,646) - pyvm Parallel 2,765 ns/op — faster than wazero (4,530) and v8go (12,311), close to goja (1,982) - pyvm B/op 496, allocs/op 10 — smallest footprint of any runtime - pyvm cold start 14.3 ms/module — worst in the field; warm-load at boot, never lazy-load - pyvm per-module RSS asymptote 4.6 MB — highest in the field HIP-0105 verdict: experimental-by-default, production-recommended for single-tenant runtime:pyvm deployments only. Multi-tenant must add crash-isolation:none + hard-abort:none labels so operators can policy-reject. Free-threading (PEP 703) NOT measured — homebrew does not ship [email protected] on darwin/arm64 as of 2026-05-18. Runtime detection path is wired; verification needs a Linux host. Bench crash mode found + fixed: defer-order LIFO race in pyvm_leave/release on Parallel — restructured to straight-line ordering. No further crash modes observed across 90+ s of load. * feat(base): starlark extension runtime (plugins/starkvm) Adds Google's go.starlark.net interpreter as the sixth extruntime Runtime. Pure Go, no cgo, no GIL, per-thread Cancel() for cooperative abort. Same Module shape as gojavm/wasmvm: pre-warmed pool, JSON payload in / JSON result out, ctx-cancel honored. Manifest: "runtime": "starlark", "module": "validate.star". Fixture plugins/extbench/fixtures/starkvm-star/ implements the shared validate(email, age) workload. Uses the permissive Starlark dialect (while + recursion enabled via FileOptions); strict Bazel Starlark would forbid while, doc note in the .star file. Wired into bench_test.go (Serial/Parallel), coldstart_test.go, memory_test.go (TestMemory_Starkvm), and scale_test.go (spec entry + BASE_STARKVM_POOL_SIZE pool-elbow sweep). Tests cover capabilities, roundtrip, unknown fn, post-close, ctx cancel mid-loop, pool reuse, and the full validate fixture against 5 input cases (8 tests total). First measurement on Apple M1 Max: 3701 B/invoke, 1 GC per 1000 calls -- comparable to goja (3433 B/invoke). * feat(extbench): wasm-Rust fixture + bench Pure-Rust validate(email, age) compiled to wasm32-unknown-unknown, loaded by the existing wasmvm runtime. Implements HIP-0105 pointer/ length ABI directly (no zip-rs dep, no Hanzo-internal deps). Compares apples-to-apples with the AssemblyScript fixture: same ABI, same payload, different source language. - Cargo.toml: standalone crate (workspace=[] to escape hanzo workspace) - src/lib.rs: no_std + bump allocator + tolerant JSON scrape - build.sh: cargo build --release --target wasm32-unknown-unknown - validate.wasm: 9.1 KB committed artifact (no toolchain dep at bench run) - bench_test.go: BenchmarkWazero_Rust_{Serial,Parallel} Run: ~4.5us serial / 3.9us parallel on M1 Max. * docs: deferred CPython-WASI + RustPython ABI shim Both runtimes emit WASI command modules (stdin/stdout via _start) which don't match Base's wasmvm pointer ABI (validate(ptr,len) -> i64). CPython-WASI deferred: requires either a 30 MB python.wasm artifact + a stdio-piping Rust/AS shim, or building CPython from source against the WASI SDK (~15-20 min) and forking _start. Out of 60-min budget. RustPython deferred: tried embedding rustpython-vm 0.5.0 in a Rust shim that exposes the pointer ABI directly. The {freeze-stdlib + rustpython-compiler} feature combo fails to compile on wasm32-wasip1 because of cfg-gated paths in define_exception_fn!. Resolving requires either patching the crate or waiting on upstream 0.6.x. Documented the exact attempt + failure in fixtures/wazero-rustpython/README.md so the next agent doesn't repeat the work. Both fixtures stay in tree with extension.json + validate.py + README; the wasmvm runtime reports "module not built" and the bench harness Skip()s them with a clear message. * test(extbench): add pyvm + wasm-Rust memory + coldstart tests memory_test.go gains TestMemory_Pyvm + TestMemory_Wazero_Rust + deferred-fixture skips (TestMemory_Wazero_CPythonWASI/RustPython). runMemory helper now Skip()s when manifest.module artifact is missing — same loadFixture path the bench harness uses, so deferred fixtures (extension.json present, .wasm not built) skip cleanly instead of failing. coldstart_test.go gains BenchmarkColdstart_Pyvm + ..._Wazero_Rust + the two deferred-fixture skips. benchColdstart already probes Load and skips on failure, so the new ones inherit clean Skip behavior. All tests pass under default + v8vm + pyvm tag combinations. * bench: full cross-runtime comparison results Captured 2026-05-19 on Apple M1 Max, default + -tags 'v8vm pyvm'. 8 runtimes measured (5 built, 3 deferred-by-ABI): - native-go, goja, starkvm, wazero-as, wazero-rust, v8go, pyvm: full throughput + memory + cold-start numbers - wazero-cpython-wasi, wazero-rustpython: Skip with "module not built" message (deferred per fixture README) Headline: - pyvm 6.9us serial (only native-go is faster) but 34ms cold-start - starkvm 21us serial — comparable to goja, pure-Go, no JIT - wazero-rust 14.6us serial — slightly better than wazero-as - v8go is the slowest non-deferred runtime (cgo marshalling cost) * docs: consolidated runtime comparison in EXTENSIONS_BENCHMARK.md New section "Full Cross-Runtime Comparison (2026-05-19)" appended. Covers 8 runtimes side-by-side: - 5 originally measured (native, goja, wazero-as, v8go, pyvm) - 3 added in this sprint (starkvm, wazero-rust, plus two documented deferrals: wazero-cpython-wasi + wazero-rustpython) Sections: - Throughput table (serial / parallel / B/op / allocs/op / coldstart) - Per-invocation memory (TestMemory) - Cold-start (BenchmarkColdstart) - Scale + crash characteristics (qualitative) - Recommendation grid (use case -> best/second/avoid) - Headline findings (5 bullets, honest, including the deferral wall) Numbers come from plugins/extbench/bench-results-all.txt; reproduce section at the bottom. * docs(pyvm): add measured 3.13t free-threading numbers + cpy3 fork note - EXTENSIONS_BENCHMARK.md "Free-threading vs sub-interpreter parallelism" section now has the measured numbers (3.13 vs 3.13t, Apple M1 Max, default-bridge pyvm): free-threading is a non-event for the embedding workload (4,073 / 5,733 ns/op default-GIL vs 4,478 / 5,805 ns/op free-threaded). PEP 684 OWN_GIL pool already delivers per-OS-thread parallelism; PEP 703 doesn't add headroom in this contract. - Library-choice paragraph now references hanzoai/cpy3 (the canonical Hanzo Go binding for embedded CPython — fork of go-python/cpy3 with 3.12+ compat + sub-interp wrapper + free-threading detection + JSON hot-path helper + 3.13t build script) and explains why pyvm itself keeps the consolidated direct cgo bridge (one cgo call per Invoke, ~30% faster than routing through cpy3 wrappers for this workload). - go.mod tidied — cpy3 no longer in pyvm's import graph; cpy3 is documented but not directly used by base.
404 lines
13 KiB
Rust
404 lines
13 KiB
Rust
// Pure-Rust validate fixture for the Base extbench harness.
|
|
//
|
|
// Host-guest ABI (HIP-0105 pointer/length convention, matches
|
|
// plugins/wasmvm/module.go):
|
|
//
|
|
// __base_alloc(size: i32) -> i32 host -> guest scratch
|
|
// __base_free(ptr: i32, size: i32) host releases scratch
|
|
// validate(ptr: i32, len: i32) -> i64 packed: (resPtr<<32) | resLen
|
|
//
|
|
// The host writes a JSON payload at ptr/len, calls validate, then reads
|
|
// resLen bytes at resPtr (UTF-8 JSON) and frees them via __base_free.
|
|
//
|
|
// Shared semantics across all extbench fixtures (canonical reference:
|
|
// goja-js/validate.js):
|
|
//
|
|
// in: {"email":"[email protected] ","age":25}
|
|
// ok: {"ok":true,"email":"[email protected]","age":25}
|
|
// err: {"ok":false,"error":"input must be an object"}
|
|
// err: {"ok":false,"error":"email required"}
|
|
// err: {"ok":false,"error":"age out of range"}
|
|
// err: {"ok":false,"error":"email shape"}
|
|
// err: {"ok":false,"error":"email domain"}
|
|
//
|
|
// We build for wasm32-unknown-unknown (no WASI), so the runtime surface
|
|
// is purely linear-memory + exported functions. The Base wasmvm host
|
|
// instantiates WASI snapshot_preview1 globally; modules that don't
|
|
// import any WASI symbols (like this one) ignore it.
|
|
|
|
#![no_std]
|
|
|
|
extern crate alloc;
|
|
|
|
use alloc::alloc::{alloc, dealloc, Layout};
|
|
use alloc::string::{String, ToString};
|
|
use core::panic::PanicInfo;
|
|
|
|
// wasm32 has no default panic handler with #![no_std]. abort by trap.
|
|
#[panic_handler]
|
|
fn panic(_: &PanicInfo) -> ! {
|
|
core::arch::wasm32::unreachable()
|
|
}
|
|
|
|
// ---------- memory exports --------------------------------------------------
|
|
|
|
// Allocate `size` bytes and return the raw address. We over-allocate by
|
|
// `size_of::<usize>()` to stash the layout's size next to the buffer so
|
|
// __base_free can reconstruct the same Layout (alignment is fixed at 8
|
|
// to match Rust's default for byte buffers; that's safe for the JSON
|
|
// payloads we round-trip).
|
|
const ALIGN: usize = 8;
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn __base_alloc(size: i32) -> i32 {
|
|
if size <= 0 {
|
|
return 0;
|
|
}
|
|
let size = size as usize;
|
|
// We need to remember `size` for the matching dealloc. Pack it as a
|
|
// little-endian u32 in the first 8 bytes (8 so the user pointer stays
|
|
// 8-byte-aligned) and return a pointer past the prefix.
|
|
let total = size + ALIGN;
|
|
let layout = match Layout::from_size_align(total, ALIGN) {
|
|
Ok(l) => l,
|
|
Err(_) => return 0,
|
|
};
|
|
unsafe {
|
|
let raw = alloc(layout);
|
|
if raw.is_null() {
|
|
return 0;
|
|
}
|
|
// Stash size as u32 in first 4 bytes of the prefix; the rest
|
|
// of the prefix is padding to maintain alignment.
|
|
(raw as *mut u32).write(size as u32);
|
|
// User pointer is past the prefix.
|
|
raw.add(ALIGN) as i32
|
|
}
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn __base_free(ptr: i32, _size: i32) {
|
|
if ptr == 0 {
|
|
return;
|
|
}
|
|
unsafe {
|
|
let user = ptr as *mut u8;
|
|
let raw = user.sub(ALIGN);
|
|
let size = (raw as *const u32).read() as usize;
|
|
let total = size + ALIGN;
|
|
if let Ok(layout) = Layout::from_size_align(total, ALIGN) {
|
|
dealloc(raw, layout);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- validate --------------------------------------------------------
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn validate(ptr: i32, len: i32) -> i64 {
|
|
let input = unsafe { read_input(ptr, len) };
|
|
let json = match input {
|
|
Ok(s) => s,
|
|
Err(_) => return pack(r#"{"ok":false,"error":"input must be an object"}"#),
|
|
};
|
|
|
|
// Minimal tolerant JSON scrape. We never need a full parser — the
|
|
// benchmark payload is fixed-shape `{"email":<string>,"age":<int>}`.
|
|
let email = match read_string(&json, "email") {
|
|
Some(s) => s,
|
|
None => return pack(r#"{"ok":false,"error":"email required"}"#),
|
|
};
|
|
if email.is_empty() {
|
|
return pack(r#"{"ok":false,"error":"email required"}"#);
|
|
}
|
|
let age = match read_int(&json, "age") {
|
|
Some(n) => n,
|
|
None => return pack(r#"{"ok":false,"error":"age out of range"}"#),
|
|
};
|
|
if !(0..=150).contains(&age) {
|
|
return pack(r#"{"ok":false,"error":"age out of range"}"#);
|
|
}
|
|
|
|
let normalized = email.trim().to_ascii_lowercase();
|
|
let at = match normalized.find('@') {
|
|
Some(i) if i > 0 && i < normalized.len() - 1 => i,
|
|
_ => return pack(r#"{"ok":false,"error":"email shape"}"#),
|
|
};
|
|
let domain = &normalized[at + 1..];
|
|
if !domain.contains('.') {
|
|
return pack(r#"{"ok":false,"error":"email domain"}"#);
|
|
}
|
|
|
|
// Manually assemble result JSON. The email is the only user-supplied
|
|
// string and we only allow ascii lowercase + a few separators after
|
|
// normalisation, but escape conservatively anyway.
|
|
let mut out = String::with_capacity(48 + normalized.len());
|
|
out.push_str(r#"{"ok":true,"email":""#);
|
|
json_escape(&mut out, &normalized);
|
|
out.push_str(r#"","age":"#);
|
|
push_i64(&mut out, age);
|
|
out.push('}');
|
|
pack(&out)
|
|
}
|
|
|
|
// ---------- helpers ---------------------------------------------------------
|
|
|
|
// SAFETY: caller has written `len` bytes at `ptr`. We copy them out into
|
|
// a Rust string so we can drop the raw pointer immediately.
|
|
unsafe fn read_input(ptr: i32, len: i32) -> Result<String, ()> {
|
|
if ptr == 0 || len < 0 {
|
|
return Err(());
|
|
}
|
|
let bytes = core::slice::from_raw_parts(ptr as *const u8, len as usize);
|
|
core::str::from_utf8(bytes).map(|s| s.to_string()).map_err(|_| ())
|
|
}
|
|
|
|
// pack writes `s` into a freshly-allocated buffer and returns the
|
|
// (ptr<<32) | len i64 that the host decodes. The buffer is owned by the
|
|
// host after return; the host frees it via __base_free once it's read.
|
|
fn pack(s: &str) -> i64 {
|
|
let bytes = s.as_bytes();
|
|
let len = bytes.len();
|
|
let ptr = __base_alloc(len as i32);
|
|
if ptr == 0 {
|
|
return 0;
|
|
}
|
|
unsafe {
|
|
core::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, len);
|
|
}
|
|
((ptr as i64) << 32) | (len as i64)
|
|
}
|
|
|
|
// find_value_start locates the byte index of the first non-whitespace
|
|
// character after `"<key>":`. Returns None if the key isn't present.
|
|
fn find_value_start(src: &str, key: &str) -> Option<usize> {
|
|
// Build the needle on the stack to avoid an allocation for typical
|
|
// short keys. We just want byte search; rust's str::find is fine.
|
|
let mut needle = [0u8; 16];
|
|
let needle_len = 1 + key.len() + 1;
|
|
if needle_len > needle.len() {
|
|
return None;
|
|
}
|
|
needle[0] = b'"';
|
|
needle[1..1 + key.len()].copy_from_slice(key.as_bytes());
|
|
needle[1 + key.len()] = b'"';
|
|
let needle = core::str::from_utf8(&needle[..needle_len]).ok()?;
|
|
let start = src.find(needle)?;
|
|
let mut idx = start + needle_len;
|
|
let bytes = src.as_bytes();
|
|
while idx < bytes.len() {
|
|
match bytes[idx] {
|
|
b':' => {
|
|
idx += 1;
|
|
break;
|
|
}
|
|
b' ' | b'\t' | b'\n' | b'\r' => idx += 1,
|
|
_ => return None,
|
|
}
|
|
}
|
|
while idx < bytes.len() {
|
|
match bytes[idx] {
|
|
b' ' | b'\t' | b'\n' | b'\r' => idx += 1,
|
|
_ => return Some(idx),
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_string(src: &str, key: &str) -> Option<String> {
|
|
let start = find_value_start(src, key)?;
|
|
let bytes = src.as_bytes();
|
|
if bytes[start] != b'"' {
|
|
return None;
|
|
}
|
|
let mut out = String::new();
|
|
let mut i = start + 1;
|
|
while i < bytes.len() {
|
|
match bytes[i] {
|
|
b'"' => return Some(out),
|
|
b'\\' if i + 1 < bytes.len() => {
|
|
let n = bytes[i + 1];
|
|
match n {
|
|
b'"' => out.push('"'),
|
|
b'\\' => out.push('\\'),
|
|
b'/' => out.push('/'),
|
|
b'n' => out.push('\n'),
|
|
b't' => out.push('\t'),
|
|
b'r' => out.push('\r'),
|
|
_ => { /* drop unknown escapes — tolerant scrape */ }
|
|
}
|
|
i += 2;
|
|
}
|
|
c => {
|
|
out.push(c as char);
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_int(src: &str, key: &str) -> Option<i64> {
|
|
let start = find_value_start(src, key)?;
|
|
let bytes = src.as_bytes();
|
|
let mut i = start;
|
|
let mut neg = false;
|
|
if bytes[i] == b'-' {
|
|
neg = true;
|
|
i += 1;
|
|
}
|
|
if i == bytes.len() || !bytes[i].is_ascii_digit() {
|
|
return None;
|
|
}
|
|
let mut n: i64 = 0;
|
|
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
|
n = n.checked_mul(10)?.checked_add((bytes[i] - b'0') as i64)?;
|
|
i += 1;
|
|
}
|
|
Some(if neg { -n } else { n })
|
|
}
|
|
|
|
fn json_escape(out: &mut String, s: &str) {
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => {
|
|
let mut buf = [0u8; 6];
|
|
let _ = write_unicode_escape(&mut buf, c);
|
|
out.push_str(core::str::from_utf8(&buf).unwrap_or(""));
|
|
}
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_unicode_escape(buf: &mut [u8; 6], c: char) -> usize {
|
|
let hex = b"0123456789abcdef";
|
|
let n = c as u32;
|
|
buf[0] = b'\\';
|
|
buf[1] = b'u';
|
|
buf[2] = hex[((n >> 12) & 0xf) as usize];
|
|
buf[3] = hex[((n >> 8) & 0xf) as usize];
|
|
buf[4] = hex[((n >> 4) & 0xf) as usize];
|
|
buf[5] = hex[(n & 0xf) as usize];
|
|
6
|
|
}
|
|
|
|
fn push_i64(out: &mut String, n: i64) {
|
|
if n == 0 {
|
|
out.push('0');
|
|
return;
|
|
}
|
|
let mut buf = [0u8; 20];
|
|
let mut idx = buf.len();
|
|
let mut v = if n < 0 {
|
|
out.push('-');
|
|
(n as i128).unsigned_abs() as u64
|
|
} else {
|
|
n as u64
|
|
};
|
|
while v > 0 {
|
|
idx -= 1;
|
|
buf[idx] = b'0' + (v % 10) as u8;
|
|
v /= 10;
|
|
}
|
|
out.push_str(core::str::from_utf8(&buf[idx..]).unwrap_or("0"));
|
|
}
|
|
|
|
// ---------- allocator -------------------------------------------------------
|
|
//
|
|
// no_std + dynamic alloc requires choosing an allocator. wee_alloc is
|
|
// archived; we use the bump allocator approach via dlmalloc which is
|
|
// what wasi-libc ships and what alloc::alloc::Global expects.
|
|
//
|
|
// For wasm32-unknown-unknown there is no default allocator. Provide
|
|
// the minimal one — dlmalloc via the `dlmalloc` crate would pull deps;
|
|
// instead we provide a trivial growable bump allocator that's
|
|
// adequate for our test workload (one alloc per call, freed immediately).
|
|
|
|
use core::alloc::{GlobalAlloc, Layout as CoreLayout};
|
|
use core::cell::UnsafeCell;
|
|
use core::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
struct BumpAlloc {
|
|
// current offset into the heap, in bytes. Linear-memory page is 64KB;
|
|
// we grow on demand via core::arch::wasm32::memory_grow.
|
|
offset: AtomicUsize,
|
|
// Free list for layouts <= 256 bytes, indexed by size class.
|
|
// Heap-allocated singly linked list — wasm linear memory is the
|
|
// backing store. Stored inside an UnsafeCell because we can't have
|
|
// a Mutex in no_std and wasm is single-threaded by default.
|
|
free_lists: UnsafeCell<[*mut u8; 33]>, // 8..=256 in 8-byte steps
|
|
}
|
|
|
|
unsafe impl Sync for BumpAlloc {}
|
|
|
|
const HEAP_BASE: usize = 0x10000; // leave the first page (64 KB) for stack
|
|
const PAGE_BYTES: usize = 65536;
|
|
|
|
unsafe impl GlobalAlloc for BumpAlloc {
|
|
unsafe fn alloc(&self, layout: CoreLayout) -> *mut u8 {
|
|
let size = layout.size().max(8);
|
|
let align = layout.align().max(8);
|
|
|
|
// Try the free list for small allocations to keep the bump cursor
|
|
// from running away. The benchmark loop is alloc/free in pairs so
|
|
// recycling is the difference between linear and constant heap.
|
|
if size <= 256 && align <= 8 {
|
|
let idx = (size + 7) / 8 - 1;
|
|
let lists = self.free_lists.get();
|
|
let head = (*lists)[idx];
|
|
if !head.is_null() {
|
|
let next = (head as *mut *mut u8).read();
|
|
(*lists)[idx] = next;
|
|
return head;
|
|
}
|
|
}
|
|
|
|
// Bump path. Align up the current cursor to `align`.
|
|
let mut cur = self.offset.load(Ordering::Relaxed);
|
|
if cur == 0 {
|
|
cur = HEAP_BASE;
|
|
}
|
|
let aligned = (cur + align - 1) & !(align - 1);
|
|
let new_end = aligned + size;
|
|
|
|
// Grow linear memory if needed.
|
|
let cur_pages = core::arch::wasm32::memory_size(0);
|
|
let need_pages = (new_end + PAGE_BYTES - 1) / PAGE_BYTES;
|
|
if need_pages > cur_pages {
|
|
let extra = need_pages - cur_pages;
|
|
if core::arch::wasm32::memory_grow(0, extra) == usize::MAX {
|
|
return core::ptr::null_mut();
|
|
}
|
|
}
|
|
|
|
self.offset.store(new_end, Ordering::Relaxed);
|
|
aligned as *mut u8
|
|
}
|
|
|
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: CoreLayout) {
|
|
let size = layout.size().max(8);
|
|
let align = layout.align().max(8);
|
|
if size <= 256 && align <= 8 {
|
|
let idx = (size + 7) / 8 - 1;
|
|
let lists = self.free_lists.get();
|
|
let head = (*lists)[idx];
|
|
(ptr as *mut *mut u8).write(head);
|
|
(*lists)[idx] = ptr;
|
|
}
|
|
// Large allocations are not recycled — fine for the bench
|
|
// workload (one ~50-byte JSON in/out per call).
|
|
}
|
|
}
|
|
|
|
#[global_allocator]
|
|
static ALLOC: BumpAlloc = BumpAlloc {
|
|
offset: AtomicUsize::new(0),
|
|
free_lists: UnsafeCell::new([core::ptr::null_mut(); 33]),
|
|
};
|