From 491480e1ad3990c5d6f834e6afbd910420a34ee3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 02:09:43 -0500 Subject: [PATCH] Convert clamped stores inline and test thread-owned views once in typed-array loops A Uint8ClampedArray store called scr_bytes_to_u8_clamp for every element, which kept loops of clamped stores scalar. The store now computes ToUint8Clamp in line: NaN and values at most 0 store 0, values at least 255 store 255, and values between round through (v + 2^52) - 2^52, which rounds a value in (0, 255) to the nearest integer, ties to even, as the runtime function does. In worker-enabled programs, every typed-array element access tests the view's shared storage and branches between a relaxed atomic access and the ordinary one, so loops over thread-owned views kept one branch per access. A view's shared storage is fixed at construction, like its length, so the typed-array loop version guard now tests it once for each invariant receiver, and the fast copy reads and writes those views directly. In worker programs a loop is versioned for this even when no bound needs a guard. When any receiver is shared, the checked copy runs unchanged. Programs without workers emit the same code as before. Adds a Uint8ClampedArray brighten kernel to the typed-array-kernels workload. --- benchmarks/runtime/README.md | 2 +- .../runtime/workloads/typed-array-kernels.ts | 12 +++ .../backend/llvm/bytes-loop-versions.test.ts | 31 ++++++++ .../src/backend/llvm/bytes-loop-versions.ts | 46 ++++++++++- .../src/backend/llvm/emit-function.ts | 1 + .../src/backend/llvm/emit-statements.ts | 5 ++ packages/compiler/src/backend/llvm/emitter.ts | 11 ++- .../compiler/src/backend/llvm/expr-bytes.ts | 52 ++++++++++--- .../test/bytes-element-emission.test.ts | 42 ++++++++++ .../6994-uint8-clamped-store-rounding.ts.json | 6 ++ ...5-worker-typed-array-loop-versions.ts.json | 6 ++ .../6994-uint8-clamped-store-rounding.ts | 77 +++++++++++++++++++ .../6995-worker-typed-array-loop-versions.ts | 74 ++++++++++++++++++ .../6994-uint8-clamped-store-rounding.ts.json | 1 + ...5-worker-typed-array-loop-versions.ts.json | 1 + 15 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6994-uint8-clamped-store-rounding.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6995-worker-typed-array-loop-versions.ts.json create mode 100644 tests/corpus/6994-uint8-clamped-store-rounding.ts create mode 100644 tests/corpus/6995-worker-typed-array-loop-versions.ts create mode 100644 tests/harness/corpus-costs/6994-uint8-clamped-store-rounding.ts.json create mode 100644 tests/harness/corpus-costs/6995-worker-typed-array-loop-versions.ts.json diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 1acdc9ce7..cbf47675a 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -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 | diff --git a/benchmarks/runtime/workloads/typed-array-kernels.ts b/benchmarks/runtime/workloads/typed-array-kernels.ts index 87c227bbc..ba68f7241 100644 --- a/benchmarks/runtime/workloads/typed-array-kernels.ts +++ b/benchmarks/runtime/workloads/typed-array-kernels.ts @@ -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}`); diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index ea568bc3f..3fbc01cc5 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -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 => { diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index df8977426..dcb735e61 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -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; + /** Worker programs: typed-array accesses whose views the guard proves + * thread-owned (no shared storage). */ + owned: ReadonlySet; /** 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; /** Accesses already proved in bounds without a guard. */ proven: ReadonlySet; + /** 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; } 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(); 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(); @@ -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(); + const ownedNodes = new Set(); const used = new Set(); + for (const [receiver, nodes] of ownedKept) { + used.add(receiver); + for (const node of nodes) ownedNodes.add(node); + } const ranges = new Map(); 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}`); diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index d83b9ff64..5053c1b54 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -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 diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 433029651..d2e96994b 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -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); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 1f2be4439..c254d0907 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1125,6 +1125,9 @@ export class LlEmitter { * guard already passed. */ readonly provenCountedGuards = new Set(); bytesBounds: ReadonlySet = new Set(); + /** Worker programs: typed-array accesses whose views an enclosing version + * guard proved thread-owned. */ + bytesOwned: ReadonlySet = new Set(); integerViews = new Map(); /** 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 { diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index d36001bf6..062bfdfbe 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -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( diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 505b51963..c0a78a898 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -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/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6994-uint8-clamped-store-rounding.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6994-uint8-clamped-store-rounding.ts.json new file mode 100644 index 000000000..f5a6b8742 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6994-uint8-clamped-store-rounding.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6994-uint8-clamped-store-rounding.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6995-worker-typed-array-loop-versions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6995-worker-typed-array-loop-versions.ts.json new file mode 100644 index 000000000..8078248f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6995-worker-typed-array-loop-versions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6995-worker-typed-array-loop-versions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6994-uint8-clamped-store-rounding.ts b/tests/corpus/6994-uint8-clamped-store-rounding.ts new file mode 100644 index 000000000..5fc3295da --- /dev/null +++ b/tests/corpus/6994-uint8-clamped-store-rounding.ts @@ -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(",")); diff --git a/tests/corpus/6995-worker-typed-array-loop-versions.ts b/tests/corpus/6995-worker-typed-array-loop-versions.ts new file mode 100644 index 000000000..5b85271b9 --- /dev/null +++ b/tests/corpus/6995-worker-typed-array-loop-versions.ts @@ -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)); +} diff --git a/tests/harness/corpus-costs/6994-uint8-clamped-store-rounding.ts.json b/tests/harness/corpus-costs/6994-uint8-clamped-store-rounding.ts.json new file mode 100644 index 000000000..a52f2c454 --- /dev/null +++ b/tests/harness/corpus-costs/6994-uint8-clamped-store-rounding.ts.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 3300 } diff --git a/tests/harness/corpus-costs/6995-worker-typed-array-loop-versions.ts.json b/tests/harness/corpus-costs/6995-worker-typed-array-loop-versions.ts.json new file mode 100644 index 000000000..c0aef3bb7 --- /dev/null +++ b/tests/harness/corpus-costs/6995-worker-typed-array-loop-versions.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3900 }