Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,13 @@ decide deliberately and document here.
cost ~45 ns/byte and capped host→guest byte traffic at ~22 MB/s). Stream
payload copies share these paths, and u8 stream chunks stay `Uint8Array`
through host buffers too, so a host-side stream read costs exactly the one
rendezvous copy.
rendezvous copy. Lists of the other flat element types (bool, s8,
u16–u64/s16–s64, f32/f64) keep their plain-array host shapes but also copy
bulk, through TypedArray views with the deterministic profile's NaN
canonicalization preserved in both directions (issue #67); the platform's
little-endianness is a named assumption checked once, with the DataView
per-element path as the big-endian fallback. `char` stays per-element (its
lift is per-element USV validation).
- **Memory views** are re-acquired after any call that can grow memory
(`ArrayBuffer` detach on `memory.grow`).
- **Resources.** Host-facing handles are classes with `Symbol.dispose`
Expand Down
13 changes: 13 additions & 0 deletions examples/guests/stream-pass/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ impl Guest for Component {
input
}

async fn take(mut input: StreamReader<u8>, count: u32) -> u64 {
let mut sum = 0u64;
for _ in 0..count {
match input.next().await {
Some(b) => sum += u64::from(b),
None => break,
}
}
sum
// `input` drops here with the remainder unread: the host's parked
// write settles short ("reader went away"), cleanly.
}

async fn consume_then_trap(mut input: StreamReader<u8>, count: u32) {
for _ in 0..count {
let _ = input.next().await;
Expand Down
5 changes: 5 additions & 0 deletions examples/guests/stream-pass/wit/world.wit
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ world stream-pass {
/// Result-position pass-through for a non-numeric element type.
export pass-through-text: async func(input: stream<string>) -> stream<string>;

/// Reads exactly `count` elements off `input` (leaving the rest unread),
/// returns their sum, and drops the reader — the bounded-consumption probe
/// behind host-side partial-write coverage (#67 checklist / #63 review F3).
export take: async func(input: stream<u8>, count: u32) -> u64;

/// Reads `count` elements off `input`, then traps (unreachable) — the #66
/// shape: the stream's readable end dies inside this instance's poisoned
/// handle table while host writes are still parked.
Expand Down
205 changes: 205 additions & 0 deletions runtime/src/cabi/bulk_lists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Bulk (TypedArray-backed) list copies for flat element types (issue #67).
//
// The per-element interpreted `load()`/`store()` costs ~13-45 ns/element
// (despecialize + asserts + DataView per element); these helpers replace the
// loop body with one typed-array view per list and a tight per-element pass
// that preserves the interpreted path's EXACT observable semantics:
//
// * integers: the same `assert_` texts as `storeInt` (`"int store"`,
// `"64-bit store requires bigint"`), the same wrap-on-overflow (a
// TypedArray element write coerces exactly like the matching DataView
// setter), the same number/bigint host shapes on lift;
// * floats: the deterministic profile's NaN canonicalization on BOTH
// directions (float.ts `decodeI32AsFloat` / `encodeFloatAsI32`): every
// lifted NaN becomes the JS canonical NaN, every stored `number` NaN
// writes the canonical bit pattern. Non-NaN values round-trip bit-exactly
// (Float32Array narrowing is the same IEEE round-to-nearest-even as
// `DataView.setFloat32`);
// * bool: store normalizes any value by truthiness to 0/1 (`store()`'s
// `Number(Boolean(v))`), lift maps any nonzero byte to `true`
// (`convertIntToBool` semantics — it never traps for unsigned bytes).
//
// u8 is NOT here: it has its own, shape-changing fast path (`list<u8>` is
// `Uint8Array` on the host — load.ts/store.ts). char is NOT here: its lift
// validates USVs per element (`convertI32ToChar` traps), which is the cost.
//
// NAMED ASSUMPTION (issue #67): wasm linear memory is little-endian by spec;
// JS TypedArrays follow the PLATFORM's endianness. Every engine deltic
// targets runs little-endian, but rather than bake that in silently, the
// check below gates the fast paths — on a big-endian platform they decline
// and the callers keep the (endianness-correct) DataView per-element loops.
//
// Alignment: the canonical ABI guarantees list pointers are element-aligned,
// and real guest memories sit at byteOffset 0, so the view construction
// below virtually never declines; a misaligned combination (possible for a
// test MemInst wrapping a subarray) falls back the same way.

import { assert_ } from "./trap.ts";
import { bytesOf, type MemInst } from "./memory.ts";
import type { ComponentValue } from "./types.ts";
import { CANONICAL_FLOAT32_NAN, CANONICAL_FLOAT64_NAN } from "./float.ts";

export const PLATFORM_LITTLE_ENDIAN: boolean =
new Uint8Array(new Uint32Array([0x11223344]).buffer)[0] === 0x44;

type IntArrayCtor =
| Int8ArrayConstructor
| Uint16ArrayConstructor
| Int16ArrayConstructor
| Uint32ArrayConstructor
| Int32ArrayConstructor;

const INT_CTORS: Record<string, IntArrayCtor> = {
s8: Int8Array,
u16: Uint16Array,
s16: Int16Array,
u32: Uint32Array,
s32: Int32Array,
};

const BIG_CTORS: Record<
string,
BigUint64ArrayConstructor | BigInt64ArrayConstructor
> = {
u64: BigUint64Array,
s64: BigInt64Array,
};

const FLOAT_CTORS: Record<
string,
Float32ArrayConstructor | Float64ArrayConstructor
> = {
f32: Float32Array,
f64: Float64Array,
};

/** Kinds these helpers handle (besides them, u8 has its own path). */
export function isBulkListKind(kind: string): boolean {
return kind === "bool" || kind in INT_CTORS || kind in BIG_CTORS ||
kind in FLOAT_CTORS;
}

function viewOf<
C extends { new (b: ArrayBufferLike, o: number, n: number): InstanceType<C>; readonly BYTES_PER_ELEMENT: number },
>(ctor: C, mem: MemInst, ptr: number, length: number): InstanceType<C> | null {
if (!PLATFORM_LITTLE_ENDIAN) return null;
const byteOffset = mem.bytes.byteOffset + ptr;
if (byteOffset % ctor.BYTES_PER_ELEMENT !== 0) return null;
return new ctor(mem.bytes.buffer, byteOffset, length);
}

/**
* Bulk lift of `length` elements of `kind` at `ptr`. Returns `null` when the
* kind is not handled here (caller falls back to the per-element loop) —
* never for a handled kind on a little-endian platform with an aligned view.
* The caller has already trap-checked alignment and bounds.
*/
export function tryLoadNumericList(
mem: MemInst,
ptr: number,
length: number,
kind: string,
): ComponentValue[] | null {
if (kind === "bool") {
const out = new Array<ComponentValue>(length);
const bytes = bytesOf(mem, ptr, length); // range assert: defense-in-depth
for (let i = 0; i < length; i++) out[i] = bytes[i] !== 0;
return out;
}
const intCtor = INT_CTORS[kind];
if (intCtor !== undefined) {
const view = viewOf(intCtor, mem, ptr, length);
if (view === null) return null;
// Manual preallocated loop: measurably faster than Array.from(view).
const out = new Array<ComponentValue>(length);
for (let i = 0; i < length; i++) out[i] = view[i];
return out;
}
const bigCtor = BIG_CTORS[kind];
if (bigCtor !== undefined) {
const view = viewOf(bigCtor, mem, ptr, length);
if (view === null) return null;
const out = new Array<ComponentValue>(length);
for (let i = 0; i < length; i++) out[i] = view[i];
return out;
}
const floatCtor = FLOAT_CTORS[kind];
if (floatCtor !== undefined) {
const view = viewOf(floatCtor, mem, ptr, length);
if (view === null) return null;
const out = new Array<ComponentValue>(length);
for (let i = 0; i < length; i++) {
const v = view[i];
// decodeI32AsFloat/decodeI64AsFloat: every NaN lifts as the canonical
// one (the JS NaN literal IS the canonical f64 NaN, and the canonical
// f32 NaN widens to it exactly).
out[i] = v === v ? v : NaN;
}
return out;
}
return null;
}

/**
* Bulk store of `v` as elements of `kind` at `ptr`. Returns false when not
* handled (caller falls back). The caller has already trap-checked alignment
* and bounds.
*/
export function tryStoreNumericList(
mem: MemInst,
v: ArrayLike<ComponentValue>,
ptr: number,
kind: string,
): boolean {
const n = v.length;
if (kind === "bool") {
const bytes = bytesOf(mem, ptr, n); // range assert: defense-in-depth
for (let i = 0; i < n; i++) bytes[i] = v[i] ? 1 : 0;
return true;
}
const intCtor = INT_CTORS[kind];
if (intCtor !== undefined) {
const view = viewOf(intCtor, mem, ptr, n);
if (view === null) return false;
for (let i = 0; i < n; i++) {
const x = v[i];
assert_(typeof x === "number" && Number.isInteger(x), "int store");
view[i] = x as number; // wraps exactly like the DataView setter
}
return true;
}
const bigCtor = BIG_CTORS[kind];
if (bigCtor !== undefined) {
const view = viewOf(bigCtor, mem, ptr, n);
if (view === null) return false;
for (let i = 0; i < n; i++) {
const x = v[i];
assert_(typeof x === "bigint", "64-bit store requires bigint");
view[i] = x as bigint; // wraps mod 2^64 like setBigUint64/setBigInt64
}
return true;
}
const floatCtor = FLOAT_CTORS[kind];
if (floatCtor !== undefined) {
const view = viewOf(floatCtor, mem, ptr, n);
if (view === null) return false;
const size = floatCtor.BYTES_PER_ELEMENT;
for (let i = 0; i < n; i++) {
const x = v[i];
if (typeof x === "number" && Number.isNaN(x)) {
// encodeFloatAsI32/encodeFloatAsI64: a number NaN stores the
// canonical bit pattern, never the engine's.
if (size === 4) {
mem.view.setUint32(ptr + i * 4, CANONICAL_FLOAT32_NAN, true);
} else {
mem.view.setBigUint64(ptr + i * 8, CANONICAL_FLOAT64_NAN, true);
}
} else {
// Same ToNumber coercion + IEEE narrowing as DataView.setFloat*.
view[i] = x as number;
}
}
return true;
}
return false;
}
10 changes: 9 additions & 1 deletion runtime/src/cabi/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "./layout.ts";
import { convertI32ToChar, loadString } from "./strings.ts";
import { type LiftLowerContext, requireMemory } from "./context.ts";
import { tryLoadNumericList } from "./bulk_lists.ts";
import { liftBorrow, liftOwn } from "./handles.ts";
import {
type CaseType,
Expand Down Expand Up @@ -130,10 +131,17 @@ export function loadListFromValidRange(
elemType: ValType,
): ComponentValue {
const mem = requireMemory(cx.opts);
const kind = despecialize(elemType).kind;
// docs/architecture.md §7: list<u8> lifts to a Uint8Array copy.
if (despecialize(elemType).kind === "u8") {
if (kind === "u8") {
return bytesOf(mem, ptr, length).slice();
}
// Other flat element types lift bulk too (issue #67) — same host shapes
// (number[]/bigint[]/boolean[]), same NaN canonicalization; falls through
// to the per-element loop for compound types, char (per-element USV
// validation is the point), and non-little-endian platforms.
const bulk = tryLoadNumericList(mem, ptr, length, kind);
if (bulk !== null) return bulk;
const size = elemSize(elemType, mem.ptrType());
const a: ComponentValue[] = [];
for (let i = 0; i < length; i++) {
Expand Down
1 change: 1 addition & 0 deletions runtime/src/cabi/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from "./float.ts";
export * from "./context.ts";
export * from "./handles.ts";
export * from "./strings.ts";
export * from "./bulk_lists.ts";
export * from "./load.ts";
export * from "./store.ts";
export * from "./flatten.ts";
Expand Down
9 changes: 8 additions & 1 deletion runtime/src/cabi/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { assert_, NotImplemented, trapIf } from "./trap.ts";
import { bytesOf, storeInt, storePtr } from "./memory.ts";
import { tryStoreNumericList } from "./bulk_lists.ts";
import { encodeFloatAsI32, encodeFloatAsI64 } from "./float.ts";
import {
alignment,
Expand Down Expand Up @@ -163,12 +164,13 @@ export function storeListIntoValidRange(
elemType: ValType,
): void {
const mem = requireMemory(cx.opts);
const kind = despecialize(elemType).kind;
// docs/architecture.md §7: list<u8> is Uint8Array-shaped on the host, and
// both directions are bulk copies — this is the store-side mirror of
// load.ts `loadListFromValidRange`'s u8 fast path (issue #54: the
// per-element interpreted store cost ~45 ns/byte, capping async imports
// returning list<u8> at ~22 MB/s while the lift ran at memcpy speed).
if (despecialize(elemType).kind === "u8") {
if (kind === "u8") {
const dst = bytesOf(mem, ptr, v.length);
if (v instanceof Uint8Array) {
dst.set(v);
Expand All @@ -184,6 +186,11 @@ export function storeListIntoValidRange(
}
return;
}
// Other flat element types store bulk too (issue #67), preserving the
// per-element semantics exactly (same asserts, same wrap, canonical-NaN
// floats); falls through for compound types, char, and non-little-endian
// platforms.
if (tryStoreNumericList(mem, v, ptr, kind)) return;
const size = elemSize(elemType, mem.ptrType());
for (let i = 0; i < v.length; i++) {
store(cx, v[i], elemType, ptr + i * size);
Expand Down
Loading
Loading