Files
scriptc/packages/compiler/test/worker-shared-loop-version-emission.test.ts
T
Chris Tate feb9877f1a Read and write shared typed-array elements at a counted loop's counter with plain accesses
In a program with workers, every element of a view that may be backed
by a SharedArrayBuffer was read and written with relaxed atomics, so that
a loop which reads one element again sees another thread's write. LLVM
does not vectorize loops with atomic accesses: a byte loop over a shared
Uint8Array ran at 8.5 ns per element in a program with workers and 0.14
ns in one without.

In a counted loop that polls for cancellation between blocks of
iterations, an element read or written at the loop's counter, outside any
loop nested in the body, is now a plain load or store, like a
thread-owned view's. The counter steps by a nonzero constant and the body
does not assign it, so every iteration reaches a different element and
no read is merged across iterations or moved out of the loop. Within one
iteration, a racing write from another thread can be seen or missed, the
documented gap of non-atomic shared accesses. Other accesses, Float16
elements and Atomics keep their atomic paths.

Corpus 8332 runs these loops over shared views of each element type on a
worker and the main thread, and matches Node's output.
2026-10-25 22:44:42 -05:00

77 lines
2.8 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
async function emitted(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-worker-shared-loop-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
return emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const kernel = `
function sumTo(view: Float64Array, n: number): number {
let total = 0;
for (let i = 0; i < n; i++) total += view[i]!;
return total;
}
`;
function body(ll: string, name: string): string {
return new RegExp(`^define internal [^\\n]*@sc_bf_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec(
ll,
)![0];
}
/** The instructions of the blocks whose labels start with `prefix`. */
function blocks(fn: string, prefix: string): string {
return fn
.split(/\n(?=[\w.$-]+:)/)
.filter((block) => block.startsWith(prefix))
.join("\n");
}
test("a worker program's loop over views that may be shared keeps a bounds-proven copy", async () => {
const ll = await emitted(`
import { Worker, isMainThread } from "node:worker_threads";
${kernel}
if (isMainThread) {
const view = new Float64Array(new SharedArrayBuffer(64));
console.log(sumTo(view, 8));
new Worker(new URL(import.meta.url));
}
`);
const fn = body(ll, "sumTo");
// The guard tests the bounds first, then whether the view is shared.
expect(fn).toMatch(/^bytes\.version\.owned\d*:/m);
expect(fn).toMatch(/^bytes\.version\.fast\.shared\d*:/m);
// The shared copy reads with no bounds test. Each copy is a counted loop
// that polls between blocks, so its read at the counter is a plain load
// (plainSharedElement in expr-bytes.ts).
const shared = blocks(fn, "bytes.version.fast.shared");
expect(shared).not.toMatch(/icmp (ult|uge|ugt) i64 %\w+, %\w+/);
expect(fn).toMatch(/load double, ptr %\w+/);
expect(fn).not.toMatch(/load atomic double/);
});
test("a program without workers keeps two copies of the loop", async () => {
const ll = await emitted(`${kernel}
console.log(sumTo(new Float64Array(8), 8));
`);
const fn = body(ll, "sumTo");
expect(fn).toMatch(/^bytes\.version\.fast\d*:/m);
expect(fn).not.toMatch(/bytes\.version\.fast\.shared/);
expect(fn).not.toMatch(/bytes\.version\.owned/);
});