WASM Memory
New to WASM in TJS? Start with the WASM Quick Start.
Zero-copy arrays and automatic data marshaling between JS and WASM.
/#
## How Data Moves Between JS and WASM
The #1 WebAssembly question: "How do I get my data into WASM?"
TJS handles it automatically. Three modes:
### 1. Scalars — pass through
`i32`, `f32`, `f64` go directly as WASM parameters. No marshaling.
### 2. Regular typed arrays — transparent copy
Pass a normal `Float32Array` and TJS copies it into WASM memory
before the call, then copies results back out after. You don't
have to think about it.
### 3. `wasmBuffer()` — zero-copy shared memory
Allocate directly in WASM memory. Both JS and WASM see the same
bytes. No copy in, no copy out. Mutations are instantly visible.
const xs = wasmBuffer(Float32Array, 50000)
xs[0] = 3.14 // JS writes to WASM memory
wasmFunction(xs) // WASM reads/writes the same memory
console.log(xs[0]) // JS sees WASM's mutations immediately
### Supported types
`Float32Array`, `Float64Array`, `Int32Array`, `Uint8Array`
### How it works internally
All WASM blocks in a file share one `WebAssembly.Memory` (64MB).
`wasmBuffer` is a bump allocator — it hands out slices of this memory.
When a typed array argument's `.buffer === wasmMemory.buffer`, the
wrapper skips the copy and passes the byte offset directly.
#/
// --- Regular arrays: transparent copy ---
function addOne(! arr: Float32Array, len: 0) {
wasm {
for (let i = 0; i < len; i += 4) {
let off = i * 4
let v = f32x4_load(arr, off)
let ones = f32x4_splat(1.0)
f32x4_store(arr, off, f32x4_add(v, ones))
}
} fallback {
for (let i = 0; i < len; i++) arr[i] += 1
}
}
// Regular Float32Array — TJS copies in before, copies out after
const regular = new Float32Array([10, 20, 30, 40])
addOne(regular, 4)
console.log('Regular array after WASM:', Array.from(regular))
// [11, 21, 31, 41] — changes visible in JS
// --- wasmBuffer: zero-copy shared memory ---
const shared = wasmBuffer(Float32Array, 4)
shared[0] = 100
shared[1] = 200
shared[2] = 300
shared[3] = 400
addOne(shared, 4)
console.log('wasmBuffer after WASM:', Array.from(shared))
// [101, 201, 301, 401] — zero copy, same memory
// --- Practical: large array processing ---
const SIZE = 10000
const data = wasmBuffer(Float32Array, SIZE)
for (let i = 0; i < SIZE; i++) data[i] = i * 0.01
// Process in WASM — no marshaling overhead
function normalize(! arr: Float32Array, len: 0) {
wasm {
// Find max (scalar — SIMD max needs horizontal reduction)
let max = 0.0
for (let i = 0; i < len; i++) {
let off = i * 4
let v = f32x4_extract_lane(f32x4_load(arr, off), 0)
if (v > max) { max = v }
}
// Scale to [0, 1]
if (max > 0.0) {
let inv = f32x4_splat(1.0 / max)
for (let i = 0; i < len; i += 4) {
let off = i * 4
f32x4_store(arr, off, f32x4_mul(f32x4_load(arr, off), inv))
}
}
} fallback {
let max = 0
for (let i = 0; i < len; i++) if (arr[i] > max) max = arr[i]
if (max > 0) for (let i = 0; i < len; i++) arr[i] /= max
}
}
// Measure per-op time by running until at least minMs of wall clock has elapsed,
// then dividing. Timing a single sub-millisecond pass does not work: Firefox clamps
// `performance.now()` to ~1ms for fingerprinting resistance, so a fast result reads
// as 0.00ms — and on hardware faster than whatever the iteration count was tuned for,
// a fixed count drifts back under the clock all by itself.
function timePerOp(fn, minMs = 25) {
let iters = 0
const start = performance.now()
do {
fn()
iters++
} while (performance.now() - start < minMs)
return (performance.now() - start) / iters
}
// Re-fill each pass: normalize() mutates `data`, and normalizing already-normalized
// data is a different (cheaper) workload — timing it would measure the wrong thing.
const perOp = timePerOp(() => {
for (let i = 0; i < SIZE; i++) data[i] = i * 0.01
normalize(data, SIZE)
})
console.log(`Normalized ${SIZE} floats in ${(perOp * 1000).toFixed(1)}us per pass`)
console.log('First 4:', data[0].toFixed(4), data[1].toFixed(4), data[2].toFixed(4), data[3].toFixed(4))
console.log('Last:', data[SIZE - 1].toFixed(4))