diff --git a/apps/typegpu-docs/src/examples/tests/concurrent-scan/index.html b/apps/typegpu-docs/src/examples/tests/concurrent-scan/index.html
deleted file mode 100644
index cb3584bae..000000000
--- a/apps/typegpu-docs/src/examples/tests/concurrent-scan/index.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
Concurrent Scan Test
-
Running...
-
-
-
diff --git a/apps/typegpu-docs/src/examples/tests/concurrent-scan/index.ts b/apps/typegpu-docs/src/examples/tests/concurrent-scan/index.ts
deleted file mode 100644
index bbc31b369..000000000
--- a/apps/typegpu-docs/src/examples/tests/concurrent-scan/index.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-// irrelevant import so the file becomes a module
-import { prefixScan } from '@typegpu/concurrent-scan';
-import tgpu from 'typegpu';
-import * as d from 'typegpu/data';
-import * as std from 'typegpu/std';
-
-// setup
-const root = await tgpu.init({
- device: {
- requiredFeatures: [
- 'timestamp-query',
- ],
- },
-});
-const adapter = await navigator.gpu?.requestAdapter();
-const device = await adapter?.requestDevice();
-if (!device) {
- throw new Error('WebGPU is not supported!');
-}
-
-// --- test 0: concat ---
-
-/**
- * Concats two numbers. Loses precision when the result has more than 7 digits.
- * @example
- * concat(123, 456); // 123456
- */
-const concat10 = tgpu.fn([d.f32, d.f32], d.f32)((a, b) => {
- if (a === 0) return b;
- if (b === 0) return a;
- if (b === 1) return a * 10 + b;
- const digits = std.ceil(std.log(b) / std.log(10));
- const result = std.pow(10, digits) * a + b;
- const roundedResult = std.ceil(result - 0.4);
- return roundedResult;
-});
-
-const buffer = root
- .createBuffer(d.arrayOf(d.f32, 16), [
- 0,
- 0,
- 1,
- 0,
- 2,
- 0,
- 3,
- 0,
- 4,
- 0,
- 5,
- 6,
- 0,
- 0,
- 7,
- 0,
- ])
- .$usage('storage');
-const result = prefixScan(root, buffer, {
- identityElement: 0,
- operation: concat10,
-});
-await root.device.queue.onSubmittedWorkDone();
-
-const resultString = await result.read();
-const resultMessage = resultString.toString() ===
- '0,0,0,1,1,12,12,123,123,1234,1234,12345,123456,123456,123456,1234567'
- ? 'Test passed! The result is correct.'
- : 'Test failed! The result is incorrect.';
-console.log('Result:', resultString);
-
-const resultDiv = document.getElementById('result');
-if (!resultDiv) throw new Error('No result div found');
-resultDiv.textContent = resultMessage;
-resultDiv.style.color = resultMessage.includes('passed') ? 'green' : 'red';
-
-// --- test 1: sum (exclusive prefix sum) ---
-const sumBuffer = root.createBuffer(d.arrayOf(d.f32, 4), [1, 2, 3, 4]).$usage(
- 'storage',
-);
-const sumResult = prefixScan(root, sumBuffer, {
- identityElement: 0,
- operation: std.add,
-});
-await root.device.queue.onSubmittedWorkDone();
-
-const sumString = (await sumResult.read()).toString();
-const sumMessage = sumString === '0,1,3,6'
- ? 'Test passed! The result is correct.'
- : 'Test failed! The result is incorrect.';
-console.log('Sum Result:', sumString);
-const sumDiv = document.getElementById('result-sum');
-if (sumDiv) {
- sumDiv.textContent = sumMessage;
- sumDiv.style.color = sumMessage.includes('passed') ? 'green' : 'red';
-}
-
-// --- test 2 - std.max ---
-const maxFn = tgpu.fn([d.f32, d.f32], d.f32)((a, b) => std.max(a, b));
-const maxBuffer = root.createBuffer(d.arrayOf(d.f32, 4), [2, 5, 1, 4]).$usage(
- 'storage',
-);
-const maxResult = prefixScan(root, maxBuffer, {
- identityElement: 0,
- operation: maxFn,
-});
-await root.device.queue.onSubmittedWorkDone();
-const maxString = (await maxResult.read()).toString();
-const maxMessage = maxString === '0,2,5,5'
- ? 'Test passed! The result is correct.'
- : 'Test failed! The result is incorrect.';
-console.log('Max Result:', maxString);
-const maxDiv = document.getElementById('result-max');
-if (maxDiv) {
- maxDiv.textContent = maxMessage;
- maxDiv.style.color = maxMessage.includes('passed') ? 'green' : 'red';
-}
diff --git a/apps/typegpu-docs/src/examples/tests/prefix-scan/functions.ts b/apps/typegpu-docs/src/examples/tests/prefix-scan/functions.ts
new file mode 100644
index 000000000..7ef8cbfcc
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/tests/prefix-scan/functions.ts
@@ -0,0 +1,62 @@
+import tgpu from 'typegpu';
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+import type { BinaryOp } from '@typegpu/concurrent-scan';
+
+// tgpu functions
+
+export const addFn = tgpu.fn([d.f32, d.f32], d.f32)((a, b) => {
+ return a + b;
+});
+
+export const mulFn = tgpu.fn([d.f32, d.f32], d.f32)((a, b) => {
+ return a * b;
+});
+
+/**
+ * Concats two numbers. Loses precision when the result has more than 7 digits.
+ *
+ * @example
+ * concat10(123, 456); // 123456
+ * concat10(123, 0); // 123, since 0 is considered to have 0 digits
+ */
+export const concat10 = tgpu.fn([d.f32, d.f32], d.f32)((a, b) => {
+ if (a === 0) return b;
+ if (b === 0) return a;
+ if (b === 1) return a * 10 + b;
+ const digits = std.ceil(std.log(b) / std.log(10));
+ const result = std.pow(10, digits) * a + b;
+ const roundedResult = std.round(result);
+ return roundedResult;
+});
+
+// JS helpers
+
+function applyOp(
+ op: BinaryOp,
+ a: number | undefined,
+ b: number | undefined,
+): number {
+ return op.operation(a as number & d.F32, b as number & d.F32) as number;
+}
+
+export function prefixScanJS(arr: number[], op: BinaryOp) {
+ const result = Array.from({ length: arr.length }, () => op.identityElement);
+ for (let i = 1; i < arr.length; i++) {
+ result[i] = applyOp(op, result[i - 1], arr[i - 1]);
+ }
+ return result;
+}
+
+export function scanJS(arr: number[], op: BinaryOp) {
+ let result = op.identityElement;
+ for (let i = 0; i < arr.length; i++) {
+ result = applyOp(op, result, arr[i]);
+ }
+ return [result];
+}
+
+export function isArrayEqual(arr1: number[], arr2: number[]): boolean {
+ return arr1.length === arr2.length &&
+ arr1.every((elem, i) => elem === arr2[i]);
+}
diff --git a/apps/typegpu-docs/src/examples/tests/prefix-scan/index.html b/apps/typegpu-docs/src/examples/tests/prefix-scan/index.html
new file mode 100644
index 000000000..ebebada87
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/tests/prefix-scan/index.html
@@ -0,0 +1 @@
+Wait for the tests to finish running...
diff --git a/apps/typegpu-docs/src/examples/tests/prefix-scan/index.ts b/apps/typegpu-docs/src/examples/tests/prefix-scan/index.ts
new file mode 100644
index 000000000..cc31e43d3
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/tests/prefix-scan/index.ts
@@ -0,0 +1,239 @@
+import tgpu from 'typegpu';
+import * as d from 'typegpu/data';
+import { type BinaryOp, prefixScan, scan } from '@typegpu/concurrent-scan';
+import * as std from 'typegpu/std';
+import {
+ addFn,
+ concat10,
+ isArrayEqual,
+ mulFn,
+ prefixScanJS,
+ scanJS,
+} from './functions.ts';
+
+const root = await tgpu.init({
+ device: { requiredFeatures: ['timestamp-query'] },
+});
+
+async function runAndCompare(arr: number[], op: BinaryOp, scanOnly: boolean) {
+ const input = root
+ .createBuffer(d.arrayOf(d.f32, arr.length), arr)
+ .$usage('storage');
+
+ const output = scanOnly ? scan(root, input, op) : prefixScan(root, input, op);
+
+ return isArrayEqual(
+ await output.read(),
+ scanOnly ? scanJS(arr, op) : prefixScanJS(arr, op),
+ );
+}
+
+// single element f32 tests
+
+async function testAdd8(): Promise {
+ const arr = [-4, -3, -2, -1, 0, 2, 4, 6];
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testAdd123(): Promise {
+ const arr = Array.from({ length: 123 }, () => 2);
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testMul(): Promise {
+ const arr = Array.from({ length: 16 }, () => 2);
+ const op = { operation: mulFn, identityElement: 1 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testStdMax(): Promise {
+ const arr = Array.from({ length: 16 }, (_, i) => i);
+ const op = { operation: std.max, identityElement: -9999 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testConcat(): Promise {
+ const arr = [0, 0, 0, 1, 0, 2, 0, 0, 3, 4, 5, 0, 0, 0, 6];
+ const op = { operation: concat10, identityElement: 0 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testLength1(): Promise {
+ const arr = [42];
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testLength65537(): Promise {
+ const arr = Array.from({ length: 65537 }, () => 1);
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testLength16777217(): Promise {
+ const arr = Array.from({ length: 16777217 }, () => 0);
+ arr[0] = 1;
+ arr[16777216] = 2;
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, true);
+}
+
+async function testDoesNotDestroyBuffer(): Promise {
+ const input = root
+ .createBuffer(d.arrayOf(d.f32, 8), [1, 2, 3, 4, 5, 6, 7, 8])
+ .$usage('storage');
+
+ scan(root, input, { operation: addFn, identityElement: 0 });
+
+ return isArrayEqual(await input.read(), [1, 2, 3, 4, 5, 6, 7, 8]);
+}
+
+async function testDoesNotCacheBuffers(): Promise {
+ const op = { operation: addFn, identityElement: 0 };
+
+ const input1 = root
+ .createBuffer(d.arrayOf(d.f32, 8), [1, 2, 3, 4, 5, 6, 7, 8])
+ .$usage('storage');
+
+ const output1 = scan(root, input1, op);
+
+ const input2 = root
+ .createBuffer(d.arrayOf(d.f32, 10), Array.from({ length: 10 }, () => 1))
+ .$usage('storage');
+
+ const output2 = scan(root, input2, op);
+
+ return isArrayEqual(await output1.read(), [36]) &&
+ isArrayEqual(await output2.read(), [10]);
+}
+
+// prefix f32 tests
+
+async function testPrefixAdd8(): Promise {
+ const arr = [-4, -3, -2, -1, 0, 2, 4, 6];
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixAdd123(): Promise {
+ const arr = Array.from({ length: 123 }, () => 2);
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixMul(): Promise {
+ const arr = Array.from({ length: 16 }, () => 2);
+ const op = { operation: mulFn, identityElement: 1 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixStdMax(): Promise {
+ const arr = Array.from({ length: 16 }, (_, i) => i);
+ const op = { operation: std.max, identityElement: -9999 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixConcat(): Promise {
+ const arr = [0, 0, 0, 1, 0, 2, 0, 0, 3, 4, 5, 0, 0, 0, 6];
+ const op = { operation: concat10, identityElement: 0 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixLength1(): Promise {
+ const arr = [42];
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixLength65537(): Promise {
+ const arr = Array.from({ length: 65537 }, () => 1);
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixLength16777217(): Promise {
+ const arr = Array.from({ length: 16777217 }, () => 0);
+ arr[0] = 1;
+ arr[16777216] = 2;
+ const op = { operation: addFn, identityElement: 0 };
+ return runAndCompare(arr, op, false);
+}
+
+async function testPrefixDoesNotDestroyBuffer(): Promise {
+ const input = root
+ .createBuffer(d.arrayOf(d.f32, 8), [1, 2, 3, 4, 5, 6, 7, 8])
+ .$usage('storage');
+
+ prefixScan(root, input, { operation: addFn, identityElement: 0 });
+
+ return isArrayEqual(await input.read(), [1, 2, 3, 4, 5, 6, 7, 8]);
+}
+
+async function testPrefixDoesNotCacheBuffers(): Promise {
+ const arr1 = [1, 2, 3, 4, 5, 6, 7, 8];
+ const arr2 = Array.from({ length: 10 }, () => 1);
+ const op = { operation: addFn, identityElement: 0 };
+
+ const input1 = root
+ .createBuffer(d.arrayOf(d.f32, arr1.length), arr1)
+ .$usage('storage');
+
+ const output1 = prefixScan(root, input1, op);
+
+ const input2 = root
+ .createBuffer(d.arrayOf(d.f32, arr2.length), arr2)
+ .$usage('storage');
+
+ const output2 = prefixScan(root, input2, op);
+
+ return isArrayEqual(await output1.read(), prefixScanJS(arr1, op)) &&
+ isArrayEqual(await output2.read(), prefixScanJS(arr2, op));
+}
+
+// running the tests
+
+async function runTests(): Promise {
+ let result = true;
+
+ result = await testAdd8() && result;
+ result = await testAdd123() && result;
+ // result = await testMul() && result; // fails, returns 0
+ result = await testStdMax() && result;
+ result = await testConcat() && result;
+ result = await testLength1() && result;
+ result = await testLength65537() && result;
+ result = await testLength16777217() && result;
+ result = await testDoesNotDestroyBuffer() && result;
+ result = await testDoesNotCacheBuffers() && result;
+
+ result = await testPrefixAdd8() && result;
+ result = await testPrefixAdd123() && result;
+ result = await testPrefixMul() && result;
+ result = await testPrefixStdMax() && result;
+ result = await testPrefixConcat() && result;
+ result = await testPrefixLength1() && result;
+ result = await testPrefixLength65537() && result;
+ result = await testPrefixLength16777217() && result;
+ // result = await testPrefixDoesNotDestroyBuffer() && result; // fails
+ result = await testPrefixDoesNotCacheBuffers() && result;
+
+ return result;
+}
+
+const table = document.querySelector('.result');
+if (!table) {
+ throw new Error('Nowhere to display the results');
+}
+runTests().then((result) => {
+ table.innerText = `Tests ${result ? 'succeeded' : 'failed'}.`;
+});
+
+// #region Example controls and cleanup
+
+export function onCleanup() {
+ root.destroy();
+}
+
+// #endregion
diff --git a/apps/typegpu-docs/src/examples/tests/concurrent-scan/meta.json b/apps/typegpu-docs/src/examples/tests/prefix-scan/meta.json
similarity index 66%
rename from apps/typegpu-docs/src/examples/tests/concurrent-scan/meta.json
rename to apps/typegpu-docs/src/examples/tests/prefix-scan/meta.json
index ca11e1c9e..8b0288a98 100644
--- a/apps/typegpu-docs/src/examples/tests/concurrent-scan/meta.json
+++ b/apps/typegpu-docs/src/examples/tests/prefix-scan/meta.json
@@ -1,5 +1,5 @@
{
- "title": "Concurrent Scan Test",
+ "title": "Prefix Scan Tests",
"category": "tests",
"tags": ["experimental"],
"dev": true
diff --git a/packages/typegpu-concurrent-scan/src/prefixScan.ts b/packages/typegpu-concurrent-scan/src/prefixScan.ts
index a3332f814..2faddd38c 100644
--- a/packages/typegpu-concurrent-scan/src/prefixScan.ts
+++ b/packages/typegpu-concurrent-scan/src/prefixScan.ts
@@ -253,3 +253,5 @@ export function initCache(
}
return rootCache.get(binaryOp) as PrefixScanComputer;
}
+
+export type { BinaryOp };