Convert clamped stores inline and test thread-owned views once in typed-array loops (#1767)

This commit is contained in:
Chris Tate
2026-10-22 02:14:03 -05:00
committed by GitHub
15 changed files with 348 additions and 19 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds |
| `template-render` | cpu | template literals, escaping, `map`/`join` |
| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread |
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, RGBA to gray |
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray |
| `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage |
| `unicode-lexer` | cpu | a hand-written lexer over a 2.7M-unit bundled module whose comments and strings are in several languages: `charCodeAt` scans, line starts |
| `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains |
@@ -17,6 +17,10 @@ function addWrap(a: Int32Array, b: Int32Array, out: Int32Array): void {
for (let i = 0; i < out.length; i++) out[i] = (a[i]! + b[i]!) | 0;
}
function brighten(src: Uint8ClampedArray, gain: number, out: Uint8ClampedArray): void {
for (let i = 0; i < out.length; i++) out[i] = src[i]! * gain + 8;
}
function gray(rgba: Uint8Array, out: Uint8Array): void {
for (let i = 0; i < out.length; i++) {
const j = i * 4;
@@ -38,6 +42,8 @@ const masked = new Uint8Array(n);
const ia = new Int32Array(n);
const ib = new Int32Array(n);
const io = new Int32Array(n);
const pixels = new Uint8ClampedArray(n);
const bright = new Uint8ClampedArray(n);
const rgba = new Uint8Array(n * 4);
const luma = new Uint8Array(n);
let state = 2463534242;
@@ -56,6 +62,7 @@ for (let i = 0; i < n; i++) {
key[i] = next() & 255;
ia[i] = next() | 0;
ib[i] = next() | 0;
pixels[i] = next() & 255;
}
for (let i = 0; i < rgba.length; i++) rgba[i] = next() & 255;
@@ -64,6 +71,7 @@ let f64Sum = 0;
let f32Sum = 0;
let byteSum = 0;
let intSum = 0;
let brightSum = 0;
let lumaSum = 0;
for (let r = 0; r < rounds; r++) {
// Change one input element per round so no round repeats the last.
@@ -72,21 +80,25 @@ for (let r = 0; r < rounds; r++) {
fa[at] = r;
data[at] = r & 255;
ia[at] = r * 65537;
pixels[at] = r & 255;
rgba[at * 4] = r & 255;
axpy(1.5, x, y, d);
blend((r & 15) / 16, fa, fb, fo);
mask(data, key, masked);
addWrap(ia, ib, io);
brighten(pixels, 0.75 + (r & 7) / 8, bright);
gray(rgba, luma);
const probe = (r * 104729) & (n - 1);
f64Sum += d[probe]!;
f32Sum += fo[probe]!;
byteSum += masked[probe]!;
intSum = (intSum + io[probe]!) | 0;
brightSum += bright[probe]!;
lumaSum += luma[probe]!;
}
console.log(`float64 ${f64Sum.toFixed(3)}`);
console.log(`float32 ${f32Sum.toFixed(3)}`);
console.log(`uint8 ${byteSum}`);
console.log(`int32 ${intSum}`);
console.log(`clamped ${brightSum}`);
console.log(`gray ${lumaSum}`);
@@ -109,6 +109,8 @@ function match(
ranges,
initialized: findInitializedByteLoopBindings(f).get(loop) ?? new Set(),
proven: new Set(),
workers: false,
owned: new Set(),
});
}
@@ -250,6 +252,35 @@ test("loops to a typed array's length version the other typed arrays they index"
expect(rebound).toContain("bytes.index.invalid");
});
test("worker programs test each receiver's shared storage once in the guard", () => {
// for (let i = 0; i < n; i++) out[i] = src[i] * 2, where either view may
// be shared.
const loop = counted([
set(ref("out", f64s), ref("i"), bin("*", get(ref("src", f64s), ref("i")), num(2))),
]);
const kernel = fn([loop]);
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "__main",
workers: true,
functions: [
{ name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
kernel,
],
};
const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m));
expect(guard.match(/icmp eq ptr %\S+, null/g)).toHaveLength(2);
const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0];
expect(fast).not.toContain("bytes.shared");
expect(fast).not.toMatch(/(load|store) atomic double/);
// The checked copy keeps the shared-memory path for each access.
const slow = body.slice(body.search(/^bytes\.version\.slow\d+:$/m), body.indexOf(fast));
expect(slow).toContain("load atomic double");
expect(slow).toContain("store atomic double");
});
test("Float16Array accesses version in elements and share the element tag", () => {
const f16s: IrType = { kind: "bytes", elem: "f16" };
const withHalves = (body: IrStmt[]): IrFunction => {
@@ -6,7 +6,7 @@ import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js";
import { emitBytesHeaderLength } from "./expr-bytes.js";
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
type Node = IrExpr | IrStmt;
type VarRef = IrExpr & { kind: "varRef" };
@@ -41,8 +41,13 @@ interface AffineCheck {
export interface BytesLoopVersion {
/** Typed-array accesses that are in bounds whenever the guard holds. */
bounds: ReadonlySet<Node>;
/** Worker programs: typed-array accesses whose views the guard proves
* thread-owned (no shared storage). */
owned: ReadonlySet<Node>;
/** Root receiver bindings, read once before the loop. */
receivers: { ref: VarRef; global: boolean }[];
/** Receivers the guard requires to have no shared storage. */
ownedReceivers: number[];
fixed: FixedCheck[];
affine: AffineCheck[];
/** The counted loop whose iteration space the affine checks cover. */
@@ -66,6 +71,11 @@ export interface BytesLoopFacts {
initialized: ReadonlySet<string>;
/** Accesses already proved in bounds without a guard. */
proven: ReadonlySet<Node>;
/** Worker programs: an access of a view that may be shared tests the
* view's shared storage first. */
workers: boolean;
/** Accesses already proved thread-owned by an enclosing version. */
owned: ReadonlySet<Node>;
}
const MAX_COST = 400;
@@ -87,6 +97,11 @@ const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"])
* and last values in bounds, such as `out[base + i]` for `i < n` or
* `view.getUint32(i * 4)`.
*
* In worker programs, every access of a view that may be shared tests its
* shared storage. A view's storage is also fixed at construction, so the
* same guard tests each invariant receiver once, and the fast copy reads
* and writes thread-owned views directly.
*
* Only small synchronous loops are versioned (nested loops included), and
* the emitter bounds how deeply versions nest. The checked loop runs
* whenever the guard fails, and keeps every out-of-bounds read, ignored
@@ -314,10 +329,19 @@ export function matchBytesLoopVersion(
}
return index;
};
// Worker programs: accesses through invariant receivers, proven or not,
// become thread-owned when the guard finds no shared storage.
const owned = new Map<number, Node[]>();
const access = (node: Node, array: IrExpr, index: IrExpr, width: number): void => {
if (facts.proven.has(node)) return;
const ref = root(array);
if (!ref) return;
if (facts.workers && !facts.owned.has(node)) {
const receiver = receiverOf(ref);
const nodes = owned.get(receiver);
if (nodes) nodes.push(node);
else owned.set(receiver, [node]);
}
if (facts.proven.has(node)) return;
const shape = counter !== null ? affine(index) : null;
if (shape && shape.counter !== 0) {
const operands = new Set<IrExpr>();
@@ -383,9 +407,15 @@ export function matchBytesLoopVersion(
});
const fixedKept = [...fixed.values()].slice(0, MAX_CHECKS);
const affineKept = affineGroups.slice(0, MAX_CHECKS - fixedKept.length);
if (fixedKept.length === 0 && affineKept.length === 0) return null;
const ownedKept = [...owned.entries()].slice(0, MAX_CHECKS);
if (fixedKept.length === 0 && affineKept.length === 0 && ownedKept.length === 0) return null;
const bounds = new Set<Node>();
const ownedNodes = new Set<Node>();
const used = new Set<number>();
for (const [receiver, nodes] of ownedKept) {
used.add(receiver);
for (const node of nodes) ownedNodes.add(node);
}
const ranges = new Map<IrExpr, IntegerRange>();
const counterLow = Math.max(counted?.range.min ?? 0, -bound),
counterHigh = Math.min(counted?.range.max ?? 0, bound);
@@ -420,7 +450,9 @@ export function matchBytesLoopVersion(
(counted.range.min < -bound || counted.range.max > bound);
return {
bounds,
owned: ownedNodes,
receivers: order.map((index) => receivers[index]!),
ownedReceivers: ownedKept.map(([receiver]) => renumber.get(receiver)!),
fixed: fixedKept.map((g) => ({
receiver: renumber.get(g.check.receiver)!,
end: g.check.end,
@@ -439,7 +471,8 @@ export function matchBytesLoopVersion(
}
/** Branches to `fast` when every access the version proves is in bounds for
* every iteration, and to `slow` otherwise (including a counted loop whose
* every iteration and every view it proves thread-owned has no shared
* storage, and to `slow` otherwise (including a counted loop whose
* integer form is not established, or a module receiver not yet set). */
export function emitBytesLoopVersionGuard(
host: LlvmEmitterContext,
@@ -473,6 +506,11 @@ export function emitBytesLoopVersionGuard(
return wide;
});
const conditions: string[] = [];
for (const index of version.ownedReceivers) {
const owned = B.tmp();
B.line(`${owned} = icmp eq ptr ${emitBytesSharedStorage(host, receivers[index]!)}, null`);
conditions.push(owned);
}
for (const check of version.fixed) {
const ok = B.tmp();
B.line(`${ok} = icmp ugt i64 ${lengths[check.receiver]}, ${check.end}`);
@@ -228,6 +228,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.integerLoopRanges = rangeFacts.loopRanges;
emitter.int32Elements = slotFacts.elements ?? null;
emitter.bytesBounds = findBytesBounds(numericFn, emitter.integerRanges);
emitter.bytesOwned = new Set();
// Debug builds keep every number local a double for the debugger's
// variable view. Lazily boxed captured parameters keep their double,
// which the box is created from, and so do plain copies of a capture or
@@ -517,6 +517,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
s.index,
s.value,
emitter.bytesBounds.has(s),
emitter.bytesOwned.has(s),
);
break;
}
@@ -970,6 +971,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
done = B.newLabel("bytes.version.done");
emitBytesLoopVersionGuard(emitter, version, fast, slow);
const originalBounds = emitter.bytesBounds,
originalOwned = emitter.bytesOwned,
originalRanges = emitter.integerRanges;
emitter.bytesLoopVersioning.add(s);
try {
@@ -978,6 +980,8 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.br(done);
B.startBlock(fast);
emitter.bytesBounds = new Set([...originalBounds, ...version.bounds]);
if (version.owned.size > 0)
emitter.bytesOwned = new Set([...originalOwned, ...version.owned]);
if (version.ranges.size > 0)
emitter.integerRanges = new Map([...originalRanges, ...version.ranges]);
if (version.counted?.guarded) emitter.provenCountedGuards.add(s);
@@ -985,6 +989,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.br(done);
} finally {
emitter.bytesBounds = originalBounds;
emitter.bytesOwned = originalOwned;
emitter.integerRanges = originalRanges;
emitter.provenCountedGuards.delete(s);
emitter.bytesLoopVersioning.delete(s);
@@ -1125,6 +1125,9 @@ export class LlEmitter {
* guard already passed. */
readonly provenCountedGuards = new Set<IrStmt>();
bytesBounds: ReadonlySet<IrExpr | IrStmt> = new Set();
/** Worker programs: typed-array accesses whose views an enclosing version
* guard proved thread-owned. */
bytesOwned: ReadonlySet<IrExpr | IrStmt> = new Set();
integerViews = new Map<string, string>();
/** Module globals the current function owns outright (initializer
* bindings): their function-local slot, which stands in for the global. */
@@ -3836,6 +3839,8 @@ export class LlEmitter {
ranges: this.integerRanges,
initialized,
proven: this.bytesBounds,
workers: this.mod.workers === true,
owned: this.bytesOwned,
});
}
@@ -6196,8 +6201,9 @@ export class LlEmitter {
expr?: IrExpr,
inBounds = false,
invalidNaN = false,
owned = false,
): LlValue {
return emitBytesGet(this, elem, receiver, index, expr, inBounds, invalidNaN);
return emitBytesGet(this, elem, receiver, index, expr, inBounds, invalidNaN, owned);
}
emitToUint32(value: string, expr?: IrExpr, uint32?: string): string {
@@ -6260,8 +6266,9 @@ export class LlEmitter {
indexExpr?: IrExpr,
expr?: IrExpr,
inBounds = false,
owned = false,
): void {
return emitBytesSet(this, elem, receiver, index, value, indexExpr, expr, inBounds);
return emitBytesSet(this, elem, receiver, index, value, indexExpr, expr, inBounds, owned);
}
emitBytesIntrinsic(e: IrExpr & { kind: "bytesIntrinsic" }): LlValue {
@@ -176,6 +176,17 @@ export function emitBytesLength(
return { name: out, type: F64 };
}
/** A view's shared backing (null for a buffer this thread owns). It is
* set at construction, before script code can see the view. */
export function emitBytesSharedStorage(host: LlvmEmitterContext, receiver: string): string {
const B = host.B;
const slot = B.tmp(),
storage = B.tmp();
B.line(`${slot} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 9`);
B.line(`${storage} = load ptr, ptr ${slot}${bytesHeaderAlias(host)}`);
return storage;
}
/** 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;
@@ -186,12 +197,9 @@ export function emitBytesLength(
function emitSharedBytesSplit(host: LlvmEmitterContext, receiver: string): string {
const B = host.B;
host.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
const slot = B.tmp(),
storage = B.tmp(),
const storage = emitBytesSharedStorage(host, receiver),
isShared = B.tmp(),
expected = B.tmp();
B.line(`${slot} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 9`);
B.line(`${storage} = load ptr, ptr ${slot}${bytesHeaderAlias(host)}`);
B.line(`${isShared} = icmp ne ptr ${storage}, null`);
B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${isShared}, i1 false)`);
const shared = B.newLabel("bytes.shared"),
@@ -209,15 +217,17 @@ export function emitBytesGet(
expr?: IrExpr,
inBounds = false,
invalidNaN = false,
owned = false,
): LlValue {
const B = host.B;
// Any invalid index traps (the array runtime's discipline).
if (!invalidNaN) return emitBytesLoad(host, elem, receiver, index, expr, inBounds, undefined);
if (!invalidNaN)
return emitBytesLoad(host, elem, receiver, index, expr, inBounds, undefined, owned);
// The numeric view of an ordinary read: an invalid index never touches
// storage and reads as NaN — ToNumber of Node's undefined.
const invalid = B.newLabel("bytes.read.invalid");
const done = B.newLabel("bytes.read.done");
const loaded = emitBytesLoad(host, elem, receiver, index, expr, inBounds, invalid);
const loaded = emitBytesLoad(host, elem, receiver, index, expr, inBounds, invalid, owned);
const ok = B.newLabel("bytes.read.ok");
B.br(ok);
B.startBlock(ok);
@@ -260,8 +270,10 @@ function emitBytesLoad(
expr: IrExpr | undefined,
inBounds: boolean,
invalid: string | undefined,
owned: boolean,
): LlValue {
if (host.mod.workers) {
// `owned`: an enclosing version guard proved the view thread-owned.
if (host.mod.workers && !owned) {
const idx = emitBytesIndex(host, receiver, index, expr, invalid, inBounds);
return emitSharedOrLocalLoad(host, elem, receiver, idx);
}
@@ -515,13 +527,14 @@ export function emitBytesSet(
indexExpr?: IrExpr,
expr?: IrExpr,
inBounds = false,
owned = false,
): void {
const B = host.B;
// Invalid integer-indexed writes are ignored after evaluating the RHS.
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 && elem === "f16") {
if (host.mod.workers && !owned && elem === "f16") {
host.declare(`declare void @scr_bytes_shared_set(ptr, ${host.sizeType}, double)`);
const local = emitSharedBytesSplit(host, receiver);
B.line(
@@ -532,10 +545,24 @@ export function emitBytesSet(
}
let stored: string | null;
if (elem === "u8c") {
host.declare(`declare double @scr_bytes_to_u8_clamp(double)`);
const clamped = B.tmp();
// ToUint8Clamp, as scr_bytes_to_u8_clamp computes it: NaN and values
// at most 0 store 0, values at least 255 store 255, and values between
// round to the nearest integer, ties to even. Adding and subtracting
// 2^52 rounds a value in (0, 255) that way in the default rounding mode,
// with no call or branch, so loops of clamped stores can vectorize.
const positive = B.tmp(),
high = B.tmp(),
shifted = B.tmp(),
rounded = B.tmp(),
upper = B.tmp(),
clamped = B.tmp();
stored = B.tmp();
B.line(`${clamped} = call double @scr_bytes_to_u8_clamp(double ${value.name})`);
B.line(`${positive} = fcmp ogt double ${value.name}, 0.0`);
B.line(`${high} = fcmp oge double ${value.name}, 255.0`);
B.line(`${shifted} = fadd double ${value.name}, 0x4330000000000000`);
B.line(`${rounded} = fsub double ${shifted}, 0x4330000000000000`);
B.line(`${upper} = select i1 ${high}, double 255.0, double ${rounded}`);
B.line(`${clamped} = select i1 ${positive}, double ${upper}, double 0.0`);
B.line(`${stored} = fptoui double ${clamped} to i32`);
} else
stored =
@@ -572,7 +599,7 @@ export function emitBytesSet(
ty = "i32";
operand = stored!;
}
if (host.mod.workers) {
if (host.mod.workers && !owned) {
// A shared view's element takes a relaxed atomic store of its width.
const local = emitSharedBytesSplit(host, receiver);
const p = B.tmp();
@@ -721,6 +748,7 @@ export function emitBytesIntrinsic(
e.args[0],
host.bytesBounds.has(e),
e.invalidNaN === true,
host.bytesOwned.has(e),
);
case "slice":
return call(
@@ -406,3 +406,45 @@ test("large record clones stay outlined while small clones remain inlineable", (
expect(large).toContain("define internal ptr @sc_rclone_large(ptr %src) #2");
expect(large).toContain("attributes #2 = { noinline sanitize_address }");
});
test("Uint8ClampedArray stores clamp and round without a runtime call", () => {
const type = bytesOf("u8c");
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "__main",
functions: [
{ name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
{
name: "store",
params: [
{ localId: "px", name: "px", type },
{ localId: "v", name: "v", type: F64 },
],
returnType: VOID,
locals: [
{ id: "px", name: "px", type, mutable: false },
{ id: "v", name: "v", type: F64, mutable: false },
],
body: [
{
kind: "bytesSet",
arr: { kind: "varRef", localId: "px", type, loc },
index: { kind: "numLit", value: 0, type: F64, loc },
value: { kind: "varRef", localId: "v", type: F64, loc },
loc,
},
],
loc,
},
],
};
expect(validateModule(mod)).toEqual([]);
const body = /^define internal [^\n]*@sc_bf_store[^]*?^}/m.exec(emitLlvmModule(mod))![0];
expect(body).not.toContain("scr_bytes_to_u8_clamp");
expect(body).toMatch(/fcmp ogt double %\S+, 0\.0/);
expect(body).toMatch(/fcmp oge double %\S+, 255\.0/);
expect(body).toMatch(/fadd double %\S+, 0x4330000000000000/);
expect(body).toMatch(/fsub double %\S+, 0x4330000000000000/);
expect(body).toMatch(/store i8 [^\n]*, align 1/);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6994-uint8-clamped-store-rounding.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6995-worker-typed-array-loop-versions.ts"
],
"diags": []
}
@@ -0,0 +1,77 @@
// Uint8ClampedArray stores: NaN and values at most 0 store 0, values at
// least 255 store 255, and values between round to the nearest integer,
// ties to even. Covers single stores and loops, every tie between 0 and
// 255 with the doubles on either side of it, and values far outside.
/** The largest power of two at most `x` (x > 0). */
function floorPower(x: number): number {
let p = 1;
while (p * 2 <= x) p *= 2;
while (p > x) p /= 2;
return p;
}
/** The doubles just above and below `x` (x >= 0). */
function nextUp(x: number): number {
return x === 0 ? 5e-324 : x + floorPower(x) * 2 ** -52;
}
function nextDown(x: number): number {
if (x === 0) return -5e-324;
const p = floorPower(x);
return x - (p === x ? p * 2 ** -53 : p * 2 ** -52);
}
function storeAll(values: Float64Array, out: Uint8ClampedArray): void {
const n = out.length;
for (let i = 0; i < n; i++) out[i] = values[i]!;
}
function scale(px: Uint8ClampedArray, k: number): void {
const n = px.length;
for (let i = 0; i < n; i++) px[i] = px[i]! * k;
}
const special = [
NaN, -NaN, Infinity, -Infinity, 0, -0, 5e-324, -5e-324, 2.2250738585072014e-308, 0.49999999999999994,
0.5, 0.5000000000000001, 1, 1.5, 2.5, 3.5, 127.5, 128.5, 253.5, 254.49999999999997, 254.5,
254.50000000000003, 254.99999999999997, 255, 255.00000000000003, 255.5, 256, -1, -0.5, -0.4,
1e300, -1e300, 2 ** 31, 2 ** 32 + 0.5, 2 ** 52, 2 ** 52 + 1, 2 ** 53, -(2 ** 52),
];
const one = new Uint8ClampedArray(1);
const singles: number[] = [];
for (const v of special) {
one[0] = v;
singles.push(one[0]!);
}
console.log("single", singles.join(","));
const ties: number[] = [];
for (let k = 0; k <= 255; k++) {
ties.push(k + 0.5, nextDown(k + 0.5), nextUp(k + 0.5), k, nextDown(k), nextUp(k));
}
const values = new Float64Array(ties.length + special.length);
values.set(ties);
values.set(special, ties.length);
const stored = new Uint8ClampedArray(values.length);
storeAll(values, stored);
const expected = new Uint8ClampedArray(values.length);
for (let i = 0; i < values.length; i++) expected[i] = values[i]!;
let mismatches = 0;
for (let i = 0; i < values.length; i++) if (stored[i] !== expected[i]) mismatches++;
console.log("loop", values.length, "mismatches", mismatches);
let digest = 0;
for (let i = 0; i < stored.length; i++) digest = (Math.imul(digest, 31) + stored[i]!) | 0;
console.log("digest", digest, stored.subarray(0, 24).join(","));
console.log("tail", stored.subarray(ties.length).join(","));
const px = new Uint8ClampedArray(64);
for (let i = 0; i < px.length; i++) px[i] = i * 4.25;
scale(px, 1.37);
console.log("scaled", px.join(","));
scale(px, -1);
console.log("negated", px.join(","));
const fromInts = new Uint8ClampedArray(8);
const ints = new Int32Array([-300, -1, 0, 1, 254, 255, 256, 2147483647]);
for (let i = 0; i < fromInts.length; i++) fromInts[i] = ints[i]!;
console.log("ints", fromInts.join(","));
@@ -0,0 +1,74 @@
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
// Typed-array loops in a worker-enabled program, over views of a thread's
// own ArrayBuffer, views of a SharedArrayBuffer, and both in one loop. Each
// loop gives the same results whichever kind of buffer its views use, and
// writes through shared views are visible to the other thread.
function scale(src: Float64Array, dst: Float32Array, k: number): void {
for (let i = 0; i < dst.length; i++) dst[i] = src[i]! * k;
}
function countAbove(view: Int16Array, limit: number): number {
let n = 0;
for (let i = 0; i < view.length; i++) if (view[i]! > limit) n++;
return n;
}
function mix(a: Uint8Array, b: Uint8Array, out: Uint8ClampedArray): void {
const n = out.length;
for (let i = 0; i < n; i++) out[i] = a[i]! * 2 - b[i]!;
}
function bump(view: Int32Array, by: number): void {
for (let i = 0; i < view.length; i++) view[i] = (view[i]! + by) | 0;
}
function report(label: string, src: Float64Array, dst: Float32Array, ints: Int16Array, a: Uint8Array, b: Uint8Array, out: Uint8ClampedArray): string {
for (let i = 0; i < src.length; i++) src[i] = i * 1.5 - 4;
for (let i = 0; i < ints.length; i++) ints[i] = (i * 7919) % 2001 - 1000;
for (let i = 0; i < a.length; i++) {
a[i] = i * 37;
b[i] = 255 - i * 11;
}
scale(src, dst, 0.1);
mix(a, b, out);
return `${label}: ${dst.join(",")} | ${countAbove(ints, 250)} ${countAbove(ints.subarray(3), -1)} | ${out.join(",")}`;
}
function views(buffer: ArrayBuffer | SharedArrayBuffer, other: ArrayBuffer | SharedArrayBuffer): string {
const src = new Float64Array(buffer, 0, 12);
const dst = new Float32Array(other, 0, 10);
const ints = new Int16Array(buffer, 96, 40);
const a = new Uint8Array(other, 40, 16);
const b = new Uint8Array(buffer, 176, 16);
const out = new Uint8ClampedArray(other, 56, 16);
return report("pair", src, dst, ints, a, b, out);
}
if (isMainThread) {
// Bytes 224-255 of the shared buffer hold counters the views never use.
const shared = new SharedArrayBuffer(256);
console.log(views(new ArrayBuffer(256), new ArrayBuffer(256)));
console.log(views(shared, new ArrayBuffer(256)));
console.log(views(new ArrayBuffer(256), shared));
console.log(views(shared, new SharedArrayBuffer(256)));
const own = new Int32Array(8);
bump(own, 5);
bump(new Int32Array(shared, 224, 8), 1);
const worker = new Worker(new URL(import.meta.url), { workerData: shared });
worker.on("message", (line: string) => console.log(line));
worker.on("exit", (code: number) => {
const seen = new Int32Array(shared, 224, 8);
bump(seen, 1000);
console.log(`exit ${code}: ${seen.join(",")} own ${own.join(",")}`);
console.log(`dst after worker: ${new Float32Array(shared, 0, 10).join(",")}`);
});
} else {
const shared = workerData as SharedArrayBuffer;
const counters = new Int32Array(shared, 224, 8);
bump(counters, 10);
bump(counters.subarray(2, 5), -3);
parentPort!.postMessage(`worker: ${counters.join(",")}`);
parentPort!.postMessage(views(new ArrayBuffer(256), shared));
}
@@ -0,0 +1 @@
{ "plain": 2400, "san": 3300 }
@@ -0,0 +1 @@
{ "plain": 2700, "san": 3900 }