File size: 7,049 Bytes
5e84645 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | //! Raw `wasm32-unknown-unknown` ABI — no wasm-bindgen.
//!
//! **Why raw exports rather than wasm-pack.** wasm-bindgen exists to make *rich JS types*
//! ergonomic across the boundary. This kernel's interface is one JSON string in, one JSON string
//! out, so the glue buys nothing and costs binary size plus a toolchain dependency that must
//! version-match the compiler. Raw exports also keep the benchmark honest: what is timed is
//! compute, not bindgen marshalling.
//!
//! **Protocol** (`wasm/run.mjs` is the reference consumer). Parsing is deliberately a *separate*
//! call from computing, so the timed region matches the native CLI's timed region exactly —
//! otherwise wasm would be charged for JSON parsing that native excludes:
//!
//! 1. `alloc(len)` → pointer to `len` writable bytes; JS copies UTF-8 JSON in.
//! 2. `load(ptr, len)` → parses and stores the vectors. **Not timed.**
//! 3. `compute()` → runs one kernel pass over the stored vectors, returns a pointer to
//! `[u32 little-endian length][UTF-8 JSON bytes]`. **This is the timed call.**
//! 4. `dealloc(ptr, len)` releases buffers.
use crate::{coverage, coverage_checksums, energy, gradient, model::Vectors};
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
/// Parsed vectors + precomputed target, held between `load` and `compute` so that JSON
/// parsing stays outside the timed region.
static STATE: RefCell<Option<(Vectors, Vec<f64>)>> = const { RefCell::new(None) };
}
/// One kernel pass over already-parsed vectors, serialised at the golden sample indices.
/// Kept free of any wasm specifics so it is testable on native.
pub fn compute_json(v: &Vectors, target: &[f64]) -> String {
let cov = coverage::region_coverages(v);
let img = energy::compose(v, &cov);
let e_data = energy::e_data(&img, target, v.l0);
let grad = gradient::vertex_gradients(v, &img, target);
let cov_by_label: HashMap<i64, &Vec<f64>> = cov.iter().map(|(l, c)| (*l, c)).collect();
let coverage_samples: Vec<f64> = v
.golden
.coverage_samples
.iter()
.map(|s| {
cov_by_label
.get(&s.label)
.map(|c| c[s.row * v.width + s.col])
.unwrap_or(f64::NAN)
})
.collect();
let vertex_gradients: Vec<[f64; 2]> = v
.golden
.vertex_gradients
.iter()
.map(|s| {
grad.get(&s.edge)
.map(|pc| pc[s.cubic][s.vertex])
.unwrap_or([f64::NAN, f64::NAN])
})
.collect();
let checksums = coverage_checksums(&cov);
serde_json::json!({
"name": v.name,
"engine": "wasm",
"e_data": e_data,
"coverage_checksums": checksums.iter()
.map(|(l, c)| (l.to_string(), serde_json::json!({"sum": c.sum, "sumsq": c.sumsq})))
.collect::<serde_json::Map<_, _>>(),
"coverage_samples": coverage_samples,
"vertex_gradients": vertex_gradients,
})
.to_string()
}
// The three helpers below are consumed by the `abi` module (wasm32 only) and by the tests, so on a
// plain native build they are legitimately unreachable. Scoped allow rather than a blanket one.
#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))]
fn store(text: &str) -> i32 {
match serde_json::from_str::<Vectors>(text) {
Ok(v) => {
let target = v.target();
STATE.with(|s| *s.borrow_mut() = Some((v, target)));
0
}
Err(_) => -1,
}
}
#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))]
fn compute_stored() -> String {
STATE.with(|s| {
let b = s.borrow();
let (v, target) = b.as_ref().expect("compute() called before load()");
compute_json(v, target)
})
}
/// Wrap a payload as `[u32 le length][bytes]` and leak it for the caller to read and free.
#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))]
fn leak_length_prefixed(payload: String) -> *mut u8 {
let bytes = payload.into_bytes();
let mut buf = Vec::<u8>::with_capacity(4 + bytes.len());
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(&bytes);
let p = buf.as_mut_ptr();
std::mem::forget(buf);
p
}
// --- the wasm ABI surface ---------------------------------------------------------------
// Gated to wasm32 so these symbols never collide with the host allocator on native builds.
#[cfg(target_arch = "wasm32")]
mod abi {
use super::*;
/// Allocate `len` bytes and hand ownership to the caller.
///
/// # Safety
/// Release with `dealloc(ptr, len)` using the same length.
#[no_mangle]
pub extern "C" fn alloc(len: usize) -> *mut u8 {
let mut buf = Vec::<u8>::with_capacity(len);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf);
ptr
}
/// Release a buffer previously returned by `alloc`.
///
/// # Safety
/// `ptr` must come from `alloc`, with `len` as allocated.
#[no_mangle]
pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: usize) {
if !ptr.is_null() {
drop(Vec::from_raw_parts(ptr, 0, len));
}
}
/// Parse and store the vectors document. Returns 0 on success, -1 on parse failure.
///
/// # Safety
/// `ptr`/`len` must describe a valid UTF-8 buffer obtained from `alloc`.
#[no_mangle]
pub unsafe extern "C" fn load(ptr: *const u8, len: usize) -> i32 {
let bytes = std::slice::from_raw_parts(ptr, len);
match std::str::from_utf8(bytes) {
Ok(text) => store(text),
Err(_) => -1,
}
}
/// Run one kernel pass over the stored vectors. This is the timed entry point.
#[no_mangle]
pub extern "C" fn compute() -> *mut u8 {
leak_length_prefixed(compute_stored())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The wasm entry point must produce the same result as the native path — it is the same
/// code, and this pins that.
#[test]
fn compute_json_round_trips_a_minimal_document() {
let doc = serde_json::json!({
"name": "unit", "kind": "unit", "seed": 1,
"width": 4, "height": 4, "background": 1.0, "l0": 1.0,
"colors255": {"0": [255.0, 0.0, 0.0]},
"edges": [],
"faces": [],
"target_u8": vec![0u8; 4 * 4 * 3],
"golden": {
"e_data": 0.0,
"coverage_checksums": {},
"coverage_samples": [],
"vertex_gradients": []
}
})
.to_string();
assert_eq!(store(&doc), 0, "minimal document should parse");
let out = compute_stored();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
// No geometry → the image stays at background (1.0) against a black target: 3·16 = 48.
assert!((parsed["e_data"].as_f64().unwrap() - 48.0).abs() < 1e-12);
}
}
|