Read and write shared typed-array elements inline

This commit is contained in:
Chris Tate
2026-10-22 00:37:37 -05:00
parent 9e252e1700
commit 8bbccee4f6
5 changed files with 228 additions and 35 deletions
@@ -176,10 +176,10 @@ export function emitBytesLength(
return { name: out, type: F64 };
}
/** Worker programs: a view of a SharedArrayBuffer (`shared` is fixed before
* script code can see the view) is read and written by the runtime under
* its shared-memory lock; every other buffer belongs to this thread and
* takes the ordinary inline path. The test follows the index check: a
/** Worker programs: an element of a view of a SharedArrayBuffer (`shared` is
* fixed before script code can see the view) is read and written with one
* relaxed atomic access of its width, as the runtime's shared accessors do;
* every other buffer belongs to this thread and takes the ordinary path. The test follows the index check: a
* view's length never changes, so both paths use the one checked integer
* index. Branches to the returned label for a thread-owned buffer and
* continues in the shared block. */
@@ -276,8 +276,9 @@ function emitBytesLoad(
* shared. An integer element joins as its 32-bit value and converts to a
* number after the join, as the local load does, so compares and
* ToInt32/ToUint32 of the result stay integer operations. The shared read
* is an ordinary call: another thread's write must stay visible to a loop
* that reads the same element again. */
* is a monotonic atomic load, which LLVM neither hoists nor merges: another
* thread's write stays visible to a loop that reads the same element again.
* Float16 elements read through the runtime. */
function emitSharedOrLocalLoad(
host: LlvmEmitterContext,
elem: IrBytesElem,
@@ -286,12 +287,18 @@ function emitSharedOrLocalLoad(
): LlValue {
const B = host.B;
const floating = elem === "f16" || elem === "f32" || elem === "f64";
const fn = floating ? "scr_bytes_shared_get_f64" : "scr_bytes_shared_get_int";
const ret = floating ? "double" : "i32";
host.declare(`declare ${ret} @${fn}(ptr, ${host.sizeType})`);
const local = emitSharedBytesSplit(host, receiver);
const shared = B.tmp();
B.line(`${shared} = call ${ret} @${fn}(ptr ${receiver}, ${host.sizeType} ${idx}) cold`);
let shared: string;
if (elem === "f16") {
host.declare(`declare double @scr_bytes_shared_get_f64(ptr, ${host.sizeType})`);
shared = B.tmp();
B.line(
`${shared} = call double @scr_bytes_shared_get_f64(ptr ${receiver}, ${host.sizeType} ${idx}) cold`,
);
} else {
const loaded = emitSharedLoadAt(host, elem, host.emitBytesData(receiver), idx);
shared = loaded.uint32 ?? loaded.name;
}
const sharedDone = B.newLabel("bytes.shared.done");
B.br(sharedDone);
B.startBlock(local);
@@ -316,6 +323,43 @@ function emitSharedOrLocalLoad(
return { name: out, type: F64, uint32: u32 };
}
/** The element at a checked index of a shared view, given its data pointer:
* a relaxed atomic load of the element's width, aligned as shared views
* are. Not for Float16 elements. */
function emitSharedLoadAt(
host: LlvmEmitterContext,
elem: IrBytesElem,
data: string,
idx: string,
): LlValue {
const B = host.B;
const size = BYTES_ELEMENT_SIZE[elem];
const p = B.tmp();
if (elem === "f32" || elem === "f64") {
const ty = elem === "f32" ? "float" : "double";
const raw = B.tmp();
B.line(`${p} = getelementptr inbounds ${ty}, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`${raw} = load atomic ${ty}, ptr ${p} monotonic, align ${size}`);
if (elem === "f64") return { name: raw, type: F64 };
const out = B.tmp();
B.line(`${out} = fpext float ${raw} to double`);
return { name: out, type: F64 };
}
const bits = size * 8;
const raw = B.tmp();
B.line(`${p} = getelementptr inbounds i${bits}, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`${raw} = load atomic i${bits}, ptr ${p} monotonic, align ${size}`);
const signed = elem === "i8" || elem === "i16" || elem === "i32";
let wide = raw;
if (bits < 32) {
wide = B.tmp();
B.line(`${wide} = ${signed ? "sext" : "zext"} i${bits} ${raw} to i32`);
}
const out = B.tmp();
B.line(`${out} = ${signed ? "sitofp" : "uitofp"} i32 ${wide} to double`);
return { name: out, type: F64, uint32: wide };
}
/** The element at a checked index of a thread-owned view, given its data
* pointer. */
function emitBytesLoadAt(
@@ -477,7 +521,7 @@ export function emitBytesSet(
const done = B.newLabel("bytes.store.done");
const data = host.emitBytesData(receiver);
const idx = emitBytesIndex(host, receiver, index, indexExpr, done, inBounds);
if (host.mod.workers) {
if (host.mod.workers && elem === "f16") {
host.declare(`declare void @scr_bytes_shared_set(ptr, ${host.sizeType}, double)`);
const local = emitSharedBytesSplit(host, receiver);
B.line(
@@ -498,32 +542,48 @@ export function emitBytesSet(
elem === "f16" || elem === "f32" || elem === "f64"
? null
: host.emitToUint32(value.name, expr, value.uint32);
const p = B.tmp();
if (elem === "f16") {
// One round-to-nearest-even step from the double, in the runtime.
host.declare(`declare zeroext i16 @scr_f16_from_f64(double)`);
const narrowed = B.tmp();
const p = B.tmp();
B.line(`${narrowed} = call zeroext i16 @scr_f16_from_f64(double ${value.name})`);
B.line(`${p} = getelementptr inbounds i16, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store i16 ${narrowed}, ptr ${p}, align 1${bytesElementAlias(host)}`);
} else if (BYTES_ELEMENT_SIZE[elem] < 4) {
const bits = BYTES_ELEMENT_SIZE[elem] * 8;
const byte = B.tmp();
B.line(`${byte} = trunc i32 ${stored!} to i${bits}`);
B.line(`${p} = getelementptr inbounds i${bits}, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store i${bits} ${byte}, ptr ${p}, align 1${bytesElementAlias(host)}`);
} else if (elem === "f32") {
const narrowed = B.tmp();
B.line(`${narrowed} = fptrunc double ${value.name} to float`);
B.line(`${p} = getelementptr inbounds float, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store float ${narrowed}, ptr ${p}, align 1${bytesElementAlias(host)}`);
} else if (elem === "f64") {
B.line(`${p} = getelementptr inbounds double, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store double ${value.name}, ptr ${p}, align 1${bytesElementAlias(host)}`);
} else {
B.line(`${p} = getelementptr inbounds i32, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store i32 ${stored!}, ptr ${p}, align 1${bytesElementAlias(host)}`);
B.br(done);
B.startBlock(done);
return;
}
// The element's bits as an LLVM value of the element's width.
let ty: string;
let operand: string;
if (BYTES_ELEMENT_SIZE[elem] < 4) {
ty = `i${BYTES_ELEMENT_SIZE[elem] * 8}`;
operand = B.tmp();
B.line(`${operand} = trunc i32 ${stored!} to ${ty}`);
} else if (elem === "f32") {
ty = "float";
operand = B.tmp();
B.line(`${operand} = fptrunc double ${value.name} to float`);
} else if (elem === "f64") {
ty = "double";
operand = value.name;
} else {
ty = "i32";
operand = stored!;
}
if (host.mod.workers) {
// A shared view's element takes a relaxed atomic store of its width.
const local = emitSharedBytesSplit(host, receiver);
const p = B.tmp();
B.line(`${p} = getelementptr inbounds ${ty}, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store atomic ${ty} ${operand}, ptr ${p} monotonic, align ${BYTES_ELEMENT_SIZE[elem]}`);
B.br(done);
B.startBlock(local);
}
const p = B.tmp();
B.line(`${p} = getelementptr inbounds ${ty}, ptr ${data}, ${host.sizeType} ${idx}`);
B.line(`store ${ty} ${operand}, ptr ${p}, align 1${bytesElementAlias(host)}`);
B.br(done);
B.startBlock(done);
}
@@ -335,11 +335,17 @@ test("a body mutation keeps the byte loop on the general f64 path", () => {
test("worker programs read and write shared views at the checked integer index", () => {
const ll = emitLlvmModule({ ...fixture(), workers: true });
// The shared path receives the index the inline path checked, as an integer,
// and integer elements come back as 32-bit values.
expect(ll).toMatch(/call i32 @scr_bytes_shared_get_int\(ptr %\w+, i64 %?\w+\) cold/);
expect(ll).toMatch(/call double @scr_bytes_shared_get_f64\(ptr %\w+, i64 %?\w+\) cold/);
expect(ll).toMatch(/call void @scr_bytes_shared_set\(ptr %\w+, i64 %?\w+, double %?[\w.]+\)/);
// The shared path uses the index the inline path checked, as an integer:
// each element is one relaxed atomic access of its width, aligned to it.
expect(ll).toMatch(/load atomic i8, ptr %\w+ monotonic, align 1/);
expect(ll).toMatch(/load atomic i32, ptr %\w+ monotonic, align 4/);
expect(ll).toMatch(/load atomic float, ptr %\w+ monotonic, align 4/);
expect(ll).toMatch(/store atomic i8 [%\w.]+, ptr %\w+ monotonic, align 1/);
expect(ll).toMatch(/store atomic i32 [%\w.]+, ptr %\w+ monotonic, align 4/);
expect(ll).toMatch(/store atomic float [%\w.]+, ptr %\w+ monotonic, align 4/);
expect(ll).not.toContain("@scr_bytes_shared_get_int");
expect(ll).not.toContain("@scr_bytes_shared_get_f64");
expect(ll).not.toContain("@scr_bytes_shared_set");
expect(ll).not.toContain("@scr_bytes_get_or_nan");
expect(ll).not.toContain("@scr_bytes_set(");
// Both paths join as one integer, which converts to a number after the join.
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6864-shared-typed-array-elements.ts"
],
"diags": []
}
@@ -0,0 +1,120 @@
// Element reads and writes of typed arrays over a SharedArrayBuffer, for every
// element kind, from the main thread and from a worker: each kind's
// conversion on write (wrapping, clamping, rounding to float32 and float16),
// NaN, -0 and the infinities, and values one thread writes and the other
// reads after a message. A worker loop that reads a flag without Atomics
// sees the main thread's write.
import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
const KINDS = 10;
const VALUES = [0, -0, 1, -1, 1.5, 2.5, -2.5, 127, 128, 255, 256, -129, 65535, 65536, 2 ** 31, -(2 ** 31) - 1, 2 ** 32 + 7, 0.1, 1e40, -1e-50, NaN, Infinity, -Infinity];
const LENGTH = VALUES.length;
interface Views {
i8: Int8Array;
u8: Uint8Array;
u8c: Uint8ClampedArray;
i16: Int16Array;
u16: Uint16Array;
i32: Int32Array;
u32: Uint32Array;
f16: Float16Array;
f32: Float32Array;
f64: Float64Array;
}
/** One region of LENGTH elements per kind, each aligned to 8 bytes. */
function viewsOf(sab: SharedArrayBuffer): Views {
const region = LENGTH * 8;
return {
i8: new Int8Array(sab, 0 * region, LENGTH),
u8: new Uint8Array(sab, 1 * region, LENGTH),
u8c: new Uint8ClampedArray(sab, 2 * region, LENGTH),
i16: new Int16Array(sab, 3 * region, LENGTH),
u16: new Uint16Array(sab, 4 * region, LENGTH),
i32: new Int32Array(sab, 5 * region, LENGTH),
u32: new Uint32Array(sab, 6 * region, LENGTH),
f16: new Float16Array(sab, 7 * region, LENGTH),
f32: new Float32Array(sab, 8 * region, LENGTH),
f64: new Float64Array(sab, 9 * region, LENGTH),
};
}
function show(x: number): string {
return Object.is(x, -0) ? "-0" : String(x);
}
/** Writes VALUES (scaled by `scale`) through every view. */
function writeAll(v: Views, scale: number): void {
for (let i = 0; i < LENGTH; i++) {
const x = VALUES[i]! * scale;
v.i8[i] = x;
v.u8[i] = x;
v.u8c[i] = x;
v.i16[i] = x;
v.u16[i] = x;
v.i32[i] = x;
v.u32[i] = x;
v.f16[i] = x;
v.f32[i] = x;
v.f64[i] = x;
}
}
/** Every element of every view, read one at a time, and a sum per view. */
function readAll(v: Views): string[] {
const lines: string[] = [];
const row = (kind: string, read: (i: number) => number): void => {
const cells: string[] = [];
for (let i = 0; i < LENGTH; i++) cells.push(show(read(i)));
lines.push(`${kind}: ${cells.join(" ")}`);
};
row("Int8Array", (i) => v.i8[i]!);
row("Uint8Array", (i) => v.u8[i]!);
row("Uint8ClampedArray", (i) => v.u8c[i]!);
row("Int16Array", (i) => v.i16[i]!);
row("Uint16Array", (i) => v.u16[i]!);
row("Int32Array", (i) => v.i32[i]!);
row("Uint32Array", (i) => v.u32[i]!);
row("Float16Array", (i) => v.f16[i]!);
row("Float32Array", (i) => v.f32[i]!);
row("Float64Array", (i) => v.f64[i]!);
let total = 0;
for (let i = 0; i < LENGTH; i++) total += v.i32[i]! + v.u16[i]! + (v.u8c[i]! | 0);
lines.push(`integer total ${total}`);
return lines;
}
if (isMainThread) {
const sab = new SharedArrayBuffer(KINDS * LENGTH * 8 + 16);
const views = viewsOf(sab);
const flag = new Int32Array(sab, KINDS * LENGTH * 8, 2);
writeAll(views, 1);
console.log("main wrote, main reads");
for (const line of readAll(views)) console.log(line);
const worker = new Worker(new URL(import.meta.url), { workerData: sab });
worker.on("message", (lines: string[]) => {
if (lines.length === 1) {
console.log(lines[0]);
return;
}
console.log("main wrote, worker reads; worker wrote, main reads");
for (const line of lines) console.log(line);
for (const line of readAll(views)) console.log(line);
flag[1] = 7;
flag[0] = 1;
});
} else {
const sab = workerData as SharedArrayBuffer;
const views = viewsOf(sab);
const flag = new Int32Array(sab, KINDS * LENGTH * 8, 2);
const seen = readAll(views);
writeAll(views, -3);
parentPort!.postMessage(seen);
while (flag[0] === 0) {
// Spin until the main thread's write is visible.
}
parentPort!.postMessage([`worker saw the flag, then ${flag[1]}`]);
}
@@ -0,0 +1 @@
{ "plain": 2290, "san": 5070 }